Files
pigway-frame-flow/tests/test_production_deploy.py
T

315 lines
11 KiB
Python

"""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": "18787",
"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": "18787"}
]
},
"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 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