"""Deployment gates, isolation checks, and rollback use mocked Docker only.""" import importlib.util import json from pathlib import Path from types import SimpleNamespace import pytest @pytest.fixture def tool(): spec = importlib.util.spec_from_file_location( "production_deploy", Path(__file__).parents[1] / "scripts/production-deploy.py" ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) instance = module.Deploy() instance.env = { "PROD_PROJECT": "production-frame", "PROD_CONTAINER": "production-frame", "PROD_IMAGE_REPO": "pigway-frame-flow", "PROD_DATA_DIR": "/confirmed/data", "PROD_CONFIG_DIR": "/confirmed/config", "PROD_WEB_BIND": "192.0.2.1", "PROD_WEB_PORT": "6005", "PROD_STATE_VOLUME": "production-frame-cd", "PROD_MOCK": "false", "PROD_WRITE_ENABLED": "false", } return instance def existing(tool): return { "Image": "sha256:previous", "State": {"Running": True}, "Config": { "User": "10333:10333", "Env": ["BUILD_COMMIT=previous", "FRAME_FLOW_TOKEN=fixture-token"], "Labels": { "com.docker.compose.project": tool.env["PROD_PROJECT"], "com.docker.compose.service": "pigway-frame-flow", }, }, "Mounts": [ {"Type": "bind", "Destination": target, "Source": tool.env[key], "RW": True} for target, key in [ ("/data", "PROD_DATA_DIR"), ("/config", "PROD_CONFIG_DIR"), ] ], "HostConfig": { "PortBindings": { "18787/tcp": [{"HostIp": tool.env["PROD_WEB_BIND"], "HostPort": "6005"}] }, "NetworkMode": "production-frame_default", "RestartPolicy": {"Name": "unless-stopped"}, }, } def test_preflight_preserves_paths_and_has_no_write_commands(tool, monkeypatch): monkeypatch.setattr(tool, "inspect", lambda _: existing(tool)) calls = [] monkeypatch.setattr( tool, "run", lambda *args, **kwargs: calls.append(args) or SimpleNamespace(returncode=1), ) config = tool.preflight() service = config["services"]["pigway-frame-flow"] assert service["image"] == "sha256:previous" assert all(m["bind"]["create_host_path"] is False for m in service["volumes"]) assert service["environment"]["FRAME_FLOW_TOKEN"] == "fixture-token" assert calls == [("volume", "inspect", "production-frame-cd")] @pytest.mark.parametrize("change", ["path", "project", "port", "network"]) def test_preflight_rejects_wrong_host_resources(tool, monkeypatch, change): old = existing(tool) if change == "path": old["Mounts"][0]["Source"] = "/unrelated/data" elif change == "project": old["Config"]["Labels"]["com.docker.compose.project"] = "ffci-fixture" elif change == "port": old["HostConfig"]["PortBindings"]["18787/tcp"][0]["HostPort"] = "9999" else: old["HostConfig"]["NetworkMode"] = "host" monkeypatch.setattr(tool, "inspect", lambda _: old) with pytest.raises(ValueError): tool.preflight() @pytest.mark.parametrize("failure", [None, "candidate", "rollback"]) def test_deploy_rolls_back_exact_baseline_without_deleting_data( tool, monkeypatch, failure ): tool.old_image_tag = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8 old = {"services": {"pigway-frame-flow": {"image": "sha256:previous"}}} new = {"services": {"pigway-frame-flow": {"image": "candidate"}}} activated, calls, saved = [], [], [] monkeypatch.setattr(tool, "preflight", lambda: old) monkeypatch.setattr(tool, "lock", lambda: calls.append("lock")) monkeypatch.setattr(tool, "unlock", lambda: calls.append("unlock")) monkeypatch.setattr(tool, "run", lambda *args, **kw: calls.append(args)) monkeypatch.setattr(tool, "build", lambda _: new) monkeypatch.setattr(tool, "diagnostics", lambda: None) monkeypatch.setattr(tool, "activate", lambda c: activated.append(c)) monkeypatch.setattr( tool, "state_io", lambda state: saved.append(json.loads(json.dumps(state))) ) cleanup = [] monkeypatch.setattr( tool, "cleanup_images", lambda: cleanup.append(saved[-1]["status"]) ) verifies = [] def verify(config): verifies.append(config) if config == new and failure: raise RuntimeError("candidate not ready") if config == old and failure == "rollback" and len(verifies) > 1: raise RuntimeError("rollback not ready") return {"commit": "previous", "version": "0.1.0"} monkeypatch.setattr(tool, "verify", verify) if failure: with pytest.raises(RuntimeError): tool.deploy() assert cleanup == [] assert activated == [new, old] assert saved[0]["previous"] == old assert ("unlock" in calls) is (failure != "rollback") else: tool.deploy() assert cleanup == ["success"] assert "unlock" in calls assert activated == [new] assert saved[-1]["status"] == "success" assert saved[-1]["rollback_tag"] == tool.old_image_tag + "-rollback" assert not any( isinstance(c, tuple) and ("down" in c or "prune" in c) for c in calls ) def test_activate_escapes_dollar_secrets_and_never_down(tool, monkeypatch): calls = [] def run(*args, **kwargs): calls.append(args) if "config" in args: path = Path(args[args.index("-f") + 1]) assert "$$literal" in path.read_text() monkeypatch.setattr(tool, "run", run) tool.activate( { "services": { "pigway-frame-flow": {"environment": {"FRAME_FLOW_TOKEN": "$literal"}} } } ) assert len(calls) == 2 and "up" in calls[1] and "--no-build" in calls[1] def test_workflow_deploy_is_verify_gated_and_manual_plan_default(tool): import yaml workflow = yaml.safe_load( (Path(__file__).parents[1] / ".github/workflows/verify.yml").read_text() ) job = workflow["jobs"]["deploy"] assert job["needs"] == "verify" and "success()" in job["if"] assert "refs/heads/main" in job["if"] and "pull_request" not in job["if"] assert job["env"]["PROD_DEPLOY_ENABLED"] == "${{ vars.PROD_DEPLOY_ENABLED }}" assert ( workflow[True]["workflow_dispatch"]["inputs"]["deploy_action"]["default"] == "plan" ) assert job["steps"][-1]["if"] == "always()" def test_recovery_handles_missing_or_crashed_production_container(tool, monkeypatch): previous = { "name": tool.env["PROD_PROJECT"], "services": { "pigway-frame-flow": { "container_name": tool.env["PROD_CONTAINER"], "image": "sha256:old", "volumes": [ {"source": tool.env["PROD_DATA_DIR"], "target": "/data"}, {"source": tool.env["PROD_CONFIG_DIR"], "target": "/config"}, ], } }, } state = {"owner": tool.owner, "status": "prepared", "previous": previous} calls = [] def run(*args, **kwargs): calls.append(args) if args[0] == "inspect": assert args[1].endswith("-deploy-lock") return SimpleNamespace( returncode=0, stdout=json.dumps( [ { "Id": "owned-lock", "Image": "sha256:old", "Config": { "Labels": {"frameflow.deploy.owner": tool.owner} }, } ] ).encode(), ) return SimpleNamespace( returncode=0, stdout=json.dumps( [{"Labels": {"frameflow.deploy.project": tool.env["PROD_PROJECT"]}}] ).encode(), ) monkeypatch.setattr(tool, "run", run) def state_io(payload=None): if payload is not None: state.update(payload) return json.dumps(state).encode() monkeypatch.setattr(tool, "state_io", state_io) monkeypatch.setattr( tool, "rollback", lambda config: calls.append(("rollback", config)) ) tool.recover() assert ("rollback", previous) in calls assert ("rm", "owned-lock") in calls assert state["status"] == "rolled_back" def test_lock_conflict_never_activates_production(tool, monkeypatch): monkeypatch.setattr(tool, "preflight", lambda: {}) monkeypatch.setattr(tool, "activate", lambda _: pytest.fail("Production touched")) def fail(*args, **kwargs): raise RuntimeError("Lock already reserved") monkeypatch.setattr(tool, "run", fail) with pytest.raises(RuntimeError): tool.deploy() def test_image_retention_keeps_latest_and_protects_other_containers(tool, monkeypatch): tool.state = {"previous_image": "previous"} release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8 rollback = "pigway-frame-flow:rollback-" + "c" * 32 tool.state["rollback_tag"] = release + "-rollback" images = [ {"Id": "current", "RepoTags": ["pigway-frame-flow:latest", release]}, {"Id": "previous", "RepoTags": [release + "-rollback", release, rollback]}, {"Id": "stopped", "RepoTags": [release]}, {"Id": "obsolete", "RepoTags": [release, rollback]}, {"Id": "shared", "RepoTags": [release, "other:latest"]}, {"Id": "ci", "RepoTags": ["ffci-fixture:latest"]}, ] calls = [] def run(*args, **kwargs): calls.append(args) if args == ("ps", "-aq"): data = "container1 container2" elif args[0] == "inspect": data = json.dumps( [ { "Image": "current", "Config": {"Image": "pigway-frame-flow:latest"}, }, {"Image": "stopped"}, ] ) elif args[:2] == ("image", "ls"): data = "current previous stopped obsolete shared ci" elif args[:2] == ("image", "inspect"): data = json.dumps(images) else: data = "" return SimpleNamespace(stdout=data.encode(), returncode=0) monkeypatch.setattr(tool, "run", run) tool.cleanup_images() assert [c for c in calls if c[:2] == ("image", "rm")] == [ ("image", "rm", release), ("image", "rm", release + "-rollback", release, rollback), ("image", "rm", release, rollback), ] def test_image_retention_failure_does_not_fail_verified_deploy( tool, monkeypatch, capsys ): tool.state = {"previous_image": "previous"} def unavailable(*args, **kwargs): raise RuntimeError("private diagnostic") monkeypatch.setattr(tool, "run", unavailable) tool.cleanup_images() output = capsys.readouterr().out assert "cleanup deferred" in output assert "private diagnostic" not in output