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 mypy pigway_frame_flow
|
||||||
- run: python -m pytest -q
|
- run: python -m pytest -q
|
||||||
- run: python -m pip_audit -r requirements.lock --no-deps --disable-pip
|
- run: python -m pip_audit -r requirements.lock --no-deps --disable-pip
|
||||||
- run: |
|
- name: Isolated Docker build, smoke and restart verification
|
||||||
cp .env.example .env
|
run: |
|
||||||
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
|
|
||||||
. ./deploy/build-cn.env
|
. ./deploy/build-cn.env
|
||||||
./scripts/build-verify.sh
|
python scripts/ci-compose-verify.py
|
||||||
mkdir -p data/images data/generated data/cache config
|
- name: Cleanup isolated Docker resources
|
||||||
sudo chown -R 10333:10333 data config
|
if: always()
|
||||||
docker compose up -d
|
run: python scripts/ci-compose-verify.py --cleanup
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
|
||||||
digest check alone does not invalidate dependency layers. To deliberately
|
digest check alone does not invalidate dependency layers. To deliberately
|
||||||
refresh installed APT versions, use `docker compose build --pull --no-cache`.
|
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" info >/dev/null
|
||||||
"$DOCKER_BIN" compose config --quiet
|
"$DOCKER_BIN" compose config --quiet
|
||||||
"$DOCKER_BIN" compose build --pull
|
"$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