Release v0.1.16
This commit is contained in:
@@ -158,6 +158,81 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
evidence["snapshot"]["ready_node_names"],
|
||||
)
|
||||
|
||||
def test_kubernetes_api_loss_uses_a_non_json_mutation_command(self) -> None:
|
||||
initial_pods = [
|
||||
_kubernetes_test_pod("api-a", "api", "node-a"),
|
||||
_kubernetes_test_pod("api-b", "api", "node-b"),
|
||||
_kubernetes_test_pod("web-a", "web", "node-a"),
|
||||
_kubernetes_test_pod("web-b", "web", "node-b"),
|
||||
]
|
||||
replacement_pods = [
|
||||
_kubernetes_test_pod("api-b", "api", "node-b"),
|
||||
_kubernetes_test_pod("api-c", "api", "node-a"),
|
||||
]
|
||||
deleted = False
|
||||
actions: list[tuple[str, ...]] = []
|
||||
|
||||
def run(arguments):
|
||||
if "nodes" in arguments:
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {"name": name},
|
||||
"spec": {},
|
||||
"status": {
|
||||
"conditions": [
|
||||
{"type": "Ready", "status": "True"}
|
||||
]
|
||||
},
|
||||
}
|
||||
for name in ("node-a", "node-b")
|
||||
]
|
||||
}
|
||||
if "deployments" in arguments:
|
||||
return {
|
||||
"items": [
|
||||
_kubernetes_test_deployment("api", 2),
|
||||
_kubernetes_test_deployment("web", 2),
|
||||
]
|
||||
}
|
||||
return {"items": replacement_pods if deleted else initial_pods}
|
||||
|
||||
def act(arguments):
|
||||
nonlocal deleted
|
||||
actions.append(tuple(arguments))
|
||||
deleted = True
|
||||
|
||||
evidence = collect_kubernetes_evidence(
|
||||
installation_id="govoplan-cluster",
|
||||
namespace="govoplan",
|
||||
ops_url="https://govoplan.example.test/api/v1/ops/status",
|
||||
api_key="not-retained",
|
||||
exercise_api_pod_loss=True,
|
||||
command_runner=run,
|
||||
action_runner=act,
|
||||
json_fetcher=lambda _url, _key: {
|
||||
"readiness": {"ready": True},
|
||||
"runtime_cluster": {
|
||||
"composition": {"skewed": False},
|
||||
"software_versions": {"skewed": False},
|
||||
"queues": {"missing": []},
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"id": "database_capacity",
|
||||
"state": "ok",
|
||||
"detail": "Within budget",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("passed", evidence["api_pod_loss"]["state"])
|
||||
self.assertEqual(1, len(actions))
|
||||
self.assertIn("delete", actions[0])
|
||||
self.assertNotIn("-o", actions[0])
|
||||
self.assertNotIn("not-retained", json.dumps(evidence))
|
||||
|
||||
def test_pre_migration_failure_restores_checksum_verified_applied_bundle(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -347,6 +422,10 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
"template"
|
||||
]["spec"]["containers"][0]["command"]
|
||||
self.assertIn("govoplan_core.commands.fenced_run", scheduler_command)
|
||||
self.assertEqual(
|
||||
["--schedule", "/tmp/celerybeat-schedule"],
|
||||
scheduler_command[-2:],
|
||||
)
|
||||
api_init_command = deployments["govoplan-cluster-api"]["spec"]["template"][
|
||||
"spec"
|
||||
]["initContainers"][0]["command"]
|
||||
@@ -385,6 +464,12 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
"spec"
|
||||
]["containers"][0]["command"]
|
||||
self.assertIn("--concurrency", worker_command)
|
||||
for deployment in deployments.values():
|
||||
spread = deployment["spec"]["template"]["spec"][
|
||||
"topologySpreadConstraints"
|
||||
][0]
|
||||
self.assertEqual("DoNotSchedule", spread["whenUnsatisfiable"])
|
||||
self.assertEqual(["pod-template-hash"], spread["matchLabelKeys"])
|
||||
self.assertEqual(
|
||||
"62",
|
||||
manifest["metadata"]["annotations"][
|
||||
@@ -392,6 +477,99 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_kubernetes_export_mounts_an_optional_s3_ca_on_backend_roles(
|
||||
self,
|
||||
) -> None:
|
||||
spec = default_spec(
|
||||
installation_id="govoplan-cluster",
|
||||
postgres_mode="external",
|
||||
redis_mode="external",
|
||||
storage_mode="s3",
|
||||
api_replicas=2,
|
||||
web_replicas=2,
|
||||
worker_replicas=2,
|
||||
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: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",
|
||||
"GOVOPLAN_DB_CONNECTION_LIMIT": "100",
|
||||
},
|
||||
)
|
||||
|
||||
manifest = render_kubernetes(
|
||||
spec,
|
||||
environment,
|
||||
s3_ca_secret_name="govoplan-s3-ca",
|
||||
backup_required=False,
|
||||
)
|
||||
backend_pods = [
|
||||
item["spec"]["template"]["spec"]
|
||||
for item in manifest["items"]
|
||||
if item["kind"] in {"Deployment", "Job"}
|
||||
and item["metadata"]["labels"].get("app.kubernetes.io/component")
|
||||
in {"api", "worker", "scheduler", "migration"}
|
||||
]
|
||||
web = next(
|
||||
item
|
||||
for item in manifest["items"]
|
||||
if item["kind"] == "Deployment"
|
||||
and item["metadata"]["labels"].get("app.kubernetes.io/component")
|
||||
== "web"
|
||||
)
|
||||
|
||||
self.assertTrue(backend_pods)
|
||||
for pod in backend_pods:
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "s3-ca",
|
||||
"secret": {
|
||||
"secretName": "govoplan-s3-ca",
|
||||
"items": [{"key": "ca.crt", "path": "s3-ca.crt"}],
|
||||
},
|
||||
},
|
||||
pod["volumes"],
|
||||
)
|
||||
for container in [*pod.get("initContainers", []), *pod["containers"]]:
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "AWS_CA_BUNDLE",
|
||||
"value": "/etc/govoplan/trust/s3-ca.crt",
|
||||
},
|
||||
container["env"],
|
||||
)
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "s3-ca",
|
||||
"mountPath": "/etc/govoplan/trust",
|
||||
"readOnly": True,
|
||||
},
|
||||
container["volumeMounts"],
|
||||
)
|
||||
self.assertNotIn(
|
||||
"s3-ca",
|
||||
{
|
||||
volume["name"]
|
||||
for volume in web["spec"]["template"]["spec"]["volumes"]
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "S3 CA secret"):
|
||||
render_kubernetes(
|
||||
spec,
|
||||
environment,
|
||||
s3_ca_secret_name="INVALID_NAME",
|
||||
backup_required=False,
|
||||
)
|
||||
|
||||
def test_kubernetes_export_splits_worker_queues_and_rejects_capacity_overrun(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
LAB_TOOLS = META_ROOT / "tools" / "lab"
|
||||
if str(LAB_TOOLS) not in sys.path:
|
||||
sys.path.insert(0, str(LAB_TOOLS))
|
||||
|
||||
from govoplan_lab.cli import main # noqa: E402
|
||||
from govoplan_lab.config import LabConfigError, load_config # noqa: E402
|
||||
from govoplan_lab.lifecycle import ( # noqa: E402
|
||||
CommandRunner,
|
||||
LabOperationError,
|
||||
_assert_domain_owned,
|
||||
_domain_description,
|
||||
_render_kubectl_wrapper,
|
||||
destroy,
|
||||
)
|
||||
from govoplan_lab.render import ( # noqa: E402
|
||||
render_k3s_config,
|
||||
render_registry_config,
|
||||
render_state_compose,
|
||||
write_private,
|
||||
)
|
||||
|
||||
|
||||
REHEARSAL_CONFIG = LAB_TOOLS / "govoplan-lab.example.toml"
|
||||
ACCEPTANCE_CONFIG = LAB_TOOLS / "govoplan-lab.acceptance.example.toml"
|
||||
|
||||
|
||||
class KubernetesLabTests(unittest.TestCase):
|
||||
def test_example_inventories_describe_their_evidence_boundary(self) -> None:
|
||||
rehearsal = load_config(REHEARSAL_CONFIG)
|
||||
acceptance = load_config(ACCEPTANCE_CONFIG)
|
||||
|
||||
self.assertEqual("rehearsal", rehearsal.mode)
|
||||
self.assertFalse(rehearsal.evidence_capable)
|
||||
self.assertEqual(2, len(rehearsal.workers))
|
||||
self.assertEqual("acceptance", acceptance.mode)
|
||||
self.assertTrue(acceptance.evidence_capable)
|
||||
self.assertEqual(3, len({node.hypervisor for node in acceptance.nodes}))
|
||||
self.assertEqual(3, len({node.failure_domain for node in acceptance.nodes}))
|
||||
|
||||
def test_acceptance_inventory_rejects_collapsed_worker_failure_domains(self) -> None:
|
||||
source = ACCEPTANCE_CONFIG.read_text(encoding="utf-8")
|
||||
collapsed = source.replace(
|
||||
'hypervisor = "lab-admin@hypervisor-b.example.org"',
|
||||
'hypervisor = "lab-admin@hypervisor-a.example.org"',
|
||||
).replace('failure_domain = "rack-b"', 'failure_domain = "rack-a"')
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-lab-config-") as directory:
|
||||
path = Path(directory) / "lab.toml"
|
||||
path.write_text(collapsed, encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(LabConfigError, "acceptance mode"):
|
||||
load_config(path)
|
||||
|
||||
def test_create_without_apply_is_a_non_mutating_preview(self) -> None:
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
exit_code = main(["--config", str(REHEARSAL_CONFIG), "create"])
|
||||
|
||||
self.assertEqual(0, exit_code)
|
||||
self.assertIn("Dry run: create", output.getvalue())
|
||||
self.assertIn("Re-run with --apply", output.getvalue())
|
||||
|
||||
def test_local_hypervisor_uses_system_libvirt_without_sudo(self) -> None:
|
||||
config = load_config(REHEARSAL_CONFIG)
|
||||
runner = CommandRunner(config)
|
||||
runner.run = MagicMock(
|
||||
return_value=subprocess.CompletedProcess([], 0, stdout=b"", stderr=b"")
|
||||
)
|
||||
|
||||
runner.hypervisor(config.nodes[0], ["virsh", "dominfo", "test-domain"])
|
||||
|
||||
runner.run.assert_called_once_with(
|
||||
[
|
||||
"virsh",
|
||||
"--connect",
|
||||
"qemu:///system",
|
||||
"dominfo",
|
||||
"test-domain",
|
||||
],
|
||||
capture=False,
|
||||
check=True,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
def test_local_hypervisor_file_operations_do_not_use_sudo(self) -> None:
|
||||
config = load_config(REHEARSAL_CONFIG)
|
||||
runner = CommandRunner(config)
|
||||
runner.run = MagicMock(
|
||||
return_value=subprocess.CompletedProcess([], 0, stdout=b"", stderr=b"")
|
||||
)
|
||||
|
||||
runner.hypervisor(config.nodes[0], ["install", "-d", "/tmp/lab"])
|
||||
|
||||
runner.run.assert_called_once_with(
|
||||
["install", "-d", "/tmp/lab"],
|
||||
capture=False,
|
||||
check=True,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
def test_kubectl_wrapper_quotes_remote_arguments(self) -> None:
|
||||
wrapper = _render_kubectl_wrapper(
|
||||
["ssh", "-i", "/tmp/lab key", "govoplan@example.test"]
|
||||
)
|
||||
|
||||
self.assertIn("shlex.join(_REMOTE)", wrapper)
|
||||
self.assertIn('["sudo", "--", "k3s", "kubectl", *sys.argv[1:]]', wrapper)
|
||||
self.assertNotIn('kubectl \"$@\"', wrapper)
|
||||
|
||||
def test_destroy_requires_the_exact_lab_name(self) -> None:
|
||||
config = load_config(REHEARSAL_CONFIG)
|
||||
|
||||
with self.assertRaisesRegex(LabOperationError, "--confirm"):
|
||||
destroy(
|
||||
config,
|
||||
apply=True,
|
||||
confirmation="wrong-lab",
|
||||
purge_local_state=False,
|
||||
)
|
||||
|
||||
def test_enroll_admin_without_apply_is_a_non_mutating_preview(self) -> None:
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
exit_code = main(
|
||||
[
|
||||
"--config",
|
||||
str(REHEARSAL_CONFIG),
|
||||
"enroll-admin",
|
||||
"--email",
|
||||
"owner@example.test",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(0, exit_code)
|
||||
self.assertIn("Dry run: enroll-admin", output.getvalue())
|
||||
self.assertIn("owner@example.test", output.getvalue())
|
||||
|
||||
def test_domain_ownership_requires_marker_and_expected_disks(self) -> None:
|
||||
config = load_config(REHEARSAL_CONFIG)
|
||||
node = config.nodes[0]
|
||||
node_directory = f"{config.vm_image_directory}/{config.name}/{node.name}"
|
||||
runner = MagicMock()
|
||||
runner.hypervisor.side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
[],
|
||||
0,
|
||||
stdout=(_domain_description(config, node) + "\n").encode(),
|
||||
stderr=b"",
|
||||
),
|
||||
subprocess.CompletedProcess(
|
||||
[],
|
||||
0,
|
||||
stdout=(
|
||||
f"file disk vda {node_directory}/root.qcow2\n"
|
||||
f"file cdrom sda {node_directory}/seed.img\n"
|
||||
).encode(),
|
||||
stderr=b"",
|
||||
),
|
||||
]
|
||||
|
||||
_assert_domain_owned(config, runner, node)
|
||||
|
||||
runner.hypervisor.side_effect = [
|
||||
subprocess.CompletedProcess(
|
||||
[],
|
||||
0,
|
||||
stdout=b"unrelated domain\n",
|
||||
stderr=b"",
|
||||
)
|
||||
]
|
||||
with self.assertRaisesRegex(LabOperationError, "ownership marker"):
|
||||
_assert_domain_owned(config, runner, node)
|
||||
|
||||
def test_state_compose_uses_only_supplied_pinned_images(self) -> None:
|
||||
names = ("postgres", "redis", "garage", "managed_ingress", "test_mail")
|
||||
images = {
|
||||
name: f"registry.example.test/{name}@sha256:{index:064x}"
|
||||
for index, name in enumerate(names, start=1)
|
||||
}
|
||||
|
||||
compose = json.loads(render_state_compose(images))
|
||||
|
||||
self.assertEqual(images["postgres"], compose["services"]["postgres"]["image"])
|
||||
self.assertEqual(images["redis"], compose["services"]["redis"]["image"])
|
||||
self.assertEqual(images["garage"], compose["services"]["garage"]["image"])
|
||||
self.assertEqual(
|
||||
images["managed_ingress"], compose["services"]["s3-tls"]["image"]
|
||||
)
|
||||
self.assertEqual(
|
||||
images["test_mail"], compose["services"]["test-mail"]["image"]
|
||||
)
|
||||
|
||||
def test_k3s_workers_join_the_primary_control_and_receive_failure_labels(
|
||||
self,
|
||||
) -> None:
|
||||
config = load_config(ACCEPTANCE_CONFIG)
|
||||
worker = config.workers[0]
|
||||
|
||||
rendered = render_k3s_config(config, worker, cluster_token="test-token")
|
||||
|
||||
self.assertIn(f'server: "https://{config.primary_control.address}:6443"', rendered)
|
||||
self.assertIn(
|
||||
f'topology.govoplan.add-ideas.de/failure-domain={worker.failure_domain}',
|
||||
rendered,
|
||||
)
|
||||
self.assertNotIn("cluster-init", rendered)
|
||||
|
||||
def test_registry_credentials_are_all_or_nothing(self) -> None:
|
||||
self.assertEqual("", render_registry_config("", ""))
|
||||
with self.assertRaisesRegex(ValueError, "supplied together"):
|
||||
render_registry_config("publisher", "")
|
||||
rendered = render_registry_config("publisher", "secret")
|
||||
self.assertIn('"git.add-ideas.de"', rendered)
|
||||
self.assertIn("publisher", rendered)
|
||||
|
||||
def test_private_writer_enforces_owner_only_permissions(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-lab-private-") as directory:
|
||||
path = Path(directory) / "nested" / "secret.txt"
|
||||
write_private(path, "secret\n")
|
||||
|
||||
self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,6 +27,13 @@ from govoplan_release.catalog_entry_synthesis import ( # noqa: E402
|
||||
from govoplan_release.selective_catalog import apply_repo_updates # noqa: E402
|
||||
|
||||
|
||||
def repository_version(name: str) -> str:
|
||||
payload = tomllib.loads(
|
||||
(META_ROOT.parent / name / "pyproject.toml").read_text(encoding="utf-8")
|
||||
)
|
||||
return str(payload["project"]["version"])
|
||||
|
||||
|
||||
class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||
def test_catalog_entry_preserves_architecture_and_provider_declarations(
|
||||
self,
|
||||
@@ -92,6 +100,12 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual("vertical_slice", entry["architecture"]["maturity"])
|
||||
self.assertEqual(
|
||||
"contract_only",
|
||||
entry["information_governance"]["dimensions"]["retention"][
|
||||
"adoption"
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
"external_mirror",
|
||||
entry["external_providers"][0]["objects"][0][
|
||||
@@ -116,9 +130,12 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
||||
changes = apply_repo_updates(
|
||||
payload,
|
||||
repo_versions={
|
||||
"govoplan-addresses": "0.1.9",
|
||||
"govoplan-poll": "0.1.11",
|
||||
"govoplan-scheduling": "0.1.11",
|
||||
name: repository_version(name)
|
||||
for name in (
|
||||
"govoplan-addresses",
|
||||
"govoplan-poll",
|
||||
"govoplan-scheduling",
|
||||
)
|
||||
},
|
||||
repo_contracts={},
|
||||
repository_base="git+ssh://git@git.add-ideas.de/GovOPlaN",
|
||||
|
||||
@@ -121,6 +121,22 @@ class RuntimeDistributionBuildTests(unittest.TestCase):
|
||||
):
|
||||
self.assertIn(temporary_path, nginx)
|
||||
|
||||
def test_web_runtime_resolves_the_configured_api_service_at_startup(self) -> None:
|
||||
nginx = (ROOT / "tools/release/runtime/nginx.conf").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
dockerfile = (ROOT / "tools/release/runtime/Dockerfile.web").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
entrypoint = (ROOT / "tools/release/runtime/web-entrypoint.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("proxy_pass ${GOVOPLAN_API_UPSTREAM};", nginx)
|
||||
self.assertIn("nginx.conf.template", dockerfile)
|
||||
self.assertIn("govoplan-web-entrypoint", dockerfile)
|
||||
self.assertIn("envsubst '${GOVOPLAN_API_UPSTREAM}'", entrypoint)
|
||||
|
||||
def test_workflow_verifies_portable_bootstrap_artifacts_before_execution(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user