Release v0.1.16
This commit is contained in:
@@ -219,6 +219,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
kubernetes.add_argument("--namespace", default="govoplan")
|
||||
kubernetes.add_argument("--secret-name", default="govoplan-runtime")
|
||||
kubernetes.add_argument("--tls-secret-name", default="govoplan-tls")
|
||||
kubernetes.add_argument(
|
||||
"--s3-ca-secret-name",
|
||||
help=(
|
||||
"Optional Secret containing ca.crt for the external S3 endpoint; "
|
||||
"mounted read-only into backend runtime roles."
|
||||
),
|
||||
)
|
||||
kubernetes.add_argument("--ingress-class-name")
|
||||
kubernetes.add_argument(
|
||||
"--output",
|
||||
@@ -239,7 +246,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
verify_kubernetes.add_argument(
|
||||
"--api-key-env",
|
||||
default="GOVOPLAN_OPS_API_KEY",
|
||||
help="Environment variable containing an API key with Ops read scope.",
|
||||
help=(
|
||||
"Environment variable containing an API key authorized to read "
|
||||
"Ops status."
|
||||
),
|
||||
)
|
||||
verify_kubernetes.add_argument(
|
||||
"--exercise-api-pod-loss",
|
||||
@@ -1146,6 +1156,7 @@ def _render_kubernetes(args: argparse.Namespace) -> int:
|
||||
namespace=args.namespace,
|
||||
secret_name=args.secret_name,
|
||||
tls_secret_name=args.tls_secret_name,
|
||||
s3_ca_secret_name=args.s3_ca_secret_name,
|
||||
ingress_class_name=args.ingress_class_name,
|
||||
backup_required=backup_required,
|
||||
backup_evidence=backup_summary,
|
||||
@@ -1170,7 +1181,8 @@ def _verify_kubernetes(args: argparse.Namespace) -> int:
|
||||
api_key = str(os.environ.get(args.api_key_env) or "").strip()
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"{args.api_key_env} must contain an API key with Ops read scope"
|
||||
f"{args.api_key_env} must contain an API key authorized to read "
|
||||
"Ops status"
|
||||
)
|
||||
ops_url = args.ops_url or (spec.public_url.rstrip("/") + "/api/v1/ops/status")
|
||||
evidence = collect_kubernetes_evidence(
|
||||
|
||||
@@ -14,6 +14,7 @@ from urllib.request import Request, urlopen
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
CommandRunner = Callable[[Sequence[str]], JsonObject]
|
||||
ActionRunner = Callable[[Sequence[str]], None]
|
||||
JsonFetcher = Callable[[str, str], JsonObject]
|
||||
|
||||
|
||||
@@ -26,11 +27,13 @@ def collect_kubernetes_evidence(
|
||||
exercise_api_pod_loss: bool = False,
|
||||
timeout_seconds: float = 180.0,
|
||||
command_runner: CommandRunner | None = None,
|
||||
action_runner: ActionRunner | None = None,
|
||||
json_fetcher: JsonFetcher | None = None,
|
||||
) -> JsonObject:
|
||||
"""Inspect a live cluster and optionally exercise one API pod replacement."""
|
||||
|
||||
run_json = command_runner or _kubectl_json
|
||||
run_action = action_runner or _kubectl_action
|
||||
fetch_json = json_fetcher or _fetch_json
|
||||
nodes = run_json(("get", "nodes", "-o", "json"))
|
||||
pods = run_json(
|
||||
@@ -76,6 +79,7 @@ def collect_kubernetes_evidence(
|
||||
initial_pods=pods,
|
||||
timeout_seconds=timeout_seconds,
|
||||
run_json=run_json,
|
||||
run_action=run_action,
|
||||
fetch_json=fetch_json,
|
||||
)
|
||||
evidence = {
|
||||
@@ -211,6 +215,7 @@ def _exercise_api_pod_loss(
|
||||
initial_pods: Mapping[str, Any],
|
||||
timeout_seconds: float,
|
||||
run_json: CommandRunner,
|
||||
run_action: ActionRunner,
|
||||
fetch_json: JsonFetcher,
|
||||
) -> JsonObject:
|
||||
candidates = [
|
||||
@@ -228,7 +233,7 @@ def _exercise_api_pod_loss(
|
||||
victim = sorted(candidates, key=lambda item: item["name"])[0]
|
||||
initial_uids = {item["uid"] for item in candidates}
|
||||
desired_ready = len(candidates)
|
||||
run_json(
|
||||
run_action(
|
||||
(
|
||||
"-n",
|
||||
namespace,
|
||||
@@ -236,8 +241,6 @@ def _exercise_api_pod_loss(
|
||||
"pod",
|
||||
victim["name"],
|
||||
"--wait=false",
|
||||
"-o",
|
||||
"json",
|
||||
)
|
||||
)
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
@@ -309,6 +312,22 @@ def _kubectl_json(arguments: Sequence[str]) -> JsonObject:
|
||||
return payload
|
||||
|
||||
|
||||
def _kubectl_action(arguments: Sequence[str]) -> None:
|
||||
kubectl = shutil.which("kubectl")
|
||||
if kubectl is None:
|
||||
raise ValueError("kubectl is required for Kubernetes evidence collection")
|
||||
result = subprocess.run(
|
||||
(kubectl, *arguments),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode:
|
||||
detail = result.stderr.strip() or result.stdout.strip()
|
||||
raise ValueError(f"kubectl failed: {detail}")
|
||||
|
||||
|
||||
def _fetch_json(url: str, api_key: str) -> JsonObject:
|
||||
request = Request(
|
||||
url,
|
||||
|
||||
@@ -76,13 +76,20 @@ def render_kubernetes(
|
||||
namespace: str = "govoplan",
|
||||
secret_name: str = "govoplan-runtime",
|
||||
tls_secret_name: str = "govoplan-tls",
|
||||
s3_ca_secret_name: str | None = None,
|
||||
ingress_class_name: str | None = None,
|
||||
backup_required: bool = True,
|
||||
backup_evidence: Mapping[str, object] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Render runtime roles only; shared state services stay externally managed."""
|
||||
|
||||
_validate_cluster_profile(spec, environment, namespace, secret_name)
|
||||
_validate_cluster_profile(
|
||||
spec,
|
||||
environment,
|
||||
namespace,
|
||||
secret_name,
|
||||
s3_ca_secret_name=s3_ca_secret_name,
|
||||
)
|
||||
worker_pools = _worker_pools(spec, environment)
|
||||
database_capacity = _database_capacity(spec, environment, worker_pools)
|
||||
name = _resource_name(spec.installation_id)
|
||||
@@ -155,6 +162,7 @@ def render_kubernetes(
|
||||
),
|
||||
config_name=config_name,
|
||||
secret_name=secret_name,
|
||||
s3_ca_secret_name=s3_ca_secret_name,
|
||||
service_account=service_account,
|
||||
container_port=8000,
|
||||
readiness_path="/health/ready",
|
||||
@@ -179,6 +187,7 @@ def render_kubernetes(
|
||||
command=(),
|
||||
config_name=None,
|
||||
secret_name=None,
|
||||
s3_ca_secret_name=None,
|
||||
service_account=service_account,
|
||||
container_port=8080,
|
||||
extra_environment={"GOVOPLAN_API_UPSTREAM": f"http://{name}-api:8000"},
|
||||
@@ -198,6 +207,7 @@ def render_kubernetes(
|
||||
image=spec.release.api_image,
|
||||
config_name=config_name,
|
||||
secret_name=secret_name,
|
||||
s3_ca_secret_name=s3_ca_secret_name,
|
||||
service_account=service_account,
|
||||
database_environment=_role_database_environment(
|
||||
environment,
|
||||
@@ -237,6 +247,7 @@ def render_kubernetes(
|
||||
),
|
||||
config_name=config_name,
|
||||
secret_name=secret_name,
|
||||
s3_ca_secret_name=s3_ca_secret_name,
|
||||
service_account=service_account,
|
||||
extra_environment={
|
||||
**_role_database_environment(environment, "WORKER"),
|
||||
@@ -287,9 +298,12 @@ def render_kubernetes(
|
||||
"beat",
|
||||
"--loglevel",
|
||||
"INFO",
|
||||
"--schedule",
|
||||
"/tmp/celerybeat-schedule",
|
||||
),
|
||||
config_name=config_name,
|
||||
secret_name=secret_name,
|
||||
s3_ca_secret_name=s3_ca_secret_name,
|
||||
service_account=service_account,
|
||||
extra_environment=_role_database_environment(
|
||||
environment,
|
||||
@@ -318,6 +332,7 @@ def render_kubernetes(
|
||||
"annotations": {
|
||||
"govoplan.add-ideas.de/profile": "stateless-shared-state",
|
||||
"govoplan.add-ideas.de/secret-contract": ",".join(_SECRET_KEYS),
|
||||
"govoplan.add-ideas.de/s3-ca-secret": s3_ca_secret_name or "",
|
||||
"govoplan.add-ideas.de/database-connection-peak": str(
|
||||
database_capacity["peak"]
|
||||
),
|
||||
@@ -342,9 +357,13 @@ def _validate_cluster_profile(
|
||||
environment: Mapping[str, str],
|
||||
namespace: str,
|
||||
secret_name: str,
|
||||
*,
|
||||
s3_ca_secret_name: str | None,
|
||||
) -> 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 s3_ca_secret_name is not None and not _DNS_LABEL.fullmatch(s3_ca_secret_name):
|
||||
raise ValueError("Kubernetes S3 CA secret name must be a DNS label")
|
||||
if spec.installation_id == "govoplan-local":
|
||||
raise ValueError(
|
||||
"Kubernetes export requires a non-default stable installation id"
|
||||
@@ -633,6 +652,7 @@ def _deployment(
|
||||
command: tuple[str, ...],
|
||||
config_name: str | None,
|
||||
secret_name: str | None,
|
||||
s3_ca_secret_name: str | None,
|
||||
service_account: str,
|
||||
container_port: int | None = None,
|
||||
readiness_path: str | None = None,
|
||||
@@ -660,6 +680,10 @@ def _deployment(
|
||||
)
|
||||
if secret_name:
|
||||
environment.extend(_secret_environment(secret_name))
|
||||
if s3_ca_secret_name:
|
||||
environment.append(
|
||||
{"name": "AWS_CA_BUNDLE", "value": "/etc/govoplan/trust/s3-ca.crt"}
|
||||
)
|
||||
container: dict[str, Any] = {
|
||||
"name": role,
|
||||
"image": image,
|
||||
@@ -704,12 +728,16 @@ def _deployment(
|
||||
{
|
||||
"maxSkew": 1,
|
||||
"topologyKey": "kubernetes.io/hostname",
|
||||
"whenUnsatisfiable": "ScheduleAnyway",
|
||||
"whenUnsatisfiable": "DoNotSchedule",
|
||||
"matchLabelKeys": ["pod-template-hash"],
|
||||
"labelSelector": {"matchLabels": role_labels},
|
||||
}
|
||||
],
|
||||
}
|
||||
container["volumeMounts"] = [{"name": "tmp", "mountPath": "/tmp"}]
|
||||
if s3_ca_secret_name:
|
||||
pod_spec["volumes"].append(_s3_ca_volume(s3_ca_secret_name))
|
||||
container["volumeMounts"].append(_s3_ca_volume_mount())
|
||||
if config_name:
|
||||
pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}]
|
||||
if config_name and secret_name:
|
||||
@@ -731,6 +759,16 @@ def _deployment(
|
||||
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration-wait"},
|
||||
{"name": "GOVOPLAN_DB_POOL_SIZE", "value": "1"},
|
||||
{"name": "GOVOPLAN_DB_MAX_OVERFLOW", "value": "0"},
|
||||
*(
|
||||
[
|
||||
{
|
||||
"name": "AWS_CA_BUNDLE",
|
||||
"value": "/etc/govoplan/trust/s3-ca.crt",
|
||||
}
|
||||
]
|
||||
if s3_ca_secret_name
|
||||
else []
|
||||
),
|
||||
*_secret_environment(secret_name),
|
||||
],
|
||||
"securityContext": {
|
||||
@@ -738,7 +776,10 @@ def _deployment(
|
||||
"capabilities": {"drop": ["ALL"]},
|
||||
"readOnlyRootFilesystem": True,
|
||||
},
|
||||
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
|
||||
"volumeMounts": [
|
||||
{"name": "tmp", "mountPath": "/tmp"},
|
||||
*([_s3_ca_volume_mount()] if s3_ca_secret_name else []),
|
||||
],
|
||||
}
|
||||
]
|
||||
return {
|
||||
@@ -769,6 +810,7 @@ def _migration_job(
|
||||
image: str,
|
||||
config_name: str,
|
||||
secret_name: str,
|
||||
s3_ca_secret_name: str | None,
|
||||
service_account: str,
|
||||
database_environment: Mapping[str, str],
|
||||
backup_required: bool,
|
||||
@@ -832,6 +874,16 @@ def _migration_job(
|
||||
"env": [
|
||||
{"name": "TMPDIR", "value": "/tmp"},
|
||||
{"name": "GOVOPLAN_RUNTIME_ROLE", "value": "migration"},
|
||||
*(
|
||||
[
|
||||
{
|
||||
"name": "AWS_CA_BUNDLE",
|
||||
"value": "/etc/govoplan/trust/s3-ca.crt",
|
||||
}
|
||||
]
|
||||
if s3_ca_secret_name
|
||||
else []
|
||||
),
|
||||
*(
|
||||
{"name": key, "value": value}
|
||||
for key, value in sorted(
|
||||
@@ -851,10 +903,24 @@ def _migration_job(
|
||||
"capabilities": {"drop": ["ALL"]},
|
||||
"readOnlyRootFilesystem": True,
|
||||
},
|
||||
"volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}],
|
||||
"volumeMounts": [
|
||||
{"name": "tmp", "mountPath": "/tmp"},
|
||||
*(
|
||||
[_s3_ca_volume_mount()]
|
||||
if s3_ca_secret_name
|
||||
else []
|
||||
),
|
||||
],
|
||||
}
|
||||
],
|
||||
"volumes": [{"name": "tmp", "emptyDir": {}}],
|
||||
"volumes": [
|
||||
{"name": "tmp", "emptyDir": {}},
|
||||
*(
|
||||
[_s3_ca_volume(s3_ca_secret_name)]
|
||||
if s3_ca_secret_name
|
||||
else []
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -873,6 +939,24 @@ def _secret_environment(secret_name: str) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _s3_ca_volume(secret_name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": "s3-ca",
|
||||
"secret": {
|
||||
"secretName": secret_name,
|
||||
"items": [{"key": "ca.crt", "path": "s3-ca.crt"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _s3_ca_volume_mount() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "s3-ca",
|
||||
"mountPath": "/etc/govoplan/trust",
|
||||
"readOnly": True,
|
||||
}
|
||||
|
||||
|
||||
def _service(
|
||||
*,
|
||||
name: str,
|
||||
|
||||
Reference in New Issue
Block a user