timmy-talking-turd/tests/staging-deploy.test.py
Timmy f8f01c4b2e
All checks were successful
Quality gates / quality (pull_request) Successful in 1m28s
feat: add hardened atomic staging deployment
2026-08-21 14:49:28 +00:00

345 lines
17 KiB
Python

#!/usr/bin/env python3
"""Filesystem and failure-path tests for immutable staging deployment."""
from __future__ import annotations
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "deploy_staging.py"
COMMIT_A = "a" * 40
COMMIT_B = "b" * 40
def load_deploy():
spec = importlib.util.spec_from_file_location("deploy_staging", SCRIPT)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def make_archive(path: Path, members: list[tuple[str, bytes, str]]) -> str:
with tarfile.open(path, "w:gz") as archive:
for name, body, kind in members:
info = tarfile.TarInfo(name)
if kind == "file":
info.size = len(body)
archive.addfile(info, io.BytesIO(body))
elif kind == "dir":
info.type = tarfile.DIRTYPE
archive.addfile(info)
elif kind == "symlink":
info.type = tarfile.SYMTYPE
info.linkname = "server.mjs"
archive.addfile(info)
elif kind == "hardlink":
info.type = tarfile.LNKTYPE
info.linkname = "server.mjs"
archive.addfile(info)
elif kind == "fifo":
info.type = tarfile.FIFOTYPE
archive.addfile(info)
elif kind == "device":
info.type = tarfile.CHRTYPE
archive.addfile(info)
return hashlib.sha256(path.read_bytes()).hexdigest()
class DeployTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / "opt"
self.root.mkdir()
self.deploy = load_deploy()
self.config = self.deploy.DeploymentConfig(
root=self.root,
restart_command=("fixture-restart",),
stop_command=("fixture-stop",),
smoke_command=("fixture-smoke",),
health_url="http://127.0.0.1:4174/api/healthz",
command_timeout=1.0,
health_timeout=1.0,
)
self.commands = []
def tearDown(self):
self.tmp.cleanup()
def runner(self, argv, **kwargs):
self.commands.append((tuple(argv), kwargs))
return subprocess.CompletedProcess(argv, 0, "", "")
def healthy(self, url, commit, timeout):
self.assertEqual(url, self.config.health_url)
self.assertEqual(timeout, self.config.health_timeout)
return {"ok": True, "commit": commit}
def archive(self, name="release.tar.gz", members=None):
path = Path(self.tmp.name) / name
digest = make_archive(path, members or [
("timmy-release/", b"", "dir"),
("timmy-release/server.mjs", b"console.log('ok')\n", "file"),
("timmy-release/package.json", b"{}\n", "file"),
])
return path, digest
def seed_release(self, commit):
release = self.root / "releases" / commit
release.mkdir(parents=True)
(release / "server.mjs").write_text("ok", encoding="utf-8")
(release / ".timmy-release.json").write_text(json.dumps({"commit": commit, "tag": "old"}), encoding="utf-8")
return release
def point_current(self, commit):
(self.root / "current").symlink_to(Path("releases") / commit)
def promote(self, archive, digest, commit=COMMIT_B, **kwargs):
return self.deploy.promote(
config=self.config, tag="daily-test", archive=archive,
expected_sha256=digest, commit=commit,
run_command=kwargs.get("run_command", self.runner),
health_check=kwargs.get("health_check", self.healthy),
)
def test_checksum_mismatch_fails_before_tar_is_opened(self):
archive = Path(self.tmp.name) / "not-even-a-tar"
archive.write_bytes(b"untrusted")
with self.assertRaisesRegex(self.deploy.DeploymentError, "SHA-256 mismatch"):
self.promote(archive, "0" * 64)
self.assertFalse((self.root / "releases").exists())
def test_verified_private_archive_copy_is_used_after_caller_archive_mutates(self):
archive, digest = self.archive("mutable.tar.gz")
evil = Path(self.tmp.name) / "evil.tar.gz"
make_archive(evil, [
("timmy-release/", b"", "dir"),
("timmy-release/server.mjs", b"EVIL\n", "file"),
])
real_inspect = self.deploy.inspect_archive
inspected_paths = []
def mutate_then_inspect(path, config):
inspected_paths.append(Path(path))
archive.write_bytes(evil.read_bytes())
return real_inspect(path, config)
with mock.patch.object(self.deploy, "inspect_archive", side_effect=mutate_then_inspect):
self.promote(archive, digest)
release = self.root / "releases" / COMMIT_B
self.assertNotEqual(inspected_paths, [archive])
self.assertEqual((release / "server.mjs").read_text(), "console.log('ok')\n")
self.assertTrue(all(not path.exists() for path in inspected_paths), "verified temp archive must be cleaned")
def test_archive_source_must_be_a_nonsymlink_regular_file(self):
archive, digest = self.archive("regular.tar.gz")
symlink = Path(self.tmp.name) / "archive-link.tar.gz"
symlink.symlink_to(archive)
for source in (symlink, Path(self.tmp.name)):
with self.subTest(source=source), self.assertRaisesRegex(self.deploy.DeploymentError, "regular file|stage archive"):
self.promote(source, digest)
def test_absolute_and_traversal_paths_are_rejected(self):
for index, unsafe in enumerate(("/etc/passwd", "root/../../escape", "../escape", "root//double")):
archive, digest = self.archive(f"unsafe-{index}.tar.gz", [(unsafe, b"bad", "file")])
with self.subTest(unsafe=unsafe), self.assertRaisesRegex(self.deploy.DeploymentError, "unsafe archive path"):
self.promote(archive, digest)
self.assertFalse((self.root / "releases" / COMMIT_B).exists())
def test_links_devices_fifos_and_oversized_archives_are_rejected(self):
for index, kind in enumerate(("symlink", "hardlink", "fifo", "device")):
archive, digest = self.archive(f"special-{index}.tar.gz", [(f"root/bad-{kind}", b"", kind)])
with self.subTest(kind=kind), self.assertRaisesRegex(self.deploy.DeploymentError, "unsupported archive member"):
self.promote(archive, digest)
archive, digest = self.archive("large.tar.gz", [("root/large", b"x" * 17, "file")])
tiny = self.deploy.DeploymentConfig(**{**self.config.__dict__, "max_member_bytes": 16})
with self.assertRaisesRegex(self.deploy.DeploymentError, "member size"):
self.deploy.promote(config=tiny, tag="daily-test", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=self.runner, health_check=self.healthy)
def test_member_count_limit_is_enforced(self):
archive, digest = self.archive("many.tar.gz", [(f"root/{i}", b"x", "file") for i in range(3)])
tiny = self.deploy.DeploymentConfig(**{**self.config.__dict__, "max_members": 2})
with self.assertRaisesRegex(self.deploy.DeploymentError, "member count"):
self.deploy.promote(config=tiny, tag="daily-test", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=self.runner, health_check=self.healthy)
def test_secrets_env_and_forbidden_artifacts_are_rejected(self):
forbidden = ("root/.env", "root/secrets/token.txt", "root/private.pem", "root/model.gguf", "root/.git/config", "root/video/raw.webm")
for index, name in enumerate(forbidden):
archive, digest = self.archive(f"secret-{index}.tar.gz", [(name, b"secret", "file")])
with self.subTest(name=name), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
self.promote(archive, digest)
def test_forbidden_common_roots_are_rejected_case_insensitively_but_release_wrapper_is_allowed(self):
for index, root in enumerate((".git", "ViDeO", "ARTIFACTS", "Credentials")):
archive, _ = self.archive(f"forbidden-root-{index}.tar.gz", [
(f"{root}/", b"", "dir"),
(f"{root}/server.mjs", b"evil", "file"),
])
with self.subTest(root=root), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
self.deploy.inspect_archive(archive, self.config)
archive, _ = self.archive("normal-wrapper.tar.gz")
_, wrapper = self.deploy.inspect_archive(archive, self.config)
self.assertEqual(wrapper, "timmy-release")
def test_release_root_symlink_is_rejected(self):
outside = Path(self.tmp.name) / "outside"
outside.mkdir()
(self.root / "releases").symlink_to(outside, target_is_directory=True)
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "releases directory"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_deployment_root_symlink_is_rejected_before_writing_outside(self):
outside = Path(self.tmp.name) / "outside-root"
outside.mkdir()
self.root.rmdir()
self.root.symlink_to(outside, target_is_directory=True)
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_deployment_root_non_directory_is_rejected(self):
self.root.rmdir()
self.root.write_text("not a directory")
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root is not a directory"):
self.promote(archive, digest)
def test_current_and_pending_symlink_boundaries_are_rejected_before_extraction(self):
outside = Path(self.tmp.name) / "outside-boundary"
outside.mkdir()
(self.root / "releases").mkdir()
(self.root / "current").symlink_to(outside, target_is_directory=True)
archive, digest = self.archive("current-boundary.tar.gz")
with self.assertRaisesRegex(self.deploy.DeploymentError, "current symlink escapes"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
(self.root / "current").unlink()
pending = self.root / "releases" / f".pending-{COMMIT_B}-fixed"
pending.symlink_to(outside, target_is_directory=True)
with mock.patch.object(self.deploy.secrets, "token_hex", return_value="fixed"):
with self.assertRaisesRegex(self.deploy.DeploymentError, "pending release boundary"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_valid_archive_extracts_to_commit_release_and_is_never_overwritten(self):
archive, digest = self.archive()
result = self.promote(archive, digest)
release = self.root / "releases" / COMMIT_B
self.assertEqual(result["commit"], COMMIT_B)
self.assertEqual((release / "server.mjs").read_text(), "console.log('ok')\n")
self.assertEqual(json.loads((release / ".timmy-release.json").read_text())["sha256"], digest)
with self.assertRaisesRegex(self.deploy.DeploymentError, "already exists"):
self.promote(archive, digest)
def test_success_atomically_swaps_current_and_uses_bounded_argv_commands(self):
self.seed_release(COMMIT_A)
self.point_current(COMMIT_A)
archive, digest = self.archive()
self.promote(archive, digest)
self.assertEqual((self.root / "current").resolve(), self.root / "releases" / COMMIT_B)
self.assertEqual([command for command, _ in self.commands], [("fixture-restart",), ("fixture-smoke",)])
self.assertTrue(all(options["shell"] is False and options["timeout"] == 1.0 for _, options in self.commands))
self.assertFalse(any(path.name.startswith(".current-") for path in self.root.iterdir()))
def test_restart_health_and_smoke_failures_automatically_restore_prior_release(self):
phases = ("restart", "health", "smoke")
for phase in phases:
with self.subTest(phase=phase):
root = Path(self.tmp.name) / phase
config = self.deploy.DeploymentConfig(**{**self.config.__dict__, "root": root})
release = root / "releases" / COMMIT_A
release.mkdir(parents=True)
(release / "server.mjs").write_text("ok")
(root / "current").symlink_to(Path("releases") / COMMIT_A)
archive, digest = self.archive(f"{phase}.tar.gz")
calls = []
def run(argv, **kwargs):
calls.append(tuple(argv))
if (phase == "restart" and len(calls) == 1) or (phase == "smoke" and tuple(argv) == ("fixture-smoke",)):
raise subprocess.CalledProcessError(1, argv)
return subprocess.CompletedProcess(argv, 0, "", "")
def health(url, commit, timeout):
if phase == "health" and commit == COMMIT_B:
raise self.deploy.DeploymentError("health failed")
return {"ok": True, "commit": commit}
with self.assertRaisesRegex(self.deploy.DeploymentError, "promotion verification failed"):
self.deploy.promote(config=config, tag="daily-test", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=run, health_check=health)
self.assertEqual((root / "current").resolve(), root / "releases" / COMMIT_A)
self.assertGreaterEqual(calls.count(("fixture-restart",)), 2)
def test_first_promotion_failure_removes_current_and_stops_service_without_restart(self):
archive, digest = self.archive("first-failure.tar.gz")
calls = []
def run(argv, **kwargs):
calls.append(tuple(argv))
if tuple(argv) == ("fixture-smoke",):
raise subprocess.CalledProcessError(1, argv)
return subprocess.CompletedProcess(argv, 0, "", "")
with self.assertRaisesRegex(self.deploy.DeploymentError, "no prior release; service stopped"):
self.promote(archive, digest, run_command=run)
self.assertFalse((self.root / "current").exists())
self.assertFalse((self.root / "current").is_symlink())
self.assertEqual(calls, [("fixture-restart",), ("fixture-smoke",), ("fixture-stop",)])
self.assertTrue((self.root / "releases" / COMMIT_B).is_dir(), "failed release remains inert evidence")
def test_rollback_requires_valid_immutable_release_and_restarts_and_checks_health(self):
self.seed_release(COMMIT_A)
self.seed_release(COMMIT_B)
self.point_current(COMMIT_B)
result = self.deploy.rollback(config=self.config, commit=COMMIT_A, run_command=self.runner, health_check=self.healthy)
self.assertEqual(result["commit"], COMMIT_A)
self.assertEqual((self.root / "current").resolve(), self.root / "releases" / COMMIT_A)
with self.assertRaisesRegex(self.deploy.DeploymentError, "does not exist"):
self.deploy.rollback(config=self.config, commit="c" * 40, run_command=self.runner, health_check=self.healthy)
tampered = self.seed_release("d" * 40)
(tampered / ".timmy-release.json").write_text(json.dumps({"commit": COMMIT_A, "tag": "wrong"}))
with self.assertRaisesRegex(self.deploy.DeploymentError, "metadata"):
self.deploy.rollback(config=self.config, commit="d" * 40, run_command=self.runner, health_check=self.healthy)
def test_default_command_adapter_accepts_verifier_kwargs_and_stays_bounded(self):
result = self.deploy._run_argv(
(sys.executable, "-c", "print('ok')"), check=True, text=True,
capture_output=True, shell=False, timeout=1.0,
)
self.assertEqual(result.stdout.strip(), "ok")
def test_status_and_cli_dry_run_are_rootless_and_machine_readable(self):
self.seed_release(COMMIT_A)
self.point_current(COMMIT_A)
status = self.deploy.status(self.config)
self.assertEqual(status["commit"], COMMIT_A)
run = subprocess.run([
sys.executable, str(SCRIPT), "--root", str(self.root), "--dry-run", "status"
], text=True, capture_output=True, check=False)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertEqual(json.loads(run.stdout)["commit"], COMMIT_A)
def test_commit_tag_and_checksum_arguments_are_strictly_validated(self):
archive, digest = self.archive()
for commit in ("abc", "A" * 40, "a" * 41, "../" + "a" * 40):
with self.subTest(commit=commit), self.assertRaisesRegex(self.deploy.DeploymentError, "commit"):
self.promote(archive, digest, commit=commit)
with self.assertRaisesRegex(self.deploy.DeploymentError, "tag"):
self.deploy.promote(config=self.config, tag="../bad", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=self.runner, health_check=self.healthy)
if __name__ == "__main__":
unittest.main()