unip-controller/controller/src/apicmp/handlers.py

1052 lines
46 KiB
Python
Raw Normal View History

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