From 2a110574016ac89b4eeea350cac76aa1c5d5c00b Mon Sep 17 00:00:00 2001 From: champion Date: Tue, 17 Mar 2026 03:59:09 -0400 Subject: [PATCH 1/6] feat: add extension manifest audit --- dream-server/dream-cli | 27 ++ dream-server/scripts/audit-extensions.py | 453 +++++++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 dream-server/scripts/audit-extensions.py diff --git a/dream-server/dream-cli b/dream-server/dream-cli index 2de96779..b284a616 100755 --- a/dream-server/dream-cli +++ b/dream-server/dream-cli @@ -407,6 +407,30 @@ cmd_doctor() { fi } +cmd_audit() { + check_install + sr_load + + local -a script_args=() + while [[ $# -gt 0 ]]; do + case "$1" in + --json|--strict) + script_args+=("$1") + ;; + --help|-h) + python3 "$INSTALL_DIR/scripts/audit-extensions.py" --help + return 0 + ;; + *) + script_args+=("$(resolve_service "$1")") + ;; + esac + shift + done + + python3 "$INSTALL_DIR/scripts/audit-extensions.py" --project-dir "$INSTALL_DIR" "${script_args[@]}" +} + #============================================================================= # Extension Management Commands #============================================================================= @@ -911,6 +935,7 @@ ${CYAN}Commands:${NC} chat "" Quick chat with the LLM benchmark Run a quick performance test doctor [report] Run diagnostics and write JSON report + audit [service] Audit extension manifests and feature contracts help Show this help ${CYAN}Preset Commands:${NC} @@ -974,6 +999,7 @@ ${CYAN}Examples:${NC} dream restart stt # Restart Whisper (via alias) dream chat "What is 2+2?" # Quick LLM test dream config edit # Edit .env file + dream audit --json whisper # Audit one extension as JSON ${CYAN}Environment:${NC} DREAM_HOME Installation directory (default: ~/dream-server) @@ -1004,6 +1030,7 @@ case "${1:-help}" in chat|c) shift; cmd_chat "$@" ;; benchmark|bench|b) cmd_benchmark ;; doctor|diag|d) shift; cmd_doctor "$@" ;; + audit) shift; cmd_audit "$@" ;; help|h|--help|-h) cmd_help ;; version|v|--version|-v) echo "dream-cli v${VERSION}" ;; *) error "Unknown command: $1. Run 'dream help' for usage." ;; diff --git a/dream-server/scripts/audit-extensions.py b/dream-server/scripts/audit-extensions.py new file mode 100644 index 00000000..4931cf63 --- /dev/null +++ b/dream-server/scripts/audit-extensions.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +"""Audit Dream Server extension manifests for registry 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 + + +MANIFEST_NAMES = ("manifest.yaml", "manifest.yml", "manifest.json") +VALID_CATEGORIES = {"core", "recommended", "optional"} +VALID_TYPES = {"docker", "host-systemd"} +VALID_GPU_BACKENDS = {"amd", "nvidia", "apple", "all", "none"} +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]] + category: str + service_type: str + issues: list[Issue] = field(default_factory=list) + + def add_issue(self, severity: str, code: str, message: str) -> None: + self.issues.append( + Issue( + severity=severity, + code=code, + message=message, + service=self.service_id, + path=str(self.manifest_path), + ) + ) + + @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(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Audit Dream Server extension manifests and feature contracts." + ) + parser.add_argument( + "--project-dir", + type=Path, + default=Path(__file__).resolve().parent.parent, + help="Dream Server project directory (defaults to this repo).", + ) + parser.add_argument("--json", action="store_true", help="Emit JSON output.") + 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 services.", + ) + return parser.parse_args(argv) + + +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 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 in (None, "") or isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 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 discover_services(project_dir: Path) -> tuple[list[ServiceRecord], list[Issue]]: + ext_dir = project_dir / "extensions" / "services" + records: list[ServiceRecord] = [] + issues: list[Issue] = [] + + if not ext_dir.exists(): + return records, [ + Issue( + severity="error", + code="extensions-dir-missing", + message="extensions/services directory not found", + path=str(ext_dir), + ) + ] + + 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: + 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: + 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): + 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): + 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 + + features = manifest.get("features") or [] + if not isinstance(features, list): + issues.append( + Issue( + severity="warning", + code="features-invalid", + message="features should be a list", + service=service.get("id", service_dir.name), + path=str(manifest_path), + ) + ) + features = [] + + records.append( + ServiceRecord( + service_id=str(service.get("id") or service_dir.name), + directory_name=service_dir.name, + directory=service_dir, + manifest_path=manifest_path, + manifest=manifest, + service=service, + features=features, + category=str(service.get("category") or "optional"), + service_type=str(service.get("type") or "docker"), + ) + ) + + return records, issues + + +def filter_records(records: list[ServiceRecord], requested: list[str]) -> tuple[list[ServiceRecord], list[Issue]]: + if not requested: + return records, [] + + selected = [record for record in records if record.service_id in set(requested)] + known = {record.service_id for record in records} + missing = [ + Issue( + severity="error", + code="service-not-found", + message=f"requested service '{service_id}' was not found", + service=service_id, + ) + for service_id in requested + if service_id not in known + ] + return selected, missing + + +def validate_records(records: list[ServiceRecord], global_issues: list[Issue], reference_records: list[ServiceRecord]) -> None: + known_services = {record.service_id for record in reference_records} + alias_owners: dict[str, set[str]] = {} + feature_owners: dict[str, set[str]] = {} + service_id_owners: dict[str, set[str]] = {} + + for record in reference_records: + service_id_owners.setdefault(record.service_id, set()).add(record.directory_name) + for alias in as_string_list(record.service.get("aliases")): + alias_owners.setdefault(alias, set()).add(record.service_id) + for feature in record.features: + if not isinstance(feature, dict): + continue + feature_id = str(feature.get("id") or "") + if feature_id: + feature_owners.setdefault(feature_id, set()).add(record.service_id) + + for service_id, owners in service_id_owners.items(): + if len(owners) > 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") != "dream.services.v1": + record.add_issue("error", "schema-version-invalid", "schema_version must be dream.services.v1") + + if record.directory_name != record.service_id: + record.add_issue("error", "service-id-directory-mismatch", "service.id must match its directory name") + + if not str(service.get("name") or "").strip(): + record.add_issue("error", "service-name-missing", "service.name is required") + + if record.category not in VALID_CATEGORIES: + record.add_issue("error", "service-category-invalid", "service.category is invalid") + + if record.service_type not in VALID_TYPES: + record.add_issue("error", "service-type-invalid", "service.type is invalid") + + if parse_positive_int(service.get("port")) is None: + record.add_issue("error", "service-port-invalid", "service.port must be a positive integer") + + health = str(service.get("health") or "") + if not health.startswith("/"): + record.add_issue("error", "service-health-invalid", "service.health must start with '/'") + + backends = as_string_list(service.get("gpu_backends") or ["amd", "nvidia"]) + invalid_backends = [backend for backend in 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(invalid_backends)}") + + aliases = as_string_list(service.get("aliases")) + seen_aliases: set[str] = set() + for alias in aliases: + if alias in seen_aliases: + record.add_issue("error", "alias-duplicate-local", f"alias '{alias}' is listed more than once") + continue + seen_aliases.add(alias) + owners = alias_owners.get(alias, set()) + if owners - {record.service_id}: + owner = sorted(owners - {record.service_id})[0] + record.add_issue("error", "alias-collision", f"alias '{alias}' already belongs to '{owner}'") + + 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") + + for dep in as_string_list(service.get("depends_on")): + if dep not in known_services: + record.add_issue("error", "dependency-missing", f"depends_on references unknown service '{dep}'") + + for feature in record.features: + if not isinstance(feature, dict): + record.add_issue("error", "feature-invalid", "each feature entry must be a mapping") + 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}'") + + feature_id = str(feature.get("id") or "") + owners = feature_owners.get(feature_id, set()) + if feature_id and owners - {record.service_id}: + owner = sorted(owners - {record.service_id})[0] + record.add_issue("error", "feature-id-collision", f"feature id '{feature_id}' already belongs to '{owner}'") + + for ref in collect_service_references(feature): + if ref not in known_services: + record.add_issue("error", "feature-service-reference-invalid", f"feature references unknown service '{ref}'") + + +def build_payload(project_dir: Path, records: list[ServiceRecord], global_issues: list[Issue], strict: bool, requested: list[str]) -> dict[str, Any]: + errors = sum(1 for issue in global_issues if issue.severity == "error") + warnings = sum(1 for issue in global_issues if issue.severity == "warning") + + services = [] + for record in records: + errors += sum(1 for issue in record.issues if issue.severity == "error") + warnings += sum(1 for issue in record.issues if issue.severity == "warning") + services.append( + { + "service_id": record.service_id, + "category": record.category, + "type": record.service_type, + "status": record.status, + "issues": [asdict(issue) for issue in record.issues], + } + ) + + result = "fail" if errors > 0 or (strict and warnings > 0) else "pass" + return { + "project_dir": str(project_dir), + "requested_services": requested, + "summary": { + "services_audited": len(records), + "errors": errors, + "warnings": warnings, + "strict": strict, + "result": result, + }, + "global_issues": [asdict(issue) for issue in global_issues], + "services": services, + } + + +def print_human_report(payload: dict[str, Any]) -> None: + summary = payload["summary"] + print("Dream Server Extension Audit") + print(f"Project: {payload['project_dir']}") + if payload["requested_services"]: + print(f"Scope: {', '.join(payload['requested_services'])}") + else: + print(f"Scope: all extensions ({summary['services_audited']})") + print("") + + for issue in payload["global_issues"]: + prefix = "ERROR" if issue["severity"] == "error" else "WARN" + print(f"{prefix} global {issue['code']}: {issue['message']}") + if payload["global_issues"]: + print("") + + for service in payload["services"]: + print(f"{service['status'].upper():4} {service['service_id']} ({service['category']}, {service['type']})") + for issue in service["issues"]: + prefix = "ERROR" if issue["severity"] == "error" else "WARN" + print(f" {prefix} {issue['code']}: {issue['message']}") + + print("") + print( + "Summary: " + f"{summary['services_audited']} services, " + f"{summary['errors']} errors, " + f"{summary['warnings']} warnings, " + f"result={summary['result']}" + ) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + project_dir = args.project_dir.resolve() + records, global_issues = discover_services(project_dir) + selected, missing = filter_records(records, args.services) + global_issues.extend(missing) + validate_records(selected, global_issues, records) + payload = build_payload(project_dir, selected, global_issues, args.strict, args.services) + + if args.json: + json.dump(payload, sys.stdout, indent=2) + sys.stdout.write("\n") + else: + print_human_report(payload) + + if payload["summary"]["errors"] > 0: + return 1 + if args.strict and payload["summary"]["warnings"] > 0: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 54a6998e79da868822334bf5c5d1c28baa13ce4a Mon Sep 17 00:00:00 2001 From: champion Date: Tue, 17 Mar 2026 04:00:29 -0400 Subject: [PATCH 2/6] feat: validate extension compose contracts --- dream-server/scripts/audit-extensions.py | 145 +++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/dream-server/scripts/audit-extensions.py b/dream-server/scripts/audit-extensions.py index 4931cf63..9d1738ef 100644 --- a/dream-server/scripts/audit-extensions.py +++ b/dream-server/scripts/audit-extensions.py @@ -17,6 +17,11 @@ VALID_CATEGORIES = {"core", "recommended", "optional"} VALID_TYPES = {"docker", "host-systemd"} VALID_GPU_BACKENDS = {"amd", "nvidia", "apple", "all", "none"} +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"), @@ -44,6 +49,9 @@ class ServiceRecord: 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) @@ -142,6 +150,71 @@ def collect_service_references(feature: dict[str, Any]) -> list[str]: return refs +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 load_compose_service(path: Path, service_id: str) -> Any: + document = load_document(path) + if not isinstance(document, dict): + raise ValueError("compose root must be a mapping") + services = document.get("services", {}) + if not isinstance(services, dict): + raise ValueError("compose services block must be a mapping") + if services == {}: + return {} + return services.get(service_id) + + +def extract_target_ports(service_def: Any) -> list[int]: + if not isinstance(service_def, dict): + return [] + + results: list[int] = [] + for port in as_list(service_def.get("ports")): + if isinstance(port, int) and port > 0: + results.append(port) + continue + if isinstance(port, str): + tail = port.rsplit(":", 1)[-1].split("/", 1)[0] + try: + results.append(int(tail)) + except ValueError: + continue + continue + if isinstance(port, dict): + target = parse_positive_int(port.get("target")) + if target: + results.append(target) + 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 discover_services(project_dir: Path) -> tuple[list[ServiceRecord], list[Issue]]: ext_dir = project_dir / "extensions" / "services" records: list[ServiceRecord] = [] @@ -226,6 +299,17 @@ def discover_services(project_dir: Path) -> tuple[list[ServiceRecord], list[Issu ) features = [] + compose_path, compose_enabled = resolve_compose_path( + service_dir, str(service.get("compose_file") or "") + ) + overlay_paths = { + backend: next( + (service_dir / name for name in names if (service_dir / name).exists()), + None, + ) + for backend, names in OVERLAY_SUFFIXES.items() + } + records.append( ServiceRecord( service_id=str(service.get("id") or service_dir.name), @@ -235,11 +319,20 @@ def discover_services(project_dir: Path) -> tuple[list[ServiceRecord], list[Issu 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"), ) ) + records[-1].overlay_paths = { + backend: path + for backend, path in records[-1].overlay_paths.items() + if path is not None + } + return records, issues @@ -337,6 +430,58 @@ def validate_records(records: list[ServiceRecord], global_issues: list[Issue], r 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") + if record.service_type == "docker": + 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") + elif 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") + elif record.compose_path and record.compose_path.exists(): + try: + base_service = load_compose_service(record.compose_path, record.service_id) + except Exception as exc: + record.add_issue("error", "compose-invalid", f"failed to parse compose file: {exc}") + base_service = None + + if record.category != "core" and base_service is None: + record.add_issue("error", "compose-service-missing", f"compose file does not define service '{record.service_id}'") + + backends_for_stub = [backend for backend in backends if backend in {"amd", "nvidia", "apple"}] + if base_service == {}: + for backend in backends_for_stub: + if backend not in record.overlay_paths: + record.add_issue("error", "overlay-required", f"stub compose requires compose.{backend}.yaml") + + definitions = [definition for definition in [base_service] if isinstance(definition, dict)] + for overlay_path in record.overlay_paths.values(): + try: + overlay_service = load_compose_service(overlay_path, record.service_id) + except Exception as exc: + record.add_issue("error", "overlay-invalid", f"failed to parse {overlay_path.name}: {exc}") + continue + if overlay_service is None: + record.add_issue("error", "overlay-service-missing", f"{overlay_path.name} does not define service '{record.service_id}'") + continue + definitions.append(overlay_service) + + container_name = str(service.get("container_name") or "") + if container_name and definitions: + if not any(definition.get("container_name") == container_name for definition in definitions): + record.add_issue("error", "container-name-mismatch", f"container_name '{container_name}' was not found in compose definitions") + + port = parse_positive_int(service.get("port")) + if port is not None and definitions: + if not any(port in extract_target_ports(definition) for definition in definitions): + record.add_issue("error", "compose-port-mismatch", f"no compose port mapping targets manifest service.port {port}") + + external_port_env = str(service.get("external_port_env") or "") + if external_port_env and definitions: + if not any(ports_reference_env(definition, external_port_env) for definition in definitions): + record.add_issue("warning", "compose-port-env-unused", f"compose ports do not reference '{external_port_env}'") + + if definitions and not any("healthcheck" in definition for definition in definitions): + record.add_issue("warning", "healthcheck-missing", "docker service has no healthcheck stanza in compose definitions") + for dep in as_string_list(service.get("depends_on")): if dep not in known_services: record.add_issue("error", "dependency-missing", f"depends_on references unknown service '{dep}'") From 8c479a502bbae54707352f06eada5b476ca8ced0 Mon Sep 17 00:00:00 2001 From: champion Date: Tue, 17 Mar 2026 04:01:15 -0400 Subject: [PATCH 3/6] test: cover extension audit edge cases --- dream-server/tests/test-extension-audit.sh | 218 +++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 dream-server/tests/test-extension-audit.sh diff --git a/dream-server/tests/test-extension-audit.sh b/dream-server/tests/test-extension-audit.sh new file mode 100644 index 00000000..964657cd --- /dev/null +++ b/dream-server/tests/test-extension-audit.sh @@ -0,0 +1,218 @@ +#!/bin/bash +# Regression tests for scripts/audit-extensions.py + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +AUDIT_SCRIPT="$PROJECT_DIR/scripts/audit-extensions.py" + +PASS=0 +FAIL=0 + +pass() { echo "PASS $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL $1"; FAIL=$((FAIL + 1)); } + +make_fixture_root() { + local root + root=$(mktemp -d) + mkdir -p "$root/extensions/services" + echo "$root" +} + +write_service() { + local root="$1" + local service_id="$2" + shift 2 + local dir="$root/extensions/services/$service_id" + mkdir -p "$dir" + "$@" "$dir" +} + +service_llama() { + local dir="$1" + cat > "$dir/manifest.yaml" <<'EOF' +schema_version: dream.services.v1 + +service: + id: llama-server + name: llama-server + aliases: [llm] + container_name: dream-llama-server + port: 8080 + external_port_env: OLLAMA_PORT + external_port_default: 8080 + health: /health + type: docker + gpu_backends: [amd, nvidia] + category: core + depends_on: [] +EOF +} + +service_search() { + local dir="$1" + cat > "$dir/manifest.yaml" <<'EOF' +schema_version: dream.services.v1 + +service: + id: search + name: Search + aliases: [search-ui] + container_name: dream-search + port: 8080 + external_port_env: SEARCH_PORT + external_port_default: 8888 + health: /healthz + type: docker + gpu_backends: [amd, nvidia] + compose_file: compose.yaml + category: recommended + depends_on: [llama-server] + +features: + - id: search-ui + name: Search UI + description: Private search + category: productivity + priority: 3 + requirements: + services: [search] + enabled_services_all: [search] +EOF + cat > "$dir/compose.yaml" <<'EOF' +services: + search: + image: example/search:latest + container_name: dream-search + ports: + - "127.0.0.1:${SEARCH_PORT:-8888}:8080" + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/healthz"] +EOF +} + +service_image_gen() { + local dir="$1" + cat > "$dir/manifest.yaml" <<'EOF' +schema_version: dream.services.v1 + +service: + id: image-gen + name: Image Generation + aliases: [] + container_name: dream-image-gen + port: 8188 + external_port_env: IMAGE_GEN_PORT + external_port_default: 8188 + health: / + type: docker + gpu_backends: [amd, nvidia] + compose_file: compose.yaml + category: optional + depends_on: [] +EOF + cat > "$dir/compose.yaml" <<'EOF' +services: {} +EOF + cat > "$dir/compose.amd.yaml" <<'EOF' +services: + image-gen: + image: example/image-gen:amd + container_name: dream-image-gen + ports: + - "${IMAGE_GEN_PORT:-8188}:8188" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8188/"] +EOF + cat > "$dir/compose.nvidia.yaml" <<'EOF' +services: + image-gen: + image: example/image-gen:nvidia + container_name: dream-image-gen + ports: + - "${IMAGE_GEN_PORT:-8188}:8188" + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8188/"] +EOF +} + +create_valid_project() { + local root="$1" + write_service "$root" "llama-server" service_llama + write_service "$root" "search" service_search + write_service "$root" "image-gen" service_image_gen +} + +run_audit() { + python3 "$AUDIT_SCRIPT" --project-dir "$1" "${@:2}" +} + +assert_json_expr() { + local file="$1" + local expr="$2" + python3 - "$file" "$expr" <<'PY' +import json +import sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +expr = sys.argv[2] +value = eval(expr, {"payload": payload}) +raise SystemExit(0 if value else 1) +PY +} + +ROOT_A=$(make_fixture_root) +ROOT_B=$(make_fixture_root) +ROOT_C=$(make_fixture_root) +ROOT_D=$(make_fixture_root) +trap 'rm -rf "$ROOT_A" "$ROOT_B" "$ROOT_C" "$ROOT_D"' EXIT + +create_valid_project "$ROOT_A" +if run_audit "$ROOT_A" --json > /tmp/ext-audit-a.json; then + pass "valid fixture passes" +else + fail "valid fixture passes" +fi +assert_json_expr /tmp/ext-audit-a.json "payload['summary']['result'] == 'pass'" && pass "valid fixture reports pass" || fail "valid fixture reports pass" + +create_valid_project "$ROOT_B" +python3 - "$ROOT_B/extensions/services/search/manifest.yaml" <<'PY' +import yaml, sys +path = sys.argv[1] +doc = yaml.safe_load(open(path, encoding="utf-8")) +doc["service"]["depends_on"] = ["missing-service"] +yaml.safe_dump(doc, open(path, "w", encoding="utf-8"), sort_keys=False) +PY +if run_audit "$ROOT_B" --json > /tmp/ext-audit-b.json 2>/dev/null; then + fail "missing dependency should fail" +else + pass "missing dependency fails" +fi +assert_json_expr /tmp/ext-audit-b.json "any(issue['code'] == 'dependency-missing' for svc in payload['services'] for issue in svc['issues'])" && pass "missing dependency is reported" || fail "missing dependency is reported" + +create_valid_project "$ROOT_C" +rm -f "$ROOT_C/extensions/services/image-gen/compose.nvidia.yaml" +if run_audit "$ROOT_C" --json > /tmp/ext-audit-c.json 2>/dev/null; then + fail "missing overlay should fail" +else + pass "missing overlay fails" +fi +assert_json_expr /tmp/ext-audit-c.json "any(issue['code'] == 'overlay-required' for svc in payload['services'] for issue in svc['issues'])" && pass "missing overlay is reported" || fail "missing overlay is reported" + +create_valid_project "$ROOT_D" +python3 - "$ROOT_D/extensions/services/search/compose.yaml" <<'PY' +import yaml, sys +path = sys.argv[1] +doc = yaml.safe_load(open(path, encoding="utf-8")) +doc["services"]["search"]["ports"] = ["127.0.0.1:${SEARCH_PORT:-8888}:9090"] +yaml.safe_dump(doc, open(path, "w", encoding="utf-8"), sort_keys=False) +PY +if run_audit "$ROOT_D" --json > /tmp/ext-audit-d.json 2>/dev/null; then + fail "port mismatch should fail" +else + pass "port mismatch fails" +fi +assert_json_expr /tmp/ext-audit-d.json "any(issue['code'] == 'compose-port-mismatch' for svc in payload['services'] for issue in svc['issues'])" && pass "port mismatch is reported" || fail "port mismatch is reported" + +echo "Result: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]] From 547ad41141396b0ab9fc87fe8e87e2e5968129e0 Mon Sep 17 00:00:00 2001 From: champion Date: Tue, 17 Mar 2026 04:02:04 -0400 Subject: [PATCH 4/6] feat: export extension catalog --- dream-server/scripts/extension-catalog.py | 168 ++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 dream-server/scripts/extension-catalog.py diff --git a/dream-server/scripts/extension-catalog.py b/dream-server/scripts/extension-catalog.py new file mode 100644 index 00000000..3555feb7 --- /dev/null +++ b/dream-server/scripts/extension-catalog.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Export Dream Server extension metadata as JSON or Markdown.""" + +from __future__ import annotations + +import argparse +import json +from collections import Counter +from pathlib import Path +from typing import Any + +import yaml + + +MANIFEST_NAMES = ("manifest.yaml", "manifest.yml", "manifest.json") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Export a catalog of Dream Server extensions." + ) + parser.add_argument( + "--project-dir", + type=Path, + default=Path(__file__).resolve().parent.parent, + help="Dream Server project directory (defaults to this repo).", + ) + parser.add_argument( + "--format", + choices=("json", "markdown"), + default="json", + help="Output format (default: json).", + ) + return parser.parse_args(argv) + + +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 as_string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item) for item in value if str(item)] + return [str(value)] + + +def service_enabled(service_dir: Path, compose_file: str) -> str: + if not compose_file: + return "always-on" + enabled = service_dir / compose_file + disabled = service_dir / f"{compose_file}.disabled" + if enabled.exists(): + return "enabled" + if disabled.exists(): + return "disabled" + return "missing" + + +def discover_catalog(project_dir: Path) -> dict[str, Any]: + services_dir = project_dir / "extensions" / "services" + services: list[dict[str, Any]] = [] + + for service_dir in sorted(services_dir.iterdir()): + if not service_dir.is_dir(): + continue + + manifest_path = find_manifest(service_dir) + if manifest_path is None: + continue + + document = load_document(manifest_path) + if not isinstance(document, dict): + continue + + service = document.get("service") + if not isinstance(service, dict): + continue + + service_id = str(service.get("id") or service_dir.name) + compose_file = str(service.get("compose_file") or "") + features = document.get("features") if isinstance(document.get("features"), list) else [] + + services.append( + { + "id": service_id, + "name": str(service.get("name") or service_id), + "category": str(service.get("category") or "optional"), + "type": str(service.get("type") or "docker"), + "status": service_enabled(service_dir, compose_file), + "aliases": as_string_list(service.get("aliases")), + "depends_on": as_string_list(service.get("depends_on")), + "gpu_backends": as_string_list(service.get("gpu_backends") or ["amd", "nvidia"]), + "feature_count": len(features), + "path": str(service_dir.relative_to(project_dir)), + } + ) + + counts = Counter(service["category"] for service in services) + status_counts = Counter(service["status"] for service in services) + + return { + "summary": { + "service_count": len(services), + "categories": dict(sorted(counts.items())), + "statuses": dict(sorted(status_counts.items())), + }, + "services": services, + } + + +def render_markdown(catalog: dict[str, Any]) -> str: + summary = catalog["summary"] + lines = [ + "# Dream Server Extension Catalog", + "", + f"- Services: {summary['service_count']}", + f"- Categories: {json.dumps(summary['categories'], sort_keys=True)}", + f"- Statuses: {json.dumps(summary['statuses'], sort_keys=True)}", + "", + "| ID | Category | Status | Type | Features | Aliases | Depends On |", + "|---|---|---|---|---:|---|---|", + ] + + for service in catalog["services"]: + alias_text = ", ".join(service["aliases"]) or "-" + depends_text = ", ".join(service["depends_on"]) or "-" + lines.append( + "| {id} | {category} | {status} | {type} | {feature_count} | {aliases} | {depends_on} |".format( + id=service["id"], + category=service["category"], + status=service["status"], + type=service["type"], + feature_count=service["feature_count"], + aliases=alias_text, + depends_on=depends_text, + ) + ) + + return "\n".join(lines) + "\n" + + +def main() -> int: + args = parse_args() + project_dir = args.project_dir.resolve() + catalog = discover_catalog(project_dir) + + if args.format == "markdown": + print(render_markdown(catalog), end="") + else: + print(json.dumps(catalog, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 5102b2bd40183b19f7b67ecaf328a4912324fcfe Mon Sep 17 00:00:00 2001 From: champion Date: Tue, 17 Mar 2026 05:57:07 -0400 Subject: [PATCH 5/6] feat: add catalog category and status filters --- dream-server/scripts/extension-catalog.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/dream-server/scripts/extension-catalog.py b/dream-server/scripts/extension-catalog.py index 3555feb7..24fbb940 100644 --- a/dream-server/scripts/extension-catalog.py +++ b/dream-server/scripts/extension-catalog.py @@ -31,6 +31,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default="json", help="Output format (default: json).", ) + parser.add_argument( + "--category", + choices=("core", "recommended", "optional"), + help="Filter services by category.", + ) + parser.add_argument( + "--status", + choices=("always-on", "enabled", "disabled", "missing"), + help="Filter services by runtime status.", + ) return parser.parse_args(argv) @@ -69,7 +79,7 @@ def service_enabled(service_dir: Path, compose_file: str) -> str: return "missing" -def discover_catalog(project_dir: Path) -> dict[str, Any]: +def discover_catalog(project_dir: Path, *, category: str | None = None, status: str | None = None) -> dict[str, Any]: services_dir = project_dir / "extensions" / "services" services: list[dict[str, Any]] = [] @@ -108,6 +118,11 @@ def discover_catalog(project_dir: Path) -> dict[str, Any]: } ) + if category: + services = [service for service in services if service["category"] == category] + if status: + services = [service for service in services if service["status"] == status] + counts = Counter(service["category"] for service in services) status_counts = Counter(service["status"] for service in services) @@ -155,7 +170,7 @@ def render_markdown(catalog: dict[str, Any]) -> str: def main() -> int: args = parse_args() project_dir = args.project_dir.resolve() - catalog = discover_catalog(project_dir) + catalog = discover_catalog(project_dir, category=args.category, status=args.status) if args.format == "markdown": print(render_markdown(catalog), end="") From 32ff1d2db0c0008b02ddbccb1a27b60dd37f2606 Mon Sep 17 00:00:00 2001 From: champion Date: Tue, 17 Mar 2026 07:50:26 -0400 Subject: [PATCH 6/6] feat: expand extension catalog exporter and add regression tests --- dream-server/scripts/extension-catalog.py | 481 ++++++++++++++++--- dream-server/tests/test-extension-catalog.sh | 185 +++++++ 2 files changed, 611 insertions(+), 55 deletions(-) create mode 100644 dream-server/tests/test-extension-catalog.sh diff --git a/dream-server/scripts/extension-catalog.py b/dream-server/scripts/extension-catalog.py index 24fbb940..3f181c49 100644 --- a/dream-server/scripts/extension-catalog.py +++ b/dream-server/scripts/extension-catalog.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -"""Export Dream Server extension metadata as JSON or Markdown.""" +"""Export Dream Server extension metadata as JSON, Markdown, or NDJSON.""" from __future__ import annotations import argparse import json +import sys from collections import Counter +from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any @@ -13,6 +15,61 @@ MANIFEST_NAMES = ("manifest.yaml", "manifest.yml", "manifest.json") +VALID_CATEGORIES = {"core", "recommended", "optional"} +VALID_TYPES = {"docker", "host-systemd"} +VALID_STATUSES = {"always-on", "enabled", "disabled", "missing"} + + +@dataclass +class CatalogIssue: + code: str + message: str + severity: str = "error" + service: str | None = None + path: str | None = None + + +@dataclass +class FeatureEntry: + id: str + name: str + category: str + priority: int + description: str = "" + + +@dataclass +class ServiceEntry: + id: str + name: str + category: str + type: str + status: str + aliases: list[str] + depends_on: list[str] + gpu_backends: list[str] + feature_count: int + path: str + compose_file: str + features: list[FeatureEntry] = field(default_factory=list) + + def to_dict(self, include_features: bool) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": self.id, + "name": self.name, + "category": self.category, + "type": self.type, + "status": self.status, + "aliases": self.aliases, + "depends_on": self.depends_on, + "gpu_backends": self.gpu_backends, + "feature_count": self.feature_count, + "path": self.path, + "compose_file": self.compose_file, + } + if include_features: + payload["features"] = [asdict(feature) for feature in self.features] + return payload def parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -27,20 +84,68 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: ) parser.add_argument( "--format", - choices=("json", "markdown"), + choices=("json", "markdown", "ndjson"), default="json", help="Output format (default: json).", ) parser.add_argument( "--category", - choices=("core", "recommended", "optional"), + choices=sorted(VALID_CATEGORIES), help="Filter services by category.", ) parser.add_argument( "--status", - choices=("always-on", "enabled", "disabled", "missing"), + choices=sorted(VALID_STATUSES), help="Filter services by runtime status.", ) + parser.add_argument( + "--service-type", + choices=sorted(VALID_TYPES), + help="Filter services by service.type.", + ) + parser.add_argument( + "--gpu-backend", + action="append", + default=[], + help="Filter services that include one or more gpu_backends values.", + ) + parser.add_argument( + "--service", + action="append", + default=[], + help="Include only specific service IDs (repeatable).", + ) + parser.add_argument( + "--include-features", + action="store_true", + help="Include full feature payload in JSON/NDJSON output.", + ) + parser.add_argument( + "--sort", + choices=("id", "name", "category", "status", "feature_count"), + default="id", + help="Sort key for services output.", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Return non-zero if catalog issues are found.", + ) + parser.add_argument( + "--summary-only", + action="store_true", + help="Print only summary output.", + ) + parser.add_argument( + "--output", + type=Path, + help="Write output to file instead of stdout.", + ) + parser.add_argument( + "--compact", + action="store_true", + help="Compact JSON output (json/ndjson only).", + ) return parser.parse_args(argv) @@ -67,7 +172,16 @@ def as_string_list(value: Any) -> list[str]: return [str(value)] -def service_enabled(service_dir: Path, compose_file: str) -> str: +def as_int(value: Any, default: int = 0) -> int: + try: + if isinstance(value, bool): + return default + return int(value) + except (TypeError, ValueError): + return default + + +def service_status(service_dir: Path, compose_file: str) -> str: if not compose_file: return "always-on" enabled = service_dir / compose_file @@ -79,9 +193,141 @@ def service_enabled(service_dir: Path, compose_file: str) -> str: return "missing" -def discover_catalog(project_dir: Path, *, category: str | None = None, status: str | None = None) -> dict[str, Any]: +def collect_features(document: dict[str, Any], issues: list[CatalogIssue], service_id: str, manifest_path: Path) -> list[FeatureEntry]: + raw_features = document.get("features") + if raw_features is None: + return [] + if not isinstance(raw_features, list): + issues.append( + CatalogIssue( + code="features-not-list", + message="features should be a list", + severity="warning", + service=service_id, + path=str(manifest_path), + ) + ) + return [] + + features: list[FeatureEntry] = [] + for idx, raw in enumerate(raw_features): + if not isinstance(raw, dict): + issues.append( + CatalogIssue( + code="feature-invalid", + message=f"feature[{idx}] is not an object", + severity="warning", + service=service_id, + path=str(manifest_path), + ) + ) + continue + feature_id = str(raw.get("id") or f"feature-{idx}") + features.append( + FeatureEntry( + id=feature_id, + name=str(raw.get("name") or feature_id), + category=str(raw.get("category") or "uncategorized"), + priority=as_int(raw.get("priority"), 0), + description=str(raw.get("description") or ""), + ) + ) + return features + + +def build_service_entry(service_dir: Path, manifest_path: Path, document: dict[str, Any], issues: list[CatalogIssue]) -> ServiceEntry | None: + service = document.get("service") + if not isinstance(service, dict): + issues.append( + CatalogIssue( + code="service-section-missing", + message="manifest missing service mapping", + service=service_dir.name, + path=str(manifest_path), + ) + ) + return None + + service_id = str(service.get("id") or service_dir.name) + category = str(service.get("category") or "optional") + service_type = str(service.get("type") or "docker") + compose_file = str(service.get("compose_file") or "") + aliases = as_string_list(service.get("aliases")) + depends_on = as_string_list(service.get("depends_on")) + gpu_backends = as_string_list(service.get("gpu_backends") or ["amd", "nvidia"]) + features = collect_features(document, issues, service_id, manifest_path) + + if document.get("schema_version") != "dream.services.v1": + issues.append( + CatalogIssue( + code="schema-version-invalid", + message="schema_version should be dream.services.v1", + service=service_id, + path=str(manifest_path), + ) + ) + + if service_dir.name != service_id: + issues.append( + CatalogIssue( + code="service-id-dir-mismatch", + message=f"service.id '{service_id}' differs from directory '{service_dir.name}'", + severity="warning", + service=service_id, + path=str(manifest_path), + ) + ) + + if category not in VALID_CATEGORIES: + issues.append( + CatalogIssue( + code="category-invalid", + message=f"unknown category '{category}'", + service=service_id, + path=str(manifest_path), + ) + ) + + if service_type not in VALID_TYPES: + issues.append( + CatalogIssue( + code="type-invalid", + message=f"unknown service.type '{service_type}'", + service=service_id, + path=str(manifest_path), + ) + ) + + return ServiceEntry( + id=service_id, + name=str(service.get("name") or service_id), + category=category, + type=service_type, + status=service_status(service_dir, compose_file), + aliases=aliases, + depends_on=depends_on, + gpu_backends=gpu_backends, + feature_count=len(features), + path=str(service_dir.relative_to(service_dir.parents[2])), + compose_file=compose_file, + features=features, + ) + + +def discover_services(project_dir: Path) -> tuple[list[ServiceEntry], list[CatalogIssue]]: services_dir = project_dir / "extensions" / "services" - services: list[dict[str, Any]] = [] + issues: list[CatalogIssue] = [] + entries: list[ServiceEntry] = [] + + if not services_dir.exists(): + issues.append( + CatalogIssue( + code="extensions-dir-missing", + message=f"missing directory: {services_dir}", + path=str(services_dir), + ) + ) + return entries, issues for service_dir in sorted(services_dir.iterdir()): if not service_dir.is_dir(): @@ -89,93 +335,218 @@ def discover_catalog(project_dir: Path, *, category: str | None = None, status: manifest_path = find_manifest(service_dir) if manifest_path is None: + issues.append( + CatalogIssue( + code="manifest-missing", + message="service directory has no manifest file", + severity="warning", + service=service_dir.name, + path=str(service_dir), + ) + ) continue - document = load_document(manifest_path) - if not isinstance(document, dict): + try: + document = load_document(manifest_path) + except Exception as exc: + issues.append( + CatalogIssue( + code="manifest-parse-failed", + message=str(exc), + service=service_dir.name, + path=str(manifest_path), + ) + ) continue - service = document.get("service") - if not isinstance(service, dict): + if not isinstance(document, dict): + issues.append( + CatalogIssue( + code="manifest-root-invalid", + message="manifest root must be an object", + service=service_dir.name, + path=str(manifest_path), + ) + ) continue - service_id = str(service.get("id") or service_dir.name) - compose_file = str(service.get("compose_file") or "") - features = document.get("features") if isinstance(document.get("features"), list) else [] - - services.append( - { - "id": service_id, - "name": str(service.get("name") or service_id), - "category": str(service.get("category") or "optional"), - "type": str(service.get("type") or "docker"), - "status": service_enabled(service_dir, compose_file), - "aliases": as_string_list(service.get("aliases")), - "depends_on": as_string_list(service.get("depends_on")), - "gpu_backends": as_string_list(service.get("gpu_backends") or ["amd", "nvidia"]), - "feature_count": len(features), - "path": str(service_dir.relative_to(project_dir)), - } - ) + entry = build_service_entry(service_dir, manifest_path, document, issues) + if entry is not None: + entries.append(entry) + + return entries, issues + +def apply_filters( + entries: list[ServiceEntry], + *, + category: str | None, + status: str | None, + service_type: str | None, + gpu_backends: list[str], + service_ids: list[str], +) -> list[ServiceEntry]: + filtered = entries if category: - services = [service for service in services if service["category"] == category] + filtered = [entry for entry in filtered if entry.category == category] if status: - services = [service for service in services if service["status"] == status] - - counts = Counter(service["category"] for service in services) - status_counts = Counter(service["status"] for service in services) + filtered = [entry for entry in filtered if entry.status == status] + if service_type: + filtered = [entry for entry in filtered if entry.type == service_type] + if gpu_backends: + required = set(gpu_backends) + filtered = [ + entry + for entry in filtered + if required.intersection(set(entry.gpu_backends)) + ] + if service_ids: + allowed = set(service_ids) + filtered = [entry for entry in filtered if entry.id in allowed] + return filtered + + +def sort_entries(entries: list[ServiceEntry], sort_key: str) -> list[ServiceEntry]: + if sort_key == "feature_count": + return sorted(entries, key=lambda item: (item.feature_count, item.id)) + if sort_key == "name": + return sorted(entries, key=lambda item: (item.name.lower(), item.id)) + if sort_key == "category": + return sorted(entries, key=lambda item: (item.category, item.id)) + if sort_key == "status": + return sorted(entries, key=lambda item: (item.status, item.id)) + return sorted(entries, key=lambda item: item.id) + + +def make_summary(entries: list[ServiceEntry], issues: list[CatalogIssue]) -> dict[str, Any]: + categories = Counter(entry.category for entry in entries) + statuses = Counter(entry.status for entry in entries) + service_types = Counter(entry.type for entry in entries) + feature_count = sum(entry.feature_count for entry in entries) + issue_counts = Counter(issue.severity for issue in issues) return { - "summary": { - "service_count": len(services), - "categories": dict(sorted(counts.items())), - "statuses": dict(sorted(status_counts.items())), + "service_count": len(entries), + "feature_count": feature_count, + "categories": dict(sorted(categories.items())), + "statuses": dict(sorted(statuses.items())), + "types": dict(sorted(service_types.items())), + "issues": { + "total": len(issues), + "errors": issue_counts.get("error", 0), + "warnings": issue_counts.get("warning", 0), }, - "services": services, } -def render_markdown(catalog: dict[str, Any]) -> str: - summary = catalog["summary"] +def build_payload(entries: list[ServiceEntry], issues: list[CatalogIssue], include_features: bool) -> dict[str, Any]: + return { + "summary": make_summary(entries, issues), + "issues": [asdict(issue) for issue in issues], + "services": [entry.to_dict(include_features=include_features) for entry in entries], + } + + +def render_markdown(payload: dict[str, Any]) -> str: + summary = payload["summary"] lines = [ "# Dream Server Extension Catalog", "", f"- Services: {summary['service_count']}", + f"- Features: {summary['feature_count']}", f"- Categories: {json.dumps(summary['categories'], sort_keys=True)}", f"- Statuses: {json.dumps(summary['statuses'], sort_keys=True)}", "", - "| ID | Category | Status | Type | Features | Aliases | Depends On |", - "|---|---|---|---|---:|---|---|", + "| ID | Category | Status | Type | Features | GPU | Aliases | Depends On |", + "|---|---|---|---|---:|---|---|---|", ] - for service in catalog["services"]: - alias_text = ", ".join(service["aliases"]) or "-" - depends_text = ", ".join(service["depends_on"]) or "-" + for service in payload["services"]: + aliases = ", ".join(service["aliases"]) or "-" + deps = ", ".join(service["depends_on"]) or "-" + backends = ", ".join(service["gpu_backends"]) or "-" lines.append( - "| {id} | {category} | {status} | {type} | {feature_count} | {aliases} | {depends_on} |".format( + "| {id} | {category} | {status} | {type} | {feature_count} | {gpu} | {aliases} | {depends_on} |".format( id=service["id"], category=service["category"], status=service["status"], type=service["type"], feature_count=service["feature_count"], - aliases=alias_text, - depends_on=depends_text, + gpu=backends, + aliases=aliases, + depends_on=deps, ) ) + if payload["issues"]: + lines.extend( + [ + "", + "## Catalog Issues", + "", + "| Severity | Code | Service | Message |", + "|---|---|---|---|", + ] + ) + for issue in payload["issues"]: + lines.append( + "| {severity} | {code} | {service} | {message} |".format( + severity=issue["severity"], + code=issue["code"], + service=issue.get("service") or "-", + message=str(issue["message"]).replace("|", "\\|"), + ) + ) + return "\n".join(lines) + "\n" -def main() -> int: - args = parse_args() +def render_ndjson(payload: dict[str, Any], compact: bool) -> str: + separators = (",", ":") if compact else (",", ": ") + lines = [] + for service in payload["services"]: + lines.append(json.dumps(service, separators=separators)) + return "\n".join(lines) + ("\n" if lines else "") + + +def emit_output(text: str, output: Path | None) -> None: + if output is None: + sys.stdout.write(text) + return + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(text, encoding="utf-8") + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) project_dir = args.project_dir.resolve() - catalog = discover_catalog(project_dir, category=args.category, status=args.status) - if args.format == "markdown": - print(render_markdown(catalog), end="") + entries, issues = discover_services(project_dir) + entries = apply_filters( + entries, + category=args.category, + status=args.status, + service_type=args.service_type, + gpu_backends=args.gpu_backend, + service_ids=args.service, + ) + entries = sort_entries(entries, args.sort) + payload = build_payload(entries, issues, include_features=bool(args.include_features)) + + if args.summary_only: + content = json.dumps(payload["summary"], indent=None if args.compact else 2) + emit_output(content + "\n", args.output) + elif args.format == "markdown": + emit_output(render_markdown(payload), args.output) + elif args.format == "ndjson": + emit_output(render_ndjson(payload, compact=bool(args.compact)), args.output) else: - print(json.dumps(catalog, indent=2)) + indent = None if args.compact else 2 + separators = (",", ":") if args.compact else None + emit_output(json.dumps(payload, indent=indent, separators=separators) + "\n", args.output) + + if args.strict and payload["summary"]["issues"]["errors"] > 0: + return 2 return 0 diff --git a/dream-server/tests/test-extension-catalog.sh b/dream-server/tests/test-extension-catalog.sh new file mode 100644 index 00000000..d0d75816 --- /dev/null +++ b/dream-server/tests/test-extension-catalog.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# Regression tests for scripts/extension-catalog.py + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +CATALOG_SCRIPT="$PROJECT_DIR/scripts/extension-catalog.py" + +PASS=0 +FAIL=0 + +pass() { + echo "PASS $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL $1" + FAIL=$((FAIL + 1)) +} + +run_expect() { + local expected_exit="$1" + local label="$2" + shift 2 + + set +e + "$@" >/tmp/dream-catalog-test.out 2>/tmp/dream-catalog-test.err + local exit_code=$? + set -e + + if [[ "$exit_code" -eq "$expected_exit" ]]; then + pass "$label" + else + fail "$label (expected $expected_exit, got $exit_code)" + sed -n '1,20p' /tmp/dream-catalog-test.err + fi +} + +assert_json_expr() { + local file="$1" + local expr="$2" + python3 - "$file" "$expr" <<'PY' +import json +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +expr = sys.argv[2] +value = eval(expr, {"payload": payload}) +raise SystemExit(0 if value else 1) +PY +} + +[[ -f "$CATALOG_SCRIPT" ]] || { echo "missing $CATALOG_SCRIPT"; exit 1; } +python3 -m py_compile "$CATALOG_SCRIPT" +pass "extension-catalog.py compiles" + +run_expect 0 "--help exits 0" python3 "$CATALOG_SCRIPT" --help + +run_expect 0 "default JSON output succeeds" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" >/tmp/dream-catalog.json +if assert_json_expr /tmp/dream-catalog.json "payload['summary']['service_count'] > 0"; then + pass "default payload has services" +else + fail "default payload has services" +fi + +run_expect 0 "category filter works" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --category core +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --category core >/tmp/dream-catalog-core.json +if assert_json_expr /tmp/dream-catalog-core.json "all(s['category'] == 'core' for s in payload['services'])"; then + pass "category filter only returns core" +else + fail "category filter only returns core" +fi + +run_expect 0 "status filter works" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --status enabled +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --status enabled >/tmp/dream-catalog-enabled.json +if assert_json_expr /tmp/dream-catalog-enabled.json "all(s['status'] == 'enabled' for s in payload['services'])"; then + pass "status filter only returns enabled" +else + fail "status filter only returns enabled" +fi + +run_expect 0 "service filter works" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --service whisper +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --service whisper >/tmp/dream-catalog-whisper.json +if assert_json_expr /tmp/dream-catalog-whisper.json "payload['summary']['service_count'] == 1 and payload['services'][0]['id'] == 'whisper'"; then + pass "service filter returns whisper" +else + fail "service filter returns whisper" +fi + +run_expect 0 "gpu backend filter works" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --gpu-backend amd +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --gpu-backend amd >/tmp/dream-catalog-amd.json +if assert_json_expr /tmp/dream-catalog-amd.json "all('amd' in s['gpu_backends'] for s in payload['services'])"; then + pass "gpu filter includes only amd-capable services" +else + fail "gpu filter includes only amd-capable services" +fi + +run_expect 0 "include-features adds feature payload" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --service whisper --include-features +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --service whisper --include-features >/tmp/dream-catalog-features.json +if assert_json_expr /tmp/dream-catalog-features.json "'features' in payload['services'][0]"; then + pass "include-features returns features list" +else + fail "include-features returns features list" +fi + +run_expect 0 "summary-only JSON works" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --summary-only +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --summary-only >/tmp/dream-catalog-summary.json +if assert_json_expr /tmp/dream-catalog-summary.json "'service_count' in payload and 'categories' in payload"; then + pass "summary-only has expected keys" +else + fail "summary-only has expected keys" +fi + +run_expect 0 "markdown output works" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --format markdown +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --format markdown >/tmp/dream-catalog-markdown.txt +if grep -q "| ID | Category | Status | Type | Features | GPU | Aliases | Depends On |" /tmp/dream-catalog-markdown.txt; then + pass "markdown table header present" +else + fail "markdown table header present" +fi + +run_expect 0 "ndjson output works" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --format ndjson --service whisper +python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --format ndjson --service whisper >/tmp/dream-catalog.ndjson +if python3 - <<'PY' +import json +line = open("/tmp/dream-catalog.ndjson", encoding="utf-8").read().strip() +obj = json.loads(line) +raise SystemExit(0 if obj["id"] == "whisper" else 1) +PY +then + pass "ndjson emits valid object lines" +else + fail "ndjson emits valid object lines" +fi + +run_expect 0 "output file option writes content" \ + python3 "$CATALOG_SCRIPT" --project-dir "$PROJECT_DIR" --output /tmp/dream-catalog-output.json +if [[ -s /tmp/dream-catalog-output.json ]]; then + pass "output file created" +else + fail "output file created" +fi + +FIXTURE_ROOT=$(mktemp -d) +trap 'rm -rf "$FIXTURE_ROOT" /tmp/dream-catalog-test.out /tmp/dream-catalog-test.err /tmp/dream-catalog.json /tmp/dream-catalog-core.json /tmp/dream-catalog-enabled.json /tmp/dream-catalog-whisper.json /tmp/dream-catalog-amd.json /tmp/dream-catalog-features.json /tmp/dream-catalog-summary.json /tmp/dream-catalog-markdown.txt /tmp/dream-catalog.ndjson /tmp/dream-catalog-output.json /tmp/dream-catalog-fixture.json' EXIT + +mkdir -p "$FIXTURE_ROOT/extensions/services/bad-service" +cat > "$FIXTURE_ROOT/extensions/services/bad-service/manifest.yaml" <<'EOF' +schema_version: dream.services.v1 +service: + id: bad-service + name: Bad Service + category: invalid-category + type: docker + compose_file: compose.yaml +EOF +cat > "$FIXTURE_ROOT/extensions/services/bad-service/compose.yaml" <<'EOF' +services: + bad-service: + image: example/bad:latest +EOF + +run_expect 2 "strict mode fails on catalog issues" \ + python3 "$CATALOG_SCRIPT" --project-dir "$FIXTURE_ROOT" --strict +python3 "$CATALOG_SCRIPT" --project-dir "$FIXTURE_ROOT" >/tmp/dream-catalog-fixture.json +if assert_json_expr /tmp/dream-catalog-fixture.json "payload['summary']['issues']['errors'] >= 1"; then + pass "fixture reports catalog errors" +else + fail "fixture reports catalog errors" +fi + +echo "Result: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]]