diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a91b597..0dc8100 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -17,24 +17,10 @@ jobs: - run: python -m mypy pigway_frame_flow - run: python -m pytest -q - run: python -m pip_audit -r requirements.lock --no-deps --disable-pip - - run: | - cp .env.example .env - python - <<'PY' - from pathlib import Path - import secrets - p=Path('.env') - p.write_text(p.read_text().replace('FRAME_FLOW_TOKEN=\n', 'FRAME_FLOW_TOKEN='+secrets.token_urlsafe(32)+'\n')) - PY + - name: Isolated Docker build, smoke and restart verification + run: | . ./deploy/build-cn.env - ./scripts/build-verify.sh - mkdir -p data/images data/generated data/cache config - sudo chown -R 10333:10333 data config - docker compose up -d - for attempt in $(seq 1 30); do curl -fsS http://127.0.0.1:18787/ready >/dev/null && break || sleep 2; done - python scripts/smoke-test.py - docker compose restart pigway-frame-flow - for attempt in $(seq 1 30); do curl -fsS http://127.0.0.1:18787/ready >/dev/null && break || sleep 2; done - python scripts/smoke-test.py - docker inspect pigway-frame-flow --format '{{.HostConfig.RestartPolicy.Name}} {{.State.Health.Status}}' - docker image inspect pigway-frame-flow:latest --format '{{.Size}} {{.Architecture}}' - docker compose down + python scripts/ci-compose-verify.py + - name: Cleanup isolated Docker resources + if: always() + run: python scripts/ci-compose-verify.py --cleanup diff --git a/README.md b/README.md index a243678..056f09e 100644 --- a/README.md +++ b/README.md @@ -284,3 +284,29 @@ or using a fresh Docker daemon loses the benefit. Cache mounts are disposable: an empty cache still builds correctly. `--pull` remains enabled; a pinned base digest check alone does not invalidate dependency layers. To deliberately refresh installed APT versions, use `docker compose build --pull --no-cache`. + +### CI with a host Docker socket + +Run `python scripts/ci-compose-verify.py` after loading the build mirror profile. +The script derives a temporary Compose document from production settings, with +an absolute client-side build context, a UUID project name, a unique CI image +tag, and project-scoped `data` / `config` named volumes. It removes production +bind mounts, the fixed container name, host ports, and the production `.env` +reference. A fresh test token, mock driver, and disabled device writes are +explicitly configured. Docker initializes empty named volumes from the image's +owned `/data` and `/config` directories; the service remains UID/GID 10333. + +Build contexts are sent by the Docker client, so the job workspace can remain +inside the job container. Runtime storage is managed by the daemon; no matching +NAS host `/workspace` directory is needed. HTTP readiness and the unmodified +nine-endpoint smoke script run inside the service with `compose exec -T`. +Restart is followed by another readiness check and the same smoke test. + +A `finally` block removes the test containers, network, volumes, and unique +image tag on success or failure. The workflow records the private temporary +Compose path in `GITHUB_ENV` and repeats cleanup in an `always()` step, covering +normal cancellation/interruption. Failed cleanup retains the manifest so the +fallback can retry. Abrupt host/daemon loss or a forcibly terminated entire job +can prevent both cleanup paths; no CI script can guarantee cleanup in that case. +No global prune or production resource removal is performed. Keep builder +caches: only the run-specific image tag is removed. diff --git a/scripts/build-verify.sh b/scripts/build-verify.sh index 0d68fc6..b22cff8 100755 --- a/scripts/build-verify.sh +++ b/scripts/build-verify.sh @@ -7,4 +7,4 @@ DOCKER_BIN=${DOCKER_BIN:-docker} "$DOCKER_BIN" info >/dev/null "$DOCKER_BIN" compose config --quiet "$DOCKER_BIN" compose build --pull -"$DOCKER_BIN" image inspect pigway-frame-flow:latest --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}' +"$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}' diff --git a/scripts/ci-compose-verify.py b/scripts/ci-compose-verify.py new file mode 100644 index 0000000..8374d81 --- /dev/null +++ b/scripts/ci-compose-verify.py @@ -0,0 +1,157 @@ +"""Verify production image in an isolated Compose project on any Docker daemon.""" + +import argparse +import os +import secrets +import signal +import subprocess +import tempfile +import time +from pathlib import Path +from uuid import uuid4 + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +DOCKER = os.getenv("DOCKER_BIN", "docker") + + +def isolated_compose(project): + config = yaml.safe_load((ROOT / "docker-compose.yml").read_text()) + config["name"] = project + service = config["services"]["pigway-frame-flow"] + for key in ("container_name", "ports", "env_file"): + service.pop(key, None) + service["image"] = f"pigway-frame-flow-ci:{project}" + service["build"]["context"] = str(ROOT) + service["environment"].update( + KOKONNA_MOCK="true", + KOKONNA_WRITE_ENABLED="false", + KOKONNA_API_KEY="", + FRAME_FLOW_TOKEN=secrets.token_urlsafe(32), + ) + service["volumes"] = ["data:/data", "config:/config"] + config["volumes"] = {"data": {}, "config": {}} + return config + + +def compose(path, *args, **kwargs): + project = yaml.safe_load(path.read_text())["name"] + return subprocess.run( + [DOCKER, "compose", "-p", project, "-f", str(path), *args], check=True, **kwargs + ) + + +def cleanup(path): + if not path or not path.exists(): + return + config = yaml.safe_load(path.read_text()) + compose(path, "down", "--volumes", "--remove-orphans", "--timeout", "10") + image = config["services"]["pigway-frame-flow"]["image"] + if ( + subprocess.run( + [DOCKER, "image", "inspect", image], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ): + subprocess.run([DOCKER, "image", "rm", image], check=True) + path.unlink() + + +def ready(path): + project = yaml.safe_load(path.read_text())["name"] + for _ in range(30): + result = subprocess.run( + [ + DOCKER, + "compose", + "-p", + project, + "-f", + str(path), + "exec", + "-T", + "pigway-frame-flow", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/ready', timeout=3)", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + return + time.sleep(2) + raise RuntimeError("CI container /ready did not return HTTP 200") + + +def verify(): + project = "ffci-" + uuid4().hex + with tempfile.NamedTemporaryFile( + prefix=project + "-", suffix=".yml", mode="w", delete=False + ) as file: + path = Path(file.name) + config = isolated_compose(project) + yaml.safe_dump(config, file) + env = os.environ | { + "COMPOSE_FILE": str(path), + "COMPOSE_PROJECT_NAME": project, + "FRAME_FLOW_IMAGE": config["services"]["pigway-frame-flow"]["image"], + } + # The always() workflow step can clean up if this process is interrupted. + if os.getenv("GITHUB_ENV"): + with Path(os.environ["GITHUB_ENV"]).open("a") as file: + file.write(f"FRAME_FLOW_CI_COMPOSE={path}\n") + try: + subprocess.run([str(ROOT / "scripts/build-verify.sh")], env=env, check=True) + compose(path, "up", "-d") + for restart in (False, True): + if restart: + compose(path, "restart", "pigway-frame-flow") + ready(path) + compose( + path, + "exec", + "-T", + "pigway-frame-flow", + "python", + "-", + input=(ROOT / "scripts/smoke-test.py").read_bytes(), + ) + container = compose( + path, "ps", "-q", "pigway-frame-flow", capture_output=True, text=True + ).stdout.strip() + subprocess.run( + [ + DOCKER, + "inspect", + container, + "--format", + "{{.HostConfig.RestartPolicy.Name}} {{.State.Health.Status}}", + ], + check=True, + ) + finally: + cleanup(path) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cleanup", action="store_true") + args = parser.parse_args() + if args.cleanup: + path = os.getenv("FRAME_FLOW_CI_COMPOSE") + cleanup(Path(path) if path else None) + else: + # Normal cancellation runs finally; SIGKILL is handled by always() below. + def cancelled(*_): + raise SystemExit(143) + + signal.signal(signal.SIGTERM, cancelled) + verify() + + +if __name__ == "__main__": + main() diff --git a/tests/test_ci_compose.py b/tests/test_ci_compose.py new file mode 100644 index 0000000..f6f6ea6 --- /dev/null +++ b/tests/test_ci_compose.py @@ -0,0 +1,86 @@ +"""CI orchestration must isolate host resources and clean up on failure.""" + +import importlib.util +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def ci(monkeypatch, tmp_path): + spec = importlib.util.spec_from_file_location( + "ci_compose", Path(__file__).parents[1] / "scripts/ci-compose-verify.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setenv("GITHUB_ENV", str(tmp_path / "job-env")) + return module + + +def test_ci_config_preserves_runtime_and_isolates_resources(ci): + config = ci.isolated_compose("ffci-fixture") + production = ci.yaml.safe_load((ci.ROOT / "docker-compose.yml").read_text()) + service = config["services"]["pigway-frame-flow"] + assert not any(key in service for key in ("ports", "container_name", "env_file")) + assert service["volumes"] == ["data:/data", "config:/config"] + assert config["volumes"] == {"data": {}, "config": {}} + assert config["name"] == "ffci-fixture" + assert service["image"] != "pigway-frame-flow:latest" + assert service["environment"]["KOKONNA_MOCK"] == "true" + assert service["environment"]["KOKONNA_WRITE_ENABLED"] == "false" + for key in ( + "user", + "read_only", + "healthcheck", + "restart", + "cap_drop", + "security_opt", + ): + assert service[key] == production["services"]["pigway-frame-flow"][key] + assert ( + production["services"]["pigway-frame-flow"]["container_name"] + == "pigway-frame-flow" + ) + + +@pytest.mark.parametrize("fail_smoke", [False, True]) +def test_verification_cleanup_and_container_side_smoke(ci, monkeypatch, fail_smoke): + calls = [] + + def run(command, **kwargs): + calls.append((command, kwargs)) + if command[-2:] == ["python", "-"] and fail_smoke: + raise subprocess.CalledProcessError(1, command) + return SimpleNamespace(returncode=0, stdout="fixture-container\n") + + monkeypatch.setattr(ci.subprocess, "run", run) + if fail_smoke: + with pytest.raises(subprocess.CalledProcessError): + ci.verify() + else: + ci.verify() + assert any("down" in cmd and "--volumes" in cmd for cmd, _ in calls) + assert any(cmd[1:3] == ["image", "rm"] for cmd, _ in calls) + smoke = [(cmd, kw) for cmd, kw in calls if cmd[-2:] == ["python", "-"]] + assert len(smoke) == (1 if fail_smoke else 2) + assert all("exec" in cmd and "-T" in cmd and kw["input"] for cmd, kw in smoke) + if not fail_smoke: + assert any("restart" in cmd for cmd, _ in calls) + build_env = calls[0][1]["env"] + assert build_env["FRAME_FLOW_IMAGE"].startswith("pigway-frame-flow-ci:ffci-") + assert not Path(build_env["COMPOSE_FILE"]).exists() + + +def test_cleanup_failure_keeps_manifest_for_always_step(ci, monkeypatch, tmp_path): + path = tmp_path / "compose.yml" + path.write_text(ci.yaml.safe_dump(ci.isolated_compose("ffci-fixture"))) + + def fail(*args, **kwargs): + raise subprocess.CalledProcessError(1, args[0]) + + monkeypatch.setattr(ci.subprocess, "run", fail) + with pytest.raises(subprocess.CalledProcessError): + ci.cleanup(path) + assert path.exists()