#!/usr/bin/env python3 """CI guardrail: validate every app in ./apps/ before cutting a release. For each `apps//`: - manifest.json must parse via furtka.manifest.load_manifest (same schema the on-box reconciler expects — fields, settings rules, icon name). - docker-compose.yaml must exist and reference every manifest-declared volume as `external: true` (the reconciler creates the volumes; compose must not try to manage their lifecycle). - `docker compose config` must parse the compose file (catches YAML typos + missing env refs). The `furtka` package is vendored in `scripts/vendor/` — lifted verbatim from daniel/furtka's `furtka/manifest.py`. Keeping the copy local avoids dragging the core repo's whole pyproject into this repo's CI. If the core manifest schema evolves we bump the vendored copy with a conventional commit. """ from __future__ import annotations import subprocess import sys from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE / "vendor")) from furtka_manifest import ManifestError, load_manifest # noqa: E402 APPS_ROOT = HERE.parent / "apps" def check_app(app_dir: Path) -> list[str]: errors: list[str] = [] manifest_path = app_dir / "manifest.json" if not manifest_path.is_file(): return [f"{app_dir.name}: manifest.json missing"] try: m = load_manifest(manifest_path, expected_name=app_dir.name) except ManifestError as e: return [f"{app_dir.name}: invalid manifest: {e}"] compose_path = app_dir / "docker-compose.yaml" if not compose_path.is_file(): errors.append(f"{app_dir.name}: docker-compose.yaml missing") return errors compose_text = compose_path.read_text() # Cheap text search — pyyaml would be cleaner but we stay stdlib-only. # The reconciler also works on a text basis: volume must be referenced # at all, and must carry `external: true` in its definition block. for volume in m.volumes: namespaced = f"furtka_{m.name}_{volume}" if namespaced not in compose_text: errors.append( f"{app_dir.name}: docker-compose.yaml doesn't reference namespaced " f"volume {namespaced!r}" ) # very forgiving pattern — accept `external: true` anywhere after the # namespaced name appears. A more rigorous YAML walk can come later. tail = compose_text.split(namespaced, 1)[-1] if "external: true" not in tail and "external:\n true" not in tail: errors.append( f"{app_dir.name}: volume {namespaced!r} must be declared with " f"external: true (reconciler creates the volume before compose up)" ) # `docker compose config` parses the file and resolves env substitutions. # Skip the check if docker isn't installed in the environment — locally # that's fine, and CI images should have it. docker = subprocess.run( ["which", "docker"], capture_output=True, text=True, check=False ) if docker.returncode == 0: result = subprocess.run( ["docker", "compose", "-f", str(compose_path), "config"], capture_output=True, text=True, check=False, cwd=app_dir, # Mask any env-var substitution errors — we don't have the real # .env on a catalog validation pass; `docker compose config` # returns the stderr line "WARN[0000] The X variable is not set" # which is fine for syntax check. ) if result.returncode != 0: errors.append( f"{app_dir.name}: `docker compose config` failed:\n" + (result.stderr or result.stdout).strip() ) return errors def main() -> int: if not APPS_ROOT.is_dir(): print(f"no apps/ directory at {APPS_ROOT}", file=sys.stderr) return 2 all_errors: list[str] = [] apps = sorted(p for p in APPS_ROOT.iterdir() if p.is_dir()) if not apps: print("no apps to validate", file=sys.stderr) return 2 for app_dir in apps: errors = check_app(app_dir) if errors: all_errors.extend(errors) else: print(f"OK {app_dir.name}") if all_errors: print("\nFAIL:") for e in all_errors: print(f" - {e}") return 1 print(f"\nValidated {len(apps)} app(s).") return 0 if __name__ == "__main__": sys.exit(main())