feat: implement institutional governance and recovery architecture
This commit is contained in:
@@ -31,6 +31,7 @@ from govoplan_deploy.bundle import ( # noqa: E402
|
||||
)
|
||||
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
|
||||
import govoplan_deploy.cli as deployment_cli # noqa: E402
|
||||
from govoplan_deploy.kubernetes import render_kubernetes # noqa: E402
|
||||
from govoplan_deploy.model import ( # noqa: E402
|
||||
SpecError,
|
||||
default_spec,
|
||||
@@ -38,6 +39,12 @@ from govoplan_deploy.model import ( # noqa: E402
|
||||
)
|
||||
from govoplan_deploy.planning import _endpoint_check, build_plan # noqa: E402
|
||||
import govoplan_deploy.planning as deployment_planning # noqa: E402
|
||||
from govoplan_deploy.recovery import ( # noqa: E402
|
||||
DeploymentOperationJournal,
|
||||
load_operation,
|
||||
recover_operation,
|
||||
write_applied_state,
|
||||
)
|
||||
|
||||
|
||||
def run_cli(arguments: list[str]) -> tuple[int, str, str]:
|
||||
@@ -49,6 +56,242 @@ def run_cli(arguments: list[str]) -> tuple[int, str, str]:
|
||||
|
||||
|
||||
class DeploymentInstallerTests(unittest.TestCase):
|
||||
def test_pre_migration_failure_restores_checksum_verified_applied_bundle(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
previous_environment = initial_secrets(spec)
|
||||
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||
write_env(paths.env, previous_environment)
|
||||
atomic_write(
|
||||
paths.compose, canonical_json(render_compose(spec)), mode=0o600
|
||||
)
|
||||
atomic_write(
|
||||
paths.receipt, canonical_json({"spec_sha256": "old"}), mode=0o600
|
||||
)
|
||||
write_applied_state(paths)
|
||||
|
||||
atomic_write(paths.spec, canonical_json({"changed": True}), mode=0o600)
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
journal.failed(RuntimeError("pull failed"))
|
||||
|
||||
result = recover_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
self.assertEqual("configuration_restored", result.action)
|
||||
self.assertEqual(spec.to_dict(), json.loads(paths.spec.read_text()))
|
||||
self.assertEqual(previous_environment, read_env(paths.env))
|
||||
|
||||
def test_post_migration_failure_requires_forward_recovery(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||
write_env(paths.env, initial_secrets(spec))
|
||||
atomic_write(
|
||||
paths.compose, canonical_json(render_compose(spec)), mode=0o600
|
||||
)
|
||||
write_applied_state(paths)
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
journal.migration_started()
|
||||
journal.failed(RuntimeError("health failed"))
|
||||
|
||||
result = recover_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
self.assertEqual("forward_recovery", result.action)
|
||||
self.assertIn("not restored", result.detail)
|
||||
|
||||
def test_recovery_refuses_tampered_migration_boundary(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
journal.migration_started()
|
||||
payload = json.loads(journal.path.joinpath("operation.json").read_text())
|
||||
payload["migration_started"] = False
|
||||
atomic_write(
|
||||
journal.path / "operation.json",
|
||||
canonical_json(payload),
|
||||
mode=0o600,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "migration boundary"):
|
||||
load_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
def test_recovery_verifies_complete_snapshot_before_mutating_bundle(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||
write_env(paths.env, initial_secrets(spec))
|
||||
atomic_write(
|
||||
paths.compose, canonical_json(render_compose(spec)), mode=0o600
|
||||
)
|
||||
write_applied_state(paths)
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
changed_spec = canonical_json({"changed": True})
|
||||
atomic_write(paths.spec, changed_spec, mode=0o600)
|
||||
atomic_write(
|
||||
journal.path / "before" / "compose.json", b"tampered", mode=0o600
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "checksum failed"):
|
||||
recover_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
self.assertEqual(changed_spec, paths.spec.read_bytes())
|
||||
|
||||
def test_recovery_refuses_tampered_desired_plan(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-deploy-recovery-"
|
||||
) as directory:
|
||||
paths = bundle_paths(Path(directory))
|
||||
paths.root.chmod(0o700)
|
||||
spec = default_spec()
|
||||
journal = DeploymentOperationJournal.begin(
|
||||
paths,
|
||||
plan={"installation_id": spec.installation_id},
|
||||
)
|
||||
payload = json.loads(journal.path.joinpath("operation.json").read_text())
|
||||
payload["desired"]["installation_id"] = "other-installation"
|
||||
atomic_write(
|
||||
journal.path / "operation.json",
|
||||
canonical_json(payload),
|
||||
mode=0o600,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "plan"):
|
||||
load_operation(paths, operation_id=journal.operation_id)
|
||||
|
||||
def test_kubernetes_export_is_stateless_and_uses_exact_secret_contract(
|
||||
self,
|
||||
) -> None:
|
||||
spec = default_spec(
|
||||
installation_id="govoplan-cluster",
|
||||
postgres_mode="external",
|
||||
redis_mode="external",
|
||||
storage_mode="s3",
|
||||
api_replicas=3,
|
||||
web_replicas=2,
|
||||
worker_replicas=4,
|
||||
api_image="registry.example.test/govoplan-api@sha256:" + "a" * 64,
|
||||
web_image="registry.example.test/govoplan-web@sha256:" + "b" * 64,
|
||||
)
|
||||
environment = initial_secrets(
|
||||
spec,
|
||||
supplied={
|
||||
"DATABASE_URL": "postgresql+psycopg://user:db-secret@postgres.example.test/govoplan",
|
||||
"GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://user:db-secret@postgres.example.test/govoplan",
|
||||
"REDIS_URL": "rediss://:redis-secret@redis.example.test/0",
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
|
||||
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
|
||||
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||
},
|
||||
)
|
||||
|
||||
manifest = render_kubernetes(spec, environment)
|
||||
rendered = json.dumps(manifest, sort_keys=True)
|
||||
kinds = [item["kind"] for item in manifest["items"]]
|
||||
deployments = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in manifest["items"]
|
||||
if item["kind"] == "Deployment"
|
||||
}
|
||||
|
||||
self.assertNotIn("db-secret", rendered)
|
||||
self.assertNotIn("redis-secret", rendered)
|
||||
self.assertNotIn("object-secret", rendered)
|
||||
self.assertNotIn("PersistentVolumeClaim", kinds)
|
||||
self.assertNotIn("StatefulSet", kinds)
|
||||
self.assertEqual(3, deployments["govoplan-cluster-api"]["spec"]["replicas"])
|
||||
self.assertEqual(4, deployments["govoplan-cluster-worker"]["spec"]["replicas"])
|
||||
scheduler_command = deployments["govoplan-cluster-scheduler"]["spec"][
|
||||
"template"
|
||||
]["spec"]["containers"][0]["command"]
|
||||
self.assertIn("govoplan_core.commands.fenced_run", scheduler_command)
|
||||
api_init_command = deployments["govoplan-cluster-api"]["spec"]["template"][
|
||||
"spec"
|
||||
]["initContainers"][0]["command"]
|
||||
self.assertIn("govoplan_core.commands.wait_for_database", api_init_command)
|
||||
migration = next(item for item in manifest["items"] if item["kind"] == "Job")
|
||||
self.assertRegex(
|
||||
migration["metadata"]["name"],
|
||||
r"^govoplan-cluster-migrate-[0-9a-f]{8}$",
|
||||
)
|
||||
self.assertEqual(
|
||||
"forward-recovery",
|
||||
migration["metadata"]["annotations"]["govoplan.add-ideas.de/recovery-mode"],
|
||||
)
|
||||
config = next(item for item in manifest["items"] if item["kind"] == "ConfigMap")
|
||||
self.assertEqual("shared", config["data"]["GOVOPLAN_STATE_PROFILE"])
|
||||
self.assertEqual("3", config["data"]["GOVOPLAN_EXPECTED_API_REPLICAS"])
|
||||
self.assertEqual(
|
||||
[{"name": "Host", "value": "127.0.0.1"}],
|
||||
deployments["govoplan-cluster-api"]["spec"]["template"]["spec"][
|
||||
"containers"
|
||||
][0]["readinessProbe"]["httpGet"]["httpHeaders"],
|
||||
)
|
||||
|
||||
def test_kubernetes_export_rejects_mutable_release_images(self) -> None:
|
||||
spec = default_spec(
|
||||
installation_id="govoplan-cluster",
|
||||
postgres_mode="external",
|
||||
redis_mode="external",
|
||||
storage_mode="s3",
|
||||
api_image="registry.example.test/govoplan-api:latest",
|
||||
web_image="registry.example.test/govoplan-web:latest",
|
||||
)
|
||||
environment = initial_secrets(
|
||||
spec,
|
||||
supplied={
|
||||
"DATABASE_URL": "postgresql+psycopg://user:secret@postgres.example.test/govoplan",
|
||||
"GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://user:secret@postgres.example.test/govoplan",
|
||||
"REDIS_URL": "rediss://:secret@redis.example.test/0",
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
|
||||
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
|
||||
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "digest-pinned"):
|
||||
render_kubernetes(spec, environment)
|
||||
|
||||
def test_kubernetes_export_rejects_installer_managed_state_services(self) -> None:
|
||||
spec = default_spec(
|
||||
installation_id="govoplan-cluster",
|
||||
storage_mode="garage",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "external shared PostgreSQL"):
|
||||
render_kubernetes(spec, initial_secrets(spec))
|
||||
|
||||
def test_default_bundle_has_base_modules_and_managed_dependencies(self) -> None:
|
||||
spec = default_spec()
|
||||
compose = render_compose(spec)
|
||||
@@ -80,6 +323,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
self.assertNotIn("scheduler", compose["services"])
|
||||
self.assertEqual("false", values["CELERY_ENABLED"])
|
||||
self.assertEqual("true", values["GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE"])
|
||||
self.assertEqual("local", values["GOVOPLAN_STATE_PROFILE"])
|
||||
|
||||
def test_self_hosted_rejects_insecure_or_test_only_choices(self) -> None:
|
||||
with self.assertRaisesRegex(SpecError, "must use HTTPS"):
|
||||
@@ -154,7 +398,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual("rediss://:secret@redis.example.test/0", values["REDIS_URL"])
|
||||
|
||||
def test_s3_requires_complete_private_configuration_and_https_in_production(
|
||||
def test_s3_requires_complete_private_configuration_and_https_endpoint(
|
||||
self,
|
||||
) -> None:
|
||||
evaluation = default_spec(storage_mode="s3")
|
||||
@@ -168,17 +412,21 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "secret",
|
||||
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||
}
|
||||
self.assertEqual(
|
||||
"s3",
|
||||
initial_secrets(evaluation, supplied=supplied)["FILE_STORAGE_BACKEND"],
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "must use HTTPS"):
|
||||
initial_secrets(evaluation, supplied=supplied)
|
||||
supplied["FILE_STORAGE_S3_ENDPOINT_URL"] = "https://s3.example.test"
|
||||
values = initial_secrets(evaluation, supplied=supplied)
|
||||
self.assertEqual("s3", values["FILE_STORAGE_BACKEND"])
|
||||
self.assertEqual("true", values["FILE_STORAGE_S3_ENDPOINT_TRUSTED"])
|
||||
production = default_spec(
|
||||
profile="self-hosted",
|
||||
public_url="https://govoplan.example.test",
|
||||
storage_mode="s3",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "must use HTTPS"):
|
||||
initial_secrets(production, supplied=supplied)
|
||||
self.assertEqual(
|
||||
"s3",
|
||||
initial_secrets(production, supplied=supplied)["FILE_STORAGE_BACKEND"],
|
||||
)
|
||||
|
||||
def test_managed_garage_bootstraps_private_s3_storage(self) -> None:
|
||||
spec = default_spec(storage_mode="garage")
|
||||
@@ -187,6 +435,7 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual("s3", values["FILE_STORAGE_BACKEND"])
|
||||
self.assertEqual("true", values["FILE_STORAGE_S3_DEPLOYMENT_MANAGED"])
|
||||
self.assertEqual("false", values["FILE_STORAGE_S3_ENDPOINT_TRUSTED"])
|
||||
self.assertEqual(
|
||||
"http://garage:3900",
|
||||
values["FILE_STORAGE_S3_ENDPOINT_URL"],
|
||||
@@ -244,6 +493,16 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("server-template web- 2 web:8080", config)
|
||||
self.assertIn("server-template api- 3 api:8000", config)
|
||||
self.assertIn("option httpchk GET /health/ready", config)
|
||||
self.assertIn("http-check send hdr Host 127.0.0.1", config)
|
||||
self.assertIn(
|
||||
"headers={'Host': '127.0.0.1'}",
|
||||
compose["services"]["api"]["healthcheck"]["test"][-1],
|
||||
)
|
||||
self.assertEqual(
|
||||
"host-shared",
|
||||
initial_secrets(spec)["GOVOPLAN_STATE_PROFILE"],
|
||||
)
|
||||
|
||||
def test_multi_replica_runtime_requires_shared_redis(self) -> None:
|
||||
with self.assertRaisesRegex(SpecError, "multiple API replicas"):
|
||||
@@ -725,11 +984,17 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
for index, command in enumerate(commands)
|
||||
if "--remove-orphans" in command
|
||||
)
|
||||
stop_index = next(
|
||||
index
|
||||
for index, command in enumerate(commands)
|
||||
if "stop" in command and "--timeout" in command
|
||||
)
|
||||
self.assertLess(stop_index, migrate_index)
|
||||
self.assertLess(migrate_index, runtime_index)
|
||||
self.assertIn("postgres", commands[0])
|
||||
self.assertIn("redis", commands[0])
|
||||
wait_for_health.assert_called_once_with(
|
||||
"http://127.0.0.1:8080/health",
|
||||
"http://127.0.0.1:8080/health/ready",
|
||||
timeout_seconds=120.0,
|
||||
)
|
||||
receipt = json.loads((root / "receipt.json").read_text(encoding="utf-8"))
|
||||
|
||||
Reference in New Issue
Block a user