650 lines
20 KiB
Python
650 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise a pinned GovOPlaN runtime image pair on one OCI platform."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
from datetime import UTC, datetime
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import time
|
|
from typing import Callable, Sequence
|
|
|
|
|
|
PLATFORMS = frozenset({"linux/amd64", "linux/arm64"})
|
|
DIGEST_IMAGE = re.compile(r"^[^\s@]+@sha256:[0-9a-f]{64}$")
|
|
BASE_MODULES = (
|
|
"tenancy",
|
|
"organizations",
|
|
"identity",
|
|
"idm",
|
|
"access",
|
|
"admin",
|
|
"dashboard",
|
|
"policy",
|
|
"audit",
|
|
"docs",
|
|
"ops",
|
|
)
|
|
|
|
|
|
class SmokeError(RuntimeError):
|
|
"""A runtime image failed its bounded acceptance drill."""
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--api-metadata", type=Path, required=True)
|
|
parser.add_argument("--web-metadata", type=Path, required=True)
|
|
parser.add_argument("--postgres-image", required=True)
|
|
parser.add_argument("--redis-image", required=True)
|
|
parser.add_argument("--platform", choices=sorted(PLATFORMS), required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--timeout-seconds", type=float, default=600.0)
|
|
return parser
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _digest_image(value: object, label: str) -> str:
|
|
if not isinstance(value, str) or DIGEST_IMAGE.fullmatch(value) is None:
|
|
raise SmokeError(f"{label} must be an exact sha256 image reference")
|
|
return value
|
|
|
|
|
|
def platform_image(path: Path, platform: str, label: str) -> str:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise SmokeError(f"cannot read {label} OCI metadata") from exc
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("platforms"), dict):
|
|
raise SmokeError(f"{label} OCI metadata has no platform map")
|
|
return _digest_image(payload["platforms"].get(platform), f"{label} {platform}")
|
|
|
|
|
|
def _tail(value: str, *, limit: int = 4000) -> str:
|
|
return value[-limit:].strip()
|
|
|
|
|
|
def _run(
|
|
arguments: Sequence[str],
|
|
*,
|
|
check: bool = True,
|
|
timeout: float = 600.0,
|
|
redactions: Sequence[str] = (),
|
|
) -> subprocess.CompletedProcess[str]:
|
|
try:
|
|
result = subprocess.run(
|
|
list(arguments),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise SmokeError(f"container command could not complete: {type(exc).__name__}") from exc
|
|
if check and result.returncode != 0:
|
|
detail = _tail(result.stderr or result.stdout or "no diagnostic output")
|
|
for secret in redactions:
|
|
if secret:
|
|
detail = detail.replace(secret, "[redacted]")
|
|
raise SmokeError(f"container command failed: {detail}")
|
|
return result
|
|
|
|
|
|
def _wait_for(
|
|
label: str,
|
|
probe: Callable[[], subprocess.CompletedProcess[str]],
|
|
*,
|
|
timeout: float,
|
|
container: str | None = None,
|
|
redactions: Sequence[str] = (),
|
|
) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
last = ""
|
|
while time.monotonic() < deadline:
|
|
if container is not None:
|
|
state = _run(
|
|
("docker", "inspect", "--format", "{{.State.Running}}", container),
|
|
check=False,
|
|
timeout=30,
|
|
)
|
|
if state.returncode != 0 or state.stdout.strip() != "true":
|
|
logs = _run(
|
|
("docker", "logs", "--tail", "100", container),
|
|
check=False,
|
|
timeout=30,
|
|
)
|
|
detail = _tail(logs.stdout + logs.stderr, limit=8000)
|
|
for secret in redactions:
|
|
if secret:
|
|
detail = detail.replace(secret, "[redacted]")
|
|
raise SmokeError(
|
|
f"{label} container exited before readiness: "
|
|
f"{detail or 'no diagnostic output'}"
|
|
)
|
|
result = probe()
|
|
if result.returncode == 0:
|
|
return
|
|
last = _tail(result.stderr or result.stdout)
|
|
time.sleep(2.0)
|
|
raise SmokeError(f"{label} did not become ready: {last or 'probe failed'}")
|
|
|
|
|
|
def _environment(
|
|
*,
|
|
database_password: str,
|
|
master_key: str,
|
|
platform_slug: str,
|
|
) -> dict[str, str]:
|
|
database = (
|
|
f"postgresql+psycopg://govoplan:{database_password}@postgres:5432/govoplan"
|
|
)
|
|
return {
|
|
"APP_ENV": "dev",
|
|
"GOVOPLAN_INSTALL_PROFILE": "evaluation",
|
|
"GOVOPLAN_INSTALLATION_ID": f"runtime-smoke-{platform_slug}",
|
|
"GOVOPLAN_STATE_PROFILE": "host-shared",
|
|
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS": "5",
|
|
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS": "30",
|
|
"GOVOPLAN_EXPECTED_API_REPLICAS": "1",
|
|
"GOVOPLAN_EXPECTED_WORKER_REPLICAS": "1",
|
|
"DATABASE_URL": database,
|
|
"GOVOPLAN_DATABASE_URL_PGTOOLS": (
|
|
f"postgresql://govoplan:{database_password}@postgres:5432/govoplan"
|
|
),
|
|
"GOVOPLAN_DB_CONNECTION_LIMIT": "100",
|
|
"GOVOPLAN_DB_CONNECTION_RESERVE": "10",
|
|
"REDIS_URL": "redis://redis:6379/0",
|
|
"CELERY_ENABLED": "true",
|
|
"CELERY_QUEUES": "default",
|
|
"CELERY_WORKER_CONCURRENCY": "1",
|
|
"ENABLED_MODULES": ",".join(BASE_MODULES),
|
|
"GOVOPLAN_MIGRATION_TRACK": "release",
|
|
"DEV_AUTO_MIGRATE_ENABLED": "false",
|
|
"DEV_BOOTSTRAP_ENABLED": "false",
|
|
"AUTH_LOGIN_THROTTLE_ENABLED": "true",
|
|
"AUTH_COOKIE_SECURE": "false",
|
|
"CORS_ORIGINS": "http://localhost",
|
|
"GOVOPLAN_TRUSTED_HOSTS": "127.0.0.1,localhost,api",
|
|
"FORWARDED_ALLOW_IPS": "127.0.0.1",
|
|
"MASTER_KEY_B64": master_key,
|
|
"FILE_STORAGE_BACKEND": "local",
|
|
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
|
|
"GOVOPLAN_MODULE_LIVE_APPLY_ENABLED": "false",
|
|
}
|
|
|
|
|
|
def _env_arguments(values: dict[str, str], *, role: str, node_id: str) -> list[str]:
|
|
arguments: list[str] = []
|
|
for key, value in sorted(
|
|
{**values, "GOVOPLAN_RUNTIME_ROLE": role, "GOVOPLAN_NODE_ID": node_id}.items()
|
|
):
|
|
arguments.extend(("--env", f"{key}={value}"))
|
|
return arguments
|
|
|
|
|
|
def run_smoke(
|
|
*,
|
|
api_image: str,
|
|
web_image: str,
|
|
postgres_image: str,
|
|
redis_image: str,
|
|
platform: str,
|
|
timeout: float,
|
|
) -> dict[str, object]:
|
|
for label, value in (
|
|
("API image", api_image),
|
|
("Web image", web_image),
|
|
("PostgreSQL image", postgres_image),
|
|
("Redis image", redis_image),
|
|
):
|
|
_digest_image(value, label)
|
|
if platform not in PLATFORMS:
|
|
raise SmokeError(f"unsupported runtime smoke platform: {platform}")
|
|
|
|
slug = platform.replace("linux/", "").replace("/", "-")
|
|
suffix = secrets.token_hex(4)
|
|
prefix = f"govoplan-runtime-{slug}-{suffix}"
|
|
names = {
|
|
"network": f"{prefix}-network",
|
|
"volume": f"{prefix}-data",
|
|
"postgres": f"{prefix}-postgres",
|
|
"redis": f"{prefix}-redis",
|
|
"api": f"{prefix}-api",
|
|
"web": f"{prefix}-web",
|
|
"worker": f"{prefix}-worker",
|
|
}
|
|
database_password = secrets.token_hex(20)
|
|
master_key = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii")
|
|
redactions = (database_password, master_key)
|
|
environment = _environment(
|
|
database_password=database_password,
|
|
master_key=master_key,
|
|
platform_slug=slug,
|
|
)
|
|
checks: list[dict[str, object]] = []
|
|
started = time.monotonic()
|
|
|
|
def record(check_id: str, began: float) -> None:
|
|
duration = round(time.monotonic() - began, 3)
|
|
checks.append(
|
|
{
|
|
"id": check_id,
|
|
"state": "passed",
|
|
"duration_seconds": duration,
|
|
}
|
|
)
|
|
print(f"PASS {platform} {check_id} ({duration}s)", flush=True)
|
|
|
|
common_runtime = [
|
|
"--platform",
|
|
platform,
|
|
"--network",
|
|
names["network"],
|
|
"--read-only",
|
|
"--tmpfs",
|
|
"/tmp:rw,noexec,nosuid,size=64m",
|
|
"--security-opt",
|
|
"no-new-privileges:true",
|
|
"--cap-drop",
|
|
"ALL",
|
|
"--mount",
|
|
f"type=volume,source={names['volume']},target=/var/lib/govoplan",
|
|
]
|
|
|
|
try:
|
|
_run(("docker", "network", "create", names["network"]), timeout=timeout)
|
|
_run(("docker", "volume", "create", names["volume"]), timeout=timeout)
|
|
|
|
began = time.monotonic()
|
|
_run(
|
|
(
|
|
"docker",
|
|
"run",
|
|
"--detach",
|
|
"--platform",
|
|
platform,
|
|
"--name",
|
|
names["postgres"],
|
|
"--network",
|
|
names["network"],
|
|
"--network-alias",
|
|
"postgres",
|
|
"--env",
|
|
"POSTGRES_DB=govoplan",
|
|
"--env",
|
|
"POSTGRES_USER=govoplan",
|
|
"--env",
|
|
f"POSTGRES_PASSWORD={database_password}",
|
|
"--tmpfs",
|
|
"/var/lib/postgresql/data:rw,noexec,nosuid,size=384m",
|
|
postgres_image,
|
|
),
|
|
timeout=timeout,
|
|
redactions=redactions,
|
|
)
|
|
_run(
|
|
(
|
|
"docker",
|
|
"run",
|
|
"--detach",
|
|
"--platform",
|
|
platform,
|
|
"--name",
|
|
names["redis"],
|
|
"--network",
|
|
names["network"],
|
|
"--network-alias",
|
|
"redis",
|
|
"--read-only",
|
|
"--tmpfs",
|
|
"/data:rw,noexec,nosuid,size=64m",
|
|
redis_image,
|
|
"redis-server",
|
|
"--save",
|
|
"",
|
|
"--appendonly",
|
|
"no",
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
_wait_for(
|
|
"PostgreSQL",
|
|
lambda: _run(
|
|
(
|
|
"docker",
|
|
"exec",
|
|
names["postgres"],
|
|
"pg_isready",
|
|
"--username",
|
|
"govoplan",
|
|
"--dbname",
|
|
"govoplan",
|
|
),
|
|
check=False,
|
|
timeout=30,
|
|
),
|
|
timeout=timeout,
|
|
container=names["postgres"],
|
|
redactions=redactions,
|
|
)
|
|
_wait_for(
|
|
"Redis",
|
|
lambda: _run(
|
|
("docker", "exec", names["redis"], "redis-cli", "ping"),
|
|
check=False,
|
|
timeout=30,
|
|
),
|
|
timeout=timeout,
|
|
container=names["redis"],
|
|
redactions=redactions,
|
|
)
|
|
record("managed_dependencies_ready", began)
|
|
|
|
began = time.monotonic()
|
|
_run(
|
|
(
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--name",
|
|
f"{prefix}-migrate",
|
|
*common_runtime,
|
|
*_env_arguments(
|
|
environment,
|
|
role="migration",
|
|
node_id=f"runtime-smoke-{slug}-migration",
|
|
),
|
|
api_image,
|
|
"python",
|
|
"-m",
|
|
"govoplan_core.commands.init_db",
|
|
"--migration-track",
|
|
"release",
|
|
),
|
|
timeout=timeout,
|
|
redactions=redactions,
|
|
)
|
|
record("release_migrations", began)
|
|
|
|
began = time.monotonic()
|
|
_run(
|
|
(
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--name",
|
|
f"{prefix}-schema",
|
|
*common_runtime,
|
|
*_env_arguments(
|
|
environment,
|
|
role="migration",
|
|
node_id=f"runtime-smoke-{slug}-schema",
|
|
),
|
|
api_image,
|
|
"python",
|
|
"-c",
|
|
(
|
|
"import os;"
|
|
"from sqlalchemy import create_engine,inspect;"
|
|
"engine=create_engine(os.environ['DATABASE_URL']);"
|
|
"tables=set(inspect(engine).get_table_names());"
|
|
"required={'alembic_version','core_scopes','core_system_settings',"
|
|
"'core_runtime_nodes'};"
|
|
"missing=required-tables;"
|
|
"assert not missing, f'missing release tables: {sorted(missing)}';"
|
|
"engine.dispose()"
|
|
),
|
|
),
|
|
timeout=timeout,
|
|
redactions=redactions,
|
|
)
|
|
record("release_schema_contract", began)
|
|
|
|
began = time.monotonic()
|
|
_run(
|
|
(
|
|
"docker",
|
|
"run",
|
|
"--detach",
|
|
"--name",
|
|
names["api"],
|
|
"--network-alias",
|
|
"api",
|
|
"--network-alias",
|
|
"load-balancer",
|
|
*common_runtime,
|
|
*_env_arguments(
|
|
environment,
|
|
role="api",
|
|
node_id=f"runtime-smoke-{slug}-api",
|
|
),
|
|
api_image,
|
|
),
|
|
timeout=timeout,
|
|
redactions=redactions,
|
|
)
|
|
_wait_for(
|
|
"GovOPlaN API",
|
|
lambda: _run(
|
|
(
|
|
"docker",
|
|
"exec",
|
|
names["api"],
|
|
"python",
|
|
"-c",
|
|
(
|
|
"import urllib.request;"
|
|
"r=urllib.request.Request('http://127.0.0.1:8000/health/ready',"
|
|
"headers={'Host':'127.0.0.1'});"
|
|
"assert urllib.request.urlopen(r,timeout=3).status==200"
|
|
),
|
|
),
|
|
check=False,
|
|
timeout=30,
|
|
),
|
|
timeout=timeout,
|
|
container=names["api"],
|
|
redactions=redactions,
|
|
)
|
|
_run(
|
|
(
|
|
"docker",
|
|
"exec",
|
|
names["api"],
|
|
"python",
|
|
"-c",
|
|
"import os; assert os.getuid() == 10001",
|
|
),
|
|
timeout=30,
|
|
)
|
|
record("api_non_root_readiness", began)
|
|
|
|
began = time.monotonic()
|
|
_run(
|
|
(
|
|
"docker",
|
|
"run",
|
|
"--detach",
|
|
"--platform",
|
|
platform,
|
|
"--name",
|
|
names["web"],
|
|
"--network",
|
|
names["network"],
|
|
"--network-alias",
|
|
"web",
|
|
"--read-only",
|
|
"--tmpfs",
|
|
"/tmp:rw,noexec,nosuid,size=64m",
|
|
"--security-opt",
|
|
"no-new-privileges:true",
|
|
"--cap-drop",
|
|
"ALL",
|
|
web_image,
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
_wait_for(
|
|
"GovOPlaN WebUI",
|
|
lambda: _run(
|
|
(
|
|
"docker",
|
|
"exec",
|
|
names["api"],
|
|
"python",
|
|
"-c",
|
|
(
|
|
"import urllib.request;"
|
|
"assert urllib.request.urlopen('http://web:8080/health',timeout=3).status==200;"
|
|
"assert urllib.request.urlopen('http://web:8080/',timeout=3).status==200"
|
|
),
|
|
),
|
|
check=False,
|
|
timeout=30,
|
|
),
|
|
timeout=timeout,
|
|
container=names["web"],
|
|
redactions=redactions,
|
|
)
|
|
_run(
|
|
("docker", "exec", names["web"], "sh", "-c", "test \"$(id -u)\" = 101"),
|
|
timeout=30,
|
|
)
|
|
record("web_non_root_readiness", began)
|
|
|
|
began = time.monotonic()
|
|
_run(
|
|
(
|
|
"docker",
|
|
"run",
|
|
"--detach",
|
|
"--name",
|
|
names["worker"],
|
|
*common_runtime,
|
|
*_env_arguments(
|
|
environment,
|
|
role="worker",
|
|
node_id=f"runtime-smoke-{slug}-worker",
|
|
),
|
|
api_image,
|
|
"python",
|
|
"-m",
|
|
"celery",
|
|
"-A",
|
|
"govoplan_core.celery_app:celery",
|
|
"worker",
|
|
"--queues",
|
|
"default",
|
|
"--pool",
|
|
"solo",
|
|
"--concurrency",
|
|
"1",
|
|
"--hostname",
|
|
f"runtime-smoke-{slug}@%h",
|
|
"--loglevel",
|
|
"WARNING",
|
|
),
|
|
timeout=timeout,
|
|
redactions=redactions,
|
|
)
|
|
_wait_for(
|
|
"GovOPlaN worker",
|
|
lambda: _run(
|
|
(
|
|
"docker",
|
|
"exec",
|
|
names["api"],
|
|
"python",
|
|
"-c",
|
|
(
|
|
"from govoplan_core.celery_app import celery;"
|
|
"result=celery.send_task('govoplan.ping',queue='default');"
|
|
"assert result.get(timeout=10)=='pong'"
|
|
),
|
|
),
|
|
check=False,
|
|
timeout=30,
|
|
),
|
|
timeout=timeout,
|
|
container=names["worker"],
|
|
redactions=redactions,
|
|
)
|
|
_run(("docker", "stop", "--time", "20", names["worker"]), timeout=30)
|
|
record("worker_delivery_and_shutdown", began)
|
|
except SmokeError as exc:
|
|
for role in ("api", "web", "worker", "postgres", "redis"):
|
|
result = _run(
|
|
("docker", "logs", "--tail", "100", names[role]),
|
|
check=False,
|
|
timeout=30,
|
|
)
|
|
if result.stdout or result.stderr:
|
|
detail = _tail(result.stdout + result.stderr, limit=8000)
|
|
for secret in redactions:
|
|
detail = detail.replace(secret, "[redacted]")
|
|
print(f"--- {role} logs ---\n{detail}")
|
|
raise exc
|
|
finally:
|
|
for role in ("worker", "web", "api", "redis", "postgres"):
|
|
_run(
|
|
("docker", "rm", "--force", names[role]),
|
|
check=False,
|
|
timeout=30,
|
|
)
|
|
_run(("docker", "volume", "rm", "--force", names["volume"]), check=False)
|
|
_run(("docker", "network", "rm", names["network"]), check=False)
|
|
|
|
return {
|
|
"schema_version": "1",
|
|
"evidence_kind": "govoplan.runtime-image-smoke",
|
|
"captured_at": _utc_now(),
|
|
"platform": platform,
|
|
"images": {
|
|
"api": api_image,
|
|
"web": web_image,
|
|
"postgres": postgres_image,
|
|
"redis": redis_image,
|
|
},
|
|
"result": {"state": "passed"},
|
|
"checks": checks,
|
|
"duration_seconds": round(time.monotonic() - started, 3),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
try:
|
|
api_image = platform_image(args.api_metadata, args.platform, "API")
|
|
web_image = platform_image(args.web_metadata, args.platform, "Web")
|
|
evidence = run_smoke(
|
|
api_image=api_image,
|
|
web_image=web_image,
|
|
postgres_image=args.postgres_image,
|
|
redis_image=args.redis_image,
|
|
platform=args.platform,
|
|
timeout=args.timeout_seconds,
|
|
)
|
|
except (OSError, SmokeError, ValueError) as exc:
|
|
print(f"runtime image smoke failed: {exc}")
|
|
return 1
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
|
|
temporary.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
temporary.chmod(0o644)
|
|
temporary.replace(args.output)
|
|
print(f"Runtime image smoke evidence written to {args.output}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|