#!/usr/bin/env python3 """ Audit ODS extensions for manifest, compose, overlay, and feature contract consistency. """ from __future__ import annotations import argparse import json import sys from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any import yaml VALID_CATEGORIES = {"core", "recommended", "optional"} VALID_TYPES = {"docker", "host-systemd"} VALID_GPU_BACKENDS = {"amd", "nvidia", "apple", "all", "none"} MANIFEST_NAMES = ("manifest.yaml", "manifest.yml", "manifest.json") OVERLAY_SUFFIXES = { "amd": ("compose.amd.yaml", "compose.amd.yml"), "nvidia": ("compose.nvidia.yaml", "compose.nvidia.yml"), "apple": ("compose.apple.yaml", "compose.apple.yml"), } FEATURE_SERVICE_KEYS = ( ("requirements", "services"), ("requirements", "services_all"), ("requirements", "services_any"), ("enabled_services_all",), ("enabled_services_any",), ) @dataclass class Issue: severity: str code: str message: str service: str | None = None path: str | None = None @dataclass class ServiceRecord: service_id: str directory_name: str directory: Path manifest_path: Path manifest: dict[str, Any] service: dict[str, Any] features: list[dict[str, Any]] compose_path: Path | None compose_enabled: bool overlay_paths: dict[str, Path] category: str service_type: str issues: list[Issue] = field(default_factory=list) def add_issue( self, severity: str, code: str, message: str, *, path: Path | None = None, ) -> None: self.issues.append( Issue( severity=severity, code=code, message=message, service=self.service_id, path=str(path) if path else None, ) ) @property def status(self) -> str: if any(issue.severity == "error" for issue in self.issues): return "fail" if any(issue.severity == "warning" for issue in self.issues): return "warn" return "pass" def parse_args() -> argparse.Namespace: default_project = Path(__file__).resolve().parent.parent parser = argparse.ArgumentParser( description="Audit ODS extension manifests and compose fragments." ) parser.add_argument( "--project-dir", type=Path, default=default_project, help="ODS project directory (defaults to the repo root).", ) parser.add_argument( "--json", action="store_true", help="Emit JSON instead of the human-readable report.", ) parser.add_argument( "--strict", action="store_true", help="Treat warnings as failures.", ) parser.add_argument( "services", nargs="*", help="Optional service IDs to audit. Defaults to all discovered services.", ) return parser.parse_args() def load_document(path: Path) -> Any: with path.open("r", encoding="utf-8") as handle: if path.suffix == ".json": return json.load(handle) return yaml.safe_load(handle) def find_manifest(service_dir: Path) -> Path | None: for name in MANIFEST_NAMES: candidate = service_dir / name if candidate.exists(): return candidate return None def resolve_compose_path(service_dir: Path, compose_file: str) -> tuple[Path | None, bool]: if not compose_file: return None, False enabled = service_dir / compose_file if enabled.exists(): return enabled, True disabled = service_dir / f"{compose_file}.disabled" if disabled.exists(): return disabled, False return enabled, False def discover_services(project_dir: Path) -> tuple[list[ServiceRecord], list[Issue]]: ext_dir = project_dir / "extensions" / "services" records: list[ServiceRecord] = [] global_issues: list[Issue] = [] if not ext_dir.exists(): global_issues.append( Issue( severity="error", code="extensions-dir-missing", message="extensions/services directory not found", path=str(ext_dir), ) ) return records, global_issues for service_dir in sorted(ext_dir.iterdir()): if not service_dir.is_dir(): continue manifest_path = find_manifest(service_dir) if manifest_path is None: global_issues.append( Issue( severity="warning", code="manifest-missing", message="service directory has no manifest", service=service_dir.name, path=str(service_dir), ) ) continue try: manifest = load_document(manifest_path) except Exception as exc: global_issues.append( Issue( severity="error", code="manifest-invalid", message=f"failed to parse manifest: {exc}", service=service_dir.name, path=str(manifest_path), ) ) continue if not isinstance(manifest, dict): global_issues.append( Issue( severity="error", code="manifest-shape-invalid", message="manifest root must be a mapping", service=service_dir.name, path=str(manifest_path), ) ) continue service = manifest.get("service") if not isinstance(service, dict): global_issues.append( Issue( severity="error", code="service-section-missing", message="manifest must contain a service mapping", service=service_dir.name, path=str(manifest_path), ) ) continue service_id = str(service.get("id") or service_dir.name) features = manifest.get("features") or [] if not isinstance(features, list): features = [] compose_path, compose_enabled = resolve_compose_path( service_dir, str(service.get("compose_file") or "") ) overlay_paths: dict[str, Path] = {} for backend, names in OVERLAY_SUFFIXES.items(): for name in names: candidate = service_dir / name if candidate.exists(): overlay_paths[backend] = candidate break records.append( ServiceRecord( service_id=service_id, directory_name=service_dir.name, directory=service_dir, manifest_path=manifest_path, manifest=manifest, service=service, features=features, compose_path=compose_path, compose_enabled=compose_enabled, overlay_paths=overlay_paths, category=str(service.get("category") or "optional"), service_type=str(service.get("type") or "docker"), ) ) return records, global_issues def as_list(value: Any) -> list[Any]: if value is None: return [] if isinstance(value, list): return value return [value] def as_string_list(value: Any) -> list[str]: return [str(item) for item in as_list(value) if str(item)] def parse_positive_int(value: Any) -> int | None: if value is None or value == "": return None if isinstance(value, bool): return None try: integer = int(value) except (TypeError, ValueError): return None return integer if integer > 0 else None def parse_non_negative_int(value: Any) -> int | None: if value is None or value == "": return None if isinstance(value, bool): return None try: integer = int(value) except (TypeError, ValueError): return None return integer if integer >= 0 else None def collect_service_references(feature: dict[str, Any]) -> list[str]: refs: list[str] = [] for path in FEATURE_SERVICE_KEYS: target: Any = feature for key in path: if not isinstance(target, dict): target = None break target = target.get(key) refs.extend(as_string_list(target)) return refs def load_compose_definitions(record: ServiceRecord) -> tuple[dict[str, Any], dict[str, Path]]: definitions: dict[str, Any] = {} source_paths: dict[str, Path] = {} def capture(label: str, path: Path) -> None: try: doc = load_document(path) except Exception as exc: record.add_issue("error", "compose-invalid", f"failed to parse compose file: {exc}", path=path) return if doc is None: doc = {} if not isinstance(doc, dict): record.add_issue("error", "compose-shape-invalid", "compose root must be a mapping", path=path) return services = doc.get("services", {}) if not isinstance(services, dict): record.add_issue("error", "compose-services-invalid", "compose services block must be a mapping", path=path) return definitions[label] = services.get(record.service_id) source_paths[label] = path if record.compose_path and record.compose_path.exists(): capture("base", record.compose_path) for backend, path in record.overlay_paths.items(): capture(backend, path) return definitions, source_paths def extract_target_ports(service_def: Any) -> list[int]: """Return every container-internal port a service makes available. Looks at BOTH `ports:` (host-bound) and `expose:` (internal-only) declarations so internal-only services like ods-hermes (fronted by hermes-proxy) still register their port. Without this, a service that switches from `ports:` to `expose:` would fail compose-port-mismatch even though its container is fully reachable by other containers on the bridge network. """ if not isinstance(service_def, dict): return [] results: list[int] = [] for port in as_list(service_def.get("ports")): if isinstance(port, int): if port > 0: results.append(port) continue if isinstance(port, str): tail = port.rsplit(":", 1)[-1] tail = tail.split("/", 1)[0] try: results.append(int(tail)) except ValueError: continue continue if isinstance(port, dict): target = port.get("target") target_int = parse_positive_int(target) if target_int: results.append(target_int) # `expose:` is a list of "" or "/" strings/ints — # internal-network-only, never bound to the host. for entry in as_list(service_def.get("expose")): if isinstance(entry, int): if entry > 0: results.append(entry) continue if isinstance(entry, str): head = entry.split("/", 1)[0] try: results.append(int(head)) except ValueError: continue return results def ports_reference_env(service_def: Any, env_name: str) -> bool: if not isinstance(service_def, dict) or not env_name: return False needle = f"${{{env_name}" for port in as_list(service_def.get("ports")): if isinstance(port, str) and needle in port: return True if isinstance(port, dict): published = port.get("published") if isinstance(published, str) and env_name in published: return True return False def service_has_runtime_definition(definitions: dict[str, Any]) -> bool: for definition in definitions.values(): if isinstance(definition, dict): return True return False def base_is_stub(definitions: dict[str, Any]) -> bool: base = definitions.get("base") return base == {} or base is None def validate_records( records: list[ServiceRecord], global_issues: list[Issue], *, reference_records: list[ServiceRecord] | None = None, ) -> None: reference_records = reference_records or records known_services = {record.service_id: record for record in reference_records} selected_ids = set(known_services) alias_owners: dict[str, set[str]] = {} feature_owners: dict[str, set[str]] = {} id_owners: dict[str, set[str]] = {} for ref in reference_records: id_owners.setdefault(ref.service_id, set()).add(ref.directory_name) for alias in as_string_list(ref.service.get("aliases")): alias_owners.setdefault(alias, set()).add(ref.service_id) for feature in ref.features: if isinstance(feature, dict): feature_id = str(feature.get("id") or "") if feature_id: feature_owners.setdefault(feature_id, set()).add(ref.service_id) for service_id, directories in sorted(id_owners.items()): if len(directories) > 1: global_issues.append( Issue( severity="error", code="service-id-collision", message=f"service.id '{service_id}' is declared in multiple directories", service=service_id, ) ) for record in records: manifest = record.manifest service = record.service if manifest.get("schema_version") != "ods.services.v1": record.add_issue( "error", "schema-version-invalid", "schema_version must be ods.services.v1", path=record.manifest_path, ) if record.directory_name != record.service_id: record.add_issue( "error", "service-id-directory-mismatch", f"directory '{record.directory_name}' does not match service.id '{record.service_id}'", path=record.manifest_path, ) name = str(service.get("name") or "").strip() if not name: record.add_issue("error", "service-name-missing", "service.name is required", path=record.manifest_path) if record.category not in VALID_CATEGORIES: record.add_issue( "error", "service-category-invalid", f"service.category must be one of {sorted(VALID_CATEGORIES)}", path=record.manifest_path, ) if record.service_type not in VALID_TYPES: record.add_issue( "error", "service-type-invalid", f"service.type must be one of {sorted(VALID_TYPES)}", path=record.manifest_path, ) # `host_network: true` marks services that use Docker's # `network_mode: host` (Tailscale being the canonical example) — they # don't have a Docker-mapped port or an HTTP health endpoint, so the # port/health requirements don't apply. The flag also flips off the # compose-port-mismatch check further down. host_network = bool(service.get("host_network")) port = parse_positive_int(service.get("port")) if port is None and not host_network: record.add_issue("error", "service-port-invalid", "service.port must be a positive integer", path=record.manifest_path) health = str(service.get("health") or "") if not health.startswith("/") and not host_network: record.add_issue( "error", "service-health-invalid", "service.health must start with '/'", path=record.manifest_path, ) ext_port_default = service.get("external_port_default") if ext_port_default not in (None, "") and parse_non_negative_int(ext_port_default) is None: record.add_issue( "error", "service-external-port-invalid", "service.external_port_default must be a non-negative integer when set", path=record.manifest_path, ) external_port_env = str(service.get("external_port_env") or "") if external_port_env and not external_port_env.replace("_", "").isalnum(): record.add_issue( "error", "service-port-env-invalid", "service.external_port_env must be shell-friendly", path=record.manifest_path, ) gpu_backends = as_string_list(service.get("gpu_backends") or ["amd", "nvidia"]) invalid_backends = [backend for backend in gpu_backends if backend not in VALID_GPU_BACKENDS] if invalid_backends: record.add_issue( "error", "service-gpu-backends-invalid", f"unknown gpu_backends values: {', '.join(sorted(invalid_backends))}", path=record.manifest_path, ) alias_list = as_string_list(service.get("aliases")) seen_local_aliases: set[str] = set() for alias in alias_list: if alias in seen_local_aliases: record.add_issue( "error", "alias-duplicate-local", f"alias '{alias}' is listed more than once", path=record.manifest_path, ) continue seen_local_aliases.add(alias) owners = alias_owners.get(alias, set()) if owners - {record.service_id}: record.add_issue( "error", "alias-collision", f"alias '{alias}' already belongs to service '{sorted(owners - {record.service_id})[0]}'", path=record.manifest_path, ) for dep in as_string_list(service.get("depends_on")): if dep not in selected_ids: record.add_issue( "error", "dependency-missing", f"depends_on references unknown service '{dep}'", path=record.manifest_path, ) env_vars = service.get("env_vars") if env_vars is not None and not isinstance(env_vars, list): record.add_issue( "error", "env-vars-invalid", "service.env_vars must be a list when present", path=record.manifest_path, ) elif isinstance(env_vars, list): for item in env_vars: if not isinstance(item, dict) or not str(item.get("key") or "").strip(): record.add_issue( "error", "env-var-entry-invalid", "each service.env_vars entry must contain a non-empty key", path=record.manifest_path, ) for feature in record.features: if not isinstance(feature, dict): record.add_issue( "error", "feature-invalid", "each feature entry must be a mapping", path=record.manifest_path, ) continue for required in ("id", "name", "description", "category", "priority"): if feature.get(required) in (None, ""): record.add_issue( "error", "feature-field-missing", f"feature is missing required field '{required}'", path=record.manifest_path, ) feature_id = str(feature.get("id") or "") if feature_id: owners = feature_owners.get(feature_id, set()) if owners - {record.service_id}: record.add_issue( "error", "feature-id-collision", f"feature id '{feature_id}' already belongs to service '{sorted(owners - {record.service_id})[0]}'", path=record.manifest_path, ) for ref in collect_service_references(feature): if ref not in selected_ids: record.add_issue( "error", "feature-service-reference-invalid", f"feature references unknown service '{ref}'", path=record.manifest_path, ) if record.service_type == "host-systemd" and service.get("compose_file"): record.add_issue( "warning", "compose-file-unexpected", "host-systemd service usually should not declare compose_file", path=record.manifest_path, ) if record.service_type != "docker": continue compose_file = str(service.get("compose_file") or "") if record.category != "core" and not compose_file: record.add_issue( "error", "compose-file-missing", "non-core docker services must declare service.compose_file", path=record.manifest_path, ) continue if compose_file and (record.compose_path is None or not record.compose_path.exists()): record.add_issue( "error", "compose-file-missing", f"compose file '{compose_file}' was not found (enabled or disabled)", path=record.manifest_path, ) continue definitions, source_paths = load_compose_definitions(record) if not service_has_runtime_definition(definitions): if record.category != "core" and record.compose_enabled: record.add_issue( "error", "compose-service-missing", f"no compose definition found for service '{record.service_id}'", path=record.compose_path or record.manifest_path, ) continue if base_is_stub(definitions): for backend in gpu_backends: if backend in {"all", "none", "apple"}: continue if backend not in record.overlay_paths: record.add_issue( "error", "overlay-required", f"stub compose requires compose.{backend}.yaml because gpu_backends includes '{backend}'", path=record.compose_path or record.manifest_path, ) for backend, overlay_path in record.overlay_paths.items(): if gpu_backends and "all" not in gpu_backends and backend not in gpu_backends: record.add_issue( "warning", "overlay-backend-extra", f"{overlay_path.name} exists but service.gpu_backends does not include '{backend}'", path=overlay_path, ) container_name = str(service.get("container_name") or "") if container_name: matched = False for label, definition in definitions.items(): if isinstance(definition, dict) and definition.get("container_name") == container_name: matched = True break if not matched and record.category != "core": record.add_issue( "error", "container-name-mismatch", f"container_name '{container_name}' was not found in compose definitions", path=source_paths.get("base", record.manifest_path), ) if port is not None and not host_network: # host-network services share the host's network namespace; their # listen ports come from whatever process binds inside the host, # not from Docker's port mapping. Skipping this check is what # makes host_network: true viable in the first place. port_matches = False for definition in definitions.values(): if port in extract_target_ports(definition): port_matches = True break if not port_matches and record.category != "core": record.add_issue( "error", "compose-port-mismatch", f"no compose port mapping targets manifest service.port {port}", path=source_paths.get("base", record.manifest_path), ) if external_port_env: env_ref_found = False for definition in definitions.values(): if ports_reference_env(definition, external_port_env): env_ref_found = True break if not env_ref_found and record.category != "core": record.add_issue( "warning", "compose-port-env-unused", f"compose ports do not reference service.external_port_env '{external_port_env}'", path=source_paths.get("base", record.manifest_path), ) healthcheck_found = False for definition in definitions.values(): if isinstance(definition, dict) and "healthcheck" in definition: healthcheck_found = True break if not healthcheck_found and record.category != "core": record.add_issue( "warning", "healthcheck-missing", "docker service has no healthcheck stanza in its compose definitions", path=source_paths.get("base", record.manifest_path), ) def filter_records(records: list[ServiceRecord], requested_services: list[str]) -> tuple[list[ServiceRecord], list[Issue]]: if not requested_services: return records, [] requested = set(requested_services) available = {record.service_id for record in records} missing = sorted(requested - available) filtered = [record for record in records if record.service_id in requested] issues = [ Issue( severity="error", code="service-not-found", message=f"requested service '{service_id}' was not found", service=service_id, ) for service_id in missing ] return filtered, issues def build_payload( project_dir: Path, records: list[ServiceRecord], global_issues: list[Issue], strict: bool, requested_services: list[str], ) -> dict[str, Any]: error_count = sum(1 for issue in global_issues if issue.severity == "error") warning_count = sum(1 for issue in global_issues if issue.severity == "warning") service_items = [] for record in records: error_count += sum(1 for issue in record.issues if issue.severity == "error") warning_count += sum(1 for issue in record.issues if issue.severity == "warning") service_items.append( { "service_id": record.service_id, "directory": str(record.directory), "category": record.category, "type": record.service_type, "compose_enabled": record.compose_enabled, "status": record.status, "issues": [asdict(issue) for issue in record.issues], } ) failed = error_count > 0 or (strict and warning_count > 0) return { "project_dir": str(project_dir), "requested_services": requested_services, "summary": { "services_audited": len(records), "errors": error_count, "warnings": warning_count, "strict": strict, "result": "fail" if failed else "pass", }, "global_issues": [asdict(issue) for issue in global_issues], "services": service_items, } def print_human_report(payload: dict[str, Any]) -> None: summary = payload["summary"] requested = payload["requested_services"] print("ODS Extension Audit") print(f"Project: {payload['project_dir']}") print( f"Scope: {', '.join(requested)}" if requested else f"Scope: all extensions ({summary['services_audited']})" ) print("") for issue in payload["global_issues"]: prefix = "ERROR" if issue["severity"] == "error" else "WARN" location = f" [{issue['path']}]" if issue.get("path") else "" print(f"{prefix} global {issue['code']}: {issue['message']}{location}") if payload["global_issues"]: print("") for item in payload["services"]: label = item["status"].upper() print(f"{label:4} {item['service_id']} ({item['category']}, {item['type']})") for issue in item["issues"]: prefix = "ERROR" if issue["severity"] == "error" else "WARN" location = f" [{issue['path']}]" if issue.get("path") else "" print(f" {prefix} {issue['code']}: {issue['message']}{location}") if not payload["services"] and not payload["global_issues"]: print("No services matched the requested scope.") print("") print( "Summary: " f"{summary['services_audited']} services, " f"{summary['errors']} errors, " f"{summary['warnings']} warnings, " f"result={summary['result']}" ) def main() -> int: args = parse_args() project_dir = args.project_dir.resolve() records, global_issues = discover_services(project_dir) filtered_records, filter_issues = filter_records(records, args.services) global_issues.extend(filter_issues) validate_records(filtered_records, global_issues, reference_records=records) payload = build_payload( project_dir=project_dir, records=filtered_records, global_issues=global_issues, strict=args.strict, requested_services=args.services, ) if args.json: json.dump(payload, sys.stdout, indent=2) sys.stdout.write("\n") else: print_human_report(payload) summary = payload["summary"] if summary["errors"] > 0: return 1 if args.strict and summary["warnings"] > 0: return 1 return 0 if __name__ == "__main__": raise SystemExit(main())