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