chore: import upstream snapshot with attribution
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
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
This commit is contained in:
Executable
+649
@@ -0,0 +1,649 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(pwd)"
|
||||
TIER="1"
|
||||
GPU_BACKEND="nvidia"
|
||||
PROFILE_OVERLAYS=""
|
||||
ENV_MODE="false"
|
||||
SKIP_BROKEN="false"
|
||||
GPU_COUNT="1"
|
||||
ODS_MODE="${ODS_MODE:-local}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--script-dir)
|
||||
SCRIPT_DIR="${2:-$SCRIPT_DIR}"
|
||||
shift 2
|
||||
;;
|
||||
--tier)
|
||||
TIER="${2:-$TIER}"
|
||||
shift 2
|
||||
;;
|
||||
--gpu-backend)
|
||||
GPU_BACKEND="${2:-$GPU_BACKEND}"
|
||||
shift 2
|
||||
;;
|
||||
--profile-overlays)
|
||||
PROFILE_OVERLAYS="${2:-$PROFILE_OVERLAYS}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-broken)
|
||||
SKIP_BROKEN="true"
|
||||
shift
|
||||
;;
|
||||
--env)
|
||||
ENV_MODE="true"
|
||||
shift
|
||||
;;
|
||||
--gpu-count)
|
||||
GPU_COUNT="${2:-$GPU_COUNT}"
|
||||
shift 2
|
||||
;;
|
||||
--ods-mode)
|
||||
ODS_MODE="${2:-$ODS_MODE}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ROOT_DIR="$SCRIPT_DIR"
|
||||
PYTHON_CMD="python3"
|
||||
if [[ -f "$ROOT_DIR/lib/python-cmd.sh" ]]; then
|
||||
. "$ROOT_DIR/lib/python-cmd.sh"
|
||||
PYTHON_CMD="$(ods_detect_python_cmd_with_module yaml 2>/dev/null || ods_detect_python_cmd)"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON_CMD="python"
|
||||
fi
|
||||
|
||||
if ! "$PYTHON_CMD" -c 'import yaml' >/dev/null 2>&1; then
|
||||
echo "ERROR: PyYAML is required by resolve-compose-stack.sh for compose validation." >&2
|
||||
echo " Active Python: $PYTHON_CMD ($(command -v "$PYTHON_CMD" 2>/dev/null || echo "$PYTHON_CMD"))" >&2
|
||||
echo " If Conda/venv is active, run 'conda deactivate' before installing ODS." >&2
|
||||
echo " Or install manually: $PYTHON_CMD -m pip install pyyaml" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
"$PYTHON_CMD" - "$SCRIPT_DIR" "$TIER" "$GPU_BACKEND" "$PROFILE_OVERLAYS" "$ENV_MODE" "$SKIP_BROKEN" "$GPU_COUNT" "$ODS_MODE" <<'PY'
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import sys
|
||||
import json
|
||||
|
||||
script_dir = pathlib.Path(sys.argv[1])
|
||||
tier = (sys.argv[2] or "1").upper()
|
||||
gpu_backend = (sys.argv[3] or "nvidia").lower()
|
||||
profile_overlays = [x.strip() for x in (sys.argv[4] or "").split(",") if x.strip()]
|
||||
env_mode = (sys.argv[5] or "false").lower() == "true"
|
||||
skip_broken = (sys.argv[6] or "false").lower() == "true"
|
||||
gpu_count = int(sys.argv[7] or "1")
|
||||
ods_mode = (sys.argv[8] or os.environ.get("ODS_MODE", "local")).lower()
|
||||
lemonade_external = (
|
||||
os.environ.get("LEMONADE_EXTERNAL", "").lower() in {"1", "true", "yes", "on"}
|
||||
or (
|
||||
os.environ.get("AMD_INFERENCE_RUNTIME", "").lower() == "lemonade"
|
||||
and os.environ.get("AMD_INFERENCE_MANAGED", "").lower() == "false"
|
||||
)
|
||||
)
|
||||
|
||||
IS_DARWIN = platform.system() == "Darwin"
|
||||
APPLE_OVERLAY = "installers/macos/docker-compose.macos.yml" if IS_DARWIN else "docker-compose.apple.yml"
|
||||
|
||||
def existing(overlays):
|
||||
return all((script_dir / f).exists() for f in overlays)
|
||||
|
||||
resolved = []
|
||||
primary = "docker-compose.yml"
|
||||
|
||||
if profile_overlays and existing(profile_overlays):
|
||||
resolved = profile_overlays
|
||||
primary = profile_overlays[-1]
|
||||
elif lemonade_external and ods_mode == "lemonade":
|
||||
if existing(["docker-compose.base.yml", "docker-compose.cloud.yml", "docker-compose.lemonade-external.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.cloud.yml", "docker-compose.lemonade-external.yml"]
|
||||
primary = "docker-compose.lemonade-external.yml"
|
||||
elif existing(["docker-compose.base.yml", "docker-compose.cloud.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.cloud.yml"]
|
||||
primary = "docker-compose.cloud.yml"
|
||||
elif existing(["docker-compose.base.yml"]):
|
||||
resolved = ["docker-compose.base.yml"]
|
||||
primary = "docker-compose.base.yml"
|
||||
elif ods_mode == "cloud" or tier == "CLOUD":
|
||||
if existing(["docker-compose.base.yml", "docker-compose.cloud.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.cloud.yml"]
|
||||
primary = "docker-compose.cloud.yml"
|
||||
elif existing(["docker-compose.base.yml"]):
|
||||
resolved = ["docker-compose.base.yml"]
|
||||
primary = "docker-compose.base.yml"
|
||||
elif tier in {"AP_ULTRA", "AP_PRO", "AP_BASE"}:
|
||||
if existing(["docker-compose.base.yml", APPLE_OVERLAY]):
|
||||
resolved = ["docker-compose.base.yml", APPLE_OVERLAY]
|
||||
primary = APPLE_OVERLAY
|
||||
elif existing(["docker-compose.base.yml"]):
|
||||
resolved = ["docker-compose.base.yml"]
|
||||
primary = "docker-compose.base.yml"
|
||||
elif gpu_backend == "cpu":
|
||||
if existing(["docker-compose.base.yml", "docker-compose.cpu.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.cpu.yml"]
|
||||
primary = "docker-compose.cpu.yml"
|
||||
elif existing(["docker-compose.base.yml"]):
|
||||
resolved = ["docker-compose.base.yml"]
|
||||
primary = "docker-compose.base.yml"
|
||||
elif tier in {"SH_LARGE", "SH_COMPACT"}:
|
||||
if existing(["docker-compose.base.yml", "docker-compose.amd.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.amd.yml"]
|
||||
primary = "docker-compose.amd.yml"
|
||||
elif gpu_backend == "apple":
|
||||
if existing(["docker-compose.base.yml", APPLE_OVERLAY]):
|
||||
resolved = ["docker-compose.base.yml", APPLE_OVERLAY]
|
||||
primary = APPLE_OVERLAY
|
||||
elif existing(["docker-compose.base.yml"]):
|
||||
resolved = ["docker-compose.base.yml"]
|
||||
primary = "docker-compose.base.yml"
|
||||
elif gpu_backend == "amd":
|
||||
if existing(["docker-compose.base.yml", "docker-compose.amd.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.amd.yml"]
|
||||
primary = "docker-compose.amd.yml"
|
||||
elif gpu_backend in ("intel", "sycl") or tier in ("ARC", "ARC_LITE"):
|
||||
if existing(["docker-compose.base.yml", "docker-compose.arc.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.arc.yml"]
|
||||
primary = "docker-compose.arc.yml"
|
||||
elif existing(["docker-compose.base.yml", "docker-compose.intel.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.intel.yml"]
|
||||
primary = "docker-compose.intel.yml"
|
||||
elif existing(["docker-compose.base.yml"]):
|
||||
resolved = ["docker-compose.base.yml"]
|
||||
primary = "docker-compose.base.yml"
|
||||
else:
|
||||
if existing(["docker-compose.base.yml", "docker-compose.nvidia.yml"]):
|
||||
resolved = ["docker-compose.base.yml", "docker-compose.nvidia.yml"]
|
||||
primary = "docker-compose.nvidia.yml"
|
||||
elif (script_dir / "docker-compose.yml").exists():
|
||||
resolved = ["docker-compose.yml"]
|
||||
primary = "docker-compose.yml"
|
||||
|
||||
if not resolved:
|
||||
resolved = [primary]
|
||||
|
||||
# Multi-GPU overlay if we have more than 1 GPU.
|
||||
if gpu_count > 1:
|
||||
multigpu_file = f"docker-compose.multigpu-{gpu_backend}.yml"
|
||||
if (script_dir / multigpu_file).exists():
|
||||
resolved.append(multigpu_file)
|
||||
|
||||
# PyYAML is a hard requirement — extensions and overlays are YAML and must be
|
||||
# parsed for the compose security scan. Silent fallback used to hide install
|
||||
# breakage on systems without PyYAML (Arch/Alpine/Void/some macOS) and let
|
||||
# user-extension composes through unscanned. Fail loud here instead.
|
||||
import yaml
|
||||
import re
|
||||
|
||||
_LOOPBACK_VAR_DEFAULT_RE = re.compile(
|
||||
r"^\$\{[A-Za-z_][A-Za-z0-9_]*:-127\.0\.0\.1\}$",
|
||||
)
|
||||
|
||||
# Capabilities and security_opt strings that grant container escape primitives.
|
||||
_DANGEROUS_CAPS = {
|
||||
"SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "NET_RAW",
|
||||
"DAC_OVERRIDE", "SETUID", "SETGID", "SYS_MODULE",
|
||||
"SYS_RAWIO", "ALL",
|
||||
}
|
||||
_DANGEROUS_SECURITY_OPTS = {
|
||||
"seccomp:unconfined", "apparmor:unconfined", "label:disable",
|
||||
}
|
||||
|
||||
# Core service IDs — user extensions must not declare services with these names
|
||||
# (would shadow the built-in services in the compose merge). Mirrors the
|
||||
# dashboard-api install endpoint's CORE_SERVICE_IDS / skip_name_collision check
|
||||
# so a hand-dropped or backup-restored extension under the user-extensions dir
|
||||
# can't override e.g. dashboard-api or llama-server. Loaded best-effort: if
|
||||
# config/core-service-ids.json is missing or unparseable, fall back to a
|
||||
# hardcoded list matching helpers.py CORE_SERVICE_IDS_FALLBACK.
|
||||
import json as _json_mod
|
||||
try:
|
||||
_CORE_SERVICE_IDS = set(
|
||||
_json_mod.loads((script_dir / "config" / "core-service-ids.json").read_text(encoding="utf-8"))
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
_CORE_SERVICE_IDS = {
|
||||
"ape", "comfyui", "dashboard", "dashboard-api",
|
||||
"embeddings", "langfuse", "litellm", "llama-server", "n8n",
|
||||
"open-webui", "openclaw", "perplexica", "privacy-shield", "qdrant",
|
||||
"searxng", "token-spy", "tts", "whisper",
|
||||
}
|
||||
|
||||
|
||||
def _host_part_is_loopback(host):
|
||||
if host == "127.0.0.1":
|
||||
return True
|
||||
return bool(_LOOPBACK_VAR_DEFAULT_RE.fullmatch(host))
|
||||
|
||||
|
||||
def _split_port_host(port_str):
|
||||
"""Mirror dashboard-api/_split_port_host: handle ${VAR:-127.0.0.1}: prefix."""
|
||||
if port_str.startswith("${"):
|
||||
end = port_str.find("}")
|
||||
if end == -1 or end + 1 >= len(port_str) or port_str[end + 1] != ":":
|
||||
return port_str, ""
|
||||
return port_str[: end + 1], port_str[end + 2:]
|
||||
if ":" not in port_str:
|
||||
return None, port_str
|
||||
host, _, rest = port_str.partition(":")
|
||||
if host.isdigit():
|
||||
return None, port_str
|
||||
return host, rest
|
||||
|
||||
|
||||
def _scan_user_compose_content(compose_path):
|
||||
"""Reject compose fragments containing dangerous directives.
|
||||
|
||||
Mirrors dashboard-api/routers/extensions.py:_scan_compose_content (without
|
||||
the FastAPI HTTPException dependency). Returns ``(ok, warnings)``: ``ok``
|
||||
is False on any rejection, ``warnings`` is a list of human-readable
|
||||
messages. User-extension contexts are always untrusted at the resolver
|
||||
layer — no ``trusted=True`` exemption.
|
||||
"""
|
||||
try:
|
||||
data = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError) as e:
|
||||
return (False, [f"invalid compose file {compose_path}: {e}"])
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return (False, [f"compose file {compose_path} must be a YAML mapping"])
|
||||
|
||||
warnings = []
|
||||
services = data.get("services", {})
|
||||
if not isinstance(services, dict):
|
||||
return (True, warnings)
|
||||
|
||||
ok = True
|
||||
|
||||
def reject(msg):
|
||||
nonlocal ok
|
||||
ok = False
|
||||
warnings.append(msg)
|
||||
|
||||
for svc_name, svc_def in services.items():
|
||||
if not isinstance(svc_def, dict):
|
||||
continue
|
||||
# Name-collision: user-extension service names must not shadow
|
||||
# built-in core services in the compose merge. Mirrors the
|
||||
# dashboard-api install endpoint's skip_name_collision=False path.
|
||||
if svc_name in _CORE_SERVICE_IDS:
|
||||
reject(f"service '{svc_name}' collides with a built-in core service name")
|
||||
if svc_def.get("privileged") is True:
|
||||
reject(f"service '{svc_name}' uses privileged mode")
|
||||
if "build" in svc_def:
|
||||
reject(f"service '{svc_name}' uses a local build — only pre-built images are allowed for user extensions")
|
||||
user = svc_def.get("user")
|
||||
if user is not None and str(user).split(":")[0] in ("root", "0"):
|
||||
reject(f"service '{svc_name}' runs as root")
|
||||
if svc_def.get("network_mode") == "host":
|
||||
reject(f"service '{svc_name}' uses host network mode")
|
||||
if svc_def.get("pid") == "host":
|
||||
reject(f"service '{svc_name}' uses host PID namespace")
|
||||
if svc_def.get("ipc") == "host":
|
||||
reject(f"service '{svc_name}' uses host IPC namespace")
|
||||
if svc_def.get("userns_mode") == "host":
|
||||
reject(f"service '{svc_name}' uses host user namespace")
|
||||
cap_add = svc_def.get("cap_add", [])
|
||||
if isinstance(cap_add, list):
|
||||
for cap in cap_add:
|
||||
if str(cap).upper() in _DANGEROUS_CAPS:
|
||||
reject(f"service '{svc_name}' adds dangerous capability: {cap}")
|
||||
security_opt = svc_def.get("security_opt", [])
|
||||
if isinstance(security_opt, list):
|
||||
for opt in security_opt:
|
||||
opt_str = str(opt).lower().replace("=", ":")
|
||||
if opt_str in _DANGEROUS_SECURITY_OPTS:
|
||||
reject(f"service '{svc_name}' uses dangerous security_opt '{opt}'")
|
||||
if svc_def.get("devices"):
|
||||
reject(f"service '{svc_name}' declares devices")
|
||||
deploy = svc_def.get("deploy")
|
||||
if isinstance(deploy, dict):
|
||||
resources = deploy.get("resources")
|
||||
if isinstance(resources, dict):
|
||||
reservations = resources.get("reservations")
|
||||
if isinstance(reservations, dict) and reservations.get("devices"):
|
||||
reject(f"service '{svc_name}' requests GPU passthrough via deploy.resources.reservations.devices")
|
||||
volumes = svc_def.get("volumes", [])
|
||||
if isinstance(volumes, list):
|
||||
for vol in volumes:
|
||||
if isinstance(vol, dict):
|
||||
source = str(vol.get("source", ""))
|
||||
target = str(vol.get("target", ""))
|
||||
if "docker.sock" in source or "docker.sock" in target:
|
||||
reject(f"service '{svc_name}' mounts the Docker socket")
|
||||
if source.startswith("/"):
|
||||
reject(f"service '{svc_name}' bind-mounts absolute host path '{source}'")
|
||||
continue
|
||||
vol_str = str(vol)
|
||||
if "docker.sock" in vol_str:
|
||||
reject(f"service '{svc_name}' mounts the Docker socket")
|
||||
vol_parts = vol_str.split(":")
|
||||
if len(vol_parts) >= 2 and vol_parts[0].startswith("/"):
|
||||
reject(f"service '{svc_name}' bind-mounts absolute host path '{vol_parts[0]}'")
|
||||
if svc_def.get("extra_hosts"):
|
||||
reject(f"service '{svc_name}' declares extra_hosts")
|
||||
if svc_def.get("sysctls"):
|
||||
reject(f"service '{svc_name}' declares sysctls")
|
||||
labels = svc_def.get("labels", [])
|
||||
if isinstance(labels, dict):
|
||||
label_keys = labels.keys()
|
||||
elif isinstance(labels, list):
|
||||
label_keys = [lbl.split("=", 1)[0] for lbl in labels if isinstance(lbl, str)]
|
||||
else:
|
||||
label_keys = []
|
||||
for lk in label_keys:
|
||||
if str(lk).startswith("com.docker.compose."):
|
||||
reject(f"service '{svc_name}' uses reserved Docker Compose label '{lk}'")
|
||||
ports = svc_def.get("ports", [])
|
||||
if isinstance(ports, list):
|
||||
for port in ports:
|
||||
if isinstance(port, dict):
|
||||
host_ip = port.get("host_ip", "")
|
||||
if port.get("published") and not _host_part_is_loopback(host_ip):
|
||||
reject(f"service '{svc_name}' dict port binding must use host_ip 127.0.0.1 or '${{VAR:-127.0.0.1}}'")
|
||||
else:
|
||||
port_str = str(port)
|
||||
host_part, rest = _split_port_host(port_str)
|
||||
if host_part is None:
|
||||
reject(f"service '{svc_name}' port '{port_str}' must use 127.0.0.1:host:container format")
|
||||
continue
|
||||
if not _host_part_is_loopback(host_part):
|
||||
reject(f"service '{svc_name}' port '{port_str}' must bind 127.0.0.1 (literal or '${{VAR:-127.0.0.1}}')")
|
||||
continue
|
||||
core = rest.split("/", 1)[0]
|
||||
if ":" not in core:
|
||||
reject(f"service '{svc_name}' port '{port_str}' must specify host:host_port:container_port")
|
||||
|
||||
top_volumes = data.get("volumes", {})
|
||||
if isinstance(top_volumes, dict):
|
||||
for vol_name, vol_def in top_volumes.items():
|
||||
if not isinstance(vol_def, dict):
|
||||
continue
|
||||
driver_opts = vol_def.get("driver_opts", {})
|
||||
if not isinstance(driver_opts, dict):
|
||||
continue
|
||||
vol_type = str(driver_opts.get("type", "")).lower()
|
||||
device = str(driver_opts.get("device", ""))
|
||||
if vol_type in ("none", "bind") and device.startswith("/"):
|
||||
reject(f"named volume '{vol_name}' uses driver_opts to bind-mount host path '{device}'")
|
||||
|
||||
return (ok, warnings)
|
||||
|
||||
|
||||
# Discover enabled extension compose fragments via manifests
|
||||
ext_dir = script_dir / "extensions" / "services"
|
||||
if ext_dir.exists():
|
||||
for service_dir in sorted(ext_dir.iterdir()):
|
||||
if not service_dir.is_dir():
|
||||
continue
|
||||
# Find manifest
|
||||
manifest_path = None
|
||||
for name in ("manifest.yaml", "manifest.yml", "manifest.json"):
|
||||
candidate = service_dir / name
|
||||
if candidate.exists():
|
||||
manifest_path = candidate
|
||||
break
|
||||
if not manifest_path:
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path) as f:
|
||||
if manifest_path.suffix == ".json":
|
||||
manifest = json.load(f)
|
||||
else:
|
||||
manifest = yaml.safe_load(f)
|
||||
if not isinstance(manifest, dict):
|
||||
print(f"WARNING: empty/non-dict manifest for {service_dir.name} at {manifest_path}, skipping", file=sys.stderr)
|
||||
continue
|
||||
if manifest.get("schema_version") != "ods.services.v1":
|
||||
continue
|
||||
service = manifest.get("service", {})
|
||||
# Check GPU backend compatibility
|
||||
backends = service.get("gpu_backends", ["amd", "nvidia"])
|
||||
# "none" means CPU-only — compatible with any GPU backend
|
||||
if gpu_backend not in backends and "all" not in backends and "none" not in backends:
|
||||
continue
|
||||
# Get compose file from manifest
|
||||
compose_rel = service.get("compose_file", "")
|
||||
if compose_rel and not compose_rel.endswith(".disabled"):
|
||||
# Validate compose_file stays inside its extension's directory.
|
||||
# Path.relative_to() does lexical part-prefix matching, so a
|
||||
# `..`-traversal ("../../../etc/passwd") still string-matches
|
||||
# script_dir without escape detection; an absolute compose_file
|
||||
# ("/etc/shadow") replaces service_dir entirely under `/`. Both
|
||||
# would otherwise reach docker compose `-f`. Boundary-check on
|
||||
# fully resolved paths, but keep the unresolved compose_path
|
||||
# for the emit so the existing relative_to(script_dir) contract
|
||||
# still holds on systems where script_dir contains symlinks
|
||||
# (macOS /var -> /private/var would mismatch otherwise).
|
||||
compose_path = service_dir / compose_rel
|
||||
try:
|
||||
compose_path.resolve().relative_to(service_dir.resolve())
|
||||
except ValueError:
|
||||
print(f"WARNING: {service_dir.name}: compose_file '{compose_rel}' "
|
||||
f"escapes the extension directory; skipping",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
if compose_path.exists():
|
||||
resolved.append(str(compose_path.relative_to(script_dir)))
|
||||
elif (service_dir / f"{compose_rel}.disabled").exists():
|
||||
continue # Service disabled — skip all overlays
|
||||
else:
|
||||
print(f"WARNING: {service_dir.name}: compose_file '{compose_rel}' not found, skipping overlays", file=sys.stderr)
|
||||
continue # Base compose missing — skip GPU/mode overlays
|
||||
# GPU-specific overlay (filesystem discovery — not in manifest)
|
||||
gpu_overlay = service_dir / f"compose.{gpu_backend}.yaml"
|
||||
if gpu_overlay.exists():
|
||||
resolved.append(str(gpu_overlay.relative_to(script_dir)))
|
||||
|
||||
# Mode-specific overlay — depends_on for local/hybrid mode only.
|
||||
# Skip on Apple Silicon: macOS runs llama-server natively on the host
|
||||
# (Docker service has replicas: 0), so `depends_on: llama-server:
|
||||
# service_healthy` inside compose.local.yaml overlays can never be
|
||||
# satisfied and deadlocks the stack. The real LLM-ready gate on macOS
|
||||
# is the `llama-server-ready` sidecar defined in the macOS overlay.
|
||||
# External Lemonade is also a host process. Its stack layers the
|
||||
# cloud overlay to profile out ODS's managed llama-server, so
|
||||
# local-mode overlays that wait on `llama-server: service_healthy`
|
||||
# would point at a disabled service and break lifecycle commands.
|
||||
if ods_mode in ("local", "hybrid", "lemonade") and tier != "CLOUD" and gpu_backend != "apple" and not lemonade_external:
|
||||
local_mode_overlay = service_dir / "compose.local.yaml"
|
||||
if local_mode_overlay.exists():
|
||||
resolved.append(str(local_mode_overlay.relative_to(script_dir)))
|
||||
|
||||
# Multi-GPU overlay if we have more than 1 GPU
|
||||
if gpu_count > 1:
|
||||
multi_gpu_overlay = service_dir / f"compose.multigpu-{gpu_backend}.yaml"
|
||||
if multi_gpu_overlay.exists():
|
||||
resolved.append(str(multi_gpu_overlay.relative_to(script_dir)))
|
||||
|
||||
except Exception as e:
|
||||
# Narrow exception handling to specific parse/structure errors
|
||||
yaml_error = isinstance(e, yaml.YAMLError)
|
||||
json_error = isinstance(e, json.JSONDecodeError)
|
||||
structure_error = isinstance(e, (KeyError, TypeError))
|
||||
|
||||
if yaml_error or json_error or structure_error:
|
||||
print(f"ERROR: Failed to parse manifest for {service_dir.name}: {e}", file=sys.stderr)
|
||||
print(f" Manifest path: {manifest_path}", file=sys.stderr)
|
||||
print(f" This service will be skipped. Fix the manifest or disable the service.", file=sys.stderr)
|
||||
if skip_broken:
|
||||
continue
|
||||
else:
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Unexpected error — re-raise to crash visibly
|
||||
raise
|
||||
|
||||
# Discover enabled user-installed extensions (from dashboard portal)
|
||||
user_ext_dir = script_dir / "data" / "user-extensions"
|
||||
if user_ext_dir.exists():
|
||||
try:
|
||||
for service_dir in sorted(user_ext_dir.iterdir()):
|
||||
if not service_dir.is_dir():
|
||||
continue
|
||||
# Find manifest
|
||||
manifest_path = None
|
||||
for name in ("manifest.yaml", "manifest.yml", "manifest.json"):
|
||||
candidate = service_dir / name
|
||||
if candidate.exists():
|
||||
manifest_path = candidate
|
||||
break
|
||||
try:
|
||||
manifest = None # init so the gpu_backends gate below is safe in the manifest-less branch
|
||||
if manifest_path is not None:
|
||||
with open(manifest_path) as f:
|
||||
if manifest_path.suffix == ".json":
|
||||
manifest = json.load(f)
|
||||
else:
|
||||
manifest = yaml.safe_load(f)
|
||||
if manifest is not None and not isinstance(manifest, dict):
|
||||
print(f"WARNING: empty/non-dict manifest for {service_dir.name} at {manifest_path}, skipping", file=sys.stderr)
|
||||
continue
|
||||
if isinstance(manifest, dict) and manifest.get("schema_version") != "ods.services.v1":
|
||||
continue
|
||||
service = manifest.get("service", {}) if isinstance(manifest, dict) else {}
|
||||
else:
|
||||
service = {}
|
||||
# Apply gpu_backends filter — same predicate as the built-in loop above.
|
||||
# Gated on isinstance(manifest, dict) so the manifest-less compat
|
||||
# carve-out (legacy user extensions that pre-date the manifest convention)
|
||||
# falls through unfiltered.
|
||||
if isinstance(manifest, dict):
|
||||
backends = service.get("gpu_backends", ["amd", "nvidia"])
|
||||
# "none" means CPU-only — compatible with any GPU backend
|
||||
if gpu_backend not in backends and "all" not in backends and "none" not in backends:
|
||||
continue
|
||||
# Get compose file from manifest, default to compose.yaml
|
||||
compose_rel = service.get("compose_file", "compose.yaml")
|
||||
if compose_rel and not compose_rel.endswith(".disabled"):
|
||||
# Boundary check: a malicious manifest could point compose_file
|
||||
# at "../../etc/passwd" or an absolute path. Resolve both sides
|
||||
# so a `/var → /private/var` symlink on macOS doesn't false-flag.
|
||||
compose_path = service_dir / compose_rel
|
||||
try:
|
||||
compose_path.resolve().relative_to(service_dir.resolve())
|
||||
except ValueError:
|
||||
print(f"WARNING: {service_dir.name}: compose_file '{compose_rel}' "
|
||||
f"escapes the extension directory; skipping",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
if compose_path.exists():
|
||||
# Scan content before appending — user-ext composes are
|
||||
# untrusted. The dashboard-api scans at install time;
|
||||
# the resolver runs every `ods` invocation, so a
|
||||
# tampered-with compose dropped under data/user-extensions
|
||||
# without going through the install API would otherwise
|
||||
# bypass scanning entirely.
|
||||
ok, warnings = _scan_user_compose_content(compose_path)
|
||||
for w in warnings:
|
||||
print(f"WARNING: {service_dir.name}: {w}", file=sys.stderr)
|
||||
if not ok:
|
||||
continue
|
||||
resolved.append(str(compose_path.relative_to(script_dir)))
|
||||
elif (service_dir / f"{compose_rel}.disabled").exists():
|
||||
continue # Service disabled — skip all overlays
|
||||
else:
|
||||
# No base compose — skip overlays for this user extension
|
||||
continue
|
||||
# GPU-specific overlay (filesystem discovery — not in manifest)
|
||||
gpu_overlay = service_dir / f"compose.{gpu_backend}.yaml"
|
||||
if gpu_overlay.exists():
|
||||
# Fixed filename so traversal isn't possible, but the same
|
||||
# security checks apply to the overlay's content.
|
||||
ok, warnings = _scan_user_compose_content(gpu_overlay)
|
||||
for w in warnings:
|
||||
print(f"WARNING: {service_dir.name}: {w}", file=sys.stderr)
|
||||
if ok:
|
||||
resolved.append(str(gpu_overlay.relative_to(script_dir)))
|
||||
|
||||
# Mode-specific overlay — depends_on for local/hybrid mode only.
|
||||
# Skip on Apple Silicon: macOS runs llama-server natively on the host
|
||||
# (Docker service has replicas: 0), so `depends_on: llama-server:
|
||||
# service_healthy` inside compose.local.yaml overlays can never be
|
||||
# satisfied and deadlocks the stack. The real LLM-ready gate on macOS
|
||||
# is the `llama-server-ready` sidecar defined in the macOS overlay.
|
||||
# External Lemonade likewise runs on the host and uses the cloud
|
||||
# overlay to disable ODS's managed llama-server, so user-local
|
||||
# overlays must not add local llama-server health dependencies.
|
||||
# Mirrors the same guard in the built-in loop above (PR #1004).
|
||||
if ods_mode in ("local", "hybrid", "lemonade") and tier != "CLOUD" and gpu_backend != "apple" and not lemonade_external:
|
||||
local_mode_overlay = service_dir / "compose.local.yaml"
|
||||
if local_mode_overlay.exists():
|
||||
# Same content scan as compose.yaml/gpu overlay above —
|
||||
# without it, a malicious user extension can put
|
||||
# privileged: true / docker.sock mounts in compose.local.yaml
|
||||
# and reach the host since ODS_MODE defaults to "local".
|
||||
ok, warnings = _scan_user_compose_content(local_mode_overlay)
|
||||
for w in warnings:
|
||||
print(f"WARNING: {service_dir.name}: {w}", file=sys.stderr)
|
||||
if ok:
|
||||
resolved.append(str(local_mode_overlay.relative_to(script_dir)))
|
||||
|
||||
# Multi-GPU overlay if we have more than 1 GPU
|
||||
if gpu_count > 1:
|
||||
multi_gpu_overlay = service_dir / "compose.multigpu.yaml"
|
||||
if multi_gpu_overlay.exists():
|
||||
# Fixed filename, but same content scan applies — see
|
||||
# the gpu/local-mode overlay scans above.
|
||||
ok, warnings = _scan_user_compose_content(multi_gpu_overlay)
|
||||
for w in warnings:
|
||||
print(f"WARNING: {service_dir.name}: {w}", file=sys.stderr)
|
||||
if ok:
|
||||
resolved.append(str(multi_gpu_overlay.relative_to(script_dir)))
|
||||
|
||||
except Exception as e:
|
||||
# Narrow exception handling to specific parse/structure errors
|
||||
yaml_error = isinstance(e, yaml.YAMLError)
|
||||
json_error = isinstance(e, json.JSONDecodeError)
|
||||
structure_error = isinstance(e, (KeyError, TypeError))
|
||||
|
||||
if yaml_error or json_error or structure_error:
|
||||
print(f"ERROR: Failed to parse manifest for {service_dir.name}: {e}", file=sys.stderr)
|
||||
print(f" Manifest path: {manifest_path}", file=sys.stderr)
|
||||
print(f" This service will be skipped. Fix the manifest or disable the service.", file=sys.stderr)
|
||||
if skip_broken:
|
||||
continue
|
||||
else:
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Unexpected error — re-raise to crash visibly
|
||||
raise
|
||||
except OSError as e:
|
||||
print(f"WARNING: Could not scan user-extensions: {e}", file=sys.stderr)
|
||||
|
||||
# Include docker-compose.override.yml if it exists (user customizations).
|
||||
# Even though the operator placed this file themselves, the resolver runs
|
||||
# under installer/CI and may handle composes from sources the operator
|
||||
# trusts less than themselves (cloned repo, restored backup). Apply the
|
||||
# same content scan as user extensions.
|
||||
override = script_dir / "docker-compose.override.yml"
|
||||
if override.exists():
|
||||
ok, warnings = _scan_user_compose_content(override)
|
||||
for w in warnings:
|
||||
print(f"WARNING: docker-compose.override.yml: {w}", file=sys.stderr)
|
||||
if ok:
|
||||
resolved.append("docker-compose.override.yml")
|
||||
|
||||
def to_flags(files):
|
||||
return " ".join(f"-f {f}" for f in files)
|
||||
|
||||
resolved_flags = to_flags(resolved)
|
||||
|
||||
if env_mode:
|
||||
def out(key, value):
|
||||
safe = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
||||
print(f'{key}="{safe}"')
|
||||
out("COMPOSE_PRIMARY_FILE", primary)
|
||||
out("COMPOSE_FILE_LIST", ",".join(resolved))
|
||||
out("COMPOSE_FLAGS", resolved_flags)
|
||||
else:
|
||||
print(resolved_flags)
|
||||
PY
|
||||
Reference in New Issue
Block a user