Extend the existing release gates and ship an image Compose bundle. Refs QRun-IO/qqq#921.
100 lines
4.3 KiB
Python
100 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Package digest-pinned Compose with the tracked full-profile infrastructure."""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
from release_gate import validate_version
|
|
|
|
|
|
def create_bundle(root, version, sha, image, output):
|
|
validate_version(version)
|
|
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
|
raise ValueError("Source SHA must be a full commit ID")
|
|
if not re.fullmatch(r"ghcr\.io/qrun-io/qqq-all@sha256:[0-9a-f]{64}", image):
|
|
raise ValueError("Release image must be the qqq-all GHCR digest")
|
|
environment = {key: value for key, value in os.environ.items() if not key.startswith("COMPOSE_")}
|
|
rendered = subprocess.run(
|
|
["docker", "compose", "--env-file", ".env.example", "--profile", "*", "-f", "compose.yaml",
|
|
"config", "--no-interpolate", "--no-env-resolution", "--no-path-resolution", "--format", "json"],
|
|
cwd=root, env=environment, capture_output=True, text=True, check=True,
|
|
)
|
|
compose = json.loads(rendered.stdout)
|
|
compose.pop("x-app-image", None)
|
|
for name in ("app-core", "app-full"):
|
|
app = compose["services"][name]
|
|
app.pop("build", None)
|
|
app["image"] = image
|
|
app["pull_policy"] = "always"
|
|
compose["services"]["minio"]["image"] = "qqq-all-minio:" + version
|
|
name = "qqq-all-" + version
|
|
bundle = output / name
|
|
bundle.mkdir(parents=True, exist_ok=False)
|
|
(bundle / "compose.yaml").write_text(json.dumps(compose, indent=2) + "\n")
|
|
tracked = subprocess.check_output(["git", "-C", str(root), "ls-files", "--", "infra"], text=True).splitlines()
|
|
for filename in (".env.example", "LICENSE", "NOTICE", *tracked):
|
|
source = root / filename
|
|
if source.is_symlink() or not source.is_file():
|
|
raise ValueError(f"Bundle input must be a regular file: {filename}")
|
|
target = bundle / filename
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, target)
|
|
(bundle / "release.json").write_text(json.dumps(
|
|
{"version": version, "source_sha": sha, "image": image}, indent=2) + "\n")
|
|
(bundle / "README.md").write_text(f"""# qqq-all {version}
|
|
|
|
Extract this complete bundle and run commands from this directory. The application uses
|
|
`{image}` (source `{sha}`). No application source, Maven or app build is required.
|
|
Docker with Compose v2 is required. Copy `.env.example` to `.env` and replace
|
|
the demonstration passwords before sharing the environment. All fixture values remain
|
|
demo-only; retain the infra directory next to compose.yaml.
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
docker compose -p qqq-all-demo --profile core up -d --wait --no-build
|
|
```
|
|
|
|
For the full profile, stop core first. The included MinIO Dockerfile still builds locally
|
|
and needs network access; PostgreSQL/MySQL/Mongo initialization and the Keycloak realm
|
|
use the bundled infra files. External service images also need network access.
|
|
|
|
```bash
|
|
docker compose -p qqq-all-demo --profile core down
|
|
docker compose -p qqq-all-demo --profile full build minio
|
|
docker compose -p qqq-all-demo --profile full up -d --wait --no-build
|
|
```
|
|
|
|
Use a distinct Compose project name for each installation. The application listens on
|
|
http://localhost:8080 by default. The full-profile identity provider uses
|
|
http://keycloak.localhost:8081; that hostname must resolve to loopback on the client.
|
|
Ports and fixture settings are in `.env.example`. `docker compose ... down` retains
|
|
named data volumes; remove volumes only when intentionally resetting that installation.
|
|
The standalone compose.yaml asset needs this bundle's infra and environment template
|
|
for full-profile operation. See release.json for the exact application identity.
|
|
""")
|
|
archive = output / (name + ".tar.gz")
|
|
with tarfile.open(archive, "x:gz") as handle:
|
|
handle.add(bundle, arcname=name)
|
|
return archive
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, required=True)
|
|
parser.add_argument("--version", required=True)
|
|
parser.add_argument("--sha", required=True)
|
|
parser.add_argument("--image", required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
print(create_bundle(args.root.resolve(), args.version, args.sha, args.image, args.output.resolve()))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|