feat: implement institutional governance and recovery architecture
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
"""Deterministic Kubernetes export for the stateless multi-host profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
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",
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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),
|
||||
"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,
|
||||
),
|
||||
_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,
|
||||
),
|
||||
]
|
||||
if spec.replicas.worker:
|
||||
items.append(
|
||||
_deployment(
|
||||
name=f"{name}-worker",
|
||||
namespace=namespace,
|
||||
labels=labels,
|
||||
role="worker",
|
||||
replicas=spec.replicas.worker,
|
||||
image=spec.release.api_image,
|
||||
command=(
|
||||
"python",
|
||||
"-m",
|
||||
"celery",
|
||||
"-A",
|
||||
"govoplan_core.celery_app:celery",
|
||||
"worker",
|
||||
"--queues",
|
||||
str(environment["CELERY_QUEUES"]),
|
||||
"--loglevel",
|
||||
"INFO",
|
||||
),
|
||||
config_name=config_name,
|
||||
secret_name=secret_name,
|
||||
service_account=service_account,
|
||||
)
|
||||
)
|
||||
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,
|
||||
)
|
||||
)
|
||||
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),
|
||||
}
|
||||
},
|
||||
"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") if not environment.get(key)
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Kubernetes runtime contract is missing: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
role_labels = {**labels, "app.kubernetes.io/component": role}
|
||||
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"},
|
||||
*_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,
|
||||
) -> 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": "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,
|
||||
) -> dict[str, Any]:
|
||||
role_labels = {**labels, "app.kubernetes.io/component": role}
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user