1005 lines
33 KiB
Python
1005 lines
33 KiB
Python
"""Deterministic Kubernetes export for the stateless multi-host profile."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from hashlib import sha256
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any, Mapping
|
|
from urllib.parse import urlsplit
|
|
|
|
from .model import InstallationSpec, image_is_digest_pinned
|
|
|
|
|
|
_DNS_LABEL = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$")
|
|
_SECRET_KEYS = (
|
|
"MASTER_KEY_B64",
|
|
"DATABASE_URL",
|
|
"GOVOPLAN_DATABASE_URL_PGTOOLS",
|
|
"REDIS_URL",
|
|
"FILE_STORAGE_S3_ACCESS_KEY_ID",
|
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
|
|
)
|
|
_CONFIG_KEYS = (
|
|
"APP_ENV",
|
|
"GOVOPLAN_INSTALL_PROFILE",
|
|
"ENABLED_MODULES",
|
|
"CELERY_ENABLED",
|
|
"CELERY_QUEUES",
|
|
"GOVOPLAN_DB_CONNECTION_LIMIT",
|
|
"GOVOPLAN_DB_CONNECTION_RESERVE",
|
|
"GOVOPLAN_API_DB_POOL_SIZE",
|
|
"GOVOPLAN_API_DB_MAX_OVERFLOW",
|
|
"GOVOPLAN_WORKER_DB_POOL_SIZE",
|
|
"GOVOPLAN_WORKER_DB_MAX_OVERFLOW",
|
|
"GOVOPLAN_SCHEDULER_DB_POOL_SIZE",
|
|
"GOVOPLAN_SCHEDULER_DB_MAX_OVERFLOW",
|
|
"GOVOPLAN_MIGRATION_DB_POOL_SIZE",
|
|
"GOVOPLAN_MIGRATION_DB_MAX_OVERFLOW",
|
|
"GOVOPLAN_WORKER_POOLS",
|
|
"CORS_ORIGINS",
|
|
"GOVOPLAN_TRUSTED_HOSTS",
|
|
"FORWARDED_ALLOW_IPS",
|
|
"AUTH_COOKIE_SECURE",
|
|
"AUTH_COOKIE_SAMESITE",
|
|
"GOVOPLAN_HTTP_HSTS_SECONDS",
|
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
|
"GOVOPLAN_MIGRATION_TRACK",
|
|
"DEV_AUTO_MIGRATE_ENABLED",
|
|
"DEV_BOOTSTRAP_ENABLED",
|
|
"FILE_STORAGE_BACKEND",
|
|
"FILE_STORAGE_S3_ENDPOINT_URL",
|
|
"FILE_STORAGE_S3_REGION",
|
|
"FILE_STORAGE_S3_BUCKET",
|
|
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
|
)
|
|
_QUEUE_NAME = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _WorkerPool:
|
|
name: str
|
|
queues: tuple[str, ...]
|
|
replicas: int
|
|
concurrency: int
|
|
|
|
|
|
def render_kubernetes(
|
|
spec: InstallationSpec,
|
|
environment: Mapping[str, str],
|
|
*,
|
|
namespace: str = "govoplan",
|
|
secret_name: str = "govoplan-runtime",
|
|
tls_secret_name: str = "govoplan-tls",
|
|
ingress_class_name: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Render runtime roles only; shared state services stay externally managed."""
|
|
|
|
_validate_cluster_profile(spec, environment, namespace, secret_name)
|
|
worker_pools = _worker_pools(spec, environment)
|
|
database_capacity = _database_capacity(spec, environment, worker_pools)
|
|
name = _resource_name(spec.installation_id)
|
|
public_host = urlsplit(spec.public_url).hostname or "localhost"
|
|
labels = {"app.kubernetes.io/name": "govoplan", "app.kubernetes.io/instance": name}
|
|
config_name = f"{name}-runtime"
|
|
service_account = f"{name}-runtime"
|
|
config = {
|
|
key: str(environment[key])
|
|
for key in _CONFIG_KEYS
|
|
if environment.get(key) is not None
|
|
}
|
|
config.update(
|
|
{
|
|
"GOVOPLAN_INSTALLATION_ID": spec.installation_id,
|
|
"GOVOPLAN_STATE_PROFILE": "shared",
|
|
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS": "15",
|
|
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS": "60",
|
|
"GOVOPLAN_EXPECTED_API_REPLICAS": str(spec.replicas.api),
|
|
"GOVOPLAN_EXPECTED_WORKER_REPLICAS": str(spec.replicas.worker),
|
|
"GOVOPLAN_DB_CONNECTION_PEAK": str(database_capacity["peak"]),
|
|
"GOVOPLAN_DB_CONNECTION_AVAILABLE": str(database_capacity["available"]),
|
|
"FILE_STORAGE_BACKEND": "s3",
|
|
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED": "false",
|
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED": "true",
|
|
# Trust no cross-pod proxy headers by default. Operators can bind
|
|
# an exact ingress-controller CIDR through their ConfigMap overlay.
|
|
"FORWARDED_ALLOW_IPS": "127.0.0.1",
|
|
}
|
|
)
|
|
items: list[dict[str, Any]] = [
|
|
{
|
|
"apiVersion": "v1",
|
|
"kind": "Namespace",
|
|
"metadata": {"name": namespace, "labels": labels},
|
|
},
|
|
{
|
|
"apiVersion": "v1",
|
|
"kind": "ServiceAccount",
|
|
"metadata": {
|
|
"name": service_account,
|
|
"namespace": namespace,
|
|
"labels": labels,
|
|
},
|
|
"automountServiceAccountToken": False,
|
|
},
|
|
{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"metadata": {"name": config_name, "namespace": namespace, "labels": labels},
|
|
"data": dict(sorted(config.items())),
|
|
},
|
|
_deployment(
|
|
name=f"{name}-api",
|
|
namespace=namespace,
|
|
labels=labels,
|
|
role="api",
|
|
replicas=spec.replicas.api,
|
|
image=spec.release.api_image,
|
|
command=(
|
|
"python",
|
|
"-m",
|
|
"uvicorn",
|
|
"govoplan_core.server.app:app",
|
|
"--host",
|
|
"0.0.0.0",
|
|
"--port",
|
|
"8000",
|
|
"--proxy-headers",
|
|
),
|
|
config_name=config_name,
|
|
secret_name=secret_name,
|
|
service_account=service_account,
|
|
container_port=8000,
|
|
readiness_path="/health/ready",
|
|
liveness_path="/health",
|
|
probe_host=public_host,
|
|
extra_environment=_role_database_environment(environment, "API"),
|
|
),
|
|
_service(
|
|
name=f"{name}-api",
|
|
namespace=namespace,
|
|
labels=labels,
|
|
role="api",
|
|
port=8000,
|
|
),
|
|
_deployment(
|
|
name=f"{name}-web",
|
|
namespace=namespace,
|
|
labels=labels,
|
|
role="web",
|
|
replicas=spec.replicas.web,
|
|
image=spec.release.web_image,
|
|
command=(),
|
|
config_name=None,
|
|
secret_name=None,
|
|
service_account=service_account,
|
|
container_port=8080,
|
|
extra_environment={"GOVOPLAN_API_UPSTREAM": f"http://{name}-api:8000"},
|
|
),
|
|
_service(
|
|
name=f"{name}-web",
|
|
namespace=namespace,
|
|
labels=labels,
|
|
role="web",
|
|
port=8080,
|
|
),
|
|
_migration_job(
|
|
name=name,
|
|
release_key=_release_key(spec),
|
|
namespace=namespace,
|
|
labels=labels,
|
|
image=spec.release.api_image,
|
|
config_name=config_name,
|
|
secret_name=secret_name,
|
|
service_account=service_account,
|
|
database_environment=_role_database_environment(
|
|
environment,
|
|
"MIGRATION",
|
|
),
|
|
),
|
|
]
|
|
for pool in worker_pools:
|
|
worker_name = (
|
|
f"{name}-worker"
|
|
if len(worker_pools) == 1 and pool.name == "default"
|
|
else _name_with_suffix(name, f"worker-{pool.name}")
|
|
)
|
|
items.append(
|
|
_deployment(
|
|
name=worker_name,
|
|
namespace=namespace,
|
|
labels=labels,
|
|
role="worker",
|
|
replicas=pool.replicas,
|
|
image=spec.release.api_image,
|
|
command=(
|
|
"python",
|
|
"-m",
|
|
"celery",
|
|
"-A",
|
|
"govoplan_core.celery_app:celery",
|
|
"worker",
|
|
"--queues",
|
|
",".join(pool.queues),
|
|
"--concurrency",
|
|
str(pool.concurrency),
|
|
"--loglevel",
|
|
"INFO",
|
|
),
|
|
config_name=config_name,
|
|
secret_name=secret_name,
|
|
service_account=service_account,
|
|
extra_environment={
|
|
**_role_database_environment(environment, "WORKER"),
|
|
"CELERY_QUEUES": ",".join(pool.queues),
|
|
"CELERY_WORKER_CONCURRENCY": str(pool.concurrency),
|
|
"GOVOPLAN_WORKER_POOL": pool.name,
|
|
},
|
|
selector_labels={
|
|
"govoplan.add-ideas.de/worker-pool": pool.name,
|
|
},
|
|
)
|
|
)
|
|
if pool.replicas > 1:
|
|
items.append(
|
|
_pod_disruption_budget(
|
|
worker_name,
|
|
namespace,
|
|
labels,
|
|
"worker",
|
|
selector_labels={
|
|
"govoplan.add-ideas.de/worker-pool": pool.name,
|
|
},
|
|
)
|
|
)
|
|
if worker_pools:
|
|
items.append(
|
|
_deployment(
|
|
name=f"{name}-scheduler",
|
|
namespace=namespace,
|
|
labels=labels,
|
|
role="scheduler",
|
|
replicas=1,
|
|
image=spec.release.api_image,
|
|
command=(
|
|
"python",
|
|
"-m",
|
|
"govoplan_core.commands.fenced_run",
|
|
"--resource",
|
|
"scheduler:celery-beat",
|
|
"--wait-seconds",
|
|
"120",
|
|
"--",
|
|
"python",
|
|
"-m",
|
|
"celery",
|
|
"-A",
|
|
"govoplan_core.celery_app:celery",
|
|
"beat",
|
|
"--loglevel",
|
|
"INFO",
|
|
),
|
|
config_name=config_name,
|
|
secret_name=secret_name,
|
|
service_account=service_account,
|
|
extra_environment=_role_database_environment(
|
|
environment,
|
|
"SCHEDULER",
|
|
),
|
|
)
|
|
)
|
|
if spec.replicas.api > 1:
|
|
items.append(_pod_disruption_budget(f"{name}-api", namespace, labels, "api"))
|
|
if spec.replicas.web > 1:
|
|
items.append(_pod_disruption_budget(f"{name}-web", namespace, labels, "web"))
|
|
items.append(
|
|
_ingress(
|
|
spec,
|
|
name=name,
|
|
namespace=namespace,
|
|
labels=labels,
|
|
tls_secret_name=tls_secret_name,
|
|
ingress_class_name=ingress_class_name,
|
|
)
|
|
)
|
|
return {
|
|
"apiVersion": "v1",
|
|
"kind": "List",
|
|
"metadata": {
|
|
"annotations": {
|
|
"govoplan.add-ideas.de/profile": "stateless-shared-state",
|
|
"govoplan.add-ideas.de/secret-contract": ",".join(_SECRET_KEYS),
|
|
"govoplan.add-ideas.de/database-connection-peak": str(
|
|
database_capacity["peak"]
|
|
),
|
|
"govoplan.add-ideas.de/database-connection-available": str(
|
|
database_capacity["available"]
|
|
),
|
|
"govoplan.add-ideas.de/worker-pools": ",".join(
|
|
pool.name for pool in worker_pools
|
|
),
|
|
}
|
|
},
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def kubernetes_secret_contract() -> tuple[str, ...]:
|
|
return _SECRET_KEYS
|
|
|
|
|
|
def _validate_cluster_profile(
|
|
spec: InstallationSpec,
|
|
environment: Mapping[str, str],
|
|
namespace: str,
|
|
secret_name: str,
|
|
) -> None:
|
|
if not _DNS_LABEL.fullmatch(namespace) or not _DNS_LABEL.fullmatch(secret_name):
|
|
raise ValueError("Kubernetes namespace and secret names must be DNS labels")
|
|
if spec.installation_id == "govoplan-local":
|
|
raise ValueError(
|
|
"Kubernetes export requires a non-default stable installation id"
|
|
)
|
|
if spec.components.postgres.mode != "external":
|
|
raise ValueError("Kubernetes export requires external shared PostgreSQL")
|
|
if spec.components.redis.mode != "external":
|
|
raise ValueError("Kubernetes export requires external shared Redis")
|
|
if spec.components.storage.mode != "s3":
|
|
raise ValueError("Kubernetes export requires external shared S3 storage")
|
|
unpinned_images = [
|
|
label
|
|
for label, image in (
|
|
("release.api_image", spec.release.api_image),
|
|
("release.web_image", spec.release.web_image),
|
|
)
|
|
if not image_is_digest_pinned(image)
|
|
]
|
|
if unpinned_images:
|
|
raise ValueError(
|
|
"Kubernetes export requires immutable digest-pinned release images: "
|
|
+ ", ".join(unpinned_images)
|
|
)
|
|
missing = [
|
|
key
|
|
for key in (
|
|
*_SECRET_KEYS,
|
|
"CELERY_QUEUES",
|
|
"GOVOPLAN_DB_CONNECTION_LIMIT",
|
|
)
|
|
if not environment.get(key)
|
|
]
|
|
if missing:
|
|
raise ValueError(
|
|
"Kubernetes runtime contract is missing: " + ", ".join(missing)
|
|
)
|
|
|
|
|
|
def _worker_pools(
|
|
spec: InstallationSpec,
|
|
environment: Mapping[str, str],
|
|
) -> tuple[_WorkerPool, ...]:
|
|
if spec.replicas.worker == 0:
|
|
if str(environment.get("GOVOPLAN_WORKER_POOLS") or "").strip():
|
|
raise ValueError("Worker pools require at least one worker replica")
|
|
return ()
|
|
configured_queues = tuple(
|
|
item.strip()
|
|
for item in str(environment.get("CELERY_QUEUES") or "").split(",")
|
|
if item.strip()
|
|
)
|
|
if not configured_queues or any(
|
|
_QUEUE_NAME.fullmatch(item) is None for item in configured_queues
|
|
):
|
|
raise ValueError("CELERY_QUEUES must contain canonical queue names")
|
|
raw_pools = str(environment.get("GOVOPLAN_WORKER_POOLS") or "").strip()
|
|
if not raw_pools:
|
|
return (
|
|
_WorkerPool(
|
|
name="default",
|
|
queues=configured_queues,
|
|
replicas=spec.replicas.worker,
|
|
concurrency=_bounded_environment_integer(
|
|
environment,
|
|
"CELERY_WORKER_CONCURRENCY",
|
|
default=2,
|
|
minimum=1,
|
|
maximum=64,
|
|
),
|
|
),
|
|
)
|
|
try:
|
|
payload = json.loads(raw_pools)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("GOVOPLAN_WORKER_POOLS must be valid JSON") from exc
|
|
if not isinstance(payload, list) or not payload:
|
|
raise ValueError("GOVOPLAN_WORKER_POOLS must be a non-empty JSON list")
|
|
pools: list[_WorkerPool] = []
|
|
seen_names: set[str] = set()
|
|
seen_queues: set[str] = set()
|
|
for index, item in enumerate(payload):
|
|
if not isinstance(item, dict):
|
|
raise ValueError(f"Worker pool {index} must be an object")
|
|
unknown = set(item) - {"name", "queues", "replicas", "concurrency"}
|
|
if unknown:
|
|
raise ValueError(
|
|
f"Worker pool {index} has unsupported keys: "
|
|
+ ", ".join(sorted(unknown))
|
|
)
|
|
pool_name = item.get("name")
|
|
queues = item.get("queues")
|
|
replicas = item.get("replicas")
|
|
concurrency = item.get("concurrency")
|
|
if not isinstance(pool_name, str) or _DNS_LABEL.fullmatch(pool_name) is None:
|
|
raise ValueError(f"Worker pool {index} has an invalid name")
|
|
if pool_name in seen_names:
|
|
raise ValueError(f"Worker pool name is duplicated: {pool_name}")
|
|
if (
|
|
not isinstance(queues, list)
|
|
or not queues
|
|
or any(
|
|
not isinstance(queue, str) or _QUEUE_NAME.fullmatch(queue) is None
|
|
for queue in queues
|
|
)
|
|
):
|
|
raise ValueError(f"Worker pool {pool_name} has invalid queues")
|
|
duplicate_queues = seen_queues.intersection(queues)
|
|
if duplicate_queues:
|
|
raise ValueError(
|
|
"Worker queues must have one owning pool: "
|
|
+ ", ".join(sorted(duplicate_queues))
|
|
)
|
|
if not isinstance(replicas, int) or not 1 <= replicas <= 128:
|
|
raise ValueError(
|
|
f"Worker pool {pool_name} replicas must be between 1 and 128"
|
|
)
|
|
if not isinstance(concurrency, int) or not 1 <= concurrency <= 64:
|
|
raise ValueError(
|
|
f"Worker pool {pool_name} concurrency must be between 1 and 64"
|
|
)
|
|
pools.append(
|
|
_WorkerPool(
|
|
name=pool_name,
|
|
queues=tuple(queues),
|
|
replicas=replicas,
|
|
concurrency=concurrency,
|
|
)
|
|
)
|
|
seen_names.add(pool_name)
|
|
seen_queues.update(queues)
|
|
if sum(pool.replicas for pool in pools) != spec.replicas.worker:
|
|
raise ValueError("Worker pool replicas must equal installation replicas.worker")
|
|
configured_queue_set = set(configured_queues)
|
|
if seen_queues != configured_queue_set:
|
|
missing = configured_queue_set - seen_queues
|
|
unknown = seen_queues - configured_queue_set
|
|
detail = []
|
|
if missing:
|
|
detail.append("missing " + ", ".join(sorted(missing)))
|
|
if unknown:
|
|
detail.append("unknown " + ", ".join(sorted(unknown)))
|
|
raise ValueError(
|
|
"Worker pools must cover CELERY_QUEUES exactly: " + "; ".join(detail)
|
|
)
|
|
return tuple(sorted(pools, key=lambda pool: pool.name))
|
|
|
|
|
|
def _database_capacity(
|
|
spec: InstallationSpec,
|
|
environment: Mapping[str, str],
|
|
worker_pools: tuple[_WorkerPool, ...],
|
|
) -> dict[str, int]:
|
|
limit = _bounded_environment_integer(
|
|
environment,
|
|
"GOVOPLAN_DB_CONNECTION_LIMIT",
|
|
minimum=10,
|
|
maximum=1_000_000,
|
|
)
|
|
reserve = _bounded_environment_integer(
|
|
environment,
|
|
"GOVOPLAN_DB_CONNECTION_RESERVE",
|
|
default=10,
|
|
minimum=1,
|
|
maximum=999_999,
|
|
)
|
|
if reserve >= limit:
|
|
raise ValueError("Database connection reserve must be below the limit")
|
|
api_ceiling = _role_database_ceiling(environment, "API", 5, 2)
|
|
worker_ceiling = _role_database_ceiling(environment, "WORKER", 1, 1)
|
|
scheduler_ceiling = _role_database_ceiling(environment, "SCHEDULER", 1, 0)
|
|
migration_ceiling = _role_database_ceiling(environment, "MIGRATION", 2, 0)
|
|
worker_processes = sum(
|
|
pool.replicas * (pool.concurrency + 1) for pool in worker_pools
|
|
)
|
|
steady = (
|
|
spec.replicas.api * api_ceiling
|
|
+ worker_processes * worker_ceiling
|
|
+ (scheduler_ceiling if worker_pools else 0)
|
|
)
|
|
rollout_surge = api_ceiling + migration_ceiling
|
|
if worker_pools:
|
|
rollout_surge += scheduler_ceiling
|
|
rollout_surge += sum(
|
|
(pool.concurrency + 1) * worker_ceiling for pool in worker_pools
|
|
)
|
|
peak = steady + rollout_surge
|
|
available = limit - reserve
|
|
if peak > available:
|
|
raise ValueError(
|
|
"Kubernetes database connection peak exceeds the configured budget: "
|
|
f"peak={peak}, available={available}, limit={limit}, reserve={reserve}"
|
|
)
|
|
return {
|
|
"limit": limit,
|
|
"reserve": reserve,
|
|
"available": available,
|
|
"steady": steady,
|
|
"peak": peak,
|
|
}
|
|
|
|
|
|
def _role_database_ceiling(
|
|
environment: Mapping[str, str],
|
|
role: str,
|
|
default_pool_size: int,
|
|
default_overflow: int,
|
|
) -> int:
|
|
return _bounded_environment_integer(
|
|
environment,
|
|
f"GOVOPLAN_{role}_DB_POOL_SIZE",
|
|
default=default_pool_size,
|
|
minimum=1,
|
|
maximum=100,
|
|
) + _bounded_environment_integer(
|
|
environment,
|
|
f"GOVOPLAN_{role}_DB_MAX_OVERFLOW",
|
|
default=default_overflow,
|
|
minimum=0,
|
|
maximum=200,
|
|
)
|
|
|
|
|
|
def _role_database_environment(
|
|
environment: Mapping[str, str],
|
|
role: str,
|
|
) -> dict[str, str]:
|
|
defaults = {
|
|
"API": (5, 2),
|
|
"WORKER": (1, 1),
|
|
"SCHEDULER": (1, 0),
|
|
"MIGRATION": (2, 0),
|
|
}
|
|
pool_size, overflow = defaults[role]
|
|
return {
|
|
"GOVOPLAN_DB_POOL_SIZE": str(
|
|
_bounded_environment_integer(
|
|
environment,
|
|
f"GOVOPLAN_{role}_DB_POOL_SIZE",
|
|
default=pool_size,
|
|
minimum=1,
|
|
maximum=100,
|
|
)
|
|
),
|
|
"GOVOPLAN_DB_MAX_OVERFLOW": str(
|
|
_bounded_environment_integer(
|
|
environment,
|
|
f"GOVOPLAN_{role}_DB_MAX_OVERFLOW",
|
|
default=overflow,
|
|
minimum=0,
|
|
maximum=200,
|
|
)
|
|
),
|
|
}
|
|
|
|
|
|
def _bounded_environment_integer(
|
|
environment: Mapping[str, str],
|
|
name: str,
|
|
*,
|
|
default: int | None = None,
|
|
minimum: int,
|
|
maximum: int,
|
|
) -> int:
|
|
raw = environment.get(name)
|
|
if raw is None or not str(raw).strip():
|
|
if default is None:
|
|
raise ValueError(f"{name} is required")
|
|
return default
|
|
try:
|
|
value = int(str(raw))
|
|
except ValueError as exc:
|
|
raise ValueError(f"{name} must be an integer") from exc
|
|
if not minimum <= value <= maximum:
|
|
raise ValueError(f"{name} must be between {minimum} and {maximum}")
|
|
return value
|
|
|
|
|
|
def _deployment(
|
|
*,
|
|
name: str,
|
|
namespace: str,
|
|
labels: dict[str, str],
|
|
role: str,
|
|
replicas: int,
|
|
image: str,
|
|
command: tuple[str, ...],
|
|
config_name: str | None,
|
|
secret_name: str | None,
|
|
service_account: str,
|
|
container_port: int | None = None,
|
|
readiness_path: str | None = None,
|
|
liveness_path: str | None = None,
|
|
probe_host: str | None = None,
|
|
extra_environment: Mapping[str, str] | None = None,
|
|
selector_labels: Mapping[str, str] | None = None,
|
|
) -> dict[str, Any]:
|
|
role_labels = {
|
|
**labels,
|
|
"app.kubernetes.io/component": role,
|
|
**dict(selector_labels or {}),
|
|
}
|
|
environment: list[dict[str, Any]] = [
|
|
{"name": "TMPDIR", "value": "/tmp"},
|
|
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": role},
|
|
{
|
|
"name": "GOVOPLAN_NODE_ID",
|
|
"valueFrom": {"fieldRef": {"fieldPath": "metadata.name"}},
|
|
},
|
|
]
|
|
environment.extend(
|
|
{"name": key, "value": value}
|
|
for key, value in sorted((extra_environment or {}).items())
|
|
)
|
|
if secret_name:
|
|
environment.extend(_secret_environment(secret_name))
|
|
container: dict[str, Any] = {
|
|
"name": role,
|
|
"image": image,
|
|
"imagePullPolicy": "IfNotPresent",
|
|
"env": environment,
|
|
"securityContext": {
|
|
"allowPrivilegeEscalation": False,
|
|
"capabilities": {"drop": ["ALL"]},
|
|
"readOnlyRootFilesystem": True,
|
|
},
|
|
"resources": {
|
|
"requests": {"cpu": "100m", "memory": "256Mi"},
|
|
"limits": {"memory": "1Gi"},
|
|
},
|
|
}
|
|
if command:
|
|
container["command"] = list(command)
|
|
if container_port is not None:
|
|
container["ports"] = [{"name": "http", "containerPort": container_port}]
|
|
if readiness_path:
|
|
container["readinessProbe"] = _http_probe(
|
|
readiness_path,
|
|
container_port or 8000,
|
|
host=probe_host,
|
|
)
|
|
if liveness_path:
|
|
container["livenessProbe"] = _http_probe(
|
|
liveness_path,
|
|
container_port or 8000,
|
|
host=probe_host,
|
|
)
|
|
pod_spec: dict[str, Any] = {
|
|
"serviceAccountName": service_account,
|
|
"automountServiceAccountToken": False,
|
|
"securityContext": {
|
|
"runAsNonRoot": True,
|
|
"seccompProfile": {"type": "RuntimeDefault"},
|
|
},
|
|
"containers": [container],
|
|
"volumes": [{"name": "tmp", "emptyDir": {}}],
|
|
"topologySpreadConstraints": [
|
|
{
|
|
"maxSkew": 1,
|
|
"topologyKey": "kubernetes.io/hostname",
|
|
"whenUnsatisfiable": "ScheduleAnyway",
|
|
"labelSelector": {"matchLabels": role_labels},
|
|
}
|
|
],
|
|
}
|
|
container["volumeMounts"] = [{"name": "tmp", "mountPath": "/tmp"}]
|
|
if config_name:
|
|
pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}]
|
|
if config_name and secret_name:
|
|
pod_spec["initContainers"] = [
|
|
{
|
|
"name": "wait-for-database",
|
|
"image": image,
|
|
"imagePullPolicy": "IfNotPresent",
|
|
"command": [
|
|
"python",
|
|
"-m",
|
|
"govoplan_core.commands.wait_for_database",
|
|
"--timeout-seconds",
|
|
"900",
|
|
],
|
|
"envFrom": [{"configMapRef": {"name": config_name}}],
|
|
"env": [
|
|
{"name": "TMPDIR", "value": "/tmp"},
|
|
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration-wait"},
|
|
{"name": "GOVOPLAN_DB_POOL_SIZE", "value": "1"},
|
|
{"name": "GOVOPLAN_DB_MAX_OVERFLOW", "value": "0"},
|
|
*_secret_environment(secret_name),
|
|
],
|
|
"securityContext": {
|
|
"allowPrivilegeEscalation": False,
|
|
"capabilities": {"drop": ["ALL"]},
|
|
"readOnlyRootFilesystem": True,
|
|
},
|
|
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
|
|
}
|
|
]
|
|
return {
|
|
"apiVersion": "apps/v1",
|
|
"kind": "Deployment",
|
|
"metadata": {"name": name, "namespace": namespace, "labels": role_labels},
|
|
"spec": {
|
|
"replicas": replicas,
|
|
"strategy": {
|
|
"type": "RollingUpdate",
|
|
"rollingUpdate": {"maxUnavailable": 0, "maxSurge": 1},
|
|
},
|
|
"selector": {"matchLabels": role_labels},
|
|
"template": {
|
|
"metadata": {"labels": role_labels},
|
|
"spec": pod_spec,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _migration_job(
|
|
*,
|
|
name: str,
|
|
release_key: str,
|
|
namespace: str,
|
|
labels: dict[str, str],
|
|
image: str,
|
|
config_name: str,
|
|
secret_name: str,
|
|
service_account: str,
|
|
database_environment: Mapping[str, str],
|
|
) -> dict[str, Any]:
|
|
job_labels = {**labels, "app.kubernetes.io/component": "migration"}
|
|
job_name = _name_with_suffix(name, f"migrate-{release_key}")
|
|
return {
|
|
"apiVersion": "batch/v1",
|
|
"kind": "Job",
|
|
"metadata": {
|
|
"name": job_name,
|
|
"namespace": namespace,
|
|
"labels": job_labels,
|
|
"annotations": {
|
|
"govoplan.add-ideas.de/recovery-mode": "forward-recovery",
|
|
"govoplan.add-ideas.de/backup-required": "true",
|
|
"argocd.argoproj.io/sync-wave": "-1",
|
|
},
|
|
},
|
|
"spec": {
|
|
"backoffLimit": 1,
|
|
"ttlSecondsAfterFinished": 86400,
|
|
"template": {
|
|
"metadata": {"labels": job_labels},
|
|
"spec": {
|
|
"restartPolicy": "Never",
|
|
"serviceAccountName": service_account,
|
|
"automountServiceAccountToken": False,
|
|
"securityContext": {
|
|
"runAsNonRoot": True,
|
|
"seccompProfile": {"type": "RuntimeDefault"},
|
|
},
|
|
"containers": [
|
|
{
|
|
"name": "migration",
|
|
"image": image,
|
|
"command": [
|
|
"python",
|
|
"-m",
|
|
"govoplan_core.commands.init_db",
|
|
],
|
|
"envFrom": [{"configMapRef": {"name": config_name}}],
|
|
"env": [
|
|
{"name": "TMPDIR", "value": "/tmp"},
|
|
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration"},
|
|
*(
|
|
{"name": key, "value": value}
|
|
for key, value in sorted(
|
|
database_environment.items()
|
|
)
|
|
),
|
|
{
|
|
"name": "GOVOPLAN_NODE_ID",
|
|
"valueFrom": {
|
|
"fieldRef": {"fieldPath": "metadata.name"}
|
|
},
|
|
},
|
|
*_secret_environment(secret_name),
|
|
],
|
|
"securityContext": {
|
|
"allowPrivilegeEscalation": False,
|
|
"capabilities": {"drop": ["ALL"]},
|
|
"readOnlyRootFilesystem": True,
|
|
},
|
|
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
|
|
}
|
|
],
|
|
"volumes": [{"name": "tmp", "emptyDir": {}}],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _secret_environment(secret_name: str) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"name": key,
|
|
"valueFrom": {
|
|
"secretKeyRef": {"name": secret_name, "key": key, "optional": False}
|
|
},
|
|
}
|
|
for key in _SECRET_KEYS
|
|
]
|
|
|
|
|
|
def _service(
|
|
*,
|
|
name: str,
|
|
namespace: str,
|
|
labels: dict[str, str],
|
|
role: str,
|
|
port: int,
|
|
) -> dict[str, Any]:
|
|
role_labels = {**labels, "app.kubernetes.io/component": role}
|
|
return {
|
|
"apiVersion": "v1",
|
|
"kind": "Service",
|
|
"metadata": {"name": name, "namespace": namespace, "labels": role_labels},
|
|
"spec": {
|
|
"selector": role_labels,
|
|
"ports": [{"name": "http", "port": port, "targetPort": "http"}],
|
|
},
|
|
}
|
|
|
|
|
|
def _pod_disruption_budget(
|
|
name: str,
|
|
namespace: str,
|
|
labels: dict[str, str],
|
|
role: str,
|
|
*,
|
|
selector_labels: Mapping[str, str] | None = None,
|
|
) -> dict[str, Any]:
|
|
role_labels = {
|
|
**labels,
|
|
"app.kubernetes.io/component": role,
|
|
**dict(selector_labels or {}),
|
|
}
|
|
return {
|
|
"apiVersion": "policy/v1",
|
|
"kind": "PodDisruptionBudget",
|
|
"metadata": {"name": name, "namespace": namespace, "labels": role_labels},
|
|
"spec": {"minAvailable": 1, "selector": {"matchLabels": role_labels}},
|
|
}
|
|
|
|
|
|
def _ingress(
|
|
spec: InstallationSpec,
|
|
*,
|
|
name: str,
|
|
namespace: str,
|
|
labels: dict[str, str],
|
|
tls_secret_name: str,
|
|
ingress_class_name: str | None,
|
|
) -> dict[str, Any]:
|
|
public = urlsplit(spec.public_url)
|
|
ingress_spec: dict[str, Any] = {
|
|
"rules": [
|
|
{
|
|
"host": public.hostname,
|
|
"http": {
|
|
"paths": [
|
|
{
|
|
"path": "/",
|
|
"pathType": "Prefix",
|
|
"backend": {
|
|
"service": {
|
|
"name": f"{name}-web",
|
|
"port": {"name": "http"},
|
|
}
|
|
},
|
|
}
|
|
]
|
|
},
|
|
}
|
|
]
|
|
}
|
|
if public.scheme == "https":
|
|
if not tls_secret_name:
|
|
raise ValueError("HTTPS Kubernetes export requires a TLS secret name")
|
|
ingress_spec["tls"] = [
|
|
{"hosts": [public.hostname], "secretName": tls_secret_name}
|
|
]
|
|
if ingress_class_name:
|
|
ingress_spec["ingressClassName"] = ingress_class_name
|
|
return {
|
|
"apiVersion": "networking.k8s.io/v1",
|
|
"kind": "Ingress",
|
|
"metadata": {"name": name, "namespace": namespace, "labels": labels},
|
|
"spec": ingress_spec,
|
|
}
|
|
|
|
|
|
def _http_probe(
|
|
path: str,
|
|
port: int,
|
|
*,
|
|
host: str | None = None,
|
|
) -> dict[str, Any]:
|
|
request: dict[str, Any] = {"path": path, "port": port}
|
|
if host:
|
|
request["httpHeaders"] = [{"name": "Host", "value": host}]
|
|
return {
|
|
"httpGet": request,
|
|
"initialDelaySeconds": 10,
|
|
"periodSeconds": 10,
|
|
"timeoutSeconds": 3,
|
|
"failureThreshold": 6,
|
|
}
|
|
|
|
|
|
def _resource_name(installation_id: str) -> str:
|
|
value = installation_id[:48].rstrip("-")
|
|
if not _DNS_LABEL.fullmatch(value):
|
|
raise ValueError("Installation id cannot be represented as a Kubernetes name")
|
|
return value
|
|
|
|
|
|
def _release_key(spec: InstallationSpec) -> str:
|
|
manifest_hash = str(spec.release.manifest_sha256 or "").strip().lower()
|
|
if re.fullmatch(r"[0-9a-f]{8,}", manifest_hash):
|
|
return manifest_hash[:8]
|
|
payload = "\0".join(
|
|
(
|
|
spec.release.version,
|
|
spec.release.api_image,
|
|
spec.release.web_image,
|
|
)
|
|
).encode("utf-8")
|
|
return sha256(payload).hexdigest()[:8]
|
|
|
|
|
|
def _name_with_suffix(name: str, suffix: str) -> str:
|
|
available = 63 - len(suffix) - 1
|
|
prefix = name[:available].rstrip("-")
|
|
candidate = f"{prefix}-{suffix}"
|
|
if not _DNS_LABEL.fullmatch(candidate):
|
|
raise ValueError("Kubernetes resource name cannot include the release suffix")
|
|
return candidate
|
|
|
|
|
|
def write_secret_creation_hint(
|
|
environment_path: Path,
|
|
*,
|
|
namespace: str,
|
|
secret_name: str,
|
|
) -> str:
|
|
keys = " ".join(f"--from-env-file={environment_path}" for _ in (0,))
|
|
return (
|
|
f"kubectl -n {namespace} create secret generic {secret_name} {keys} "
|
|
"--dry-run=client -o yaml | kubectl apply -f -"
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"kubernetes_secret_contract",
|
|
"render_kubernetes",
|
|
"write_secret_creation_hint",
|
|
]
|