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
448 lines
17 KiB
Python
448 lines
17 KiB
Python
"""Shared configuration and manifest loading for ODS Dashboard API."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- Paths ---
|
|
|
|
INSTALL_DIR = os.environ.get("ODS_INSTALL_DIR", os.path.expanduser("~/ods"))
|
|
DATA_DIR = os.environ.get("ODS_DATA_DIR", os.path.expanduser("~/.ods"))
|
|
EXTENSIONS_DIR = Path(
|
|
os.environ.get(
|
|
"ODS_EXTENSIONS_DIR",
|
|
str(Path(INSTALL_DIR) / "extensions" / "services")
|
|
)
|
|
)
|
|
|
|
DEFAULT_SERVICE_HOST = os.environ.get("SERVICE_HOST", "host.docker.internal")
|
|
GPU_BACKEND = os.environ.get("GPU_BACKEND", "nvidia")
|
|
|
|
|
|
def _read_env_from_file(key: str) -> str:
|
|
"""Read a variable from the .env file when not available in process environment."""
|
|
env_path = Path(INSTALL_DIR) / ".env"
|
|
try:
|
|
for line in env_path.read_text().splitlines():
|
|
if line.startswith(f"{key}="):
|
|
return line.split("=", 1)[1].strip().strip("\"'")
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
# --- Manifest Loading ---
|
|
|
|
|
|
def _read_manifest_file(path: Path) -> dict[str, Any]:
|
|
"""Load a JSON or YAML extension manifest file."""
|
|
text = path.read_text()
|
|
if path.suffix.lower() == ".json":
|
|
data = json.loads(text)
|
|
else:
|
|
data = yaml.safe_load(text)
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Manifest root must be an object")
|
|
return data
|
|
|
|
|
|
def load_extension_manifests(
|
|
manifest_dir: Path, gpu_backend: str,
|
|
) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]], list[dict[str, str]]]:
|
|
"""Load service and feature definitions from extension manifests.
|
|
|
|
Returns a 3-tuple: (services, features, errors) where *errors* is a list
|
|
of ``{"file": ..., "error": ...}`` dicts for manifests that failed to load.
|
|
"""
|
|
services: dict[str, dict[str, Any]] = {}
|
|
features: list[dict[str, Any]] = []
|
|
errors: list[dict[str, str]] = []
|
|
loaded = 0
|
|
|
|
if not manifest_dir.exists():
|
|
logger.info("Extension manifest directory not found: %s", manifest_dir)
|
|
return services, features, errors
|
|
|
|
manifest_files: list[Path] = []
|
|
for item in sorted(manifest_dir.iterdir()):
|
|
if item.is_dir():
|
|
for name in ("manifest.yaml", "manifest.yml", "manifest.json"):
|
|
candidate = item / name
|
|
if candidate.exists():
|
|
manifest_files.append(candidate)
|
|
break
|
|
elif item.suffix.lower() in (".yaml", ".yml", ".json"):
|
|
manifest_files.append(item)
|
|
|
|
for path in manifest_files:
|
|
try:
|
|
# Skip disabled extensions (compose.yaml.disabled convention)
|
|
ext_dir = path.parent
|
|
if (ext_dir / "compose.yaml.disabled").exists() or (ext_dir / "compose.yml.disabled").exists():
|
|
logger.debug("Skipping disabled extension: %s", ext_dir.name)
|
|
continue
|
|
|
|
manifest = _read_manifest_file(path)
|
|
if manifest.get("schema_version") != "ods.services.v1":
|
|
logger.warning("Skipping manifest with unsupported schema_version: %s", path)
|
|
errors.append({"file": str(path), "error": "Unsupported schema_version"})
|
|
continue
|
|
|
|
service = manifest.get("service")
|
|
if isinstance(service, dict):
|
|
service_id = service.get("id")
|
|
if not service_id:
|
|
raise ValueError("service.id is required")
|
|
supported = service.get("gpu_backends", ["amd", "nvidia", "apple"])
|
|
if gpu_backend == "apple":
|
|
if service.get("type") == "host-systemd":
|
|
continue # Linux-only service, not available on macOS
|
|
# All docker services run on macOS regardless of gpu_backends declaration
|
|
elif gpu_backend not in supported and "all" not in supported:
|
|
continue
|
|
|
|
host_env = service.get("host_env")
|
|
default_host = service.get("default_host", "localhost")
|
|
host = os.environ.get(host_env, default_host) if host_env else default_host
|
|
|
|
ext_port_env = service.get("external_port_env")
|
|
ext_port_default = service.get("external_port_default", service.get("port", 0))
|
|
if ext_port_env:
|
|
val = os.environ.get(ext_port_env) or _read_env_from_file(ext_port_env)
|
|
external_port = int(val) if val else int(ext_port_default)
|
|
else:
|
|
external_port = int(ext_port_default)
|
|
|
|
services[service_id] = {
|
|
"host": host,
|
|
"port": int(service.get("port", 0)),
|
|
"external_port": external_port,
|
|
"health": service.get("health", "/health"),
|
|
"name": service.get("name", service_id),
|
|
"ui_path": service.get("ui_path", "/"),
|
|
"external_link": bool(service.get("external_link", True)),
|
|
"container_name": service.get("container_name", f"ods-{service_id}"),
|
|
"depends_on": service.get("depends_on", []),
|
|
"category": service.get("category", "optional"),
|
|
"host_network": bool(service.get("host_network", False)),
|
|
"setup_hook": service.get("setup_hook", ""),
|
|
"hooks": service.get("hooks", {}),
|
|
"gpu_backends": service.get("gpu_backends", []),
|
|
**({"type": service["type"]} if "type" in service else {}),
|
|
**({"health_port": int(service["health_port"])} if "health_port" in service else {}),
|
|
}
|
|
|
|
manifest_features = manifest.get("features", [])
|
|
if isinstance(manifest_features, list):
|
|
for feature in manifest_features:
|
|
if not isinstance(feature, dict):
|
|
continue
|
|
supported = feature.get("gpu_backends", ["amd", "nvidia", "apple"])
|
|
if gpu_backend != "apple" and gpu_backend not in supported and "all" not in supported:
|
|
continue
|
|
if feature.get("id") and feature.get("name"):
|
|
missing = [f for f in ("description", "icon", "category", "setup_time", "priority") if f not in feature]
|
|
if missing:
|
|
logger.warning("Feature '%s' in %s missing optional fields: %s", feature["id"], path, ", ".join(missing))
|
|
features.append(feature)
|
|
|
|
loaded += 1
|
|
except (yaml.YAMLError, json.JSONDecodeError, OSError, KeyError, TypeError, ValueError) as e:
|
|
logger.warning("Failed loading manifest %s: %s", path, e)
|
|
errors.append({"file": str(path), "error": str(e)})
|
|
|
|
logger.info("Loaded %d extension manifests (%d services, %d features)", loaded, len(services), len(features))
|
|
return services, features, errors
|
|
|
|
|
|
# --- Service Registry ---
|
|
|
|
MANIFEST_SERVICES, MANIFEST_FEATURES, MANIFEST_ERRORS = load_extension_manifests(EXTENSIONS_DIR, GPU_BACKEND)
|
|
SERVICES = MANIFEST_SERVICES
|
|
if not SERVICES:
|
|
logger.error("No services loaded from manifests in %s — dashboard will have no services", EXTENSIONS_DIR)
|
|
|
|
# Lemonade serves at /api/v1 instead of llama.cpp's /v1. Override the
|
|
# health path so the dashboard poll loop hits the correct endpoint.
|
|
LLM_BACKEND = os.environ.get("LLM_BACKEND", "")
|
|
if LLM_BACKEND == "lemonade" and "llama-server" in SERVICES:
|
|
SERVICES["llama-server"]["health"] = "/api/v1/health"
|
|
logger.info("Lemonade backend detected — overriding llama-server health to /api/v1/health")
|
|
|
|
# --- Features ---
|
|
|
|
FEATURES = MANIFEST_FEATURES
|
|
if not FEATURES:
|
|
logger.warning("No features loaded from manifests — check %s", EXTENSIONS_DIR)
|
|
|
|
# --- Workflow Config ---
|
|
|
|
|
|
def resolve_workflow_dir() -> Path:
|
|
"""Resolve canonical workflow directory with legacy fallback."""
|
|
env_dir = os.environ.get("WORKFLOW_DIR")
|
|
if env_dir:
|
|
return Path(env_dir)
|
|
canonical = Path(INSTALL_DIR) / "config" / "n8n"
|
|
if canonical.exists():
|
|
return canonical
|
|
return Path(INSTALL_DIR) / "workflows"
|
|
|
|
|
|
WORKFLOW_DIR = resolve_workflow_dir()
|
|
WORKFLOW_CATALOG_FILE = WORKFLOW_DIR / "catalog.json"
|
|
DEFAULT_WORKFLOW_CATALOG = {"workflows": [], "categories": {}}
|
|
|
|
def _default_n8n_url() -> str:
|
|
cfg = SERVICES.get("n8n", {})
|
|
host = cfg.get("host", "n8n")
|
|
port = cfg.get("port", 5678)
|
|
return f"http://{host}:{port}"
|
|
|
|
N8N_URL = os.environ.get("N8N_URL", _default_n8n_url())
|
|
N8N_API_KEY = os.environ.get("N8N_API_KEY", "")
|
|
|
|
# --- Setup / Personas ---
|
|
|
|
SETUP_CONFIG_DIR = Path(DATA_DIR) / "config"
|
|
|
|
PERSONAS = {
|
|
"general": {
|
|
"name": "General Helper",
|
|
"system_prompt": "You are a friendly and helpful AI assistant. You're knowledgeable, patient, and aim to be genuinely useful. Keep responses clear and conversational.",
|
|
"icon": "\U0001f4ac"
|
|
},
|
|
"coding": {
|
|
"name": "Coding Buddy",
|
|
"system_prompt": "You are a skilled programmer and technical assistant. You write clean, well-documented code and explain technical concepts clearly. You're precise, thorough, and love solving problems.",
|
|
"icon": "\U0001f4bb"
|
|
},
|
|
"creative": {
|
|
"name": "Creative Writer",
|
|
"system_prompt": "You are an imaginative creative writer and storyteller. You craft vivid descriptions, engaging narratives, and think outside the box. You're expressive and enjoy wordplay.",
|
|
"icon": "\U0001f3a8"
|
|
}
|
|
}
|
|
|
|
# --- Sidebar Icons ---
|
|
|
|
SIDEBAR_ICONS = {
|
|
"open-webui": "MessageSquare",
|
|
"n8n": "Network",
|
|
"openclaw": "Bot",
|
|
"hermes": "Bot",
|
|
"hermes-proxy": "Shield",
|
|
"opencode": "Code",
|
|
"perplexica": "Search",
|
|
"comfyui": "Image",
|
|
"token-spy": "Terminal",
|
|
"langfuse": "BarChart2",
|
|
}
|
|
|
|
# --- Extensions Portal ---
|
|
|
|
CATALOG_PATH = Path(os.environ.get(
|
|
"ODS_EXTENSIONS_CATALOG",
|
|
str(Path(INSTALL_DIR) / "config" / "extensions-catalog.json")
|
|
))
|
|
|
|
EXTENSIONS_LIBRARY_DIR = Path(os.environ.get(
|
|
"ODS_EXTENSIONS_LIBRARY_DIR",
|
|
str(Path(DATA_DIR) / "extensions-library")
|
|
))
|
|
|
|
USER_EXTENSIONS_DIR = Path(os.environ.get(
|
|
"ODS_USER_EXTENSIONS_DIR",
|
|
str(Path(DATA_DIR) / "user-extensions")
|
|
))
|
|
|
|
def _load_core_service_ids() -> frozenset:
|
|
core_ids_path = Path(INSTALL_DIR) / "config" / "core-service-ids.json"
|
|
if core_ids_path.exists():
|
|
try:
|
|
return frozenset(json.loads(core_ids_path.read_text(encoding="utf-8")))
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
# Fallback to hardcoded list
|
|
return frozenset({
|
|
"dashboard-api", "dashboard", "llama-server", "open-webui",
|
|
"litellm", "langfuse", "hermes", "hermes-proxy", "n8n", "openclaw", "opencode",
|
|
"perplexica", "searxng", "qdrant", "tts", "whisper",
|
|
"embeddings", "token-spy", "comfyui", "ape", "privacy-shield",
|
|
})
|
|
|
|
|
|
CORE_SERVICE_IDS = _load_core_service_ids()
|
|
|
|
# Always-on services defined in docker-compose.base.yml — never manageable via API.
|
|
# Distinct from CORE_SERVICE_IDS (the full built-in service allowlist).
|
|
ALWAYS_ON_SERVICES: frozenset = frozenset({"llama-server", "open-webui", "dashboard", "dashboard-api"})
|
|
|
|
|
|
def load_extension_catalog() -> list[dict]:
|
|
"""Load the static extensions catalog JSON. Returns empty list on failure."""
|
|
if not CATALOG_PATH.exists():
|
|
logger.info("Extensions catalog not found at %s", CATALOG_PATH)
|
|
return []
|
|
try:
|
|
data = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
|
|
return data.get("extensions", [])
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning("Failed to load extensions catalog: %s", e)
|
|
return []
|
|
|
|
|
|
EXTENSION_CATALOG = load_extension_catalog()
|
|
|
|
# --- Host Agent ---
|
|
|
|
def _running_inside_container() -> bool:
|
|
"""Best-effort check for Docker/Podman/containerd runtime context."""
|
|
if Path("/.dockerenv").exists():
|
|
return True
|
|
try:
|
|
cgroup = Path("/proc/1/cgroup").read_text(encoding="utf-8").lower()
|
|
except OSError:
|
|
return False
|
|
return any(marker in cgroup for marker in ("docker", "containerd", "kubepods", "podman"))
|
|
|
|
|
|
def _detect_container_default_gateway(route_path: str = "/proc/net/route") -> str:
|
|
"""Return this container's default-gateway IP, or empty on failure.
|
|
|
|
Reads /proc/net/route directly so the container image doesn't need
|
|
iproute2 installed. The default route line has destination 00000000 and
|
|
a little-endian-hex gateway in the 3rd field.
|
|
|
|
Why this matters: dashboard-api runs on `ods-network` (a custom bridge,
|
|
e.g. 172.18.0.0/16). On Linux, the ods-host-agent binds to that network's
|
|
host-side gateway when it can, so targeting this container's default gateway
|
|
is routable without depending on `host.docker.internal:host-gateway`, which
|
|
Docker often resolves to the default bridge gateway (172.17.0.1). That
|
|
default bridge address is unreachable from custom networks under Docker's
|
|
default DOCKER-ISOLATION-STAGE-2 iptables rules.
|
|
"""
|
|
try:
|
|
with open(route_path, "r", encoding="utf-8") as f:
|
|
for line in f.readlines()[1:]:
|
|
fields = line.strip().split()
|
|
# destination == 0.0.0.0 AND flags has RTF_GATEWAY (0x2)
|
|
if len(fields) < 4 or fields[1] != "00000000":
|
|
continue
|
|
gw_hex = fields[2]
|
|
try:
|
|
flags = int(fields[3], 16)
|
|
gateway_raw = int(gw_hex, 16)
|
|
except ValueError:
|
|
continue
|
|
if not (flags & 0x2) or gateway_raw == 0 or len(gw_hex) != 8:
|
|
continue
|
|
# Little-endian: 0100A8C0 -> 192.168.0.1
|
|
return ".".join(
|
|
str(int(gw_hex[i:i + 2], 16)) for i in (6, 4, 2, 0)
|
|
)
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def _resolve_agent_host() -> str:
|
|
"""Pick the host name/IP to use for the ods-host-agent.
|
|
|
|
Priority:
|
|
1. ODS_AGENT_HOST env (explicit operator override)
|
|
2. The container's own default-gateway IP (works regardless of which
|
|
Docker network the container is on)
|
|
3. host.docker.internal (legacy fallback — broken on custom networks
|
|
under default Docker iptables, but kept so explicit operator setups
|
|
relying on it don't silently change)
|
|
"""
|
|
explicit = os.environ.get("ODS_AGENT_HOST", "").strip()
|
|
if explicit:
|
|
return explicit
|
|
if _running_inside_container():
|
|
gw = _detect_container_default_gateway()
|
|
if gw:
|
|
logger.info("Resolved ODS_AGENT_HOST=%s via /proc/net/route", gw)
|
|
return gw
|
|
logger.warning(
|
|
"Could not detect container default gateway; falling back to "
|
|
"host.docker.internal. If host-agent calls time out, set "
|
|
"ODS_AGENT_HOST=<host-ip> in dashboard-api's environment."
|
|
)
|
|
return "host.docker.internal"
|
|
|
|
|
|
AGENT_HOST = _resolve_agent_host()
|
|
AGENT_PORT = int(os.environ.get("ODS_AGENT_PORT", "7710"))
|
|
AGENT_URL = f"http://{AGENT_HOST}:{AGENT_PORT}"
|
|
DASHBOARD_API_KEY = os.environ.get("DASHBOARD_API_KEY", "")
|
|
# Prefer dedicated ODS_AGENT_KEY; fall back to DASHBOARD_API_KEY for
|
|
# existing installs that haven't generated a separate key yet.
|
|
ODS_AGENT_KEY = os.environ.get("ODS_AGENT_KEY", "") or DASHBOARD_API_KEY
|
|
|
|
|
|
# --- Templates ---
|
|
|
|
TEMPLATES_DIR = Path(
|
|
os.environ.get(
|
|
"ODS_TEMPLATES_DIR",
|
|
str(Path(INSTALL_DIR) / "templates")
|
|
)
|
|
)
|
|
|
|
_TEMPLATE_SCHEMA = None
|
|
try:
|
|
import jsonschema as _jsonschema_mod
|
|
_schema_path = Path(__file__).parent.parent.parent / "schema" / "service-template.v1.json"
|
|
if _schema_path.exists():
|
|
_TEMPLATE_SCHEMA = json.loads(_schema_path.read_text(encoding="utf-8"))
|
|
except ImportError:
|
|
_jsonschema_mod = None
|
|
|
|
|
|
def load_templates() -> list[dict]:
|
|
"""Load service templates from YAML files. Returns empty list on failure."""
|
|
if not TEMPLATES_DIR.exists():
|
|
logger.info("Templates directory not found at %s", TEMPLATES_DIR)
|
|
return []
|
|
|
|
templates = []
|
|
for path in sorted(TEMPLATES_DIR.iterdir()):
|
|
if path.suffix.lower() not in (".yaml", ".yml"):
|
|
continue
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
logger.warning("Skipping template %s: root is not a mapping", path.name)
|
|
continue
|
|
if data.get("schema_version") != "ods.templates.v1":
|
|
logger.warning("Skipping template %s: unsupported schema_version", path.name)
|
|
continue
|
|
# Validate against JSON Schema if available
|
|
if _TEMPLATE_SCHEMA is not None and _jsonschema_mod is not None:
|
|
try:
|
|
_jsonschema_mod.validate(data, _TEMPLATE_SCHEMA)
|
|
except _jsonschema_mod.ValidationError as ve:
|
|
logger.warning("Template validation failed for %s: %s", path.name, ve.message)
|
|
continue
|
|
template = data.get("template")
|
|
if not isinstance(template, dict) or not template.get("id") or not template.get("services"):
|
|
logger.warning("Skipping template %s: missing required fields", path.name)
|
|
continue
|
|
templates.append(template)
|
|
except (yaml.YAMLError, OSError, ValueError) as e:
|
|
logger.warning("Failed loading template %s: %s", path.name, e)
|
|
|
|
logger.info("Loaded %d service templates", len(templates))
|
|
return templates
|
|
|
|
|
|
TEMPLATES = load_templates()
|