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
+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()