unip-controller/controller/src/apicmp/handlers.py
2025-04-15 20:56:15 +03:00

1051 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# Система: Единая библиотека, Центр ИИ НИУ ВШЭ
# Модуль: APIComponent
# Авторы: Полежаев В.А., Хританков А.С.
# Дата создания: 2025 г.
# ============================================================
import base64
import posixpath
import secrets
import string
import urllib.parse
import kopf
import yaml
from kubernetes.client import ApiClient
from apicmp.config import UNIP_API_CMP_CORS_ENABLED, UNIP_API_CMP_APPS_CORS_ENABLED, UNIP_API_CMP_CORS_ALLOW_METHODS, \
UNIP_API_CMP_CORS_ALLOW_HEADERS, UNIP_API_CMP_CORS_EXPOSE_HEADERS, UNIP_API_CMP_CORS_ALLOW_ORIGIN, \
UNIP_API_CMP_CORS_MAX_AGE
from apicmp.jinja import basic_jinja_env, api_cmp_jinja_env
from auth import create_htpasswd_password
from basic_resources.deployments import delete_deployment_if_exists, create_or_update_deployment
from basic_resources.ingresses import delete_ingress_if_exists, create_or_update_ingress
from basic_resources.network_policies import delete_network_policy_if_exists, create_or_update_network_policy
from basic_resources.secrets import prepare_secret_manifest, delete_secret_if_exists, \
create_or_update_secret
from basic_resources.services import delete_svc_if_exists, create_or_update_service
from bound import not_dev_namespace
from cmplink.basic_auth_link import get_basic_auth_joint_secret_name, create_or_update_basic_auth_joint_secret
from cmplink.keycloak_group_link import get_oauth2_proxy_deployment_name, create_or_update_groups_joint_secret, \
get_groups_joint_secret_name
from config import UNIP_DOMAIN, PIPELINES_SERVICE_PORT, PIPELINES_EXTERNAL_SERVICE_NAME, OIDC_END_USERS_CLIENT_SECRET, \
OIDC_END_USERS_ISSUER_URL, OIDC_END_USERS_CLIENT_ID, OIDC_END_USERS_COOKIE_SECRET, OIDC_ROBOTS_AUD, \
OIDC_ROBOTS_ISSUER_URL, OIDC_END_USERS_AUD
from exceptions import InputValidationPermanentError
from exp_pipeline.client_config import INTERNAL_API_USER_ID
from exp_pipeline.pipeline import INTERNAL_API_SECRET_SUFFIX
from kopf_k8s_client import api_client
from parse import get_user_namespace
ML_CMP_SERVICE_PORT = 80
FILES_SERVICE_PORT = 80
OAUTH2_PROXY_SERVICE_PORT = 4180
OAUTH2_PROXY_CONTAINER_PORT = 4180
INTERNAL_API_BA_SECRET_SUFFIX = '-internal-ba-cred'
def join_url_segments(parts):
return "/".join(map(lambda x: str(x).strip("/"), parts))
def _prepare_manifest(template_name, variables):
template = basic_jinja_env.get_template(template_name)
text = template.render(variables)
data = yaml.safe_load(text)
return data
def _prepare_manifest_api_cmp_env(template_name, variables):
template = api_cmp_jinja_env.get_template(template_name)
text = template.render(variables)
data = yaml.safe_load(text)
return data
def _get_ingress_name(name):
return f'{name}-ingress'
def _get_oauth2_proxy_ingress_name(name):
return f'{name}-oauth2-ingress'
def _get_internal_ingress_name(name):
return f'{name}-internal-ingress'
def _get_mlcmp_paths(namespace, restful_api_section):
if 'path' not in restful_api_section:
raise InputValidationPermanentError('MLComponent requires path in restfulApi section')
path = restful_api_section['path']
prefix = '/' + namespace + '/' + path
prefix = posixpath.normpath(prefix)
quoted_prefix = urllib.parse.quote(prefix)
return [
f'{quoted_prefix}/(predict/?)',
f'{quoted_prefix}/(modelversion/.*/license/?)',
f'{quoted_prefix}/(modelversion/?)'
]
def _get_files_paths(namespace):
return ['/' + namespace + '/files']
def _get_pipelines_general_paths(namespace):
pipelines = '/' + namespace + '/pipelines'
trials = '/' + namespace + '/trials'
name = '[0-9A-Za-z_-]+'
return [
f'{trials}/{name}',
f'{trials}',
f'{pipelines}'
]
def _get_pipeline_internal_paths(namespace, pipeline_name):
pipelines = '/' + namespace + '/pipelines'
name = '[0-9A-Za-z_-]+'
return [
f'{pipelines}/{pipeline_name}/check',
f'{pipelines}/{pipeline_name}/trials/continue',
f'{pipelines}/{pipeline_name}/trials/{name}/status/conditions'
]
def _get_pipeline_paths(namespace, pipeline_name):
pipelines = '/' + namespace + '/pipelines'
return [
f'{pipelines}/{pipeline_name}/version',
f'{pipelines}/{pipeline_name}/trials',
f'{pipelines}/{pipeline_name}'
]
def _get_oauth2_proxy_path_prefix(namespace, upstream_name):
return '/' + namespace + '/proxies/' + upstream_name
def _get_oauth2_proxy_path(namespace, upstream_name):
return _get_oauth2_proxy_path_prefix(namespace, upstream_name) + '(/|$)(.*)'
def _get_mlcmp_rewrite_target():
return '/$1'
def _get_mlcmp_service_name(mlcmp_name):
return f'{mlcmp_name}-svc'
def _get_files_service_name():
return 'files-svc'
def _get_pipelines_general_service_name():
return 'pipelines-svc'
def _get_pipeline_internal_service_name(name):
return f'{name}-internal-svc'
def _get_pipeline_service_name(name):
return f'{name}-svc'
def _get_files_external_service_name():
# todo: использовать константу аналогично PIPELINES_EXTERNAL_SERVICE_NAME
return 'files-svc.unip-system-controller'
def _get_oauth2_proxy_service_name(name):
return f'{name}-oauth2-svc'
def _get_oauth2_proxy_selector_label(name):
return f'{name}-oauth2-proxy'
def _get_oauth2_proxy_np_name(name):
return f'ingress-{name}-oauth2-traffic'
def _append_oidc_to_ingress_model(name, namespace, ingress_model: dict, oidc: dict | None):
if oidc is None:
return ingress_model
if not oidc.get('enabled', True):
return ingress_model
base_url = 'https://$host/'
proxy_path_prefix = _get_oauth2_proxy_path_prefix(namespace, name)
auth_url_parts = [proxy_path_prefix, 'oauth2/auth']
auth_url = join_url_segments(auth_url_parts)
auth_url = urllib.parse.urljoin(base_url, auth_url)
auth_signin_parts = [proxy_path_prefix, 'oauth2/start?rd=$escaped_request_uri']
auth_signin = join_url_segments(auth_signin_parts)
auth_signin = urllib.parse.urljoin(base_url, auth_signin)
ingress_model['oidc'] = {
'auth_url': auth_url,
'auth_signin': auth_signin
}
def _append_basic_to_ingress_model(name, ingress_model: dict, basic: dict | None):
if basic is None:
return ingress_model
if not basic.get('enabled', True):
return ingress_model
ingress_model['basic'] = {
'secret_name': get_basic_auth_joint_secret_name(name)
}
def _parse_cors_config_param(cors_enabled, app_cors_enabled, cors, config_value, spec_param_name, default_value,
preprocess=None):
def process_param_values(values_):
vals = map(lambda m: m.strip(), values_)
if preprocess:
vals = map(preprocess, vals)
res = set(vals)
return res
result = set()
if cors_enabled:
if config_value:
result = process_param_values(config_value.split(','))
else:
if app_cors_enabled:
if not cors or spec_param_name in cors:
result = default_value
if app_cors_enabled:
if cors and spec_param_name in cors:
to_join = process_param_values(cors[spec_param_name])
result = result | to_join
elif config_value:
to_join = process_param_values(config_value.split(','))
result = result | to_join
result = ','.join(result)
return result
def _parse_cors_max_age_param(cors_enabled, app_cors_enabled, cors):
result = None
if cors_enabled:
if UNIP_API_CMP_CORS_MAX_AGE:
result = UNIP_API_CMP_CORS_MAX_AGE
if app_cors_enabled:
if cors and 'maxAge' in cors:
result = cors['maxAge']
elif UNIP_API_CMP_CORS_MAX_AGE:
result = UNIP_API_CMP_CORS_MAX_AGE
return result
def _append_cors_to_ingress_model(ingress_model, cors: dict | None):
cors_enabled = UNIP_API_CMP_CORS_ENABLED
app_cors_enabled = UNIP_API_CMP_APPS_CORS_ENABLED
if cors:
app_cors_enabled = cors.get('enabled', app_cors_enabled)
if not cors_enabled and not app_cors_enabled:
return
allow_methods_default = {'GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'OPTIONS'}
allow_methods = _parse_cors_config_param(cors_enabled, app_cors_enabled, cors,
UNIP_API_CMP_CORS_ALLOW_METHODS, 'allowMethods',
allow_methods_default)
allow_headers_default = {'DNT', 'Keep-Alive', 'User-Agent', 'X-Requested-With', 'If-Modified-Since',
'Cache-Control', 'Content-Type', 'Range', 'Authorization'}
allow_headers = _parse_cors_config_param(cors_enabled, app_cors_enabled, cors,
UNIP_API_CMP_CORS_ALLOW_HEADERS, 'allowHeaders',
allow_headers_default)
expose_headers = _parse_cors_config_param(cors_enabled, app_cors_enabled, cors,
UNIP_API_CMP_CORS_EXPOSE_HEADERS, 'exposeHeaders',
set())
allow_origin = _parse_cors_config_param(cors_enabled, app_cors_enabled, cors,
UNIP_API_CMP_CORS_ALLOW_ORIGIN, 'allowOrigin',
set(), preprocess=lambda m: m.rstrip('/'))
max_age = _parse_cors_max_age_param(cors_enabled, app_cors_enabled, cors)
cors_model = {}
if allow_methods:
cors_model['allow_methods'] = allow_methods
if allow_headers:
cors_model['allow_headers'] = allow_headers
if expose_headers:
cors_model['expose_headers'] = expose_headers
if allow_origin:
cors_model['allow_origin'] = allow_origin
if max_age:
cors_model['max_age'] = max_age
ingress_model['cors'] = cors_model
def _construct_files_ingress_model(name: str, namespace: str, spec: dict,
basic: dict | None, oidc: dict | None,
cors: dict | None):
restful_api = spec['restfulApi']
auth = restful_api['auth']
ingress_model = {
'name': _get_ingress_name(name),
'namespace': namespace,
'auth_realm': name,
'paths': _get_files_paths(namespace),
'service_name': _get_files_service_name(),
'service_port': FILES_SERVICE_PORT,
'identity_pass_through': auth['identityPassThrough'],
'domain': UNIP_DOMAIN
}
_append_oidc_to_ingress_model(name, namespace, ingress_model, oidc)
_append_basic_to_ingress_model(name, ingress_model, basic)
_append_cors_to_ingress_model(ingress_model, cors)
return ingress_model
def _construct_pipelines_general_ingress_model(name: str, namespace: str, spec: dict,
basic: dict | None, oidc: dict | None,
cors: dict | None):
restful_api = spec['restfulApi']
auth = restful_api['auth']
ingress_model = {
'name': _get_ingress_name(name),
'namespace': namespace,
'auth_realm': name,
'paths': _get_pipelines_general_paths(namespace),
'service_name': _get_pipelines_general_service_name(),
'service_port': PIPELINES_SERVICE_PORT,
'identity_pass_through': auth['identityPassThrough'],
'domain': UNIP_DOMAIN
}
_append_oidc_to_ingress_model(name, namespace, ingress_model, oidc)
_append_basic_to_ingress_model(name, ingress_model, basic)
_append_cors_to_ingress_model(ingress_model, cors)
return ingress_model
def _construct_pipeline_internal_ingress_model(name: str, namespace: str, spec: dict):
pipeline = spec['experimentPipeline']
pipeline_name = pipeline['name']
ingress_model = {
'name': _get_internal_ingress_name(name),
'namespace': namespace,
'secret_name': _get_internal_api_ba_secret_name(name),
'auth_realm': name,
'paths': _get_pipeline_internal_paths(namespace, pipeline_name),
'service_name': _get_pipeline_internal_service_name(name),
'service_port': PIPELINES_SERVICE_PORT,
'identity_pass_through': True,
'domain': UNIP_DOMAIN
}
return ingress_model
def _construct_pipeline_ingress_model(name: str, namespace: str, spec: dict,
basic: dict | None, oidc: dict | None,
cors: dict | None):
pipeline = spec['experimentPipeline']
pipeline_name = pipeline['name']
ingress_model = {
'name': _get_ingress_name(name),
'namespace': namespace,
'auth_realm': name,
'paths': _get_pipeline_paths(namespace, pipeline_name),
'service_name': _get_pipeline_service_name(name),
'service_port': PIPELINES_SERVICE_PORT,
'identity_pass_through': True,
'domain': UNIP_DOMAIN
}
_append_oidc_to_ingress_model(name, namespace, ingress_model, oidc)
_append_basic_to_ingress_model(name, ingress_model, basic)
_append_cors_to_ingress_model(ingress_model, cors)
return ingress_model
def _construct_mlcmp_ingress_model(name: str, namespace: str, spec: dict,
basic: dict | None, oidc: dict | None,
cors: dict | None):
restful_api = spec['restfulApi']
auth = restful_api['auth']
ml_cmp = spec['mlComponent']
ingress_model = {
'name': _get_ingress_name(name),
'namespace': namespace,
'auth_realm': name,
'rewrite_target': _get_mlcmp_rewrite_target(),
'paths': _get_mlcmp_paths(namespace, restful_api),
'service_name': _get_mlcmp_service_name(ml_cmp['name']),
'service_port': ML_CMP_SERVICE_PORT,
'identity_pass_through': auth['identityPassThrough'],
'domain': UNIP_DOMAIN
}
_append_oidc_to_ingress_model(name, namespace, ingress_model, oidc)
_append_basic_to_ingress_model(name, ingress_model, basic)
_append_cors_to_ingress_model(ingress_model, cors)
return ingress_model
def _construct_oauth2_proxy_ingress_model(name: str, namespace: str, cors: dict | None):
ingress_model = {
'name': _get_oauth2_proxy_ingress_name(name),
'namespace': namespace,
'domain': UNIP_DOMAIN,
'path': _get_oauth2_proxy_path(namespace, name),
'service_name': _get_oauth2_proxy_service_name(name),
'service_port': OAUTH2_PROXY_SERVICE_PORT
}
_append_cors_to_ingress_model(ingress_model, cors)
return ingress_model
def _construct_oauth2_proxy_deployment_model(name: str, namespace: str, oidc: dict):
base_url = 'https://' + UNIP_DOMAIN
proxy_path_prefix = _get_oauth2_proxy_path_prefix(namespace, name)
redirect_url_parts = [proxy_path_prefix, 'oauth2/callback']
redirect_url = join_url_segments(redirect_url_parts)
redirect_url = urllib.parse.urljoin(base_url, redirect_url)
proxy_prefix_parts = [proxy_path_prefix, 'oauth2']
proxy_prefix = '/' + join_url_segments(proxy_prefix_parts)
oidc_extra_audience = f'{OIDC_END_USERS_AUD},{OIDC_ROBOTS_AUD}' \
if OIDC_END_USERS_AUD != OIDC_ROBOTS_AUD \
else OIDC_END_USERS_AUD
extra_jwt_issuers = f'{OIDC_END_USERS_ISSUER_URL}={OIDC_END_USERS_AUD},{OIDC_ROBOTS_ISSUER_URL}={OIDC_ROBOTS_AUD}' \
if OIDC_END_USERS_AUD != OIDC_ROBOTS_AUD \
else f'{OIDC_END_USERS_ISSUER_URL}={OIDC_END_USERS_AUD}'
deployment_model = {
'deployment_name': get_oauth2_proxy_deployment_name(name),
'selector_label': _get_oauth2_proxy_selector_label(name),
'namespace': namespace,
'client_secret': OIDC_END_USERS_CLIENT_SECRET,
'client_id': OIDC_END_USERS_CLIENT_ID,
'cookie_secret': OIDC_END_USERS_COOKIE_SECRET,
'redirect_url': redirect_url,
'oidc_issuer_url': OIDC_END_USERS_ISSUER_URL,
'oidc_extra_audience': oidc_extra_audience,
'extra_jwt_issuers': extra_jwt_issuers,
'proxy_prefix': proxy_prefix,
'container_port': OAUTH2_PROXY_CONTAINER_PORT,
'groups_secret_name': get_groups_joint_secret_name(name)
}
if 'roles' in oidc:
deployment_model['roles'] = ','.join(oidc['roles'])
return deployment_model
def _construct_oauth2_proxy_service_model(name: str, namespace: str):
service_model = {
'selector_label': _get_oauth2_proxy_selector_label(name),
'namespace': namespace,
'service_name': _get_oauth2_proxy_service_name(name),
'service_port': OAUTH2_PROXY_SERVICE_PORT,
'container_port': OAUTH2_PROXY_CONTAINER_PORT
}
return service_model
def _construct_oauth2_proxy_np(name: str, namespace: str):
np_model = {
'np_name': _get_oauth2_proxy_np_name(name),
'selector_label': _get_oauth2_proxy_selector_label(name),
'namespace': namespace
}
return np_model
def _create_or_update_ingress(api_client_: ApiClient, name, namespace, ingress_model, logger):
ingress_manifest = _prepare_manifest('ingress-multi-auth.yaml', ingress_model)
create_or_update_ingress(api_client_, name, namespace, ingress_manifest, logger)
def _create_or_update_oauth2_proxy_ingress(api_client_: ApiClient, name, namespace, ingress_model, logger):
ingress_manifest = _prepare_manifest_api_cmp_env('oauth2-proxy-ingress.yaml', ingress_model)
create_or_update_ingress(api_client_, name, namespace, ingress_manifest, logger)
def _create_or_update_external_name_files_service(api_client_: ApiClient, namespace, logger):
svc_name = _get_files_service_name()
svc_model = {
'service_name': _get_files_service_name(),
'namespace': namespace,
'external_service_name': _get_files_external_service_name()
}
svc_manifest = _prepare_manifest('external-name-svc.yaml', svc_model)
create_or_update_service(api_client_, svc_name, namespace, svc_manifest, logger)
def _create_or_update_external_name_pipelines_service(api_client_: ApiClient, namespace, svc_name, logger):
svc_model = {
'service_name': svc_name,
'namespace': namespace,
'external_service_name': PIPELINES_EXTERNAL_SERVICE_NAME
}
svc_manifest = _prepare_manifest('external-name-svc.yaml', svc_model)
create_or_update_service(api_client_, svc_name, namespace, svc_manifest, logger)
def _create_or_update_external_name_pipelines_general_service(api_client_: ApiClient, namespace, logger):
svc_name = _get_pipelines_general_service_name()
_create_or_update_external_name_pipelines_service(api_client_, namespace, svc_name, logger)
def _create_or_update_external_name_pipeline_internal_service(api_client_: ApiClient, name, namespace, logger):
svc_name = _get_pipeline_internal_service_name(name)
_create_or_update_external_name_pipelines_service(api_client_, namespace, svc_name, logger)
def _create_or_update_external_name_pipeline_service(api_client_: ApiClient, name, namespace, logger):
svc_name = _get_pipeline_service_name(name)
_create_or_update_external_name_pipelines_service(api_client_, namespace, svc_name, logger)
def _create_or_update_oauth2_proxy_deployment(api_client_: ApiClient, name, namespace, deployment_model, logger):
deployment_manifest = _prepare_manifest_api_cmp_env('oauth2-proxy-deployment.yaml', deployment_model)
create_or_update_deployment(api_client_, name, namespace, deployment_manifest, logger)
def _create_or_update_oauth2_proxy_service(api_client_: ApiClient, name, namespace, service_model, logger):
svc_manifest = _prepare_manifest_api_cmp_env('oauth2-proxy-svc.yaml', service_model)
create_or_update_service(api_client_, name, namespace, svc_manifest, logger)
def _create_or_update_oauth2_proxy_np(api_client_: ApiClient, name, namespace, np_model, logger):
np_manifest = _prepare_manifest('allow-ingress-traffic-np.yaml', np_model)
create_or_update_network_policy(api_client_, name, namespace, np_manifest, logger)
_passwords_alphabet = string.ascii_letters + string.digits + string.punctuation
def _create_password():
return ''.join(secrets.choice(_passwords_alphabet) for _ in range(10))
def _get_internal_api_secret_name(name):
return name + INTERNAL_API_SECRET_SUFFIX
def _get_internal_api_ba_secret_name(name):
return name + INTERNAL_API_BA_SECRET_SUFFIX
def _create_pipeline_internal_api_secrets(api_client_: ApiClient, name, namespace, logger):
password = _create_password()
record = f'{INTERNAL_API_USER_ID}:{password}'
encoded_value = base64.b64encode(record.encode('utf-8')).decode('ascii')
data = [{'key': 'credentials', 'value': encoded_value}]
secret_name = _get_internal_api_secret_name(name)
manifest = prepare_secret_manifest(name=secret_name,
namespace=namespace,
type_='Opaque',
data=data,
data_attr='data')
create_or_update_secret(api_client=api_client_,
name=secret_name,
namespace=namespace,
manifest=manifest,
logger=logger)
encrypted_password = create_htpasswd_password(password)
htpasswd_record = f'{INTERNAL_API_USER_ID}:{encrypted_password}'
encoded_value = base64.b64encode(htpasswd_record.encode('utf-8')).decode('ascii')
basic_auth_data = [
{'key': 'auth', 'value': encoded_value}
]
secret_name = _get_internal_api_ba_secret_name(name)
manifest = prepare_secret_manifest(name=secret_name,
namespace=namespace,
type_='Opaque',
data=basic_auth_data,
data_attr='data')
create_or_update_secret(api_client=api_client_,
name=secret_name,
namespace=namespace,
manifest=manifest,
logger=logger)
def _delete_ingress_if_exists(api_client_: ApiClient, name: str, namespace: str,
logger):
ingress_name = _get_ingress_name(name)
delete_ingress_if_exists(api_client=api_client_, name=ingress_name, namespace=namespace, logger=logger)
def _delete_oauth2_proxy_ingress_if_exists(api_client_: ApiClient, name: str, namespace: str,
logger):
ingress_name = _get_oauth2_proxy_ingress_name(name)
delete_ingress_if_exists(api_client=api_client_, name=ingress_name, namespace=namespace, logger=logger)
def _delete_internal_ingress_if_exists(api_client_: ApiClient, name: str, namespace: str,
logger):
ingress_name = _get_internal_ingress_name(name)
delete_ingress_if_exists(api_client=api_client_, name=ingress_name, namespace=namespace, logger=logger)
def _delete_files_svc_if_exists(api_client_: ApiClient, namespace: str, logger):
svc_name = _get_files_service_name()
delete_svc_if_exists(api_client_, namespace, svc_name, logger)
def _delete_pipelines_general_svc_if_exists(api_client_: ApiClient, namespace: str, logger):
svc_name = _get_pipelines_general_service_name()
delete_svc_if_exists(api_client_, namespace, svc_name, logger)
def _delete_pipeline_svc_if_exists(api_client_: ApiClient, name: str, namespace: str, logger):
svc_name = _get_pipeline_service_name(name)
delete_svc_if_exists(api_client_, namespace, svc_name, logger)
def _delete_pipeline_internal_svc_if_exists(api_client_: ApiClient, name: str, namespace: str, logger):
svc_name = _get_pipeline_internal_service_name(name)
delete_svc_if_exists(api_client_, namespace, svc_name, logger)
def _delete_pipeline_internal_api_secrets_if_exist(api_client_: ApiClient, name: str, namespace: str, logger):
secret_name = _get_internal_api_secret_name(name)
delete_secret_if_exists(api_client=api_client_, name=secret_name, namespace=namespace, logger=logger)
secret_name = _get_internal_api_ba_secret_name(name)
delete_secret_if_exists(api_client=api_client_, name=secret_name, namespace=namespace, logger=logger)
def _delete_oauth2_proxy_deployment_if_exists(api_client_: ApiClient, name: str, namespace: str, logger):
deployment_name = get_oauth2_proxy_deployment_name(name)
delete_deployment_if_exists(api_client_, namespace, deployment_name, logger)
def _delete_oauth2_proxy_service_if_exists(api_client_: ApiClient, name: str, namespace: str, logger):
service_name = _get_oauth2_proxy_service_name(name)
delete_svc_if_exists(api_client_, namespace, service_name, logger)
def _delete_oauth2_proxy_np_if_exists(api_client_: ApiClient, name: str, namespace: str, logger):
np_name = _get_oauth2_proxy_np_name(name)
delete_network_policy_if_exists(api_client_, np_name, namespace, logger)
def _get_oidc_from_spec(spec):
restful_api = spec['restfulApi']
auth = restful_api['auth']
return auth.get('oidc')
def _get_basic_from_spec(spec):
restful_api = spec['restfulApi']
auth = restful_api['auth']
return auth.get('basic')
def _get_cors_from_spec(spec):
restful_api = spec['restfulApi']
return restful_api.get('cors')
def _should_api_be_published(spec):
published_flag = spec['published']
if not published_flag:
return False
basic = _get_basic_from_spec(spec)
basic_enabled = basic.get('enabled', True) if basic is not None else False
oidc = _get_oidc_from_spec(spec)
oidc_enabled = oidc.get('enabled', True) if oidc is not None else False
if basic_enabled or oidc_enabled:
return True
# пока считаем, что если любая аутентификация выключена, то доступ закрыт
return False
@kopf.on.create('unified-platform.cs.hse.ru', 'apicomponents', retries=18, when=not_dev_namespace)
def create_api_component(name, namespace, spec, patch, logger, **_):
"""
Обработчик создания ресурса APIComponent.
Создает ресурсы Service, Ingress для предоставления доступа к сервисам фреймворка и сервисам приложения.
При спецификации OIDC аутентификации настраивает подчиненные ресурсы Deployment, Service, Ingress
для OAuth2 Proxy.
Обновляет объединенный секрет с реквизитами Basic аутентификации.
:param spec: спецификация ресурса APIComponent
:param name: имя ресурса APIComponent
:param namespace: пространство имен ресурса APIComponent
:param patch: объект patch фреймворка kopf
:param logger: kopf logger
:param _: остальные параметры kopf, которые не используются обработчиком
:return: None
"""
published = _should_api_be_published(spec)
oidc = _get_oidc_from_spec(spec)
basic = _get_basic_from_spec(spec)
cors = _get_cors_from_spec(spec)
if 'status' not in patch:
patch['status'] = {}
user_namespace = get_user_namespace(namespace)
ingress_name = _get_ingress_name(name)
_update_oauth2_proxy(name, namespace, spec, oidc, cors, logger)
@kopf.subhandler(id=f'create-or-update-ba-joint-secret-for-{namespace}.{name}', retries=3)
def _create_or_update_ba_joint_secret_handler(**_):
create_or_update_basic_auth_joint_secret(api_client=api_client,
target=name,
target_namespace=namespace,
user_namespace=user_namespace,
logger=logger)
if 'mlComponent' in spec and published:
@kopf.subhandler(id=f'create-ingress-{name}', retries=3)
def _create_mlcmp_ingress_handler(**_):
ingress_model = _construct_mlcmp_ingress_model(name, namespace, spec, basic, oidc, cors)
_create_or_update_ingress(api_client, ingress_name, namespace, ingress_model, logger)
elif 'files' in spec:
files = spec['files']
enabled = files['enabled']
if enabled:
@kopf.subhandler(id=f'create-files-svc-{name}', retries=3)
def _create_files_svc_handler(**_):
_create_or_update_external_name_files_service(api_client, namespace, logger)
if published:
@kopf.subhandler(id=f'create-ingress-{name}', retries=3)
def _create_files_ingress_handler(**_):
ingress_model = _construct_files_ingress_model(name, namespace, spec, basic, oidc, cors)
_create_or_update_ingress(api_client, ingress_name, namespace, ingress_model, logger)
elif 'pipelines' in spec:
pipelines = spec['pipelines']
enabled = pipelines['enabled']
if enabled:
@kopf.subhandler(id=f'create-pipelines-svc-{name}', retries=3)
def _create_pipelines_svc_handler(**_):
_create_or_update_external_name_pipelines_general_service(api_client, namespace, logger)
if published:
@kopf.subhandler(id=f'create-ingress-{name}', retries=3)
def _create_pipelines_general_ingress_handler(**_):
ingress_model = _construct_pipelines_general_ingress_model(name, namespace, spec, basic, oidc, cors)
_create_or_update_ingress(api_client, ingress_name, namespace, ingress_model, logger)
elif 'experimentPipeline' in spec and published:
@kopf.subhandler(id=f'create-ingress-{name}', retries=3)
def _create_pipeline_ingress_handler(**_):
ingress_model = _construct_pipeline_ingress_model(name, namespace, spec, basic, oidc, cors)
_create_or_update_ingress(api_client, ingress_name, namespace, ingress_model, logger)
@kopf.subhandler(id=f'create-pipeline-svc-{name}', retries=3)
def _create_pipeline_svc_handler(**_):
_create_or_update_external_name_pipeline_service(api_client, name, namespace, logger)
@kopf.subhandler(id=f'create-ingress-internal-{name}', retries=3)
def _create_pipeline_internal_ingress_handler(**_):
ingress_model = _construct_pipeline_internal_ingress_model(name, namespace, spec)
_create_or_update_ingress(api_client, ingress_name, namespace, ingress_model, logger)
@kopf.subhandler(id=f'create-pipeline-internal-svc-{name}', retries=3)
def _create_pipeline_internal_svc_handler(**_):
_create_or_update_external_name_pipeline_internal_service(api_client, name, namespace, logger)
@kopf.subhandler(id=f'create-pipeline-internal-secrets-{name}', retries=3)
def _create_pipeline_internal_secrets_handler(**_):
patch['status'].setdefault('experimentPipeline', {})
patch['status']['experimentPipeline']['internalApiSecretName'] = _get_internal_api_secret_name(name)
_create_pipeline_internal_api_secrets(api_client, name, namespace, logger)
else:
if not published:
logger.info('Published if False, ingress is not created')
else:
raise InputValidationPermanentError('Only files, pipelines, experimentPipeline or mlComponent '
'are supported now, but not specified')
@kopf.on.delete('unified-platform.cs.hse.ru', 'apicomponents', retries=3,
when=not_dev_namespace)
def delete_api_component(spec, name, namespace, logger, **_):
"""
Обработчик удаления ресурса APIComponent.
Удаляет ресурсы Service, Ingress, которые использовались для предоставления доступа
к сервисам фреймворка и сервисам приложения.
Если была специфицирована OIDC аутентификация, удаляет подчиненные ресурсы
Deployment, Service, Ingress OAuth2 Proxy.
Обновляет объединенный секрет с реквизитами Basic аутентификации.
:param spec: спецификация ресурса APIComponent
:param name: имя ресурса APIComponent
:param namespace: пространство имен ресурса APIComponent
:param patch: объект patch фреймворка kopf
:param logger: kopf logger
:param _: остальные параметры kopf, которые не используются обработчиком
:return: None
"""
_delete_ingress_if_exists(api_client, name, namespace, logger)
user_namespace = get_user_namespace(namespace)
_delete_oauth2_proxy(name, namespace, logger)
if 'files' in spec:
_delete_files_svc_if_exists(api_client, namespace, logger)
if 'pipelines' in spec:
_delete_pipelines_general_svc_if_exists(api_client, namespace, logger)
if 'experimentPipeline' in spec:
_delete_pipeline_svc_if_exists(api_client, name, namespace, logger)
# дополнительно удаляется internal ingress для ExperimentPipeline,
# потому что у APIComponent для ExperimentPipeline есть два ингресса ("обычный" и internal)
# internal_ingress, internal_svc - специальный случай;
_delete_internal_ingress_if_exists(api_client, name, namespace, logger)
_delete_pipeline_internal_svc_if_exists(api_client, name, namespace, logger)
_delete_pipeline_internal_api_secrets_if_exist(api_client, name, namespace, logger)
if 'mlComponent':
# удалять mlcmp svc не нужно, потому что он управляется контроллером MLComponent,
# это может измениться в будущем
pass
# обновить общий basic auth секрет
create_or_update_basic_auth_joint_secret(api_client=api_client,
target=name,
target_namespace=namespace,
user_namespace=user_namespace,
logger=logger,
exclude_direct_spec=True)
@kopf.on.update('unified-platform.cs.hse.ru', 'apicomponents',
retries=24, when=not_dev_namespace, field='spec')
def update_api_component(name, namespace, old, new, patch, logger, **_):
"""
Обработчик изменения ресурса APIComponent.
Обновляет ресурсы Service, Ingress для предоставления доступа к сервисам фреймворка и сервисам приложения.
При спецификации OIDC аутентификации настраивает подчиненные ресурсы Deployment, Service, Ingress
для OAuth2 Proxy.
Обновляет объединенный секрет с реквизитами Basic аутентификации.
С небольшими доработками update_api_component и create_api_component
могут быть объединены. С точки зрения level-based triggering не так важно,
какое именно событие инициирует изменение (создание или изменение).
Важно только перевести текущее состояние в целевое, событие -
это просто дополнительная информация.
:param old: предыдущая версия спецификации ресурса APIComponent
:param new: новая версия спецификации ресурса APIComponent
:param name: имя ресурса APIComponent
:param namespace: пространство имен ресурса APIComponent
:param patch: объект patch фреймворка kopf
:param logger: kopf logger
:param _: остальные параметры kopf, которые не используются обработчиком
:return: None
"""
old_spec = old if old else {}
spec = new
oidc = _get_oidc_from_spec(spec)
basic = _get_basic_from_spec(spec)
cors = _get_cors_from_spec(spec)
published = _should_api_be_published(spec)
if 'status' not in patch:
patch['status'] = {}
ingress_name = _get_ingress_name(name)
_update_oauth2_proxy(name, namespace, spec, oidc, cors, logger)
@kopf.subhandler(id=f'update-ingress-{name}', retries=3)
def _update_ingress_handler(**_):
# здесь и в других местах можно в целом более гранулярно управлять распространением изменений -
# не удалять и создавать целые подчиненные ресурсы;
_delete_ingress_if_exists(api_client, name, namespace, logger)
ingress_model = None
if 'mlComponent' in spec and published:
ingress_model = _construct_mlcmp_ingress_model(name, namespace, spec, basic, oidc, cors)
elif 'files' in spec and published:
files = spec['files']
enabled = files['enabled']
if enabled:
ingress_model = _construct_files_ingress_model(name, namespace, spec, basic, oidc, cors)
elif 'pipelines' in spec and published:
pipelines = spec['pipelines']
enabled = pipelines['enabled']
if enabled:
ingress_model = _construct_pipelines_general_ingress_model(name, namespace, spec, basic, oidc, cors)
elif 'experimentPipeline' in spec and published:
ingress_model = _construct_pipeline_ingress_model(name, namespace, spec, basic, oidc, cors)
else:
if not published:
logger.info('Published if False, ingress is not created')
else:
raise InputValidationPermanentError(
'Only files, pipelines, experimentPipeline or mlComponent are supported '
'now, but not specified')
if ingress_model:
_create_or_update_ingress(api_client, ingress_name, namespace, ingress_model, logger)
@kopf.subhandler(id=f'update-files-svc-{name}', retries=3)
def _update_files_svc_handler(**_):
if 'files' in old_spec:
_delete_files_svc_if_exists(api_client, namespace, logger)
if 'files' in spec and published:
files = spec['files']
enabled = files['enabled']
if enabled:
_create_or_update_external_name_files_service(api_client, namespace, logger)
@kopf.subhandler(id=f'update-pipeline-svc-{name}', retries=3)
def _update_pipeline_svc_handler(**_):
if 'experimentPipeline' in old_spec:
_delete_pipeline_svc_if_exists(api_client, name, namespace, logger)
if 'experimentPipeline' in spec and published:
_create_or_update_external_name_pipeline_service(api_client, name, namespace, logger)
@kopf.subhandler(id=f'update-pipelines-general-svc-{name}', retries=3)
def _update_pipelines_general_svc_handler(**_):
if 'pipelines' in old_spec:
_delete_pipelines_general_svc_if_exists(api_client, namespace, logger)
if 'pipelines' in spec and published:
pipelines = spec['pipelines']
enabled = pipelines['enabled']
if enabled:
_create_or_update_external_name_pipelines_general_service(api_client, namespace, logger)
@kopf.subhandler(id=f'update-internal-ingress-{name}', retries=3)
def _update_internal_ingress_handler(**_):
# для internal ingress нужен отдельный обработчик, это специальный случай,
# поскольку у APIComponent для ExperimentPipeline два ингресса;
# а общий обработчик update_ingress обрабатывает обновление одного ингресса;
if 'experimentPipeline' in old_spec:
_delete_internal_ingress_if_exists(api_client, name, namespace, logger)
if 'experimentPipeline' in spec and published:
ingress_model = _construct_pipeline_internal_ingress_model(name, namespace, spec)
_create_or_update_ingress(api_client, ingress_name, namespace, ingress_model, logger)
@kopf.subhandler(id=f'update-pipeline-internal-svc-{name}', retries=3)
def _update_pipeline_internal_svc_handler(**_):
if 'experimentPipeline' in old_spec:
_delete_pipeline_internal_svc_if_exists(api_client, name, namespace, logger)
if 'experimentPipeline' in spec and published:
_create_or_update_external_name_pipeline_internal_service(api_client, name, namespace, logger)
@kopf.subhandler(id=f'update-pipeline-internal-secrets-{name}', retries=3)
def _update_pipeline_internal_secrets_handler(**_):
if 'experimentPipeline' in old_spec:
_delete_pipeline_internal_api_secrets_if_exist(api_client, name, namespace, logger)
if 'experimentPipeline' in spec and published:
patch['status'].setdefault('experimentPipeline', {})
patch['status']['experimentPipeline']['internalApiSecretName'] = _get_internal_api_secret_name(name)
_create_pipeline_internal_api_secrets(api_client, name, namespace, logger)
@kopf.subhandler(id=f'create-or-update-joint-ba-secret-for-{namespace}.{name}', retries=3)
def _create_or_update_ba_joint_secret_handler(**_):
# теоретически, можно выделить в отдельный обработчик @kopf.on.field
old_api = old_spec['restfulApi']
old_auth = old_api['auth']
old_basic = old_auth.get('basic')
old_secret = old_basic.get('credentials') if old_basic else None
api = spec['restfulApi']
auth = api['auth']
new_basic = auth.get('basic')
new_secret = new_basic.get('credentials') if basic else None
if old_secret != new_secret:
user_namespace = get_user_namespace(namespace)
create_or_update_basic_auth_joint_secret(api_client=api_client,
target=name,
target_namespace=namespace,
user_namespace=user_namespace,
logger=logger)
def _should_oauth2_proxy_be_created(api_cmp_spec, new_oidc):
if new_oidc is None:
return False
if not new_oidc.get('enabled', True):
return False
published = api_cmp_spec['published']
if 'mlComponent' in api_cmp_spec and published:
return True
elif 'experimentPipeline' in api_cmp_spec and published:
return True
elif 'files' in api_cmp_spec and published:
files = api_cmp_spec['files']
enabled = files['enabled']
if enabled:
return True
elif 'pipelines' in api_cmp_spec and published:
pipelines = api_cmp_spec['pipelines']
enabled = pipelines['enabled']
if enabled:
return True
else:
if published:
raise InputValidationPermanentError(
'Only files, pipelines, experimentPipeline or mlComponent are supported '
'now, but not specified')
return False
def _update_oauth2_proxy(api_cmp_name, namespace, api_cmp_spec, new_oidc, cors, logger):
create_oauth2_proxy = _should_oauth2_proxy_be_created(api_cmp_spec, new_oidc)
@kopf.subhandler(id=f'update-oauth2-proxy-ingress-{api_cmp_name}', retries=3)
def _update_oauth2_proxy_ingress_handler(**_):
_delete_oauth2_proxy_ingress_if_exists(api_client, api_cmp_name, namespace, logger)
if create_oauth2_proxy:
ingress_name = _get_oauth2_proxy_ingress_name(api_cmp_name)
ingress_model = _construct_oauth2_proxy_ingress_model(api_cmp_name, namespace, cors)
_create_or_update_oauth2_proxy_ingress(api_client, ingress_name, namespace, ingress_model,
logger)
@kopf.subhandler(id=f'update-oauth2-proxy-deployment-{api_cmp_name}', retries=3)
def _update_oauth2_proxy_deployment_handler(**_):
_delete_oauth2_proxy_deployment_if_exists(api_client, api_cmp_name, namespace, logger)
if create_oauth2_proxy:
deployment_name = get_oauth2_proxy_deployment_name(api_cmp_name)
deployment_model = _construct_oauth2_proxy_deployment_model(api_cmp_name, namespace, new_oidc)
user_namespace = get_user_namespace(namespace)
create_or_update_groups_joint_secret(api_client, api_cmp_name, namespace, user_namespace, logger)
_create_or_update_oauth2_proxy_deployment(api_client, deployment_name, namespace, deployment_model, logger)
@kopf.subhandler(id=f'update-oauth2-proxy-service-{api_cmp_name}', retries=3)
def _update_oauth2_proxy_service_handler(**_):
_delete_oauth2_proxy_service_if_exists(api_client, api_cmp_name, namespace, logger)
if create_oauth2_proxy:
service_name = _get_oauth2_proxy_service_name(api_cmp_name)
service_model = _construct_oauth2_proxy_service_model(api_cmp_name, namespace)
_create_or_update_oauth2_proxy_service(api_client, service_name, namespace, service_model, logger)
@kopf.subhandler(id=f'update-oauth2-proxy-np-{api_cmp_name}', retries=3)
def _update_oauth2_proxy_np_handler(**_):
_delete_oauth2_proxy_np_if_exists(api_client, api_cmp_name, namespace, logger)
if create_oauth2_proxy:
np_name = _get_oauth2_proxy_np_name(api_cmp_name)
np_model = _construct_oauth2_proxy_np(api_cmp_name, namespace)
_create_or_update_oauth2_proxy_np(api_client, np_name, namespace, np_model, logger)
def _delete_oauth2_proxy(api_cmp_name, namespace, logger):
_delete_oauth2_proxy_ingress_if_exists(api_client, api_cmp_name, namespace, logger)
_delete_oauth2_proxy_deployment_if_exists(api_client, api_cmp_name, namespace, logger)
_delete_oauth2_proxy_service_if_exists(api_client, api_cmp_name, namespace, logger)
_delete_oauth2_proxy_np_if_exists(api_client, api_cmp_name, namespace, logger)