9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
885 lines
30 KiB
Python
885 lines
30 KiB
Python
#!/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 "<port>" or "<port>/<proto>" 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())
|