# ============================================================ # Система: Единая библиотека, Центр ИИ НИУ ВШЭ # Модуль: 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)