Isolate CI Compose runtime with temporary volumes and reliable cleanup

This commit is contained in:
Pigway
2026-10-07 20:29:48 +08:00
parent d88c88f876
commit 58823051ee
5 changed files with 276 additions and 21 deletions
+6 -20
View File
@@ -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
+26
View File
@@ -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.
+1 -1
View File
@@ -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}}'
+157
View File
@@ -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()
+86
View File
@@ -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()