Isolate CI Compose runtime with temporary volumes and reliable cleanup
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}'
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user