Files
DoGaMa-serv/tools/validate_spec.py
T
codex 4ed3c8ae58
CI / validate (pull_request) Successful in 26m33s
refactor(catalog): make template artwork fully local
2026-08-26 21:58:30 +02:00

214 lines
11 KiB
Python

#!/usr/bin/env python3
"""Validate DoGaMa specification schemas, examples and internal links."""
from __future__ import annotations
import hashlib
import json
import re
import sys
from pathlib import Path
import yaml
from jsonschema import Draft202012Validator
ROOT = Path(__file__).resolve().parent.parent
def load_json(path: Path):
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def load_yaml(path: Path):
with path.open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle)
def validate(schema_path: Path, document_path: Path) -> dict:
schema = load_json(schema_path)
Draft202012Validator.check_schema(schema)
document = load_yaml(document_path)
errors = sorted(Draft202012Validator(schema).iter_errors(document), key=lambda item: list(item.path))
if errors:
rendered = "\n".join(f" {document_path}:{'/'.join(map(str, error.path))}: {error.message}" for error in errors)
raise ValueError(f"Schema validation failed:\n{rendered}")
return document
def validate_links() -> None:
pattern = re.compile(r"\[[^]]+\]\(([^)]+)\)")
failures = []
for markdown in ROOT.rglob("*.md"):
text = markdown.read_text(encoding="utf-8")
for target in pattern.findall(text):
target = target.split("#", 1)[0].strip()
if not target or target.startswith(("http://", "https://", "mailto:")):
continue
resolved = (markdown.parent / target).resolve()
if not resolved.exists():
failures.append(f"{markdown.relative_to(ROOT)} -> {target}")
if failures:
raise ValueError("Broken internal links:\n " + "\n ".join(failures))
def validate_cross_references(template: dict, manifest: dict | None, template_path: Path) -> list[str]:
warnings = []
port_ids = {item["id"] for item in template["container"]["ports"]}
mount_ids = {item["id"] for item in template["storage"]["mounts"]}
capabilities = set(template["capabilities"])
integration = template.get("integration")
if integration:
assert manifest is not None, f"Declared module is missing: {integration['module_id']}"
assert integration["port_id"] in port_ids, "Template integration references an unknown port"
assert integration["module_id"] == manifest["id"], "Template and manifest module IDs disagree"
assert template["game"]["id"] in manifest["game_ids"], "Manifest does not support template game ID"
assert set(manifest["permissions"]["network"]["port_ids"]) <= port_ids, "Manifest references an unknown template port"
assert set(manifest["capabilities"]) == capabilities, "Template and reference manifest capabilities disagree"
assert set(template["backup"]["source_mounts"]) <= mount_ids, "Backup references an unknown mount"
assert template["imports"]["destination_mount"] in mount_ids, "Import references an unknown mount"
mods = template.get("mods", {})
if mods.get("supported"):
assert mods.get("destination_mount") in mount_ids, "Mods reference an unknown mount"
for field in ("logo", "image", "poster"):
assert not template["game"]["artwork"][field].startswith(("http://", "https://")), f"Remote artwork URL is forbidden: {field}"
path = (template_path.parent / template["game"]["artwork"][field]).resolve()
assert path.is_relative_to(template_path.parent.resolve()), f"Artwork path escapes template: {field}"
assert path.is_file(), f"Missing artwork asset {field}: {path}"
for asset in template["container"].get("assets", []):
path = (template_path.parent / asset["source"]).resolve()
assert path.is_relative_to(template_path.parent.resolve()), "Packaged asset path escapes template"
assert path.is_file(), f"Missing packaged asset: {path}"
if manifest is None:
return warnings
wasm_digest = manifest["artifacts"]["sha256"]
wasm_path = template_path.parent / "module" / manifest["artifacts"]["wasm"]
if wasm_digest == "0" * 64 and not wasm_path.exists():
warnings.append("Palworld module is a source specification: module.wasm and its final checksum are intentionally pending.")
elif wasm_path.is_file():
assert hashlib.sha256(wasm_path.read_bytes()).hexdigest() == wasm_digest, "WASM checksum mismatch"
else:
raise AssertionError("Manifest names a missing WASM artifact without the documented placeholder")
return warnings
def validate_catalog() -> list[str]:
template_schema = ROOT / "specs/template.schema.json"
manifest_schema = ROOT / "specs/module-manifest.schema.json"
template_paths = sorted((ROOT / "catalog").glob("*/template.yaml"))
assert template_paths, "Catalog contains no templates"
warnings = []
identities = set()
for template_path in template_paths:
template = validate(template_schema, template_path)
identity = (template["id"], template["version"])
assert identity not in identities, f"Duplicate template ID and version: {identity[0]}@{identity[1]}"
identities.add(identity)
integration = template.get("integration")
manifest = None
module = template.get("module")
if integration:
assert module is not None, "Integration requires a template-local module"
if module:
module_path = Path(module["path"])
assert not module_path.is_absolute() and ".." not in module_path.parts, "Module path escapes template"
manifest_path = (template_path.parent / module_path).resolve()
assert manifest_path.parent.is_relative_to((template_path.parent / "module").resolve()), "Module path is outside template module directory"
assert manifest_path.is_file(), f"Declared module manifest is missing: {manifest_path}"
manifest = validate(manifest_schema, manifest_path)
warnings.extend(validate_cross_references(template, manifest, template_path))
return warnings
def validate_coverage() -> None:
required = {
"Docker agent": "docs/architecture/docker-agent.md",
"WebAssembly": "docs/architecture/wasm-modules.md",
"threat model": "docs/security/security-and-threat-model.md",
"SQLite": "docs/domain/data-model.md",
"backup": "docs/operations/backups-import-export.md",
"Discord": "docs/operations/notifications-and-audit.md",
"30 days": "docs/operations/notifications-and-audit.md",
"manager": "docs/domain/authorization.md",
"Palworld": "catalog/palworld/README.md",
"acceptance": "docs/product/acceptance-criteria.md",
"roadmap": "docs/product/roadmap.md",
"Codex": "docs/contributing/ai-codex-guide.md",
}
missing = []
for needle, relative in required.items():
if needle.casefold() not in (ROOT / relative).read_text(encoding="utf-8").casefold():
missing.append(f"{needle!r} in {relative}")
if missing:
raise ValueError("Missing required coverage: " + ", ".join(missing))
def validate_compose() -> None:
compose = load_yaml(ROOT / "compose.yaml")
services = compose["services"]
assert set(services) == {"dogama", "agent"}, "Compose must expose exactly the application and restricted agent"
forbidden = {
"DOGAMA_AGENT_TOKEN_FILE", "DOGAMA_MASTER_KEY_FILE", "DOGAMA_SERVERS_ROOT",
"DOGAMA_BACKUPS_ROOT", "DOGAMA_IMPORTS_ROOT", "DOGAMA_ALLOWED_SERVER_ROOT",
"DOGAMA_ALLOWED_BACKUP_ROOT",
}
for name, service in services.items():
environment = service.get("environment", {})
assert forbidden.isdisjoint(environment), f"{name} exposes an internal environment setting"
assert "secrets" not in service, f"{name} still requires a user-provided Compose secret"
assert "secrets" not in compose, "Compose still defines user-provided internal secrets"
assert all(not service.get("cap_add") for service in services.values()), "Compose adds Linux capabilities"
assert "/var/run/docker.sock:/var/run/docker.sock" not in services["dogama"].get("volumes", []), "main application mounts Docker"
assert not services["agent"].get("ports"), "agent must not publish a port"
assert "agent_state:/var/lib/dogama-agent" in services["agent"]["volumes"], "authenticated agent registry is not persistent"
assert "agent_auth:/var/lib/dogama-agent/secrets:ro" in services["dogama"]["volumes"], "main application cannot read the shared token"
assert "agent_state:/var/lib/dogama-agent:ro" not in services["dogama"]["volumes"], "main application must not read the agent registry"
assert "agent_auth:/var/lib/dogama-agent/secrets" in services["agent"]["volumes"], "agent token is not persistent"
assert all("master_key" not in volume for volume in services["agent"]["volumes"]), "agent can access the master key"
rendered = (ROOT / "compose.yaml").read_text(encoding="utf-8")
assert "${DOGAMA_SERVERS_PATH:-./servers}:/srv/game-servers" in rendered
assert "${DOGAMA_BACKUPS_PATH:-./backups}:/srv/game-backups" in rendered
assert "name: ${DOGAMA_NETWORK:-dogama}" in rendered, "public application network is not configurable"
assert "DOGAMA_DOCKER_NETWORK: ${DOGAMA_GAMES_NETWORK:-dogama-games}" in rendered, "configured game network does not reach the agent"
def validate_workflows() -> None:
ci = (ROOT / ".gitea/workflows/ci.yml").read_text(encoding="utf-8")
release = (ROOT / ".gitea/workflows/release.yml").read_text(encoding="utf-8")
assert "pull_request:" in ci and "branches: [main]" in ci, "normal CI does not cover PRs and main"
assert "docker login" not in ci and "--push" not in ci, "normal CI can publish images"
assert 'tags:\n - "v*.*.*"' in release, "release workflow tag trigger is incorrect"
assert "needs: validate" in release, "release publication does not depend on validation"
assert release.count("--push .") == 2, "release workflow must publish exactly two image targets"
assert "create_gitea_release.py" in release, "release workflow does not create a Gitea Release"
assert "branches: [main]" not in release and "pull_request:" not in release, "release workflow has a non-tag trigger"
def main() -> int:
validate_compose()
validate_workflows()
for fixture in ROOT.rglob("*.json"):
load_json(fixture)
validate_links()
warnings = validate_catalog()
validate_coverage()
print("DoGaMa specification validation passed.")
for warning in warnings:
print(f"WARNING: {warning}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as error:
print(f"ERROR: {error}", file=sys.stderr)
raise SystemExit(1)