furtka-apps/scripts/validate-catalog.py

125 lines
4.4 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""CI guardrail: validate every app in ./apps/ before cutting a release.
For each `apps/<name>/`:
- 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())