211 lines
10 KiB
Python
211 lines
10 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"):
|
|
path = (template_path.parent / template["game"]["artwork"][field]).resolve()
|
|
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_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)
|