360 lines
14 KiB
Python
360 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from unittest.mock import patch
|
|
|
|
|
|
SCRIPT = Path(__file__).with_name("run-periodic-learner-e2e.py")
|
|
|
|
|
|
def load_module() -> ModuleType:
|
|
spec = importlib.util.spec_from_file_location("run_periodic_learner_e2e", SCRIPT)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError("cannot load periodic learner runner")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
runner = load_module()
|
|
|
|
|
|
class FakeController:
|
|
def __init__(self, *, tracked_status: str = "") -> None:
|
|
self.tracked_status = tracked_status
|
|
self.commands: list[tuple[str, list[str]]] = []
|
|
self.starts: list[tuple[str, list[str]]] = []
|
|
self.stops: list[int] = []
|
|
|
|
def run(
|
|
self,
|
|
stage,
|
|
argv,
|
|
*,
|
|
cwd,
|
|
env=None,
|
|
timeout,
|
|
check=True,
|
|
):
|
|
del cwd, env, timeout, check
|
|
self.commands.append((stage, list(argv)))
|
|
stdout = ""
|
|
if stage == "source_head":
|
|
stdout = "1" * 40 + "\n"
|
|
elif stage == "source_tree":
|
|
stdout = "2" * 40 + "\n"
|
|
elif stage == "source_tracked_clean":
|
|
stdout = self.tracked_status
|
|
elif stage == "source_untracked_inventory":
|
|
stdout = "?? apps/api/engine.err.log.bak\n"
|
|
elif stage == "docker_context_show":
|
|
stdout = "desktop-linux\n"
|
|
elif stage == "docker_context_inspect":
|
|
stdout = json.dumps(
|
|
[
|
|
{
|
|
"Endpoints": {
|
|
"docker": {
|
|
"Host": "npipe:////./pipe/dockerDesktopLinuxEngine"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
)
|
|
return runner.CommandResult(0, stdout, "", 0.01)
|
|
|
|
def start(
|
|
self,
|
|
stage,
|
|
argv,
|
|
*,
|
|
cwd,
|
|
env,
|
|
stdout_path,
|
|
stderr_path,
|
|
):
|
|
del cwd, env, stdout_path, stderr_path
|
|
self.starts.append((stage, list(argv)))
|
|
return runner.ProcessHandle(4242)
|
|
|
|
def stop_exact(self, handle, *, timeout):
|
|
del timeout
|
|
self.stops.append(handle.pid)
|
|
return True
|
|
|
|
def http_json(self, url, *, headers=None, timeout):
|
|
del url, headers, timeout
|
|
return {"ok": True, "status": "ok", "db": True, "engine": True}
|
|
|
|
def http_text(self, url, *, timeout):
|
|
del url, timeout
|
|
return '<div id="root"></div>'
|
|
|
|
def tcp_listening(self, host, port, *, timeout=0.25):
|
|
del host, port, timeout
|
|
return False
|
|
|
|
def sleep(self, seconds):
|
|
del seconds
|
|
|
|
|
|
def config(receipt_path: Path) -> runner.RunnerConfig:
|
|
return runner.RunnerConfig(
|
|
receipt_path=receipt_path,
|
|
python_exe="python.exe",
|
|
node_exe="node.exe",
|
|
docker_exe="docker.exe",
|
|
execute=True,
|
|
)
|
|
|
|
|
|
class SafetyContractTests(unittest.TestCase):
|
|
def test_rejects_every_protected_port_from_argv_and_runtime_env(self) -> None:
|
|
for port in sorted(runner.FORBIDDEN_PORTS):
|
|
with self.subTest(port=port):
|
|
with self.assertRaises(runner.GateError):
|
|
runner.assert_safe_invocation([f"http://127.0.0.1:{port}"], {})
|
|
with self.assertRaises(runner.GateError):
|
|
runner.assert_safe_invocation([], {"DATABASE_URL": f"postgresql://x@127.0.0.1:{port}/db"})
|
|
|
|
def test_rejects_public_and_nas_markers(self) -> None:
|
|
values = [
|
|
"https://vignette.chanpaca.net",
|
|
"https://api-vignette.chanpaca.net/health",
|
|
"postgresql://app@100.116.83.60:55433/vignette",
|
|
"vignette-preview-20260807",
|
|
"vignette-dev-db",
|
|
"/volume1/docker/vignette",
|
|
]
|
|
for value in values:
|
|
with self.subTest(value=value), self.assertRaises(runner.GateError):
|
|
runner.assert_safe_value("contract", value)
|
|
|
|
def test_rejects_inherited_runtime_targets_even_when_the_port_looks_local(self) -> None:
|
|
for key, value in (
|
|
("DATABASE_URL", "postgresql://app@127.0.0.1:55439/vignette"),
|
|
("ENGINE_URL", "http://127.0.0.1:9199"),
|
|
("PLAYWRIGHT_BASE_URL", "http://127.0.0.1:5199"),
|
|
("COMPOSE_PROJECT_NAME", "some-existing-project"),
|
|
):
|
|
with self.subTest(key=key), self.assertRaises(runner.GateError):
|
|
runner.assert_no_inherited_runtime_targets({key: value})
|
|
|
|
def test_runtime_identity_and_generated_environment_are_disposable(self) -> None:
|
|
source = runner.SourceIdentity("1" * 40, "2" * 40)
|
|
with patch.object(runner, "allocate_unique_ports", return_value=(18080, 18443, 15439, 19199)):
|
|
runtime = runner.build_runtime_identity(source)
|
|
runner.validate_runtime_identity(runtime)
|
|
values = runner.build_stack_environment(runtime)
|
|
|
|
self.assertEqual(len(set(runtime.ports)), 4)
|
|
self.assertTrue(runner.PROJECT_RE.fullmatch(runtime.project))
|
|
self.assertEqual(values["HTTP_PORT"], "18080")
|
|
self.assertEqual(values["DB_HOST_PORT"], "15439")
|
|
self.assertEqual(values["ENGINE_URL"], "http://host.docker.internal:19199")
|
|
self.assertNotIn("vignette.chanpaca.net", json.dumps(values))
|
|
self.assertFalse(set(runtime.ports) & runner.FORBIDDEN_PORTS)
|
|
|
|
def test_compose_override_labels_every_resource_and_loopback_binds_database(self) -> None:
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
path = Path(raw) / "override.yml"
|
|
runner.write_compose_override(path)
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
self.assertEqual(text.count("com.vignette.periodic-e2e-sentinel"), 8)
|
|
self.assertIn('127.0.0.1:${DB_HOST_PORT}:5432', text)
|
|
self.assertIn("pgdata:", text)
|
|
self.assertIn("apiuploads:", text)
|
|
self.assertIn("caddydata:", text)
|
|
self.assertIn("vignette:", text)
|
|
|
|
def test_resolved_compose_contract_binds_only_loopback_and_internal_db(self) -> None:
|
|
runtime = runner.RuntimeIdentity(
|
|
"20260809T120000-abcdef12",
|
|
"vignette-periodic-11111111-abcdef12",
|
|
"periodic:" + "1" * 40 + ":" + "2" * 40 + ":run",
|
|
18080,
|
|
18443,
|
|
15439,
|
|
19199,
|
|
)
|
|
stack_env = {
|
|
"POSTGRES_DB": "vignette_periodic",
|
|
}
|
|
payload = {
|
|
"services": {
|
|
name: {
|
|
"labels": {
|
|
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
|
|
},
|
|
"ports": [],
|
|
}
|
|
for name in ("db", "api", "web", "proxy")
|
|
},
|
|
"volumes": {
|
|
name: {
|
|
"labels": {
|
|
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
|
|
}
|
|
}
|
|
for name in ("pgdata", "apiuploads", "caddydata")
|
|
},
|
|
"networks": {
|
|
"vignette": {
|
|
"labels": {
|
|
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
|
|
}
|
|
}
|
|
},
|
|
}
|
|
payload["services"]["db"]["ports"] = [
|
|
{"host_ip": "127.0.0.1", "target": 5432, "published": "15439"}
|
|
]
|
|
payload["services"]["proxy"]["ports"] = [
|
|
{"host_ip": "127.0.0.1", "target": 80, "published": "18080"},
|
|
{"host_ip": "127.0.0.1", "target": 443, "published": "18443"},
|
|
]
|
|
payload["services"]["api"]["environment"] = {
|
|
"DATABASE_URL": "postgresql://app:secret@db:5432/vignette_periodic",
|
|
"ENGINE_URL": "http://host.docker.internal:19199",
|
|
"FRONTEND_BASE_URL": "http://127.0.0.1:18080",
|
|
"OAUTH_REDIRECT_URI": "http://127.0.0.1:18080/api/auth/callback",
|
|
}
|
|
|
|
class ConfigFake(FakeController):
|
|
def run(self, stage, argv, **kwargs):
|
|
result = super().run(stage, argv, **kwargs)
|
|
if stage == "resolved_compose_config":
|
|
return runner.CommandResult(0, json.dumps(payload), "", 0.01)
|
|
return result
|
|
|
|
fake = ConfigFake()
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake)
|
|
periodic.state.runtime = runtime
|
|
periodic.state.docker_context = "desktop-linux"
|
|
periodic.state.env_file = Path(raw) / "stack.env"
|
|
periodic.state.override_file = Path(raw) / "override.yml"
|
|
digest = periodic._validate_resolved_compose_config(stack_env)
|
|
|
|
self.assertEqual(len(digest), 64)
|
|
payload["services"]["proxy"]["ports"][0]["host_ip"] = "0.0.0.0"
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), ConfigFake())
|
|
periodic.state.runtime = runtime
|
|
periodic.state.docker_context = "desktop-linux"
|
|
periodic.state.env_file = Path(raw) / "stack.env"
|
|
periodic.state.override_file = Path(raw) / "override.yml"
|
|
with self.assertRaises(runner.GateError):
|
|
periodic._validate_resolved_compose_config(stack_env)
|
|
|
|
def test_dirty_source_fails_before_process_or_compose_mutation(self) -> None:
|
|
fake = FakeController(tracked_status=" M apps/web/src/App.tsx\n")
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
receipt = Path(raw) / "receipt.json"
|
|
execution = runner.PeriodicRunner(config(receipt), fake).execute()
|
|
stored = json.loads(receipt.read_text(encoding="utf-8"))
|
|
|
|
self.assertEqual(execution["status"], "FAILED")
|
|
self.assertEqual(stored["status"], "FAILED")
|
|
self.assertIn("tracked worktree differs", execution["error"])
|
|
self.assertEqual(fake.starts, [])
|
|
self.assertFalse(any("compose" in command for _, command in fake.commands))
|
|
|
|
def test_docker_context_is_pinned_to_local_windows_named_pipe(self) -> None:
|
|
fake = FakeController()
|
|
periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake)
|
|
proof = periodic._pin_local_docker_context()
|
|
self.assertEqual(proof, {"context": "desktop-linux", "transport": "npipe", "remote": False})
|
|
self.assertEqual(periodic.state.docker_context, "desktop-linux")
|
|
|
|
class RemoteDockerFake(FakeController):
|
|
def run(self, stage, argv, **kwargs):
|
|
result = super().run(stage, argv, **kwargs)
|
|
if stage == "docker_context_inspect":
|
|
return runner.CommandResult(
|
|
0,
|
|
json.dumps(
|
|
[
|
|
{
|
|
"Endpoints": {
|
|
"docker": {"Host": "tcp://127.0.0.1:2375"}
|
|
}
|
|
}
|
|
]
|
|
),
|
|
"",
|
|
0.01,
|
|
)
|
|
return result
|
|
|
|
with self.assertRaises(runner.GateError):
|
|
runner.PeriodicRunner(
|
|
config(Path("receipt.json")), RemoteDockerFake()
|
|
)._pin_local_docker_context()
|
|
|
|
def test_cleanup_targets_only_exact_project_and_proves_listener_zero(self) -> None:
|
|
fake = FakeController()
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake)
|
|
periodic.state.source = runner.SourceIdentity("1" * 40, "2" * 40)
|
|
periodic.state.runtime = runner.RuntimeIdentity(
|
|
"20260809T120000-abcdef12",
|
|
"vignette-periodic-11111111-abcdef12",
|
|
"periodic:" + "1" * 40 + ":" + "2" * 40 + ":run",
|
|
18080,
|
|
18443,
|
|
15439,
|
|
19199,
|
|
)
|
|
periodic.state.docker_context = "desktop-linux"
|
|
periodic.state.env_file = Path(raw) / "stack.env"
|
|
periodic.state.override_file = Path(raw) / "override.yml"
|
|
periodic.state.stack_attempted = True
|
|
periodic.state.engine = runner.ProcessHandle(4242)
|
|
proof = periodic._cleanup()
|
|
|
|
down = next(command for stage, command in fake.commands if stage == "compose_down")
|
|
self.assertIn("vignette-periodic-11111111-abcdef12", down)
|
|
self.assertIn("--remove-orphans", down)
|
|
self.assertIn("--volumes", down)
|
|
self.assertEqual(fake.stops, [4242])
|
|
self.assertEqual(proof["container_remainder"], 0)
|
|
self.assertEqual(proof["volume_remainder"], 0)
|
|
self.assertEqual(proof["network_remainder"], 0)
|
|
self.assertEqual(set(proof["listener_counts"].values()), {0})
|
|
self.assertTrue(periodic._cleanup_green(proof))
|
|
|
|
def test_untracked_runtime_source_is_rejected_but_log_backup_is_not(self) -> None:
|
|
fake = FakeController()
|
|
periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake)
|
|
identity = periodic.source_identity(require_clean=True)
|
|
self.assertEqual(identity.head, "1" * 40)
|
|
|
|
class DangerousFake(FakeController):
|
|
def run(self, stage, argv, **kwargs):
|
|
result = super().run(stage, argv, **kwargs)
|
|
if stage == "source_untracked_inventory":
|
|
return runner.CommandResult(
|
|
0,
|
|
"?? apps/web/e2e/uncommitted-runtime.spec.ts\n",
|
|
"",
|
|
0.01,
|
|
)
|
|
return result
|
|
|
|
with self.assertRaises(runner.GateError):
|
|
runner.PeriodicRunner(
|
|
config(Path("receipt.json")), DangerousFake()
|
|
).source_identity(require_clean=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|