158 lines
4.6 KiB
Python
158 lines
4.6 KiB
Python
"""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()
|