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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:33 +08:00
commit 9e8f1bbeed
1156 changed files with 235330 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
# ODS Scripts
Utility scripts for diagnostics, testing, validation, and operations.
## Diagnostics
| Script | Description | Requires Stack? |
|--------|-------------|-----------------|
| `ods-doctor.sh` | JSON diagnostic report with autofix hints | No |
| `ods-preflight.sh` | Pre-install hardware/software checks | No |
| `detect-hardware.sh` | Hardware detection (`--json` for machine output) | No |
| `classify-hardware.sh` | GPU-to-tier classification | No |
| `build-capability-profile.sh` | Machine capability JSON profile | No |
| `health-check.sh` | Service health checks | Yes |
## Testing
| Script | Description | Requires Stack? |
|--------|-------------|-----------------|
| `ods-test.sh` | Full validation (`--quick`, `--json`, `--service`) | Yes |
| `ods-test-functional.sh` | Functional tests (inference, TTS, STT) | Yes |
| `validate.sh` | Post-install validation | Yes |
| `validate-env.sh` | Validate .env against schema | No |
| `audit-extensions.py` | Audit extension manifests and compose contracts | No |
| `simulate-installers.sh` | Cross-platform installer simulation | No |
| `release-gate.sh` | Full pre-release checklist | No |
| `check-compatibility.sh` | Manifest compatibility checks | No |
| `check-release-claims.sh` | Verify release claim accuracy | No |
## Operations
| Script | Description | Requires Stack? |
|--------|-------------|-----------------|
| `mode-switch.sh` | Switch deployment modes | Yes |
| `upgrade-model.sh` | Legacy model-directory swap helper; use [`../docs/MODEL-MANAGEMENT.md`](../docs/MODEL-MANAGEMENT.md) for current GGUF workflows | Yes |
| `migrate-config.sh` | Migrate config between versions | No |
| `session-cleanup.sh` | OpenClaw session lifecycle | Yes |
| `pre-download.sh` | Pre-download models for offline use | No |
| `llm-cold-storage.sh` | Archive/restore models | No |
## Installer Support
| Script | Description |
|--------|-------------|
| `load-backend-contract.sh` | Load backend contract JSON as env vars |
| `resolve-compose-stack.sh` | Resolve compose overlay stack |
| `preflight-engine.sh` | Preflight validation engine |
| `check-offline-models.sh` | Verify offline model availability |
## Python Utilities
| Script | Description |
|--------|-------------|
| `healthcheck.py` | Container health check helper |
| `validate-models.py` | Validate model file integrity |
| `validate-sim-summary.py` | Validate simulation summary output |
## Systemd Units (`systemd/`)
| Unit | Description |
|------|-------------|
| `openclaw-session-cleanup.service/.timer` | Periodic OpenClaw session cleanup |
| `memory-shepherd-memory.service/.timer` | Agent memory lifecycle management |
| `memory-shepherd-workspace.service/.timer` | Agent workspace maintenance |
## Other
| Script | Description |
|--------|-------------|
| `showcase.sh` | Demo/showcase runner |
| `first-boot-demo.sh` | First-boot guided tour |
| `demo-offline.sh` | Offline mode demo |
+54
View File
@@ -0,0 +1,54 @@
# /etc/ods/ap-mode.conf — operator config for ODS's first-boot AP.
#
# This file is sourced as bash by scripts/ap-mode.sh. Keep it small,
# keep it shell-safe (no spaces around =, no fancy quoting).
#
# Mode 0600 — contains the AP password.
#
# Prerequisites for AP-mode traffic to actually land somewhere:
# 1. `ods-proxy` is enabled and listening on port 80
# (`ods enable ods-proxy` if not).
# 2. `.env` has `BIND_ADDRESS=0.0.0.0` so the proxy fields traffic
# from the AP-side interface (192.168.7.1 by default).
# Without those, the DNAT rules in ap-mode.sh redirect AP clients to
# an empty port 80 and the captive portal looks broken.
# See docs/AP-MODE.md for the full architecture diagram.
#
# ----------------------------------------------------------------------------
# REQUIRED
# ----------------------------------------------------------------------------
# Network name broadcast by the AP. Convention: include a per-unit suffix
# so two ODSs in the same room don't collide.
ODS_AP_SSID="ODS-Setup-XXXX"
# WPA2 password. Must be at least 8 characters. Recommend 12+ random
# characters baked in per device. The script refuses this placeholder
# value so images do not accidentally ship a known AP password.
ODS_AP_PASSWORD="changeme-set-per-device"
# ----------------------------------------------------------------------------
# OPTIONAL — sensible defaults if you skip these
# ----------------------------------------------------------------------------
# Wireless interface that supports AP mode. `iw list | grep -A4 'Supported
# interface modes' | grep AP` to verify. Default: wlan0.
# ODS_AP_INTERFACE="wlan0"
# Gateway IP that the AP advertises. Clients reach the wizard at this IP.
# Pick a private range that doesn't clash with the LAN you might rejoin.
# ODS_AP_GATEWAY_IP="192.168.7.1"
#
# Network size — CIDR prefix length is the canonical form (what `ip addr
# add` actually expects). ODS_AP_NETMASK is still accepted as a
# dotted-decimal mask for back-compat; ap-mode.sh auto-converts at
# bring-up time. ODS_AP_PREFIX takes precedence — set ONLY ONE.
# When neither is set, /24 (255.255.255.0) is the effective default.
# ODS_AP_PREFIX="24"
# ODS_AP_NETMASK="255.255.255.0"
# DHCP lease range. Format: <start>,<end>,<lease-time>.
# ODS_AP_DHCP_RANGE="192.168.7.10,192.168.7.50,1h"
# 2.4 GHz channel. 1, 6, 11 are the non-overlapping options.
# ODS_AP_CHANNEL="6"
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env bash
# ap-mode.sh — bring up / tear down ODS's first-boot AP.
#
# Mode of operation:
# up Bring up the AP (configure interface, start hostapd + dnsmasq,
# install iptables rules). DESTRUCTIVE: takes the configured
# wireless interface off NetworkManager.
# down Stop hostapd + dnsmasq, remove iptables rules, hand the
# interface back to NetworkManager. Idempotent.
# status Print a JSON status snapshot. Read-only, safe to run.
#
# OPT-IN ONLY. The systemd unit shipped alongside this script is
# disabled by default. The first-boot wizard in PR-11 will toggle it
# when ODS_AP_MODE=true is set in .env AND the device isn't yet
# configured. Until then, every invocation has to be explicit.
#
# This script is Linux-only and assumes:
# * hostapd, dnsmasq, iptables installed and on $PATH
# * NetworkManager available (we use nmcli to release / reclaim the iface)
# * The wireless interface supports AP mode (`iw list | grep -A4 "Supported interface modes" | grep AP`)
# * Run as root (or under a service unit with the right capabilities)
#
# Config: read from /etc/ods/ap-mode.conf if it exists, otherwise
# falls back to sane defaults documented in docs/AP-MODE.md.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF_DIR="${ODS_AP_CONF_DIR:-/etc/ods}"
RUN_DIR="${ODS_AP_RUN_DIR:-/run/ods-ap-mode}"
STATE_FILE="${RUN_DIR}/state.json"
HOSTAPD_CONF="${RUN_DIR}/hostapd.conf"
DNSMASQ_CONF="${RUN_DIR}/dnsmasq.conf"
HOSTAPD_PID="${RUN_DIR}/hostapd.pid"
DNSMASQ_PID="${RUN_DIR}/dnsmasq.pid"
# Defaults — override in /etc/ods/ap-mode.conf
ODS_AP_SSID="${ODS_AP_SSID:-ODS-Setup}"
ODS_AP_PASSWORD="${ODS_AP_PASSWORD:-}"
ODS_AP_INTERFACE="${ODS_AP_INTERFACE:-wlan0}"
ODS_AP_GATEWAY_IP="${ODS_AP_GATEWAY_IP:-192.168.7.1}"
# ODS_AP_PREFIX is CIDR prefix length used by `ip addr add`.
# ODS_AP_NETMASK stays accepted (as dotted-decimal) for back-compat;
# bring_up_interface converts it to a prefix when ODS_AP_PREFIX is
# unset. PREFIX is left empty here on purpose so an operator who only
# sets ODS_AP_NETMASK in /etc/ods/ap-mode.conf gets the prefix
# derived from THEIR netmask rather than the script default short-
# circuiting them to /24.
ODS_AP_PREFIX="${ODS_AP_PREFIX:-}"
ODS_AP_NETMASK="${ODS_AP_NETMASK:-255.255.255.0}"
ODS_AP_DHCP_RANGE="${ODS_AP_DHCP_RANGE:-192.168.7.10,192.168.7.50,1h}"
ODS_AP_CHANNEL="${ODS_AP_CHANNEL:-6}"
# Load operator overrides if present. Sourced — be deliberate about what
# you put in there.
if [[ -f "${CONF_DIR}/ap-mode.conf" ]]; then
# shellcheck disable=SC1091
source "${CONF_DIR}/ap-mode.conf"
fi
log() { printf '[ap-mode] %s\n' "$*" >&2; }
err() { printf '[ap-mode] ERROR: %s\n' "$*" >&2; }
# Convert a dotted-decimal netmask (255.255.255.0) to a CIDR prefix
# length (24). `ip addr add` requires the prefix form. If conversion
# fails we exit with an error rather than guessing.
_netmask_to_prefix() {
local mask="$1"
local count=0 octet bits seen_zero=0
local -a octets
IFS='.' read -r -a octets <<< "$mask"
if (( ${#octets[@]} != 4 )); then
err "invalid netmask '$mask' (expected four octets)"
return 1
fi
for octet in "${octets[@]}"; do
case "$octet" in
255) bits=8 ;;
254) bits=7 ;;
252) bits=6 ;;
248) bits=5 ;;
240) bits=4 ;;
224) bits=3 ;;
192) bits=2 ;;
128) bits=1 ;;
0) bits=0 ;;
*) err "invalid netmask octet '$octet' in '$mask'"; return 1 ;;
esac
if (( seen_zero && bits > 0 )); then
err "invalid non-contiguous netmask '$mask'"
return 1
fi
if (( bits < 8 )); then
seen_zero=1
fi
count=$((count + bits))
done
printf '%s' "$count"
}
# --- Preflight ----------------------------------------------------------------
require_root() {
if [[ "$(id -u)" -ne 0 ]]; then
err "must run as root (currently $(id -un))"
return 1
fi
}
require_linux() {
if [[ "$(uname -s)" != "Linux" ]]; then
err "AP mode only supported on Linux (this is $(uname -s))"
return 1
fi
}
require_binaries() {
local missing=()
for bin in hostapd dnsmasq iptables ip nmcli; do
if ! command -v "$bin" >/dev/null 2>&1; then
missing+=("$bin")
fi
done
if (( ${#missing[@]} > 0 )); then
err "missing required binaries: ${missing[*]}"
err "install with: apt install hostapd dnsmasq iptables network-manager"
return 1
fi
}
require_password() {
# Open APs are tolerated but called out — for first-boot AP we
# *strongly* recommend setting a per-device password so the unit
# doesn't accept random clients during the wizard window.
if [[ -z "${ODS_AP_PASSWORD}" ]]; then
log "WARNING: ODS_AP_PASSWORD unset — bringing up an open AP."
log " Set ODS_AP_PASSWORD in ${CONF_DIR}/ap-mode.conf to require auth."
elif [[ "${ODS_AP_PASSWORD,,}" == "changeme-set-per-device" ]]; then
err "ODS_AP_PASSWORD still has the example placeholder value"
err "set a unique per-device AP password in ${CONF_DIR}/ap-mode.conf"
return 1
elif [[ ${#ODS_AP_PASSWORD} -lt 8 ]]; then
err "ODS_AP_PASSWORD must be at least 8 characters (WPA2 minimum)"
return 1
fi
}
interface_supports_ap() {
# Best-effort check via iw. Not all drivers report capabilities cleanly;
# we warn rather than fail-hard if iw isn't available.
if ! command -v iw >/dev/null 2>&1; then
log "WARNING: 'iw' not available — skipping AP-capability check"
return 0
fi
if ! iw list 2>/dev/null | grep -A20 "Supported interface modes" | grep -q "\* AP"; then
err "interface does not advertise AP mode in 'iw list' output"
err "this driver may not support hostapd"
return 1
fi
}
# --- Bring up -----------------------------------------------------------------
write_hostapd_conf() {
cat > "${HOSTAPD_CONF}" <<HEREDOC
# Auto-generated by ap-mode.sh — do not edit by hand.
interface=${ODS_AP_INTERFACE}
driver=nl80211
ssid=${ODS_AP_SSID}
hw_mode=g
channel=${ODS_AP_CHANNEL}
auth_algs=1
wmm_enabled=1
HEREDOC
if [[ -n "${ODS_AP_PASSWORD}" ]]; then
cat >> "${HOSTAPD_CONF}" <<HEREDOC
wpa=2
wpa_key_mgmt=WPA-PSK
wpa_pairwise=CCMP
rsn_pairwise=CCMP
wpa_passphrase=${ODS_AP_PASSWORD}
HEREDOC
fi
chmod 0600 "${HOSTAPD_CONF}"
}
write_dnsmasq_conf() {
# Listen only on the AP interface. address=/#/<gateway> resolves every
# hostname to the gateway IP — the classic captive-portal trick, no
# separate DNS responder needed.
cat > "${DNSMASQ_CONF}" <<HEREDOC
# Auto-generated by ap-mode.sh — do not edit by hand.
interface=${ODS_AP_INTERFACE}
bind-interfaces
except-interface=lo
listen-address=${ODS_AP_GATEWAY_IP}
dhcp-range=${ODS_AP_DHCP_RANGE}
dhcp-option=3,${ODS_AP_GATEWAY_IP}
dhcp-option=6,${ODS_AP_GATEWAY_IP}
# Captive-portal DNS: every name resolves to the device's setup IP.
address=/#/${ODS_AP_GATEWAY_IP}
# Don't read /etc/resolv.conf upstream — we ARE the resolver.
no-resolv
no-hosts
log-facility=${RUN_DIR}/dnsmasq.log
HEREDOC
chmod 0600 "${DNSMASQ_CONF}"
}
install_iptables_rules() {
# Redirect HTTP / HTTPS originating on the AP interface to the gateway.
# This + the wildcard DNS = captive portal. We tag rules with a ODS
# comment so teardown can remove only what we added.
#
# Idempotency: `iptables -A` always appends, even if an identical rule
# already exists. Re-running `ap-mode.sh up` after a partial teardown
# would otherwise stack duplicate PREROUTING rules. Use `-C` to check
# first so each `up` adds at most one of each rule.
if ! iptables -t nat -C PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 80 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:80" -m comment --comment "ods-ap-mode" 2>/dev/null; then
iptables -t nat -A PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 80 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:80" -m comment --comment "ods-ap-mode"
fi
if ! iptables -t nat -C PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 443 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:443" -m comment --comment "ods-ap-mode" 2>/dev/null; then
iptables -t nat -A PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 443 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:443" -m comment --comment "ods-ap-mode"
fi
}
remove_iptables_rules() {
# Drop any PREROUTING rule tagged with our comment. Loop in case there
# are multiple (e.g. a previous up didn't fully tear down).
while iptables -t nat -C PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 80 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:80" -m comment --comment "ods-ap-mode" 2>/dev/null; do
iptables -t nat -D PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 80 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:80" -m comment --comment "ods-ap-mode" || true
done
while iptables -t nat -C PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 443 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:443" -m comment --comment "ods-ap-mode" 2>/dev/null; do
iptables -t nat -D PREROUTING -i "${ODS_AP_INTERFACE}" -p tcp --dport 443 \
-j DNAT --to-destination "${ODS_AP_GATEWAY_IP}:443" -m comment --comment "ods-ap-mode" || true
done
}
release_interface_from_nm() {
# Tell NetworkManager to stop managing the AP interface. Otherwise it
# fights hostapd over wlan0. nmcli returns non-zero if NM doesn't
# currently manage the iface — that's fine.
nmcli device set "${ODS_AP_INTERFACE}" managed no 2>/dev/null || true
}
reclaim_interface_for_nm() {
nmcli device set "${ODS_AP_INTERFACE}" managed yes 2>/dev/null || true
}
bring_up_interface() {
# `ip addr add` wants a CIDR prefix length (e.g. 192.168.7.1/24), NOT
# a dotted-decimal netmask (192.168.7.1/255.255.255.0). The dotted form
# silently fails on most modern iproute2 builds. We use ODS_AP_PREFIX
# when set; otherwise we convert ODS_AP_NETMASK to a prefix at
# bring-up time so back-compat with operator configs that set NETMASK
# is preserved.
local prefix="${ODS_AP_PREFIX}"
if [[ -z "${prefix}" ]]; then
prefix="$(_netmask_to_prefix "${ODS_AP_NETMASK}")" || return 1
fi
ip link set dev "${ODS_AP_INTERFACE}" up
ip addr flush dev "${ODS_AP_INTERFACE}"
ip addr add "${ODS_AP_GATEWAY_IP}/${prefix}" dev "${ODS_AP_INTERFACE}"
}
write_state() {
local status="$1"
cat > "${STATE_FILE}" <<HEREDOC
{
"status": "${status}",
"ssid": "${ODS_AP_SSID}",
"interface": "${ODS_AP_INTERFACE}",
"gateway_ip": "${ODS_AP_GATEWAY_IP}",
"since": "$(date -Iseconds)"
}
HEREDOC
chmod 0644 "${STATE_FILE}"
}
cmd_up() {
require_linux
require_root
require_binaries
require_password
interface_supports_ap
mkdir -p "${RUN_DIR}"
# Snapshot the original state so we can recover on teardown.
log "bringing up AP on ${ODS_AP_INTERFACE} as '${ODS_AP_SSID}'"
release_interface_from_nm
bring_up_interface
write_hostapd_conf
write_dnsmasq_conf
install_iptables_rules
# Start daemons directly and track them with PID files so teardown can
# cleanly stop only the processes started for AP mode.
if ! pgrep -f "hostapd .*${HOSTAPD_CONF}" >/dev/null; then
hostapd -B -P "${HOSTAPD_PID}" "${HOSTAPD_CONF}" \
|| { err "hostapd failed to start — check journalctl"; cmd_down; return 1; }
fi
if ! pgrep -f "dnsmasq.*${DNSMASQ_CONF}" >/dev/null; then
dnsmasq -C "${DNSMASQ_CONF}" --pid-file="${DNSMASQ_PID}" \
|| { err "dnsmasq failed to start — check ${RUN_DIR}/dnsmasq.log"; cmd_down; return 1; }
fi
write_state "active"
log "AP up: SSID=${ODS_AP_SSID} gateway=${ODS_AP_GATEWAY_IP}"
log " any hostname resolves to ${ODS_AP_GATEWAY_IP} (captive portal)"
log " HTTP/HTTPS on ${ODS_AP_INTERFACE} redirected to the gateway"
}
cmd_down() {
# Idempotent. Never errors out — best-effort cleanup so we don't leave
# the system in a half-configured state.
require_linux
require_root
log "tearing down AP on ${ODS_AP_INTERFACE}"
if [[ -f "${HOSTAPD_PID}" ]]; then
kill "$(cat "${HOSTAPD_PID}")" 2>/dev/null || true
rm -f "${HOSTAPD_PID}"
fi
pkill -f "hostapd .*${HOSTAPD_CONF}" 2>/dev/null || true
if [[ -f "${DNSMASQ_PID}" ]]; then
kill "$(cat "${DNSMASQ_PID}")" 2>/dev/null || true
rm -f "${DNSMASQ_PID}"
fi
pkill -f "dnsmasq.*${DNSMASQ_CONF}" 2>/dev/null || true
remove_iptables_rules
ip addr flush dev "${ODS_AP_INTERFACE}" 2>/dev/null || true
reclaim_interface_for_nm
rm -f "${STATE_FILE}" "${HOSTAPD_CONF}" "${DNSMASQ_CONF}"
log "AP down; ${ODS_AP_INTERFACE} returned to NetworkManager"
}
cmd_status() {
# No root required — purely read-only. Outputs JSON for the host-agent
# to forward to the dashboard / wizard.
if [[ -f "${STATE_FILE}" ]]; then
cat "${STATE_FILE}"
else
printf '{"status":"inactive"}\n'
fi
}
main() {
case "${1:-status}" in
up|start) cmd_up ;;
down|stop) cmd_down ;;
status) cmd_status ;;
-h|--help|help)
cat <<HEREDOC
Usage: ap-mode.sh {up|down|status}
up Bring up the AP. DESTRUCTIVE — takes ${ODS_AP_INTERFACE}
off NetworkManager and applies iptables NAT rules.
down Tear down the AP and restore the prior state. Idempotent.
status Print JSON status (read-only).
Config is loaded from ${CONF_DIR}/ap-mode.conf if present.
See docs/AP-MODE.md for the full setting list.
HEREDOC
;;
*)
err "unknown command: $1 (try: up | down | status)"
exit 2
;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env python3
"""
assign_gpus.py — GPU assignment algorithm for ODS
Usage:
python3 assign_gpus.py --topology topo.json --model-size 70000
python3 assign_gpus.py --topology topo.json --model-size 70000 --enabled-services llama_server,whisper
Output: gpu_assignment JSON to stdout
Errors: to stderr, exit code 1
"""
import argparse
import json
import math
import sys
from dataclasses import dataclass
from itertools import combinations
from typing import Optional
# Constants
HIGH_BW_THRESHOLD = 80 # min rank for NVLink / XGMI
DEFAULT_SERVICES = ["llama_server", "whisper", "comfyui", "embeddings"]
NON_LLAMA = ["whisper", "comfyui", "embeddings"]
# Data Models
@dataclass
class GPU:
index: int
uuid: str
name: str
memory_mb: float
memory_total_mb: float
memory_type: str = "discrete" # "discrete" or "unified" (APU)
@dataclass
class Link:
gpu_a: int
gpu_b: int
link_type: str
link_label: str
rank: int
@dataclass
class Subset:
gpus: list
min_link_rank: int
total_vram_mb: float
all_pairs_highbw: bool
@dataclass
class LlamaParallelism:
mode: str
tensor_parallel_size: int
pipeline_parallel_size: int
gpu_memory_utilization: float
tensor_split: Optional[list] = None
@dataclass
class ServiceAssignment:
gpus: list
parallelism: Optional[LlamaParallelism] = None
@dataclass
class AssignmentResult:
strategy: str
services: dict
# Phase 1: Topology Analysis
def parse_gpus(topology: dict) -> list:
gpus = []
for g in topology["gpus"]:
total_mb = float(g["memory_gb"]) * 1024
scheduling_mb = total_mb
if g.get("memory_free_gb") is not None:
scheduling_mb = max(float(g["memory_free_gb"]) * 1024, 0.0)
gpus.append(GPU(
index=g["index"],
uuid=g["uuid"],
name=g["name"],
memory_mb=scheduling_mb,
memory_total_mb=total_mb,
memory_type=g.get("memory_type", "discrete"),
))
return gpus
def parse_links(topology: dict) -> list:
links = []
for link in topology.get("links", []):
links.append(Link(
gpu_a=link["gpu_a"],
gpu_b=link["gpu_b"],
link_type=link["link_type"],
link_label=link["link_label"],
rank=link["rank"],
))
return links
def build_rank_matrix(links: list) -> dict:
"""
rank_matrix[(min_idx, max_idx)] = rank
Pairs not in links default to 0.
"""
matrix = {}
for link in links:
key = (min(link.gpu_a, link.gpu_b), max(link.gpu_a, link.gpu_b))
matrix[key] = link.rank
return matrix
def get_rank(rank_matrix: dict, a: int, b: int) -> int:
return rank_matrix.get((min(a, b), max(a, b)), 0)
def compute_subset(gpus: list, rank_matrix: dict) -> Subset:
"""
Compute a Subset from a list of GPUs.
Single GPU: min_link_rank=0, all_pairs_highbw=True (no links needed).
"""
if len(gpus) == 1:
return Subset(
gpus=gpus,
min_link_rank=0,
total_vram_mb=gpus[0].memory_mb,
all_pairs_highbw=True,
)
indices = [g.index for g in gpus]
ranks = [get_rank(rank_matrix, a, b) for a, b in combinations(indices, 2)]
min_rank = min(ranks)
return Subset(
gpus=gpus,
min_link_rank=min_rank,
total_vram_mb=sum(g.memory_mb for g in gpus),
all_pairs_highbw=(min_rank >= HIGH_BW_THRESHOLD),
)
def enumerate_subsets(gpus: list, rank_matrix: dict) -> list:
"""
Generate all non-empty subsets of GPUs, ordered by:
1. min_link_rank DESC (topology quality)
2. subset size ASC (prefer fewer GPUs, leave more for services)
3. total_vram DESC (tiebreaker)
"""
all_subsets = []
for size in range(1, len(gpus) + 1):
for combo in combinations(gpus, size):
all_subsets.append(compute_subset(list(combo), rank_matrix))
return sorted(
all_subsets,
key=lambda s: (s.min_link_rank, -len(s.gpus), s.total_vram_mb),
reverse=True,
)
# Phase 2: GPU Assignment
def find_llama_subset(ordered_subsets: list, model_size_mb: float) -> Subset:
"""
Pick the best-ranked subset whose total VRAM covers model_size_mb.
Returns the first match (best topology, smallest size, most VRAM).
"""
for subset in ordered_subsets:
if subset.total_vram_mb >= model_size_mb and subset_can_host_equal_split(subset, model_size_mb):
return subset
return None
def subset_can_host_equal_split(subset: Subset, model_size_mb: float) -> bool:
"""Conservative fit check for llama.cpp layer/pipeline splits.
ODS emits equal tensor-split weights for non-heterogeneous pipeline splits.
A multi-GPU host with one mostly busy GPU can therefore have enough total
VRAM but still crash when llama.cpp allocates that GPU's share. Treat free
VRAM as the scheduling budget when topology provides it, and require every
selected GPU to be able to carry an equal share of the model file.
"""
if not subset.gpus:
return False
required_per_gpu = model_size_mb / len(subset.gpus)
return all(g.memory_mb >= required_per_gpu for g in subset.gpus)
def span_subsets(all_gpus: list, rank_matrix: dict, model_size_mb: float, ordered_subsets: list) -> Subset:
"""
No single subset covers model_size_mb.
Take the best subset, then greedily add GPUs from the remaining pool
(ordered by memory_mb DESC) until VRAM is covered.
Recomputes min_link_rank on the combined set.
"""
best = ordered_subsets[0]
accumulated = list(best.gpus)
used = {g.index for g in accumulated}
remaining = sorted(
[g for g in all_gpus if g.index not in used],
key=lambda g: g.memory_mb,
reverse=True,
)
for gpu in remaining:
accumulated.append(gpu)
candidate = compute_subset(accumulated, rank_matrix)
if candidate.total_vram_mb >= model_size_mb and subset_can_host_equal_split(candidate, model_size_mb):
return candidate
raise ValueError(
f"Model size {model_size_mb:.0f}MB exceeds assignable free VRAM "
f"({sum(g.memory_mb for g in all_gpus):.0f}MB across all GPUs)."
)
def assign_services(all_gpus: list, llama_gpus: list, rank_matrix: dict, enabled_services: list, vendor: str = "nvidia") -> tuple:
"""
Assign remaining GPUs to non-llama services.
Returns (service_assignments dict, final_llama_gpus list, strategy str).
Rules:
remaining == 0 → all 3 services share llama's last GPU → colocated
remaining == 1 → all 3 services share remaining[0] → colocated
remaining == 2 → whisper → [0], comfyui+embeddings → [1] → colocated
remaining >= 3 → whisper → [0], comfyui → [1], emb → [2] → dedicated
remaining[3:] → back to llama
AMD APU+dGPU hybrid: if mixed memory types (unified + discrete), prefer APU
GPUs for auxiliary services (lower bandwidth but sufficient for whisper/embeddings).
"""
llama_indices = {g.index for g in llama_gpus}
remaining = sorted(
[g for g in all_gpus if g.index not in llama_indices],
key=lambda g: g.memory_mb,
reverse=True,
)
# AMD hybrid strategy: sort remaining so APU GPUs come first for auxiliary
# services (they're better suited for lightweight tasks like whisper/embeddings,
# freeing discrete GPUs for LLM inference)
if vendor == "amd" and any(g.memory_type == "unified" for g in remaining):
remaining = sorted(
remaining,
key=lambda g: (0 if g.memory_type == "unified" else 1, -g.memory_mb),
)
active_non_llama = [s for s in NON_LLAMA if s in enabled_services]
assignments = {}
final_llama_gpus = list(llama_gpus)
if len(remaining) == 0:
fallback = llama_gpus[-1]
for s in active_non_llama:
assignments[s] = ServiceAssignment(gpus=[fallback])
strategy = "colocated"
elif len(remaining) == 1:
for s in active_non_llama:
assignments[s] = ServiceAssignment(gpus=[remaining[0]])
strategy = "colocated"
elif len(remaining) == 2:
if "whisper" in enabled_services:
assignments["whisper"] = ServiceAssignment(gpus=[remaining[0]])
if "comfyui" in enabled_services:
assignments["comfyui"] = ServiceAssignment(gpus=[remaining[1]])
if "embeddings" in enabled_services:
assignments["embeddings"] = ServiceAssignment(gpus=[remaining[1]])
strategy = "colocated"
else:
if "whisper" in enabled_services:
assignments["whisper"] = ServiceAssignment(gpus=[remaining[0]])
if "comfyui" in enabled_services:
assignments["comfyui"] = ServiceAssignment(gpus=[remaining[1]])
if "embeddings" in enabled_services:
assignments["embeddings"] = ServiceAssignment(gpus=[remaining[2]])
# Push extras back to llama so no GPU sits idle
if len(remaining) > 3:
final_llama_gpus = final_llama_gpus + remaining[3:]
strategy = "dedicated"
assignments["llama_server"] = ServiceAssignment(gpus=final_llama_gpus)
return assignments, final_llama_gpus, strategy
# Phase 3: Llama Parallelism
def largest_pow2_divisor(n: int) -> int:
"""
Find the largest power of 2 p such that:
- p divides n evenly
- p <= sqrt(n) (keeps tensor_size <= pipeline_size for balance)
Minimum return value is 2 (hybrid requires at least 2 tensor groups).
"""
p = 1
while True:
candidate = p * 2
if candidate > n or n % candidate != 0:
break
if candidate > math.sqrt(n):
break
p = candidate
return max(2, p)
def is_heterogeneous(gpus: list) -> bool:
vrams = [g.memory_mb for g in gpus]
return max(vrams) != min(vrams)
def compute_tensor_split(gpus: list) -> list:
"""Proportional VRAM weights, rounded to 4 decimal places."""
total = sum(g.memory_mb for g in gpus)
return [round(g.memory_mb / total, 4) for g in gpus]
def select_parallelism(subset: Subset) -> LlamaParallelism:
"""
Select parallelism mode based on GPU count and min_link_rank.
Thresholds:
rank >= 80 → NVLink / XGMI → tensor or hybrid
rank 11-79 → same-NUMA PCIe → pipeline, or hybrid if rank >= 40 and >= 4 GPUs
rank <= 10 → cross-NUMA → pipeline only
"""
gpus = subset.gpus
n = len(gpus)
rank = subset.min_link_rank
split = compute_tensor_split(gpus) if is_heterogeneous(gpus) else None
# Single GPU
if n == 1:
return LlamaParallelism(
mode="none",
tensor_parallel_size=1,
pipeline_parallel_size=1,
gpu_memory_utilization=0.95,
)
# High-bandwidth (NVLink / XGMI)
if rank >= HIGH_BW_THRESHOLD:
if n <= 3:
return LlamaParallelism(
mode="tensor",
tensor_parallel_size=n,
pipeline_parallel_size=1,
gpu_memory_utilization=0.92,
tensor_split=split,
)
else:
tp = largest_pow2_divisor(n)
pp = n // tp
return LlamaParallelism(
mode="hybrid",
tensor_parallel_size=tp,
pipeline_parallel_size=pp,
gpu_memory_utilization=0.93,
tensor_split=split,
)
# Cross-NUMA PCIe
if rank <= 10:
return LlamaParallelism(
mode="pipeline",
tensor_parallel_size=1,
pipeline_parallel_size=n,
gpu_memory_utilization=0.95,
)
# Same-NUMA PCIe (rank 11-79)
if n <= 3:
return LlamaParallelism(
mode="pipeline",
tensor_parallel_size=1,
pipeline_parallel_size=n,
gpu_memory_utilization=0.95,
)
else:
if rank >= 40:
tp = largest_pow2_divisor(n)
pp = n // tp
return LlamaParallelism(
mode="hybrid",
tensor_parallel_size=tp,
pipeline_parallel_size=pp,
gpu_memory_utilization=0.93,
tensor_split=split,
)
else:
return LlamaParallelism(
mode="pipeline",
tensor_parallel_size=1,
pipeline_parallel_size=n,
gpu_memory_utilization=0.95,
)
# Phase 4: Build Output JSON
def build_output(result: AssignmentResult) -> dict:
services = {}
for name, assignment in result.services.items():
entry = {
"gpus": [g.uuid for g in assignment.gpus],
"gpu_indices": [g.index for g in assignment.gpus],
}
if assignment.parallelism:
p = assignment.parallelism
para = {
"mode": p.mode,
"tensor_parallel_size": p.tensor_parallel_size,
"pipeline_parallel_size": p.pipeline_parallel_size,
"gpu_memory_utilization": p.gpu_memory_utilization,
}
if p.tensor_split is not None:
para["tensor_split"] = p.tensor_split
entry["parallelism"] = para
services[name] = entry
return {
"gpu_assignment": {
"version": "1.0",
"strategy": result.strategy,
"services": services,
}
}
# Entry Point
def main():
parser = argparse.ArgumentParser(description="GPU assignment algorithm for ODS")
parser.add_argument("--topology", required=True, help="Path to topology JSON file")
parser.add_argument("--model-size", required=True, type=float, help="Model size in MB")
parser.add_argument("--enabled-services", default=",".join(DEFAULT_SERVICES),
help="Comma-separated list of enabled services")
args = parser.parse_args()
# Load topology
try:
with open(args.topology) as f:
topology = json.load(f)
except FileNotFoundError:
print(f"ERROR: topology file not found: {args.topology}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"ERROR: invalid JSON in topology file: {e}", file=sys.stderr)
sys.exit(1)
enabled_services = [s.strip() for s in args.enabled_services.split(",")]
model_size_mb = args.model_size
gpu_count = topology.get("gpu_count", 0)
if gpu_count == 0:
print("ERROR: no GPUs found in topology", file=sys.stderr)
sys.exit(1)
# Early exit: single GPU
if gpu_count == 1:
gpu = parse_gpus(topology)[0]
if model_size_mb > gpu.memory_mb:
print(
f"ERROR: Model size {model_size_mb:.0f}MB exceeds assignable free VRAM "
f"({gpu.memory_mb:.0f}MB across all GPUs).",
file=sys.stderr,
)
sys.exit(1)
parallelism = LlamaParallelism(
mode="none",
tensor_parallel_size=1,
pipeline_parallel_size=1,
gpu_memory_utilization=0.95,
)
services = {}
for s in enabled_services:
services[s] = ServiceAssignment(gpus=[gpu])
# llama_server always runs on the single GPU, even when the caller's
# --enabled-services list omits it. This mirrors the multi-GPU path,
# where assign_services() assigns llama_server unconditionally; without
# it, the line below raised KeyError: 'llama_server'.
services.setdefault("llama_server", ServiceAssignment(gpus=[gpu]))
services["llama_server"].parallelism = parallelism
result = AssignmentResult(strategy="single", services=services)
print(json.dumps(build_output(result), indent=2))
return
# Phase 1: Topology analysis
gpus = parse_gpus(topology)
links = parse_links(topology)
rank_matrix = build_rank_matrix(links)
ordered = enumerate_subsets(gpus, rank_matrix)
# Phase 2: GPU assignment
try:
llama_subset = find_llama_subset(ordered, model_size_mb)
if llama_subset is None:
llama_subset = span_subsets(gpus, rank_matrix, model_size_mb, ordered)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
vendor = topology.get("vendor", "nvidia")
# AMD APU+dGPU hybrid: if topology has mixed memory types, ensure LLM
# gets discrete GPUs. Re-sort llama subset to prefer discrete over unified.
if vendor == "amd":
has_mixed = (
any(g.memory_type == "unified" for g in gpus)
and any(g.memory_type == "discrete" for g in gpus)
)
if has_mixed:
discrete_gpus = [g for g in llama_subset.gpus if g.memory_type == "discrete"]
if discrete_gpus:
# Rebuild llama subset using only discrete GPUs if they cover model size
discrete_subset = compute_subset(discrete_gpus, rank_matrix)
if discrete_subset.total_vram_mb >= model_size_mb:
llama_subset = discrete_subset
service_assignments, final_llama_gpus, strategy = assign_services(
gpus, llama_subset.gpus, rank_matrix, enabled_services, vendor=vendor
)
# Phase 3: Llama parallelism
final_subset = compute_subset(final_llama_gpus, rank_matrix)
parallelism = select_parallelism(final_subset)
service_assignments["llama_server"].parallelism = parallelism
# Phase 4: Emit JSON
result = AssignmentResult(strategy=strategy, services=service_assignments)
print(json.dumps(build_output(result), indent=2))
if __name__ == "__main__":
main()
+884
View File
@@ -0,0 +1,884 @@
#!/usr/bin/env python3
"""
Audit ODS extensions for manifest, compose, overlay, and feature
contract consistency.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
import yaml
VALID_CATEGORIES = {"core", "recommended", "optional"}
VALID_TYPES = {"docker", "host-systemd"}
VALID_GPU_BACKENDS = {"amd", "nvidia", "apple", "all", "none"}
MANIFEST_NAMES = ("manifest.yaml", "manifest.yml", "manifest.json")
OVERLAY_SUFFIXES = {
"amd": ("compose.amd.yaml", "compose.amd.yml"),
"nvidia": ("compose.nvidia.yaml", "compose.nvidia.yml"),
"apple": ("compose.apple.yaml", "compose.apple.yml"),
}
FEATURE_SERVICE_KEYS = (
("requirements", "services"),
("requirements", "services_all"),
("requirements", "services_any"),
("enabled_services_all",),
("enabled_services_any",),
)
@dataclass
class Issue:
severity: str
code: str
message: str
service: str | None = None
path: str | None = None
@dataclass
class ServiceRecord:
service_id: str
directory_name: str
directory: Path
manifest_path: Path
manifest: dict[str, Any]
service: dict[str, Any]
features: list[dict[str, Any]]
compose_path: Path | None
compose_enabled: bool
overlay_paths: dict[str, Path]
category: str
service_type: str
issues: list[Issue] = field(default_factory=list)
def add_issue(
self,
severity: str,
code: str,
message: str,
*,
path: Path | None = None,
) -> None:
self.issues.append(
Issue(
severity=severity,
code=code,
message=message,
service=self.service_id,
path=str(path) if path else None,
)
)
@property
def status(self) -> str:
if any(issue.severity == "error" for issue in self.issues):
return "fail"
if any(issue.severity == "warning" for issue in self.issues):
return "warn"
return "pass"
def parse_args() -> argparse.Namespace:
default_project = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(
description="Audit ODS extension manifests and compose fragments."
)
parser.add_argument(
"--project-dir",
type=Path,
default=default_project,
help="ODS project directory (defaults to the repo root).",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit JSON instead of the human-readable report.",
)
parser.add_argument(
"--strict",
action="store_true",
help="Treat warnings as failures.",
)
parser.add_argument(
"services",
nargs="*",
help="Optional service IDs to audit. Defaults to all discovered services.",
)
return parser.parse_args()
def load_document(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
if path.suffix == ".json":
return json.load(handle)
return yaml.safe_load(handle)
def find_manifest(service_dir: Path) -> Path | None:
for name in MANIFEST_NAMES:
candidate = service_dir / name
if candidate.exists():
return candidate
return None
def resolve_compose_path(service_dir: Path, compose_file: str) -> tuple[Path | None, bool]:
if not compose_file:
return None, False
enabled = service_dir / compose_file
if enabled.exists():
return enabled, True
disabled = service_dir / f"{compose_file}.disabled"
if disabled.exists():
return disabled, False
return enabled, False
def discover_services(project_dir: Path) -> tuple[list[ServiceRecord], list[Issue]]:
ext_dir = project_dir / "extensions" / "services"
records: list[ServiceRecord] = []
global_issues: list[Issue] = []
if not ext_dir.exists():
global_issues.append(
Issue(
severity="error",
code="extensions-dir-missing",
message="extensions/services directory not found",
path=str(ext_dir),
)
)
return records, global_issues
for service_dir in sorted(ext_dir.iterdir()):
if not service_dir.is_dir():
continue
manifest_path = find_manifest(service_dir)
if manifest_path is None:
global_issues.append(
Issue(
severity="warning",
code="manifest-missing",
message="service directory has no manifest",
service=service_dir.name,
path=str(service_dir),
)
)
continue
try:
manifest = load_document(manifest_path)
except Exception as exc:
global_issues.append(
Issue(
severity="error",
code="manifest-invalid",
message=f"failed to parse manifest: {exc}",
service=service_dir.name,
path=str(manifest_path),
)
)
continue
if not isinstance(manifest, dict):
global_issues.append(
Issue(
severity="error",
code="manifest-shape-invalid",
message="manifest root must be a mapping",
service=service_dir.name,
path=str(manifest_path),
)
)
continue
service = manifest.get("service")
if not isinstance(service, dict):
global_issues.append(
Issue(
severity="error",
code="service-section-missing",
message="manifest must contain a service mapping",
service=service_dir.name,
path=str(manifest_path),
)
)
continue
service_id = str(service.get("id") or service_dir.name)
features = manifest.get("features") or []
if not isinstance(features, list):
features = []
compose_path, compose_enabled = resolve_compose_path(
service_dir, str(service.get("compose_file") or "")
)
overlay_paths: dict[str, Path] = {}
for backend, names in OVERLAY_SUFFIXES.items():
for name in names:
candidate = service_dir / name
if candidate.exists():
overlay_paths[backend] = candidate
break
records.append(
ServiceRecord(
service_id=service_id,
directory_name=service_dir.name,
directory=service_dir,
manifest_path=manifest_path,
manifest=manifest,
service=service,
features=features,
compose_path=compose_path,
compose_enabled=compose_enabled,
overlay_paths=overlay_paths,
category=str(service.get("category") or "optional"),
service_type=str(service.get("type") or "docker"),
)
)
return records, global_issues
def as_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def as_string_list(value: Any) -> list[str]:
return [str(item) for item in as_list(value) if str(item)]
def parse_positive_int(value: Any) -> int | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return None
try:
integer = int(value)
except (TypeError, ValueError):
return None
return integer if integer > 0 else None
def parse_non_negative_int(value: Any) -> int | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return None
try:
integer = int(value)
except (TypeError, ValueError):
return None
return integer if integer >= 0 else None
def collect_service_references(feature: dict[str, Any]) -> list[str]:
refs: list[str] = []
for path in FEATURE_SERVICE_KEYS:
target: Any = feature
for key in path:
if not isinstance(target, dict):
target = None
break
target = target.get(key)
refs.extend(as_string_list(target))
return refs
def load_compose_definitions(record: ServiceRecord) -> tuple[dict[str, Any], dict[str, Path]]:
definitions: dict[str, Any] = {}
source_paths: dict[str, Path] = {}
def capture(label: str, path: Path) -> None:
try:
doc = load_document(path)
except Exception as exc:
record.add_issue("error", "compose-invalid", f"failed to parse compose file: {exc}", path=path)
return
if doc is None:
doc = {}
if not isinstance(doc, dict):
record.add_issue("error", "compose-shape-invalid", "compose root must be a mapping", path=path)
return
services = doc.get("services", {})
if not isinstance(services, dict):
record.add_issue("error", "compose-services-invalid", "compose services block must be a mapping", path=path)
return
definitions[label] = services.get(record.service_id)
source_paths[label] = path
if record.compose_path and record.compose_path.exists():
capture("base", record.compose_path)
for backend, path in record.overlay_paths.items():
capture(backend, path)
return definitions, source_paths
def extract_target_ports(service_def: Any) -> list[int]:
"""Return every container-internal port a service makes available.
Looks at BOTH `ports:` (host-bound) and `expose:` (internal-only)
declarations so internal-only services like ods-hermes (fronted by
hermes-proxy) still register their port. Without this, a service
that switches from `ports:` to `expose:` would fail
compose-port-mismatch even though its container is fully reachable
by other containers on the bridge network.
"""
if not isinstance(service_def, dict):
return []
results: list[int] = []
for port in as_list(service_def.get("ports")):
if isinstance(port, int):
if port > 0:
results.append(port)
continue
if isinstance(port, str):
tail = port.rsplit(":", 1)[-1]
tail = tail.split("/", 1)[0]
try:
results.append(int(tail))
except ValueError:
continue
continue
if isinstance(port, dict):
target = port.get("target")
target_int = parse_positive_int(target)
if target_int:
results.append(target_int)
# `expose:` is a list of "<port>" or "<port>/<proto>" strings/ints —
# internal-network-only, never bound to the host.
for entry in as_list(service_def.get("expose")):
if isinstance(entry, int):
if entry > 0:
results.append(entry)
continue
if isinstance(entry, str):
head = entry.split("/", 1)[0]
try:
results.append(int(head))
except ValueError:
continue
return results
def ports_reference_env(service_def: Any, env_name: str) -> bool:
if not isinstance(service_def, dict) or not env_name:
return False
needle = f"${{{env_name}"
for port in as_list(service_def.get("ports")):
if isinstance(port, str) and needle in port:
return True
if isinstance(port, dict):
published = port.get("published")
if isinstance(published, str) and env_name in published:
return True
return False
def service_has_runtime_definition(definitions: dict[str, Any]) -> bool:
for definition in definitions.values():
if isinstance(definition, dict):
return True
return False
def base_is_stub(definitions: dict[str, Any]) -> bool:
base = definitions.get("base")
return base == {} or base is None
def validate_records(
records: list[ServiceRecord],
global_issues: list[Issue],
*,
reference_records: list[ServiceRecord] | None = None,
) -> None:
reference_records = reference_records or records
known_services = {record.service_id: record for record in reference_records}
selected_ids = set(known_services)
alias_owners: dict[str, set[str]] = {}
feature_owners: dict[str, set[str]] = {}
id_owners: dict[str, set[str]] = {}
for ref in reference_records:
id_owners.setdefault(ref.service_id, set()).add(ref.directory_name)
for alias in as_string_list(ref.service.get("aliases")):
alias_owners.setdefault(alias, set()).add(ref.service_id)
for feature in ref.features:
if isinstance(feature, dict):
feature_id = str(feature.get("id") or "")
if feature_id:
feature_owners.setdefault(feature_id, set()).add(ref.service_id)
for service_id, directories in sorted(id_owners.items()):
if len(directories) > 1:
global_issues.append(
Issue(
severity="error",
code="service-id-collision",
message=f"service.id '{service_id}' is declared in multiple directories",
service=service_id,
)
)
for record in records:
manifest = record.manifest
service = record.service
if manifest.get("schema_version") != "ods.services.v1":
record.add_issue(
"error",
"schema-version-invalid",
"schema_version must be ods.services.v1",
path=record.manifest_path,
)
if record.directory_name != record.service_id:
record.add_issue(
"error",
"service-id-directory-mismatch",
f"directory '{record.directory_name}' does not match service.id '{record.service_id}'",
path=record.manifest_path,
)
name = str(service.get("name") or "").strip()
if not name:
record.add_issue("error", "service-name-missing", "service.name is required", path=record.manifest_path)
if record.category not in VALID_CATEGORIES:
record.add_issue(
"error",
"service-category-invalid",
f"service.category must be one of {sorted(VALID_CATEGORIES)}",
path=record.manifest_path,
)
if record.service_type not in VALID_TYPES:
record.add_issue(
"error",
"service-type-invalid",
f"service.type must be one of {sorted(VALID_TYPES)}",
path=record.manifest_path,
)
# `host_network: true` marks services that use Docker's
# `network_mode: host` (Tailscale being the canonical example) — they
# don't have a Docker-mapped port or an HTTP health endpoint, so the
# port/health requirements don't apply. The flag also flips off the
# compose-port-mismatch check further down.
host_network = bool(service.get("host_network"))
port = parse_positive_int(service.get("port"))
if port is None and not host_network:
record.add_issue("error", "service-port-invalid", "service.port must be a positive integer", path=record.manifest_path)
health = str(service.get("health") or "")
if not health.startswith("/") and not host_network:
record.add_issue(
"error",
"service-health-invalid",
"service.health must start with '/'",
path=record.manifest_path,
)
ext_port_default = service.get("external_port_default")
if ext_port_default not in (None, "") and parse_non_negative_int(ext_port_default) is None:
record.add_issue(
"error",
"service-external-port-invalid",
"service.external_port_default must be a non-negative integer when set",
path=record.manifest_path,
)
external_port_env = str(service.get("external_port_env") or "")
if external_port_env and not external_port_env.replace("_", "").isalnum():
record.add_issue(
"error",
"service-port-env-invalid",
"service.external_port_env must be shell-friendly",
path=record.manifest_path,
)
gpu_backends = as_string_list(service.get("gpu_backends") or ["amd", "nvidia"])
invalid_backends = [backend for backend in gpu_backends if backend not in VALID_GPU_BACKENDS]
if invalid_backends:
record.add_issue(
"error",
"service-gpu-backends-invalid",
f"unknown gpu_backends values: {', '.join(sorted(invalid_backends))}",
path=record.manifest_path,
)
alias_list = as_string_list(service.get("aliases"))
seen_local_aliases: set[str] = set()
for alias in alias_list:
if alias in seen_local_aliases:
record.add_issue(
"error",
"alias-duplicate-local",
f"alias '{alias}' is listed more than once",
path=record.manifest_path,
)
continue
seen_local_aliases.add(alias)
owners = alias_owners.get(alias, set())
if owners - {record.service_id}:
record.add_issue(
"error",
"alias-collision",
f"alias '{alias}' already belongs to service '{sorted(owners - {record.service_id})[0]}'",
path=record.manifest_path,
)
for dep in as_string_list(service.get("depends_on")):
if dep not in selected_ids:
record.add_issue(
"error",
"dependency-missing",
f"depends_on references unknown service '{dep}'",
path=record.manifest_path,
)
env_vars = service.get("env_vars")
if env_vars is not None and not isinstance(env_vars, list):
record.add_issue(
"error",
"env-vars-invalid",
"service.env_vars must be a list when present",
path=record.manifest_path,
)
elif isinstance(env_vars, list):
for item in env_vars:
if not isinstance(item, dict) or not str(item.get("key") or "").strip():
record.add_issue(
"error",
"env-var-entry-invalid",
"each service.env_vars entry must contain a non-empty key",
path=record.manifest_path,
)
for feature in record.features:
if not isinstance(feature, dict):
record.add_issue(
"error",
"feature-invalid",
"each feature entry must be a mapping",
path=record.manifest_path,
)
continue
for required in ("id", "name", "description", "category", "priority"):
if feature.get(required) in (None, ""):
record.add_issue(
"error",
"feature-field-missing",
f"feature is missing required field '{required}'",
path=record.manifest_path,
)
feature_id = str(feature.get("id") or "")
if feature_id:
owners = feature_owners.get(feature_id, set())
if owners - {record.service_id}:
record.add_issue(
"error",
"feature-id-collision",
f"feature id '{feature_id}' already belongs to service '{sorted(owners - {record.service_id})[0]}'",
path=record.manifest_path,
)
for ref in collect_service_references(feature):
if ref not in selected_ids:
record.add_issue(
"error",
"feature-service-reference-invalid",
f"feature references unknown service '{ref}'",
path=record.manifest_path,
)
if record.service_type == "host-systemd" and service.get("compose_file"):
record.add_issue(
"warning",
"compose-file-unexpected",
"host-systemd service usually should not declare compose_file",
path=record.manifest_path,
)
if record.service_type != "docker":
continue
compose_file = str(service.get("compose_file") or "")
if record.category != "core" and not compose_file:
record.add_issue(
"error",
"compose-file-missing",
"non-core docker services must declare service.compose_file",
path=record.manifest_path,
)
continue
if compose_file and (record.compose_path is None or not record.compose_path.exists()):
record.add_issue(
"error",
"compose-file-missing",
f"compose file '{compose_file}' was not found (enabled or disabled)",
path=record.manifest_path,
)
continue
definitions, source_paths = load_compose_definitions(record)
if not service_has_runtime_definition(definitions):
if record.category != "core" and record.compose_enabled:
record.add_issue(
"error",
"compose-service-missing",
f"no compose definition found for service '{record.service_id}'",
path=record.compose_path or record.manifest_path,
)
continue
if base_is_stub(definitions):
for backend in gpu_backends:
if backend in {"all", "none", "apple"}:
continue
if backend not in record.overlay_paths:
record.add_issue(
"error",
"overlay-required",
f"stub compose requires compose.{backend}.yaml because gpu_backends includes '{backend}'",
path=record.compose_path or record.manifest_path,
)
for backend, overlay_path in record.overlay_paths.items():
if gpu_backends and "all" not in gpu_backends and backend not in gpu_backends:
record.add_issue(
"warning",
"overlay-backend-extra",
f"{overlay_path.name} exists but service.gpu_backends does not include '{backend}'",
path=overlay_path,
)
container_name = str(service.get("container_name") or "")
if container_name:
matched = False
for label, definition in definitions.items():
if isinstance(definition, dict) and definition.get("container_name") == container_name:
matched = True
break
if not matched and record.category != "core":
record.add_issue(
"error",
"container-name-mismatch",
f"container_name '{container_name}' was not found in compose definitions",
path=source_paths.get("base", record.manifest_path),
)
if port is not None and not host_network:
# host-network services share the host's network namespace; their
# listen ports come from whatever process binds inside the host,
# not from Docker's port mapping. Skipping this check is what
# makes host_network: true viable in the first place.
port_matches = False
for definition in definitions.values():
if port in extract_target_ports(definition):
port_matches = True
break
if not port_matches and record.category != "core":
record.add_issue(
"error",
"compose-port-mismatch",
f"no compose port mapping targets manifest service.port {port}",
path=source_paths.get("base", record.manifest_path),
)
if external_port_env:
env_ref_found = False
for definition in definitions.values():
if ports_reference_env(definition, external_port_env):
env_ref_found = True
break
if not env_ref_found and record.category != "core":
record.add_issue(
"warning",
"compose-port-env-unused",
f"compose ports do not reference service.external_port_env '{external_port_env}'",
path=source_paths.get("base", record.manifest_path),
)
healthcheck_found = False
for definition in definitions.values():
if isinstance(definition, dict) and "healthcheck" in definition:
healthcheck_found = True
break
if not healthcheck_found and record.category != "core":
record.add_issue(
"warning",
"healthcheck-missing",
"docker service has no healthcheck stanza in its compose definitions",
path=source_paths.get("base", record.manifest_path),
)
def filter_records(records: list[ServiceRecord], requested_services: list[str]) -> tuple[list[ServiceRecord], list[Issue]]:
if not requested_services:
return records, []
requested = set(requested_services)
available = {record.service_id for record in records}
missing = sorted(requested - available)
filtered = [record for record in records if record.service_id in requested]
issues = [
Issue(
severity="error",
code="service-not-found",
message=f"requested service '{service_id}' was not found",
service=service_id,
)
for service_id in missing
]
return filtered, issues
def build_payload(
project_dir: Path,
records: list[ServiceRecord],
global_issues: list[Issue],
strict: bool,
requested_services: list[str],
) -> dict[str, Any]:
error_count = sum(1 for issue in global_issues if issue.severity == "error")
warning_count = sum(1 for issue in global_issues if issue.severity == "warning")
service_items = []
for record in records:
error_count += sum(1 for issue in record.issues if issue.severity == "error")
warning_count += sum(1 for issue in record.issues if issue.severity == "warning")
service_items.append(
{
"service_id": record.service_id,
"directory": str(record.directory),
"category": record.category,
"type": record.service_type,
"compose_enabled": record.compose_enabled,
"status": record.status,
"issues": [asdict(issue) for issue in record.issues],
}
)
failed = error_count > 0 or (strict and warning_count > 0)
return {
"project_dir": str(project_dir),
"requested_services": requested_services,
"summary": {
"services_audited": len(records),
"errors": error_count,
"warnings": warning_count,
"strict": strict,
"result": "fail" if failed else "pass",
},
"global_issues": [asdict(issue) for issue in global_issues],
"services": service_items,
}
def print_human_report(payload: dict[str, Any]) -> None:
summary = payload["summary"]
requested = payload["requested_services"]
print("ODS Extension Audit")
print(f"Project: {payload['project_dir']}")
print(
f"Scope: {', '.join(requested)}"
if requested
else f"Scope: all extensions ({summary['services_audited']})"
)
print("")
for issue in payload["global_issues"]:
prefix = "ERROR" if issue["severity"] == "error" else "WARN"
location = f" [{issue['path']}]" if issue.get("path") else ""
print(f"{prefix} global {issue['code']}: {issue['message']}{location}")
if payload["global_issues"]:
print("")
for item in payload["services"]:
label = item["status"].upper()
print(f"{label:4} {item['service_id']} ({item['category']}, {item['type']})")
for issue in item["issues"]:
prefix = "ERROR" if issue["severity"] == "error" else "WARN"
location = f" [{issue['path']}]" if issue.get("path") else ""
print(f" {prefix} {issue['code']}: {issue['message']}{location}")
if not payload["services"] and not payload["global_issues"]:
print("No services matched the requested scope.")
print("")
print(
"Summary: "
f"{summary['services_audited']} services, "
f"{summary['errors']} errors, "
f"{summary['warnings']} warnings, "
f"result={summary['result']}"
)
def main() -> int:
args = parse_args()
project_dir = args.project_dir.resolve()
records, global_issues = discover_services(project_dir)
filtered_records, filter_issues = filter_records(records, args.services)
global_issues.extend(filter_issues)
validate_records(filtered_records, global_issues, reference_records=records)
payload = build_payload(
project_dir=project_dir,
records=filtered_records,
global_issues=global_issues,
strict=args.strict,
requested_services=args.services,
)
if args.json:
json.dump(payload, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print_human_report(payload)
summary = payload["summary"]
if summary["errors"] > 0:
return 1
if args.strict and summary["warnings"] > 0:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_FILE=""
ENV_MODE="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_FILE="${2:-}"
shift 2
;;
--env)
ENV_MODE="true"
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "$OUTPUT_FILE" ]]; then
OUTPUT_FILE="${ROOT_DIR}/.capabilities.json"
fi
if [[ ! -x "${SCRIPT_DIR}/detect-hardware.sh" ]]; then
echo "detect-hardware.sh not found or not executable" >&2
exit 1
fi
[[ -f "$ROOT_DIR/lib/safe-env.sh" ]] && . "$ROOT_DIR/lib/safe-env.sh"
HARDWARE_JSON="$("${SCRIPT_DIR}/detect-hardware.sh" --json)"
PYTHON_CMD="python3"
if [[ -f "$ROOT_DIR/lib/python-cmd.sh" ]]; then
. "$ROOT_DIR/lib/python-cmd.sh"
PYTHON_CMD="$(ods_detect_python_cmd)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
CLASS_ENV="$("${SCRIPT_DIR}/classify-hardware.sh" \
--platform-id "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('os','unknown'))" "$HARDWARE_JSON")" \
--gpu-vendor "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('gpu',{}).get('type','unknown'))" "$HARDWARE_JSON")" \
--memory-type "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('gpu',{}).get('memory_type','unknown'))" "$HARDWARE_JSON")" \
--vram-mb "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('gpu',{}).get('vram_mb',0))" "$HARDWARE_JSON")" \
--device-id "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('gpu',{}).get('device_id',''))" "$HARDWARE_JSON")" \
--gpu-name "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('gpu',{}).get('name',''))" "$HARDWARE_JSON")" \
--cpu-name "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('cpu',''))" "$HARDWARE_JSON")" \
--ram-mb "$("$PYTHON_CMD" -c "import json,sys; print(json.loads(sys.argv[1]).get('ram_gb',0) * 1024)" "$HARDWARE_JSON")" \
--env)"
load_env_from_output <<< "$CLASS_ENV"
# Source service registry for LLM port
if [[ -f "$ROOT_DIR/lib/service-registry.sh" ]]; then
export SCRIPT_DIR="$ROOT_DIR"
. "$ROOT_DIR/lib/service-registry.sh"
sr_load
[[ -f "$ROOT_DIR/lib/safe-env.sh" ]] && . "$ROOT_DIR/lib/safe-env.sh"
[[ -f "$ROOT_DIR/.env" ]] && load_env_file "$ROOT_DIR/.env" && sr_resolve_ports
fi
_LLM_PORT="${SERVICE_PORTS[llama-server]:-11434}"
_LLM_HEALTH="${SERVICE_HEALTH[llama-server]:-/health}"
"$PYTHON_CMD" - "$HARDWARE_JSON" "$OUTPUT_FILE" "$ENV_MODE" "${HW_CLASS_ID:-unknown}" "${HW_CLASS_LABEL:-Unknown}" "${HW_REC_BACKEND:-cpu}" "${HW_REC_TIER:-T1}" "${HW_REC_COMPOSE_OVERLAYS:-}" "$_LLM_PORT" "$_LLM_HEALTH" <<'PY'
import json
import os
import pathlib
import sys
hardware = json.loads(sys.argv[1])
output_path = pathlib.Path(sys.argv[2])
env_mode = sys.argv[3] == "true"
hw_class_id = sys.argv[4]
hw_class_label = sys.argv[5]
hw_rec_backend = sys.argv[6]
hw_rec_tier = sys.argv[7]
hw_rec_overlays = [x for x in sys.argv[8].split(",") if x]
llm_port = int(sys.argv[9]) if len(sys.argv) > 9 else 11434
llm_health = sys.argv[10] if len(sys.argv) > 10 else "/health"
os_name = (hardware.get("os") or "unknown").lower()
if os_name in {"linux", "wsl"}:
family = "linux"
elif os_name == "macos":
family = "darwin"
elif os_name == "windows":
family = "windows"
else:
family = "unknown"
gpu = hardware.get("gpu", {})
gpu_type = (gpu.get("type") or "none").lower()
gpu_name = gpu.get("name") or "None"
memory_type = (gpu.get("memory_type") or "none").lower()
vram_mb = int(gpu.get("vram_mb") or 0)
gpu_count = int(gpu.get("count") or (1 if gpu_type not in {"none", ""} else 0))
experimental_jetson = os.environ.get("ODS_ENABLE_EXPERIMENTAL_JETSON") == "1"
llm_health_url = f"http://localhost:{llm_port}{llm_health}"
llm_api_port = llm_port
if gpu_type == "amd" and memory_type == "unified":
llm_backend = "amd"
overlays = ["docker-compose.base.yml", "docker-compose.amd.yml"]
elif gpu_type == "nvidia":
llm_backend = "nvidia"
overlays = ["docker-compose.base.yml", "docker-compose.nvidia.yml"]
elif gpu_type == "jetson" and experimental_jetson:
# Jetson is Tegra (arm64 + iGPU + unified memory). The dedicated overlay
# docker-compose.jetson.yml lands in milestone 1 phase 3; until then fall
# back to the cpu overlay so the installer pipeline stays valid on Jetson
# hosts without claiming a runtime path that doesn't exist yet.
llm_backend = "jetson"
overlays = ["docker-compose.base.yml", "docker-compose.cpu.yml"]
elif gpu_type == "apple":
llm_backend = "apple"
overlays = ["docker-compose.base.yml", "docker-compose.amd.yml"]
else:
llm_backend = "cpu"
overlays = ["docker-compose.base.yml", "docker-compose.cpu.yml"]
tier = (hardware.get("tier") or "T1").upper()
if tier in {"T1", "T2", "T3", "T4"}:
recommended = tier
elif tier in {"SH_COMPACT", "SH_LARGE"}:
recommended = tier
else:
recommended = "T1"
if hw_rec_tier:
recommended = hw_rec_tier
if hw_rec_backend:
llm_backend = hw_rec_backend
if hw_rec_overlays:
overlays = hw_rec_overlays
profile = {
"version": "1",
"platform": {
"id": os_name,
"family": family,
},
"gpu": {
"vendor": gpu_type if gpu_type in {"nvidia", "amd", "apple", "none"} else "unknown",
"name": gpu_name,
"memory_type": memory_type if memory_type in {"discrete", "unified", "none"} else "unknown",
"count": gpu_count,
"vram_mb": vram_mb,
},
"runtime": {
"llm_backend": llm_backend,
"llm_health_url": llm_health_url,
"llm_api_port": llm_api_port,
},
"compose": {
"overlays": overlays,
},
"tier": {
"recommended": recommended,
},
"hardware_class": {
"id": hw_class_id,
"label": hw_class_label,
}
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(profile, indent=2) + "\n", encoding="utf-8")
if env_mode:
env = {
"CAP_PROFILE_VERSION": profile["version"],
"CAP_PLATFORM_ID": profile["platform"]["id"],
"CAP_PLATFORM_FAMILY": profile["platform"]["family"],
"CAP_GPU_VENDOR": profile["gpu"]["vendor"],
"CAP_GPU_NAME": profile["gpu"]["name"],
"CAP_GPU_MEMORY_TYPE": profile["gpu"]["memory_type"],
"CAP_GPU_COUNT": str(profile["gpu"]["count"]),
"CAP_GPU_VRAM_MB": str(profile["gpu"]["vram_mb"]),
"CAP_LLM_BACKEND": profile["runtime"]["llm_backend"],
"CAP_LLM_HEALTH_URL": profile["runtime"]["llm_health_url"],
"CAP_LLM_API_PORT": str(profile["runtime"]["llm_api_port"]),
"CAP_RECOMMENDED_TIER": profile["tier"]["recommended"],
"CAP_COMPOSE_OVERLAYS": ",".join(profile["compose"]["overlays"]),
"CAP_HARDWARE_CLASS_ID": profile["hardware_class"]["id"],
"CAP_HARDWARE_CLASS_LABEL": profile["hardware_class"]["label"],
"CAP_PROFILE_FILE": str(output_path),
}
for key, value in env.items():
safe = str(value).replace("\\", "\\\\").replace('"', '\\"')
print(f'{key}="{safe}"')
PY
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env python3
"""Generate ``data/persona/SOUL.md`` from the static persona template plus a
detected snapshot of *this* ODS install — what GPU backend it has,
which model is loaded, which services are running, what URLs are reachable.
The goal is for ODS (the agent) to answer accurately when the operator
asks "what can you do here?" or "is comfyui running?" or "what model are you
using?" — instead of inventing capabilities it doesn't have, or denying
ones it does. The dynamic context block is bounded to a few hundred tokens
so it doesn't bloat Hermes's 16k-token system prompt.
Idempotent. Safe to re-run after enabling/disabling services. Designed to
be invoked from:
* installer phase 11 (after services come up — so docker ps reports the
real state, not the pre-launch one)
* ``ods restart hermes`` (so the persona reflects current state on every
container recreate)
* the operator manually when they want to refresh
Reads only — never modifies SOUL.md.template, never touches anything other
than the output path.
"""
from __future__ import annotations
import argparse
import json
import re
import socket
import subprocess
import sys
from pathlib import Path
# Service-id → (display name, one-line "what it does and how the user
# reaches it"). Tuned so the model gets actionable, specific info — port
# numbers + intended use — not just service names. The agent itself often
# can't directly call these (its tools are web_search / files / code /
# memory / etc.), but it can point the user at the right surface.
_SERVICE_CAPABILITIES: dict[str, tuple[str, str]] = {
"llama-server": ("Local LLM inference", "the engine running my chat model (port 8080 internal)"),
"open-webui": ("Open WebUI", "a desktop-style chat surface, reachable at chat.{device}.local"),
"dashboard": ("ODS dashboard", "the operator's local web UI for managing services, models, and extensions — `{device}.local`"),
"hermes": ("Hermes Agent", "that's me — the agent runtime, port 9119 internal"),
"searxng": ("SearXNG", "privacy-respecting metasearch the operator can hit at port 8888; this is what my `web_search` tool uses under the hood"),
"brave-search": ("Brave Search backend", "alternative paid web-search backend (when an API key is set)"),
"perplexica": ("Perplexica", "web-augmented Q&A UI on top of SearXNG"),
"comfyui": ("ComfyUI", "image + video generation (SDXL Lightning, LTX-2.3). UI on port 8188. The operator can ask me to suggest a prompt for it but I don't generate images directly — I'd point them at ComfyUI"),
"tts": ("Kokoro TTS", "text-to-speech (port 8880). My replies in ODS Talk can be spoken aloud through this"),
"whisper": ("Whisper STT", "speech-to-text (port 8000). Used for voice messages in ODS Talk"),
"embeddings": ("Embeddings server", "vector embeddings (port 8090) — feeds the RAG path"),
"qdrant": ("Qdrant", "vector database (port 6333) for semantic recall and RAG"),
"n8n": ("n8n", "visual no-code workflow + automation engine on port 5678. Operators wire scheduled jobs and integrations here"),
"ape": ("APE", "agentic prompt engineering surface"),
"opencode": ("OpenCode", "coding agent (an alternative to me for code work)"),
"openclaw": ("OpenClaw", "older Claude-style agent (being deprecated in favor of me)"),
"privacy-shield": ("Privacy Shield", "PII scrubber that can sit in front of LLM calls"),
"token-spy": ("Token Spy", "inference traffic introspection"),
"tailscale": ("Tailscale", "mesh VPN — the operator can reach this whole stack remotely without exposing ports to the public internet"),
"langfuse": ("Langfuse", "LLM call tracing + analytics (port 3010)"),
}
# Where the dynamic block gets inserted in SOUL.md.template. The template
# has the literal marker line; this script replaces it.
_INSERT_MARKER = "<!-- INSTALLATION_CONTEXT -->"
def _read_env(env_path: Path) -> dict[str, str]:
"""Parse a .env file into a dict. Ignores comments / blank lines / quotes."""
out: dict[str, str] = {}
if not env_path.exists():
return out
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
v = v.strip()
# Strip surrounding quotes if present
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1]
out[k.strip()] = v
return out
def _container_to_service_map(repo_root: Path) -> dict[str, str]:
"""Build a {container_name: service_id} index from extension manifests.
Manifests declare both ``service.id`` and an explicit ``container_name``
(often ``ods-<id>`` but sometimes different, e.g. open-webui →
ods-webui). We need both directions: incoming docker ps names are
container names, the capability mapping below keys on service-id.
Cheap regex over manifest.yaml — avoiding a YAML dep keeps this script
runnable with Python's stdlib only.
"""
index: dict[str, str] = {}
services_dir = repo_root / "extensions" / "services"
if not services_dir.is_dir():
return index
id_re = re.compile(r"^\s*id:\s*([A-Za-z0-9_-]+)\s*(?:#.*)?$")
cname_re = re.compile(r"^\s*container_name:\s*([A-Za-z0-9_-]+)\s*(?:#.*)?$")
for manifest in sorted(services_dir.glob("*/manifest.yaml")):
sid: str | None = None
cnames: list[str] = []
in_service = False
for raw in manifest.read_text(encoding="utf-8").splitlines():
if raw.startswith("service:"):
in_service = True
continue
if in_service and not raw.startswith((" ", "\t")) and raw.strip():
in_service = False
if not in_service:
continue
m = id_re.match(raw)
if m and sid is None:
sid = m.group(1)
m = cname_re.match(raw)
if m:
cnames.append(m.group(1))
if sid:
# Always include the default `ods-<id>` convention too —
# some manifests omit container_name when it matches.
cnames.append(f"ods-{sid}")
for cname in cnames:
index[cname] = sid
return index
def _running_services(repo_root: Path) -> set[str]:
"""Set of service-ids currently up per ``docker ps``. Cross-references
container names against the manifest-built {container_name: service_id}
map so e.g. ``ods-webui`` resolves to service id ``open-webui``.
Children of compound services (``ods-langfuse-clickhouse`` etc.) get
collapsed to their parent service-id via prefix match. Containers that
don't match any manifest are skipped — they're either non-ODS sidecars
or post-install user additions we shouldn't claim as ours.
"""
try:
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", "name=ods-"],
capture_output=True, text=True, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return set()
cmap = _container_to_service_map(repo_root)
known_sids = set(cmap.values())
running: set[str] = set()
for line in result.stdout.splitlines():
cname = line.strip()
if not cname:
continue
# Exact manifest match first
if cname in cmap:
running.add(cmap[cname])
continue
# Compound-service collapse: ods-langfuse-clickhouse → "langfuse"
# if the latter exists as a known service id.
if cname.startswith("ods-"):
stem = cname[len("ods-"):]
parts = stem.split("-")
for i in range(len(parts), 0, -1):
candidate = "-".join(parts[:i])
if candidate in known_sids:
running.add(candidate)
break
return running
def _loaded_model(llm_port: int = 8080) -> str | None:
"""Best-effort: ask llama-server / Lemonade what's currently loaded.
Returns the model id, or None on any failure (network, no service)."""
# Try Lemonade health first — has structured per-model state
for path in ("/api/v1/health", "/v1/models"):
try:
import urllib.request
with urllib.request.urlopen(f"http://127.0.0.1:{llm_port}{path}", timeout=3) as resp:
data = json.load(resp)
except Exception:
continue
loaded = data.get("all_models_loaded") if isinstance(data, dict) else None
if isinstance(loaded, list) and loaded:
return loaded[0].get("model_name")
models = data.get("data") if isinstance(data, dict) else None
if isinstance(models, list) and models:
return models[0].get("id")
return None
def _humanize_gpu(env: dict[str, str]) -> str:
"""One-line GPU/backend description from .env."""
backend = (env.get("GPU_BACKEND") or "cpu").lower()
mode = env.get("ODS_MODE", "").lower()
if backend == "amd":
if mode == "lemonade":
return "AMD GPU (ROCm/Vulkan via Lemonade)"
return "AMD GPU"
if backend == "nvidia":
return "NVIDIA GPU (CUDA via llama.cpp)"
if backend == "apple":
return "Apple Silicon (Metal via llama.cpp)"
if backend == "cpu":
return "CPU-only (no GPU acceleration)"
return backend
def _capabilities_block(running: set[str], device: str) -> list[str]:
"""Render the user-facing 'what's installed and reachable' bullets.
Only lists services that are *actually running* — not just defined.
Substitutes ``{device}`` into descriptions so the agent gets the live
LAN hostname instead of a placeholder."""
bullets: list[str] = []
seen = set()
# Display order: search → vision/voice → storage → automation → other.
priority = [
"searxng", "perplexica", "brave-search",
"comfyui",
"tts", "whisper",
"qdrant", "embeddings",
"n8n",
"ape", "opencode", "privacy-shield", "token-spy",
"langfuse",
"open-webui",
"tailscale",
]
for sid in priority:
if sid in running and sid in _SERVICE_CAPABILITIES:
label, desc = _SERVICE_CAPABILITIES[sid]
bullets.append(f"- **{label}** — {desc.replace('{device}', device)}")
seen.add(sid)
# Anything running we didn't enumerate gets a generic line so future
# services don't silently vanish from the agent's knowledge.
for sid in sorted(running - seen):
if sid in {"llama-server", "hermes", "hermes-proxy", "dashboard",
"dashboard-api", "ods-proxy", "litellm"}:
continue # internal plumbing — agent doesn't need to mention these
if sid in _SERVICE_CAPABILITIES:
label, desc = _SERVICE_CAPABILITIES[sid]
bullets.append(f"- **{label}** — {desc.replace('{device}', device)}")
else:
bullets.append(f"- **{sid}** — service running on this host")
return bullets
def build_context_block(env_path: Path) -> str:
"""Render the dynamic 'About this installation' Markdown block."""
env = _read_env(env_path)
repo_root = env_path.resolve().parent
running = _running_services(repo_root)
device = env.get("ODS_DEVICE_NAME") or socket.gethostname() or "this machine"
gpu = _humanize_gpu(env)
model_hint = env.get("LLM_MODEL") or env.get("GGUF_FILE") or "the locally-served model"
live_model = _loaded_model()
if live_model:
model_hint = live_model
ctx_size = env.get("CTX_SIZE") or env.get("MAX_CONTEXT") or "?"
if ctx_size.isdigit() and int(ctx_size) >= 1024:
ctx_pretty = f"{int(ctx_size) // 1024}k"
else:
ctx_pretty = ctx_size
talk_host = f"talk.{device}.local"
dash_host = f"{device}.local"
chat_host = f"chat.{device}.local"
bullets = _capabilities_block(running, device)
# Lead with the install identity — hard factual claims phrased so the
# model doesn't drift into running tool calls to "verify." Hermes's
# own system prompt advertises a Hermes dashboard at port 9119; the
# model has a strong prior to mention that when asked about
# "dashboard," which is wrong on a ODS install. We explicitly
# distinguish below.
lines: list[str] = [
"## About this ODS install — read this BEFORE answering questions about your environment",
"",
"**You are running inside ODS, a fully-local AI stack the operator installed on their own hardware.** Hermes Agent (what you run on) is one component of that stack — not the whole thing. When the operator asks about \"the dashboard,\" \"the system,\" \"what's running,\" \"what you can do,\" or \"your hardware,\" they mean the ODS install around you, not Hermes Agent in isolation.",
"",
"**These facts are authoritative. Don't run tool calls (terminal, file probes, GPU queries) to second-guess them — they are auto-generated from this exact install and refreshed when services change. Quote them directly:**",
"",
f"- **Host**: `{device}` (LAN hostname `{dash_host}`)",
f"- **GPU / backend**: {gpu}",
f"- **Chat model serving you right now**: `{model_hint}` (context window: {ctx_pretty})",
f"- **ODS admin dashboard** (model management, extensions catalog, system status): `http://{dash_host}` — this is what the operator means by \"the dashboard.\" NOT Hermes's internal `:9119` endpoint, which is just where you live.",
f"- **Mobile chat portal (ODS Talk)**: `http://{talk_host}` — the operator scans an owner-card QR code to land here on their phone",
f"- **Open WebUI** (desktop-style chat surface, separate from ODS Talk): `http://{chat_host}`",
"",
"## Services running on this install right now",
"",
"ODS has an **extensions system** — services bundled with the stack that the operator can enable/disable from the dashboard's Extensions page without reinstalling. The list below is what's currently running. Some are tools you can call directly through your own tool-calling layer; others are surfaces you can point the operator at.",
"",
"**When asked verbally about this list, summarize it conversationally — don't recite the bullets.** A good answer is one or two sentences that names a few of the most relevant services for the question, not the whole catalog. Example: *\"Web search through SearXNG, image generation in ComfyUI, voice via Kokoro and Whisper, plus workflows in n8n — and there's more on the dashboard.\"* The bullets below are your reference; speak them as natural sentences.",
"",
]
if bullets:
lines.extend(bullets)
else:
lines.append("- (no extension services running — only the core chat path is up)")
lines.extend([
"",
"## Your direct capabilities vs. what the operator reaches separately",
"",
"**You can directly invoke these** via your tool-calling layer:",
"- `web_search` — backed by the SearXNG service above (privacy-respecting, no rate limits)",
"- `web_extract` — fetch a specific URL the operator names",
"- `read_file` / `write_file` / `search_files` — in your sandboxed agent workspace",
"- `execute_code` — sandboxed Python",
"- `memory` — save / recall facts about the operator across sessions",
"- `text_to_speech` — your replies on ODS Talk can be spoken aloud",
"- A `skills` catalog with dozens of pluggable skill modules for specific domains (github, notion, kanban, research, etc.)",
"",
"**You can't directly invoke these but can refer the operator to them** when they ask:",
"- **Image / video generation** → ComfyUI (if running above). The operator opens it from the ODS dashboard. You can help by suggesting prompts, but you don't generate the image yourself.",
"- **Automated workflows / scheduled jobs / third-party integrations** → n8n (if running above). For simple recurring agent tasks you have your own `cronjob` tool.",
"- **Enable more extensions** → Extensions page on the ODS dashboard (`http://" + dash_host + "/extensions`). Catalog includes Aider, OpenCode, Privacy Shield, Tailscale, Perplexica, etc.",
"- **Swap or download a different LLM** → Models page on the ODS dashboard. Operators can pull from Hugging Face directly.",
"- **Remote access from outside the LAN** → Tailscale (if running above)",
"",
"When the operator names a service you don't see in the list above, say so honestly — \"that's not running on this install\" — rather than invent it. The list reflects what `docker ps` reports as of the last regeneration.",
])
return "\n".join(lines)
def build_compact_soul(env_path: Path) -> str:
"""Render a short local profile for backends with tight prompt/schema limits."""
env = _read_env(env_path)
repo_root = env_path.resolve().parent
running = _running_services(repo_root)
device = env.get("ODS_DEVICE_NAME") or socket.gethostname() or "this machine"
gpu = _humanize_gpu(env)
model = _loaded_model() or env.get("LLM_MODEL") or env.get("GGUF_FILE") or "the locally-served model"
ctx_size = env.get("CTX_SIZE") or env.get("MAX_CONTEXT") or "?"
service_names: list[str] = []
for sid in sorted(running):
if sid in {"llama-server", "hermes", "hermes-proxy", "dashboard",
"dashboard-api", "ods-proxy", "litellm"}:
continue
label = _SERVICE_CAPABILITIES.get(sid, (sid, ""))[0]
service_names.append(label)
service_line = ", ".join(service_names) if service_names else "core local chat services"
return "\n".join([
"# ODS - compact local profile",
"",
"You are ODS, the resident assistant on this ODS install. "
"Keep answers brief, natural, and accurate. Use tools when the task needs them.",
"",
"## Install facts",
f"- Host: `{device}`",
f"- GPU/backend: {gpu}",
f"- Local model: `{model}` (context window: {ctx_size})",
f"- Dashboard: `http://{device}.local`",
f"- ODS Talk: `http://talk.{device}.local`",
f"- Open WebUI: `http://chat.{device}.local`",
f"- Running services/extensions: {service_line}",
"",
"## Direct tools",
"- Use `web_search` for current or external facts.",
"- Use `web_extract` for specific URLs.",
"- Use file tools for reading, writing, and searching workspace files.",
"- Use `execute_code` for calculations, snippets, and small scripts.",
"",
"When asked about this environment, answer from these install facts. "
"Do not invent services that are not listed. If the operator asks for "
"image/video generation, workflows, model downloads, or extensions, point "
"them to the ODS dashboard unless that service is listed above.",
"",
])
def build_soul(
template_path: Path,
env_path: Path,
output_path: Path,
profile: str = "full",
) -> bool:
"""Render the assembled SOUL.md. Returns True if the file actually
changed (so callers can decide whether to bounce Hermes)."""
template = template_path.read_text(encoding="utf-8")
context = build_context_block(env_path)
if _INSERT_MARKER in template:
assembled = template.replace(_INSERT_MARKER, context)
else:
# Template doesn't have the marker yet — append the block so
# operators upgrading from an older template still get the
# installation-context behaviour.
assembled = template.rstrip() + "\n\n" + context + "\n"
if profile == "local-lemonade":
assembled = build_compact_soul(env_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Self-heal a pathological state: Docker's bind-mount engine auto-creates
# the source path as a *directory* when the path doesn't exist at
# compose-up time. If a previous install ran compose-up before this
# script generated the file, ``data/persona/SOUL.md`` is now an empty
# directory, and the next compose-up keeps failing with "not a directory:
# Are you trying to mount a directory onto a file." Remove that directory
# so we can write the real file in its place. Caught when re-running
# /ods-fleet-test on mac-mini after a prior failed install.
if output_path.exists() and not output_path.is_file():
import shutil
shutil.rmtree(output_path)
previous = output_path.read_text(encoding="utf-8") if output_path.is_file() else ""
if previous == assembled:
return False
output_path.write_text(assembled, encoding="utf-8")
return True
def main(argv: list[str] | None = None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
repo_root = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(
description="Build data/persona/SOUL.md from the static persona + detected install state.",
)
parser.add_argument(
"--template",
type=Path,
default=repo_root / "extensions" / "services" / "hermes" / "SOUL.md.template",
)
parser.add_argument(
"--env",
type=Path,
default=repo_root / ".env",
)
parser.add_argument(
"--output",
type=Path,
# Keep this on the host-user side of the data directory tree —
# data/hermes/ is owned by uid 10000 (HERMES_UID) so the host's
# operator can't write into it. data/persona/ is a separate
# operator-owned directory bind-mounted ro into the Hermes
# container at /opt/hermes/docker/SOUL.md.
default=repo_root / "data" / "persona" / "SOUL.md",
)
parser.add_argument(
"--check",
action="store_true",
help="Print the assembled file to stdout instead of writing it.",
)
parser.add_argument(
"--profile",
choices=["full", "local-lemonade"],
default="full",
help="Prompt profile to render. local-lemonade keeps Windows AMD prompts compact.",
)
args = parser.parse_args(argv)
if args.check:
ctx = build_compact_soul(args.env) if args.profile == "local-lemonade" else build_context_block(args.env)
sys.stdout.write(ctx)
sys.stdout.write("\n")
return 0
if not args.template.exists():
print(f"ERROR: template not found at {args.template}", file=sys.stderr)
return 2
changed = build_soul(args.template, args.env, args.output, profile=args.profile)
print("changed" if changed else "unchanged")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Validate core compatibility contracts from manifest.json.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST_FILE="${ROOT_DIR}/manifest.json"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
fail() { echo -e "${RED}[FAIL]${NC} $1"; exit 1; }
pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
command -v jq >/dev/null 2>&1 || fail "jq is required"
test -f "$MANIFEST_FILE" || fail "manifest.json not found"
jq -e '.manifestVersion and .release.version and .compatibility and .contracts' "$MANIFEST_FILE" >/dev/null \
|| fail "manifest.json missing required top-level fields"
pass "manifest structure"
# Compose contract files
while IFS= read -r file; do
test -f "${ROOT_DIR}/${file}" || fail "missing compose contract file: ${file}"
done < <(jq -r '.contracts.compose.canonical[]' "$MANIFEST_FILE")
pass "compose canonical files"
# Workflow catalog canonical path
workflow_path="$(jq -r '.contracts.workflowCatalog.canonicalPath' "$MANIFEST_FILE")"
test -f "${ROOT_DIR}/${workflow_path}" || fail "missing canonical workflow catalog: ${workflow_path}"
pass "workflow catalog canonical path"
# Extension schema contract
schema_path="$(jq -r '.contracts.extensions.serviceManifestSchema' "$MANIFEST_FILE")"
test -f "${ROOT_DIR}/${schema_path}" || fail "missing extension schema: ${schema_path}"
pass "extension schema contract"
# Port contract
ports_path="$(jq -r '.contracts.ports.canonicalPath' "$MANIFEST_FILE")"
test -f "${ROOT_DIR}/${ports_path}" || fail "missing canonical ports contract: ${ports_path}"
jq -e '.version and (.ports | type=="array" and length>0)' "${ROOT_DIR}/${ports_path}" >/dev/null \
|| fail "invalid ports contract structure: ${ports_path}"
pass "ports contract"
# Support matrix consistency checks
if jq -e '.compatibility.os.macos.supported == false' "$MANIFEST_FILE" >/dev/null; then
grep -q "macOS.*Tier C" "${ROOT_DIR}/docs/SUPPORT-MATRIX.md" \
|| warn "manifest says macOS unsupported/preview but docs may be out of sync"
fi
pass "compatibility check complete"
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Validate pinned runtime dependency references.
This is intentionally narrow: it tracks runtime container images shipped by
ODS Compose files and extension Dockerfiles. The goal is to make image
drift explicit in review instead of discovering it during an install.
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
ROOT = Path(__file__).resolve().parents[1]
LOCK_PATH = ROOT / "config" / "dependency-lock.json"
IMAGE_RE = re.compile(r"^\s*image:\s*(?P<value>\S+)")
ARG_RE = re.compile(r"^\s*ARG\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?:=(?P<value>\S+))?")
VAR_RE = re.compile(
r"\$\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)(?:(?P<op>:-|-)(?P<default>[^}]+))?\}"
r"|\$(?P<plain>[A-Za-z_][A-Za-z0-9_]*)"
)
EPHEMERAL_SHA_TAG_RE = re.compile(r"^sha-[0-9a-f]{7,64}$", re.IGNORECASE)
@dataclass(frozen=True)
class ImageRef:
path: str
line: int
raw: str
value: str
source: str
def _strip_inline_comment(line: str) -> str:
in_single = False
in_double = False
for idx, char in enumerate(line):
if char == "'" and not in_double:
in_single = not in_single
elif char == '"' and not in_single:
in_double = not in_double
elif char == "#" and not in_single and not in_double:
return line[:idx]
return line
def _clean_value(value: str) -> str:
value = value.strip().strip(",")
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
return value.strip()
def _rel(path: Path, root: Path = ROOT) -> str:
return path.relative_to(root).as_posix()
def _resolve_vars(value: str, defaults: dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
name = match.group("braced") or match.group("plain") or ""
default = match.group("default")
if default is not None:
return default
if name in defaults:
return defaults[name]
return match.group(0)
return VAR_RE.sub(replace, value)
def _compose_image_refs(path: Path, root: Path = ROOT) -> list[ImageRef]:
refs: list[ImageRef] = []
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
match = IMAGE_RE.match(_strip_inline_comment(line))
if not match:
continue
raw = _clean_value(match.group("value"))
refs.append(
ImageRef(
path=_rel(path, root),
line=line_no,
raw=raw,
value=_resolve_vars(raw, {}),
source="compose image",
)
)
return refs
def _dockerfile_from_value(line: str) -> str | None:
line = _strip_inline_comment(line).strip()
if not line.upper().startswith("FROM "):
return None
tokens = line.split()[1:]
while tokens and tokens[0].startswith("--"):
tokens.pop(0)
if not tokens:
return None
return _clean_value(tokens[0])
def _dockerfile_image_refs(path: Path, root: Path = ROOT) -> list[ImageRef]:
refs: list[ImageRef] = []
defaults: dict[str, str] = {}
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
arg_match = ARG_RE.match(_strip_inline_comment(line))
if arg_match and arg_match.group("value") is not None:
defaults[arg_match.group("name")] = _clean_value(arg_match.group("value"))
raw = _dockerfile_from_value(line)
if raw is None:
continue
refs.append(
ImageRef(
path=_rel(path, root),
line=line_no,
raw=raw,
value=_resolve_vars(raw, defaults),
source="dockerfile from",
)
)
return refs
def _image_ref_files(bases: Iterable[Path]) -> set[Path]:
files: set[Path] = set()
for base in bases:
if not base.exists():
continue
for path in base.rglob("*"):
if not path.is_file():
continue
name = path.name
if name.startswith("Dockerfile") or name.startswith("compose.") or name.startswith(
"docker-compose"
):
files.add(path)
return files
def candidate_files(root: Path = ROOT) -> list[Path]:
files: set[Path] = set(root.glob("docker-compose*.yml"))
files.update(root.glob("docker-compose*.yaml"))
files.update(_image_ref_files((root / "installers", root / "extensions" / "services")))
return sorted(files)
def extension_library_candidate_files(root: Path = ROOT) -> list[Path]:
files = _image_ref_files((root / "extensions" / "library" / "services",))
return sorted(files)
def _image_refs_from_files(files: Iterable[Path], root: Path) -> list[ImageRef]:
refs: list[ImageRef] = []
for path in files:
if path.name.startswith("Dockerfile"):
refs.extend(_dockerfile_image_refs(path, root))
else:
refs.extend(_compose_image_refs(path, root))
return refs
def discover_image_refs(root: Path = ROOT) -> list[ImageRef]:
return _image_refs_from_files(candidate_files(root), root)
def discover_extension_library_image_refs(root: Path = ROOT) -> list[ImageRef]:
return _image_refs_from_files(extension_library_candidate_files(root), root)
def _key(item: dict[str, object]) -> tuple[str, str]:
return str(item.get("path", "")), str(item.get("value", ""))
def _image_tag(value: str) -> str | None:
without_digest = value.split("@", 1)[0]
last_segment = without_digest.rsplit("/", 1)[-1]
if ":" not in last_segment:
return None
return last_segment.rsplit(":", 1)[1]
def _has_digest(value: str) -> bool:
return "@sha256:" in value
def _has_tag_or_digest(value: str) -> bool:
return _has_digest(value) or _image_tag(value) is not None
def _is_variable_ref(value: str) -> bool:
return "$" in value
def _is_ephemeral_sha_tag(value: str) -> bool:
tag = _image_tag(value)
return tag is not None and EPHEMERAL_SHA_TAG_RE.match(tag) is not None
def _ephemeral_sha_tag_error(ref: ImageRef) -> str:
location = f"{ref.path}:{ref.line}"
return (
f"{location}: ephemeral sha-* image tags are not release-stable; "
f"pin to a retained version tag or @sha256 digest: {ref.value}"
)
def _arg_default_present(path: Path, arg: str, default: str) -> bool:
expected = f"ARG {arg}={default}"
return expected in path.read_text(encoding="utf-8")
def _validate_lock_shape(lock: dict[str, object], root: Path) -> list[str]:
errors: list[str] = []
if lock.get("version") != 1:
errors.append("dependency-lock.json version must be 1")
ids: set[str] = set()
entry_keys: set[tuple[str, str]] = set()
for entry in lock.get("entries", []):
if not isinstance(entry, dict):
errors.append("lock entries must be objects")
continue
entry_id = str(entry.get("id", ""))
path = str(entry.get("path", ""))
value = str(entry.get("value", ""))
raw = str(entry.get("raw", value))
if not entry_id or not path or not value:
errors.append(f"lock entry is missing id/path/value: {entry!r}")
continue
if entry_id in ids:
errors.append(f"duplicate lock entry id: {entry_id}")
ids.add(entry_id)
if (path, value) in entry_keys:
errors.append(f"duplicate lock entry path/value: {path} -> {value}")
entry_keys.add((path, value))
file_path = root / path
if not file_path.exists():
errors.append(f"lock entry points at missing file: {path}")
continue
text = file_path.read_text(encoding="utf-8")
if raw not in text and value not in text:
errors.append(f"lock entry value is not present in {path}: {value}")
for list_name in ("allow_latest", "allow_local_images", "allow_variable_refs"):
seen: set[tuple[str, str]] = set()
for item in lock.get(list_name, []):
if not isinstance(item, dict):
errors.append(f"{list_name} entries must be objects")
continue
path, value = _key(item)
if not path or not value:
errors.append(f"{list_name} entry is missing path/value: {item!r}")
continue
if (path, value) in seen:
errors.append(f"duplicate {list_name} entry: {path} -> {value}")
seen.add((path, value))
file_path = root / path
if not file_path.exists():
errors.append(f"{list_name} entry points at missing file: {path}")
continue
if value not in file_path.read_text(encoding="utf-8"):
errors.append(f"{list_name} value is not present in {path}: {value}")
arg = item.get("arg")
default = item.get("default")
if isinstance(arg, str) and isinstance(default, str):
if not _arg_default_present(file_path, arg, default):
errors.append(
f"{list_name} entry for {path} expects ARG {arg}={default}"
)
return errors
def validate_refs(refs: Iterable[ImageRef], lock: dict[str, object], root: Path = ROOT) -> list[str]:
errors: list[str] = []
entry_keys = {_key(entry) for entry in lock.get("entries", []) if isinstance(entry, dict)}
latest_allow = {
_key(entry) for entry in lock.get("allow_latest", []) if isinstance(entry, dict)
}
local_allow = {
_key(entry) for entry in lock.get("allow_local_images", []) if isinstance(entry, dict)
}
variable_allow = {
_key(entry) for entry in lock.get("allow_variable_refs", []) if isinstance(entry, dict)
}
discovered_keys: set[tuple[str, str]] = set()
for ref in refs:
discovered_keys.add((ref.path, ref.value))
location = f"{ref.path}:{ref.line}"
if _is_variable_ref(ref.raw):
if (ref.path, ref.raw) not in variable_allow:
errors.append(f"{location}: variable image ref is not documented: {ref.raw}")
if _is_variable_ref(ref.value):
errors.append(f"{location}: variable image ref does not resolve to a default: {ref.raw}")
continue
if (ref.path, ref.value) in local_allow:
continue
if not _has_tag_or_digest(ref.value):
errors.append(f"{location}: image ref must include a tag or digest: {ref.value}")
if _image_tag(ref.value) == "latest" and not _has_digest(ref.value):
if (ref.path, ref.value) not in latest_allow:
errors.append(f"{location}: latest tag requires allow_latest: {ref.value}")
if _is_ephemeral_sha_tag(ref.value) and not _has_digest(ref.value):
errors.append(_ephemeral_sha_tag_error(ref))
if (ref.path, ref.value) not in entry_keys and (ref.path, ref.value) not in latest_allow:
errors.append(f"{location}: image ref is not recorded in dependency-lock.json: {ref.value}")
for path, value in entry_keys:
if (path, value) not in discovered_keys:
errors.append(f"lock entry is stale or undiscovered: {path} -> {value}")
return errors
def validate_ephemeral_sha_tags(refs: Iterable[ImageRef]) -> list[str]:
errors: list[str] = []
for ref in refs:
if _is_ephemeral_sha_tag(ref.value) and not _has_digest(ref.value):
errors.append(_ephemeral_sha_tag_error(ref))
return errors
def load_lock(path: Path = LOCK_PATH) -> dict[str, object]:
with path.open(encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("dependency-lock.json must contain a JSON object")
return data
def check(path: Path = LOCK_PATH, root: Path = ROOT) -> list[str]:
lock = load_lock(path)
errors = _validate_lock_shape(lock, root)
errors.extend(validate_refs(discover_image_refs(root), lock, root))
errors.extend(validate_ephemeral_sha_tags(discover_extension_library_image_refs(root)))
return errors
def main() -> int:
errors = check()
if errors:
for error in errors:
print(f"[FAIL] {error}", file=sys.stderr)
return 1
print("[PASS] dependency pins are documented and enforceable")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+52
View File
@@ -0,0 +1,52 @@
# ============================================================================
# Detect legacy backticks (`cmd`) inside the body of UNQUOTED here-documents.
# ============================================================================
# Issue #509. Quoted here-documents (<<'EOF' / <<"EOF") suppress shell
# expansion, so backticks there are literal characters and harmless. Unquoted
# here-documents (<<EOF / <<-EOF) DO expand backticks as command substitutions
# — and unlike normal `$(...)`, those expansions can't be nested cleanly and
# are easy to misread as plain text. Forbid the pattern outright in installer
# scripts where the value of the heredoc body is what gets shipped to users
# (systemd unit files, generated config snippets, README fragments, …).
#
# Run via: awk -f scripts/check-heredoc-backticks.awk <files>
# Exit codes:
# 0 — no offending heredocs found
# 1 — at least one offending line; details on stderr
#
# This is awk (POSIX) on purpose: the pre-commit hook runs on every developer
# workstation including macOS BSD awk, so we avoid GNU-only features.
# ============================================================================
BEGIN { in_heredoc = 0; found = 0 }
{
if (in_heredoc) {
# End of heredoc: line whose only content is the marker (optionally
# indented when the opener used `<<-`). Body lines may themselves
# equal the marker if they are indented differently — the closing
# rule matches the *trimmed* line.
if ($0 ~ ("^[ \t]*" heredoc_marker "[ \t]*$")) {
in_heredoc = 0
next
}
if (index($0, "`") > 0) {
print FILENAME ":" FNR ": backtick in unquoted heredoc body: " $0 > "/dev/stderr"
found = 1
}
next
}
# Detect an UNQUOTED heredoc opener of the form `<<MARKER` or `<<-MARKER`
# where MARKER starts with [A-Za-z_]. This regex deliberately rejects
# `<<'EOF'` and `<<"EOF"` because the quote character appears between
# `<<-?` and the alphanumeric marker, breaking the match.
if (match($0, /<<-?[A-Za-z_][A-Za-z0-9_]*/)) {
marker = substr($0, RSTART, RLENGTH)
sub(/^<<-?/, "", marker)
in_heredoc = 1
heredoc_marker = marker
}
}
END { exit found ? 1 : 0 }
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# ODS Offline Mode - Model Pre-download Check
# Verifies required models exist before starting services
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ODS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ODS_DIR"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "=========================================="
echo "ODS Offline Mode - Model Check"
echo "=========================================="
echo ""
MISSING=()
# Check LLM model (GGUF)
if ls data/models/*.gguf &>/dev/null; then
MODEL_FILE=$(ls -1 data/models/*.gguf | sed -n '1p')
echo -e "${GREEN}${NC} LLM model: $(basename "$MODEL_FILE")"
else
echo -e "${RED}${NC} LLM model (GGUF) - MISSING"
MISSING+=("gguf-model")
fi
# Check Whisper model
if [ -d "data/whisper/faster-whisper-base" ] || [ -d "data/whisper/models--Systran--faster-whisper-base" ]; then
echo -e "${GREEN}${NC} Whisper base (STT)"
else
echo -e "${RED}${NC} Whisper base - MISSING"
MISSING+=("whisper-base")
fi
# Check Kokoro voice
if [ -f "data/kokoro/voices/af_heart.pt" ]; then
echo -e "${GREEN}${NC} Kokoro voice af_heart (TTS)"
else
echo -e "${RED}${NC} Kokoro voice af_heart - MISSING"
MISSING+=("kokoro-af_heart")
fi
# Check embeddings model
if [ -d "data/embeddings/BAAI/bge-base-en-v1.5" ] || [ -d "data/embeddings/models--BAAI--bge-base-en-v1.5" ]; then
echo -e "${GREEN}${NC} BGE base embeddings (RAG)"
else
echo -e "${RED}${NC} BGE base embeddings - MISSING"
MISSING+=("bge-base-en-v1.5")
fi
echo ""
echo "=========================================="
if [ ${#MISSING[@]} -eq 0 ]; then
echo -e "${GREEN}All models present. Ready for offline mode!${NC}"
exit 0
else
echo -e "${RED}Missing models: ${#MISSING[@]}${NC}"
echo ""
echo "Download models with:"
echo " ./scripts/download-models.sh"
echo ""
echo "Or manually download:"
for model in "${MISSING[@]}"; do
echo " - $model"
done
exit 1
fi
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="${ROOT_DIR}/manifest.json"
MATRIX="${ROOT_DIR}/docs/SUPPORT-MATRIX.md"
TRUTH="${ROOT_DIR}/docs/PLATFORM-TRUTH-TABLE.md"
fail() { echo "[FAIL] $1"; exit 1; }
pass() { echo "[PASS] $1"; }
command -v jq >/dev/null 2>&1 || fail "jq is required"
test -f "$MANIFEST" || fail "manifest.json missing"
test -f "$MATRIX" || fail "docs/SUPPORT-MATRIX.md missing"
test -f "$TRUTH" || fail "docs/PLATFORM-TRUTH-TABLE.md missing"
# Manifest support expectations
linux_supported="$(jq -r '.compatibility.os.linux.supported' "$MANIFEST")"
wsl_supported="$(jq -r '.compatibility.os.windows_wsl2.supported' "$MANIFEST")"
macos_supported="$(jq -r '.compatibility.os.macos.supported' "$MANIFEST")"
windows_native_supported="$(jq -r '.compatibility.os.windows_native.supported' "$MANIFEST")"
[[ "$linux_supported" == "true" ]] || fail "manifest must mark linux supported"
[[ "$wsl_supported" == "true" ]] || fail "manifest must mark windows_wsl2 supported"
[[ "$macos_supported" == "true" ]] || fail "manifest must mark macos supported (Tier B)"
[[ "$windows_native_supported" == "false" ]] || fail "manifest must mark windows_native unsupported"
# Support matrix wording expectations
grep -q "Windows (Docker Desktop + WSL2).*Supported\|Windows (Docker Desktop + WSL2).*Tier B" "$MATRIX" || fail "support matrix missing Windows Tier B claim"
grep -q "macOS (Apple Silicon).*Supported\|macOS (Apple Silicon).*Tier B" "$MATRIX" || fail "support matrix missing macOS Tier B claim"
grep -q "install\.ps1" "$MATRIX" || fail "support matrix missing Windows installer reference"
# Truth table consistency
grep -q "Windows (Docker Desktop + WSL2).*Tier B" "$TRUTH" || fail "truth table missing Windows Tier B"
grep -q "macOS Apple Silicon.*Tier B" "$TRUTH" || fail "truth table missing macOS Tier B"
grep -q "Not safe to claim now" "$TRUTH" || fail "truth table missing launch guardrails section"
pass "release claim gates"
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Fail the release gate when ODS version authorities drift."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def first_match(path: Path, pattern: str, label: str) -> str:
match = re.search(pattern, read_text(path), re.MULTILINE | re.DOTALL)
if not match:
raise ValueError(f"{label}: could not find version in {path.relative_to(ROOT)}")
return match.group(1)
def latest_changelog_release() -> tuple[str, str]:
changelog = read_text(ROOT / "CHANGELOG.md")
for match in re.finditer(r"^## \[([^\]]+)\](?: - ([0-9]{4}-[0-9]{2}-[0-9]{2}))?", changelog, re.MULTILINE):
version, date = match.group(1), match.group(2) or ""
if version.lower() != "unreleased":
return version, date
raise ValueError("CHANGELOG.md: could not find a released version heading")
def optional_version_file() -> str | None:
path = ROOT / ".version"
if not path.exists():
return None
raw = read_text(path).strip()
if not raw:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return raw
if isinstance(data, dict) and data.get("version"):
return str(data["version"])
return raw
def add_regex_check(
checks: list[tuple[str, str]],
errors: list[str],
label: str,
path: Path,
pattern: str,
) -> None:
try:
checks.append((label, first_match(path, pattern, label)))
except ValueError as exc:
errors.append(str(exc))
def main() -> int:
errors: list[str] = []
manifest_path = ROOT / "manifest.json"
try:
manifest = json.loads(read_text(manifest_path))
expected = str(manifest.get("ods_version", "")).strip()
release = manifest.get("release") if isinstance(manifest.get("release"), dict) else {}
release_version = str(release.get("version", "")).strip()
release_date = str(release.get("date", "")).strip()
except Exception as exc: # noqa: BLE001 - gate should report cleanly
print(f"[FAIL] version consistency: cannot read manifest.json: {exc}")
return 1
if not expected:
errors.append("manifest.json ods_version is empty")
elif not SEMVER.match(expected):
errors.append(f"manifest.json ods_version must be x.y.z, got {expected!r}")
checks = [("manifest.json release.version", release_version)]
add_regex_check(
checks,
errors,
"extensions/services/dashboard-api/main.py FastAPI version",
ROOT / "extensions/services/dashboard-api/main.py",
r"app\s*=\s*FastAPI\([^)]*?version\s*=\s*[\"']([^\"']+)[\"']",
)
add_regex_check(
checks,
errors,
"installers/lib/constants.sh VERSION",
ROOT / "installers/lib/constants.sh",
r'^VERSION="([^"]+)"',
)
add_regex_check(
checks,
errors,
"installers/macos/lib/constants.sh ODS_VERSION",
ROOT / "installers/macos/lib/constants.sh",
r'^ODS_VERSION="([^"]+)"',
)
add_regex_check(
checks,
errors,
"installers/windows/lib/constants.ps1 ODS_VERSION",
ROOT / "installers/windows/lib/constants.ps1",
r'^\$script:ODS_VERSION\s*=\s*"([^"]+)"',
)
add_regex_check(
checks,
errors,
"installers/phases/06-directories.sh ODS_VERSION fallback",
ROOT / "installers/phases/06-directories.sh",
r"^ODS_VERSION=\$\{VERSION:-([^}]+)\}",
)
add_regex_check(
checks,
errors,
"ARCHITECTURE.md version",
ROOT.parent / "ARCHITECTURE.md",
r"^> Version ([0-9]+\.[0-9]+\.[0-9]+)\s+\|",
)
version_file = optional_version_file()
if version_file is not None:
checks.append((".version version", version_file))
try:
changelog_version, changelog_date = latest_changelog_release()
checks.append(("CHANGELOG.md latest release", changelog_version))
if release_date and changelog_date and release_date != changelog_date:
errors.append(
f"manifest.json release.date {release_date!r} != CHANGELOG.md latest release date {changelog_date!r}"
)
except ValueError as exc:
errors.append(str(exc))
for label, value in checks:
if value != expected:
errors.append(f"{label} {value!r} != manifest.json ods_version {expected!r}")
if errors:
print("[FAIL] version consistency")
for error in errors:
print(f" - {error}")
return 1
print(f"[PASS] version consistency ({expected})")
return 0
if __name__ == "__main__":
sys.exit(main())
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
set -euo pipefail
# ODS Hardware Classifier — Two-pass GPU matching
# Pass 1: Match known_gpus by device_id then name_patterns (gpu-database.json)
# Pass 2: Fall back to heuristic_classes (threshold-based, same as old hardware-classes.json)
#
# Accepts both old args (--platform-id, --gpu-vendor) and new args (--device-id, --gpu-name, --ram-mb)
# Output contract: HW_CLASS_ID, HW_CLASS_LABEL, HW_REC_BACKEND, HW_REC_TIER,
# HW_REC_COMPOSE_OVERLAYS, HW_BANDWIDTH_GBPS, HW_MEMORY_SOURCE, HW_GPU_LABEL
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
GPU_DB="${ROOT_DIR}/config/gpu-database.json"
ENV_MODE="false"
PLATFORM_ID="${PLATFORM_ID:-unknown}"
GPU_VENDOR="${GPU_VENDOR:-unknown}"
MEMORY_TYPE="${MEMORY_TYPE:-unknown}"
VRAM_MB="${VRAM_MB:-0}"
DEVICE_ID=""
GPU_NAME=""
CPU_NAME=""
RAM_MB="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--platform-id) PLATFORM_ID="${2:-$PLATFORM_ID}"; shift 2 ;;
--gpu-vendor) GPU_VENDOR="${2:-$GPU_VENDOR}"; shift 2 ;;
--memory-type) MEMORY_TYPE="${2:-$MEMORY_TYPE}"; shift 2 ;;
--vram-mb) VRAM_MB="${2:-$VRAM_MB}"; shift 2 ;;
--device-id) DEVICE_ID="${2:-}"; shift 2 ;;
--gpu-name) GPU_NAME="${2:-}"; shift 2 ;;
--cpu-name) CPU_NAME="${2:-}"; shift 2 ;;
--ram-mb) RAM_MB="${2:-0}"; shift 2 ;;
--env) ENV_MODE="true"; shift ;;
--db) GPU_DB="${2:-$GPU_DB}"; shift 2 ;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ ! -f "$GPU_DB" ]]; then
echo "ERROR: GPU database not found: $GPU_DB" >&2
exit 1
fi
PYTHON_CMD="python3"
if [[ -f "$ROOT_DIR/lib/python-cmd.sh" ]]; then
. "$ROOT_DIR/lib/python-cmd.sh"
PYTHON_CMD="$(ods_detect_python_cmd)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
"$PYTHON_CMD" - "$GPU_DB" "$ENV_MODE" "$PLATFORM_ID" "$GPU_VENDOR" "$MEMORY_TYPE" "$VRAM_MB" "$DEVICE_ID" "$GPU_NAME" "$CPU_NAME" "$RAM_MB" <<'PY'
import json
import sys
db_path = sys.argv[1]
env_mode = sys.argv[2] == "true"
platform_id = sys.argv[3]
gpu_vendor = sys.argv[4]
memory_type = sys.argv[5]
vram_mb = int(float(sys.argv[6] or 0))
device_id = sys.argv[7]
gpu_name = sys.argv[8]
cpu_name = sys.argv[9]
ram_mb = int(float(sys.argv[10] or 0))
with open(db_path, "r", encoding="utf-8") as f:
db = json.load(f)
# --- Compose overlay mapping (backend → default overlays) ---
# CPU backend uses cpu overlay: CPU-only llama.cpp image, no GPU reservation
OVERLAY_MAP = {
"amd": ["docker-compose.base.yml", "docker-compose.amd.yml"],
"nvidia": ["docker-compose.base.yml", "docker-compose.nvidia.yml"],
"apple": ["docker-compose.base.yml", "docker-compose.apple.yml"],
"cpu": ["docker-compose.base.yml", "docker-compose.cpu.yml"],
}
# --- Pass 1: Match known_gpus by device_id then name_patterns ---
selected = None
best_name_len = 0 # longest matching pattern wins (prevents "XT" matching "XTX")
best_id_vram_diff = None # closest VRAM wins for device_id-only fallback
combined_name = f"{gpu_name} {cpu_name}".strip().lower()
for entry in db.get("known_gpus", []):
match = entry.get("match", {})
# Try device_id match (exact, most reliable)
dev_ids = [d.lower() for d in match.get("device_ids", [])]
id_matched = device_id.lower() in dev_ids if device_id else False
# Try name_patterns match (case-insensitive substring against gpu_name + cpu_name)
patterns = match.get("name_patterns", [])
matched_patterns = [p for p in patterns if p.lower() in combined_name] if combined_name and patterns else []
name_matched = len(matched_patterns) > 0
match_len = max((len(p) for p in matched_patterns), default=0)
if id_matched and name_matched:
# Both match — prefer longest pattern to avoid "XT" matching "XTX"
if match_len > best_name_len:
selected = entry
best_name_len = match_len
elif id_matched and best_name_len == 0:
# Device ID matched but name didn't — use VRAM proximity as tiebreaker
entry_vram = entry.get("specs", {}).get("memory_mb", 0)
if vram_mb > 0:
diff = abs(entry_vram - vram_mb)
else:
# No VRAM info: prefer smallest card (under-provision is safe,
# over-provision crashes the model loader)
diff = entry_vram if entry_vram > 0 else float("inf")
if best_id_vram_diff is None or diff < best_id_vram_diff:
selected = entry
best_id_vram_diff = diff
elif name_matched and not selected:
selected = entry
best_name_len = match_len
# --- Pass 2: Heuristic fallback (threshold-based, top-down) ---
if not selected:
for entry in db.get("heuristic_classes", []):
match = entry.get("match", {})
# Check vendor
m_vendor = match.get("vendor", "")
if m_vendor and m_vendor != gpu_vendor:
continue
# Check memory_type
m_memtype = match.get("memory_type", "")
if m_memtype and m_memtype != memory_type:
continue
# Check min_vram_mb
min_vram = match.get("min_vram_mb", -1)
if min_vram >= 0 and vram_mb < min_vram:
continue
# Check max_vram_mb. This lets the database model "too small for
# GPU inference" classes explicitly instead of catching them in broad
# vendor fallbacks.
max_vram = match.get("max_vram_mb", -1)
if max_vram >= 0 and vram_mb > max_vram:
continue
# Check min_ram_mb (for unified memory classes)
min_ram = match.get("min_ram_mb", -1)
if min_ram >= 0 and ram_mb < min_ram:
continue
selected = entry
break
# --- Bandwidth lookup ---
bandwidth = 0
if selected and "specs" in selected:
bandwidth = selected["specs"].get("bandwidth_gbps", 0)
if bandwidth == 0 and gpu_name:
# Search bandwidth table by substring match
vendor_bw = db.get("known_gpu_bandwidth", {}).get(gpu_vendor, {})
for bw_name, bw_val in vendor_bw.items():
if bw_name.lower() in gpu_name.lower() or bw_name.lower() in cpu_name.lower():
bandwidth = bw_val
break
if bandwidth == 0:
# Fall back to default bandwidth
backend_key_map = {"nvidia": "cuda", "amd": "rocm", "apple": "metal"}
bk = backend_key_map.get(gpu_vendor, "cpu_x86")
bandwidth = db.get("defaults", {}).get("bandwidth_gbps", {}).get(bk, 0)
# --- Build result ---
if selected:
# Known GPU entry
if "specs" in selected:
class_id = selected.get("id", "unknown")
label = selected["specs"].get("label", selected.get("id", "Unknown"))
rec = selected.get("recommended", {})
backend = rec.get("backend", "cpu")
tier = rec.get("tier", "T1")
memory_source = selected["specs"].get("memory_source", "vram")
else:
# Heuristic class entry
class_id = selected.get("id", "unknown")
label = selected.get("id", "Unknown").replace("_", " ").title()
rec = selected.get("recommended", {})
backend = rec.get("backend", "cpu")
tier = rec.get("tier", "T1")
m_memtype = selected.get("match", {}).get("memory_type", "")
memory_source = "ram" if backend == "cpu" or m_memtype == "unified" else "vram"
else:
class_id = "unknown"
label = "Unknown"
backend = "cpu"
tier = "T1"
memory_source = "vram"
overlays = OVERLAY_MAP.get(backend, ["docker-compose.base.yml"])
# Darwin hosts running the apple backend use the canonical macOS overlay
# (installers/macos/docker-compose.macos.yml). The OVERLAY_MAP entry for
# "apple" still lists docker-compose.apple.yml so Linux hosts selecting
# --gpu-backend apple (Lemonade) continue to get the top-level overlay.
if backend == "apple" and platform_id == "macos":
overlays = ["docker-compose.base.yml", "installers/macos/docker-compose.macos.yml"]
gpu_label = selected["specs"].get("label", "") if selected and "specs" in selected else ""
# --- Output ---
def out(key, value):
safe = str(value).replace("\\", "\\\\").replace('"', '\\"')
print(f'{key}="{safe}"')
if env_mode:
out("HW_CLASS_ID", class_id)
out("HW_CLASS_LABEL", label)
out("HW_REC_BACKEND", backend)
out("HW_REC_TIER", tier)
out("HW_REC_COMPOSE_OVERLAYS", ",".join(overlays))
out("HW_BANDWIDTH_GBPS", bandwidth)
out("HW_MEMORY_SOURCE", memory_source)
out("HW_GPU_LABEL", gpu_label)
else:
result = {
"id": class_id,
"label": label,
"recommended": {
"backend": backend,
"tier": tier,
"compose_overlays": overlays,
},
"bandwidth_gbps": bandwidth,
"memory_source": memory_source,
"gpu_label": gpu_label,
}
print(json.dumps(result, indent=2))
PY
+401
View File
@@ -0,0 +1,401 @@
#!/bin/bash
# ODS Offline Demo Mode
# Demonstrates what each feature WOULD do without running services
# Useful for sales demos, documentation screenshots, presentations
# Usage: ./scripts/demo-offline.sh
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
# Simulated typing effect
type_text() {
local text="$1"
local delay="${2:-0.02}"
for ((i=0; i<${#text}; i++)); do
printf '%s' "${text:$i:1}"
sleep "$delay"
done
echo ""
}
# Simulated streaming response
stream_text() {
local text="$1"
local delay="${2:-0.01}"
for word in $text; do
printf '%s ' "$word"
sleep "$delay"
done
echo ""
}
clear_screen() {
printf "\033[2J\033[H"
}
pause() {
echo ""
echo -e "${DIM}Press Enter to continue...${NC}"
read -r
}
print_header() {
echo ""
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ ODS — Offline Demo Mode ║${NC}"
echo -e "${BOLD}${CYAN}${DIM}(No GPU/services required)${BOLD}${CYAN}${NC}"
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
}
print_menu() {
echo -e "${BOLD}Select a demo:${NC}"
echo ""
echo -e " ${CYAN}[1]${NC} Chat with AI ${DIM}— See the chat experience${NC}"
echo -e " ${CYAN}[2]${NC} Voice-to-Voice ${DIM}— Speak to your AI${NC}"
echo -e " ${CYAN}[3]${NC} Document Q&A (RAG) ${DIM}— Ask questions about docs${NC}"
echo -e " ${CYAN}[4]${NC} Code Assistant ${DIM}— AI code review${NC}"
echo -e " ${CYAN}[5]${NC} Hardware Detection ${DIM}— What the installer sees${NC}"
echo -e " ${CYAN}[6]${NC} System Overview ${DIM}— Full stack walkthrough${NC}"
echo -e " ${CYAN}[A]${NC} Run All Demos ${DIM}— Full showcase${NC}"
echo -e " ${CYAN}[Q]${NC} Quit"
echo ""
echo -ne "${BOLD}Select: ${NC}"
}
# ── Demo 1: Chat ──────────────────────────────────────
demo_chat() {
clear_screen
echo -e "${BOLD}${MAGENTA}Demo: Chat with AI${NC}"
echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${DIM}[Connected to llama-server → local GGUF model]${NC}"
echo -e "${DIM}[API: http://localhost:8080/v1/chat/completions]${NC}"
echo ""
echo -ne "${GREEN}You: ${NC}"
type_text "What can ODS do?" 0.04
echo -ne "${CYAN}AI: ${NC}"
stream_text "ODS is your complete local AI stack. I can help you with:" 0.03
echo ""
stream_text " 1. Natural language chat (that's me!)" 0.02
stream_text " 2. Voice conversations — speak to me and I'll speak back" 0.02
stream_text " 3. Document analysis — upload docs and ask questions" 0.02
stream_text " 4. Code assistance — explain, improve, or debug your code" 0.02
stream_text " 5. Workflow automation — chain AI tasks together with n8n" 0.02
echo ""
stream_text "Everything runs locally on your hardware. No cloud, no API fees, no data leaving your network." 0.02
echo ""
echo -e "${DIM}───────────────────────────────────────────${NC}"
echo -ne "${GREEN}You: ${NC}"
type_text "How fast is inference?" 0.04
echo -ne "${CYAN}AI: ${NC}"
stream_text "On a single RTX 4090 with the 32B AWQ model, you can expect:" 0.03
echo ""
stream_text " • Time to first token: ~200ms" 0.02
stream_text " • Generation speed: 30-50 tokens/sec" 0.02
stream_text " • Throughput: handles multiple concurrent users" 0.02
echo ""
stream_text "llama-server uses continuous batching for efficient memory management, so you get near-optimal GPU utilization." 0.02
pause
}
# ── Demo 2: Voice ──────────────────────────────────────
demo_voice() {
clear_screen
echo -e "${BOLD}${MAGENTA}Demo: Voice-to-Voice${NC}"
echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${DIM}[Pipeline: Whisper STT → llama-server → Kokoro TTS]${NC}"
echo ""
echo -e "${YELLOW}Recording...${NC} ${DIM}(5 seconds)${NC}"
for i in 5 4 3 2 1; do
echo -ne "\r ${BOLD}${i}s${NC} remaining ▓▓▓"
printf '▓%.0s' $(seq 1 $((6-i)))
printf '░%.0s' $(seq 1 $((i-1)))
sleep 0.5
done
echo -e "\r ${GREEN}✓ Recorded${NC} "
echo ""
echo -e "${CYAN}Transcribing with Whisper...${NC}"
sleep 0.8
echo -e " ${GREEN}${NC} \"Tell me about the weather today\""
echo ""
echo -e "${CYAN}Generating response with llama-server...${NC}"
sleep 0.6
echo -ne " ${GREEN}${NC} "
stream_text "I don't have real-time weather data since I run locally, but I can help you set up a workflow that fetches weather from a free API and reads it to you every morning!" 0.02
echo ""
echo -e "${CYAN}Synthesizing speech with OpenTTS...${NC}"
sleep 0.5
echo -e " ${GREEN}${NC} Generated audio (2.3 seconds, en_US-lessac-medium)"
echo ""
echo -e "${GREEN}▶ Playing response...${NC}"
echo -e " ${DIM}♪ ░░░░░░░░░░░░░░░░░░░░ 2.3s${NC}"
sleep 1
echo ""
echo -e "${BOLD}curl command:${NC}"
echo -e "${DIM} curl -X POST http://localhost:5678/webhook/voice-chat \\${NC}"
echo -e "${DIM} -F \"audio=@recording.wav\" -o response.wav${NC}"
pause
}
# ── Demo 3: RAG ──────────────────────────────────────
demo_rag() {
clear_screen
echo -e "${BOLD}${MAGENTA}Demo: Document Q&A (RAG)${NC}"
echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${DIM}[Pipeline: Upload → Chunk → Embed → Qdrant → Query → LLM]${NC}"
echo ""
echo -e "${CYAN}Step 1: Uploading document...${NC}"
sleep 0.3
echo " File: company-handbook.pdf (42 pages)"
echo ""
echo -e "${CYAN}Step 2: Chunking text...${NC}"
sleep 0.3
echo " Created 187 chunks (500 chars, 100 overlap)"
echo ""
echo -e "${CYAN}Step 3: Generating embeddings...${NC}"
sleep 0.5
echo " Model: BAAI/bge-base-en-v1.5 (768 dimensions)"
echo -e " ${GREEN}${NC} 187/187 chunks embedded"
echo ""
echo -e "${CYAN}Step 4: Stored in Qdrant...${NC}"
sleep 0.3
echo " Collection: ods-docs (187 vectors)"
echo ""
echo -e "${DIM}───────────────────────────────────────────${NC}"
echo ""
echo -ne "${GREEN}Question: ${NC}"
type_text "What is the PTO policy?" 0.04
echo -e "${CYAN}Searching...${NC}"
sleep 0.4
echo " Found 3 relevant chunks (similarity: 0.89, 0.84, 0.81)"
echo ""
echo -ne "${CYAN}Answer: ${NC}"
stream_text "According to the company handbook, the PTO policy provides:" 0.03
echo ""
stream_text " • 15 days PTO for new employees (years 1-3)" 0.02
stream_text " • 20 days PTO after 3 years" 0.02
stream_text " • 25 days PTO after 5 years" 0.02
stream_text " • Unused PTO carries over up to 5 days" 0.02
echo ""
echo -e " ${DIM}Source: handbook.pdf, pages 12-13${NC}"
pause
}
# ── Demo 4: Code Assistant ──────────────────────────────
demo_code() {
clear_screen
echo -e "${BOLD}${MAGENTA}Demo: Code Assistant${NC}"
echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${DIM}[Task: improve | Language: python]${NC}"
echo ""
echo -e "${YELLOW}Input:${NC}"
echo -e "${DIM} def read_config(path):${NC}"
echo -e "${DIM} f = open(path, 'r')${NC}"
echo -e "${DIM} data = json.load(f)${NC}"
echo -e "${DIM} return data${NC}"
echo ""
echo -e "${CYAN}Analyzing...${NC}"
sleep 0.8
echo ""
echo -e "${GREEN}Improved:${NC}"
echo ""
stream_text " def read_config(path: str) -> dict:" 0.02
stream_text " \"\"\"Read configuration from JSON file.\"\"\"" 0.02
stream_text " with open(path, 'r') as f:" 0.02
stream_text " return json.load(f)" 0.02
echo ""
echo -e "${BOLD}Changes:${NC}"
stream_text " 1. Added context manager (with) — file is always closed, even on error" 0.02
stream_text " 2. Added type hints for clarity" 0.02
stream_text " 3. Added docstring" 0.02
echo ""
echo -e "${BOLD}curl command:${NC}"
echo -e "${DIM} curl -X POST http://localhost:5678/webhook/code-assist \\${NC}"
echo -e "${DIM} -H 'Content-Type: application/json' \\${NC}"
echo -e "${DIM} -d '{\"code\": \"...\", \"task\": \"improve\"}'${NC}"
pause
}
# ── Demo 5: Hardware Detection ──────────────────────────
demo_hardware() {
clear_screen
echo -e "${BOLD}${MAGENTA}Demo: Hardware Detection${NC}"
echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${DIM}Running: ./scripts/detect-hardware.sh${NC}"
echo ""
# Try running real detection, fall back to mock
if [[ -x "$(dirname "$0")/detect-hardware.sh" ]]; then
"$(dirname "$0")/detect-hardware.sh" 2>/dev/null || {
echo -e "${BLUE}╔══════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ ODS Hardware Detection ║${NC}"
echo -e "${BLUE}╚══════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}System:${NC}"
echo " OS: linux"
echo " CPU: AMD Ryzen 9 7950X"
echo " Cores: 32"
echo " RAM: 64GB"
echo ""
echo -e "${GREEN}GPU:${NC}"
echo " Type: nvidia"
echo " Name: NVIDIA GeForce RTX 4090"
echo " VRAM: 24GB"
echo ""
echo -e "${YELLOW}Recommended Tier: T3${NC}"
echo " Pro (20-47GB): 32B models, comfortable headroom"
}
else
echo -e "${BLUE}╔══════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ ODS Hardware Detection ║${NC}"
echo -e "${BLUE}╚══════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}System:${NC}"
echo " OS: linux"
echo " CPU: AMD Ryzen 9 7950X"
echo " Cores: 32"
echo " RAM: 64GB"
echo ""
echo -e "${GREEN}GPU:${NC}"
echo " Type: nvidia"
echo " Name: NVIDIA GeForce RTX 4090"
echo " VRAM: 24GB"
echo ""
echo -e "${YELLOW}Recommended Tier: T3${NC}"
echo " Pro (20-47GB): 32B models, comfortable headroom"
fi
echo ""
echo -e "${BOLD}The installer uses this to auto-select:${NC}"
echo " • Model: Qwen/Qwen2.5-32B-Instruct-AWQ"
echo " • Context: 32768 tokens"
echo " • VRAM: 90% utilization"
pause
}
# ── Demo 6: System Overview ──────────────────────────
demo_overview() {
clear_screen
echo -e "${BOLD}${MAGENTA}ODS — System Overview${NC}"
echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${BOLD}Architecture:${NC}"
echo ""
echo -e " ${CYAN}┌─────────────────────────────────────────────────┐${NC}"
echo -e " ${CYAN}│ Open WebUI (:3000) │${NC}"
echo -e " ${CYAN}│ Beautiful chat interface │${NC}"
echo -e " ${CYAN}└────────────────────┬────────────────────────── ┘${NC}"
echo -e " ${CYAN}${NC}"
echo -e " ${CYAN}┌────────────────────▼────────────────────────── ┐${NC}"
echo -e " ${CYAN}│ llama-server (:8080) │${NC}"
echo -e " ${CYAN}│ High-performance LLM inference │${NC}"
echo -e " ${CYAN}│ GGUF models • 30-50 tok/s • GPU offload │${NC}"
echo -e " ${CYAN}└──────┬────────────────────────────┬───────── ┘${NC}"
echo -e " ${CYAN} │ │${NC}"
echo -e " ${CYAN}┌──────▼──────┐ ┌──────▼──────┐${NC}"
echo -e " ${CYAN}│ Whisper STT │ │ OpenTTS TTS │${NC}"
echo -e " ${CYAN}│ (:9000) │ │ (:8880) │${NC}"
echo -e " ${CYAN}└─────────────┘ └─────────────┘${NC}"
echo ""
echo -e " ${CYAN}┌─────────────┐ ┌─────────────┐ ┌─────────────┐${NC}"
echo -e " ${CYAN}│ n8n (:5678) │ │Qdrant(:6333)│ │LiteLLM(:4K) │${NC}"
echo -e " ${CYAN}│ Workflows │ │ Vector DB │ │ API Gateway │${NC}"
echo -e " ${CYAN}└─────────────┘ └─────────────┘ └─────────────┘${NC}"
echo ""
echo -e "${BOLD}One command to install:${NC}"
echo -e " ${DIM}curl -fsSL https://get.ods.dev | bash${NC}"
echo ""
echo -e "${BOLD}Then:${NC}"
echo -e " ${GREEN}${NC} Chat UI at localhost:3000"
echo -e " ${GREEN}${NC} Voice assistant (speak ↔ listen)"
echo -e " ${GREEN}${NC} Document Q&A (upload → ask)"
echo -e " ${GREEN}${NC} Code review (paste → improve)"
echo -e " ${GREEN}${NC} Workflow automation (visual builder)"
echo ""
echo -e "${BOLD}All local. No cloud. No API fees. Your data stays yours.${NC}"
pause
}
# ── Run All ──────────────────────────────────────
run_all() {
demo_chat
demo_voice
demo_rag
demo_code
demo_hardware
demo_overview
echo -e "${GREEN}${BOLD}Demo complete!${NC}"
}
# ── Main Loop ──────────────────────────────────────
while true; do
clear_screen
print_header
print_menu
read -r choice
case "${choice,,}" in
1) demo_chat ;;
2) demo_voice ;;
3) demo_rag ;;
4) demo_code ;;
5) demo_hardware ;;
6) demo_overview ;;
a|all) run_all ;;
q|quit|exit)
echo ""
echo -e "${GREEN}Thanks for watching!${NC}"
exit 0
;;
*)
echo -e "${YELLOW}Invalid option${NC}"
sleep 0.5
;;
esac
done
+804
View File
@@ -0,0 +1,804 @@
#!/bin/bash
# ODS Hardware Detection
# Detects GPU, CPU, RAM and recommends tier
# Supports: NVIDIA (nvidia-smi), AMD APU/dGPU (sysfs), Apple Silicon
set -euo pipefail
# Note: this script is used by installers/tests. Keep it deterministic and safe:
# - never rely on uninitialized vars
# - avoid pipeline masking failures
# - do not hang on missing system tools
# Print errors with context
err() {
echo -e "${RED}Error:${NC} $*" >&2
}
# Require a dependency for a given feature
require_cmd() {
local cmd="$1"
local hint="${2:-}"
if ! command -v "$cmd" &>/dev/null; then
err "Missing required command: $cmd${hint:+ ($hint)}"
return 1
fi
return 0
}
# Safe command execution: returns empty string on failure
try() {
"$@" 2>/dev/null || true
}
# Safer head -1 that doesn't error under pipefail
first_line() {
awk 'NR==1{print; exit 0}'
}
# JSON string escape (minimal, sufficient for our fields)
json_escape() {
local s="$1"
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\n'/\\n}
s=${s//$'\r'/\\r}
s=${s//$'\t'/\\t}
printf '%s' "$s"
}
# Normalize integer (digits only) else 0
as_int() {
local v="${1:-}"
[[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo "0"
}
# Clamp integer between min/max
clamp_int() {
local v min max
v=$(as_int "${1:-0}")
min=$(as_int "${2:-0}")
max=$(as_int "${3:-0}")
(( v < min )) && v=$min
(( v > max )) && v=$max
echo "$v"
}
# Detect if running inside a container
in_container() {
[[ -f /.dockerenv ]] && return 0
[[ -f /run/.containerenv ]] && return 0
if [[ -f /proc/1/cgroup ]] && grep -qiE '(docker|containerd|kubepods|lxc)' /proc/1/cgroup 2>/dev/null; then
return 0
fi
return 1
}
# Detect if inside WSL2
is_wsl() {
[[ -f /proc/version ]] && grep -qi microsoft /proc/version 2>/dev/null
}
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
usage() {
cat <<'EOF'
Usage: detect-hardware.sh [--json] [--json-compact] [--verbose] [--help]
Options:
--json Print machine-readable JSON output
--json-compact Print JSON in one line (for scripting)
--verbose Include extra hardware identifiers in text mode
--help Show this help
EOF
}
# Detect OS and environment
detect_os() {
# Explicit WSL detection first
if is_wsl; then
echo "wsl"
return 0
fi
# OSTYPE is set in bash, but keep a fallback in case it's missing
local os_type="${OSTYPE:-}"
if [[ "$os_type" == "darwin"* ]]; then
echo "macos"
elif [[ "$os_type" == "linux-gnu"* || -f /proc/version ]]; then
echo "linux"
else
echo "unknown"
fi
}
# Detect platform quirks that impact Docker/GPU detection
# Returns: "native" | "wsl2" | "container"
detect_platform() {
if in_container; then
echo "container"
elif is_wsl; then
echo "wsl2"
else
echo "native"
fi
}
# Detect NVIDIA GPU
detect_nvidia() {
# Validate NVIDIA hardware present in sysfs before trusting nvidia-smi,
# which may be installed without NVIDIA hardware (e.g. nvidia-container-toolkit
# on AMD-only systems).
local _has_nvidia=false
for _v in /sys/class/drm/card*/device/vendor; do
[[ "$(cat "$_v" 2>/dev/null)" == "0x10de" ]] && _has_nvidia=true && break
done
$_has_nvidia || return 1
# Works on bare metal Linux; on WSL2 it may be present via Docker Desktop GPU integration.
if command -v nvidia-smi &>/dev/null; then
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null | first_line
fi
}
# Parse PCI device id from nvidia-smi output, normalize to 0x1234
nvidia_device_id() {
if command -v nvidia-smi &>/dev/null; then
local pci_id
pci_id=$(nvidia-smi --query-gpu=pci.device_id --format=csv,noheader 2>/dev/null | first_line | xargs || true)
# Example: 0x26B110DE => device part is first 6 chars => 0x26B1
if [[ -n "$pci_id" && "$pci_id" == 0x* && ${#pci_id} -ge 6 ]]; then
echo "${pci_id:0:6}"
return 0
fi
fi
echo ""
return 1
}
# Parse nvidia-smi memory.total robustly (MB)
parse_nvidia_vram_mb() {
local output="$1"
local mb
mb=$(echo "$output" | awk -F',' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2}' | xargs || true)
as_int "$mb"
}
# Count NVIDIA GPUs
count_nvidia_gpus() {
if command -v nvidia-smi &>/dev/null; then
nvidia-smi --query-gpu=name --format=csv,noheader,nounits 2>/dev/null | wc -l | tr -d ' '
else
echo "0"
fi
}
# Detect AMD GPU via sysfs (works without ROCm installed)
# Returns: gpu_name|vram_bytes|gtt_bytes|is_apu|gpu_busy|temp|power|vulkan|rocm|driver|device_id|subsystem_device|revision
detect_amd_sysfs() {
for card_dir in /sys/class/drm/card*/device; do
[[ -d "$card_dir" ]] || continue
local vendor
vendor=$(cat "$card_dir/vendor" 2>/dev/null) || continue
# 0x1002 = AMD
if [[ "$vendor" == "0x1002" ]]; then
local vram_total gtt_total gpu_name gpu_busy temp power hwmon_dir is_apu
local device_id subsystem_device revision
# Read PCI device identifiers
device_id=$(cat "$card_dir/device" 2>/dev/null) || device_id="unknown"
subsystem_device=$(cat "$card_dir/subsystem_device" 2>/dev/null) || subsystem_device="unknown"
revision=$(cat "$card_dir/revision" 2>/dev/null) || revision="unknown"
# Read memory info
vram_total=$(cat "$card_dir/mem_info_vram_total" 2>/dev/null) || vram_total=0
gtt_total=$(cat "$card_dir/mem_info_gtt_total" 2>/dev/null) || gtt_total=0
# Detect if APU (unified memory)
# Strix Halo has small VRAM carve-out (UMA frame buffer, often 1GB)
# but large GTT (actual usable GPU memory from system RAM).
is_apu="false"
if [[ $vram_total -gt 0 && $gtt_total -gt 0 ]]; then
local vram_gb=$(( vram_total / 1073741824 ))
local gtt_gb=$(( gtt_total / 1073741824 ))
if [[ $gtt_gb -ge 16 && $vram_gb -le 4 ]]; then
# Small VRAM + large GTT = APU with unified memory
is_apu="true"
elif [[ $gtt_gb -ge 32 ]]; then
is_apu="true"
elif [[ $vram_gb -ge 32 ]]; then
is_apu="true"
fi
fi
# GPU utilization
gpu_busy=$(cat "$card_dir/gpu_busy_percent" 2>/dev/null) || gpu_busy=0
# Find hwmon for temp/power
temp=0
power=0
for hwmon_dir in "$card_dir"/hwmon/hwmon*; do
if [[ -d "$hwmon_dir" ]]; then
local raw_temp raw_power
raw_temp=$(cat "$hwmon_dir/temp1_input" 2>/dev/null) || raw_temp=0
temp=$(( raw_temp / 1000 )) # millidegrees → C
raw_power=$(cat "$hwmon_dir/power1_average" 2>/dev/null) || raw_power=0
power=$(( raw_power / 1000000 )) # microwatts → W
break
fi
done
# Try to get GPU name from various sources
gpu_name=""
# Try marketing name first
if [[ -f "$card_dir/product_name" ]]; then
gpu_name=$(cat "$card_dir/product_name" 2>/dev/null) || true
fi
# Fall back to device ID lookup
if [[ -z "$gpu_name" ]]; then
gpu_name="AMD GPU ($device_id)"
fi
# Check for Vulkan support
local vulkan_available="false"
if command -v vulkaninfo &>/dev/null; then
if vulkaninfo --summary 2>/dev/null | grep -qiE "radeon|amd|gfx11"; then
vulkan_available="true"
fi
fi
# Check for ROCm
local rocm_available="false"
if command -v rocminfo &>/dev/null; then
rocm_available="true"
fi
# Check amdgpu driver loaded
local driver_loaded="false"
if lsmod 2>/dev/null | grep -q amdgpu; then
driver_loaded="true"
fi
echo "${gpu_name}|${vram_total}|${gtt_total}|${is_apu}|${gpu_busy}|${temp}|${power}|${vulkan_available}|${rocm_available}|${driver_loaded}|${device_id}|${subsystem_device}|${revision}"
return 0
fi
done
return 1
}
# Count AMD GPUs via sysfs
count_amd_gpus() {
local count=0
for card_dir in /sys/class/drm/card*/device; do
[[ -d "$card_dir" ]] || continue
local vendor
vendor=$(cat "$card_dir/vendor" 2>/dev/null) || continue
# (( 0++ )) returns exit 1 in bash, so || true prevents pipefail abort
[[ "$vendor" == "0x1002" ]] && (( count++ )) || true
done
echo "$count"
}
# Detect AMD GPU (legacy ROCm-only path)
detect_amd() {
# Try sysfs first (works without ROCm)
local sysfs_out
if sysfs_out=$(detect_amd_sysfs 2>/dev/null); then
echo "$sysfs_out"
return 0
fi
# Fall back to rocm-smi
if command -v rocm-smi &>/dev/null; then
rocm-smi --showproductname --showmeminfo vram 2>/dev/null | grep -E "GPU|Total Memory" | head -2
fi
}
# Detect NVIDIA Jetson (Tegra SoC)
# Returns: <model_name>|<l4t_release>|<ram_mb> (pipe-delimited) or empty
# Test hooks: ODS_NV_TEGRA_RELEASE, ODS_DEVICE_TREE_COMPATIBLE,
# ODS_DEVICE_TREE_MODEL, ODS_GPU0_SYSFS, ODS_UNAME_M
detect_jetson() {
[[ "${ODS_ENABLE_EXPERIMENTAL_JETSON:-0}" == "1" ]] || return 1
local tegra_release="${ODS_NV_TEGRA_RELEASE:-/etc/nv_tegra_release}"
local dt_compat="${ODS_DEVICE_TREE_COMPATIBLE:-/proc/device-tree/compatible}"
local dt_model="${ODS_DEVICE_TREE_MODEL:-/proc/device-tree/model}"
local gpu0_sysfs="${ODS_GPU0_SYSFS:-/sys/devices/gpu.0}"
local uname_m="${ODS_UNAME_M:-$(uname -m)}"
[[ "$uname_m" == "aarch64" ]] || return 1
local is_jetson=false
if [[ -f "$tegra_release" ]]; then
is_jetson=true
elif [[ -f "$dt_compat" ]] && tr -d '\0' < "$dt_compat" 2>/dev/null | grep -q "nvidia,tegra"; then
is_jetson=true
elif [[ -d "$gpu0_sysfs" ]]; then
is_jetson=true
fi
$is_jetson || return 1
local model="NVIDIA Jetson (Tegra)"
if [[ -f "$dt_model" ]]; then
local raw
raw=$(tr -d '\0' < "$dt_model" 2>/dev/null) || raw=""
[[ -n "$raw" ]] && model="$raw"
fi
local l4t=""
if [[ -f "$tegra_release" ]]; then
local major minor
major=$(grep -oE 'R[0-9]+' "$tegra_release" 2>/dev/null | head -1)
minor=$(grep -oE 'REVISION: [0-9.]+' "$tegra_release" 2>/dev/null | head -1 | awk '{print $2}')
[[ -n "$major" && -n "$minor" ]] && l4t="${major}.${minor}"
fi
local ram_kb ram_mb=0
ram_kb=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}')
[[ -n "$ram_kb" && "$ram_kb" -gt 0 ]] && ram_mb=$(( ram_kb / 1024 ))
echo "${model}|${l4t}|${ram_mb}"
}
# Detect Apple Silicon
detect_apple() {
if [[ "$(detect_os)" == "macos" ]]; then
sysctl -n machdep.cpu.brand_string 2>/dev/null
# Unified memory = system RAM on Apple Silicon
sysctl -n hw.memsize 2>/dev/null | awk '{print int($1/1024/1024/1024)"GB unified"}'
fi
}
# Get CPU info
detect_cpu() {
local os
os=$(detect_os)
case $os in
macos)
sysctl -n machdep.cpu.brand_string 2>/dev/null || echo "Unknown"
;;
*)
grep -m1 "model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2 | xargs || echo "Unknown"
;;
esac
}
# Get CPU cores
detect_cores() {
local os
os=$(detect_os)
case $os in
macos)
sysctl -n hw.ncpu 2>/dev/null || echo "0"
;;
*)
nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo 2>/dev/null || echo "0"
;;
esac
}
# Get RAM in GB
detect_ram() {
local os
os=$(detect_os)
case $os in
macos)
sysctl -n hw.memsize 2>/dev/null | awk '{print int($1/1024/1024/1024)}'
;;
*)
grep MemTotal /proc/meminfo 2>/dev/null | awk '{print int($2/1024/1024)}'
;;
esac
}
# Parse VRAM from nvidia-smi output (in MB)
# Kept for backwards compatibility with older callers; prefer parse_nvidia_vram_mb.
parse_nvidia_vram() {
parse_nvidia_vram_mb "$1"
}
# Determine tier based on VRAM (discrete GPU)
# T4: 48GB+ | T3: 20-47GB | T2: 12-19GB | T1: <12GB
get_tier() {
local vram_mb=$1
local vram_gb=$((vram_mb / 1024))
if [[ $vram_gb -ge 48 ]]; then
echo "T4"
elif [[ $vram_gb -ge 20 ]]; then
echo "T3"
elif [[ $vram_gb -ge 12 ]]; then
echo "T2"
else
echo "T1"
fi
}
# Determine Strix Halo tier based on unified memory
# SH_LARGE: 90GB+ | SH_COMPACT: <90GB
get_strix_halo_tier() {
local unified_gb=$1
if [[ $unified_gb -ge 90 ]]; then
echo "SH_LARGE"
else
echo "SH_COMPACT"
fi
}
# Determine NVIDIA Grace Blackwell tier based on unified memory
# Mirrors installers/phases/02-detection.sh:346-360 thresholds
# NV_ULTRA: 90GB+ | T4: 48-89GB | T3: 20-47GB | T2: 12-19GB | T1: <12GB
get_nvidia_unified_tier() {
local unified_gb=$1
if [[ $unified_gb -ge 90 ]]; then
echo "NV_ULTRA"
elif [[ $unified_gb -ge 48 ]]; then
echo "T4"
elif [[ $unified_gb -ge 20 ]]; then
echo "T3"
elif [[ $unified_gb -ge 12 ]]; then
echo "T2"
else
echo "T1"
fi
}
# Determine Apple Silicon tier based on unified memory
# AP_PRO: 36GB+ | AP_BASE: <36GB
get_apple_tier() {
local unified_gb=$1
if [[ $unified_gb -ge 96 ]]; then
echo "AP_ULTRA"
elif [[ $unified_gb -ge 36 ]]; then
echo "AP_PRO"
else
echo "AP_BASE"
fi
}
# Get tier description (supports NVIDIA, Strix Halo, and Apple tiers)
tier_description() {
case $1 in
T4) echo "Ultimate (48GB+): large flagship local profile with long context headroom" ;;
T3) echo "Pro (20-47GB): strong local profile with room for larger models" ;;
T2) echo "Starter (12-19GB): mid-size local profile for everyday work" ;;
T0) echo "Lightweight (<4GB discrete VRAM): CPU fallback profile for install stability" ;;
T1) echo "Mini (<12GB): compact local profile or CPU inference" ;;
NV_ULTRA) echo "NVIDIA Ultra (90GB+ unified or discrete): flagship local profile (Grace Blackwell or multi-GPU)" ;;
SH_LARGE) echo "Strix Halo 90+: flagship unified-memory local profile" ;;
SH_COMPACT) echo "Strix Halo Compact: balanced unified-memory local profile" ;;
AP_ULTRA) echo "Apple Ultra (96GB+): high-end local profile via CPU inference in Docker" ;;
AP_PRO) echo "Apple Pro (36GB+): balanced local profile via CPU inference in Docker" ;;
AP_BASE) echo "Apple Base (<36GB): compact local profile via CPU inference in Docker" ;;
esac
}
detect_model_profile() {
local profile="${MODEL_PROFILE:-qwen}"
profile="$(printf '%s' "$profile" | tr '[:upper:]' '[:lower:]')"
case "$profile" in
auto|gemma|gemma4|gemma-4) echo "gemma4" ;;
*) echo "qwen" ;;
esac
}
# Get recommended model for tier
tier_model() {
local profile
profile="$(detect_model_profile)"
if [[ "$profile" == "gemma4" ]]; then
case $1 in
T4) echo "gemma-4-31b-it" ;;
T3) echo "gemma-4-26b-a4b-it" ;;
T2) echo "gemma-4-e4b-it" ;;
T0) echo "qwen3.5-2b" ;;
T1) echo "gemma-4-e2b-it" ;;
NV_ULTRA) echo "gemma-4-31b-it" ;;
SH_LARGE) echo "gemma-4-31b-it" ;;
SH_COMPACT) echo "gemma-4-26b-a4b-it" ;;
AP_ULTRA) echo "gemma-4-31b-it-Q4_K_M.gguf" ;;
AP_PRO) echo "gemma-4-e4b-it-Q4_K_M.gguf" ;;
AP_BASE) echo "gemma-4-e2b-it-Q4_K_M.gguf" ;;
esac
return
fi
case $1 in
T4) echo "qwen3-coder-next" ;;
T3) echo "qwen3-30b-a3b" ;;
T2) echo "qwen3.5-9b" ;;
T0) echo "qwen3.5-2b" ;;
T1) echo "qwen3.5-2b" ;;
NV_ULTRA) echo "qwen3-coder-next" ;;
SH_LARGE) echo "qwen3-coder-next" ;;
SH_COMPACT) echo "qwen3-30b-a3b" ;;
AP_ULTRA) echo "qwen3-coder-next-Q4_K_M.gguf" ;;
AP_PRO) echo "qwen3.5-9b-Q4_K_M.gguf" ;;
AP_BASE) echo "qwen3.5-2b-Q4_K_M.gguf" ;;
esac
}
# Main detection
main() {
local json_output=false
local compact_json=false
local verbose=false
for arg in "$@"; do
case "$arg" in
--json) json_output=true ;;
--json-compact) json_output=true; compact_json=true ;;
--verbose) verbose=true ;;
--help|-h) usage; return 0 ;;
*)
err "Unknown argument: $arg"
usage
return 2
;;
esac
done
local os platform
os=$(detect_os)
platform=$(detect_platform)
local cpu cores ram
cpu=$(detect_cpu)
cores=$(as_int "$(detect_cores)")
ram=$(as_int "$(detect_ram)")
local gpu_name=""
local gpu_vram_mb=0
local gpu_count=0
local gpu_type="none"
local gpu_architecture=""
local memory_type="discrete"
local gpu_temp=0
local gpu_power=0
local gpu_busy=0
local vulkan_available="false"
local rocm_available="false"
local driver_loaded="false"
local device_id=""
local subsystem_device=""
local revision=""
# Guardrails: avoid nonsense values
cores=$(clamp_int "$cores" 1 1024)
ram=$(clamp_int "$ram" 1 4096)
# Try Jetson first — Tegra iGPUs don't show up as PCIe NVIDIA cards in
# sysfs and nvidia-smi is unreliable on JetPack, so detect via L4T release
# file / device-tree signature before the discrete-NVIDIA branch below.
local jetson_out=""
jetson_out=$(detect_jetson || true)
if [[ -n "$jetson_out" ]]; then
local _jetson_l4t _jetson_ram_mb
IFS='|' read -r gpu_name _jetson_l4t _jetson_ram_mb <<< "$jetson_out"
gpu_vram_mb=$(as_int "$_jetson_ram_mb")
gpu_count=1
gpu_type="jetson"
gpu_architecture="tegra"
memory_type="unified"
device_id="$_jetson_l4t"
fi
# Try NVIDIA next
local nvidia_out=""
if [[ -z "$gpu_name" ]]; then
nvidia_out=$(detect_nvidia || true)
fi
if [[ -n "$nvidia_out" ]]; then
gpu_name=$(echo "$nvidia_out" | awk -F',' '{gsub(/^[ \t]+|[ \t]+$/,"",$1); print $1}' | xargs || true)
gpu_vram_mb=$(parse_nvidia_vram_mb "$nvidia_out")
gpu_count=$(count_nvidia_gpus)
gpu_type="nvidia"
gpu_architecture="cuda"
memory_type="discrete"
# NVIDIA Grace Blackwell (GB10, GB200): nvidia-smi reports [N/A] for
# memory.total when CPU+GPU share unified memory. Fall back to system
# RAM as the VRAM budget. Mirrors installers/lib/detection.sh:178-194.
if [[ "$gpu_vram_mb" -eq 0 ]]; then
local _vram_field
_vram_field=$(echo "$nvidia_out" | head -1 | awk -F',' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2}' | xargs)
if [[ "$_vram_field" == "[N/A]" || "$_vram_field" == "N/A" ]]; then
memory_type="unified"
gpu_architecture="grace-blackwell"
local _ram_kb
_ram_kb=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}')
if [[ -n "$_ram_kb" && "$_ram_kb" -gt 0 ]]; then
gpu_vram_mb=$(( _ram_kb / 1024 ))
fi
fi
fi
device_id="$(nvidia_device_id || true)"
fi
# Try AMD if no NVIDIA
if [[ -z "$gpu_name" ]]; then
local amd_out=""
if amd_out=$(detect_amd_sysfs 2>/dev/null); then
# Parse pipe-delimited output from detect_amd_sysfs
local vram_bytes gtt_bytes is_apu busy temp power vulkan rocm driver dev_id subsys_dev rev
IFS='|' read -r gpu_name vram_bytes gtt_bytes is_apu busy temp power vulkan rocm driver dev_id subsys_dev rev <<< "$amd_out"
vram_bytes=$(as_int "$vram_bytes")
gtt_bytes=$(as_int "$gtt_bytes")
gpu_vram_mb=$(( vram_bytes / 1048576 ))
gpu_count=$(count_amd_gpus)
gpu_type="amd"
gpu_temp=$(as_int "$temp")
gpu_power=$(as_int "$power")
gpu_busy=$(as_int "$busy")
vulkan_available="$vulkan"
rocm_available="$rocm"
driver_loaded="$driver"
device_id="$dev_id"
subsystem_device="$subsys_dev"
revision="$rev"
if [[ "$is_apu" == "true" ]]; then
gpu_architecture="apu-unified"
memory_type="unified"
else
gpu_architecture="rdna"
memory_type="discrete"
fi
# Sanity clamp for telemetry
gpu_temp=$(clamp_int "$gpu_temp" 0 130)
gpu_power=$(clamp_int "$gpu_power" 0 2000)
gpu_busy=$(clamp_int "$gpu_busy" 0 100)
fi
fi
# Try Apple Silicon if macOS
if [[ -z "$gpu_name" && "$os" == "macos" ]]; then
local apple_out
apple_out=$(detect_apple || true)
if [[ -n "$apple_out" ]]; then
gpu_name="Apple Silicon (Unified Memory)"
gpu_vram_mb=$((ram * 1024))
gpu_count=1
gpu_type="apple"
gpu_architecture="apple-unified"
memory_type="unified"
fi
fi
# Determine tier
# For unified memory AMD APUs, use system RAM — VRAM reports only carve-out, not usable UMA.
local tier tier_desc recommended_model
if [[ "$memory_type" == "discrete" && "$gpu_type" == "nvidia" && "$gpu_vram_mb" -gt 0 && "$gpu_vram_mb" -lt 4096 ]]; then
tier="T0"
elif [[ "$memory_type" == "unified" && "$gpu_type" == "amd" ]]; then
tier=$(get_strix_halo_tier "$ram")
elif [[ "$memory_type" == "unified" && "$gpu_type" == "nvidia" ]]; then
local unified_gb
unified_gb=$((gpu_vram_mb / 1024))
tier=$(get_nvidia_unified_tier "$unified_gb")
elif [[ "$gpu_type" == "apple" ]]; then
local unified_gb
unified_gb=$((gpu_vram_mb / 1024))
tier=$(get_apple_tier "$unified_gb")
else
tier=$(get_tier "$gpu_vram_mb")
fi
tier_desc=$(tier_description "$tier")
recommended_model=$(tier_model "$tier")
local gpu_vram_gb
gpu_vram_gb=$((gpu_vram_mb / 1024))
if $json_output; then
# Emit JSON with escaping to avoid breaking downstream parsers.
local esc_cpu esc_gpu esc_os
esc_os=$(json_escape "$os")
esc_cpu=$(json_escape "$cpu")
esc_gpu=$(json_escape "$gpu_name")
local payload
payload=$(cat <<EOF
{
"os": "$esc_os",
"platform": "$(json_escape "$platform")",
"cpu": "$esc_cpu",
"cores": $cores,
"ram_gb": $ram,
"gpu": {
"type": "$(json_escape "$gpu_type")",
"name": "$esc_gpu",
"architecture": "$(json_escape "$gpu_architecture")",
"memory_type": "$(json_escape "$memory_type")",
"count": $gpu_count,
"vram_mb": $gpu_vram_mb,
"vram_gb": $gpu_vram_gb,
"device_id": "$(json_escape "$device_id")",
"subsystem_device": "$(json_escape "$subsystem_device")",
"revision": "$(json_escape "$revision")",
"utilization": $gpu_busy,
"temperature_c": $gpu_temp,
"power_w": $gpu_power,
"vulkan": $vulkan_available,
"rocm": $rocm_available,
"driver_loaded": $driver_loaded
},
"tier": "$(json_escape "$tier")",
"tier_description": "$(json_escape "$tier_desc")",
"recommended_model": "$(json_escape "$recommended_model")"
}
EOF
)
if $compact_json; then
printf '%s\n' "$(printf '%s' "$payload" | tr -d '\n')"
else
printf '%s\n' "$payload"
fi
else
echo -e "${BLUE}╔══════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ ODS Hardware Detection ║${NC}"
echo -e "${BLUE}╚══════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}System:${NC}"
echo " OS: $os"
echo " Platform: $platform"
echo " CPU: $cpu"
echo " Cores: $cores"
echo " RAM: ${ram}GB"
echo ""
echo -e "${GREEN}GPU:${NC}"
if [[ -n "$gpu_name" ]]; then
echo " Type: $gpu_type"
echo " Name: $gpu_name"
if [[ "$memory_type" == "unified" ]]; then
echo -e " Memory: ${CYAN}${gpu_vram_gb}GB (Unified)${NC}"
else
echo " VRAM: ${gpu_vram_gb}GB"
fi
if $verbose; then
[[ -n "$device_id" ]] && echo " Device: $device_id"
[[ -n "$subsystem_device" && "$subsystem_device" != "unknown" ]] && echo " Subsys: $subsystem_device"
[[ -n "$revision" && "$revision" != "unknown" ]] && echo " Rev: $revision"
fi
if [[ "$gpu_type" == "amd" ]]; then
echo " Arch: $gpu_architecture"
[[ $gpu_temp -gt 0 ]] && echo " Temp: ${gpu_temp}C"
[[ $gpu_power -gt 0 ]] && echo " Power: ${gpu_power}W"
[[ $gpu_busy -gt 0 ]] && echo " Load: ${gpu_busy}%"
echo " Vulkan: $vulkan_available"
echo " ROCm: $rocm_available"
echo " Driver: $driver_loaded"
fi
else
echo " No GPU detected (CPU-only mode)"
if [[ "$platform" == "container" ]]; then
echo " Hint: running inside a container; GPU device nodes may not be exposed."
elif [[ "$platform" == "wsl2" ]]; then
echo " Hint: on WSL2, GPU detection depends on the host + Docker Desktop GPU integration."
fi
fi
echo ""
echo -e "${YELLOW}Recommended Tier: ${tier}${NC}"
echo " $tier_desc"
echo -e " Model: ${CYAN}${recommended_model}${NC}"
echo ""
fi
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Extension runtime check — non-core services with an on-disk compose fragment.
# Compares Docker container state to the service registry and optionally probes
# HTTP health endpoints (same paths/timeouts as the installer health phase).
#
# Usage:
# scripts/extension-runtime-check.sh [ODS_ROOT]
# ODS_ROOT defaults to the repository root (parent of scripts/).
#
# Environment:
# EXTENSION_RUNTIME_CHECK_STRICT=1 — exit 1 if any health probe fails (running
# container but endpoint not reachable). Default is non-blocking (exit 0).
#
# Requires: bash 4+, docker (optional — skips if daemon unreachable), curl for HTTP probes.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ODS_ROOT="$(cd "${1:-$ROOT_DIR}" && pwd)"
export SCRIPT_DIR="$ODS_ROOT"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
ok_line() { echo -e "${GREEN}[OK]${NC} $1"; }
bad_line() { echo -e "${RED}[BAD]${NC} $1"; }
if [[ ! -f "$ODS_ROOT/lib/service-registry.sh" ]]; then
warn "ODS root missing lib/service-registry.sh — skipping ($ODS_ROOT)"
exit 0
fi
# shellcheck source=../lib/service-registry.sh
. "$ODS_ROOT/lib/service-registry.sh"
if [[ -f "$ODS_ROOT/lib/safe-env.sh" ]]; then
# shellcheck source=../lib/safe-env.sh
. "$ODS_ROOT/lib/safe-env.sh"
[[ -f "$ODS_ROOT/.env" ]] && load_env_file "$ODS_ROOT/.env"
fi
sr_load
sr_resolve_ports
if [[ ${#SERVICE_IDS[@]} -eq 0 ]]; then
info "No services in registry — nothing to check"
exit 0
fi
if ! command -v docker >/dev/null 2>&1; then
info "Extension runtime check — docker not in PATH (skipping)"
exit 0
fi
if ! docker info >/dev/null 2>&1; then
info "Extension runtime check — Docker daemon not reachable (skipping)"
exit 0
fi
HAVE_CURL=false
command -v curl >/dev/null 2>&1 && HAVE_CURL=true
strict="${EXTENSION_RUNTIME_CHECK_STRICT:-0}"
had_health_fail=0
info "Extension runtime check (non-core, compose enabled) — root: $ODS_ROOT"
for sid in "${SERVICE_IDS[@]}"; do
svc_category="${SERVICE_CATEGORIES[$sid]:-optional}"
[[ "$svc_category" == "core" ]] && continue
cf="${SERVICE_COMPOSE[$sid]:-}"
[[ -z "$cf" || ! -f "$cf" ]] && continue
cname="${SERVICE_CONTAINERS[$sid]:-ods-$sid}"
disp="${SERVICE_NAMES[$sid]:-$sid}"
if ! docker inspect "$cname" >/dev/null 2>&1; then
info "[$sid] $disp — no container '$cname' (not in current compose stack or not started)"
continue
fi
status="$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || echo unknown)"
if [[ "$status" != "running" ]]; then
warn "[$sid] $disp — container exists but status=$status (try: docker logs $cname)"
continue
fi
port="${SERVICE_PORTS[$sid]:-0}"
health="${SERVICE_HEALTH[$sid]:-}"
timeout_sec="${SERVICE_HEALTH_TIMEOUTS[$sid]:-5}"
if [[ ! "$port" =~ ^[0-9]+$ ]] || [[ "$port" -le 0 ]]; then
ok_line "[$sid] $disp — running (no external port to probe)"
continue
fi
if [[ -z "$health" ]]; then
ok_line "[$sid] $disp — running (no health path in manifest)"
continue
fi
if ! $HAVE_CURL; then
warn "[$sid] $disp — running; curl missing, cannot probe http://127.0.0.1:${port}${health}"
continue
fi
url="http://127.0.0.1:${port}${health}"
if curl -sf --max-time "$timeout_sec" "$url" >/dev/null; then
ok_line "[$sid] $disp — running, health OK ($url)"
else
bad_line "[$sid] $disp — running but health failed ($url) — try: docker compose logs $sid"
had_health_fail=1
fi
done
if [[ "$strict" == "1" && "$had_health_fail" -ne 0 ]]; then
exit 1
fi
exit 0
+302
View File
@@ -0,0 +1,302 @@
#!/bin/bash
# ODS First Boot Demo
# Shows off what your local AI stack can do in under 2 minutes
#
# Usage: ./first-boot-demo.sh [--all] [--quick]
# Mission: M5 (Clonable ODS Setup Server)
set -euo pipefail
#=============================================================================
# Colors
#=============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m'
BOLD='\033[1m'
#=============================================================================
# Config — resolve from service registry when available
#=============================================================================
_DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$_DEMO_DIR/lib/service-registry.sh" ]]; then
export SCRIPT_DIR="$_DEMO_DIR"
. "$_DEMO_DIR/lib/service-registry.sh"
sr_load
[[ -f "$_DEMO_DIR/lib/safe-env.sh" ]] && . "$_DEMO_DIR/lib/safe-env.sh"
load_env_file "$_DEMO_DIR/.env"
sr_resolve_ports
fi
LLM_URL="${LLM_URL:-http://localhost:${SERVICE_PORTS[llama-server]:-11434}}"
WHISPER_URL="${WHISPER_URL:-http://localhost:${SERVICE_PORTS[whisper]:-9000}}"
PIPER_URL="${PIPER_URL:-http://localhost:${SERVICE_PORTS[tts]:-8880}}"
N8N_URL="${N8N_URL:-http://localhost:${SERVICE_PORTS[n8n]:-5678}}"
WEBUI_URL="${WEBUI_URL:-http://localhost:${SERVICE_PORTS[open-webui]:-3000}}"
QUICK_MODE=false
ALL_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--quick) QUICK_MODE=true; shift ;;
--all) ALL_MODE=true; shift ;;
-h|--help)
echo "Usage: $0 [--quick] [--all]"
echo " --quick Skip slow demos, just show what's available"
echo " --all Run all demos including voice (requires audio files)"
exit 0
;;
*) shift ;;
esac
done
#=============================================================================
# Helpers
#=============================================================================
header() {
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}${CYAN} $1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
demo() {
echo -e "\n${MAGENTA}${NC} ${BOLD}$1${NC}"
}
success() {
echo -e "${GREEN}${NC} $1"
}
fail() {
echo -e "${RED}${NC} $1"
}
info() {
echo -e "${CYAN}${NC} $1"
}
wait_key() {
if [[ "$QUICK_MODE" != "true" ]]; then
echo -e "\n${YELLOW}Press Enter to continue...${NC}"
read -r
fi
}
check_service() {
local name=$1
local url=$2
local endpoint=${3:-/health}
if curl -sf "${url}${endpoint}" > /dev/null 2>&1; then
success "$name is running at $url"
return 0
else
fail "$name not responding at $url"
return 1
fi
}
#=============================================================================
# Welcome
#=============================================================================
clear
echo ""
echo -e "${BOLD}${CYAN}"
cat << 'EOF'
OOOOO DDDD SSSSS
OO OO DD DD SS
OO OO DD DD SSS
OO OO DD DD SS
OOOOO DDDD SSSS
EOF
echo -e "${NC}"
echo -e "${BOLD}ODS - First Boot Demo${NC}"
echo -e "Everything runs on YOUR hardware. No cloud. No API costs. Full privacy.\n"
#=============================================================================
# Health Check
#=============================================================================
header "🔍 Checking Services"
SERVICES_OK=0
SERVICES_TOTAL=0
# Core services
((SERVICES_TOTAL++)) || true
if check_service "LLM (llama-server)" "$LLM_URL" "/health"; then
((SERVICES_OK++)) || true
LLM_AVAILABLE=true
else
LLM_AVAILABLE=false
fi
((SERVICES_TOTAL++)) || true
if check_service "Open WebUI" "$WEBUI_URL" "/"; then
((SERVICES_OK++)) || true
fi
# Optional services
if curl -sf "${WHISPER_URL}/health" > /dev/null 2>&1; then
success "Whisper STT is running (voice input enabled)"
WHISPER_AVAILABLE=true
((SERVICES_OK++)) || true
((SERVICES_TOTAL++)) || true
else
info "Whisper STT not running (voice input disabled)"
WHISPER_AVAILABLE=false
fi
if curl -sf "${PIPER_URL}" > /dev/null 2>&1; then
success "OpenTTS TTS is running (voice output enabled)"
PIPER_AVAILABLE=true
((SERVICES_OK++)) || true
((SERVICES_TOTAL++)) || true
else
info "OpenTTS TTS not running (voice output disabled)"
PIPER_AVAILABLE=false
fi
if curl -sf "${N8N_URL}/healthz" > /dev/null 2>&1; then
success "n8n Workflows is running (automation enabled)"
N8N_AVAILABLE=true
((SERVICES_OK++)) || true
((SERVICES_TOTAL++)) || true
else
info "n8n not running (automation disabled)"
N8N_AVAILABLE=false
fi
echo ""
echo -e "${BOLD}Services: ${SERVICES_OK}/${SERVICES_TOTAL} running${NC}"
if [[ "$LLM_AVAILABLE" != "true" ]]; then
echo -e "\n${RED}LLM (llama-server) is required for demos. Is it still loading?${NC}"
echo "Check status: docker compose logs -f llama-server"
exit 1
fi
wait_key
#=============================================================================
# Demo 1: Chat Completion
#=============================================================================
header "💬 Demo 1: Local Chat Completion"
demo "Asking your local AI a question..."
RESPONSE=$(curl -sf "${LLM_URL}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen2.5-32B-Instruct-AWQ",
"messages": [{"role": "user", "content": "In one sentence, what makes local AI special?"}],
"max_tokens": 100,
"temperature": 0.7
}' 2>/dev/null | jq -r '.choices[0].message.content' 2>/dev/null || echo "")
if [[ -n "$RESPONSE" && "$RESPONSE" != "null" ]]; then
echo ""
echo -e "${GREEN}Response:${NC}"
echo -e " ${RESPONSE}"
echo ""
success "Local LLM responded! No API calls, no cloud, just your GPU."
else
fail "No response from LLM"
fi
wait_key
#=============================================================================
# Demo 2: Code Assistance
#=============================================================================
header "🧑‍💻 Demo 2: Code Assistance"
demo "Asking for help with a Python function..."
CODE_RESPONSE=$(curl -sf "${LLM_URL}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen2.5-32B-Instruct-AWQ",
"messages": [{"role": "user", "content": "Write a Python one-liner to reverse a string. Just the code, no explanation."}],
"max_tokens": 50,
"temperature": 0.3
}' 2>/dev/null | jq -r '.choices[0].message.content' 2>/dev/null || echo "")
if [[ -n "$CODE_RESPONSE" && "$CODE_RESPONSE" != "null" ]]; then
echo ""
echo -e "${GREEN}Generated code:${NC}"
echo -e " ${CODE_RESPONSE}"
echo ""
success "Code assistant works! Great for development."
else
fail "No response from code assistant"
fi
wait_key
#=============================================================================
# Demo 3: Streaming
#=============================================================================
header "📡 Demo 3: Streaming Response"
demo "Watching tokens stream in real-time..."
echo ""
# Simple streaming demo - just show it works
curl -sN "${LLM_URL}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen2.5-32B-Instruct-AWQ",
"messages": [{"role": "user", "content": "Count from 1 to 5, one number per line."}],
"max_tokens": 50,
"temperature": 0,
"stream": true
}' 2>/dev/null | while read -r line; do
if [[ "$line" == data:* ]]; then
content=$(echo "${line#data: }" | jq -r '.choices[0].delta.content // empty' 2>/dev/null)
if [[ -n "$content" ]]; then
printf "%s" "$content"
fi
fi
done
echo ""
echo ""
success "Streaming works! Great for real-time UIs."
wait_key
#=============================================================================
# Summary
#=============================================================================
header "🎉 Demo Complete!"
echo ""
echo -e "${BOLD}What you just saw:${NC}"
echo " ✓ Local LLM responding to prompts"
echo " ✓ Code assistance capabilities"
echo " ✓ Real-time streaming"
echo ""
echo -e "${BOLD}What's available:${NC}"
echo " • Open WebUI: ${WEBUI_URL}"
[[ "$N8N_AVAILABLE" == "true" ]] && echo " • n8n Workflows: ${N8N_URL}"
[[ "$WHISPER_AVAILABLE" == "true" ]] && echo " • Whisper STT: ${WHISPER_URL}"
[[ "$PIPER_AVAILABLE" == "true" ]] && echo " • OpenTTS TTS: ${PIPER_URL}"
echo ""
echo -e "${BOLD}Next steps:${NC}"
echo " 1. Open ${WEBUI_URL} and start chatting"
echo " 2. Import workflows from ./workflows/ into n8n"
echo " 3. Try the voice demo: ./scripts/voice-demo.sh"
echo " 4. OpenClaw agent: http://localhost:7860"
echo ""
echo -e "${CYAN}Everything runs locally. Your data stays private. Enjoy! 🚀${NC}"
echo ""
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Generate a static extensions catalog JSON from extension manifest files.
Scans the product-owned extension library for valid ods.services.v1 manifests,
extracts catalog-relevant fields, and writes a sorted JSON catalog
to ods/config/extensions-catalog.json.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
SCHEMA_VERSION = "ods.services.v1"
CATALOG_SCHEMA_VERSION = "1.0.0"
EXCLUDED_IDS = {"privacy-shield"}
SERVICE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
def parse_args() -> argparse.Namespace:
script_dir = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(
description="Generate extensions catalog from manifest files.",
)
parser.add_argument(
"--library-dir",
type=Path,
default=script_dir / ".." / "extensions" / "library" / "services",
help="Path to extensions/library/services directory",
)
parser.add_argument(
"--output",
type=Path,
default=script_dir / ".." / "config" / "extensions-catalog.json",
help="Output path for the catalog JSON",
)
return parser.parse_args()
def strip_secrets(env_vars: list[dict]) -> list[dict]:
"""Return env_vars list with the 'secret' field removed from each entry."""
cleaned = []
for var in env_vars:
entry = {k: v for k, v in var.items() if k != "secret"}
cleaned.append(entry)
return cleaned
def load_manifest(manifest_path: Path) -> dict | None:
"""Load and validate a single manifest file. Returns None on failure."""
try:
data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError) as e:
print(f"WARNING: Failed to read {manifest_path}: {e}", file=sys.stderr)
return None
if not isinstance(data, dict):
print(f"WARNING: Skipping {manifest_path}: root is not a mapping", file=sys.stderr)
return None
if data.get("schema_version") != SCHEMA_VERSION:
print(
f"WARNING: Skipping {manifest_path}: "
f"schema_version is '{data.get('schema_version')}', expected '{SCHEMA_VERSION}'",
file=sys.stderr,
)
return None
return data
def extract_entry(manifest: dict) -> dict | None:
"""Extract a catalog entry from a validated manifest dict."""
service = manifest.get("service")
if not isinstance(service, dict):
return None
service_id = service.get("id")
if not service_id or not SERVICE_ID_RE.match(service_id):
return None
if service_id in EXCLUDED_IDS:
return None
env_vars = service.get("env_vars", [])
if not isinstance(env_vars, list):
env_vars = []
entry = {
"id": service_id,
"name": service.get("name", service_id),
"description": service.get("description", ""),
"category": service.get("category", ""),
"gpu_backends": service.get("gpu_backends", []),
"compose_file": service.get("compose_file", ""),
"depends_on": service.get("depends_on", []),
"port": service.get("port", 0),
"external_port_default": service.get("external_port_default", 0),
"health_endpoint": service.get("health", ""),
"env_vars": strip_secrets(env_vars),
"tags": manifest.get("tags") or service.get("tags", []),
"features": manifest.get("features") or service.get("features", []),
}
if "startup_check" in service:
entry["startup_check"] = service.get("startup_check")
if "startup_timeout" in service:
entry["startup_timeout"] = service.get("startup_timeout")
return entry
def generate_catalog(library_dir: Path) -> list[dict]:
"""Scan manifest files and return sorted catalog entries."""
if not library_dir.is_dir():
print(f"ERROR: Library directory not found: {library_dir}", file=sys.stderr)
sys.exit(1)
entries = []
for service_dir in sorted(library_dir.iterdir()):
if not service_dir.is_dir():
continue
manifest_path = service_dir / "manifest.yaml"
if not manifest_path.exists():
continue
manifest = load_manifest(manifest_path)
if manifest is None:
continue
entry = extract_entry(manifest)
if entry is None:
continue
entries.append(entry)
entries.sort(key=lambda e: e["id"])
return entries
def main() -> None:
args = parse_args()
library_dir = args.library_dir.resolve()
output_path = args.output.resolve()
entries = generate_catalog(library_dir)
catalog = {
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"schema_version": CATALOG_SCHEMA_VERSION,
"extensions": entries,
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"Generated catalog with {len(entries)} extensions at {output_path}")
if __name__ == "__main__":
main()
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""
generate-setup-card.py — produce a printable setup card PNG for a ODS unit.
Use this when shipping a unit: pre-configure its setup-mode Wi-Fi AP (a unique
SSID + password per device), feed those creds plus the setup URL into this
script, get back a 4×6 portrait card you can print + laminate + drop in the box.
The card carries:
* Top: "ODS" wordmark + the device's mDNS name (e.g. "ods.local")
* Two big QR codes:
- Left: Wi-Fi join QR (Android + iOS recognize the WIFI:T:...;S:...;P:...;; format)
- Right: setup URL — opens straight to the first-boot wizard
* Plain-text fallback at the bottom (SSID / password / URL) for the
inevitable phone that won't auto-detect the QR
* Optional serial / batch line for fulfillment tracking
This is a tooling artifact, not a runtime feature. It only needs to run on
the operator's machine (or the fulfillment pipeline), not on the device itself.
Usage:
python3 generate-setup-card.py \\
--ssid 'ODS-Setup-A4F2' \\
--password 'xxxxxxxx' \\
--setup-url 'http://192.168.7.1/setup' \\
--device-name 'ods.local' \\
--serial 'DRM-2026-A4F2' \\
--output card-A4F2.png
python3 generate-setup-card.py \\
--mode factory-owner \\
--ssid 'ODS-Setup-A4F2' \\
--password 'xxxxxxxx' \\
--owner-url 'http://auth.ods-a4f2.local/magic-link/...' \\
--device-name 'ods-a4f2.local' \\
--output owner-card-A4F2.pdf
Requires: Pillow + qrcode. Imports lazily so `--help` works without them.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
# Card geometry — 4×6 inches @ 300 DPI = 1200×1800px portrait.
CARD_W = 1200
CARD_H = 1800
MARGIN = 80
# Brand-ish palette. Matches the dashboard's dark theme but printable.
COLOR_BG = (15, 15, 19) # near-black, but not pure black (prints better)
COLOR_FG = (228, 228, 231) # near-white
COLOR_ACCENT = (167, 139, 250) # purple, matches --theme-accent
COLOR_MUTED = (140, 140, 150)
def build_wifi_qr_payload(ssid: str, password: str, security: str = "WPA") -> str:
"""Return the standard Wi-Fi join URI Android/iOS will recognize.
Format: WIFI:T:<security>;S:<ssid>;P:<password>;H:false;;
Special characters in SSID/password must be escaped (\\:, \\;, \\\\, \\").
"""
def esc(s: str) -> str:
return (
s.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace(":", "\\:")
.replace('"', '\\"')
)
effective_security = "nopass" if not password else security
payload = f"WIFI:T:{effective_security};S:{esc(ssid)};"
if effective_security != "nopass" and password:
payload += f"P:{esc(password)};"
payload += "H:false;;"
return payload
def render_qr(text: str, target_px: int):
"""Return a Pillow Image of the QR sized to ~target_px x target_px."""
import qrcode # noqa: PLC0415 — lazy import keeps --help fast
from PIL import Image # noqa: PLC0415
from qrcode.constants import ERROR_CORRECT_M
# ERROR_CORRECT_M handles ~15% damage which is fine for a printed card.
# box_size is the pixel size of each "module" (QR cell); we scale up.
qr = qrcode.QRCode(
version=None,
error_correction=ERROR_CORRECT_M,
box_size=10,
# Keep the spec-recommended four-module quiet zone. The QR sits on a
# dark card background, so this white margin is the only separator a
# scanner sees at the code edge.
border=4,
)
qr.add_data(text)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
# qrcode picks the cell size; final image dimensions vary by data length.
# We rescale to the target so the card layout is predictable.
#
# NEAREST is critical: the default resampling (BICUBIC) antialiases the
# cell edges, which turns the pure black/white QR into ~190 grayscale
# colors. That still scans in many cases, but printed cards must be
# crisp — a phone camera in suboptimal lighting can fail on the
# antialiased version. NEAREST keeps every pixel pure black or pure
# white, matching the source modules exactly.
return img.resize((target_px, target_px), resample=Image.Resampling.NEAREST)
def render_card(
ssid: str,
password: str,
setup_url: str | None,
device_name: str,
security: str = "WPA",
serial: str | None = None,
mode: str = "setup",
owner_url: str | None = None,
):
"""Compose the full card image. Returns a Pillow Image."""
from PIL import Image, ImageDraw # noqa: PLC0415
if mode not in {"setup", "factory-owner"}:
raise ValueError("mode must be setup or factory-owner")
right_url = owner_url if mode == "factory-owner" else setup_url
if not right_url:
raise ValueError("--owner-url is required for factory-owner mode" if mode == "factory-owner" else "--setup-url is required")
right_caption = "2. OPEN ODS TALK" if mode == "factory-owner" else "2. OPEN SETUP"
tagline = "Scan to join. Scan to talk." if mode == "factory-owner" else "Scan to set up. Scan to chat."
fallback_url_label = "owner url" if mode == "factory-owner" else "then visit"
card = Image.new("RGB", (CARD_W, CARD_H), COLOR_BG)
draw = ImageDraw.Draw(card)
title_font = _load_font(size=80, bold=True)
heading_font = _load_font(size=42, bold=True)
body_font = _load_font(size=32)
small_font = _load_font(size=24)
# Note: the monospace value font is no longer eagerly loaded — the
# password-overflow fix (auto-shrinking via _fit_font_to_width) picks
# a size per-row instead of using a single fixed mono_font.
# --- Header band ---
draw.text(
(MARGIN, MARGIN),
"ODS",
font=title_font,
fill=COLOR_ACCENT,
)
draw.text(
(MARGIN, MARGIN + 100),
device_name,
font=heading_font,
fill=COLOR_FG,
)
draw.text(
(MARGIN, MARGIN + 160),
tagline,
font=body_font,
fill=COLOR_MUTED,
)
# --- QR pair ---
qr_size = (CARD_W - MARGIN * 3) // 2 # two QRs + margin between
qr_y = 400
wifi_qr = render_qr(build_wifi_qr_payload(ssid, password, security), qr_size)
url_qr = render_qr(right_url, qr_size)
card.paste(wifi_qr, (MARGIN, qr_y))
card.paste(url_qr, (MARGIN * 2 + qr_size, qr_y))
# QR captions
draw.text(
(MARGIN, qr_y + qr_size + 20),
"1. JOIN WI-FI",
font=heading_font,
fill=COLOR_ACCENT,
)
draw.text(
(MARGIN * 2 + qr_size, qr_y + qr_size + 20),
right_caption,
font=heading_font,
fill=COLOR_ACCENT,
)
# --- Plain-text fallback block ---
fallback_y = qr_y + qr_size + 130
draw.text(
(MARGIN, fallback_y),
"if a QR won't scan:",
font=small_font,
fill=COLOR_MUTED,
)
rows = [
("network", ssid),
("password", password if password else "(open)"),
(fallback_url_label, right_url),
]
# The value column starts at x=MARGIN+240 and must fit within the right
# margin (CARD_W - MARGIN). Anything wider gets shrunk to fit OR wrapped
# across lines. Max-length WPA2 passwords (63 chars) and long mDNS URLs
# both blow past the default mono font width otherwise — and a setup
# card where the password runs off the right edge defeats the point of
# having a fallback block.
value_x = MARGIN + 240
value_max_width = CARD_W - MARGIN - value_x # pixels available
row_y = fallback_y + 50
for label, value in rows:
draw.text((MARGIN, row_y), label.upper(), font=small_font, fill=COLOR_MUTED)
value_font = _fit_font_to_width(draw, value, value_max_width, base_size=36, min_size=18, monospace=True)
draw.text(
(value_x, row_y - 6),
value,
font=value_font,
fill=COLOR_FG,
)
row_y += 70
# --- Footer / serial ---
footer_y = CARD_H - MARGIN - 30
draw.text(
(MARGIN, footer_y),
"ODS is open-source — light-heart-labs.com",
font=small_font,
fill=COLOR_MUTED,
)
if serial:
bbox = draw.textbbox((0, 0), serial, font=small_font)
text_w = bbox[2] - bbox[0]
draw.text(
(CARD_W - MARGIN - text_w, footer_y),
serial,
font=small_font,
fill=COLOR_MUTED,
)
return card
def _fit_font_to_width(draw, text: str, max_width: int, base_size: int = 36,
min_size: int = 18, monospace: bool = False):
"""Return the largest font (at or below ``base_size``) whose ``text``
measures ``<= max_width`` pixels. Floors at ``min_size`` even if the
text is still wider, on the principle that a readable
fallback is more useful than a value clipped to the next-line.
Needed for the password row — WPA2 supports up to 63 characters, and
a 36-pt monospace render of that is wider than the available column.
Without auto-shrink, the value runs off the right edge of the card.
"""
for size in range(base_size, min_size - 1, -2):
font = _load_font(size=size, monospace=monospace)
try:
bbox = draw.textbbox((0, 0), text, font=font)
width = bbox[2] - bbox[0]
except (AttributeError, OSError):
# Pillow's fallback bitmap font doesn't honor textbbox cleanly
# on every platform — assume it fits and return base size.
return font
if width <= max_width:
return font
# Floor: return the smallest size and accept the overflow as a last resort.
return _load_font(size=min_size, monospace=monospace)
def _load_font(size: int, bold: bool = False, monospace: bool = False):
"""Best-effort font loader. Falls back to Pillow's default bitmap font
if no truetype font is available — the card still renders, just less
pretty. The card is meant to be printed, so we look for common system
fonts first.
"""
from PIL import ImageFont # noqa: PLC0415
candidates: list[str] = []
if monospace:
candidates += [
"C:\\Windows\\Fonts\\consola.ttf", # Windows Consolas
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/System/Library/Fonts/Menlo.ttc",
]
elif bold:
candidates += [
"C:\\Windows\\Fonts\\arialbd.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
else:
candidates += [
"C:\\Windows\\Fonts\\arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
for path in candidates:
if Path(path).exists():
try:
return ImageFont.truetype(path, size=size)
except OSError:
continue
return ImageFont.load_default()
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate a printable setup or factory-owner card for a ODS unit.",
)
parser.add_argument("--mode", default="setup", choices=["setup", "factory-owner"],
help="Card mode. setup prints Wi-Fi + setup URL; factory-owner prints Wi-Fi + owner ODS Talk QR")
parser.add_argument("--ssid", required=True, help="Wi-Fi SSID of the device's setup AP")
parser.add_argument("--password", default="", help="Wi-Fi password (empty for open network)")
parser.add_argument("--security", default="WPA", choices=["WPA", "WEP", "nopass"],
help="Wi-Fi security type (default WPA)")
parser.add_argument("--setup-url", default=None,
help="URL to open after joining the AP (e.g. http://192.168.7.1/setup)")
parser.add_argument("--owner-url", default=None,
help="Owner magic-link URL for factory-owner cards")
parser.add_argument("--device-name", default="ods.local",
help="The mDNS name printed on the card (default ods.local)")
parser.add_argument("--serial", default=None,
help="Optional serial / batch identifier printed in the footer")
parser.add_argument("--format", choices=["png", "pdf"], default=None,
help="Output format. Defaults to PNG unless the output path ends in .pdf")
parser.add_argument("--output", "-o", required=True,
help="Output path")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
if args.security == "nopass" and args.password:
print("error: --security nopass cannot be combined with --password", file=sys.stderr)
return 2
if args.mode == "setup" and not args.setup_url:
print("error: --setup-url is required in setup mode", file=sys.stderr)
return 2
if args.mode == "factory-owner" and not args.owner_url:
print("error: --owner-url is required in factory-owner mode", file=sys.stderr)
return 2
try:
import PIL # noqa: F401, PLC0415
import qrcode # noqa: F401, PLC0415
except ImportError as exc:
print(f"error: missing dependency: {exc.name}. "
"Install with: pip install 'qrcode[pil]'", file=sys.stderr)
return 2
card = render_card(
ssid=args.ssid,
password=args.password,
setup_url=args.setup_url,
device_name=args.device_name,
security=args.security,
serial=args.serial,
mode=args.mode,
owner_url=args.owner_url,
)
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_format = args.format or ("pdf" if out_path.suffix.lower() == ".pdf" else "png")
if out_format == "pdf":
card.save(out_path, format="PDF", resolution=300.0)
else:
card.save(out_path, format="PNG", dpi=(300, 300))
print(f"wrote {out_path} ({CARD_W}×{CARD_H} @ 300 DPI = 4×6 inches)")
return 0
if __name__ == "__main__":
sys.exit(main())
+477
View File
@@ -0,0 +1,477 @@
#!/bin/bash
# ODS Comprehensive Health Check
# Tests each component with actual API calls, not just connectivity
# Exit codes: 0=healthy, 1=degraded (some services down), 2=critical (core services down)
#
# Usage: ./health-check.sh [--json] [--quiet]
# ── Bash 4+ guard ─────────────────────────────────────────────────────────────
# service-registry.sh requires associative arrays (declare -A) which need Bash 4+.
# macOS ships Bash 3.2; if running there, re-exec under Homebrew bash.
if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then
for _brew_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do
if [ -x "$_brew_bash" ] && [ "$("$_brew_bash" -c 'echo "${BASH_VERSINFO[0]}"')" -ge 4 ]; then
exec "$_brew_bash" "$0" "$@"
fi
done
echo "Error: Bash 4+ required. macOS ships Bash 3.2. Install newer bash: brew install bash" >&2
exit 2
fi
set -euo pipefail
# Parse args
JSON_OUTPUT=false
QUIET=false
for arg in "$@"; do
case $arg in
--json) JSON_OUTPUT=true ;;
--quiet) QUIET=true ;;
esac
done
# Config (defaults; .env overrides after load_env_file below)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$SCRIPT_DIR/lib/service-registry.sh" ]]; then
. "$SCRIPT_DIR/lib/service-registry.sh"
sr_load
fi
INSTALL_DIR="${INSTALL_DIR:-$HOME/ods}"
LLM_HOST="${LLM_HOST:-localhost}"
LLM_PORT="${LLM_PORT:-8080}"
TIMEOUT="${TIMEOUT:-5}"
# Safe .env loading for port overrides (no eval; use lib/safe-env.sh)
[[ -f "$SCRIPT_DIR/lib/safe-env.sh" ]] && . "$SCRIPT_DIR/lib/safe-env.sh"
load_env_file "${INSTALL_DIR}/.env"
sr_resolve_ports
# Colors (disabled for JSON/quiet)
if $JSON_OUTPUT || $QUIET; then
GREEN="" RED="" YELLOW="" CYAN="" NC=""
else
GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m'
fi
# Track results (indexed arrays — Bash 3.2 compatible as defense-in-depth)
declare -a RESULT_KEYS=()
declare -a RESULT_VALS=()
CRITICAL_FAIL=false
ANY_FAIL=false
# Set a result: result_set key value
result_set() {
local key="$1" val="$2" i
for i in "${!RESULT_KEYS[@]}"; do
if [[ "${RESULT_KEYS[$i]}" == "$key" ]]; then
RESULT_VALS[i]="$val"
return
fi
done
RESULT_KEYS+=("$key")
RESULT_VALS+=("$val")
}
# Get a result: result_get key
result_get() {
local key="$1" i
for i in "${!RESULT_KEYS[@]}"; do
if [[ "${RESULT_KEYS[$i]}" == "$key" ]]; then
echo "${RESULT_VALS[$i]}"
return
fi
done
}
log() { $QUIET || echo -e "$1"; }
# Portable millisecond timestamp (macOS BSD date lacks %N)
_now_ms() {
python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || echo "$(date +%s)000"
}
# ── Test functions ──────────────────────────────────────────────────────────
# llama-server: critical path — performs an actual inference test
test_llm() {
local start
start=$(_now_ms)
# Lemonade (AMD) serves its OpenAI-compatible API under /api/v1;
# llama-server uses /v1. Honor LLM_API_BASE_PATH from .env (written by
# phase 06, default /v1) so the probe hits the backend that is actually
# running instead of failing on every Lemonade install.
local base_path="${LLM_API_BASE_PATH:-/v1}"
local response
response=$(curl -sf --max-time $TIMEOUT \
-H "Content-Type: application/json" \
-d '{"model":"default","prompt":"Hi","max_tokens":1}' \
"http://${LLM_HOST}:${LLM_PORT}${base_path}/completions" 2>/dev/null)
local end
end=$(_now_ms)
if echo "$response" | grep -q '"text"'; then
result_set "llm" "ok"
result_set "llm_latency" "$((end - start))"
return 0
fi
result_set "llm" "fail"
CRITICAL_FAIL=true
ANY_FAIL=true
return 1
}
# Check Docker container state for a service
# Returns: container state string (or empty if docker unavailable/container name missing)
check_container_state() {
local sid="$1"
local container="${SERVICE_CONTAINERS[$sid]}"
# Guard: empty container name
[[ -z "$container" ]] && return 0
# Skip if docker not available
if ! command -v docker &>/dev/null; then
return 0
fi
# Get container state via docker inspect
local state
state=$(docker inspect --format '{{.State.Status}}' "$container" 2>&1)
local inspect_exit=$?
if [[ $inspect_exit -ne 0 ]]; then
echo "not_found"
return 1
elif [[ "$state" == "running" ]]; then
echo "running"
return 0
elif [[ "$state" == "restarting" ]]; then
echo "restarting"
return 1
else
# exited, paused, dead, created
echo "$state"
return 1
fi
}
# Generic registry-driven service health check
test_service() {
local sid="$1"
local port_env="${SERVICE_PORT_ENVS[$sid]}"
local default_port="${SERVICE_PORTS[$sid]}"
local health="${SERVICE_HEALTH[$sid]}"
local timeout="${SERVICE_HEALTH_TIMEOUTS[$sid]:-$TIMEOUT}"
# Resolve port
local port="$default_port"
[[ -n "$port_env" ]] && port="${!port_env:-$default_port}"
[[ -z "$health" || "$port" == "0" ]] && return 1
# Check container state first (if docker available)
local container_state
container_state=$(check_container_state "$sid")
if [[ -n "$container_state" && "$container_state" != "running" ]]; then
result_set "$sid" "fail"
ANY_FAIL=true
return 1
fi
if curl -sf --max-time "$timeout" "http://127.0.0.1:${port}${health}" >/dev/null 2>&1; then
result_set "$sid" "ok"
return 0
fi
result_set "$sid" "fail"
ANY_FAIL=true
return 1
}
# System-level: GPU
test_gpu() {
if command -v nvidia-smi &>/dev/null; then
local gpu_info
gpu_info=$(nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu,temperature.gpu --format=csv,noheader,nounits 2>/dev/null | head -1)
if [ -n "$gpu_info" ]; then
IFS=',' read -r mem_used mem_total gpu_util temp <<< "$gpu_info"
result_set "gpu" "ok"
result_set "gpu_mem_used" "${mem_used// /}"
result_set "gpu_mem_total" "${mem_total// /}"
result_set "gpu_util" "${gpu_util// /}"
result_set "gpu_temp" "${temp// /}"
# Warn if GPU memory > 95% (approaching OOM) or temp > 80C.
# Deliberately NOT based on utilization: a llama-server doing
# inference legitimately pins the GPU at ~100% util, so a
# util-based warning would fire during normal, healthy load.
local mem_used mem_total
mem_used="$(result_get "gpu_mem_used")"
mem_total="$(result_get "gpu_mem_total")"
if [[ "$mem_used" =~ ^[0-9]+$ && "$mem_total" =~ ^[0-9]+$ ]] \
&& [ "$mem_total" -gt 0 ] \
&& [ $(( mem_used * 100 / mem_total )) -gt 95 ]; then
result_set "gpu" "warn"
fi
if [ "$(result_get "gpu_temp")" -gt 80 ] 2>/dev/null; then
result_set "gpu" "warn"
fi
return 0
fi
fi
result_set "gpu" "unavailable"
return 1
}
# System-level: Disk
test_disk() {
local usage
# df -P forces POSIX single-line output. Plain `df -h` wraps a long device
# name onto a second line, which shifts awk's column indexes so `$5` reads
# the mount point ("/") instead of the capacity — that then trips the
# numeric comparison below. Grab the capacity field by its trailing "%" so
# the parse survives any column shift, then strip the percent sign.
usage=$(df -P "$INSTALL_DIR" 2>/dev/null \
| awk 'NR>1 { for (i = 1; i <= NF; i++) if ($i ~ /%$/) { gsub(/%/, "", $i); print $i; exit } }')
# Only treat the probe as successful when we parsed a numeric percentage;
# this also keeps the `-gt` comparison from erroring on unexpected output.
if [[ "$usage" =~ ^[0-9]+$ ]]; then
result_set "disk" "ok"
result_set "disk_usage" "$usage"
if [ "$usage" -gt 90 ]; then
result_set "disk" "warn"
fi
return 0
fi
result_set "disk" "unavailable"
return 1
}
# Helper: run test_service for a service ID and log the result
check_service() {
local sid="$1"
local name="${SERVICE_NAMES[$sid]:-$sid}"
if test_service "$sid" 2>/dev/null; then
log " ${GREEN}${NC} $name - healthy"
return 0
else
log " ${YELLOW}!${NC} $name - not responding"
return 1
fi
}
# Helper: run test_service in background and store result in temp file
check_service_async() {
local sid="$1"
local result_file="$2"
# Check container state first. check_container_state exits non-zero for
# any not-running state; without the guard, set -e kills this background
# subshell before the result file is written and the service silently
# disappears from the report.
local container_state
container_state=$(check_container_state "$sid") || true
if test_service "$sid" 2>/dev/null; then
echo "ok:$sid:$container_state" > "$result_file"
else
echo "fail:$sid:$container_state" > "$result_file"
fi
}
# ── Run tests ───────────────────────────────────────────────────────────────
# Create temp dir for parallel results
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log "${CYAN} ODS Health Check${NC}"
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log ""
log "${CYAN}Core Services:${NC}"
# llama-server (critical — does inference test, not just health)
if test_llm 2>/dev/null; then
log " ${GREEN}${NC} llama-server - inference working ($(result_get "llm_latency")ms)"
else
log " ${RED}${NC} llama-server - CRITICAL: inference failed"
fi
# Launch all other core services in parallel
declare -a CORE_PIDS=()
declare -a CORE_SIDS=()
for sid in "${SERVICE_IDS[@]}"; do
[[ "$sid" == "llama-server" ]] && continue
[[ "${SERVICE_CATEGORIES[$sid]}" != "core" ]] && continue
result_file="$TEMP_DIR/core_$sid"
check_service_async "$sid" "$result_file" &
CORE_PIDS+=($!)
CORE_SIDS+=("$sid")
done
# Wait for all core service checks to complete
for pid in "${CORE_PIDS[@]}"; do
wait "$pid" 2>/dev/null || true
done
# Display core service results. The async checks run in subshells, so their
# result_set/ANY_FAIL mutations are lost — the parent must aggregate status
# from the result files or failures never reach the summary, exit code, or
# JSON output.
for sid in "${CORE_SIDS[@]}"; do
result_file="$TEMP_DIR/core_$sid"
name="${SERVICE_NAMES[$sid]:-$sid}"
status="fail"
container_state=""
if [[ -f "$result_file" ]]; then
result=$(cat "$result_file")
# Parse result format: status:sid:container_state
IFS=':' read -r status sid_check container_state <<< "$result"
fi
result_set "$sid" "$status"
if [[ "$status" == "ok" ]]; then
log " ${GREEN}${NC} $name - healthy"
else
# Core service down → critical, per the documented exit codes
CRITICAL_FAIL=true
ANY_FAIL=true
# Use container state for better error message
case "$container_state" in
not_found)
log " ${YELLOW}!${NC} $name - container not found"
;;
exited|stopped)
log " ${YELLOW}!${NC} $name - container stopped"
;;
restarting)
log " ${YELLOW}!${NC} $name - container restarting"
;;
running)
log " ${YELLOW}!${NC} $name - not responding (container running)"
;;
*)
log " ${YELLOW}!${NC} $name - not responding"
;;
esac
fi
done
log ""
log "${CYAN}Extension Services:${NC}"
# Launch all enabled extension services in parallel. Disabled extensions
# (no active compose fragment — same predicate as sr_list_enabled) are not
# part of this install's stack and must not be probed or degrade the status.
declare -a EXT_PIDS=()
declare -a EXT_SIDS=()
for sid in "${SERVICE_IDS[@]}"; do
[[ "${SERVICE_CATEGORIES[$sid]}" == "core" ]] && continue
ext_compose="${SERVICE_COMPOSE[$sid]:-}"
[[ -n "$ext_compose" && -f "$ext_compose" ]] || continue
result_file="$TEMP_DIR/ext_$sid"
check_service_async "$sid" "$result_file" &
EXT_PIDS+=($!)
EXT_SIDS+=("$sid")
done
# Wait for all extension service checks to complete
for pid in "${EXT_PIDS[@]}"; do
wait "$pid" 2>/dev/null || true
done
# Display extension service results — same aggregation as core services,
# but a down extension only degrades the stack instead of marking it critical.
for sid in "${EXT_SIDS[@]}"; do
result_file="$TEMP_DIR/ext_$sid"
name="${SERVICE_NAMES[$sid]:-$sid}"
status="fail"
container_state=""
if [[ -f "$result_file" ]]; then
result=$(cat "$result_file")
# Parse result format: status:sid:container_state
IFS=':' read -r status sid_check container_state <<< "$result"
fi
result_set "$sid" "$status"
if [[ "$status" == "ok" ]]; then
log " ${GREEN}${NC} $name - healthy"
else
ANY_FAIL=true
# Use container state for better error message
case "$container_state" in
not_found)
log " ${YELLOW}!${NC} $name - container not found"
;;
exited|stopped)
log " ${YELLOW}!${NC} $name - container stopped"
;;
restarting)
log " ${YELLOW}!${NC} $name - container restarting"
;;
running)
log " ${YELLOW}!${NC} $name - not responding (container running)"
;;
*)
log " ${YELLOW}!${NC} $name - not responding"
;;
esac
fi
done
log ""
log "${CYAN}System Resources:${NC}"
# GPU
if test_gpu 2>/dev/null; then
status_icon="${GREEN}${NC}"
[ "$(result_get "gpu")" = "warn" ] && status_icon="${YELLOW}!${NC}"
log " ${status_icon} GPU - $(result_get "gpu_mem_used")/$(result_get "gpu_mem_total") MiB, $(result_get "gpu_util")% util, $(result_get "gpu_temp")°C"
else
log " ${YELLOW}?${NC} GPU - status unavailable"
fi
# Disk
if test_disk 2>/dev/null; then
status_icon="${GREEN}${NC}"
[ "$(result_get "disk")" = "warn" ] && status_icon="${YELLOW}!${NC}"
log " ${status_icon} Disk - $(result_get "disk_usage")% used"
else
log " ${YELLOW}?${NC} Disk - status unavailable"
fi
log ""
# Summary
if $CRITICAL_FAIL; then
log "${RED}Status: CRITICAL - Core services down${NC}"
EXIT_CODE=2
elif $ANY_FAIL; then
log "${YELLOW}Status: DEGRADED - Some services unavailable${NC}"
EXIT_CODE=1
else
log "${GREEN}Status: HEALTHY - All services operational${NC}"
EXIT_CODE=0
fi
log ""
# JSON output
if $JSON_OUTPUT; then
echo "{"
echo " \"timestamp\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\","
echo " \"status\": \"$([ $EXIT_CODE -eq 0 ] && echo "healthy" || ([ $EXIT_CODE -eq 1 ] && echo "degraded" || echo "critical"))\","
echo " \"services\": {"
first=true
for i in "${!RESULT_KEYS[@]}"; do
$first || echo ","
first=false
echo -n " \"${RESULT_KEYS[$i]}\": \"${RESULT_VALS[$i]}\""
done
echo ""
echo " }"
echo "}"
fi
exit $EXIT_CODE
+409
View File
@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""ODS — universal healthcheck.
Why this exists
--------------
A lot of containers use curl/wget for HEALTHCHECK instructions, but minimal images
(distro-less, scratch-ish, python slim) frequently do not include them.
This script provides a single, dependency-free healthcheck implementation that:
- Works with *either* HTTP(S) endpoints or raw TCP sockets
- Supports GET fallback when HEAD is blocked
- Allows matching on status code ranges and/or response body regex
- Emits structured output for debugging in CI
Usage
-----
healthcheck.py http://localhost:8080/health
healthcheck.py tcp://localhost:5432
healthcheck.py localhost:5432
Options
-------
--timeout SECONDS Overall timeout for the request/connection
--retries N Retry count (with small backoff)
--method {HEAD,GET} HTTP method (default: HEAD, with GET fallback)
--expect-status 200,204,3xx Allowed HTTP status codes/ranges
--expect-body-regex REGEX Regex to match in response body (GET only)
--user-agent UA Custom user-agent
--json Emit machine-readable JSON result
Exit codes
----------
0 Healthy
1 Unhealthy (check failed)
2 Usage / invalid input
Notes
-----
- For HTTP checks we prefer HEAD to avoid moving large bodies, but many
frameworks disable HEAD or route it differently. We automatically fall back
to GET when HEAD fails with method-related errors.
- For TCP checks we just attempt to connect. This validates listening and basic
accept() path.
"""
from __future__ import annotations
import argparse
import json
import re
import socket
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import List, Optional, Sequence, Set, Tuple
# -----------------------------
# Data model
# -----------------------------
@dataclass(frozen=True)
class Result:
ok: bool
target: str
kind: str # http|tcp
detail: str
status: Optional[int] = None
elapsed_ms: Optional[int] = None
def to_json(self) -> str:
return json.dumps(
{
"ok": self.ok,
"target": self.target,
"kind": self.kind,
"detail": self.detail,
"status": self.status,
"elapsed_ms": self.elapsed_ms,
},
separators=(",", ":"),
)
# -----------------------------
# Parsing helpers
# -----------------------------
def _parse_target(raw: str) -> Tuple[str, str]:
"""Return (kind, normalized_target)."""
if raw.startswith("http://") or raw.startswith("https://"):
return ("http", raw)
if raw.startswith("tcp://"):
return ("tcp", raw[len("tcp://") :])
# host:port shorthand
if ":" in raw and not raw.startswith("["):
return ("tcp", raw)
raise ValueError("target must be http(s) URL, tcp://host:port, or host:port")
def _parse_host_port(raw: str) -> Tuple[str, int]:
host, port_s = raw.rsplit(":", 1)
host = host.strip()
if not host:
raise ValueError("host is empty")
try:
port = int(port_s)
except ValueError as exc:
raise ValueError("port must be an integer") from exc
if not (1 <= port <= 65535):
raise ValueError("port out of range (1-65535)")
return (host, port)
def _parse_expected_status(expr: str) -> Set[int]:
"""Parse '200,204,3xx,401-403' => allowed status codes set."""
allowed: Set[int] = set()
for part in (p.strip() for p in expr.split(",") if p.strip()):
if part.endswith("xx") and len(part) == 3 and part[0].isdigit():
base = int(part[0]) * 100
allowed.update(range(base, base + 100))
continue
if "-" in part:
lo_s, hi_s = (x.strip() for x in part.split("-", 1))
lo = int(lo_s)
hi = int(hi_s)
if lo > hi:
lo, hi = hi, lo
allowed.update(range(lo, hi + 1))
continue
allowed.add(int(part))
if not allowed:
raise ValueError("--expect-status produced empty set")
return allowed
# -----------------------------
# Check implementations
# -----------------------------
def check_tcp(host: str, port: int, timeout: float) -> Tuple[bool, str]:
"""Check TCP port is open."""
try:
with socket.create_connection((host, port), timeout=timeout):
return (True, "tcp connect ok")
except socket.timeout:
return (False, "tcp connect timeout")
except ConnectionRefusedError:
return (False, "tcp connection refused")
except OSError as exc:
return (False, f"tcp error: {exc}")
def _http_request(
url: str,
*,
method: str,
timeout: float,
user_agent: str,
) -> urllib.response.addinfourl:
req = urllib.request.Request(url, method=method)
req.add_header("User-Agent", user_agent)
return urllib.request.urlopen(req, timeout=timeout) # nosec B310
def check_http(
url: str,
*,
method: str,
timeout: float,
allowed_status: Optional[Set[int]],
body_regex: Optional[re.Pattern[str]],
user_agent: str,
) -> Tuple[bool, str, Optional[int]]:
"""Check HTTP endpoint matches expected status and optional body regex."""
# If a body regex is provided, we must use GET.
if body_regex is not None:
method = "GET"
try_methods: List[str]
if method.upper() == "HEAD":
# Prefer HEAD, fallback to GET if HEAD isn't supported.
try_methods = ["HEAD", "GET"]
else:
try_methods = [method.upper()]
last_err: Optional[str] = None
for m in try_methods:
try:
with _http_request(url, method=m, timeout=timeout, user_agent=user_agent) as resp:
status = getattr(resp, "status", None)
# Status validation
if status is None:
return (False, f"http {m}: missing status", None)
if allowed_status is not None and status not in allowed_status:
return (False, f"http {m}: unexpected status {status}", status)
if body_regex is not None:
# Limit body read to avoid memory blowups in bad configs.
body = resp.read(1024 * 1024) # 1 MiB cap
try:
text = body.decode("utf-8", errors="replace")
except Exception:
text = str(body)
if not body_regex.search(text):
return (False, f"http {m}: body regex did not match", status)
return (True, f"http {m}: ok", status)
except urllib.error.HTTPError as exc:
# HTTPError is a valid response with status code; treat via status checks.
status = getattr(exc, "code", None)
if allowed_status is not None and status in allowed_status:
return (True, f"http {m}: ok (error status allowed)", int(status) if status is not None else None)
last_err = f"http {m}: HTTPError {status}"
# If HEAD is rejected, allow retry with GET.
if m == "HEAD" and status in (400, 404, 405, 501):
continue
return (False, last_err, int(status) if status is not None else None)
except urllib.error.URLError as exc:
last_err = f"http {m}: URLError {exc.reason}"
continue
except socket.timeout:
last_err = f"http {m}: timeout"
continue
return (False, last_err or "http: request failed", None)
# -----------------------------
# Retry wrapper
# -----------------------------
def with_retries(fn, *, retries: int, base_sleep: float = 0.15):
last = None
for attempt in range(retries + 1):
if attempt > 0:
time.sleep(base_sleep * (1.6 ** (attempt - 1)))
last = fn()
ok = last[0]
if ok:
return last
return last
# -----------------------------
# CLI
# -----------------------------
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(prog="healthcheck.py", add_help=True)
p.add_argument("target", help="http(s) URL, tcp://host:port, or host:port")
p.add_argument("--timeout", type=float, default=5.0, help="Timeout seconds (default: 5)")
p.add_argument("--retries", type=int, default=1, help="Retry count (default: 1)")
p.add_argument("--method", default="HEAD", choices=["HEAD", "GET"], help="HTTP method")
p.add_argument(
"--expect-status",
default=None,
help="Allowed HTTP statuses, e.g. '200,204,3xx,401-403' (default: 200)",
)
p.add_argument(
"--expect-body-regex",
default=None,
help="Regex to match in response body (forces GET).",
)
p.add_argument(
"--user-agent",
default="ODS-Healthcheck/1.0",
help="User-Agent header",
)
p.add_argument("--json", action="store_true", help="Emit JSON result")
return p.parse_args(argv)
def main(argv: Sequence[str]) -> int:
try:
args = parse_args(argv)
except SystemExit as exc:
if isinstance(exc.code, int):
return exc.code
return 1
try:
kind, norm = _parse_target(args.target)
except ValueError as exc:
res = Result(ok=False, target=args.target, kind="unknown", detail=str(exc))
if args.json:
print(res.to_json())
else:
print(f"[FAIL] {res.detail}")
return 2
if args.timeout <= 0:
res = Result(ok=False, target=args.target, kind=kind, detail="--timeout must be > 0")
if args.json:
print(res.to_json())
else:
print("[FAIL] --timeout must be > 0")
return 2
if args.retries < 0 or args.retries > 50:
res = Result(ok=False, target=args.target, kind=kind, detail="--retries out of range (0-50)")
if args.json:
print(res.to_json())
else:
print("[FAIL] --retries out of range")
return 2
allowed_status: Optional[Set[int]]
if kind == "http":
if args.expect_status is None:
allowed_status = {200}
else:
try:
allowed_status = _parse_expected_status(args.expect_status)
except Exception as exc:
res = Result(ok=False, target=args.target, kind=kind, detail=f"invalid --expect-status: {exc}")
if args.json:
print(res.to_json())
else:
print(f"[FAIL] {res.detail}")
return 2
else:
allowed_status = None
body_re: Optional[re.Pattern[str]] = None
if args.expect_body_regex:
try:
body_re = re.compile(args.expect_body_regex)
except re.error as exc:
res = Result(ok=False, target=args.target, kind=kind, detail=f"invalid regex: {exc}")
if args.json:
print(res.to_json())
else:
print(f"[FAIL] {res.detail}")
return 2
start = time.perf_counter()
if kind == "tcp":
try:
host, port = _parse_host_port(norm)
except ValueError as exc:
res = Result(ok=False, target=args.target, kind=kind, detail=str(exc))
if args.json:
print(res.to_json())
else:
print(f"[FAIL] {res.detail}")
return 2
ok, detail = with_retries(lambda: check_tcp(host, port, args.timeout), retries=args.retries)
elapsed_ms = int((time.perf_counter() - start) * 1000)
res = Result(ok=bool(ok), target=args.target, kind=kind, detail=str(detail), elapsed_ms=elapsed_ms)
else:
ok, detail, status = with_retries(
lambda: check_http(
norm,
method=args.method,
timeout=args.timeout,
allowed_status=allowed_status,
body_regex=body_re,
user_agent=args.user_agent,
),
retries=args.retries,
)
elapsed_ms = int((time.perf_counter() - start) * 1000)
res = Result(
ok=bool(ok),
target=args.target,
kind=kind,
detail=str(detail),
status=int(status) if status is not None else None,
elapsed_ms=elapsed_ms,
)
if args.json:
print(res.to_json())
else:
if res.ok:
status_part = f" status={res.status}" if res.status is not None else ""
print(f"[PASS] {res.kind} {res.target}{status_part} ({res.elapsed_ms}ms)")
else:
status_part = f" status={res.status}" if res.status is not None else ""
print(f"[FAIL] {res.kind} {res.target}{status_part} ({res.elapsed_ms}ms): {res.detail}")
return 0 if res.ok else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env bash
# Linux install environment preflight — structured checks with stable IDs and JSON output.
# Use before or during install when services are not yet up (unlike ./ods-preflight.sh).
#
# Usage:
# ./scripts/linux-install-preflight.sh # human-readable report
# ./scripts/linux-install-preflight.sh --json # JSON on stdout
# ./scripts/linux-install-preflight.sh --json-file /tmp/report.json
# ./scripts/linux-install-preflight.sh --strict # exit 1 if any warn or fail
#
# Also reachable as: ./ods-preflight.sh --install-env [same args]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT_MODE="human"
JSON_FILE=""
STRICT=false
ODS_ROOT="${ODS_ROOT:-$ROOT_DIR}"
MIN_DISK_GB_FREE="${MIN_DISK_GB_FREE:-15}"
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
OUTPUT_MODE="json"
shift
;;
--json-file)
JSON_FILE="${2:-}"
OUTPUT_MODE="json"
shift 2
;;
--strict)
STRICT=true
shift
;;
--ods-root)
ODS_ROOT="${2:-}"
shift 2
;;
--min-disk-gb)
MIN_DISK_GB_FREE="${2:-}"
shift 2
;;
-h|--help)
sed -n '1,20p' "$0" | tail -n +2
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 2
;;
esac
done
CHECKS_JSONL="$(mktemp)"
trap 'rm -f "$CHECKS_JSONL"' EXIT
append_check() {
# id, status, message, remediation — safe for special characters via env
export LP_ID="$1" LP_STATUS="$2" LP_MSG="$3" LP_FIX="${4:-}"
python3 -c '
import json, os
print(json.dumps({
"id": os.environ["LP_ID"],
"status": os.environ["LP_STATUS"],
"message": os.environ["LP_MSG"],
"remediation": os.environ.get("LP_FIX", ""),
}))
' >>"$CHECKS_JSONL"
}
docker_cli_looks_like_podman() {
local docker_version=""
docker_version="$(docker --version 2>/dev/null || true)"
if printf '%s\n' "$docker_version" | grep -qi 'podman'; then
return 0
fi
# podman-docker commonly installs a docker-compatible shim. ODS
# currently relies on Docker Engine semantics, so the shim must fail loud.
local docker_path=""
docker_path="$(command -v docker 2>/dev/null || true)"
if [[ -n "$docker_path" ]] && command -v readlink >/dev/null 2>&1; then
local docker_real=""
docker_real="$(readlink -f "$docker_path" 2>/dev/null || true)"
case "$(basename "$docker_real")" in
podman|podman-docker) return 0 ;;
esac
fi
return 1
}
# --- Distro fingerprint (from /etc/os-release) ---
DISTRO_ID=""
DISTRO_VERSION_ID=""
DISTRO_PRETTY=""
DISTRO_LIKE=""
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
source /etc/os-release
DISTRO_ID="${ID:-}"
DISTRO_VERSION_ID="${VERSION_ID:-}"
DISTRO_PRETTY="${PRETTY_NAME:-}"
DISTRO_LIKE="${ID_LIKE:-}"
append_check "DISTRO_INFO" "pass" \
"Linux distro: ${PRETTY_NAME:-unknown} (ID=${ID:-?}, VERSION_ID=${VERSION_ID:-?})" \
""
else
append_check "DISTRO_INFO" "fail" \
"/etc/os-release not found — installer expects a Linux environment" \
"Run on a supported Linux distribution or use the platform-specific installer."
fi
KERNEL="$(uname -r 2>/dev/null || echo unknown)"
append_check "KERNEL_INFO" "pass" "Kernel: $KERNEL" ""
# --- curl (used by service preflight and many scripts) ---
if command -v curl >/dev/null 2>&1; then
append_check "CURL_INSTALLED" "pass" "curl is available" ""
else
append_check "CURL_INSTALLED" "warn" \
"curl not found — installer and health checks expect it" \
"Install curl (e.g. apt install curl / dnf install curl) and re-run."
fi
# --- Docker CLI ---
DOCKER_IS_PODMAN=false
if ! command -v docker >/dev/null 2>&1; then
append_check "DOCKER_INSTALLED" "fail" \
"Docker CLI not found in PATH" \
"Install Docker Engine and ensure your user can run docker (see LINUX-TROUBLESHOOTING-GUIDE.md#docker_installed)."
else
DV="$(docker --version 2>/dev/null | head -1 || true)"
append_check "DOCKER_INSTALLED" "pass" "Docker CLI: ${DV:-present}" ""
if docker_cli_looks_like_podman; then
DOCKER_IS_PODMAN=true
append_check "DOCKER_ENGINE" "fail" \
"docker resolves to Podman compatibility mode, not Docker Engine" \
"Install Docker Engine and Docker Compose v2. Podman is not a supported ODS runtime yet; remove podman-docker or put Docker Engine first in PATH."
else
append_check "DOCKER_ENGINE" "pass" "Docker CLI appears to target Docker Engine" ""
fi
fi
# --- Docker daemon ---
DOCKER_INFO_OK=false
if [[ "$DOCKER_IS_PODMAN" == true ]]; then
append_check "DOCKER_DAEMON" "fail" \
"Skipped Docker daemon probe because docker resolves to Podman" \
"Install Docker Engine; ODS does not currently support Podman as the container runtime."
elif command -v docker >/dev/null 2>&1; then
if docker info >/dev/null 2>&1; then
DOCKER_INFO_OK=true
append_check "DOCKER_DAEMON" "pass" "Docker daemon is reachable" ""
else
append_check "DOCKER_DAEMON" "fail" \
"Docker daemon not running or not accessible" \
"Start the service (e.g. sudo systemctl start docker) or log in to Docker Desktop; add your user to the docker group if permission denied (see LINUX-TROUBLESHOOTING-GUIDE.md#docker_daemon)."
fi
else
append_check "DOCKER_DAEMON" "fail" "Skipped — Docker CLI missing" ""
fi
# --- Docker Compose v2 / v1 ---
if [[ "$DOCKER_IS_PODMAN" == true ]]; then
append_check "COMPOSE_CLI" "fail" \
"Skipped Compose probe because docker resolves to Podman" \
"Install Docker Engine with the Docker Compose v2 plugin. podman compose is not a supported substitute for ODS installs yet."
elif command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
CV="$(docker compose version 2>/dev/null | head -1 || true)"
append_check "COMPOSE_CLI" "pass" "Compose: $CV" ""
elif command -v docker-compose >/dev/null 2>&1; then
CV="$(docker-compose version --short 2>/dev/null || docker-compose version 2>/dev/null | head -1)"
append_check "COMPOSE_CLI" "pass" "docker-compose (legacy): $CV" ""
else
append_check "COMPOSE_CLI" "fail" \
"Neither 'docker compose' nor 'docker-compose' is available" \
"Install Docker Compose v2 plugin or docker-compose; see LINUX-TROUBLESHOOTING-GUIDE.md#compose_cli."
fi
# --- NVIDIA Docker runtime (only if nvidia-smi works) ---
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-gpu=name --format=csv,noheader >/dev/null 2>&1; then
if [[ "$DOCKER_INFO_OK" == true ]]; then
if docker info 2>/dev/null | grep -qi nvidia; then
append_check "NVIDIA_CONTAINER_RUNTIME" "pass" "NVIDIA Container Toolkit / runtime visible to Docker" ""
else
append_check "NVIDIA_CONTAINER_RUNTIME" "warn" \
"GPU visible to nvidia-smi but Docker does not report NVIDIA runtime" \
"Install/configure nvidia-container-toolkit so GPU containers work (see LINUX-TROUBLESHOOTING-GUIDE.md#nvidia_container_runtime)."
fi
else
append_check "NVIDIA_CONTAINER_RUNTIME" "warn" \
"nvidia-smi works but Docker daemon check failed — could not verify NVIDIA runtime" \
"Fix Docker daemon access first, then install nvidia-container-toolkit if needed."
fi
else
append_check "NVIDIA_CONTAINER_RUNTIME" "pass" "No NVIDIA GPU detected via nvidia-smi — check skipped" ""
fi
# --- Free disk space for ODS root ---
if [[ -d "$ODS_ROOT" ]]; then
# POSIX-friendly: df -P, parse available KB
if DFOUT="$(df -Pk "$ODS_ROOT" 2>/dev/null | tail -1)"; then
AVAIL_KB="$(echo "$DFOUT" | awk '{print $4}')"
if [[ "$AVAIL_KB" =~ ^[0-9]+$ ]]; then
AVAIL_GB=$((AVAIL_KB / 1048576))
if [[ "$AVAIL_GB" -ge "$MIN_DISK_GB_FREE" ]]; then
append_check "DISK_SPACE" "pass" \
"Free space on $ODS_ROOT: ~${AVAIL_GB}GB (min ${MIN_DISK_GB_FREE}GB)" ""
else
append_check "DISK_SPACE" "warn" \
"Low free space on $ODS_ROOT: ~${AVAIL_GB}GB (recommended ≥${MIN_DISK_GB_FREE}GB free)" \
"Free disk space or set ODS_ROOT to a volume with more room; see LINUX-TROUBLESHOOTING-GUIDE.md#disk_space."
fi
else
append_check "DISK_SPACE" "warn" "Could not parse free space for $ODS_ROOT" ""
fi
else
append_check "DISK_SPACE" "warn" "df failed for $ODS_ROOT" ""
fi
else
append_check "DISK_SPACE" "warn" "ODS_ROOT does not exist yet: $ODS_ROOT" \
"Create the directory or run from the extracted ods tree."
fi
# --- cgroup v2 (optional signal) ---
if [[ -f /sys/fs/cgroup/cgroup.controllers ]]; then
append_check "CGROUP_V2" "pass" "cgroup v2 detected (/sys/fs/cgroup/cgroup.controllers present)" ""
else
append_check "CGROUP_V2" "warn" \
"cgroup v2 not detected — some Docker/rootless setups may differ" \
"Usually fine on modern distros; if Docker fails oddly, see LINUX-TROUBLESHOOTING-GUIDE.md#cgroups."
fi
# --- jq (installer often installs it; nice to have) ---
if command -v jq >/dev/null 2>&1; then
append_check "JQ_INSTALLED" "pass" "jq available for JSON tooling" ""
else
append_check "JQ_INSTALLED" "warn" \
"jq not found — installer may install it; some scripts expect it" \
"Install jq for smoother tooling (see INSTALL-TROUBLESHOOTING.md)."
fi
# --- Host firewall (UFW / firewalld) ---
# The dashboard host agent binds to the Docker bridge gateway (default
# 172.17.0.1:7710) and compose containers reach it via the host INPUT chain.
# Default-DROP firewalls block that traffic. Warn only — never fail.
if command -v ufw >/dev/null 2>&1 && systemctl is-active --quiet ufw 2>/dev/null; then
append_check "FIREWALL_CHECK" "warn" \
"UFW active — may block container→host:7710 traffic" \
"Installer will auto-add a scoped rule for the actual ods-network subnet after compose starts."
elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
append_check "FIREWALL_CHECK" "warn" \
"firewalld active — may block container→host:7710 traffic" \
"Installer will auto-add a scoped rule for the actual ods-network subnet after compose starts."
else
append_check "FIREWALL_CHECK" "pass" \
"No restrictive host firewall detected" ""
fi
# --- Compose files present (expected when run from repo tree) ---
if [[ -f "$ROOT_DIR/docker-compose.base.yml" ]] || [[ -f "$ROOT_DIR/docker-compose.yml" ]]; then
append_check "COMPOSE_FILES" "pass" "Compose files present under ods root" ""
else
append_check "COMPOSE_FILES" "warn" \
"No docker-compose.base.yml or docker-compose.yml in $ROOT_DIR" \
"Run this script from the extracted ODS source tree (ods/)."
fi
emit_report() {
python3 - "$CHECKS_JSONL" "$ROOT_DIR" "$KERNEL" "$DISTRO_ID" "$DISTRO_VERSION_ID" "$DISTRO_PRETTY" "$DISTRO_LIKE" "$ODS_ROOT" "$MIN_DISK_GB_FREE" <<'PY'
import json
import sys
from datetime import datetime, timezone
checks_path = sys.argv[1]
root_dir = sys.argv[2]
kernel = sys.argv[3]
distro_id = sys.argv[4]
distro_vid = sys.argv[5]
distro_pretty = sys.argv[6]
distro_like = sys.argv[7]
ods_root = sys.argv[8]
min_disk = sys.argv[9]
checks = []
with open(checks_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
checks.append(json.loads(line))
fail_n = sum(1 for c in checks if c["status"] == "fail")
warn_n = sum(1 for c in checks if c["status"] == "warn")
pass_n = sum(1 for c in checks if c["status"] == "pass")
exit_ok = fail_n == 0
report = {
"schema_version": "1",
"kind": "linux-install-preflight",
"generated_at": datetime.now(timezone.utc).isoformat(),
"distro": {
"id": distro_id,
"version_id": distro_vid,
"pretty_name": distro_pretty,
"id_like": distro_like,
},
"kernel": kernel,
"ods_root": ods_root,
"min_disk_gb_free": min_disk,
"checks": checks,
"summary": {
"pass": pass_n,
"warn": warn_n,
"fail": fail_n,
"exit_ok": exit_ok,
},
}
print(json.dumps(report, indent=2))
PY
}
REPORT_JSON="$(emit_report)"
EXIT_CODE=0
echo "$REPORT_JSON" | python3 -c '
import json,sys
r=json.load(sys.stdin)
s=r["summary"]
sys.exit(0 if s["exit_ok"] else 1)
' || EXIT_CODE=1
if [[ "$STRICT" == true ]]; then
echo "$REPORT_JSON" | python3 -c '
import json,sys
r=json.load(sys.stdin)
s=r["summary"]
sys.exit(0 if s["fail"]==0 and s["warn"]==0 else 1)
' || EXIT_CODE=1
fi
if [[ "$OUTPUT_MODE" == "json" ]]; then
echo "$REPORT_JSON"
if [[ -n "$JSON_FILE" ]]; then
printf '%s\n' "$REPORT_JSON" >"$JSON_FILE"
fi
exit "$EXIT_CODE"
fi
# --- Human output ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
echo -e "${BOLD}${CYAN}Linux install preflight${NC} (structured checks)"
echo "ODS root: $ODS_ROOT"
echo ""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
mapfile -t _trip < <(python3 -c 'import json,sys; o=json.loads(sys.argv[1]); print(o["id"]); print(o["status"]); print(o["message"])' "$line")
id="${_trip[0]}"
st="${_trip[1]}"
msg="${_trip[2]}"
case "$st" in
pass) sig="${GREEN}PASS${NC}" ;;
warn) sig="${YELLOW}WARN${NC}" ;;
fail) sig="${RED}FAIL${NC}" ;;
*) sig="$st" ;;
esac
echo -e "[$sig] ${BOLD}${id}${NC}: $msg"
done <"$CHECKS_JSONL"
echo ""
echo -e "${BOLD}Summary${NC}"
echo "$REPORT_JSON" | python3 -c '
import json,sys
r=json.load(sys.stdin)
s=r["summary"]
print(" pass:", s["pass"], " warn:", s["warn"], " fail:", s["fail"])
print(" exit_ok:", "true" if s["exit_ok"] else "false")
'
if [[ "$EXIT_CODE" -eq 0 ]]; then
echo -e "${GREEN}Preflight OK (no failures).${NC}"
else
echo -e "${RED}Preflight failed — see FAIL checks and LINUX-TROUBLESHOOTING-GUIDE.md${NC}"
fi
if [[ -n "$JSON_FILE" ]]; then
printf '%s\n' "$REPORT_JSON" >"$JSON_FILE"
echo "JSON written to: $JSON_FILE"
fi
exit "$EXIT_CODE"
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
#
# llm-cold-storage.sh — Archive idle HuggingFace models to cold storage
#
# Part of ODS tooling.
#
# Models not accessed in 7+ days are moved to cold storage on a backup drive.
# A symlink replaces the original so HuggingFace cache resolution still works.
# Models can be restored manually or are auto-detected if a process loads them.
#
# Usage:
# ./llm-cold-storage.sh # Archive idle models (dry-run)
# ./llm-cold-storage.sh --execute # Archive idle models (for real)
# ./llm-cold-storage.sh --restore <name> # Restore a specific model
# ./llm-cold-storage.sh --restore-all # Restore all archived models
# ./llm-cold-storage.sh --status # Show archive status
#
set -uo pipefail
HF_CACHE="${HF_CACHE:-$HOME/.cache/huggingface/hub}"
COLD_DIR="${COLD_DIR:-$HOME/llm-cold-storage}"
LOG_FILE="${LOG_FILE:-$HOME/.local/log/llm-cold-storage.log}"
MAX_IDLE_DAYS=7
# Ensure the log directory exists
mkdir -p "$(dirname "$LOG_FILE")"
# Models to never archive (currently serving or critical)
PROTECTED_MODELS=(
"models--BAAI--bge-base-en-v1.5"
"models--Systran--faster-whisper-base"
"models--sentence-transformers--all-MiniLM-L6-v2"
)
log() {
local msg
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" | tee -a "$LOG_FILE"
}
is_protected() {
local name="$1"
for p in "${PROTECTED_MODELS[@]}"; do
[[ "$name" == "$p" ]] && return 0
done
return 1
}
is_model_in_use() {
local name="$1"
# Extract model identifier: models--Org--Name -> Org/Name
local model_id
model_id="$(echo "$name" | sed 's/^models--//; s/--/\//g')"
# Check if any running process references this model
if pgrep -f "$model_id" > /dev/null 2>&1; then
return 0
fi
return 1
}
get_last_access_days() {
local dir="$1"
# Find most recent atime across all files in $dir using portable stat
# (BSD `find -printf` is unavailable on macOS).
local newest_atime=""
if [[ "$(uname -s)" == "Darwin" ]]; then
# BSD: stat -f %a (atime as epoch seconds)
newest_atime="$(find "$dir" -type f -exec stat -f %a {} + 2>/dev/null | sort -rn | sed -n '1p')"
else
# GNU: stat -c %X (atime as epoch seconds)
newest_atime="$(find "$dir" -type f -exec stat -c %X {} + 2>/dev/null | sort -rn | sed -n '1p')"
fi
if [[ -z "$newest_atime" ]]; then
echo "9999"
return
fi
local now
now="$(date +%s)"
local age_secs
age_secs="$(echo "$now - ${newest_atime%.*}" | bc)"
echo "$(( age_secs / 86400 ))"
}
do_archive() {
local dry_run="${1:-true}"
local archived=0
local skipped=0
log "========== LLM cold storage scan started (dry_run=$dry_run) =========="
for model_dir in "$HF_CACHE"/models--*/; do
[[ -d "$model_dir" ]] || continue
# Skip if already a symlink (already archived)
[[ -L "${model_dir%/}" ]] && continue
local name
name="$(basename "$model_dir")"
# Skip protected models
if is_protected "$name"; then
log "SKIP (protected): $name"
((skipped++))
continue
fi
# Skip if actively in use by a process
if is_model_in_use "$name"; then
log "SKIP (in use): $name"
((skipped++))
continue
fi
local idle_days
idle_days="$(get_last_access_days "$model_dir")"
local size
size="$(du -sh "$model_dir" 2>/dev/null | cut -f1)"
if (( idle_days >= MAX_IDLE_DAYS )); then
if [[ "$dry_run" == "true" ]]; then
log "WOULD ARCHIVE: $name ($size, idle ${idle_days}d)"
else
log "ARCHIVING: $name ($size, idle ${idle_days}d)"
# Move to cold storage
mv "$model_dir" "$COLD_DIR/$name"
# Create symlink so HF cache still resolves
ln -s "$COLD_DIR/$name" "${model_dir%/}"
log "ARCHIVED: $name -> $COLD_DIR/$name"
fi
((archived++))
else
log "SKIP (recent, ${idle_days}d): $name ($size)"
((skipped++))
fi
done
log "========== Scan complete: $archived archived, $skipped skipped =========="
}
do_restore() {
local name="$1"
# Normalize: accept "Qwen/Qwen2.5-7B" or "models--Qwen--Qwen2.5-7B"
if [[ "$name" != models--* ]]; then
name="models--$(echo "$name" | sed 's/\//--/g')"
fi
local cold_path="$COLD_DIR/$name"
local cache_path="$HF_CACHE/$name"
if [[ ! -d "$cold_path" ]]; then
echo "ERROR: Model not found in cold storage: $cold_path"
exit 1
fi
# Remove symlink if it exists
if [[ -L "$cache_path" ]]; then
rm "$cache_path"
fi
log "RESTORING: $name to $cache_path"
mv "$cold_path" "$cache_path"
log "RESTORED: $name"
echo "Restored: $name"
}
do_restore_all() {
log "========== Restoring all archived models =========="
for cold_model in "$COLD_DIR"/models--*/; do
[[ -d "$cold_model" ]] || continue
local name
name="$(basename "$cold_model")"
local cache_path="$HF_CACHE/$name"
if [[ -L "$cache_path" ]]; then
rm "$cache_path"
fi
log "RESTORING: $name"
mv "$cold_model" "$cache_path"
log "RESTORED: $name"
done
log "========== All models restored =========="
}
show_status() {
echo "=== LLM Cold Storage Status ==="
echo ""
echo "Active models (on NVMe):"
for model_dir in "$HF_CACHE"/models--*/; do
[[ -d "$model_dir" ]] || continue
local name
name="$(basename "$model_dir")"
if [[ -L "${model_dir%/}" ]]; then
local size
size="$(du -sh "$model_dir" 2>/dev/null | cut -f1)"
echo " [SYMLINK -> cold] $name ($size)"
else
local size idle_days status=""
size="$(du -sh "$model_dir" 2>/dev/null | cut -f1)"
idle_days="$(get_last_access_days "$model_dir")"
is_protected "$name" && status=" [protected]"
is_model_in_use "$name" && status=" [in use]"
echo " [HOT] $name ($size, idle ${idle_days}d)${status}"
fi
done
echo ""
echo "Archived models (on backup SSD):"
local has_archived=false
for cold_model in "$COLD_DIR"/models--*/; do
[[ -d "$cold_model" ]] || continue
has_archived=true
local name size
name="$(basename "$cold_model")"
size="$(du -sh "$cold_model" 2>/dev/null | cut -f1)"
echo " [COLD] $name ($size)"
done
$has_archived || echo " (none)"
echo ""
echo "NVMe cache total: $(du -sh "$HF_CACHE" 2>/dev/null | cut -f1)"
echo "Cold storage total: $(du -sh "$COLD_DIR" 2>/dev/null | cut -f1)"
}
case "${1:-}" in
--execute)
do_archive false
;;
--restore)
[[ -n "${2:-}" ]] || { echo "Usage: $0 --restore <model-name>"; exit 1; }
do_restore "$2"
;;
--restore-all)
do_restore_all
;;
--status)
show_status
;;
--help|-h)
echo "Usage: $0 [--execute|--restore <name>|--restore-all|--status|--help]"
echo ""
echo " (no args) Dry-run: show what would be archived"
echo " --execute Archive idle models (>$MAX_IDLE_DAYS days)"
echo " --restore <name> Restore model from cold storage"
echo " --restore-all Restore all archived models"
echo " --status Show current hot/cold status"
;;
*)
do_archive true
;;
esac
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
BACKEND_ID=""
ENV_MODE="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--backend)
BACKEND_ID="${2:-}"
shift 2
;;
--env)
ENV_MODE="true"
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "$BACKEND_ID" ]]; then
echo "Missing required argument: --backend" >&2
exit 1
fi
CONTRACT_FILE="${ROOT_DIR}/config/backends/${BACKEND_ID}.json"
if [[ ! -f "$CONTRACT_FILE" ]]; then
echo "Backend contract not found: $CONTRACT_FILE" >&2
exit 1
fi
if [[ "$ENV_MODE" == "true" ]]; then
PYTHON_CMD="python3"
if [[ -f "$ROOT_DIR/lib/python-cmd.sh" ]]; then
. "$ROOT_DIR/lib/python-cmd.sh"
PYTHON_CMD="$(ods_detect_python_cmd)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
"$PYTHON_CMD" - "$CONTRACT_FILE" <<'PY'
import json
import sys
contract = json.load(open(sys.argv[1], "r", encoding="utf-8"))
def out(key, value):
safe = str(value).replace("\\", "\\\\").replace('"', '\\"')
print(f'{key}="{safe}"')
out("BACKEND_CONTRACT_ID", contract.get("id", ""))
out("BACKEND_LLM_ENGINE", contract.get("llm_engine", ""))
out("BACKEND_SERVICE_NAME", contract.get("service_name", ""))
out("BACKEND_PUBLIC_API_PORT", contract.get("public_api_port", ""))
out("BACKEND_PUBLIC_HEALTH_URL", contract.get("public_health_url", ""))
out("BACKEND_PROVIDER_NAME", contract.get("provider_name", ""))
out("BACKEND_PROVIDER_URL", contract.get("provider_url", ""))
out("BACKEND_CONTRACT_FILE", sys.argv[1])
runtime = contract.get("runtime", {})
lemonade = runtime.get("lemonade", {}) if isinstance(runtime, dict) else {}
out("BACKEND_LEMONADE_CONTAINER_IMAGE", lemonade.get("container_image", ""))
out("BACKEND_LEMONADE_WINDOWS_VERSION", lemonade.get("windows_version", ""))
out("BACKEND_LEMONADE_WINDOWS_MSI_FILE", lemonade.get("windows_msi_file", ""))
out("BACKEND_LEMONADE_WINDOWS_EXECUTABLE", lemonade.get("windows_executable", ""))
out("BACKEND_LEMONADE_API_PORT", lemonade.get("api_port", ""))
out("BACKEND_LEMONADE_HEALTH_PATH", lemonade.get("health_path", ""))
out("BACKEND_LEMONADE_LINUX_BACKEND", lemonade.get("linux_backend", ""))
out("BACKEND_LEMONADE_WINDOWS_BACKEND", lemonade.get("windows_backend", ""))
PY
else
cat "$CONTRACT_FILE"
fi
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""List remote branches that may be safe to clean up.
This helper is intentionally dry-run only. It never deletes branches. It uses
local git remote refs, and when the GitHub CLI is available it excludes branches
that currently back open pull requests.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_EXCLUDE_EXACT = {
"origin/HEAD",
"origin/main",
"origin/master",
"origin/develop",
}
DEFAULT_EXCLUDE_PREFIXES = (
"origin/release/",
"origin/support/",
)
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, check=False, text=True, capture_output=True)
def repo_root() -> Path:
result = run(["git", "rev-parse", "--show-toplevel"])
if result.returncode != 0:
raise SystemExit("error: not inside a git repository")
return Path(result.stdout.strip())
def open_pr_heads() -> set[str]:
result = run(
[
"gh",
"pr",
"list",
"--state",
"open",
"--limit",
"500",
"--json",
"headRefName",
]
)
if result.returncode != 0:
return set()
try:
rows = json.loads(result.stdout)
except json.JSONDecodeError:
return set()
return {row.get("headRefName", "") for row in rows if row.get("headRefName")}
def remote_branches() -> list[tuple[datetime, str, str]]:
fmt = "%(committerdate:iso8601-strict)%09%(refname:short)%09%(objectname:short)"
result = run(["git", "for-each-ref", f"--format={fmt}", "refs/remotes/origin"])
if result.returncode != 0:
raise SystemExit(result.stderr.strip() or "error: unable to list remote refs")
branches: list[tuple[datetime, str, str]] = []
for line in result.stdout.splitlines():
if not line.strip():
continue
date_s, ref, sha = line.split("\t", 2)
date = datetime.fromisoformat(date_s.replace("Z", "+00:00"))
branches.append((date, ref, sha))
return branches
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--days", type=int, default=45, help="stale age threshold")
parser.add_argument(
"--include-open-prs",
action="store_true",
help="do not exclude branches that back open PRs",
)
args = parser.parse_args()
root = repo_root()
heads = set() if args.include_open_prs else open_pr_heads()
now = datetime.now(timezone.utc)
cutoff_days = args.days
print(f"Repository: {root}")
print(f"Stale threshold: {cutoff_days} days")
if heads:
print(f"Excluding {len(heads)} open PR branch(es)")
elif not args.include_open_prs:
print("Open PR branch exclusion unavailable or empty")
print()
candidates: list[tuple[int, str, str, str]] = []
for date, ref, sha in remote_branches():
if ref in DEFAULT_EXCLUDE_EXACT or ref.startswith(DEFAULT_EXCLUDE_PREFIXES):
continue
short = ref.removeprefix("origin/")
if short in heads:
continue
age_days = (now - date.astimezone(timezone.utc)).days
if age_days < cutoff_days:
continue
candidates.append((age_days, ref, sha, date.date().isoformat()))
if not candidates:
print("No stale branch candidates found.")
return 0
print("Dry-run candidates. Review before deleting anything:")
for age_days, ref, sha, date_s in sorted(candidates, reverse=True):
print(f"{age_days:4d}d {date_s} {sha} {ref}")
return 0
if __name__ == "__main__":
sys.exit(main())
+338
View File
@@ -0,0 +1,338 @@
#!/bin/bash
#=============================================================================
# Config Migration Manager for ODS
#
# Handles configuration changes between versions.
# Automatically detects changes and migrates user configs safely.
#
# Usage:
# ./migrate-config.sh check # Check if migration needed
# ./migrate-config.sh migrate # Run pending migrations
# ./migrate-config.sh diff # Show what changed
# ./migrate-config.sh backup # Backup current config
#=============================================================================
set -euo pipefail
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="${INSTALL_DIR:-$(dirname "$SCRIPT_DIR")}"
DATA_DIR="${DATA_DIR:-$HOME/.ods}"
BACKUP_DIR="${DATA_DIR}/backups"
# Migrations live in INSTALL_DIR/migrations/ in deployed installs;
# in source tree they live at ods/migrations/ (peer of scripts/).
# SCRIPT_DIR is ods/scripts/ when run from source tree, or
# INSTALL_DIR/scripts/ when run from a deployed install — both resolve correctly.
MIGRATIONS_DIR="${SCRIPT_DIR}/../migrations"
VERSION_FILE="${INSTALL_DIR}/.version"
MIGRATION_STATE="${DATA_DIR}/.migration-state"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Get current version
get_current_version() {
if [[ -f "$VERSION_FILE" ]]; then
if jq -e '.version' "$VERSION_FILE" >/dev/null 2>&1; then
jq -r '.version' "$VERSION_FILE"
else
cat "$VERSION_FILE" | tr -d '[:space:]'
fi
else
echo "0.0.0"
fi
}
# Get last migrated version
get_last_migrated_version() {
if [[ -f "$MIGRATION_STATE" ]]; then
cat "$MIGRATION_STATE" | tr -d '[:space:]'
else
echo "0.0.0"
fi
}
# Set last migrated version
set_last_migrated_version() {
local version="$1"
mkdir -p "$(dirname "$MIGRATION_STATE")"
echo "$version" > "$MIGRATION_STATE"
}
# Compare semantic versions
# Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2
compare_versions() {
local v1="${1#v}"
local v2="${2#v}"
if [[ "$v1" == "$v2" ]]; then
return 0
fi
IFS='.' read -ra V1_PARTS <<< "$v1"
IFS='.' read -ra V2_PARTS <<< "$v2"
for i in {0..2}; do
local p1="${V1_PARTS[$i]:-0}"
local p2="${V2_PARTS[$i]:-0}"
if [[ "$p1" -gt "$p2" ]]; then
return 1
elif [[ "$p1" -lt "$p2" ]]; then
return 2
fi
done
return 0
}
# Backup current configuration
cmd_backup() {
log_info "Backing up current configuration..."
local backup_name backup_path
backup_name="config-$(date +%Y%m%d-%H%M%S)"
backup_path="${BACKUP_DIR}/${backup_name}"
mkdir -p "$backup_path"
# Backup key config files
local cp_exit=0
cp "${INSTALL_DIR}/.env" "$backup_path/" 2>&1 || cp_exit=$?
cp "${INSTALL_DIR}/.version" "$backup_path/" 2>&1 || cp_exit=$?
cp "${INSTALL_DIR}/docker-compose.yml" "$backup_path/" 2>&1 || cp_exit=$?
cp -r "${INSTALL_DIR}/config" "$backup_path/" 2>&1 || cp_exit=$?
# Backup user data references
cp -r "${DATA_DIR}" "$backup_path/data/" 2>&1 || cp_exit=$?
log_success "Configuration backed up to: $backup_path"
echo "$backup_path"
}
# Show diff between current and example configs
cmd_diff() {
log_info "Checking for configuration changes..."
local example_env="${INSTALL_DIR}/.env.example"
local current_env="${INSTALL_DIR}/.env"
if [[ ! -f "$example_env" ]]; then
log_warn "No .env.example found — cannot show diff"
return 1
fi
if [[ ! -f "$current_env" ]]; then
log_warn "No .env found — user hasn't configured yet"
return 1
fi
echo ""
echo "=== Environment Variable Changes ==="
echo ""
# Show variables in example but not in current
echo "New variables (add these to your .env):"
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" =~ ^# ]] && continue
local grep_exit=0
grep -q "^${key}=" "$current_env" 2>&1 || grep_exit=$?
if [[ $grep_exit -ne 0 ]]; then
echo " + $key=$value"
fi
done < "$example_env"
echo ""
echo "Deprecated variables (remove from your .env):"
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" =~ ^# ]] && continue
local grep_exit=0
grep -q "^${key}=" "$example_env" 2>&1 || grep_exit=$?
if [[ $grep_exit -ne 0 ]]; then
echo " - $key"
fi
done < "$current_env"
}
# Check if migration is needed
cmd_check() {
local current_version
local last_migrated
current_version=$(get_current_version)
last_migrated=$(get_last_migrated_version)
log_info "Current version: $current_version"
log_info "Last migrated: $last_migrated"
compare_versions "$current_version" "$last_migrated"
local result
result=$?
if [[ $result -eq 2 ]]; then
log_warn "Migration needed: $last_migrated$current_version"
# List pending migrations
echo ""
echo "Pending migrations:"
for migration in "$MIGRATIONS_DIR"/migrate-v*.sh; do
if [[ -f "$migration" ]]; then
local migration_version
migration_version=$(basename "$migration" | sed 's/migrate-v//;s/.sh//')
compare_versions "$migration_version" "$last_migrated"
if [[ $? -eq 1 ]]; then
echo " - $migration_version: $(head -5 "$migration" | grep '^# Description:' | sed 's/# Description://')"
fi
fi
done
return 2
else
log_success "No migration needed (already at $current_version)"
return 0
fi
}
# Run pending migrations
cmd_migrate() {
log_info "Starting configuration migration..."
local current_version
local last_migrated
current_version=$(get_current_version)
last_migrated=$(get_last_migrated_version)
# Create backup first
cmd_backup >/dev/null
compare_versions "$current_version" "$last_migrated"
if [[ $? -ne 2 ]]; then
log_success "Already up to date ($current_version)"
return 0
fi
log_info "Migrating: $last_migrated$current_version"
# Run migrations in order
local failed=0
local ls_exit=0
local migrations
migrations=$(ls -1 "$MIGRATIONS_DIR"/migrate-v*.sh 2>&1 | sort) || ls_exit=$?
if [[ $ls_exit -ne 0 ]]; then
log_success "No migration scripts found"
return 0
fi
for migration in $migrations; do
if [[ -f "$migration" ]]; then
local migration_version
migration_version=$(basename "$migration" | sed 's/migrate-v//;s/.sh//')
# Check if this migration is needed
compare_versions "$migration_version" "$last_migrated"
if [[ $? -eq 1 ]]; then
log_info "Running migration: $migration_version"
if bash "$migration"; then
log_success "Migration $migration_version completed"
set_last_migrated_version "$migration_version"
else
log_error "Migration $migration_version failed!"
((failed++)) || true
break
fi
fi
fi
done
if [[ $failed -eq 0 ]]; then
set_last_migrated_version "$current_version"
log_success "Migration complete! Updated to $current_version"
return 0
else
log_error "Migration failed. Check logs and restore from backup."
return 1
fi
}
# Validate .env against schema
cmd_validate() {
local validator="${SCRIPT_DIR}/validate-env.sh"
local env_file="${INSTALL_DIR}/.env"
local schema_file="${INSTALL_DIR}/.env.schema.json"
if [[ ! -f "$validator" ]]; then
log_error "Validator script missing: $validator"
return 1
fi
if [[ ! -f "$schema_file" ]]; then
log_error "Schema missing: $schema_file"
return 1
fi
bash "$validator" "$env_file" "$schema_file"
}
# Show help
cmd_help() {
cat << 'EOF'
ODS Config Migration Manager
Usage: ./migrate-config.sh [command]
Commands:
check Check if migration is needed
migrate Run pending migrations (with backup)
diff Show configuration differences
backup Backup current configuration
validate Validate .env against .env.schema.json
help Show this help message
Examples:
./migrate-config.sh check
./migrate-config.sh migrate
./migrate-config.sh diff
./migrate-config.sh validate
Migration scripts should be placed in the migrations/ directory
and named: migrate-vX.Y.Z.sh
EOF
}
# Main
case "${1:-help}" in
check)
cmd_check
;;
migrate)
cmd_migrate
;;
diff)
cmd_diff
;;
backup)
cmd_backup
;;
validate)
cmd_validate
;;
help|--help|-h)
cmd_help
;;
*)
log_error "Unknown command: $1"
cmd_help
exit 1
;;
esac
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# ============================================================================
# ODS Mode Switch
# ============================================================================
# Usage: ./mode-switch.sh <local|cloud|hybrid> [--status]
#
# Switches ODS between local/cloud/hybrid modes by updating .env.
# This is the backend for `ods mode <mode>`.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${CYAN}[ods-mode]${NC} $1"; }
success() { echo -e "${GREEN}${NC} $1"; }
warn() { echo -e "${YELLOW}${NC} $1"; }
error() { echo -e "${RED}${NC} $1" >&2; exit 1; }
# Update or add a key=value in .env
# Uses awk index() instead of sed to avoid delimiter collisions
env_set() {
local key="$1" val="$2"
if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then
awk -v k="$key" -v v="$val" '{
if (index($0, k "=") == 1) print k "=" v; else print
}' "$ENV_FILE" > "${ENV_FILE}.tmp" && cat "${ENV_FILE}.tmp" > "$ENV_FILE" && rm -f "${ENV_FILE}.tmp"
else
echo "${key}=${val}" >> "$ENV_FILE"
fi
}
show_status() {
local current
current=$(grep "^ODS_MODE=" "$ENV_FILE" 2>/dev/null | cut -d= -f2)
echo "Current mode: ${current:-local}"
echo ""
echo "Available modes:"
echo " local — Local inference via llama-server (requires GPU/CPU)"
echo " cloud — Cloud APIs via LiteLLM (requires API keys)"
echo " hybrid — Local primary, cloud fallback"
}
switch_mode() {
local mode="$1"
# Validate
case "$mode" in
local|cloud|hybrid) ;;
*) error "Unknown mode: $mode. Use: local, cloud, hybrid" ;;
esac
[[ -f "$ENV_FILE" ]] || error ".env not found at $ENV_FILE"
# Update .env
env_set "ODS_MODE" "$mode"
if [[ "$mode" == "local" ]]; then
env_set "LLM_API_URL" "http://llama-server:8080"
else
env_set "LLM_API_URL" "http://litellm:4000"
# Auto-enable litellm extension
local litellm_cf="$SCRIPT_DIR/extensions/services/litellm/compose.yaml"
local litellm_disabled="${litellm_cf}.disabled"
if [[ -f "$litellm_disabled" && ! -f "$litellm_cf" ]]; then
mv "$litellm_disabled" "$litellm_cf"
success "Auto-enabled litellm for $mode mode"
fi
fi
success "Switched to $mode mode."
log "Run 'ods restart' to apply."
}
# Called directly or sourced
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
case "${1:---status}" in
--status|-s|status) show_status ;;
--help|-h|help)
echo "Usage: mode-switch.sh <local|cloud|hybrid|--status>"
;;
*) switch_mode "${1:-}" ;;
esac
fi
+1466
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
# ods-preflight.sh — Quick health check before first chat
# Usage: ./scripts/ods-preflight.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
# Source service registry
. "$SCRIPT_DIR/lib/service-registry.sh"
sr_load
# Safe .env loading for port overrides (no eval; use lib/safe-env.sh)
[[ -f "$SCRIPT_DIR/lib/safe-env.sh" ]] && . "$SCRIPT_DIR/lib/safe-env.sh"
load_env_file "$SCRIPT_DIR/.env"
sr_resolve_ports
# Resolve compose flags for accurate status checks
COMPOSE_FLAGS=""
if [[ -x "$SCRIPT_DIR/scripts/resolve-compose-stack.sh" ]]; then
# --gpu-count gates the multigpu-{backend}.yml overlay; without it,
# preflight validates the wrong stack on multi-GPU machines.
COMPOSE_FLAGS=$("$SCRIPT_DIR/scripts/resolve-compose-stack.sh" \
--script-dir "$SCRIPT_DIR" --tier "${TIER:-1}" --gpu-backend "${GPU_BACKEND:-nvidia}" \
--gpu-count "${GPU_COUNT:-1}")
fi
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
echo -e "${CYAN}ODS Preflight Check${NC}"
echo "=============================="
echo ""
# Resolve ports from registry + env overrides
LLM_PORT="${OLLAMA_PORT:-${LLAMA_SERVER_PORT:-${SERVICE_PORTS[llama-server]:-11434}}}"
LLM_HEALTH="${SERVICE_HEALTH[llama-server]:-/health}"
LLM_CONTAINER="${SERVICE_CONTAINERS[llama-server]:-ods-llama-server}"
WEBUI_PORT="${SERVICE_PORTS[open-webui]:-3000}"
WEBUI_HEALTH="${SERVICE_HEALTH[open-webui]:-/}"
# Check Docker is running
echo -n "Docker daemon... "
if docker info >/dev/null 2>&1; then
echo -e "${GREEN}✓ running${NC}"
else
echo -e "${RED}✗ not running${NC}"
echo " Fix: Start Docker Desktop or run 'sudo systemctl start docker'"
exit 1
fi
# Check containers are up
echo -n "Core containers... "
if docker compose $COMPOSE_FLAGS ps | grep -q "$LLM_CONTAINER"; then
echo -e "${GREEN}✓ running${NC}"
else
echo -e "${RED}✗ not running${NC}"
echo " Fix: Run 'docker compose up -d' first"
exit 1
fi
# Check llama-server health
CURL_HEALTH_FLAGS=(--connect-timeout 3 --max-time 10)
echo -n "llama-server API (port $LLM_PORT)... "
if curl -sf "${CURL_HEALTH_FLAGS[@]}" "http://127.0.0.1:${LLM_PORT}${LLM_HEALTH}" >/dev/null 2>&1; then
echo -e "${GREEN}✓ healthy${NC}"
else
echo -e "${YELLOW}⚠ starting up${NC}"
echo " The model is still loading. Wait 1-2 minutes and retry."
echo " Monitor: docker compose logs -f llama-server"
fi
# Check WebUI
echo -n "Open WebUI (port $WEBUI_PORT)... "
if curl -sf "${CURL_HEALTH_FLAGS[@]}" "http://127.0.0.1:${WEBUI_PORT}${WEBUI_HEALTH}" >/dev/null 2>&1; then
echo -e "${GREEN}✓ accessible${NC}"
else
echo -e "${YELLOW}⚠ not ready${NC}"
fi
# Check GPU if available
echo -n "GPU availability... "
if docker exec "$LLM_CONTAINER" nvidia-smi >/dev/null 2>&1; then
GPU_MEM=$(docker exec "$LLM_CONTAINER" nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | sed -n '1p' | tr -d ' ')
echo -e "${GREEN}✓ detected (${GPU_MEM}MB free)${NC}"
else
echo -e "${YELLOW}⚠ not detected (CPU mode)${NC}"
fi
# Check extension services that are running
for sid in "${SERVICE_IDS[@]}"; do
[[ "${SERVICE_CATEGORIES[$sid]}" == "core" ]] && continue
container="${SERVICE_CONTAINERS[$sid]}"
docker compose $COMPOSE_FLAGS ps 2>/dev/null | grep -q "$container" || continue
port="${SERVICE_PORTS[$sid]:-0}"
health="${SERVICE_HEALTH[$sid]:-/}"
name="${SERVICE_NAMES[$sid]:-$sid}"
[[ "$port" == "0" ]] && continue
echo -n "$name (port $port)... "
if curl -sf "${CURL_HEALTH_FLAGS[@]}" "http://127.0.0.1:${port}${health}" >/dev/null 2>&1; then
echo -e "${GREEN}✓ ready${NC}"
else
echo -e "${YELLOW}⚠ not ready${NC}"
fi
done
echo ""
echo -e "${CYAN}Next steps:${NC}"
echo " 1. Open http://localhost:${WEBUI_PORT}"
echo " 2. Sign in (first user becomes admin)"
echo " 3. Type 'What's 2+2?' to test"
echo ""
echo "Need help? See docs/TROUBLESHOOTING.md"
+785
View File
@@ -0,0 +1,785 @@
#!/usr/bin/env bash
set -euo pipefail
TOOL_VERSION="1"
REDACTION_VERSION="1"
DEFAULT_LOG_TAIL=200
MAX_LOG_CONTAINERS=25
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${ROOT_DIR}/artifacts/support"
JSON_OUTPUT=false
INCLUDE_LOGS=true
DOCKER_BIN="${ODS_SUPPORT_BUNDLE_DOCKER:-docker}"
usage() {
cat <<'EOF'
Usage: scripts/ods-support-bundle.sh [OPTIONS]
Create a redacted diagnostics bundle for ODS support.
Options:
--output DIR Write bundle directory/archive under DIR
--json Print machine-readable result JSON
--no-logs Skip Docker container log collection
-h, --help Show this help
The generated archive is safe-by-default, but review it before posting to a
public issue. Raw .env is never included; only config/env.redacted is written.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
[[ -n "$OUTPUT_DIR" ]] || { echo "ERROR: --output requires a directory" >&2; exit 2; }
shift 2
;;
--json)
JSON_OUTPUT=true
shift
;;
--no-logs)
INCLUDE_LOGS=false
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
detect_python() {
if command -v python3 >/dev/null 2>&1; then
command -v python3
elif command -v python >/dev/null 2>&1; then
command -v python
else
return 1
fi
}
detect_bash() {
local candidate
for candidate in \
"${ODS_SUPPORT_BUNDLE_BASH:-}" \
/opt/homebrew/bin/bash \
/usr/local/bin/bash \
"$(command -v bash 2>/dev/null || true)" \
/bin/bash
do
[[ -n "$candidate" && -x "$candidate" ]] || continue
if "$candidate" -c '[[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]' >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
done
command -v bash 2>/dev/null || printf '%s\n' /bin/bash
}
PYTHON_CMD="$(detect_python)" || {
echo "ERROR: python3 or python is required to build a redacted support bundle" >&2
exit 1
}
BASH_CMD="$(detect_bash)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
BUNDLE_NAME="ods-support-${timestamp}-$$"
BUNDLE_DIR="${OUTPUT_DIR%/}/${BUNDLE_NAME}"
ARCHIVE_PATH="${BUNDLE_DIR}.tar.gz"
STATUS_FILE="${BUNDLE_DIR}/manifest/command-status.tsv"
mkdir -p \
"$BUNDLE_DIR/config" \
"$BUNDLE_DIR/diagnostics" \
"$BUNDLE_DIR/docker" \
"$BUNDLE_DIR/logs" \
"$BUNDLE_DIR/manifest" \
"$BUNDLE_DIR/system" \
"$BUNDLE_DIR/validation"
shell_quote() {
printf "%q" "$1"
}
record_command() {
local label="$1"
local rel_path="$2"
local exit_code="$3"
printf '%s\t%s\t%s\n' "$label" "$rel_path" "$exit_code" >> "$STATUS_FILE"
}
redact_file() {
local file="$1"
[[ -f "$file" ]] || return 0
"$PYTHON_CMD" - "$file" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
raise SystemExit(0)
secret_word = r"(?:KEY|TOKEN|SECRET|PASSWORD|PASS|SALT|AUTH|CREDENTIAL)"
patterns = [
(re.compile(r"(?i)(Bearer\s+)[A-Za-z0-9._~+/=-]+"), r"\1[REDACTED]"),
(re.compile(r"(?i)((?:authorization|x-api-key|api-key|apikey)\s*[:=]\s*)([\"']?)[^\"'\s,}]+"), r"\1\2[REDACTED]"),
(re.compile(r"(?i)(https?://)([^/\s:@]+):([^@\s/]+)@"), r"\1[REDACTED]@"),
(
re.compile(rf"(?im)^([ \t]*(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*{secret_word}[A-Za-z0-9_]*[ \t]*=[ \t]*).*$"),
r"\1[REDACTED]",
),
(
re.compile(rf"(?im)(^|[{{,]\s*)([\"']?[A-Za-z0-9_-]*{secret_word}[A-Za-z0-9_-]*[\"']?\s*:\s*)([\"']?)[^\"'\s,\n}}{{\[]+([\"']?)"),
r'\1\2"[REDACTED]"',
),
]
for pattern, replacement in patterns:
text = pattern.sub(replacement, text)
path.write_text(text, encoding="utf-8")
PY
}
collect_shell() {
local rel_path="$1"
local label="$2"
local command="$3"
local abs_path="${BUNDLE_DIR}/${rel_path}"
local exit_code
mkdir -p "$(dirname "$abs_path")"
set +e
(
cd "$ROOT_DIR" || exit 1
"$BASH_CMD" -lc "$command"
) > "$abs_path" 2>&1
exit_code=$?
set -e
redact_file "$abs_path"
record_command "$label" "$rel_path" "$exit_code"
return 0
}
write_file() {
local rel_path="$1"
local abs_path="${BUNDLE_DIR}/${rel_path}"
mkdir -p "$(dirname "$abs_path")"
cat > "$abs_path"
redact_file "$abs_path"
}
copy_if_exists() {
local src="$1"
local rel_path="$2"
local abs_path="${BUNDLE_DIR}/${rel_path}"
if [[ -f "$ROOT_DIR/$src" ]]; then
mkdir -p "$(dirname "$abs_path")"
cp "$ROOT_DIR/$src" "$abs_path"
redact_file "$abs_path"
fi
}
write_redacted_env() {
local env_path="$ROOT_DIR/.env"
local out_path="$BUNDLE_DIR/config/env.redacted"
if [[ ! -f "$env_path" ]]; then
printf 'No .env file found at %s\n' "$env_path" > "$out_path"
return 0
fi
"$PYTHON_CMD" - "$env_path" "$out_path" <<'PY'
import re
import sys
from pathlib import Path
src = Path(sys.argv[1])
dest = Path(sys.argv[2])
secret = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|PASS|SALT|AUTH|CREDENTIAL)", re.I)
lines = []
for line in src.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in line:
lines.append(line)
continue
prefix, _value = line.split("=", 1)
key = prefix.strip()
if key.startswith("export "):
key = key[7:].strip()
if secret.search(key):
lines.append(f"{prefix}=[REDACTED]")
else:
lines.append(line)
dest.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
redact_file "$out_path"
}
read_env_value() {
local key="$1"
local default="$2"
local env_path="$ROOT_DIR/.env"
if [[ ! -f "$env_path" ]]; then
printf '%s\n' "$default"
return 0
fi
"$PYTHON_CMD" - "$env_path" "$key" "$default" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
target = sys.argv[2]
default = sys.argv[3]
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key.startswith("export "):
key = key[7:].strip()
if key == target:
print(value.strip().strip('"').strip("'"))
break
else:
print(default)
PY
}
docker_cli_available() {
[[ "${ODS_SUPPORT_BUNDLE_DISABLE_DOCKER:-}" == "1" ]] && return 1
command -v "$DOCKER_BIN" >/dev/null 2>&1
}
docker_daemon_available() {
docker_cli_available || return 1
if command -v timeout >/dev/null 2>&1; then
timeout 10 "$DOCKER_BIN" info >/dev/null 2>&1
else
"$DOCKER_BIN" info >/dev/null 2>&1
fi
}
docker_compose_available() {
docker_cli_available && "$DOCKER_BIN" compose version >/dev/null 2>&1
}
safe_filename() {
"$PYTHON_CMD" - "$1" <<'PY'
import re
import sys
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", sys.argv[1]).strip("._")
print(name or "container")
PY
}
collect_system_info() {
write_file "system/bash.txt" <<EOF
selected_bash=$BASH_CMD
selected_bash_version=$("$BASH_CMD" -c 'printf "%s\n" "${BASH_VERSION:-unknown}"' 2>/dev/null || printf 'unknown\n')
EOF
collect_shell "system/platform.txt" "platform-summary" '
printf "generated_at_utc="; date -u +"%Y-%m-%dT%H:%M:%SZ"
printf "root_dir=%s\n" "$PWD"
uname -a 2>/dev/null || true
if [[ -r /proc/sys/kernel/osrelease ]] && grep -qi microsoft /proc/sys/kernel/osrelease; then
echo "wsl=true"
elif [[ -r /proc/version ]] && grep -qi microsoft /proc/version; then
echo "wsl=true"
else
echo "wsl=false"
fi
if [[ -f /etc/os-release ]]; then
echo ""
cat /etc/os-release
fi
'
collect_shell "system/resources.txt" "resource-summary" '
echo "Disk:"
df -h . "$HOME" 2>/dev/null || df -h . 2>/dev/null || true
echo ""
echo "Memory:"
if command -v free >/dev/null 2>&1; then
free -h
elif [[ -f /proc/meminfo ]]; then
head -20 /proc/meminfo
else
vm_stat 2>/dev/null || true
fi
'
collect_shell "system/listening-ports.txt" "listening-ports" '
if command -v ss >/dev/null 2>&1; then
ss -ltnp 2>/dev/null || ss -ltn 2>/dev/null || true
elif command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null || true
elif command -v netstat >/dev/null 2>&1; then
netstat -an 2>/dev/null | grep LISTEN || true
else
echo "No supported port listing tool found"
fi
'
collect_shell "system/git.txt" "git-summary" '
git rev-parse --show-toplevel 2>/dev/null || true
git status --short --branch 2>/dev/null || true
git log --oneline --decorate -5 2>/dev/null || true
git remote -v 2>/dev/null || true
'
}
collect_config() {
copy_if_exists "manifest.json" "config/manifest.json"
copy_if_exists ".env.schema.json" "config/env.schema.json"
copy_if_exists ".env.example" "config/env.example"
write_redacted_env
}
collect_diagnostics() {
if [[ -f "$ROOT_DIR/scripts/ods-doctor.sh" ]]; then
collect_shell "diagnostics/ods-doctor.log" "ods-doctor" "$(shell_quote "$BASH_CMD") $(shell_quote "$ROOT_DIR/scripts/ods-doctor.sh") $(shell_quote "$BUNDLE_DIR/diagnostics/ods-doctor.json")"
redact_file "$BUNDLE_DIR/diagnostics/ods-doctor.json"
else
write_file "diagnostics/ods-doctor.log" <<< "scripts/ods-doctor.sh not found"
record_command "ods-doctor" "diagnostics/ods-doctor.log" "127"
fi
if [[ -f "$ROOT_DIR/scripts/audit-extensions.py" ]]; then
collect_shell "diagnostics/extension-audit.json" "extension-audit" "$(shell_quote "$PYTHON_CMD") $(shell_quote "$ROOT_DIR/scripts/audit-extensions.py") --project-dir $(shell_quote "$ROOT_DIR") --json"
else
write_file "diagnostics/extension-audit.json" <<< '{"error":"scripts/audit-extensions.py not found"}'
record_command "extension-audit" "diagnostics/extension-audit.json" "127"
fi
}
collect_compose_validation() {
local gpu_backend
local tier
local gpu_count
local ods_mode
local lemonade_external
local amd_runtime
local amd_managed
local flags_file="$BUNDLE_DIR/validation/compose-flags.txt"
local flags_err="$BUNDLE_DIR/validation/compose-flags.err"
local flags
local resolve_exit
gpu_backend="$(read_env_value GPU_BACKEND nvidia)"
tier="$(read_env_value TIER 1)"
gpu_count="$(read_env_value GPU_COUNT 1)"
ods_mode="$(read_env_value ODS_MODE local)"
lemonade_external="$(read_env_value LEMONADE_EXTERNAL false)"
amd_runtime="$(read_env_value AMD_INFERENCE_RUNTIME "")"
amd_managed="$(read_env_value AMD_INFERENCE_MANAGED "")"
if [[ ! -f "$ROOT_DIR/scripts/resolve-compose-stack.sh" ]]; then
write_file "validation/compose-config.txt" <<< "scripts/resolve-compose-stack.sh not found"
record_command "resolve-compose-stack" "validation/compose-config.txt" "127"
return 0
fi
set +e
flags="$(
cd "$ROOT_DIR" && \
LEMONADE_EXTERNAL="$lemonade_external" \
AMD_INFERENCE_RUNTIME="$amd_runtime" \
AMD_INFERENCE_MANAGED="$amd_managed" \
"$BASH_CMD" scripts/resolve-compose-stack.sh \
--script-dir "$ROOT_DIR" \
--tier "$tier" \
--gpu-backend "$gpu_backend" \
--gpu-count "$gpu_count" \
--ods-mode "$ods_mode" \
--skip-broken \
2> "$flags_err"
)"
resolve_exit=$?
set -e
{
printf 'GPU_BACKEND=%s\n' "$gpu_backend"
printf 'TIER=%s\n' "$tier"
printf 'GPU_COUNT=%s\n' "$gpu_count"
printf 'ODS_MODE=%s\n' "$ods_mode"
printf 'LEMONADE_EXTERNAL=%s\n' "$lemonade_external"
printf 'AMD_INFERENCE_RUNTIME=%s\n' "$amd_runtime"
printf 'AMD_INFERENCE_MANAGED=%s\n' "$amd_managed"
printf 'COMPOSE_FLAGS=%s\n' "$flags"
if [[ -s "$flags_err" ]]; then
echo ""
echo "stderr:"
cat "$flags_err"
fi
} > "$flags_file"
redact_file "$flags_file"
redact_file "$flags_err"
record_command "resolve-compose-stack" "validation/compose-flags.txt" "$resolve_exit"
if [[ "$resolve_exit" -ne 0 ]]; then
write_file "validation/compose-config.txt" <<< "Compose validation skipped because compose flag resolution failed"
record_command "compose-config" "validation/compose-config.txt" "127"
return 0
fi
if [[ "${ODS_SUPPORT_BUNDLE_DISABLE_DOCKER:-}" == "1" ]] || { ! docker_compose_available && ! command -v docker-compose >/dev/null 2>&1; }; then
write_file "validation/compose-config.txt" <<< "Compose validation skipped because docker compose is not available"
record_command "compose-config" "validation/compose-config.txt" "127"
return 0
fi
local cmd
cmd="$(shell_quote "$BASH_CMD") $(shell_quote "$ROOT_DIR/scripts/validate-compose-stack.sh") --compose-flags $(shell_quote "$flags")"
if [[ -f "$ROOT_DIR/.env" ]]; then
cmd="${cmd} --env-file $(shell_quote "$ROOT_DIR/.env")"
fi
collect_shell "validation/compose-config.txt" "compose-config" "$cmd"
}
collect_docker() {
if ! docker_cli_available; then
write_file "docker/unavailable.txt" <<< "Docker CLI not available or disabled"
record_command "docker-version" "docker/unavailable.txt" "127"
return 0
fi
collect_shell "docker/version.txt" "docker-version" "$(shell_quote "$DOCKER_BIN") version"
if ! docker_daemon_available; then
write_file "docker/info.txt" <<< "Docker daemon is not reachable"
record_command "docker-info" "docker/info.txt" "1"
return 0
fi
collect_shell "docker/info.txt" "docker-info" "$(shell_quote "$DOCKER_BIN") info"
collect_shell "docker/ps.txt" "docker-ps" "$(shell_quote "$DOCKER_BIN") ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"
if [[ "$INCLUDE_LOGS" != "true" ]]; then
write_file "logs/skipped.txt" <<< "Docker log collection skipped by --no-logs"
return 0
fi
local names_file="$BUNDLE_DIR/docker/container-names.txt"
local names_exit
set +e
"$DOCKER_BIN" ps --format '{{.Names}}' > "$names_file" 2>&1
names_exit=$?
set -e
redact_file "$names_file"
record_command "docker-container-names" "docker/container-names.txt" "$names_exit"
[[ "$names_exit" -eq 0 ]] || return 0
local count=0
local container
while IFS= read -r container; do
[[ -n "$container" ]] || continue
case "$container" in
ods-*|*ods*)
;;
*)
continue
;;
esac
count=$((count + 1))
[[ "$count" -le "$MAX_LOG_CONTAINERS" ]] || break
local safe
safe="$(safe_filename "$container")"
collect_shell "logs/${safe}.log" "docker-logs:${container}" "$(shell_quote "$DOCKER_BIN") logs --tail ${DEFAULT_LOG_TAIL} $(shell_quote "$container")"
done < "$names_file"
if [[ "$count" -eq 0 ]]; then
write_file "logs/no-ods-containers.txt" <<< "No running ODS-like containers found"
fi
}
write_manifest() {
"$PYTHON_CMD" - "$BUNDLE_DIR" "$ARCHIVE_PATH" "$TOOL_VERSION" "$REDACTION_VERSION" "$INCLUDE_LOGS" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
bundle_dir = Path(sys.argv[1])
archive_path = sys.argv[2]
tool_version = sys.argv[3]
redaction_version = sys.argv[4]
include_logs = sys.argv[5].lower() == "true"
status_path = bundle_dir / "manifest" / "command-status.tsv"
commands = []
if status_path.exists():
for line in status_path.read_text(encoding="utf-8", errors="replace").splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
label, path, exit_code = parts
try:
exit_code_value = int(exit_code)
except ValueError:
exit_code_value = None
commands.append({"label": label, "path": path, "exit_code": exit_code_value})
files = []
for path in sorted(bundle_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(bundle_dir).as_posix()
if rel == "manifest.json":
continue
try:
size = path.stat().st_size
except OSError:
size = None
files.append({"path": rel, "size_bytes": size})
manifest = {
"tool": "ods-support-bundle",
"tool_version": tool_version,
"redaction_version": redaction_version,
"generated_at": datetime.now(timezone.utc).isoformat(),
"archive_path": archive_path,
"logs_included": include_logs,
"files": files,
"commands": commands,
}
(bundle_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
PY
}
write_evidence() {
"$PYTHON_CMD" - "$BUNDLE_DIR" "$ROOT_DIR" <<'PY'
import hashlib
import json
import platform
import re
import shlex
import sys
from datetime import datetime, timezone
from pathlib import Path
bundle_dir = Path(sys.argv[1])
root_dir = Path(sys.argv[2])
status_path = bundle_dir / "manifest" / "command-status.tsv"
secret = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|PASS|SALT|AUTH|CREDENTIAL)", re.I)
def load_json(path):
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8", errors="replace"))
except Exception:
return None
def env_pairs(path):
result = {}
if not path.exists():
return result
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if key.startswith("export "):
key = key[7:].strip()
result[key] = value.strip().strip('"').strip("'")
return result
def sha256_file(path):
if not path.exists() or not path.is_file():
return None
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def command_statuses():
commands = []
if status_path.exists():
for line in status_path.read_text(encoding="utf-8", errors="replace").splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
label, path, exit_code = parts
try:
exit_code_value = int(exit_code)
except ValueError:
exit_code_value = None
commands.append({"label": label, "path": path, "exit_code": exit_code_value})
return commands
def is_wsl():
for candidate in (Path("/proc/sys/kernel/osrelease"), Path("/proc/version")):
try:
if "microsoft" in candidate.read_text(encoding="utf-8", errors="replace").lower():
return True
except OSError:
continue
return False
def parse_compose_flags():
path = bundle_dir / "validation" / "compose-flags.txt"
result = {"raw": "", "files": []}
if not path.exists():
return result
text = path.read_text(encoding="utf-8", errors="replace")
for line in text.splitlines():
if line.startswith("COMPOSE_FLAGS="):
raw = line.split("=", 1)[1].strip()
result["raw"] = raw
try:
parts = shlex.split(raw)
except ValueError:
parts = raw.split()
files = []
idx = 0
while idx < len(parts):
part = parts[idx]
if part in {"-f", "--file"} and idx + 1 < len(parts):
files.append(parts[idx + 1])
idx += 2
continue
if part.startswith("--file="):
files.append(part.split("=", 1)[1])
idx += 1
result["files"] = files
break
return result
manifest = load_json(root_dir / "manifest.json") or {}
doctor = load_json(bundle_dir / "diagnostics" / "ods-doctor.json") or {}
extension_audit = load_json(bundle_dir / "diagnostics" / "extension-audit.json") or {}
env = env_pairs(root_dir / ".env")
public_env_keys = {
key: {
"present": True,
"redacted": bool(secret.search(key)),
"value": None if secret.search(key) else value,
}
for key, value in sorted(env.items())
}
config_hash_targets = [
"manifest.json",
".env.schema.json",
"config/ports.json",
"config/golden-paths.json",
"config/generated-config-contracts.json",
"config/litellm/lemonade.yaml",
"extensions/services/hermes/cli-config.yaml.template",
]
evidence = {
"version": "1",
"generated_at": datetime.now(timezone.utc).isoformat(),
"tool": "ods-support-bundle",
"platform": {
"system": platform.system(),
"release": platform.release(),
"machine": platform.machine(),
"python": platform.python_version(),
"wsl": is_wsl(),
},
"ods": {
"version": manifest.get("ods_version") or manifest.get("release", {}).get("version"),
"release": manifest.get("release", {}),
},
"backend": {
"ods_mode": env.get("ODS_MODE"),
"gpu_backend": env.get("GPU_BACKEND"),
"gpu_count": env.get("GPU_COUNT"),
"llm_backend": env.get("LLM_BACKEND"),
"llm_model": env.get("LLM_MODEL"),
},
"inference_contract": doctor.get("runtime", {}).get("inference_contract", {}),
"env_keys": public_env_keys,
"compose": parse_compose_flags(),
"doctor_summary": doctor.get("summary", {}),
"extension_audit_summary": extension_audit.get("summary", {}),
"config_hashes": {
target: sha256_file(root_dir / target)
for target in config_hash_targets
if (root_dir / target).exists()
},
"commands": command_statuses(),
}
(bundle_dir / "manifest" / "evidence.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
redact_file "$BUNDLE_DIR/manifest/evidence.json"
}
write_summary_json() {
"$PYTHON_CMD" - "$BUNDLE_DIR" "$ARCHIVE_PATH" <<'PY'
import json
import sys
from pathlib import Path
bundle_dir = Path(sys.argv[1])
archive_path = Path(sys.argv[2])
manifest = bundle_dir / "manifest.json"
payload = {
"bundle_dir": bundle_dir.as_posix(),
"archive": archive_path.as_posix(),
"manifest": manifest.as_posix(),
"archive_exists": archive_path.exists(),
"archive_size_bytes": archive_path.stat().st_size if archive_path.exists() else None,
}
print(json.dumps(payload, indent=2))
PY
}
collect_system_info
collect_config
collect_diagnostics
collect_compose_validation
collect_docker
write_evidence
write_manifest
tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" "$BUNDLE_NAME"
if [[ "$JSON_OUTPUT" == "true" ]]; then
write_summary_json
else
echo "ODS support bundle created:"
echo " Directory: $BUNDLE_DIR"
echo " Archive: $ARCHIVE_PATH"
echo ""
echo "Review the archive before sharing it publicly."
fi
+303
View File
@@ -0,0 +1,303 @@
#!/bin/bash
#=============================================================================
# ods-test-functional.sh - Functional Testing for ODS
#
# Tests actual functionality, not just port availability:
# - LLM (llama-server) generates coherent text
# - Whisper transcribes actual audio
# - TTS generates valid audio files
# - Embeddings produce vectors
#
# This complements ods-test.sh which checks service health.
#=============================================================================
# Require Bash 4+ (associative arrays used for service-port lookup)
if (( BASH_VERSINFO[0] < 4 )); then
echo "ERROR: $(basename "$0") requires Bash 4.0+ (you have $BASH_VERSION)" >&2
echo " macOS ships Bash 3.2 due to licensing. Install a modern version:" >&2
echo " brew install bash" >&2
exit 1
fi
set -euo pipefail
# Colors
RED='\e[0;31m'
GREEN='\e[0;32m'
YELLOW='\e[1;33m'
NC='\e[0m'
# Source service registry for port resolution
_FT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$_FT_DIR/lib/service-registry.sh" ]]; then
export SCRIPT_DIR="$_FT_DIR"
. "$_FT_DIR/lib/service-registry.sh"
sr_load
[[ -f "$_FT_DIR/lib/safe-env.sh" ]] && . "$_FT_DIR/lib/safe-env.sh"
load_env_file "$_FT_DIR/.env"
sr_resolve_ports
fi
# Ensure SERVICE_PORTS is declared even if service-registry.sh was not sourced
declare -A SERVICE_PORTS
# Service endpoints — resolved from registry
LLM_URL="${LLM_URL:-http://localhost:${SERVICE_PORTS[llama-server]:-11434}}"
WHISPER_URL="${WHISPER_URL:-http://localhost:${SERVICE_PORTS[whisper]:-9000}}"
TTS_URL="${TTS_URL:-http://localhost:${SERVICE_PORTS[tts]:-8880}}"
EMBEDDING_URL="${EMBEDDING_URL:-http://localhost:${SERVICE_PORTS[embeddings]:-9103}}"
# Test tracking
TESTS_PASSED=0
TESTS_FAILED=0
pass() {
echo -e "${GREEN}${NC} $1"
# Arithmetic expansion (not `((...))`) — the compound form returns
# exit 1 when the pre-increment value is 0, which would trip `set -e`
# on the first pass/fail and abort the script before any summary.
TESTS_PASSED=$((TESTS_PASSED + 1))
}
fail() {
echo -e "${RED}${NC} $1"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
warn() {
echo -e "${YELLOW}${NC} $1"
}
# Test 1: LLM generates coherent text
test_llm_functional() {
echo ""
echo "> Testing LLM Functional Generation"
# The grep-based extraction pipelines in this script may legitimately
# produce zero matches (LLM/TTS/embeddings/whisper offline or returning
# unexpected payload). Under `set -euo pipefail` such a no-match would
# abort the script before the `[[ -z ... ]]` guard below can treat it
# as a test failure. Using `if ! VAR=$(...)` keeps the set -e safety
# net engaged everywhere else; `set -e` is disabled only for the
# evaluation of the condition (per bash spec), so a failed pipeline
# leaves VAR empty and the explicit `fail` path below runs.
local model_id=""
if ! model_id=$(curl -s --max-time 10 "$LLM_URL/v1/models" 2>/dev/null | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4); then
model_id=""
fi
model_id="${model_id:-local}"
local prompt="What is 2+2? Answer with just the number."
local payload="{\"model\": \"$model_id\", \"messages\": [{\"role\": \"user\", \"content\": \"$prompt\"}], \"max_tokens\": 10, \"temperature\": 0.1}"
local response
response=$(curl -s --max-time 30 \
-X POST "$LLM_URL/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null || echo "")
if [[ -z "$response" ]]; then
fail "LLM returned no response"
return 1
fi
local content=""
if ! content=$(echo "$response" | grep -oE '"content":[[:space:]]*"[^"]+"' | head -1 | cut -d'"' -f4); then
content=""
fi
if [[ -z "$content" ]]; then
fail "LLM returned empty content"
return 1
fi
# Check if response contains "4" (the answer to 2+2)
if echo "$content" | grep -q "4"; then
pass "LLM generates correct answer: '$content'"
else
warn "LLM generated: '$content' (expected '4')"
pass "LLM generates text (answer may vary)"
fi
}
# Test 2: TTS generates valid audio file
test_tts_functional() {
echo ""
echo "> Testing TTS Audio Generation"
local test_text="Hello, this is a test."
local output_file="/tmp/test_tts_output.wav"
local payload="{\"model\": \"kokoro\", \"input\": \"$test_text\", \"voice\": \"af_bella\", \"response_format\": \"wav\"}"
# Generate audio
local http_code
http_code=$(curl -s -w "%{http_code}" --max-time 30 \
-X POST "$TTS_URL/v1/audio/speech" \
-H "Content-Type: application/json" \
-d "$payload" \
-o "$output_file" 2>/dev/null)
if [[ "$http_code" != "200" ]]; then
fail "TTS returned HTTP $http_code"
rm -f "$output_file"
return 1
fi
# Check file exists and has content
if [[ ! -f "$output_file" ]]; then
fail "TTS did not create output file"
return 1
fi
local file_size
file_size=$(stat -c%s "$output_file" 2>/dev/null || stat -f%z "$output_file" 2>/dev/null || echo "0")
if [[ "$file_size" -lt 1000 ]]; then
fail "TTS output too small: $file_size bytes (expected >1KB)"
rm -f "$output_file"
return 1
fi
# Check it's a valid WAV file
if ! file "$output_file" | grep -qiE "audio|wav|riff"; then
warn "TTS output may not be valid WAV: $(file "$output_file")"
pass "TTS generates audio file ($file_size bytes)"
else
pass "TTS generates valid WAV audio ($file_size bytes)"
fi
rm -f "$output_file"
}
# Test 3: Embeddings produce vectors
test_embeddings_functional() {
echo ""
echo "> Testing Embeddings Vector Generation"
local test_text="This is a test sentence for embeddings."
local payload="{\"inputs\": \"$test_text\"}"
local response
response=$(curl -s --max-time 30 \
-X POST "$EMBEDDING_URL/embed" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null || echo "")
if [[ -z "$response" ]]; then
# Try alternate endpoint
response=$(curl -s --max-time 30 \
-X POST "$EMBEDDING_URL/" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null || echo "")
fi
if [[ -z "$response" ]]; then
fail "Embeddings returned no response"
return 1
fi
# Check if response contains array of numbers
if echo "$response" | grep -qE '\[\s*-?[0-9]+\.[0-9]+'; then
local vector_len=0
if ! vector_len=$(echo "$response" | grep -oE '-?[0-9]+\.[0-9]+' | wc -l); then
vector_len=0
fi
pass "Embeddings generates vectors ($vector_len dimensions)"
else
fail "Embeddings did not return valid vectors"
return 1
fi
}
# Test 4: Whisper transcribes audio (if test audio available)
test_whisper_functional() {
echo ""
echo "> Testing Whisper Transcription"
# Create a simple test audio file or use existing
local test_audio="/tmp/test_audio.wav"
# Try to generate test audio with TTS first
local tts_payload='{"model": "kokoro", "input": "Hello world", "voice": "af_bella", "response_format": "wav"}'
if ! curl -s --max-time 15 \
-X POST "$TTS_URL/v1/audio/speech" \
-H "Content-Type: application/json" \
-d "$tts_payload" \
-o "$test_audio" 2>/dev/null; then
warn "Could not generate test audio for Whisper"
warn "Skipping Whisper functional test (TTS dependency)"
return 0
fi
if [[ ! -f "$test_audio" ]] || [[ "$(stat -c%s "$test_audio" 2>/dev/null || stat -f%z "$test_audio" 2>/dev/null || echo 0)" -lt 1000 ]]; then
warn "Test audio generation failed"
return 0
fi
# Transcribe with Whisper
local response
response=$(curl -s --max-time 30 \
-X POST "$WHISPER_URL/v1/audio/transcriptions" \
-H "Content-Type: multipart/form-data" \
-F "file=@$test_audio" \
-F "model=whisper-1" 2>/dev/null || echo "")
rm -f "$test_audio"
if [[ -z "$response" ]]; then
fail "Whisper returned no response"
return 1
fi
local transcription=""
if ! transcription=$(echo "$response" | grep -oE '"text":[[:space:]]*"[^"]+"' | head -1 | cut -d'"' -f4); then
transcription=""
fi
if [[ -z "$transcription" ]]; then
fail "Whisper returned empty transcription"
return 1
fi
if echo "$transcription" | grep -qiE "hello|world"; then
pass "Whisper transcribes correctly: '$transcription'"
else
warn "Whisper transcribed: '$transcription'"
pass "Whisper generates transcription"
fi
}
# Main
echo "========================================"
echo " ODS - FUNCTIONAL TESTS"
echo " Tests actual functionality, not ports"
echo "========================================"
# Each test returns 1 on failure via its internal `fail` call; we must not
# let `set -e` short-circuit the remaining tests or skip the summary. The
# TESTS_PASSED / TESTS_FAILED counters are updated inside pass/fail and
# drive the final exit code below, so suspending strict mode for exactly
# this block is the explicit expression of "errors here are accounted for."
# (CLAUDE.md forbids `|| true` / silent swallow; this bounded toggle makes
# the intent visible instead of hiding it behind a trailing `|| true`.)
set +e
test_llm_functional
test_tts_functional
test_embeddings_functional
test_whisper_functional
set -e
echo ""
echo "========================================"
echo " Results: $TESTS_PASSED passed, $TESTS_FAILED failed"
echo "========================================"
if [[ $TESTS_FAILED -eq 0 ]]; then
echo -e "${GREEN}✓ All functional tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some functional tests failed${NC}"
exit 1
fi
+751
View File
@@ -0,0 +1,751 @@
#!/bin/bash
#=============================================================================
# ods-test.sh - ODS Validation Suite (M8)
#
# Mission M8: Agent Bench Testing Systems
# Validates all critical paths: LLM, STT, TTS, embeddings, tool calling,
# voice round-trip. Clear pass/fail with actionable errors.
#
# Target: <2 minutes runtime
# Pass criteria: All critical tests pass
#
# Usage:
# ./ods-test.sh # Run all tests
# ./ods-test.sh --quick # Fast mode (~30s, no inference)
# ./ods-test.sh --json # JSON output for automation
# ./ods-test.sh --service llm # Test specific service
#
# Exit codes:
# 0 - All critical tests passed
# 1 - One or more tests failed
# 2 - Configuration error
#=============================================================================
set -euo pipefail
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ODS_DIR="${ODS_DIR:-$HOME/.ods}"
ENV_FILE="${ENV_FILE:-$ODS_DIR/.env}"
TIMEOUT=15
QUICK_TIMEOUT=5
# Source service registry for port resolution
_DT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$_DT_DIR/lib/service-registry.sh" ]]; then
export SCRIPT_DIR="$_DT_DIR"
. "$_DT_DIR/lib/service-registry.sh"
sr_load
[[ -f "$_DT_DIR/lib/safe-env.sh" ]] && . "$_DT_DIR/lib/safe-env.sh"
load_env_file "$_DT_DIR/.env"
sr_resolve_ports
fi
# Service endpoints — resolved from registry
LLM_HOST="${LLM_HOST:-localhost}"
LLM_PORT="${LLM_PORT:-${SERVICE_PORTS[llama-server]:-11434}}"
LLM_URL="http://${LLM_HOST}:${LLM_PORT}"
WHISPER_HOST="${WHISPER_HOST:-localhost}"
WHISPER_PORT="${WHISPER_PORT:-${SERVICE_PORTS[whisper]:-9000}}"
TTS_HOST="${TTS_HOST:-localhost}"
TTS_PORT="${TTS_PORT:-${SERVICE_PORTS[tts]:-8880}}"
EMBEDDING_HOST="${EMBEDDING_HOST:-localhost}"
EMBEDDING_PORT="${EMBEDDING_PORT:-${SERVICE_PORTS[embeddings]:-9103}}"
LIVEKIT_HOST="${LIVEKIT_HOST:-localhost}"
LIVEKIT_PORT="${LIVEKIT_PORT:-7880}"
PRIVACY_SHIELD_PORT="${PRIVACY_SHIELD_PORT:-${SERVICE_PORTS[privacy-shield]:-8085}}"
# Colors (ANSI escape sequences)
RED='\e[0;31m'
GREEN='\e[0;32m'
YELLOW='\e[1;33m'
BLUE='\e[0;34m'
CYAN='\e[0;36m'
NC='\e[0m'
BOLD='\e[1m'
# Test tracking
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
SKIPPED_TESTS=0
START_TIME=0
# Mode flags
JSON_OUTPUT=false
QUICK_MODE=false
SPECIFIC_SERVICE=""
VERBOSE=false
# Results storage
RESULTS_STATUS=()
RESULTS_NAMES=()
RESULTS_DETAILS=()
#--------------------------------------------------------------------------
# Utility Functions
#--------------------------------------------------------------------------
load_env() {
[[ -f "$_DT_DIR/lib/safe-env.sh" ]] && . "$_DT_DIR/lib/safe-env.sh"
load_env_file "$ENV_FILE"
}
log() {
[[ "$VERBOSE" == "true" ]] && echo "$@" >&2
}
# Portable millisecond timestamp (macOS BSD date lacks %N)
_now_ms() {
python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || echo "$(date +%s)000"
}
print_header() {
if [[ "$JSON_OUTPUT" != "true" ]]; then
echo ""
echo "========================================"
echo " ODS - VALIDATION SUITE"
echo " Mission M8: Critical Path Testing"
echo "========================================"
echo ""
fi
}
record_result() {
local name="$1"
local status="$2"
local details="${3:-}"
RESULTS_NAMES+=("$name")
RESULTS_STATUS+=("$status")
RESULTS_DETAILS+=("$details")
TOTAL_TESTS=$((TOTAL_TESTS + 1))
case "$status" in
pass) PASSED_TESTS=$((PASSED_TESTS + 1)) ;;
fail) FAILED_TESTS=$((FAILED_TESTS + 1)) ;;
skip) SKIPPED_TESTS=$((SKIPPED_TESTS + 1)) ;;
esac
}
print_test() {
local name="$1"
local status="$2"
local details="${3:-}"
if [[ "$JSON_OUTPUT" != "true" ]]; then
local icon
case "$status" in
pass) icon="OK" ;;
fail) icon="FAIL" ;;
skip) icon="SKIP" ;;
esac
printf " %-40s [%s]" "$name" "$icon"
[[ -n "$details" ]] && printf " %s" "$details"
echo ""
fi
}
#--------------------------------------------------------------------------
# Core Test Functions
#--------------------------------------------------------------------------
test_http() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local payload="${5:-}"
local custom_timeout="${6:-$TIMEOUT}"
local response_code
local start_time end_time duration_ms
start_time=$(_now_ms)
if [[ -n "$payload" && "$method" == "POST" ]]; then
response_code=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time "$custom_timeout" \
-X POST "$url" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null || echo "000")
else
response_code=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time "$custom_timeout" \
"$url" 2>/dev/null || echo "000")
fi
end_time=$(_now_ms)
duration_ms=$(( end_time - start_time ))
if [[ "$response_code" == "$expected" ]]; then
record_result "$name" "pass" "${duration_ms}ms"
print_test "$name" "pass" "${duration_ms}ms"
return 0
else
record_result "$name" "fail" "HTTP $response_code"
print_test "$name" "fail" "HTTP $response_code"
return 1
fi
}
test_tcp() {
local name="$1"
local host="$2"
local port="$3"
if timeout "$TIMEOUT" bash -c "cat < /dev/null > /dev/tcp/$host/$port" 2>/dev/null; then
record_result "$name" "pass"
print_test "$name" "pass"
return 0
else
record_result "$name" "fail" "port $port unreachable"
print_test "$name" "fail" "port closed"
return 1
fi
}
#--------------------------------------------------------------------------
# Service Test Suites
#--------------------------------------------------------------------------
test_docker() {
echo ""
echo "> Docker Infrastructure"
if ! command -v docker &>/dev/null; then
record_result "Docker Available" "fail" "docker not installed"
print_test "Docker Available" "fail" "not installed"
return 0
fi
record_result "Docker Available" "pass"
print_test "Docker Available" "pass"
if ! timeout 10 docker info &>/dev/null; then
record_result "Docker Daemon" "fail" "daemon not running"
print_test "Docker Daemon" "fail" "not running"
return 0
fi
record_result "Docker Daemon" "pass"
print_test "Docker Daemon" "pass"
local running_count
running_count=$(timeout 10 docker ps --format '{{.Names}}' 2>/dev/null | wc -l)
record_result "Running Containers" "pass" "$running_count containers"
print_test "Running Containers" "pass" "$running_count containers"
}
test_gpu() {
echo ""
echo "> GPU Resources"
if ! command -v nvidia-smi &>/dev/null; then
record_result "NVIDIA GPU" "skip" "nvidia-smi not found"
print_test "NVIDIA GPU" "skip"
return 0
fi
local gpu_info
gpu_info=$(timeout 10 nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu --format=csv,noheader 2>/dev/null | head -1)
if [[ -n "$gpu_info" ]]; then
local name mem_used mem_total util mem_pct
name=$(echo "$gpu_info" | cut -d',' -f1 | xargs)
mem_used=$(echo "$gpu_info" | cut -d',' -f2 | xargs | cut -d' ' -f1)
mem_total=$(echo "$gpu_info" | cut -d',' -f3 | xargs | cut -d' ' -f1)
mem_pct=$(( mem_used * 100 / mem_total ))
record_result "GPU Available" "pass" "$name"
print_test "GPU Available" "pass" "$name"
if [[ $mem_pct -gt 90 ]]; then
record_result "GPU Memory" "fail" "${mem_pct}% used - critical"
print_test "GPU Memory" "fail" "${mem_pct}% used"
else
record_result "GPU Memory" "pass" "${mem_pct}% used"
print_test "GPU Memory" "pass" "${mem_pct}% used"
fi
else
record_result "NVIDIA GPU" "fail" "no GPU detected"
print_test "NVIDIA GPU" "fail" "not detected"
fi
}
test_llm() {
echo ""
echo "> LLM Inference (llama-server)"
test_http "LLM Health" "$LLM_URL/health" "200" || return 1
test_http "LLM Models API" "$LLM_URL/v1/models" "200"
if [[ "$QUICK_MODE" == "true" ]]; then
record_result "LLM Inference" "skip" "quick mode"
print_test "LLM Inference" "skip"
return 0
fi
local model_id
model_id=$(curl -s --max-time 10 "$LLM_URL/v1/models" 2>/dev/null | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
model_id="${model_id:-local}"
local payload="{\"model\": \"$model_id\", \"messages\": [{\"role\": \"user\", \"content\": \"Say hello\"}], \"max_tokens\": 10}"
local response
response=$(curl -s --max-time 30 \
-X POST "$LLM_URL/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null)
if echo "$response" | grep -q '"content"'; then
local tokens_used
tokens_used=$(echo "$response" | grep -o '"total_tokens":[0-9]*' | cut -d: -f2)
record_result "LLM Inference" "pass" "${tokens_used} tokens"
print_test "LLM Inference" "pass" "${tokens_used} tokens"
else
record_result "LLM Inference" "fail" "no content in response"
print_test "LLM Inference" "fail"
return 1
fi
}
test_tool_calling() {
echo ""
echo "> Tool Calling M8 Critical"
if [[ "$QUICK_MODE" == "true" ]]; then
record_result "Tool Calling" "skip" "quick mode"
print_test "Tool Calling" "skip"
return 0
fi
local tools='[{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}'
local payload="{\"model\": \"Qwen/Qwen2.5-32B-Instruct-AWQ\", \"messages\": [{\"role\": \"user\", \"content\": \"What is the weather in Tokyo?\"}], \"tools\": $tools, \"tool_choice\": \"auto\", \"max_tokens\": 100}"
local response
response=$(curl -s --max-time 30 \
-X POST "$LLM_URL/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null)
if echo "$response" | grep -q '"tool_calls"'; then
record_result "Tool Calling" "pass" "function called"
print_test "Tool Calling" "pass" "function called"
else
record_result "Tool Calling" "fail" "no tool_calls in response"
print_test "Tool Calling" "fail" "no tool call"
fi
}
test_whisper() {
echo ""
echo "> Whisper Speech-to-Text"
test_tcp "Whisper Port" "$WHISPER_HOST" "$WHISPER_PORT"
local health_url="http://${WHISPER_HOST}:${WHISPER_PORT}/health"
local response
response=$(curl -s --max-time "$TIMEOUT" "$health_url" 2>/dev/null || echo "")
if [[ -n "$response" ]]; then
if echo "$response" | grep -qiE "ok|healthy|ready"; then
record_result "Whisper Health" "pass"
print_test "Whisper Health" "pass"
else
record_result "Whisper Health" "pass" "responding"
print_test "Whisper Health" "pass" "responding"
fi
else
whisper_http_exit=0
test_http "Whisper HTTP" "http://${WHISPER_HOST}:${WHISPER_PORT}/" "200" || whisper_http_exit=$?
[[ $whisper_http_exit -ne 0 ]] && log "Whisper HTTP check failed (exit $whisper_http_exit)"
fi
}
test_tts() {
echo ""
echo "> Kokoro TTS Text-to-Speech"
test_tcp "TTS Port" "$TTS_HOST" "$TTS_PORT"
local voices_url="http://${TTS_HOST}:${TTS_PORT}/v1/audio/voices"
local response
response=$(curl -s --max-time "$TIMEOUT" "$voices_url" 2>/dev/null || echo "")
if echo "$response" | grep -q '"voices"'; then
local voice_count
voice_count=$(echo "$response" | grep -o '"voice_id"' | wc -l)
record_result "TTS Voices" "pass" "$voice_count voices"
print_test "TTS Voices" "pass" "$voice_count voices"
else
tts_api_exit=0
test_http "TTS API" "http://${TTS_HOST}:${TTS_PORT}/" "200" || tts_api_exit=$?
[[ $tts_api_exit -ne 0 ]] && log "TTS API check failed (exit $tts_api_exit)"
fi
}
test_embeddings() {
echo ""
echo "> Embeddings TEI"
test_tcp "Embeddings Port" "$EMBEDDING_HOST" "$EMBEDDING_PORT"
local health_url="http://${EMBEDDING_HOST}:${EMBEDDING_PORT}/health"
local response
response=$(curl -s --max-time "$TIMEOUT" "$health_url" 2>/dev/null || echo "")
if echo "$response" | grep -q "ok"; then
record_result "Embeddings Health" "pass"
print_test "Embeddings Health" "pass"
else
local payload='{"inputs": "test sentence"}'
embeddings_api_exit=0
test_http "Embeddings API" "http://${EMBEDDING_HOST}:${EMBEDDING_PORT}/embed" "200" "POST" "$payload" || embeddings_api_exit=$?
[[ $embeddings_api_exit -ne 0 ]] && log "Embeddings API check failed (exit $embeddings_api_exit)"
fi
}
test_voice_roundtrip() {
echo ""
echo "> Voice Round-Trip M8 Critical"
if [[ "$QUICK_MODE" == "true" ]]; then
record_result "Voice Round-Trip" "skip" "quick mode"
print_test "Voice Round-Trip" "skip"
return 0
fi
local whisper_ready=false
local tts_ready=false
local llm_ready=false
if curl -s --max-time 5 "http://${WHISPER_HOST}:${WHISPER_PORT}/health" &>/dev/null; then
whisper_ready=true
fi
if curl -s --max-time 5 "http://${TTS_HOST}:${TTS_PORT}/v1/audio/voices" &>/dev/null; then
tts_ready=true
fi
if curl -s --max-time 5 "$LLM_URL/health" &>/dev/null; then
llm_ready=true
fi
if [[ "$whisper_ready" != "true" ]]; then
record_result "Voice Round-Trip" "skip" "Whisper unavailable"
print_test "Voice Round-Trip" "skip" "Whisper unavailable"
return 0
fi
if [[ "$tts_ready" != "true" ]]; then
record_result "Voice Round-Trip" "skip" "TTS unavailable"
print_test "Voice Round-Trip" "skip" "TTS unavailable"
return 0
fi
if [[ "$llm_ready" != "true" ]]; then
record_result "Voice Round-Trip" "skip" "LLM unavailable"
print_test "Voice Round-Trip" "skip" "LLM unavailable"
return 0
fi
local start_time end_time duration_ms
start_time=$(_now_ms)
local llm_payload='{"model": "Qwen/Qwen2.5-32B-Instruct-AWQ", "messages": [{"role": "user", "content": "What is the weather today?"}], "max_tokens": 50}'
local llm_response
llm_response=$(curl -s --max-time 15 \
-X POST "$LLM_URL/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$llm_payload" 2>/dev/null)
if ! echo "$llm_response" | grep -q '"content"'; then
record_result "Voice Round-Trip" "fail" "LLM step failed"
print_test "Voice Round-Trip" "fail" "LLM failed"
return 1
fi
local tts_text="The weather today is sunny and 75 degrees."
local tts_payload="{\"model\": \"kokoro\", \"input\": \"$tts_text\", \"voice\": \"af_bella\"}"
local tts_response
tts_response=$(curl -s --max-time 15 \
-X POST "http://${TTS_HOST}:${TTS_PORT}/v1/audio/speech" \
-H "Content-Type: application/json" \
-d "$tts_payload" 2>/dev/null)
end_time=$(_now_ms)
duration_ms=$(( end_time - start_time ))
if [[ -n "$tts_response" ]] && [[ ${#tts_response} -gt 100 ]]; then
record_result "Voice Round-Trip" "pass" "${duration_ms}ms"
print_test "Voice Round-Trip" "pass" "${duration_ms}ms"
else
record_result "Voice Round-Trip" "fail" "TTS step failed"
print_test "Voice Round-Trip" "fail" "TTS failed"
fi
}
test_privacy_shield() {
echo ""
echo "> Privacy Shield M3"
local shield_url="http://localhost:${PRIVACY_SHIELD_PORT}"
test_http "Privacy Shield Health" "$shield_url/health" "200" || return 0
if [[ "$QUICK_MODE" == "true" ]]; then
record_result "Privacy Shield Proxy" "skip" "quick mode"
print_test "Privacy Shield Proxy" "skip"
return 0
fi
local payload='{"model": "test", "messages": [{"role": "user", "content": "My email is john@example.com"}], "max_tokens": 10}'
local response
response=$(curl -s --max-time 10 \
-X POST "$shield_url/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null || echo "")
if [[ -n "$response" ]]; then
record_result "Privacy Shield Proxy" "pass" "PII scrubbing active"
print_test "Privacy Shield Proxy" "pass" "PII scrubbing"
else
record_result "Privacy Shield Proxy" "fail" "no response"
print_test "Privacy Shield Proxy" "fail"
fi
}
test_livekit() {
echo ""
echo "> LiveKit Voice Infrastructure"
test_tcp "LiveKit Port" "$LIVEKIT_HOST" "$LIVEKIT_PORT"
livekit_health_exit=0
test_http "LiveKit Health" "http://${LIVEKIT_HOST}:${LIVEKIT_PORT}/" "200" || livekit_health_exit=$?
[[ $livekit_health_exit -ne 0 ]] && log "LiveKit health check failed (exit $livekit_health_exit)"
}
#--------------------------------------------------------------------------
# Summary and Output
#--------------------------------------------------------------------------
print_summary() {
local end_time elapsed_secs
end_time=$(date +%s)
elapsed_secs=$((end_time - START_TIME))
if [[ "$JSON_OUTPUT" == "true" ]]; then
_print_json_summary "$elapsed_secs"
else
_print_text_summary "$elapsed_secs"
fi
}
_print_json_summary() {
local elapsed="$1"
echo "{"
echo " \"timestamp\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\","
echo " \"runtime_seconds\": $elapsed,"
echo " \"summary\": {"
echo " \"total\": $TOTAL_TESTS,"
echo " \"passed\": $PASSED_TESTS,"
echo " \"failed\": $FAILED_TESTS,"
echo " \"skipped\": $SKIPPED_TESTS,"
echo " \"success\": $([ $FAILED_TESTS -eq 0 ] && echo 'true' || echo 'false')"
echo " },"
echo " \"results\": ["
local first=true
local i
for i in "${!RESULTS_NAMES[@]}"; do
[[ "$first" == "true" ]] || echo ","
printf ' {"name": "%s", "status": "%s", "details": "%s"}' \
"${RESULTS_NAMES[$i]}" "${RESULTS_STATUS[$i]}" "${RESULTS_DETAILS[$i]:-}"
first=false
done
echo ""
echo " ]"
echo "}"
}
_print_text_summary() {
local elapsed="$1"
echo ""
echo "========================================"
echo ""
if [[ $FAILED_TESTS -eq 0 ]]; then
echo -e " ${GREEN}ALL TESTS PASSED${NC}"
echo ""
echo " Passed: $PASSED_TESTS | Skipped: $SKIPPED_TESTS"
echo " Runtime: ${elapsed}s"
echo ""
echo -e " ${BOLD}ODS is ready!${NC}"
else
echo -e " ${RED}SOME TESTS FAILED${NC}"
echo ""
echo -e " Passed: ${GREEN}${PASSED_TESTS}${NC} | Failed: ${RED}${FAILED_TESTS}${NC} | Skipped: ${YELLOW}${SKIPPED_TESTS}${NC}"
echo " Runtime: ${elapsed}s"
echo ""
echo " Failed tests:"
local i
for i in "${!RESULTS_NAMES[@]}"; do
if [[ "${RESULTS_STATUS[$i]}" == "fail" ]]; then
echo " - ${RESULTS_NAMES[$i]}"
fi
done
echo ""
echo "Actionable fixes:"
if [[ "${RESULTS_STATUS[0]:-}" == "fail" ]] && [[ "${RESULTS_NAMES[0]:-}" == *"LLM"* ]]; then
echo " - LLM not responding - check: docker logs ods-llama-server"
fi
local i
for i in "${!RESULTS_NAMES[@]}"; do
if [[ "${RESULTS_STATUS[$i]}" == "fail" ]]; then
case "${RESULTS_NAMES[$i]}" in
"Tool Calling") echo " - Tool calling failed - check llama-server tool support" ;;
"Whisper Port") echo " - Whisper not running - start: docker compose up whisper" ;;
"TTS Port") echo " - TTS not running - start: docker compose up kokoro-tts" ;;
esac
fi
done
fi
echo ""
}
#--------------------------------------------------------------------------
# Main
#--------------------------------------------------------------------------
show_help() {
cat << 'EOF'
ODS Validation Suite (M8)
USAGE:
ods-test.sh [OPTIONS]
OPTIONS:
--quick, -q Fast mode (~30s, no inference tests)
--json, -j Output results as JSON
--service, -s Test specific service only
--verbose, -v Show detailed output
--help, -h Show this help
SERVICES:
docker, gpu, llm, tool-calling, whisper, tts,
embeddings, voice-roundtrip, privacy-shield, livekit
EXAMPLES:
ods-test.sh # Run all tests
ods-test.sh --quick # Fast health check
ods-test.sh --json > results.json
ods-test.sh --service llm # Test LLM only
EXIT CODES:
0 - All tests passed
1 - One or more tests failed
2 - Configuration error
EOF
}
run_all_tests() {
print_header
test_docker
test_gpu
test_llm
test_tool_calling
test_whisper
test_tts
test_embeddings
test_voice_roundtrip
test_privacy_shield
test_livekit
}
run_specific_service() {
local service="$1"
print_header
case "$service" in
docker) test_docker ;;
gpu) test_gpu ;;
llm) test_llm ;;
tool-calling) test_tool_calling ;;
whisper) test_whisper ;;
tts) test_tts ;;
embeddings) test_embeddings ;;
voice-roundtrip) test_voice_roundtrip ;;
privacy-shield) test_privacy_shield ;;
livekit) test_livekit ;;
*)
echo "Unknown service: $service" >&2
echo "Available: docker, gpu, llm, tool-calling, whisper, tts, embeddings, voice-roundtrip, privacy-shield, livekit" >&2
exit 2
;;
esac
}
main() {
START_TIME=$(date +%s)
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--json|-j)
JSON_OUTPUT=true
shift
;;
--quick|-q)
QUICK_MODE=true
TIMEOUT=$QUICK_TIMEOUT
shift
;;
--service|-s)
SPECIFIC_SERVICE="$2"
shift 2
;;
--verbose|-v)
VERBOSE=true
shift
;;
--help|-h)
show_help
exit 0
;;
*)
echo "Unknown option: $1" >&2
show_help
exit 2
;;
esac
done
# Load environment
load_env
# Run tests
if [[ -n "$SPECIFIC_SERVICE" ]]; then
run_specific_service "$SPECIFIC_SERVICE"
else
run_all_tests
fi
# Print summary
print_summary
# Exit with appropriate code
if [[ $FAILED_TESTS -eq 0 ]]; then
exit 0
else
exit 1
fi
}
main "$@"
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Patch ODS's Hermes config without clobbering user sections.
Hermes copies extensions/services/hermes/cli-config.yaml.template to
data/hermes/config.yaml only on first start. Installer migrations need to
repair the small set of ODS-managed defaults in both files while preserving
the rest of the user's config.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
def _top_level_block(lines: list[str], name: str) -> tuple[int, int] | None:
start = None
pattern = re.compile(rf"^{re.escape(name)}:\s*(?:#.*)?$")
for idx, line in enumerate(lines):
if start is None:
if pattern.match(line):
start = idx
continue
if line and not line.startswith((" ", "\t")) and not line.lstrip().startswith("#"):
return start, idx
if start is None:
return None
return start, len(lines)
def _child_block(lines: list[str], parent: tuple[int, int], name: str, indent: int) -> tuple[int, int] | None:
start = None
prefix = " " * indent
pattern = re.compile(rf"^{prefix}{re.escape(name)}:\s*(?:#.*)?$")
for idx in range(parent[0] + 1, parent[1]):
line = lines[idx]
if start is None:
if pattern.match(line):
start = idx
continue
if line.startswith(prefix) and line.strip() and not line.startswith(prefix + " ") and not line.lstrip().startswith("#"):
return start, idx
if start is None:
return None
return start, parent[1]
def _set_key(lines: list[str], block: tuple[int, int], key: str, value: str, indent: int) -> tuple[int, int]:
prefix = " " * indent
pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*.*$")
for idx in range(block[0] + 1, block[1]):
if pattern.match(lines[idx]):
lines[idx] = f"{prefix}{key}: {value}"
return block
insert_at = block[1]
lines.insert(insert_at, f"{prefix}{key}: {value}")
return block[0], block[1] + 1
def _has_key(lines: list[str], block: tuple[int, int], key: str, indent: int) -> bool:
prefix = " " * indent
pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*.*$")
return any(pattern.match(lines[idx]) for idx in range(block[0] + 1, block[1]))
def _key_value(lines: list[str], block: tuple[int, int], key: str, indent: int) -> str | None:
prefix = " " * indent
pattern = re.compile(rf"^{prefix}{re.escape(key)}:\s*(.*?)\s*(?:#.*)?$")
for idx in range(block[0] + 1, block[1]):
match = pattern.match(lines[idx])
if match:
return match.group(1).strip()
return None
def _ensure_model(lines: list[str], model: str | None, base_url: str | None, context_length: int | None, api_key: str | None = None) -> None:
block = _top_level_block(lines, "model")
if block is None:
insert = ["model:"]
if model:
insert.append(f' default: "{model}"')
if base_url:
insert.append(' provider: "custom"')
insert.append(f' base_url: "{base_url}"')
if api_key:
insert.append(f' api_key: "{api_key}"')
if context_length:
insert.append(f" context_length: {context_length}")
lines[:0] = insert + [""]
return
if model:
block = _set_key(lines, block, "default", f'"{model}"', 2)
if base_url:
block = _set_key(lines, block, "base_url", f'"{base_url}"', 2)
if api_key:
block = _set_key(lines, block, "api_key", f'"{api_key}"', 2)
if context_length:
_set_key(lines, block, "context_length", str(context_length), 2)
def _ensure_provider_timeout(lines: list[str], provider: str = "custom", timeout_seconds: int = 180) -> None:
"""Add ODS's local-provider timeout default without clobbering operators.
Hermes can spend a long time in local prefill before the first token on
35B-class models. Existing configs from older ODS installs are missing
providers.custom.request_timeout_seconds, so add it on migration. If the
operator already tuned the provider timeout, leave their value untouched.
Passing a non-default timeout updates only ODS's shipped 180s default,
which lets platform installers tune slow local backends without replacing
an operator's custom value.
"""
block = _top_level_block(lines, "providers")
if block is None:
auxiliary = _top_level_block(lines, "auxiliary")
model = _top_level_block(lines, "model")
insert_at = auxiliary[0] if auxiliary else (model[1] if model else len(lines))
payload = [
"providers:",
f" {provider}:",
f" request_timeout_seconds: {timeout_seconds}",
"",
]
lines[insert_at:insert_at] = payload
return
provider_block = _child_block(lines, block, provider, 2)
if provider_block is None:
insert_at = block[1]
lines[insert_at:insert_at] = [
f" {provider}:",
f" request_timeout_seconds: {timeout_seconds}",
]
return
existing = _key_value(lines, provider_block, "request_timeout_seconds", 4)
if existing is None:
_set_key(lines, provider_block, "request_timeout_seconds", str(timeout_seconds), 4)
elif timeout_seconds != 180 and existing == "180":
_set_key(lines, provider_block, "request_timeout_seconds", str(timeout_seconds), 4)
def _ensure_auxiliary(lines: list[str], context_length: int | None) -> None:
if not context_length:
return
block = _top_level_block(lines, "auxiliary")
if block is None:
terminal = _top_level_block(lines, "terminal")
insert_at = terminal[0] if terminal else len(lines)
payload = [
"auxiliary:",
" compression:",
f" context_length: {context_length}",
"",
]
lines[insert_at:insert_at] = payload
return
compression = _child_block(lines, block, "compression", 2)
if compression is None:
insert_at = block[1]
lines[insert_at:insert_at] = [
" compression:",
f" context_length: {context_length}",
]
return
_set_key(lines, compression, "context_length", str(context_length), 4)
def _ensure_compression(lines: list[str]) -> None:
"""Set the compression block to ODS Talk's tuned values.
Previous defaults (0.50 / 0.20 / 20) caused the agent to lose granular
context mid-conversation when a single tool result briefly spiked
context past the 50% threshold. Bumped per ODS Talk live-testing —
see cli-config.yaml.template for the full reasoning.
Idempotent: every install (fresh or upgrade) that runs this patcher
converges /opt/data/config.yaml to these values, automatically
migrating existing operator installs on the next bootstrap-upgrade.
"""
block = _top_level_block(lines, "compression")
if block is None:
lines.extend(
[
"",
"compression:",
" enabled: true",
" threshold: 0.75",
" target_ratio: 0.50",
" protect_last_n: 40",
]
)
return
block = _set_key(lines, block, "enabled", "true", 2)
block = _set_key(lines, block, "threshold", "0.75", 2)
block = _set_key(lines, block, "target_ratio", "0.50", 2)
_set_key(lines, block, "protect_last_n", "40", 2)
def _ensure_whatsapp_bridge(lines: list[str]) -> None:
"""Keep WhatsApp off by default while avoiding upstream's port 3000 bridge.
Existing operator choices win: if a WhatsApp block already exists we do not
change its enabled state, and if it already has extra.bridge_port we leave
that value alone.
"""
platforms = _top_level_block(lines, "platforms")
if platforms is None:
lines.extend(
[
"",
"platforms:",
" whatsapp:",
" enabled: false",
" extra:",
" bridge_port: 3010",
]
)
return
whatsapp = _child_block(lines, platforms, "whatsapp", 2)
if whatsapp is None:
insert_at = platforms[1]
lines[insert_at:insert_at] = [
" whatsapp:",
" enabled: false",
" extra:",
" bridge_port: 3010",
]
return
extra = _child_block(lines, whatsapp, "extra", 4)
if extra is None:
insert_at = whatsapp[1]
lines[insert_at:insert_at] = [
" extra:",
" bridge_port: 3010",
]
return
for idx in range(extra[0] + 1, extra[1]):
if re.match(r"^\s{6}bridge_port:\s*.*$", lines[idx]):
return
_set_key(lines, extra, "bridge_port", "3010", 6)
def patch_config(
path: Path,
model: str | None,
base_url: str | None,
context_length: int | None,
api_key: str | None = None,
request_timeout_seconds: int = 180,
) -> bool:
original = path.read_text(encoding="utf-8")
trailing_newline = original.endswith("\n")
lines = original.splitlines()
_ensure_model(lines, model, base_url, context_length, api_key)
_ensure_provider_timeout(lines, timeout_seconds=request_timeout_seconds)
_ensure_auxiliary(lines, context_length)
_ensure_whatsapp_bridge(lines)
_ensure_compression(lines)
updated = "\n".join(lines)
if trailing_newline:
updated += "\n"
if updated == original:
return False
path.write_text(updated, encoding="utf-8")
return True
def main() -> int:
parser = argparse.ArgumentParser(description="Patch ODS Hermes config defaults.")
parser.add_argument("path", type=Path)
parser.add_argument("--model")
parser.add_argument("--base-url")
parser.add_argument("--api-key", help="Bearer token Hermes uses to call the LLM (needed when routing through litellm)")
parser.add_argument("--context-length", type=int)
parser.add_argument("--request-timeout-seconds", type=int, default=180)
args = parser.parse_args()
if not args.path.exists():
return 0
changed = patch_config(
args.path,
args.model,
args.base_url,
args.context_length,
args.api_key,
args.request_timeout_seconds,
)
print("changed" if changed else "unchanged")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+425
View File
@@ -0,0 +1,425 @@
#!/bin/bash
#=============================================================================
# pre-download.sh — Download Models Before Installation
#
# Part of ODS — Phase 3
#
# Downloads models ahead of time so install.sh can skip the download step.
# Useful for slow/metered connections or offline installs.
#
# Usage:
# ./pre-download.sh # Auto-detect tier
# ./pre-download.sh --tier edge # Download edge tier models
# ./pre-download.sh --tier pro # Download pro tier models
# ./pre-download.sh --list # List available models
# ./pre-download.sh --verify # Verify cached models
#
# Cache location: ~/.cache/huggingface/hub/
#=============================================================================
# Require Bash 4+ (associative arrays used for tier → model mapping)
if (( BASH_VERSINFO[0] < 4 )); then
echo "ERROR: $(basename "$0") requires Bash 4.0+ (you have $BASH_VERSION)" >&2
echo " macOS ships Bash 3.2 due to licensing. Install a modern version:" >&2
echo " brew install bash" >&2
exit 1
fi
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# Model definitions by tier
declare -A TIER_MODELS
TIER_MODELS[nano]="Qwen/Qwen2.5-1.5B-Instruct"
TIER_MODELS[edge]="Qwen/Qwen2.5-7B-Instruct"
TIER_MODELS[pro]="Qwen/Qwen2.5-32B-Instruct-AWQ"
TIER_MODELS[cluster]="Qwen/Qwen2.5-72B-Instruct-AWQ"
# Approximate sizes (for progress estimates)
declare -A MODEL_SIZES_GB
MODEL_SIZES_GB[nano]="3"
MODEL_SIZES_GB[edge]="14"
MODEL_SIZES_GB[pro]="18"
MODEL_SIZES_GB[cluster]="40"
# Optional components
STT_MODEL="Systran/faster-whisper-large-v3"
TTS_MODEL="hexgrad/Kokoro-82M"
#=============================================================================
# Utility Functions
#=============================================================================
print_banner() {
echo -e "${CYAN}"
cat << 'EOF'
╔═══════════════════════════════════════════════════════════╗
║ ODS — Model Pre-Download ║
║ ║
║ Download models before installation for faster setup. ║
╚═══════════════════════════════════════════════════════════╝
EOF
echo -e "${NC}"
}
log() { echo -e "${BLUE}[INFO]${NC} $1"; }
success() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
check_dependencies() {
local missing=()
local pycmd="python3"
if command -v python3 &>/dev/null && python3 -c "import sys; sys.exit(0)" &>/dev/null; then
pycmd="python3"
elif command -v python &>/dev/null && python -c "import sys; sys.exit(0)" &>/dev/null; then
pycmd="python"
else
missing+=("python (or python3)")
fi
local pipcmd=""
if command -v pip3 &>/dev/null; then
pipcmd="pip3"
elif command -v pip &>/dev/null; then
pipcmd="pip"
else
missing+=("pip")
fi
if [[ ${#missing[@]} -gt 0 ]]; then
error "Missing dependencies: ${missing[*]}"
echo "Please install them first."
exit 1
fi
# Ensure huggingface_hub is installed
if ! "$pycmd" -c "import huggingface_hub" 2>/dev/null; then
log "Installing huggingface_hub..."
"$pipcmd" install -q huggingface_hub
fi
export ODS_PYTHON_CMD="$pycmd"
}
#=============================================================================
# Hardware Detection (simplified from install-core.sh)
#=============================================================================
detect_vram_gb() {
if command -v nvidia-smi &>/dev/null; then
nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | sed -n '1p' | awk '{print int($1/1024)}'
else
echo "0"
fi
}
detect_ram_gb() {
if [[ -f /proc/meminfo ]]; then
awk '/MemTotal/ {printf "%.0f", $2/1024/1024}' /proc/meminfo
elif command -v sysctl &>/dev/null; then
sysctl -n hw.memsize 2>/dev/null | awk '{printf "%.0f", $1/1024/1024/1024}'
else
echo "0"
fi
}
recommend_tier() {
local vram ram
vram=$(detect_vram_gb)
ram=$(detect_ram_gb)
if [[ $vram -ge 40 ]]; then
echo "cluster"
elif [[ $vram -ge 20 ]]; then
echo "pro"
elif [[ $vram -ge 6 ]] || [[ $ram -ge 16 ]]; then
echo "edge"
else
echo "nano"
fi
}
#=============================================================================
# Model Download
#=============================================================================
download_model() {
local model="$1"
local label="$2"
log "Downloading $label: $model"
"${ODS_PYTHON_CMD:-python3}" << EOF
from huggingface_hub import snapshot_download
import sys
try:
path = snapshot_download(
repo_id="$model",
resume_download=True,
local_files_only=False
)
print(f"Downloaded to: {path}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
EOF
if [[ $? -eq 0 ]]; then
success "Downloaded $label"
return 0
else
error "Failed to download $label"
return 1
fi
}
verify_model() {
local model="$1"
"${ODS_PYTHON_CMD:-python3}" << EOF
from huggingface_hub import try_to_load_from_cache, get_hf_file_metadata
import sys
# Check if model is cached
try:
from huggingface_hub import snapshot_download
path = snapshot_download(
repo_id="$model",
local_files_only=True
)
print(f"✓ Cached: {path}")
except Exception:
print(f"✗ Not cached: $model")
sys.exit(1)
EOF
}
#=============================================================================
# Main Functions
#=============================================================================
list_models() {
echo -e "\n${BOLD}Available Models by Tier:${NC}\n"
echo -e "${CYAN}Tier │ Model │ Size${NC}"
echo "─────────┼────────────────────────────────────┼──────"
for tier in nano edge pro cluster; do
local model="${TIER_MODELS[$tier]}"
local size="${MODEL_SIZES_GB[$tier]}"
printf "%-8s │ %-34s │ ~%sGB\n" "$tier" "$model" "$size"
done
echo ""
echo -e "${BOLD}Optional Components:${NC}"
echo " STT (Whisper): $STT_MODEL (~3GB)"
echo " TTS (Kokoro): $TTS_MODEL (~0.2GB)"
}
verify_cache() {
echo -e "\n${BOLD}Verifying cached models...${NC}\n"
local found=0
local missing=0
for tier in nano edge pro cluster; do
local tier_model="${TIER_MODELS[$tier]}"
if verify_model "$tier_model" 2>/dev/null; then
((found++)) || true
else
echo -e " ${RED}${NC} $tier: Not cached"
((missing++)) || true
fi
done
# Check optional
echo ""
if verify_model "$STT_MODEL" 2>/dev/null; then
((found++)) || true
else
echo -e " ${YELLOW}${NC} STT (Whisper): Not cached (optional)"
fi
if verify_model "$TTS_MODEL" 2>/dev/null; then
((found++)) || true
else
echo -e " ${YELLOW}${NC} TTS (Kokoro): Not cached (optional)"
fi
echo ""
echo "Found: $found cached | Missing: $missing required"
}
download_tier() {
local tier="$1"
local include_voice="${2:-false}"
if [[ -z "${TIER_MODELS[$tier]:-}" ]]; then
error "Unknown tier: $tier"
echo "Available tiers: nano, edge, pro, cluster"
exit 1
fi
local model="${TIER_MODELS[$tier]}"
local size="${MODEL_SIZES_GB[$tier]}"
echo -e "\n${BOLD}Downloading ${tier} tier models${NC}"
echo -e "LLM: $model (~${size}GB)"
echo ""
# Estimate time
local est_minutes
est_minutes=$((size * 2)) # ~0.5GB/min on average connection
warn "Estimated download time: ${est_minutes}-$((est_minutes * 2)) minutes (depends on connection)"
echo ""
read -p "Continue? [Y/n] " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
echo "Cancelled."
exit 0
fi
# Download LLM
download_model "$model" "LLM ($tier tier)" || exit 1
# Download voice components if requested
if [[ "$include_voice" == "true" ]]; then
echo ""
download_model "$STT_MODEL" "STT (Whisper)" || warn "STT download failed (optional)"
download_model "$TTS_MODEL" "TTS (Kokoro)" || warn "TTS download failed (optional)"
fi
echo ""
success "Pre-download complete!"
echo ""
echo "You can now run install.sh — it will use the cached models."
echo " ./install.sh"
}
interactive_menu() {
print_banner
check_dependencies
local recommended vram ram
recommended=$(recommend_tier)
vram=$(detect_vram_gb)
ram=$(detect_ram_gb)
echo -e "${BOLD}Detected Hardware:${NC}"
echo " RAM: ${ram}GB"
echo " VRAM: ${vram}GB (GPU)"
echo ""
echo -e " ${GREEN}Recommended tier: ${BOLD}$recommended${NC}"
echo ""
list_models
echo ""
read -p "Select tier to download [nano/edge/pro/cluster] ($recommended): " tier_choice
tier_choice="${tier_choice:-$recommended}"
echo ""
read -p "Also download voice components (STT/TTS)? [y/N] " -n 1 -r voice_choice
echo
local include_voice="false"
[[ $voice_choice =~ ^[Yy]$ ]] && include_voice="true"
download_tier "$tier_choice" "$include_voice"
}
#=============================================================================
# CLI Argument Parsing
#=============================================================================
show_help() {
cat << EOF
ODS Model Pre-Download
Usage: $0 [options]
Options:
--tier TIER Download models for specific tier (nano/edge/pro/cluster)
--with-voice Also download STT and TTS models
--list List available models and sizes
--verify Check which models are already cached
--help Show this help message
Examples:
$0 # Interactive mode (auto-detect tier)
$0 --tier pro # Download pro tier models
$0 --tier edge --with-voice # Download edge tier + voice models
$0 --verify # Check cache status
EOF
}
main() {
local tier=""
local include_voice="false"
local action="interactive"
while [[ $# -gt 0 ]]; do
case "$1" in
--tier)
tier="$2"
action="download"
shift 2
;;
--with-voice)
include_voice="true"
shift
;;
--list)
action="list"
shift
;;
--verify)
action="verify"
shift
;;
--help|-h)
show_help
exit 0
;;
*)
error "Unknown option: $1"
show_help
exit 1
;;
esac
done
case "$action" in
interactive)
interactive_menu
;;
download)
print_banner
check_dependencies
download_tier "$tier" "$include_voice"
;;
list)
print_banner
list_models
;;
verify)
print_banner
check_dependencies
verify_cache
;;
esac
}
main "$@"
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env bash
set -euo pipefail
REPORT_FILE="/tmp/ods-preflight-report.json"
TIER="${TIER:-1}"
RAM_GB="${RAM_GB:-0}"
DISK_GB="${DISK_GB:-0}"
GPU_BACKEND="${GPU_BACKEND:-nvidia}"
GPU_VRAM_MB="${GPU_VRAM_MB:-0}"
GPU_NAME="${GPU_NAME:-Unknown}"
PLATFORM_ID="${PLATFORM_ID:-linux}"
COMPOSE_OVERLAYS="${COMPOSE_OVERLAYS:-}"
SCRIPT_DIR="${SCRIPT_DIR:-$(pwd)}"
STRICT="false"
ENV_MODE="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--report)
REPORT_FILE="${2:-$REPORT_FILE}"
shift 2
;;
--tier)
TIER="${2:-$TIER}"
shift 2
;;
--ram-gb)
RAM_GB="${2:-$RAM_GB}"
shift 2
;;
--disk-gb)
DISK_GB="${2:-$DISK_GB}"
shift 2
;;
--gpu-backend)
GPU_BACKEND="${2:-$GPU_BACKEND}"
shift 2
;;
--gpu-vram-mb)
GPU_VRAM_MB="${2:-$GPU_VRAM_MB}"
shift 2
;;
--gpu-name)
GPU_NAME="${2:-$GPU_NAME}"
shift 2
;;
--platform-id)
PLATFORM_ID="${2:-$PLATFORM_ID}"
shift 2
;;
--compose-overlays)
COMPOSE_OVERLAYS="${2:-$COMPOSE_OVERLAYS}"
shift 2
;;
--script-dir)
SCRIPT_DIR="${2:-$SCRIPT_DIR}"
shift 2
;;
--strict)
STRICT="true"
shift
;;
--env)
ENV_MODE="true"
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PYTHON_CMD="python3"
if [[ -f "$ROOT_DIR/lib/python-cmd.sh" ]]; then
. "$ROOT_DIR/lib/python-cmd.sh"
PYTHON_CMD="$(ods_detect_python_cmd)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
"$PYTHON_CMD" - "$REPORT_FILE" "$TIER" "$RAM_GB" "$DISK_GB" "$GPU_BACKEND" "$GPU_VRAM_MB" "$GPU_NAME" "$PLATFORM_ID" "$COMPOSE_OVERLAYS" "$SCRIPT_DIR" "$ENV_MODE" "$STRICT" <<'PY'
import json
import pathlib
import sys
from datetime import datetime, timezone
(
report_file,
tier,
ram_gb,
disk_gb,
gpu_backend,
gpu_vram_mb,
gpu_name,
platform_id,
compose_overlays,
script_dir,
env_mode,
strict_mode,
) = sys.argv[1:]
env_mode = env_mode == "true"
strict_mode = strict_mode == "true"
try:
ram_gb = int(float(ram_gb))
except Exception:
ram_gb = 0
try:
disk_gb = int(float(disk_gb))
except Exception:
disk_gb = 0
try:
gpu_vram_mb = int(float(gpu_vram_mb))
except Exception:
gpu_vram_mb = 0
tier_key = str(tier).upper()
tier_rank_map = {
"0": 0,
"CLOUD": 0,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"T0": 0,
"T1": 1,
"T2": 2,
"T3": 3,
"T4": 4,
"SH_COMPACT": 3,
"SH_LARGE": 4,
}
tier_rank = tier_rank_map.get(tier_key, 1)
min_ram_map = {
"0": 4,
"CLOUD": 4,
"T0": 4,
"1": 16,
"2": 32,
"3": 48,
"4": 64,
"SH_COMPACT": 64,
"SH_LARGE": 96,
}
min_disk_map = {
"0": 15,
# Cloud mode skips local GGUF downloads and local llama-server model
# storage. It still needs room for core Docker images, configs, logs, and
# optional services, but it should not inherit the 50GB local-model default.
"CLOUD": 25,
"T0": 15,
"1": 30,
"2": 50,
"3": 80,
"4": 150,
"SH_COMPACT": 80,
"SH_LARGE": 120,
}
min_ram = min_ram_map.get(tier_key, 16)
min_disk = min_disk_map.get(tier_key, 50)
checks = []
def add_check(check_id, status, message, action):
checks.append(
{
"id": check_id,
"status": status,
"message": message,
"action": action,
}
)
# Platform support check
if platform_id in {"linux", "wsl"}:
add_check(
"platform-support",
"pass",
f"Platform '{platform_id}' is currently supported by install-core.sh.",
"",
)
elif platform_id in {"macos", "windows"}:
add_check(
"platform-support",
"warn",
f"Platform '{platform_id}' is supported via installer MVP path (not full parity yet).",
"Continue with platform installer and follow generated doctor report recommendations.",
)
else:
add_check(
"platform-support",
"blocker",
f"Platform '{platform_id}' is not yet supported by install-core.sh.",
"Use Linux/WSL path for now or run platform-specific installer once implemented.",
)
# Compose overlay existence check
overlays = [o.strip() for o in compose_overlays.split(",") if o.strip()]
if overlays:
missing = [o for o in overlays if not (pathlib.Path(script_dir) / o).exists()]
if missing:
add_check(
"compose-overlays",
"blocker",
f"Compose overlays are missing: {', '.join(missing)}.",
"Restore missing compose files or update capability profile overlay mapping.",
)
else:
add_check(
"compose-overlays",
"pass",
f"Compose overlays resolved: {', '.join(overlays)}.",
"",
)
else:
add_check(
"compose-overlays",
"warn",
"No compose overlays supplied from capability profile.",
"Ensure CAP_COMPOSE_OVERLAYS is populated; installer will use legacy fallback.",
)
# RAM and disk checks
if ram_gb >= min_ram:
add_check(
"memory",
"pass",
f"RAM {ram_gb}GB meets tier {tier_key} recommendation ({min_ram}GB).",
"",
)
else:
add_check(
"memory",
"warn",
f"RAM {ram_gb}GB is below tier {tier_key} recommendation ({min_ram}GB).",
f"Use a lower tier or increase memory to at least {min_ram}GB.",
)
if disk_gb >= min_disk:
add_check(
"disk",
"pass",
f"Disk {disk_gb}GB meets tier {tier_key} recommendation ({min_disk}GB).",
"",
)
else:
add_check(
"disk",
"blocker",
f"Disk {disk_gb}GB is below required minimum for tier {tier_key} ({min_disk}GB).",
f"Free at least {min_disk - disk_gb}GB or choose a smaller tier.",
)
# GPU checks
gpu_backend = (gpu_backend or "").lower()
if gpu_backend == "amd":
add_check(
"gpu-backend",
"pass",
f"AMD backend selected ({gpu_name}).",
"",
)
elif gpu_backend == "nvidia":
if gpu_name.strip().lower() in {"none", ""} or gpu_vram_mb <= 0:
add_check(
"gpu-vram",
"warn",
"NVIDIA backend selected but no NVIDIA GPU VRAM was detected.",
"Install/verify NVIDIA drivers or switch to a supported AMD path.",
)
elif tier_rank >= 2 and gpu_vram_mb < 10000:
add_check(
"gpu-vram",
"warn",
f"NVIDIA VRAM {gpu_vram_mb}MB is below recommended floor for tier {tier_key}.",
"Use tier 1 or a GPU with at least 12GB VRAM for better performance.",
)
else:
add_check(
"gpu-vram",
"pass",
f"NVIDIA backend selected ({gpu_name}, {gpu_vram_mb}MB VRAM).",
"",
)
elif gpu_backend == "apple":
add_check(
"gpu-backend",
"warn",
"Apple backend selected (experimental path).",
"Use macOS installer preflight + doctor and run reduced profile set until Tier A parity is complete.",
)
elif gpu_backend == "cpu":
if platform_id in {"windows", "macos"}:
add_check(
"gpu-backend",
"warn",
"CPU fallback selected on non-Linux platform.",
"Use reduced model/profile defaults; expect slower inference.",
)
else:
add_check(
"gpu-backend",
"warn",
"CPU fallback selected.",
"Install/verify GPU drivers for best performance or continue with small models.",
)
else:
add_check(
"gpu-backend",
"warn",
f"Unknown backend '{gpu_backend}'.",
"Verify capability profile and hardware detection output.",
)
blockers = [c for c in checks if c["status"] == "blocker"]
warnings = [c for c in checks if c["status"] == "warn"]
report = {
"version": "1",
"generated_at": datetime.now(timezone.utc).isoformat(),
"inputs": {
"tier": tier_key,
"ram_gb": ram_gb,
"disk_gb": disk_gb,
"gpu_backend": gpu_backend,
"gpu_vram_mb": gpu_vram_mb,
"gpu_name": gpu_name,
"platform_id": platform_id,
"compose_overlays": overlays,
"script_dir": script_dir,
},
"summary": {
"checks": len(checks),
"blockers": len(blockers),
"warnings": len(warnings),
"can_proceed": len(blockers) == 0,
},
"checks": checks,
}
report_path = pathlib.Path(report_file)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
if env_mode:
def out(key, value):
safe = str(value).replace("\\", "\\\\").replace('"', '\\"')
print(f'{key}="{safe}"')
out("PREFLIGHT_REPORT_FILE", str(report_path))
out("PREFLIGHT_CHECK_COUNT", report["summary"]["checks"])
out("PREFLIGHT_BLOCKERS", report["summary"]["blockers"])
out("PREFLIGHT_WARNINGS", report["summary"]["warnings"])
out("PREFLIGHT_CAN_PROCEED", str(report["summary"]["can_proceed"]).lower())
if strict_mode and blockers:
raise SystemExit(1)
PY
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: prune-hermes-slash-workers.sh [--force] [--dry-run] [--max-count N] [--max-age-seconds N] [--container NAME]
Finds Hermes tui_gateway.slash_worker children inside the Hermes container and
prunes only workers that exceed the age/count policy. Dry-run is the default.
Environment:
HERMES_SLASH_WORKER_MAX_COUNT Default: 8
HERMES_SLASH_WORKER_MAX_AGE_SECONDS Default: 3600
HERMES_CONTAINER Default: ods-hermes
EOF
}
MAX_COUNT="${HERMES_SLASH_WORKER_MAX_COUNT:-8}"
MAX_AGE_SECONDS="${HERMES_SLASH_WORKER_MAX_AGE_SECONDS:-3600}"
CONTAINER="${HERMES_CONTAINER:-ods-hermes}"
FORCE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--force)
FORCE=1
shift
;;
--dry-run)
FORCE=0
shift
;;
--max-count)
MAX_COUNT="${2:-}"
shift 2
;;
--max-age-seconds)
MAX_AGE_SECONDS="${2:-}"
shift 2
;;
--container)
CONTAINER="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "[FAIL] Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ ! "$MAX_COUNT" =~ ^[0-9]+$ || "$MAX_COUNT" -lt 1 ]]; then
echo "[FAIL] --max-count must be a positive integer" >&2
exit 1
fi
if [[ ! "$MAX_AGE_SECONDS" =~ ^[0-9]+$ ]]; then
echo "[FAIL] --max-age-seconds must be a non-negative integer" >&2
exit 1
fi
if [[ -z "$CONTAINER" ]]; then
echo "[FAIL] --container must not be empty" >&2
exit 1
fi
collect_workers() {
if [[ -n "${ODS_HERMES_SLASH_WORKER_PS_FIXTURE:-}" ]]; then
cat "$ODS_HERMES_SLASH_WORKER_PS_FIXTURE"
return 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "[FAIL] docker CLI not found" >&2
return 1
fi
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$CONTAINER"; then
echo "[INFO] $CONTAINER is not running; nothing to prune"
return 0
fi
docker exec "$CONTAINER" sh -c \
"ps -eo pid=,etimes=,args= 2>/dev/null || ps -ef 2>/dev/null" \
| grep '[t]ui_gateway[.]slash_worker' || true
}
WORKERS_FILE="$(mktemp)"
CANDIDATES_FILE="$(mktemp)"
OVERAGE_FILE="$(mktemp)"
trap 'rm -f "$WORKERS_FILE" "$CANDIDATES_FILE" "$OVERAGE_FILE"' EXIT
collect_workers | awk '
$0 ~ /tui_gateway[.]slash_worker/ {
pid = $1
age = $2
if (pid !~ /^[0-9]+$/) {
next
}
if (age !~ /^[0-9]+$/) {
age = -1
}
print pid "\t" age "\t" $0
}
' > "$WORKERS_FILE"
WORKER_COUNT="$(wc -l < "$WORKERS_FILE" | tr -d ' ')"
if [[ "$WORKER_COUNT" -eq 0 ]]; then
echo "[PASS] no Hermes slash workers found"
exit 0
fi
awk -F '\t' -v max_age="$MAX_AGE_SECONDS" '$2 >= max_age {print}' \
"$WORKERS_FILE" > "$CANDIDATES_FILE"
if [[ "$WORKER_COUNT" -gt "$MAX_COUNT" ]]; then
OVERAGE=$(( WORKER_COUNT - MAX_COUNT ))
sort -t "$(printf '\t')" -k2,2nr "$WORKERS_FILE" \
| head -n "$OVERAGE" > "$OVERAGE_FILE"
cat "$OVERAGE_FILE" >> "$CANDIDATES_FILE"
fi
sort -t "$(printf '\t')" -k1,1n -u "$CANDIDATES_FILE" -o "$CANDIDATES_FILE"
CANDIDATE_COUNT="$(wc -l < "$CANDIDATES_FILE" | tr -d ' ')"
echo "[INFO] found $WORKER_COUNT Hermes slash_worker process(es); policy max-count=$MAX_COUNT max-age=${MAX_AGE_SECONDS}s"
if [[ "$CANDIDATE_COUNT" -eq 0 ]]; then
echo "[PASS] no slash workers exceed the prune policy"
exit 0
fi
echo "[INFO] $CANDIDATE_COUNT slash worker(s) selected for pruning:"
awk -F '\t' '{printf " pid=%s age=%ss %s\n", $1, $2, $3}' "$CANDIDATES_FILE"
if [[ "$FORCE" -ne 1 ]]; then
echo "[DRY-RUN] rerun with --force to kill selected workers"
exit 0
fi
if [[ -n "${ODS_HERMES_SLASH_WORKER_PS_FIXTURE:-}" ]]; then
echo "[DRY-RUN] fixture mode is read-only; not killing processes"
exit 0
fi
awk -F '\t' '{print $1}' "$CANDIDATES_FILE" \
| docker exec -i "$CONTAINER" sh -c 'while read -r pid; do kill "$pid" 2>/dev/null || true; done'
echo "[PASS] requested termination for $CANDIDATE_COUNT Hermes slash_worker process(es)"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_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)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
echo "[gate] shell syntax"
mapfile -t sh_files < <(git ls-files '*.sh')
for f in "${sh_files[@]}"; do
bash -n "$f"
done
echo "[gate] compatibility + claims"
bash scripts/check-compatibility.sh
"$PYTHON_CMD" scripts/check-version-consistency.py
bash scripts/check-release-claims.sh
"$PYTHON_CMD" scripts/validate-golden-paths.py
"$PYTHON_CMD" scripts/validate-generated-configs.py
"$PYTHON_CMD" scripts/check-dependency-pins.py
echo "[gate] contracts"
bash tests/contracts/test-installer-contracts.sh
bash tests/contracts/test-preflight-fixtures.sh
bash tests/contracts/test-installer-hardening.sh
bash tests/test-uninstall-compose-flags.sh
bash tests/test-windows-missing-service-hints.sh
"$PYTHON_CMD" tests/contracts/test-network-exposure-contracts.py
echo "[gate] smoke"
bash tests/smoke/linux-amd.sh
bash tests/smoke/linux-nvidia.sh
bash tests/smoke/wsl-logic.sh
bash tests/smoke/macos-dispatch.sh
echo "[gate] installer simulation"
bash scripts/simulate-installers.sh
"$PYTHON_CMD" scripts/validate-sim-summary.py artifacts/installer-sim/summary.json
echo "[gate] update rollback"
bash tests/test-update-rollback-contract.sh
echo "[PASS] release gate"
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Render ODS runtime config surfaces deterministically.
The first purpose of this script is read-only comparison: installers and
runtime mutators can ask what config should look like without writing files.
Follow-up wiring can then replace ad-hoc heredocs one surface at a time.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Callable
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MODEL = "qwen3.5-9b"
DEFAULT_GGUF = "Qwen3.5-9B-Q4_K_M.gguf"
DEFAULT_CONTEXT = 131072
DEFAULT_LITELLM_KEY = "sk-lemonade"
NO_KEY = "no-key"
@dataclass(frozen=True)
class RenderInputs:
model: str
gguf_file: str
lemonade_model_id: str
lemonade_api_base: str
gpu_backend: str
ods_mode: str
llm_base_url: str
litellm_key: str
opencode_port: int
context_length: int
@dataclass(frozen=True)
class RenderedFile:
surface: str
path: str
content: str
def ensure_trailing_newline(text: str) -> str:
return text if text.endswith("\n") else f"{text}\n"
def lemonade_model_id(inputs: RenderInputs) -> str:
if inputs.lemonade_model_id:
return inputs.lemonade_model_id
return f"extra.{inputs.gguf_file}"
def hermes_model_id(inputs: RenderInputs) -> str:
if inputs.ods_mode == "lemonade" or inputs.gpu_backend == "amd":
return lemonade_model_id(inputs)
return inputs.gguf_file or inputs.model
def opencode_key(inputs: RenderInputs) -> str:
return inputs.litellm_key if inputs.ods_mode == "lemonade" else NO_KEY
def render_litellm_lemonade(inputs: RenderInputs) -> RenderedFile:
model = lemonade_model_id(inputs)
api_base = inputs.lemonade_api_base.rstrip("/") or "http://llama-server:8080/api/v1"
content = f"""model_list:
- model_name: default
litellm_params:
model: openai/{model}
api_base: {api_base}
api_key: {inputs.litellm_key}
extra_body:
chat_template_kwargs:
enable_thinking: false
- model_name: "*"
litellm_params:
model: openai/{model}
api_base: {api_base}
api_key: {inputs.litellm_key}
extra_body:
chat_template_kwargs:
enable_thinking: false
litellm_settings:
drop_params: true
set_verbose: false
request_timeout: 900
stream_timeout: 900
"""
return RenderedFile("litellm-lemonade", "config/litellm/lemonade.yaml", content)
def render_hermes(inputs: RenderInputs) -> RenderedFile:
model = hermes_model_id(inputs)
content = f"""model:
default: "{model}"
provider: "custom"
base_url: "{inputs.llm_base_url}"
context_length: {inputs.context_length}
auxiliary:
compression:
context_length: {inputs.context_length}
compression:
enabled: true
threshold: 0.75
target_ratio: 0.50
protect_last_n: 40
"""
return RenderedFile("hermes", "data/hermes/config.yaml", content)
def render_perplexica(inputs: RenderInputs) -> RenderedFile:
model = lemonade_model_id(inputs) if inputs.ods_mode == "lemonade" else (inputs.gguf_file or inputs.model)
base_url = inputs.llm_base_url.rstrip("/") or "http://llama-server:8080"
if not (base_url.endswith("/v1") or base_url.endswith("/api/v1")):
base_url = f"{base_url}/v1"
payload = {
"modelProviders": [
{
"id": "openai",
"type": "openai",
"name": "ODS",
"config": {
"apiKey": opencode_key(inputs),
"baseURL": base_url,
},
"chatModels": [{"key": model, "name": model}],
}
],
"preferences": {
"defaultChatProvider": "openai",
"defaultChatModel": model,
"defaultEmbeddingProvider": "transformers",
"defaultEmbeddingModel": "Xenova/all-MiniLM-L6-v2",
},
"setupComplete": True,
}
return RenderedFile(
"perplexica",
"data/perplexica/settings.seed.json",
json.dumps(payload, indent=2, sort_keys=True) + "\n",
)
def render_opencode(inputs: RenderInputs) -> RenderedFile:
payload = {
"provider": "openai-compatible",
"baseURL": inputs.llm_base_url,
"apiKey": opencode_key(inputs),
"model": lemonade_model_id(inputs) if inputs.ods_mode == "lemonade" else inputs.model,
"port": inputs.opencode_port,
}
return RenderedFile(
"opencode",
".opencode/auth.json",
json.dumps(payload, indent=2, sort_keys=True) + "\n",
)
def render_env(inputs: RenderInputs) -> RenderedFile:
lines = [
f"ODS_MODE={inputs.ods_mode}",
f"LLM_BACKEND={'lemonade' if inputs.ods_mode == 'lemonade' else 'llama-server'}",
f"LLM_MODEL={inputs.model}",
f"GGUF_FILE={inputs.gguf_file}",
f"GPU_BACKEND={inputs.gpu_backend}",
f"LLM_API_URL={inputs.llm_base_url}",
f"CTX_SIZE={inputs.context_length}",
f"MAX_CONTEXT={inputs.context_length}",
]
return RenderedFile("env", ".env.generated", "\n".join(lines) + "\n")
RENDERERS: dict[str, Callable[[RenderInputs], RenderedFile]] = {
"env": render_env,
"opencode": render_opencode,
"litellm-lemonade": render_litellm_lemonade,
"perplexica": render_perplexica,
"hermes": render_hermes,
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--surface", choices=["all", *sorted(RENDERERS)], default="all")
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--gguf-file", default=DEFAULT_GGUF)
parser.add_argument("--lemonade-model-id", default="")
parser.add_argument("--lemonade-api-base", default="http://llama-server:8080/api/v1")
parser.add_argument("--gpu-backend", choices=["amd", "apple", "cpu", "nvidia"], default="nvidia")
parser.add_argument("--ods-mode", choices=["local", "cloud", "hybrid", "lemonade"], default="local")
parser.add_argument("--llm-base-url", default="http://llama-server:8080/v1")
parser.add_argument("--litellm-key", default=DEFAULT_LITELLM_KEY)
parser.add_argument("--opencode-port", type=int, default=3003)
parser.add_argument("--context-length", type=int, default=DEFAULT_CONTEXT)
parser.add_argument("--format", choices=["json", "paths"], default="json")
parser.add_argument("--output-root", default=".", help="Root directory used with --write")
parser.add_argument("--write", action="store_true", help="Write rendered files under --output-root")
return parser.parse_args(argv)
def select_surfaces(surface: str) -> list[str]:
if surface == "all":
return ["env", "opencode", "litellm-lemonade", "perplexica", "hermes"]
return [surface]
def render(args: argparse.Namespace) -> dict[str, object]:
inputs = RenderInputs(
model=args.model,
gguf_file=args.gguf_file,
lemonade_model_id=args.lemonade_model_id,
lemonade_api_base=args.lemonade_api_base,
gpu_backend=args.gpu_backend,
ods_mode=args.ods_mode,
llm_base_url=args.llm_base_url,
litellm_key=args.litellm_key,
opencode_port=args.opencode_port,
context_length=args.context_length,
)
files = [RENDERERS[name](inputs) for name in select_surfaces(args.surface)]
written: list[str] = []
if args.write:
output_root = Path(args.output_root)
for item in files:
target = output_root / item.path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(ensure_trailing_newline(item.content), encoding="utf-8")
written.append(str(target))
return {
"version": "1",
"mode": "write" if args.write else "dry-run",
"inputs": asdict(inputs),
"files": [asdict(RenderedFile(item.surface, item.path, ensure_trailing_newline(item.content))) for item in files],
"written": written,
}
def main(argv: list[str]) -> int:
args = parse_args(argv)
payload = render(args)
if args.format == "paths":
for item in payload["files"]:
print(item["path"])
else:
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
set -euo pipefail
# repair-perplexica.sh — Re-seed Perplexica config via HTTP API
# Called by: ods repair perplexica
# Requires: Perplexica container running, python3 available
PERPLEXICA_URL="${1:-http://127.0.0.1:3004}"
LLM_MODEL="${2:-qwen3-30b-a3b}"
PERPLEXICA_MODEL="${PERPLEXICA_MODEL:-}"
PERPLEXICA_LLM_BASE_URL="${PERPLEXICA_LLM_BASE_URL:-${LLM_API_URL:-http://llama-server:8080}}"
PERPLEXICA_API_KEY="${PERPLEXICA_API_KEY:-${LITELLM_KEY:-${OPENAI_API_KEY:-no-key}}}"
case "$PERPLEXICA_LLM_BASE_URL" in
*/v1|*/api/v1) ;;
*) PERPLEXICA_LLM_BASE_URL="${PERPLEXICA_LLM_BASE_URL%/}/v1" ;;
esac
if [[ -z "$PERPLEXICA_MODEL" ]]; then
if [[ -n "${GGUF_FILE:-}" ]]; then
PERPLEXICA_MODEL="$GGUF_FILE"
_perplexica_backend="$(printf '%s' "${LLM_BACKEND:-${AMD_INFERENCE_RUNTIME:-}}" | tr '[:upper:]' '[:lower:]')"
if [[ "$_perplexica_backend" == "lemonade" ]]; then
PERPLEXICA_MODEL="extra.$GGUF_FILE"
fi
else
PERPLEXICA_MODEL="$LLM_MODEL"
fi
fi
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PYTHON_CMD="python3"
if [[ -f "$SCRIPT_DIR/lib/python-cmd.sh" ]]; then
. "$SCRIPT_DIR/lib/python-cmd.sh"
PYTHON_CMD="$(ods_detect_python_cmd)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
# Wait for Perplexica to be ready (up to 60s)
for i in $(seq 1 12); do
if curl -sf --max-time 5 "${PERPLEXICA_URL}/api/config" >/dev/null 2>&1; then
break
fi
[[ $i -lt 12 ]] && sleep 5
done
# Seed config via API — export vars so Python reads from env (no shell interpolation)
export PERPLEXICA_URL PERPLEXICA_MODEL PERPLEXICA_LLM_BASE_URL PERPLEXICA_API_KEY
curl -sf --max-time 10 "${PERPLEXICA_URL}/api/config" | \
"$PYTHON_CMD" -c '
import sys, os, json, urllib.request
config = json.load(sys.stdin)["values"]
providers = config.get("modelProviders", [])
openai_prov = next((p for p in providers if p["type"] == "openai"), None)
transformers_prov = next((p for p in providers if p["type"] == "transformers"), None)
if not openai_prov:
print("error: no openai provider found")
sys.exit(1)
url = os.environ["PERPLEXICA_URL"] + "/api/config"
model = os.environ["PERPLEXICA_MODEL"]
base_url = os.environ["PERPLEXICA_LLM_BASE_URL"]
api_key = os.environ["PERPLEXICA_API_KEY"] or "no-key"
def post(key, value):
data = json.dumps({"key": key, "value": value}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10)
def post_setup_complete():
setup_url = os.environ["PERPLEXICA_URL"] + "/api/config/setup-complete"
req = urllib.request.Request(setup_url, data=b"{}", headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
post("setupComplete", True)
# Set connection details
openai_prov["config"] = {
**(openai_prov.get("config") or {}),
"apiKey": api_key,
"baseURL": base_url,
}
openai_prov["chatModels"] = [{"key": model, "name": model}]
post("modelProviders", providers)
# Set default providers and models
post("preferences", {
"defaultChatProvider": openai_prov["id"],
"defaultChatModel": model,
"defaultEmbeddingProvider": transformers_prov["id"] if transformers_prov else openai_prov["id"],
"defaultEmbeddingModel": "Xenova/all-MiniLM-L6-v2"
})
# Mark setup complete to bypass the wizard
post_setup_complete()
print("ok")
'
+649
View File
@@ -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
+505
View File
@@ -0,0 +1,505 @@
#!/usr/bin/env python3
"""Select a pre-download ODS model from config/model-library.json.
This script is intentionally offline and deterministic. It only uses the
installer's detected hardware envelope plus the versioned model catalog; it
does not download GGUF metadata and it never treats catalog tok/s estimates as
measured performance.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
VRAM_FIT_TOLERANCE_GB = 0.25
POLICY = "context-aware-largest-capable-general-v1"
SPARK_AARCH64_POLICY = "spark-aarch64-nv-ultra-a3b-v1"
SPARK_AARCH64_MODEL_ID = "qwen3.6-35b-a3b-ud-q4"
# Unified-memory hosts (Strix Halo SH_LARGE, future AMD/NV unified-memory
# tiers) hit the same coder-next correctness pathology as Spark aarch64.
# Until upstream fixes coder-next on unified-memory backends, route the
# qwen profile to the same 35B-A3B substitution used for Spark — same
# model id, separate policy tag so the recommendation_reason is honest
# about why the substitution fired.
UNIFIED_MEMORY_POLICY = "unified-memory-coder-next-a3b-v1"
UNIFIED_MEMORY_MODEL_ID = SPARK_AARCH64_MODEL_ID
def normalize_key(value: Any) -> str:
return re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-")
def normalize_profile(value: str | None) -> str:
key = normalize_key(value or "qwen")
if key in {"gemma", "gemma4", "gemma-4"}:
return "gemma4"
if key == "auto":
return "auto"
return "qwen"
def normalize_host_arch(value: str | None) -> str:
key = normalize_key(value or "unknown")
if key in {"aarch64", "arm64"}:
return "arm64"
if key in {"x86-64", "x86_64", "amd64", "x64"}:
return "amd64"
return key or "unknown"
def list_value(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item) for item in value]
return [str(value)]
def value_enabled(value: Any) -> bool:
return normalize_key(value) not in {"", "0", "false", "off", "no"}
def effective_profile(profile: str, backend: str, tier: str) -> str:
if profile != "auto":
return profile
if normalize_key(tier) in {"cloud", "0", "t0"}:
return "qwen"
return "gemma4" if normalize_key(backend) in {"apple", "nvidia", "sycl"} else "qwen"
def normalize_model(raw: dict[str, Any]) -> dict[str, Any] | None:
gguf_parts = raw.get("gguf_parts") if isinstance(raw.get("gguf_parts"), list) else []
gguf = raw.get("gguf") or raw.get("gguf_file")
if not gguf and gguf_parts and isinstance(gguf_parts[0], dict):
gguf = gguf_parts[0].get("file")
model_id = raw.get("id") or raw.get("llm_model_name") or raw.get("name") or gguf
if not model_id or not gguf:
return None
try:
size_mb = float(raw.get("size_mb") or 0)
except (TypeError, ValueError):
size_mb = 0.0
try:
vram_required = float(raw.get("vram_required_gb") or 0)
except (TypeError, ValueError):
vram_required = 0.0
try:
context_length = int(raw.get("context_length") or 0)
except (TypeError, ValueError):
context_length = 0
return {
"id": str(model_id),
"name": raw.get("name") or str(model_id),
"family": raw.get("family") or "",
"llm_model_name": raw.get("llm_model_name") or str(model_id),
"gguf_file": str(gguf),
"gguf_url": raw.get("gguf_url") or "",
"gguf_sha256": raw.get("gguf_sha256") or "",
"gguf_parts": gguf_parts,
"size_mb": size_mb,
"vram_required_gb": vram_required,
"context_length": context_length,
"quantization": raw.get("quantization") or "",
"specialty": raw.get("specialty") or "General",
"llama_server_image": raw.get("llama_server_image") or "",
"runtime_profiles": raw.get("runtime_profiles") if isinstance(raw.get("runtime_profiles"), list) else [],
}
def load_catalog(path: Path) -> list[dict[str, Any]]:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
return [
model for model in (normalize_model(raw) for raw in data.get("models", []))
if model is not None
]
def usable_memory_gb(backend: str, memory_type: str, vram_mb: int, ram_gb: int) -> tuple[float, str]:
backend_key = normalize_key(backend)
memory_key = normalize_key(memory_type)
if backend_key == "apple" or memory_key == "unified":
# Unified-memory machines share RAM with the OS, Docker services, and
# KV cache. Use only a bounded share for the model pick so 32GB-class
# Macs/APUs are not handed a model that technically fits but thrashes.
return max(float(ram_gb) * 0.55, 2.0), "unified system memory"
if backend_key in {"cpu", "none", "unknown"} or vram_mb <= 0:
return min(max(float(ram_gb) * 0.35, 3.0), 8.0), "system RAM"
return float(vram_mb) / 1024.0, "GPU VRAM"
def fits(required_gb: float, capacity_gb: float) -> bool:
return required_gb <= capacity_gb + VRAM_FIT_TOLERANCE_GB
def estimated_param_billions(model: dict[str, Any]) -> float:
for key in ("total_params_b", "params_b"):
try:
value = float(model.get(key) or 0)
if value > 0:
return value
except (TypeError, ValueError):
pass
numbers: list[float] = []
for text in (model.get("id"), model.get("name"), model.get("llm_model_name"), model.get("gguf_file")):
numbers.extend(float(match) for match in re.findall(r"(\d+(?:\.\d+)?)\s*b", str(text or ""), re.I))
if numbers:
return max(numbers)
size_mb = float(model.get("size_mb") or 0)
if size_mb > 0:
return max(size_mb / 600.0, 1.0)
return 4.0
def estimated_context_kv_gb(model: dict[str, Any]) -> float:
context = max(int(model.get("context_length") or 0), 8192)
params_b = estimated_param_billions(model)
kv_per_32k_gb = min(max(params_b * 0.12, 0.35), 3.5)
return round(kv_per_32k_gb * (context / 32768.0), 2)
def selector_required_memory_gb(model: dict[str, Any]) -> float:
declared = float(model.get("vram_required_gb") or 0)
size_gb = float(model.get("size_mb") or 0) / 1024.0
if size_gb <= 0:
return round(declared, 2)
return round(max(declared, size_gb + estimated_context_kv_gb(model)), 2)
def matching_runtime_profile(model: dict[str, Any], backend: str, memory_type: str,
vram_mb: int, ram_gb: int, host_arch: str) -> dict[str, Any] | None:
backend_key = normalize_key(backend)
memory_key = normalize_key(memory_type)
arch_key = normalize_host_arch(host_arch)
vram_gb = float(vram_mb or 0) / 1024.0
for profile in model.get("runtime_profiles", []) or []:
if not isinstance(profile, dict):
continue
if normalize_key(profile.get("backend")) not in {"", backend_key}:
continue
allowed_arches = {normalize_host_arch(item) for item in list_value(profile.get("host_arch"))}
if allowed_arches and arch_key not in allowed_arches:
continue
required_memory_type = normalize_key(profile.get("memory_type"))
if required_memory_type and required_memory_type != memory_key:
continue
try:
if profile.get("vram_min_gb") is not None and vram_gb < float(profile["vram_min_gb"]):
continue
if profile.get("vram_max_gb") is not None and vram_gb > float(profile["vram_max_gb"]):
continue
if profile.get("system_ram_min_gb") is not None and float(ram_gb or 0) < float(profile["system_ram_min_gb"]):
continue
except (TypeError, ValueError):
continue
return profile
return None
def effective_context_length(model: dict[str, Any], runtime_profile: dict[str, Any] | None = None) -> int:
if runtime_profile and runtime_profile.get("context_length"):
return int(runtime_profile["context_length"])
return int(model.get("context_length") or 0)
def effective_required_memory_gb(model: dict[str, Any],
runtime_profile: dict[str, Any] | None = None) -> float:
if runtime_profile and runtime_profile.get("estimated_required_gb") is not None:
return round(float(runtime_profile["estimated_required_gb"]), 2)
if runtime_profile and runtime_profile.get("context_length"):
model = {**model, "context_length": int(runtime_profile["context_length"])}
return selector_required_memory_gb(model)
def family_allowed(model: dict[str, Any], profile: str) -> bool:
family = normalize_key(model.get("family"))
if profile == "gemma4":
return family == "gemma4" or model.get("id") == "qwen3.5-2b-q4"
return family != "gemma4"
def score_model(model: dict[str, Any], capacity_gb: float, profile: str) -> float:
runtime_profile = model.get("_runtime_profile") if isinstance(model.get("_runtime_profile"), dict) else None
required = effective_required_memory_gb(model, runtime_profile)
size_mb = max(float(model.get("size_mb") or 1), 1.0)
context = max(effective_context_length(model, runtime_profile), 8192)
specialty = str(model.get("specialty") or "General")
family = normalize_key(model.get("family"))
specialty_weight = {
"Code": 4.4,
"Quality": 4.1,
"General": 3.8,
"Balanced": 3.5,
"Reasoning": 3.3,
"Fast": 2.0,
"Bootstrap": 1.0,
}.get(specialty, 2.5)
family_bonus = 0.35 if profile == "gemma4" and family == "gemma4" else 0.0
family_bonus += 0.25 if profile in {"qwen", "auto"} and family == "qwen" else 0.0
context_bonus = min(context / 32768, 4.0) * 0.18
capability = min(size_mb / 1024, 48.0) * 0.24
fit_ratio = required / max(capacity_gb, 1.0)
headroom_penalty = 0.35 if fit_ratio > 0.98 else 0.15 if fit_ratio > 0.92 else 0.0
return specialty_weight + family_bonus + context_bonus + capability - headroom_penalty
def rank_models(catalog: list[dict[str, Any]], capacity_gb: float, profile: str,
installable_only: bool, backend: str, memory_type: str,
vram_mb: int, ram_gb: int, host_arch: str) -> list[dict[str, Any]]:
candidates = []
for model in catalog:
if installable_only and not model.get("gguf_url"):
continue
if not family_allowed(model, profile):
continue
runtime_profile = matching_runtime_profile(model, backend, memory_type, vram_mb, ram_gb, host_arch)
candidate_model = {**model, "_runtime_profile": runtime_profile} if runtime_profile else model
required = effective_required_memory_gb(candidate_model, runtime_profile)
if not fits(required, capacity_gb):
continue
candidates.append((score_model(candidate_model, capacity_gb, profile), candidate_model))
if not candidates:
fallback_pool = [
model for model in catalog
if (not installable_only or model.get("gguf_url")) and family_allowed(model, profile)
] or catalog
fallback = min(fallback_pool, key=lambda m: float(m.get("vram_required_gb") or 999))
return [fallback]
candidates.sort(
key=lambda item: (
item[0],
effective_required_memory_gb(item[1], item[1].get("_runtime_profile")),
effective_context_length(item[1], item[1].get("_runtime_profile")),
),
reverse=True,
)
return [model for _, model in candidates]
def arch_policy_model(catalog: list[dict[str, Any]], tier: str, profile: str,
host_arch: str, memory_type: str,
installable_only: bool,
selected_model: dict[str, Any] | None = None) -> tuple[dict[str, Any] | None, str | None]:
"""Return (model, policy_tag) for an architecture-specific override, or (None, None).
Two routes both substitute coder-next → Qwen3.6-35B-A3B-UD on unified
memory hosts where the qwen profile would otherwise pick coder-next
(which produces all-`?` tokens on those backends — see in-source
notes in installers/lib/tier-map.sh NV_ULTRA + SH_LARGE blocks):
- nv-ultra + qwen + arm64: Spark / GB10 Grace Blackwell.
- any-tier + qwen + memory_type=unified: Strix Halo + future
unified-memory NV/AMD tiers. Memory-type is the authoritative
signal (not arch or tier alone) because that's the actual
characteristic that triggers the pathology.
"""
if profile != "qwen":
return None, None
is_spark_aarch64 = (
normalize_key(tier) == "nv-ultra"
and normalize_host_arch(host_arch) == "arm64"
)
is_unified_coder_next = (
normalize_key(memory_type) == "unified"
and selected_model is not None
and is_spark_aarch64_excluded_model(selected_model)
)
if not (is_spark_aarch64 or is_unified_coder_next):
return None, None
for model in catalog:
if installable_only and not model.get("gguf_url"):
continue
if normalize_key(model.get("id")) == normalize_key(SPARK_AARCH64_MODEL_ID):
policy = SPARK_AARCH64_POLICY if is_spark_aarch64 else UNIFIED_MEMORY_POLICY
return model, policy
return None, None
def is_spark_aarch64_excluded_model(model: dict[str, Any]) -> bool:
"""True if `model` is the coder-next entry that we route around on
unified-memory backends. Function name preserved for backwards compat
with existing callers; the broader semantic is "excluded on unified
memory" (see arch_policy_model)."""
return normalize_key(model.get("llm_model_name")) == "qwen3-coder-next"
def shell_value(value: Any) -> str:
text = str(value or "")
text = (
text.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("$", "\\$")
.replace("`", "\\`")
)
return f'"{text}"'
def recommendation_reason(model: dict[str, Any], capacity_gb: float, memory_label: str,
backend: str, confidence: str) -> str:
runtime_profile = model.get("_runtime_profile") if isinstance(model.get("_runtime_profile"), dict) else None
context_k = int(effective_context_length(model, runtime_profile) / 1024)
required = effective_required_memory_gb(model, runtime_profile)
if runtime_profile:
label = runtime_profile.get("label") or runtime_profile.get("id") or "advanced runtime profile"
runtime = runtime_profile.get("runtime") or "llama.cpp"
return (
f"Catalog runtime fit ({POLICY}): {model['name']} uses {label} "
f"via {runtime}, needs about {required:g}GB GPU headroom plus "
f"{runtime_profile.get('system_ram_min_gb', 'documented')}GB system RAM, "
f"fits {capacity_gb:.1f}GB {memory_label} on {backend}, and gives "
f"{context_k}K context. Throughput still requires a local benchmark after first launch."
)
return (
f"Catalog fit ({POLICY}): {model['name']} needs "
f"about {required:g}GB including context/KV, fits {capacity_gb:.1f}GB "
f"{memory_label} on {backend}, and gives {context_k}K context. "
f"Throughput requires a local benchmark after first launch."
)
def arch_policy_reason(model: dict[str, Any], capacity_gb: float,
memory_label: str, policy_tag: str) -> str:
context_k = int((model.get("context_length") or 0) / 1024)
required = selector_required_memory_gb(model)
if policy_tag == UNIFIED_MEMORY_POLICY:
rationale = (
"is selected for unified-memory hosts (e.g. Strix Halo, future "
"AMD/NV unified-memory tiers) because qwen3-coder-next produces "
"all-`?` tokens on unified-memory backends"
)
else:
rationale = (
"is selected for arm64 NV_ULTRA Spark-class NVIDIA hosts because "
"qwen3-coder-next is excluded on this architecture by the tier map"
)
return (
f"Arch-aware catalog policy ({policy_tag}): {model['name']} "
f"{rationale}. It needs about {required:g}GB including context/KV, "
f"fits {capacity_gb:.1f}GB {memory_label}, and gives "
f"{context_k}K context. Throughput requires a local benchmark after "
f"first launch."
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog", required=True, type=Path)
parser.add_argument("--backend", default="unknown")
parser.add_argument("--memory-type", default="discrete")
parser.add_argument("--vram-mb", type=int, default=0)
parser.add_argument("--ram-gb", type=int, default=0)
parser.add_argument("--profile", default="qwen")
parser.add_argument("--tier", default="1")
parser.add_argument("--host-arch", default="unknown")
parser.add_argument("--installable-only", action="store_true")
parser.add_argument("--env", action="store_true", help="print shell assignments")
args = parser.parse_args()
catalog = load_catalog(args.catalog)
profile = effective_profile(normalize_profile(args.profile), args.backend, args.tier)
capacity_gb, memory_label = usable_memory_gb(args.backend, args.memory_type, args.vram_mb, args.ram_gb)
confidence = "high" if args.backend not in {"unknown", "none"} and capacity_gb > 0 else "medium"
ranked = rank_models(
catalog,
capacity_gb,
profile,
args.installable_only,
args.backend,
args.memory_type,
args.vram_mb,
args.ram_gb,
args.host_arch,
)
arch_selected, arch_policy_tag = arch_policy_model(
catalog, args.tier, profile, args.host_arch, args.memory_type, args.installable_only, ranked[0],
)
if arch_selected:
selected = arch_selected
alternatives = [selected] + [
model for model in ranked
if model["id"] != selected["id"] and not is_spark_aarch64_excluded_model(model)
][:2]
policy = f"{POLICY}+{arch_policy_tag}"
source = "catalog_arch_policy_pre_download"
reason = arch_policy_reason(selected, capacity_gb, memory_label, arch_policy_tag)
else:
selected = ranked[0]
alternatives = ranked[:3]
policy = POLICY
source = "catalog_runtime_profile_pre_download" if selected.get("_runtime_profile") else "catalog_fit_pre_download"
reason = recommendation_reason(selected, capacity_gb, memory_label, args.backend, confidence)
selected_public = {key: value for key, value in selected.items() if key != "_runtime_profile"}
payload = {
"policy": policy,
"source": source,
"confidence": confidence,
"profile": profile,
"host_arch": normalize_host_arch(args.host_arch),
"memory_capacity_gb": round(capacity_gb, 1),
"memory_label": memory_label,
"selected": selected_public,
"reason": reason,
"alternatives": [
{
"id": model["id"],
"name": model["name"],
"gguf": model["gguf_file"],
"vram_required_gb": model["vram_required_gb"],
"estimated_required_gb": effective_required_memory_gb(model, model.get("_runtime_profile")),
"context_length": effective_context_length(model, model.get("_runtime_profile")),
"specialty": model["specialty"],
"runtime_profile": (model.get("_runtime_profile") or {}).get("id"),
}
for model in alternatives
],
}
if not args.env:
print(json.dumps(payload, indent=2))
return 0
alt_value = ";".join(
f"{m['id']}:{effective_context_length(m, m.get('_runtime_profile'))}:{effective_required_memory_gb(m, m.get('_runtime_profile')):g}"
for m in alternatives
)
runtime_profile = selected.get("_runtime_profile") if isinstance(selected.get("_runtime_profile"), dict) else None
env = {
"LLM_MODEL": selected["llm_model_name"],
"GGUF_FILE": selected["gguf_file"],
"GGUF_URL": selected["gguf_url"],
"GGUF_SHA256": selected["gguf_sha256"],
"MAX_CONTEXT": effective_context_length(selected, runtime_profile),
"LLM_MODEL_SIZE_MB": int(round(float(selected["size_mb"]))),
"MODEL_RECOMMENDATION_SOURCE": payload["source"],
"MODEL_RECOMMENDATION_POLICY": payload["policy"],
"MODEL_RECOMMENDATION_CONFIDENCE": payload["confidence"],
"MODEL_RECOMMENDATION_REASON": payload["reason"],
"MODEL_RECOMMENDED_ALTERNATIVES": alt_value,
}
if runtime_profile:
env["MODEL_RUNTIME_PROFILE"] = runtime_profile.get("id", "")
env["MODEL_RUNTIME_PROFILE_LABEL"] = runtime_profile.get("label", "")
env["MODEL_RUNTIME_PROFILE_SOURCE"] = runtime_profile.get("source_url", "")
if runtime_profile.get("llama_server_image"):
env["LLAMA_SERVER_IMAGE"] = runtime_profile["llama_server_image"]
for key, value in (runtime_profile.get("env") or {}).items():
if value is not None:
env[str(key)] = value
elif selected.get("llama_server_image"):
env["LLAMA_SERVER_IMAGE"] = selected["llama_server_image"]
for key, value in env.items():
print(f"{key}={shell_value(value)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+176
View File
@@ -0,0 +1,176 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# ODS - Session Cleanup Script
# https://github.com/Light-Heart-Labs/ODS
#
# Prevents context overflow crashes by automatically managing
# session file lifecycle. When a session file exceeds the size
# threshold, it's deleted and its reference removed from
# sessions.json, forcing the gateway to create a fresh session.
#
# The agent doesn't notice — it just gets a clean context window.
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
# ── Configuration ──────────────────────────────────────────────
# Strix Halo: OpenClaw runs in Docker, sessions are in data volume
OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/ods/data/openclaw/home}"
SESSIONS_DIR="${SESSIONS_DIR:-$OPENCLAW_DIR/agents/main/sessions}"
SESSIONS_JSON="$SESSIONS_DIR/sessions.json"
MAX_SIZE="${MAX_SIZE:-256000}"
usage() {
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Prevents context overflow by pruning OpenClaw session files: removes inactive"
echo "sessions and deletes bloated ones (over size threshold), then updates"
echo "sessions.json so the gateway creates a fresh session."
echo ""
echo "Options:"
echo " -h, --help Show this help and exit."
echo ""
echo "Environment:"
echo " OPENCLAW_DIR Base OpenClaw dir (default: \$HOME/ods/data/openclaw/home)"
echo " SESSIONS_DIR Sessions directory (default: \$OPENCLAW_DIR/agents/main/sessions)"
echo " MAX_SIZE Max session file size in bytes (default: 256000)"
echo ""
echo "Exit: 0 (always; missing paths are skipped with a log message)."
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
# ── Preflight ──────────────────────────────────────────────────
if [ ! -f "$SESSIONS_JSON" ]; then
echo "[$(date)] No sessions.json found at $SESSIONS_JSON, skipping"
exit 0
fi
if [ ! -d "$SESSIONS_DIR" ]; then
echo "[$(date)] Sessions directory not found at $SESSIONS_DIR, skipping"
exit 0
fi
# ── Extract active session IDs (portable: no grep -P) ─────────
ACTIVE_IDS_EXIT=0
ACTIVE_IDS=$(grep -oE '"sessionId"[[:space:]]*:[[:space:]]*"[^"]+"' "$SESSIONS_JSON" 2>&1 | sed -E 's/.*"sessionId"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') || ACTIVE_IDS_EXIT=$?
if [[ $ACTIVE_IDS_EXIT -ne 0 ]]; then
ACTIVE_IDS=""
fi
echo "[$(date)] Session cleanup starting"
echo "[$(date)] Sessions dir: $SESSIONS_DIR"
echo "[$(date)] Max size threshold: $MAX_SIZE bytes"
echo "[$(date)] Active sessions found: $(echo "$ACTIVE_IDS" | wc -w)"
# ── Clean up debris ────────────────────────────────────────────
DELETED_EXIT=0
DELETED_COUNT=$(find "$SESSIONS_DIR" -name '*.deleted.*' -delete -print 2>&1 | wc -l) || DELETED_EXIT=$?
if [[ $DELETED_EXIT -ne 0 ]]; then
DELETED_COUNT=0
fi
BAK_EXIT=0
BAK_COUNT=$(find "$SESSIONS_DIR" -name '*.bak*' -not -name '*.bak-cleanup' -delete -print 2>&1 | wc -l) || BAK_EXIT=$?
if [[ $BAK_EXIT -ne 0 ]]; then
BAK_COUNT=0
fi
if [ "$DELETED_COUNT" -gt 0 ] || [ "$BAK_COUNT" -gt 0 ]; then
echo "[$(date)] Cleaned up $DELETED_COUNT .deleted files, $BAK_COUNT .bak files"
fi
# ── Process session files ──────────────────────────────────────
WIPE_IDS=""
REMOVED_INACTIVE=0
REMOVED_BLOATED=0
for f in "$SESSIONS_DIR"/*.jsonl; do
[ -f "$f" ] || continue
BASENAME=$(basename "$f" .jsonl)
# Check if this session is active
IS_ACTIVE=false
for ID in $ACTIVE_IDS; do
if [ "$BASENAME" = "$ID" ]; then
IS_ACTIVE=true
break
fi
done
if [ "$IS_ACTIVE" = false ]; then
SIZE=$(du -h "$f" | cut -f1)
echo "[$(date)] Removing inactive session: $BASENAME ($SIZE)"
rm -f "$f"
REMOVED_INACTIVE=$((REMOVED_INACTIVE + 1))
else
# Portable stat: Linux uses -c%s, macOS uses -f%z
stat_exit=0
if [ "$(uname -s)" = "Darwin" ]; then
SIZE_BYTES=$(stat -f%z "$f" 2>&1) || stat_exit=$?
else
SIZE_BYTES=$(stat -c%s "$f" 2>&1) || stat_exit=$?
fi
if [[ $stat_exit -ne 0 ]]; then
SIZE_BYTES=0
fi
if [ "$SIZE_BYTES" -gt "$MAX_SIZE" ]; then
SIZE=$(du -h "$f" | cut -f1)
SIZE_LABEL=$(command -v numfmt >/dev/null 2>&1 && numfmt --to=iec "$MAX_SIZE" || echo "${MAX_SIZE}B")
echo "[$(date)] Session $BASENAME is bloated ($SIZE > ${SIZE_LABEL}), deleting to force fresh session"
rm -f "$f"
WIPE_IDS="$WIPE_IDS $BASENAME"
REMOVED_BLOATED=$((REMOVED_BLOATED + 1))
fi
fi
done
# ── Remove wiped session references from sessions.json ─────────
if [ -n "$WIPE_IDS" ]; then
echo "[$(date)] Clearing session references from sessions.json for:$WIPE_IDS"
cp "$SESSIONS_JSON" "$SESSIONS_JSON.bak-cleanup"
for ID in $WIPE_IDS; do
PYTHON_CMD="python3"
if [[ -f "$(dirname "$0")/../lib/python-cmd.sh" ]]; then
. "$(dirname "$0")/../lib/python-cmd.sh"
PYTHON_CMD="$(ods_detect_python_cmd)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
"$PYTHON_CMD" -c "
import json, sys
sessions_file = sys.argv[1]
target_id = sys.argv[2]
with open(sessions_file, 'r') as f:
data = json.load(f)
to_remove = [k for k, v in data.items() if isinstance(v, dict) and v.get('sessionId') == target_id]
for k in to_remove:
del data[k]
print(f' Removed session key: {k}', file=sys.stderr)
with open(sessions_file, 'w') as f:
json.dump(data, f, indent=2)
" "$SESSIONS_JSON" "$ID" 2>&1
done
# Clean up the backup
rm -f "$SESSIONS_JSON.bak-cleanup"
fi
# ── Summary ────────────────────────────────────────────────────
echo "[$(date)] Cleanup complete: removed $REMOVED_INACTIVE inactive, $REMOVED_BLOATED bloated"
REMAINING_EXIT=0
REMAINING=$(find "$SESSIONS_DIR" -maxdepth 1 -name '*.jsonl' 2>&1 | wc -l) || REMAINING_EXIT=$?
if [[ $REMAINING_EXIT -ne 0 ]]; then
REMAINING=0
fi
echo "[$(date)] Remaining session files: $REMAINING"
if [ "$REMAINING" -gt 0 ]; then
ls_exit=0
ls -lhS "$SESSIONS_DIR"/*.jsonl 2>&1 | while read -r line; do
echo " $line"
done || ls_exit=$?
fi
+382
View File
@@ -0,0 +1,382 @@
#!/bin/bash
# ODS Interactive Showcase
# Demonstrates all capabilities in an interactive menu
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ODS_DIR="$(dirname "$SCRIPT_DIR")"
# Source service registry for port resolution
if [[ -f "$ODS_DIR/lib/service-registry.sh" ]]; then
export SCRIPT_DIR="$ODS_DIR"
. "$ODS_DIR/lib/service-registry.sh"
sr_load
[[ -f "$ODS_DIR/lib/safe-env.sh" ]] && . "$ODS_DIR/lib/safe-env.sh"
load_env_file "$ODS_DIR/.env"
sr_resolve_ports
fi
# URLs — resolved from registry
LLM_URL="${LLM_URL:-http://localhost:${SERVICE_PORTS[llama-server]:-11434}}"
WHISPER_URL="${WHISPER_URL:-http://localhost:${SERVICE_PORTS[whisper]:-9000}}"
TTS_URL="${TTS_URL:-http://localhost:${SERVICE_PORTS[tts]:-8880}}"
QDRANT_URL="${QDRANT_URL:-http://localhost:${SERVICE_PORTS[qdrant]:-6333}}"
EXAMPLES_DIR="$ODS_DIR/examples"
clear_screen() {
printf "\033[2J\033[H"
}
print_header() {
echo ""
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ ODS Interactive Showcase ║${NC}"
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
}
print_menu() {
echo -e "${BOLD}What would you like to try?${NC}"
echo ""
echo -e " ${CYAN}[1]${NC} 💬 Chat with AI"
echo -e " ${CYAN}[2]${NC} 🎤 Voice-to-Voice Demo"
echo -e " ${CYAN}[3]${NC} 📚 Document Q&A (RAG)"
echo -e " ${CYAN}[4]${NC} 💻 Code Assistant"
echo -e " ${CYAN}[5]${NC} 📊 System Status"
echo -e " ${CYAN}[Q]${NC} 🚪 Quit"
echo ""
echo -ne "${BOLD}Select an option: ${NC}"
}
check_service() {
local url=$1
local endpoint=$2
curl -sf "${url}${endpoint}" > /dev/null 2>&1
}
demo_chat() {
clear_screen
echo -e "${BOLD}${MAGENTA}💬 Chat with AI${NC}"
echo -e "${DIM}────────────────────────────────────────${NC}"
echo ""
if ! check_service "$LLM_URL" "/health"; then
echo -e "${RED}Error: LLM is not running${NC}"
echo "Start ODS first: docker compose up -d"
return
fi
echo -e "${DIM}Type your message (or 'back' to return to menu):${NC}"
echo ""
while true; do
echo -ne "${GREEN}You: ${NC}"
read -r user_input
if [[ "${user_input,,}" == "back" ]]; then
return
fi
if [[ -z "$user_input" ]]; then
continue
fi
echo -ne "${CYAN}AI: ${NC}"
response=$(curl -sf "${LLM_URL}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg msg "$user_input" '{
model: "local",
messages: [{role: "user", content: $msg}],
max_tokens: 512,
temperature: 0.7
}')" 2>/dev/null | jq -r '.choices[0].message.content // "Error getting response"')
echo "$response"
echo ""
done
}
demo_voice() {
clear_screen
echo -e "${BOLD}${MAGENTA}🎤 Voice-to-Voice Demo${NC}"
echo -e "${DIM}────────────────────────────────────────${NC}"
echo ""
if ! check_service "$WHISPER_URL" "/health"; then
echo -e "${YELLOW}Whisper (STT) not running. Voice input disabled.${NC}"
echo -e "${DIM}Enable with: docker compose ps whisper # Voice services start with the stack${NC}"
echo ""
fi
if ! check_service "$TTS_URL" "/health"; then
echo -e "${YELLOW}Kokoro (TTS) not running. Voice output disabled.${NC}"
echo -e "${DIM}Enable with: docker compose ps whisper # Voice services start with the stack${NC}"
echo ""
fi
# Check for example audio
if [[ -f "$EXAMPLES_DIR/sample-audio.wav" ]]; then
echo -e "${GREEN}Found example audio file${NC}"
echo ""
echo "To test voice-to-voice:"
echo ""
echo -e " ${CYAN}# Transcribe audio${NC}"
echo " curl -X POST ${WHISPER_URL}/asr -F 'audio_file=@${EXAMPLES_DIR}/sample-audio.wav'"
echo ""
echo -e " ${CYAN}# Generate speech${NC}"
echo " curl -X POST ${TTS_URL}/synthesize -d '{\"text\": \"Hello from ODS\"}' -o output.wav"
else
echo "Voice demo requires audio recording."
echo ""
echo -e "${CYAN}Quick test:${NC}"
echo ""
echo " # Record 5 seconds of audio (requires sox)"
echo " rec -r 16000 -c 1 test.wav trim 0 5"
echo ""
echo " # Transcribe"
echo " curl -X POST ${WHISPER_URL}/asr -F 'audio_file=@test.wav'"
echo ""
echo " # Text to speech"
echo " curl -X POST ${TTS_URL}/synthesize -d '{\"text\": \"Your text here\"}' -o output.wav"
fi
echo ""
echo -e "${DIM}Press Enter to return to menu...${NC}"
read -r
}
demo_rag() {
clear_screen
echo -e "${BOLD}${MAGENTA}📚 Document Q&A (RAG Demo)${NC}"
echo -e "${DIM}────────────────────────────────────────${NC}"
echo ""
if ! check_service "$LLM_URL" "/health"; then
echo -e "${RED}Error: LLM is not running${NC}"
return
fi
if ! check_service "$QDRANT_URL" "/healthz"; then
echo -e "${YELLOW}Qdrant not running. Enable with: docker compose ps qdrant # RAG services start with the stack${NC}"
echo ""
echo -e "${DIM}Press Enter to return to menu...${NC}"
read -r
return
fi
# Use example doc if available
if [[ -f "$EXAMPLES_DIR/sample-doc.txt" ]]; then
echo -e "${GREEN}Using example document...${NC}"
DOC_CONTENT=$(cat "$EXAMPLES_DIR/sample-doc.txt")
else
echo "Enter document text (or paste content, then Ctrl+D):"
DOC_CONTENT=$(cat)
fi
if [[ -z "$DOC_CONTENT" ]]; then
echo -e "${YELLOW}No content provided${NC}"
return
fi
echo ""
echo -e "${CYAN}Indexing document...${NC}"
# Simple RAG demo using direct LLM (without full workflow for CLI demo)
echo -e "${GREEN}✓ Document loaded (${#DOC_CONTENT} chars)${NC}"
echo ""
echo -e "${DIM}Ask questions about the document (or 'back' to return):${NC}"
echo ""
while true; do
echo -ne "${GREEN}Question: ${NC}"
read -r question
if [[ "${question,,}" == "back" ]]; then
return
fi
if [[ -z "$question" ]]; then
continue
fi
echo -ne "${CYAN}Answer: ${NC}"
# Use document as context
response=$(curl -sf "${LLM_URL}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg doc "$DOC_CONTENT" --arg q "$question" '{
model: "local",
messages: [
{role: "system", content: "Answer questions based on the provided document. Be concise and cite relevant parts."},
{role: "user", content: ("Document:\n" + $doc + "\n\nQuestion: " + $q)}
],
max_tokens: 512,
temperature: 0.3
}')" 2>/dev/null | jq -r '.choices[0].message.content // "Error getting response"')
echo "$response"
echo ""
done
}
demo_code() {
clear_screen
echo -e "${BOLD}${MAGENTA}💻 Code Assistant${NC}"
echo -e "${DIM}────────────────────────────────────────${NC}"
echo ""
if ! check_service "$LLM_URL" "/health"; then
echo -e "${RED}Error: LLM is not running${NC}"
return
fi
echo "What would you like to do?"
echo ""
echo -e " ${CYAN}[1]${NC} Explain code"
echo -e " ${CYAN}[2]${NC} Improve code"
echo -e " ${CYAN}[3]${NC} Debug code"
echo -e " ${CYAN}[4]${NC} Add documentation"
echo -e " ${CYAN}[5]${NC} Generate tests"
echo ""
echo -ne "Select task: "
read -r task_choice
case $task_choice in
1) task="explain" ;;
2) task="improve" ;;
3) task="debug" ;;
4) task="document" ;;
5) task="test" ;;
*) task="explain" ;;
esac
echo ""
# Use example code if available
if [[ -f "$EXAMPLES_DIR/sample-code.py" ]]; then
echo -e "${GREEN}Using example Python code...${NC}"
CODE=$(cat "$EXAMPLES_DIR/sample-code.py")
echo ""
echo -e "${DIM}$CODE${NC}"
else
echo "Paste your code (then Ctrl+D):"
CODE=$(cat)
fi
if [[ -z "$CODE" ]]; then
echo -e "${YELLOW}No code provided${NC}"
return
fi
echo ""
echo -e "${CYAN}Analyzing...${NC}"
echo ""
prompt="Task: $task\n\nCode:\n\`\`\`\n$CODE\n\`\`\`"
response=$(curl -sf "${LLM_URL}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg p "$prompt" '{
model: "local",
messages: [
{role: "system", content: "You are an expert code reviewer. Provide clear, actionable feedback."},
{role: "user", content: $p}
],
max_tokens: 2048,
temperature: 0.3
}')" 2>/dev/null | jq -r '.choices[0].message.content // "Error getting response"')
echo -e "${GREEN}Result:${NC}"
echo "$response"
echo ""
echo -e "${DIM}Press Enter to return to menu...${NC}"
read -r
}
show_status() {
clear_screen
echo -e "${BOLD}${MAGENTA}📊 System Status${NC}"
echo -e "${DIM}────────────────────────────────────────${NC}"
echo ""
echo -e "${BOLD}Services:${NC}"
echo ""
for sid in "${SERVICE_IDS[@]}"; do
_port="${SERVICE_PORTS[$sid]:-0}"
_health="${SERVICE_HEALTH[$sid]:-/health}"
_name="${SERVICE_NAMES[$sid]:-$sid}"
[[ "$_port" == "0" ]] && continue
_url="http://localhost:${_port}"
if check_service "$_url" "$_health"; then
echo -e " ${GREEN}${NC} $_name ${DIM}($_url)${NC}"
else
echo -e " ${RED}${NC} $_name ${DIM}($_url)${NC}"
fi
done
echo ""
echo -e "${BOLD}GPU:${NC}"
echo ""
if command -v nvidia-smi &> /dev/null; then
nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu --format=csv,noheader 2>/dev/null | while read -r line; do
echo -e " ${CYAN}$line${NC}"
done
else
echo -e " ${DIM}nvidia-smi not available${NC}"
fi
echo ""
echo -e "${BOLD}Quick Links:${NC}"
echo ""
echo -e " Chat UI: ${CYAN}http://localhost:${SERVICE_PORTS[open-webui]:-3000}${NC}"
echo -e " Workflows: ${CYAN}http://localhost:${SERVICE_PORTS[n8n]:-5678}${NC}"
echo -e " API: ${CYAN}http://localhost:${SERVICE_PORTS[llama-server]:-11434}/v1${NC}"
echo ""
echo -e "${DIM}Press Enter to return to menu...${NC}"
read -r
}
# Main loop
while true; do
clear_screen
print_header
print_menu
read -r choice
case "${choice,,}" in
1) demo_chat ;;
2) demo_voice ;;
3) demo_rag ;;
4) demo_code ;;
5) show_status ;;
q|quit|exit)
echo ""
echo -e "${GREEN}Thanks for trying ODS! 🌙${NC}"
echo ""
exit 0
;;
*)
echo -e "${YELLOW}Invalid option${NC}"
sleep 1
;;
esac
done
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${1:-${ROOT_DIR}/artifacts/installer-sim}"
mkdir -p "$OUT_DIR"
LINUX_LOG="${OUT_DIR}/linux-dryrun.log"
LINUX_SUMMARY_JSON="${OUT_DIR}/linux-install-summary.json"
MACOS_LOG="${OUT_DIR}/macos-installer.log"
WINDOWS_SIM_JSON="${OUT_DIR}/windows-preflight-sim.json"
MACOS_PREFLIGHT_JSON="${OUT_DIR}/macos-preflight.json"
MACOS_DOCTOR_JSON="${OUT_DIR}/macos-doctor.json"
DOCTOR_JSON="${OUT_DIR}/doctor.json"
SUMMARY_JSON="${OUT_DIR}/summary.json"
SUMMARY_MD="${OUT_DIR}/SUMMARY.md"
GOLDEN_CONTRACT_JSON="${ROOT_DIR}/config/golden-paths.json"
GOLDEN_EVIDENCE_JSON="${OUT_DIR}/golden-paths.json"
FAKEBIN="$(mktemp -d)"
trap 'rm -rf "$FAKEBIN"' EXIT
cat > "${FAKEBIN}/curl" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "${FAKEBIN}/curl"
cd "$ROOT_DIR"
# 1) Linux installer dry-run simulation
LINUX_EXIT=0
if ! PATH="${FAKEBIN}:$PATH" bash install-core.sh --dry-run --non-interactive --skip-docker --force --summary-json "$LINUX_SUMMARY_JSON" >"$LINUX_LOG" 2>&1; then
LINUX_EXIT=$?
fi
# 2) macOS installer MVP simulation
MACOS_EXIT=0
if ! bash installers/macos.sh --no-delegate --report "$MACOS_PREFLIGHT_JSON" --doctor-report "$MACOS_DOCTOR_JSON" >"$MACOS_LOG" 2>&1; then
MACOS_EXIT=$?
fi
# 3) Windows scenario simulation via preflight engine (since pwsh may be unavailable in CI/sandbox)
scripts/preflight-engine.sh \
--report "$WINDOWS_SIM_JSON" \
--tier T1 \
--ram-gb 16 \
--disk-gb 120 \
--gpu-backend nvidia \
--gpu-vram-mb 12288 \
--gpu-name "RTX 3060" \
--platform-id windows \
--compose-overlays docker-compose.base.yml,docker-compose.nvidia.yml \
--script-dir "$ROOT_DIR" \
--env >/dev/null
# 4) Doctor snapshot for current machine context
DOCTOR_EXIT=0
if ! scripts/ods-doctor.sh "$DOCTOR_JSON" >/dev/null 2>&1; then
DOCTOR_EXIT=$?
fi
PYTHON_CMD="python3"
if [[ -f "$ROOT_DIR/lib/python-cmd.sh" ]]; then
. "$ROOT_DIR/lib/python-cmd.sh"
PYTHON_CMD="$(ods_detect_python_cmd)"
elif command -v python >/dev/null 2>&1; then
PYTHON_CMD="python"
fi
"$PYTHON_CMD" - "$SUMMARY_JSON" "$SUMMARY_MD" "$GOLDEN_EVIDENCE_JSON" "$GOLDEN_CONTRACT_JSON" "$LINUX_LOG" "$MACOS_LOG" "$WINDOWS_SIM_JSON" "$MACOS_PREFLIGHT_JSON" "$MACOS_DOCTOR_JSON" "$DOCTOR_JSON" "$LINUX_SUMMARY_JSON" "$LINUX_EXIT" "$MACOS_EXIT" "$DOCTOR_EXIT" <<'PY'
import json
import pathlib
import re
import sys
from datetime import datetime, timezone
(
summary_json_path,
summary_md_path,
golden_evidence_json_path,
golden_contract_json_path,
linux_log,
macos_log,
windows_sim_json,
macos_preflight_json,
macos_doctor_json,
doctor_json,
linux_install_summary_json,
linux_exit,
macos_exit,
doctor_exit,
) = sys.argv[1:]
root_dir = pathlib.Path.cwd()
def load_json(path):
p = pathlib.Path(path)
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
linux_text = pathlib.Path(linux_log).read_text(encoding="utf-8", errors="replace") if pathlib.Path(linux_log).exists() else ""
macos_text = pathlib.Path(macos_log).read_text(encoding="utf-8", errors="replace") if pathlib.Path(macos_log).exists() else ""
linux_signals = {
"capability_loaded": bool(re.search(r"Capability profile loaded", linux_text)),
"hardware_class_logged": bool(re.search(r"Hardware class:", linux_text)),
"backend_contract_loaded": bool(re.search(r"Backend contract loaded", linux_text)),
"preflight_report_logged": bool(re.search(r"Preflight report:", linux_text)),
"compose_selection_logged": bool(re.search(r"Compose selection:", linux_text)),
}
summary = {
"version": "1",
"generated_at": datetime.now(timezone.utc).isoformat(),
"runs": {
"linux_dryrun": {
"exit_code": int(linux_exit),
"signals": linux_signals,
"log": linux_log,
"install_summary": load_json(linux_install_summary_json) or {},
},
"macos_installer_mvp": {
"exit_code": int(macos_exit),
"log": macos_log,
"preflight": load_json(macos_preflight_json),
"doctor": load_json(macos_doctor_json),
},
"windows_scenario_preflight": {
"report": load_json(windows_sim_json),
},
"doctor_snapshot": {
"exit_code": int(doctor_exit),
"report": load_json(doctor_json),
},
},
}
def run_status(run_name, run):
if run_name == "linux_dryrun":
signals = run.get("signals") or {}
return {
"ok": run.get("exit_code") == 0 and all(signals.values()),
"exit_code": run.get("exit_code"),
"required_signals": signals,
}
if run_name == "macos_installer_mvp":
summary_block = ((run.get("preflight") or {}).get("summary") or {})
blockers = summary_block.get("blockers")
return {
"ok": run.get("exit_code") == 0 and (blockers in (0, None)),
"exit_code": run.get("exit_code"),
"blockers": blockers,
"warnings": summary_block.get("warnings"),
}
if run_name == "windows_scenario_preflight":
summary_block = ((run.get("report") or {}).get("summary") or {})
blockers = summary_block.get("blockers")
return {
"ok": blockers == 0,
"blockers": blockers,
"warnings": summary_block.get("warnings"),
}
return {"ok": run is not None}
def build_golden_evidence():
contract = load_json(golden_contract_json_path) or {"scenarios": []}
evidence = {
"version": "1",
"generated_at": summary["generated_at"],
"contract": golden_contract_json_path,
"scenarios": [],
}
runs = summary.get("runs") or {}
for scenario in contract.get("scenarios", []):
installer = scenario.get("installer") or {}
expected = scenario.get("expected") or {}
run_name = installer.get("ci_simulation")
run = runs.get(run_name) if run_name else None
compose_files = expected.get("compose_files") or []
generated_configs = expected.get("generated_configs") or []
health_checks = expected.get("health_checks") or []
missing_compose = [
path for path in compose_files
if not (root_dir / path).exists()
]
status = run_status(run_name, run or {})
scenario_ok = bool(status.get("ok")) and not missing_compose
evidence["scenarios"].append({
"id": scenario.get("id"),
"label": scenario.get("label"),
"status": "pass" if scenario_ok else "fail",
"simulation_run": run_name,
"simulation_status": status,
"expected": {
"ods_mode": expected.get("ods_mode"),
"llm_backend": expected.get("llm_backend"),
"llm_host_port": expected.get("llm_host_port"),
"llm_container_port": expected.get("llm_container_port"),
"model_route": expected.get("model_route") or {},
"compose_files": compose_files,
"generated_config_surfaces": [
item.get("surface") for item in generated_configs
if isinstance(item, dict)
],
"health_check_services": [
item.get("service") for item in health_checks
if isinstance(item, dict)
],
},
"checks": {
"compose_files_exist": not missing_compose,
"missing_compose_files": missing_compose,
"generated_config_count": len(generated_configs),
"health_check_count": len(health_checks),
},
})
return evidence
golden_evidence = build_golden_evidence()
summary["golden_paths"] = {
"contract": golden_contract_json_path,
"evidence": golden_evidence_json_path,
"scenario_count": len(golden_evidence.get("scenarios", [])),
"pass_count": sum(1 for item in golden_evidence.get("scenarios", []) if item.get("status") == "pass"),
}
pathlib.Path(summary_json_path).write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
pathlib.Path(golden_evidence_json_path).write_text(json.dumps(golden_evidence, indent=2) + "\n", encoding="utf-8")
lines = []
lines.append("# Installer Simulation Summary")
lines.append("")
lines.append(f"Generated: {summary['generated_at']}")
lines.append("")
lines.append("## Linux Dry-Run")
lines.append(f"- Exit code: {linux_exit}")
for k, v in linux_signals.items():
lines.append(f"- {k}: {'yes' if v else 'no'}")
lines.append(f"- Log: `{linux_log}`")
lines.append("")
mp = summary["runs"]["macos_installer_mvp"].get("preflight") or {}
ms = (mp.get("summary") or {})
lines.append("## macOS Installer MVP")
lines.append(f"- Exit code: {macos_exit}")
lines.append(f"- Preflight blockers: {ms.get('blockers', 'n/a')}")
lines.append(f"- Preflight warnings: {ms.get('warnings', 'n/a')}")
lines.append(f"- Log: `{macos_log}`")
lines.append(f"- Preflight JSON: `{macos_preflight_json}`")
lines.append(f"- Doctor JSON: `{macos_doctor_json}`")
lines.append("")
wp = summary["runs"]["windows_scenario_preflight"].get("report") or {}
ws = (wp.get("summary") or {})
lines.append("## Windows Scenario (Simulated)")
lines.append(f"- Preflight blockers: {ws.get('blockers', 'n/a')}")
lines.append(f"- Preflight warnings: {ws.get('warnings', 'n/a')}")
lines.append(f"- Report: `{windows_sim_json}`")
lines.append("")
dr = summary["runs"]["doctor_snapshot"].get("report") or {}
dsum = dr.get("summary") or {}
lines.append("## Doctor Snapshot")
lines.append(f"- Exit code: {doctor_exit}")
lines.append(f"- Runtime ready: {dsum.get('runtime_ready', 'n/a')}")
lines.append(f"- Report: `{doctor_json}`")
lines.append("")
lines.append("## Golden Paths")
for item in golden_evidence.get("scenarios", []):
status = item.get("status", "unknown")
label = item.get("label") or item.get("id")
run_name = item.get("simulation_run") or "n/a"
lines.append(f"- {label}: {status} via `{run_name}`")
lines.append(f"- Evidence JSON: `{golden_evidence_json_path}`")
pathlib.Path(summary_md_path).write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
if [[ -x "${ROOT_DIR}/scripts/validate-sim-summary.py" ]]; then
"${ROOT_DIR}/scripts/validate-sim-summary.py" "$SUMMARY_JSON"
fi
echo "Installer simulation complete."
echo " JSON: $SUMMARY_JSON"
echo " MD: $SUMMARY_MD"
echo " Golden paths: $GOLDEN_EVIDENCE_JSON"
@@ -0,0 +1,6 @@
[Unit]
Description=Memory Shepherd — MEMORY.md Baseline Reset
[Service]
Type=oneshot
ExecStart=%h/ods/memory-shepherd/memory-shepherd.sh ods-agent-memory
@@ -0,0 +1,11 @@
[Unit]
Description=Memory Shepherd — MEMORY.md Timer (3h)
[Timer]
OnBootSec=5min
OnCalendar=*-*-* 00/3:00:00
RandomizedDelaySec=5min
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,7 @@
[Unit]
Description=Memory Shepherd — Workspace Files (AGENTS.md + TOOLS.md)
[Service]
Type=oneshot
ExecStart=%h/ods/memory-shepherd/memory-shepherd.sh ods-agent-agents
ExecStart=%h/ods/memory-shepherd/memory-shepherd.sh ods-agent-tools
@@ -0,0 +1,10 @@
[Unit]
Description=Memory Shepherd — Workspace Files Timer (60s)
[Timer]
OnBootSec=20s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=ODS first-boot AP mode (hostapd + dnsmasq captive portal)
# Order AFTER NetworkManager, not before. ap-mode.sh's bring-up calls
# `nmcli device set <iface> managed no` to release the wireless
# interface — that only works if NetworkManager is actually running.
# An earlier draft had `Before=NetworkManager.service`, which let the
# unit start before NM was up; the nmcli release was a no-op, and NM
# could then reclaim the interface from hostapd a moment later. By
# ordering after, we're guaranteed NM is alive to honor the release.
After=network-pre.target NetworkManager.service
Wants=NetworkManager.service
# OPT-IN. This unit is installed but NOT enabled by the installer.
# Enable it manually with `systemctl enable --now ods-ap-mode`
# only after writing /etc/ods/ap-mode.conf with the AP credentials.
# See docs/AP-MODE.md.
[Service]
Type=forking
# ap-mode.sh launches hostapd + dnsmasq in the background then exits.
# Both daemons write PID files into /run/ods-ap-mode/.
ExecStart=__INSTALL_DIR__/scripts/ap-mode.sh up
ExecStop=__INSTALL_DIR__/scripts/ap-mode.sh down
PIDFile=/run/ods-ap-mode/hostapd.pid
RemainAfterExit=yes
# Don't restart automatically — if hostapd died, we want the operator
# to see why. Auto-restart would hide a deeper problem (bad config,
# driver, kernel) behind retry loops.
Restart=no
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,18 @@
[Unit]
Description=ODS Host Agent
After=docker.service
[Service]
Type=simple
User=__INSTALL_USER__
SupplementaryGroups=docker
ExecStart=__PYTHON3__ __INSTALL_DIR__/bin/ods-host-agent.py
WorkingDirectory=__INSTALL_DIR__
Environment=ODS_HOME=__INSTALL_DIR__
Environment=HOME=__HOME__
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=ODS mDNS Announcer
After=network-online.target avahi-daemon.service
Wants=network-online.target
[Service]
Type=simple
User=__INSTALL_USER__
ExecStart=__PYTHON3__ __INSTALL_DIR__/bin/ods-mdns.py
WorkingDirectory=__INSTALL_DIR__
Environment=ODS_INSTALL_DIR=__INSTALL_DIR__
Environment=HOME=__HOME__
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,9 @@
[Unit]
Description=OpenClaw Session Cleanup
After=network.target
[Service]
Type=oneshot
Environment=SESSIONS_DIR=%h/ods/data/openclaw/home/agents/main/sessions
Environment=MAX_SIZE=80000
ExecStart=%h/ods/scripts/session-cleanup.sh
@@ -0,0 +1,10 @@
[Unit]
Description=OpenClaw Session Cleanup Timer
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target
@@ -0,0 +1,25 @@
[CmdletBinding()]
param(
[string]$InstallDir = ""
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path $ScriptDir -Parent
$WindowsInstallerDir = Join-Path (Join-Path $RepoRoot "installers") "windows"
$LibDir = Join-Path $WindowsInstallerDir "lib"
. (Join-Path $LibDir "constants.ps1")
. (Join-Path $LibDir "ui.ps1")
. (Join-Path $LibDir "llm-endpoint.ps1")
. (Join-Path $LibDir "opencode-config.ps1")
if ([string]::IsNullOrWhiteSpace($InstallDir)) {
$InstallDir = $script:ODS_INSTALL_DIR
}
$sync = Sync-WindowsOpenCodeConfigFromEnv -InstallDir $InstallDir -SkipIfUnavailable
if ($sync.Status -ne "skipped") {
Write-Host "OpenCode config $($sync.Status) for model $($sync.ModelName)"
}
+478
View File
@@ -0,0 +1,478 @@
#!/bin/bash
#=============================================================================
# upgrade-model.sh — Legacy Model Directory Upgrade with Rollback
#
# Part of ODS — Phase 0 Foundation
#
# Legacy helper for older model-directory layouts. Current ODS GGUF
# installs should prefer the Dashboard Models page and docs/MODEL-MANAGEMENT.md.
#
# Usage:
# ./upgrade-model.sh <new-model> # Upgrade to new model
# ./upgrade-model.sh --rollback # Rollback to previous model
# ./upgrade-model.sh --list # List available models
# ./upgrade-model.sh --current # Show current model
#
#=============================================================================
set -euo pipefail
# Prerequisites
command -v jq >/dev/null 2>&1 || { echo "Error: jq is required but not installed." >&2; exit 1; }
# Configuration
ODS_DIR="${ODS_DIR:-$HOME/ods}"
MODELS_DIR="${MODELS_DIR:-$ODS_DIR/data/models}"
STATE_FILE="$ODS_DIR/model-state.json"
BACKUP_FILE="$ODS_DIR/model-state.backup.json"
LOG_FILE="$ODS_DIR/upgrade-model.log"
OLLAMA_PORT="${OLLAMA_PORT:-${LLAMA_SERVER_PORT:-11434}}"
LLAMA_SERVER_CONTAINER="${LLAMA_SERVER_CONTAINER:-ods-llama-server}"
HEALTH_CHECK_TIMEOUT=120 # seconds
HEALTH_CHECK_INTERVAL=5 # seconds
INFERENCE_SERVICE="llama-server"
INFERENCE_PORT="$OLLAMA_PORT"
INFERENCE_CONTAINER="$LLAMA_SERVER_CONTAINER"
MODEL_ENV_KEY="LLM_MODEL"
detect_compose_file() {
COMPOSE_FILE_ARGS=()
if [[ -f "$ODS_DIR/docker-compose.base.yml" && -f "$ODS_DIR/docker-compose.amd.yml" ]]; then
COMPOSE_FILE_ARGS=(-f "$ODS_DIR/docker-compose.base.yml" -f "$ODS_DIR/docker-compose.amd.yml")
elif [[ -f "$ODS_DIR/docker-compose.base.yml" && -f "$ODS_DIR/docker-compose.nvidia.yml" ]]; then
COMPOSE_FILE_ARGS=(-f "$ODS_DIR/docker-compose.base.yml" -f "$ODS_DIR/docker-compose.nvidia.yml")
elif [[ -f "$ODS_DIR/docker-compose.yml" ]]; then
COMPOSE_FILE_ARGS=(-f "$ODS_DIR/docker-compose.yml")
fi
}
detect_inference_service() {
if [[ ${#COMPOSE_FILE_ARGS[@]} -eq 0 ]]; then
echo "llama-server"
return
fi
if docker compose "${COMPOSE_FILE_ARGS[@]}" config --services 2>/dev/null | grep -q '^llama-server$'; then
echo "llama-server"
else
echo "llama-server"
fi
}
resolve_inference_runtime() {
if command -v docker &> /dev/null; then
detect_compose_file
INFERENCE_SERVICE=$(detect_inference_service)
else
INFERENCE_SERVICE="llama-server"
fi
INFERENCE_PORT="$OLLAMA_PORT"
INFERENCE_CONTAINER="$LLAMA_SERVER_CONTAINER"
MODEL_ENV_KEY="LLM_MODEL"
}
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
#-----------------------------------------------------------------------------
# Utility Functions
#-----------------------------------------------------------------------------
log() {
local msg
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1"
echo "$msg" >> "$LOG_FILE"
echo -e "${BLUE}[INFO]${NC} $1"
}
success() {
local msg
msg="[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1"
echo "$msg" >> "$LOG_FILE"
echo -e "${GREEN}[OK]${NC} $1"
}
warn() {
local msg
msg="[$(date '+%Y-%m-%d %H:%M:%S')] WARN: $1"
echo "$msg" >> "$LOG_FILE"
echo -e "${YELLOW}[WARN]${NC} $1"
}
error() {
local msg
msg="[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1"
echo "$msg" >> "$LOG_FILE"
echo -e "${RED}[ERROR]${NC} $1" >&2
}
ensure_dirs() {
mkdir -p "$ODS_DIR" "$MODELS_DIR"
touch "$LOG_FILE"
}
#-----------------------------------------------------------------------------
# State Management
#-----------------------------------------------------------------------------
get_current_model() {
if [[ -f "$STATE_FILE" ]]; then
grep -o '"current": *"[^"]*"' "$STATE_FILE" | cut -d'"' -f4
else
echo ""
fi
}
get_previous_model() {
if [[ -f "$STATE_FILE" ]]; then
grep -o '"previous": *"[^"]*"' "$STATE_FILE" | cut -d'"' -f4
else
echo ""
fi
}
save_state() {
local current="$1"
local previous="$2"
# Backup current state
[[ -f "$STATE_FILE" ]] && cp "$STATE_FILE" "$BACKUP_FILE"
jq -n --arg cur "$current" --arg prev "$previous" --arg ts "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" '{
current: $cur,
previous: $prev,
updatedAt: $ts,
history: [
{model: $cur, activatedAt: $ts}
]
}' > "$STATE_FILE"
}
#-----------------------------------------------------------------------------
# llama-server Operations
#-----------------------------------------------------------------------------
check_llm_health() {
resolve_inference_runtime
local response
response=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
"http://${LLM_HOST:-localhost}:${INFERENCE_PORT}/health" 2>/dev/null || echo "000")
[[ "$response" == "200" ]]
}
wait_for_llm() {
local timeout=$1
local elapsed=0
resolve_inference_runtime
log "Waiting for ${INFERENCE_SERVICE} to be ready (timeout: ${timeout}s)..."
while [[ $elapsed -lt $timeout ]]; do
if check_llm_health; then
success "${INFERENCE_SERVICE} is ready"
return 0
fi
sleep $HEALTH_CHECK_INTERVAL
elapsed=$((elapsed + HEALTH_CHECK_INTERVAL))
echo -n "."
done
echo ""
error "${INFERENCE_SERVICE} health check timed out after ${timeout}s"
return 1
}
test_inference() {
resolve_inference_runtime
log "Testing inference..."
local response
response=$(curl -s --max-time 10 "http://${LLM_HOST:-localhost}:${INFERENCE_PORT}/v1/models" 2>/dev/null || echo "")
if echo "$response" | grep -q '"data"'; then
success "Inference test passed"
return 0
else
error "Inference test failed"
echo "$response" >> "$LOG_FILE"
return 1
fi
}
stop_llm() {
resolve_inference_runtime
log "Stopping ${INFERENCE_SERVICE}..."
if command -v docker &> /dev/null; then
if [[ ${#COMPOSE_FILE_ARGS[@]} -gt 0 ]]; then
docker compose "${COMPOSE_FILE_ARGS[@]}" stop "$INFERENCE_SERVICE" 2>/dev/null || true
else
docker stop "$INFERENCE_CONTAINER" 2>/dev/null || true
docker wait "$INFERENCE_CONTAINER" 2>/dev/null || true
fi
elif command -v ods &> /dev/null; then
ods stop llama-server 2>/dev/null || true
else
warn "Cannot stop llama-server: no docker or ods CLI found"
return 1
fi
success "${INFERENCE_SERVICE} stopped"
}
start_llm() {
local model="$1"
resolve_inference_runtime
log "Starting ${INFERENCE_SERVICE} with model: $model"
# Update environment or compose file
local env_file="$ODS_DIR/.env"
if [[ -f "$env_file" ]]; then
# Update active model env key for detected inference backend.
if grep -q "^${MODEL_ENV_KEY}=" "$env_file"; then
# Use awk index() instead of sed to avoid delimiter collisions
awk -v k="$MODEL_ENV_KEY" -v v="$model" '{
if (index($0, k "=") == 1) print k "=" v; else print
}' "$env_file" > "${env_file}.tmp" && cat "${env_file}.tmp" > "$env_file" && rm -f "${env_file}.tmp"
else
echo "${MODEL_ENV_KEY}=$model" >> "$env_file"
fi
fi
if command -v docker &> /dev/null; then
# Start via docker compose (supports canonical base+overlay and legacy files)
if [[ ${#COMPOSE_FILE_ARGS[@]} -gt 0 ]]; then
docker compose "${COMPOSE_FILE_ARGS[@]}" up -d "$INFERENCE_SERVICE"
else
docker start "$INFERENCE_CONTAINER"
fi
elif command -v ods &> /dev/null; then
ods start llama-server
else
error "Cannot start llama-server: no docker or ods CLI found"
return 1
fi
}
#-----------------------------------------------------------------------------
# Main Commands
#-----------------------------------------------------------------------------
cmd_list() {
echo -e "${CYAN}Available Models:${NC}"
echo ""
if [[ -d "$MODELS_DIR" ]]; then
for model_dir in "$MODELS_DIR"/*/; do
if [[ -f "${model_dir}config.json" ]]; then
local model_name size current
model_name=$(basename "$model_dir")
size=$(du -sh "$model_dir" 2>/dev/null | cut -f1)
current=$(get_current_model)
if [[ "$model_name" == "$current" ]]; then
echo -e " ${GREEN}$model_name${NC} ($size) [ACTIVE]"
else
echo -e "$model_name ($size)"
fi
fi
done
else
echo " No models found in $MODELS_DIR"
fi
echo ""
echo "For current GGUF downloads and swaps, see: docs/MODEL-MANAGEMENT.md"
}
cmd_current() {
resolve_inference_runtime
local current
current=$(get_current_model)
if [[ -n "$current" ]]; then
echo -e "${CYAN}Current model:${NC} $current"
if check_llm_health; then
echo -e "${GREEN}Status:${NC} Running (${INFERENCE_SERVICE} on :${INFERENCE_PORT})"
else
echo -e "${RED}Status:${NC} Not responding (${INFERENCE_SERVICE} on :${INFERENCE_PORT})"
fi
else
echo "No model currently configured"
fi
}
cmd_upgrade() {
local new_model="$1"
ensure_dirs
# Validate new model exists
local model_path="$MODELS_DIR/$new_model"
if [[ ! -d "$model_path" ]] || [[ ! -f "$model_path/config.json" ]]; then
# Maybe it's a full path?
if [[ -d "$new_model" ]] && [[ -f "$new_model/config.json" ]]; then
model_path="$new_model"
new_model=$(basename "$new_model")
else
error "Model not found: $new_model"
error "Available models:"
cmd_list
return 1
fi
fi
local current_model
current_model=$(get_current_model)
if [[ "$current_model" == "$new_model" ]]; then
warn "Model $new_model is already active"
return 0
fi
log "Upgrading model: $current_model$new_model"
# Phase 1: Stop llama-server
echo ""
echo -e "${CYAN}Phase 1/4:${NC} Stopping llama-server..."
stop_llm || {
error "Failed to stop llama-server"
return 1
}
# Phase 2: Update configuration
echo -e "${CYAN}Phase 2/4:${NC} Updating configuration..."
save_state "$new_model" "$current_model"
success "Configuration updated"
# Phase 3: Start llama-server with new model
echo -e "${CYAN}Phase 3/4:${NC} Starting llama-server with new model..."
start_llm "$model_path" || {
error "Failed to start llama-server"
warn "Attempting rollback..."
cmd_rollback
return 1
}
# Phase 4: Health check
echo -e "${CYAN}Phase 4/4:${NC} Verifying health..."
if wait_for_llm $HEALTH_CHECK_TIMEOUT && test_inference; then
echo ""
success "Model upgrade complete!"
echo -e " Previous: ${YELLOW}$current_model${NC}"
echo -e " Current: ${GREEN}$new_model${NC}"
echo ""
echo "Rollback available with: $0 --rollback"
else
error "Health check failed"
warn "Attempting rollback..."
cmd_rollback
return 1
fi
}
cmd_rollback() {
local previous_model
previous_model=$(get_previous_model)
if [[ -z "$previous_model" ]]; then
error "No previous model to rollback to"
return 1
fi
local current_model
current_model=$(get_current_model)
log "Rolling back: $current_model$previous_model"
# Restore from backup state if available
if [[ -f "$BACKUP_FILE" ]]; then
cp "$BACKUP_FILE" "$STATE_FILE"
fi
local model_path="$MODELS_DIR/$previous_model"
stop_llm || true
start_llm "$model_path"
if wait_for_llm $HEALTH_CHECK_TIMEOUT && test_inference; then
success "Rollback complete"
save_state "$previous_model" "$current_model"
else
error "Rollback failed - manual intervention required"
error "Check logs: $LOG_FILE"
return 1
fi
}
#-----------------------------------------------------------------------------
# Entry Point
#-----------------------------------------------------------------------------
main() {
case "${1:-}" in
--list|-l)
cmd_list
;;
--current|-c)
cmd_current
;;
--rollback|-r)
cmd_rollback
;;
--help|-h)
cat << EOF
ODS Model Upgrade
Usage:
$0 <model-name> Upgrade to specified model
$0 --list List available models
$0 --current Show current model
$0 --rollback Rollback to previous model
$0 --help Show this help
Note:
This is a legacy helper for model directories containing config.json.
Current GGUF installs should use the Dashboard Models page or
docs/MODEL-MANAGEMENT.md.
Examples:
$0 Qwen2.5-32B-Instruct-AWQ
$0 /path/to/model
$0 --rollback
Environment Variables:
MODELS_DIR Models directory (default: $MODELS_DIR)
OLLAMA_PORT llama-server port (default: 11434)
LLAMA_SERVER_CONTAINER Docker container name (default: ods-llama-server)
EOF
;;
--*)
error "Unknown option: $1"
exit 1
;;
"")
error "Model name required"
echo "Usage: $0 <model-name>"
echo " $0 --list"
exit 1
;;
*)
cmd_upgrade "$1"
;;
esac
}
main "$@"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Validate resolved Docker Compose stack for syntax errors
# Usage: validate-compose-stack.sh --compose-flags "-f file1.yml -f file2.yml" [--env-file /path/to/.env]
#
# Returns:
# 0 - Valid compose stack
# 1 - Invalid compose stack (syntax errors, missing files, etc.)
set -euo pipefail
COMPOSE_FLAGS=""
ENV_FILE=""
QUIET=false
while [[ $# -gt 0 ]]; do
case "$1" in
--compose-flags)
COMPOSE_FLAGS="${2:-}"
shift 2
;;
--env-file)
ENV_FILE="${2:-}"
shift 2
;;
--quiet)
QUIET=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "$COMPOSE_FLAGS" ]]; then
echo "ERROR: --compose-flags required" >&2
exit 1
fi
# Build env-file flag if provided (allows compose to resolve required variable references)
ENV_FILE_FLAG=""
if [[ -n "$ENV_FILE" && -f "$ENV_FILE" ]]; then
ENV_FILE_FLAG="--env-file $ENV_FILE"
fi
# Check if docker/docker compose is available
if command -v docker &>/dev/null && docker compose version &>/dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &>/dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
echo "ERROR: docker compose not found" >&2
exit 1
fi
# Validate compose stack syntax
if ! $QUIET; then
echo "Validating compose stack: $COMPOSE_FLAGS"
fi
# Use docker compose config to validate syntax and merge
# This catches:
# - YAML syntax errors
# - Missing files
# - Invalid service definitions
# - Circular dependencies
# - Invalid environment variable references
validation_output=$(mktemp)
if $DOCKER_COMPOSE_CMD $ENV_FILE_FLAG $COMPOSE_FLAGS config > "$validation_output" 2>&1; then
if ! $QUIET; then
echo "Compose stack validation passed"
# Show summary of services
service_count=$(grep -c "^ [a-z]" "$validation_output" || echo "0")
echo " Services defined: $service_count"
fi
rm -f "$validation_output"
exit 0
else
echo "Compose stack validation FAILED" >&2
echo "" >&2
echo "Errors:" >&2
cat "$validation_output" >&2
echo "" >&2
echo "Compose flags: $COMPOSE_FLAGS" >&2
rm -f "$validation_output"
exit 1
fi
+376
View File
@@ -0,0 +1,376 @@
#!/bin/bash
# Validate .env against .env.schema.json
#
# Senior-grade validation goals:
# - Correctly parse .env files including quotes and "export KEY=..." lines
# - Report line numbers and actionable messages
# - Validate required keys, unknown keys, types, enums, and numeric ranges
# - Fail deterministically with a single exit code for CI
# Require Bash 4+ (associative arrays used below).
# macOS ships Bash 3.2 due to licensing; the system /bin/bash will crash on
# `declare -A`. When launched via ods-cli this is invoked through "$BASH",
# but this guard protects direct invocations (e.g. /bin/bash validate-env.sh).
if (( BASH_VERSINFO[0] < 4 )); then
echo "✗ validate-env.sh requires Bash 4+ (you have ${BASH_VERSION})" >&2
echo " macOS ships Bash 3.2 — install a modern Bash:" >&2
echo " brew install bash" >&2
echo " Then re-run with the Homebrew bash, e.g.:" >&2
echo " /opt/homebrew/bin/bash $0 $*" >&2
exit 1
fi
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="${INSTALL_DIR:-$(dirname "$SCRIPT_DIR")}"
ENV_FILE="${1:-${INSTALL_DIR}/.env}"
SCHEMA_FILE="${2:-${INSTALL_DIR}/.env.schema.json}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
usage() {
cat <<EOF
Usage: $(basename "$0") [ENV_FILE] [SCHEMA_FILE]
Validates a ODS .env file against the JSON schema.
Exit codes:
0 valid
2 validation errors
3 missing deps / unreadable input
Tips:
- Use .env.example as a reference
- Quote values containing spaces/special characters
EOF
}
for arg in "$@"; do
case "$arg" in
--help|-h) usage; exit 0 ;;
esac
done
if [[ ! -f "$ENV_FILE" ]]; then
log_error "Env file not found: $ENV_FILE"
exit 3
fi
if [[ ! -f "$SCHEMA_FILE" ]]; then
log_error "Schema file not found: $SCHEMA_FILE"
exit 3
fi
if ! command -v jq >/dev/null 2>&1; then
log_error "jq is required for schema validation"
log_info "Install: sudo apt-get install -y jq (or your distro equivalent)"
exit 3
fi
# -----------------------------
# .env parsing (robust)
# -----------------------------
# We intentionally do NOT 'source' the .env for security reasons.
# Instead we parse key/value pairs ourselves.
declare -A ENV_MAP
declare -A ENV_LINE
declare -A ENV_DUPLICATE_FROM
trim() {
local s="$1"
s="${s#${s%%[![:space:]]*}}"
s="${s%${s##*[![:space:]]}}"
printf '%s' "$s"
}
unquote() {
# Remove matching single or double quotes; keep inner content as-is.
local s="$1"
if [[ ${#s} -ge 2 ]]; then
if [[ "$s" == "\""*"\"" ]]; then
printf '%s' "${s:1:${#s}-2}"
return 0
fi
if [[ "$s" == "'"*"'" ]]; then
printf '%s' "${s:1:${#s}-2}"
return 0
fi
fi
printf '%s' "$s"
}
# Split KEY=VALUE where VALUE may contain '='
split_kv() {
local line="$1"
local key="${line%%=*}"
local value="${line#*=}"
key="$(trim "$key")"
value="$(trim "$value")"
printf '%s\n' "$key" "$value"
}
line_no=0
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line_no=$((line_no + 1))
# Strip leading/trailing whitespace
line="$(trim "$raw_line")"
# Skip blanks/comments
[[ -z "$line" ]] && continue
[[ "$line" =~ ^# ]] && continue
# Allow: export KEY=VALUE
if [[ "$line" =~ ^export[[:space:]]+ ]]; then
line="$(trim "${line#export}")"
fi
# Must contain '='
if [[ "$line" != *"="* ]]; then
log_warn "Ignoring line $line_no (not KEY=VALUE): $raw_line"
continue
fi
key="$(trim "${line%%=*}")"
value="$(trim "${line#*=}")"
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
log_warn "Ignoring line $line_no (invalid key '$key')"
continue
fi
# Remove inline comments only when value is unquoted.
# Example: FOO=bar # comment
# Keep hashes inside quotes.
if [[ "$value" != "\""* && "$value" != "'"* ]]; then
value="$(trim "${value%%#*}")"
fi
value="$(trim "$value")"
value="$(unquote "$value")"
# Duplicate keys are almost always accidental in generated/merged .env files.
# Keep the latest value for compatibility, but report duplicates as errors.
if [[ -n "${ENV_MAP[$key]+x}" ]]; then
ENV_DUPLICATE_FROM["$key"]="${ENV_LINE[$key]:-?}:$line_no"
fi
ENV_MAP["$key"]="$value"
ENV_LINE["$key"]="$line_no"
done < "$ENV_FILE"
# -----------------------------
# Schema prep
# -----------------------------
missing=()
unknown=()
type_errors=()
enum_errors=()
range_errors=()
length_errors=()
duplicate_errors=()
mapfile -t required_keys < <(jq -r '.required[]?' "$SCHEMA_FILE")
mapfile -t schema_keys < <(jq -r '.properties | keys[]' "$SCHEMA_FILE")
declare -A SCHEMA_KEY_SET
for key in "${schema_keys[@]}"; do
SCHEMA_KEY_SET["$key"]=1
done
# -----------------------------
# Required keys
# -----------------------------
for key in "${required_keys[@]}"; do
val="${ENV_MAP[$key]-}"
if [[ -z "$val" ]]; then
missing+=("$key")
fi
done
# -----------------------------
# Unknown keys
# -----------------------------
for key in "${!ENV_MAP[@]}"; do
if [[ -z "${SCHEMA_KEY_SET[$key]-}" ]]; then
unknown+=("$key")
fi
done
# -----------------------------
# Type + enum + range checks
# -----------------------------
for key in "${schema_keys[@]}"; do
val="${ENV_MAP[$key]-}"
[[ -z "$val" ]] && continue
expected_type="$(jq -r --arg k "$key" '.properties[$k].type // "string"' "$SCHEMA_FILE")"
# Type validation
case "$expected_type" in
integer)
if [[ ! "$val" =~ ^-?[0-9]+$ ]]; then
type_errors+=("$key: expected integer, got '$val' (line ${ENV_LINE[$key]:-?})")
continue
fi
;;
number)
if [[ ! "$val" =~ ^-?[0-9]+([.][0-9]+)?$ ]]; then
type_errors+=("$key: expected number, got '$val' (line ${ENV_LINE[$key]:-?})")
continue
fi
;;
boolean)
if [[ "$val" != "true" && "$val" != "false" ]]; then
type_errors+=("$key: expected boolean true/false, got '$val' (line ${ENV_LINE[$key]:-?})")
continue
fi
;;
esac
# Enum validation
if jq -e --arg k "$key" '.properties[$k].enum? != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
if [[ "$expected_type" != "string" ]]; then
: # enums in our schema are for strings; ignore otherwise
else
if ! jq -e --arg k "$key" --arg v "$val" '.properties[$k].enum | index($v) != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
allowed="$(jq -r --arg k "$key" '.properties[$k].enum | join(", ")' "$SCHEMA_FILE")"
enum_errors+=("$key: invalid value '$val' (allowed: $allowed) (line ${ENV_LINE[$key]:-?})")
fi
fi
fi
# Range validation (minimum/maximum) for numbers/integers
if [[ "$expected_type" == "integer" || "$expected_type" == "number" ]]; then
if jq -e --arg k "$key" '.properties[$k].minimum? != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
minv="$(jq -r --arg k "$key" '.properties[$k].minimum' "$SCHEMA_FILE")"
if awk "BEGIN{exit !($val < $minv)}" 2>/dev/null; then
range_errors+=("$key: value $val is < minimum $minv (line ${ENV_LINE[$key]:-?})")
fi
fi
if jq -e --arg k "$key" '.properties[$k].maximum? != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
maxv="$(jq -r --arg k "$key" '.properties[$k].maximum' "$SCHEMA_FILE")"
if awk "BEGIN{exit !($val > $maxv)}" 2>/dev/null; then
range_errors+=("$key: value $val is > maximum $maxv (line ${ENV_LINE[$key]:-?})")
fi
fi
fi
# Minimum-length validation for strings. Rejects unset placeholders such as
# CHANGEME so secrets must be replaced with real values before the stack runs.
if [[ "$expected_type" == "string" ]]; then
if jq -e --arg k "$key" '.properties[$k].minLength? != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
minlen="$(jq -r --arg k "$key" '.properties[$k].minLength' "$SCHEMA_FILE")"
if (( ${#val} < minlen )); then
length_errors+=("$key: value length ${#val} is < minLength $minlen (line ${ENV_LINE[$key]:-?})")
fi
fi
fi
done
# -----------------------------
# Reporting
# -----------------------------
had_errors=false
if (( ${#missing[@]} > 0 )); then
had_errors=true
log_error "Missing required keys:"
for key in "${missing[@]}"; do
echo " - $key"
done
fi
if (( ${#unknown[@]} > 0 )); then
had_errors=true
log_error "Unknown keys not defined in schema:"
for key in "${unknown[@]}"; do
echo " - $key (line ${ENV_LINE[$key]:-?})"
done
fi
if (( ${#type_errors[@]} > 0 )); then
had_errors=true
log_error "Type validation errors:"
for err in "${type_errors[@]}"; do
echo " - $err"
done
fi
if (( ${#enum_errors[@]} > 0 )); then
had_errors=true
log_error "Enum validation errors:"
for err in "${enum_errors[@]}"; do
echo " - $err"
done
fi
if (( ${#range_errors[@]} > 0 )); then
had_errors=true
log_error "Range validation errors:"
for err in "${range_errors[@]}"; do
echo " - $err"
done
fi
if (( ${#length_errors[@]} > 0 )); then
had_errors=true
log_error "Length validation errors (replace placeholder/default values):"
for err in "${length_errors[@]}"; do
echo " - $err"
done
fi
for key in "${!ENV_DUPLICATE_FROM[@]}"; do
from_to="${ENV_DUPLICATE_FROM[$key]}"
duplicate_errors+=("$key: duplicate assignment at lines $from_to")
done
if (( ${#duplicate_errors[@]} > 0 )); then
had_errors=true
log_error "Duplicate key errors:"
for err in "${duplicate_errors[@]}"; do
echo " - $err"
done
fi
if [[ "$had_errors" == "true" ]]; then
echo ""
log_info "Fix .env using .env.example as reference, then re-run:"
echo " ./scripts/validate-env.sh"
exit 2
fi
log_success ".env matches schema: $SCHEMA_FILE"
log_info "Validated env file: $ENV_FILE"
log_info "Schema: $SCHEMA_FILE"
log_info "Keys in env: ${#ENV_MAP[@]}"
log_info "Keys in schema: ${#schema_keys[@]}"
log_info "Required keys: ${#required_keys[@]}"
# Optional: print helpful summary of secrets (without values)
secret_count=$(jq -r '.properties | to_entries[] | select(.value.secret==true) | .key' "$SCHEMA_FILE" | wc -l | tr -d ' ')
if [[ "$secret_count" =~ ^[0-9]+$ ]] && (( secret_count > 0 )); then
log_info "Schema marks ${secret_count} key(s) as secrets (values not printed)."
fi
exit 0
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Validate generated runtime config contracts."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PATH = ROOT / "config" / "generated-config-contracts.json"
VALID_INVARIANTS = {"file_contains", "yaml_text_contains", "json_path_enum_contains"}
class Issues:
def __init__(self) -> None:
self.items: list[str] = []
def add(self, path: str, message: str) -> None:
self.items.append(f"{path}: {message}")
def require(self, condition: bool, path: str, message: str) -> None:
if not condition:
self.add(path, message)
def exit_if_any(self) -> None:
if not self.items:
return
print("[FAIL] generated config contract validation")
for item in self.items:
print(f" - {item}")
raise SystemExit(1)
def nonempty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def resolve_json_path(data: Any, dotted_path: str) -> Any:
current = data
for part in dotted_path.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
raise KeyError(dotted_path)
return current
def validate_string_list(issues: Issues, value: Any, path: str) -> list[str]:
if not isinstance(value, list):
issues.add(path, "must be an array")
return []
result: list[str] = []
for index, item in enumerate(value):
if not nonempty_string(item):
issues.add(f"{path}[{index}]", "must be a non-empty string")
continue
result.append(item)
issues.require(len(result) == len(set(result)), path, "must not contain duplicates")
return result
def validate_writers(issues: Issues, value: Any, path: str) -> None:
if not isinstance(value, list) or not value:
issues.add(path, "must be a non-empty array")
return
platforms: list[str] = []
for index, writer in enumerate(value):
writer_path = f"{path}[{index}]"
if not isinstance(writer, dict):
issues.add(writer_path, "must be an object")
continue
platform = writer.get("platform")
target = writer.get("path")
issues.require(nonempty_string(platform), f"{writer_path}.platform", "must be a non-empty string")
issues.require(nonempty_string(target), f"{writer_path}.path", "must be a non-empty string")
if nonempty_string(platform):
platforms.append(platform)
if nonempty_string(target):
issues.require((ROOT / target).exists(), f"{writer_path}.path", f"file does not exist: {target}")
def validate_invariant(issues: Issues, invariant: Any, path: str) -> None:
if not isinstance(invariant, dict):
issues.add(path, "must be an object")
return
invariant_id = invariant.get("id")
invariant_type = invariant.get("type")
target = invariant.get("path")
issues.require(nonempty_string(invariant_id), f"{path}.id", "must be a non-empty string")
issues.require(invariant_type in VALID_INVARIANTS, f"{path}.type", f"must be one of {sorted(VALID_INVARIANTS)}")
issues.require(nonempty_string(target), f"{path}.path", "must be a non-empty string")
if not nonempty_string(target):
return
target_path = ROOT / str(target)
issues.require(target_path.exists(), f"{path}.path", f"file does not exist: {target}")
if not target_path.exists():
return
if invariant_type in {"file_contains", "yaml_text_contains"}:
needle = invariant.get("needle")
issues.require(nonempty_string(needle), f"{path}.needle", "must be a non-empty string")
if nonempty_string(needle):
text = target_path.read_text(encoding="utf-8", errors="replace")
issues.require(str(needle) in text, path, f"{target} does not contain required text {needle!r}")
elif invariant_type == "json_path_enum_contains":
json_path = invariant.get("json_path")
values = validate_string_list(issues, invariant.get("values"), f"{path}.values")
issues.require(nonempty_string(json_path), f"{path}.json_path", "must be a non-empty string")
if nonempty_string(json_path):
try:
enum_value = resolve_json_path(load_json(target_path), str(json_path))
except Exception as exc: # noqa: BLE001 - contract validator should report cleanly
issues.add(f"{path}.json_path", f"could not resolve {json_path!r}: {exc}")
return
issues.require(isinstance(enum_value, list), f"{path}.json_path", "must resolve to an array")
if isinstance(enum_value, list):
for value in values:
issues.require(value in enum_value, path, f"{target} {json_path} missing {value!r}")
def validate_surface(issues: Issues, surface: Any, path: str) -> str | None:
if not isinstance(surface, dict):
issues.add(path, "must be an object")
return None
surface_id = surface.get("id")
issues.require(nonempty_string(surface_id), f"{path}.id", "must be a non-empty string")
issues.require(nonempty_string(surface.get("label")), f"{path}.label", "must be a non-empty string")
validate_string_list(issues, surface.get("target_paths"), f"{path}.target_paths")
validate_writers(issues, surface.get("writers"), f"{path}.writers")
invariants = surface.get("invariants")
if not isinstance(invariants, list) or not invariants:
issues.add(f"{path}.invariants", "must be a non-empty array")
else:
invariant_ids: list[str] = []
for index, invariant in enumerate(invariants):
if isinstance(invariant, dict) and nonempty_string(invariant.get("id")):
invariant_ids.append(str(invariant["id"]))
validate_invariant(issues, invariant, f"{path}.invariants[{index}]")
issues.require(len(invariant_ids) == len(set(invariant_ids)), f"{path}.invariants", "ids must be unique")
return surface_id if isinstance(surface_id, str) else None
def main(argv: list[str]) -> int:
path = Path(argv[0]) if argv else DEFAULT_PATH
if not path.exists():
print(f"[FAIL] generated config contract file not found: {path}")
return 1
try:
data = load_json(path)
except json.JSONDecodeError as exc:
print(f"[FAIL] invalid JSON in {path}: {exc}")
return 1
issues = Issues()
if not isinstance(data, dict):
issues.add("$", "must be an object")
issues.exit_if_any()
return 1
issues.require(data.get("version") == 1, "$.version", "must be 1")
surfaces = data.get("surfaces")
if not isinstance(surfaces, list):
issues.add("$.surfaces", "must be an array")
else:
surface_ids: list[str] = []
for index, surface in enumerate(surfaces):
surface_id = validate_surface(issues, surface, f"$.surfaces[{index}]")
if surface_id:
surface_ids.append(surface_id)
required = {"env", "opencode", "litellm-lemonade", "perplexica", "hermes"}
issues.require(set(surface_ids) == required, "$.surfaces", f"must define exactly {sorted(required)}")
issues.exit_if_any()
print("[PASS] generated config contracts")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Validate ODS golden-path release contracts."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PATH = ROOT / "config" / "golden-paths.json"
VALID_STATUSES = {"golden"}
VALID_PLATFORMS = {"linux", "windows-wsl2", "macos"}
class Issues:
def __init__(self) -> None:
self.items: list[str] = []
def add(self, path: str, message: str) -> None:
self.items.append(f"{path}: {message}")
def require(self, condition: bool, path: str, message: str) -> None:
if not condition:
self.add(path, message)
def exit_if_any(self) -> None:
if not self.items:
return
print("[FAIL] golden path validation")
for item in self.items:
print(f" - {item}")
raise SystemExit(1)
def as_dict(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def as_list(value: Any) -> list[Any] | None:
return value if isinstance(value, list) else None
def nonempty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def validate_string_list(issues: Issues, value: Any, path: str) -> list[str]:
if not isinstance(value, list):
issues.add(path, "must be an array")
return []
result: list[str] = []
for index, item in enumerate(value):
item_path = f"{path}[{index}]"
if not nonempty_string(item):
issues.add(item_path, "must be a non-empty string")
continue
result.append(item)
issues.require(len(result) == len(set(result)), path, "must not contain duplicates")
return result
def validate_service_ids(issues: Issues, service_ids: list[str], path: str) -> None:
services_root = ROOT / "extensions" / "services"
for service_id in service_ids:
manifest = services_root / service_id / "manifest.yaml"
issues.require(manifest.exists(), path, f"unknown service id '{service_id}'")
def validate_health_checks(issues: Issues, value: Any, path: str) -> None:
checks = as_list(value)
if checks is None:
issues.add(path, "must be an array")
return
issues.require(bool(checks), path, "must include at least one health check")
for index, check_value in enumerate(checks):
check_path = f"{path}[{index}]"
check = as_dict(check_value)
if check is None:
issues.add(check_path, "must be an object")
continue
service = check.get("service")
url = check.get("url")
issues.require(nonempty_string(service), f"{check_path}.service", "must be a non-empty string")
issues.require(nonempty_string(url), f"{check_path}.url", "must be a non-empty string")
if nonempty_string(service):
validate_service_ids(issues, [service], f"{check_path}.service")
if nonempty_string(url):
issues.require(url.startswith("http://127.0.0.1:"), f"{check_path}.url", "must be localhost HTTP")
def validate_generated_configs(issues: Issues, value: Any, path: str) -> None:
configs = as_list(value)
if configs is None:
issues.add(path, "must be an array")
return
surfaces: list[str] = []
for index, config_value in enumerate(configs):
config_path = f"{path}[{index}]"
config = as_dict(config_value)
if config is None:
issues.add(config_path, "must be an object")
continue
surface = config.get("surface")
target = config.get("path")
issues.require(nonempty_string(surface), f"{config_path}.surface", "must be a non-empty string")
issues.require(nonempty_string(target), f"{config_path}.path", "must be a non-empty string")
if nonempty_string(surface):
surfaces.append(surface)
for required in ("env", "opencode", "hermes", "perplexica"):
issues.require(required in surfaces, path, f"missing generated config surface '{required}'")
def validate_scenario(issues: Issues, scenario: Any, path: str) -> str | None:
obj = as_dict(scenario)
if obj is None:
issues.add(path, "must be an object")
return None
scenario_id = obj.get("id")
issues.require(nonempty_string(scenario_id), f"{path}.id", "must be a non-empty string")
issues.require(nonempty_string(obj.get("label")), f"{path}.label", "must be a non-empty string")
issues.require(obj.get("status") in VALID_STATUSES, f"{path}.status", f"must be one of {sorted(VALID_STATUSES)}")
issues.require(obj.get("platform") in VALID_PLATFORMS, f"{path}.platform", f"must be one of {sorted(VALID_PLATFORMS)}")
issues.require(nonempty_string(obj.get("architecture")), f"{path}.architecture", "must be a non-empty string")
hardware = as_dict(obj.get("hardware"))
if hardware is None:
issues.add(f"{path}.hardware", "must be an object")
else:
issues.require(nonempty_string(hardware.get("gpu_backend")), f"{path}.hardware.gpu_backend", "must be a non-empty string")
issues.require(nonempty_string(hardware.get("accelerator")), f"{path}.hardware.accelerator", "must be a non-empty string")
installer = as_dict(obj.get("installer"))
if installer is None:
issues.add(f"{path}.installer", "must be an object")
else:
entrypoint = installer.get("entrypoint")
issues.require(nonempty_string(entrypoint), f"{path}.installer.entrypoint", "must be a non-empty string")
if nonempty_string(entrypoint):
issues.require((ROOT / entrypoint).exists(), f"{path}.installer.entrypoint", f"file does not exist: {entrypoint}")
issues.require(nonempty_string(installer.get("mode")), f"{path}.installer.mode", "must be a non-empty string")
issues.require(nonempty_string(installer.get("ci_simulation")), f"{path}.installer.ci_simulation", "must be a non-empty string")
validate_string_list(issues, installer.get("dry_run_args"), f"{path}.installer.dry_run_args")
expected = as_dict(obj.get("expected"))
if expected is None:
issues.add(f"{path}.expected", "must be an object")
return scenario_id if isinstance(scenario_id, str) else None
issues.require(nonempty_string(expected.get("ods_mode")), f"{path}.expected.ods_mode", "must be a non-empty string")
issues.require(nonempty_string(expected.get("llm_backend")), f"{path}.expected.llm_backend", "must be a non-empty string")
for port_key in ("llm_host_port", "llm_container_port"):
port = expected.get(port_key)
issues.require(isinstance(port, int) and not isinstance(port, bool) and 0 < port < 65536, f"{path}.expected.{port_key}", "must be a TCP port integer")
compose_files = validate_string_list(issues, expected.get("compose_files"), f"{path}.expected.compose_files")
for compose_file in compose_files:
issues.require((ROOT / compose_file).exists(), f"{path}.expected.compose_files", f"compose file does not exist: {compose_file}")
core_services = validate_string_list(issues, expected.get("core_services"), f"{path}.expected.core_services")
recommended_services = validate_string_list(issues, expected.get("recommended_services"), f"{path}.expected.recommended_services")
validate_service_ids(issues, core_services, f"{path}.expected.core_services")
validate_service_ids(issues, recommended_services, f"{path}.expected.recommended_services")
model_route = as_dict(expected.get("model_route"))
if model_route is None:
issues.add(f"{path}.expected.model_route", "must be an object")
else:
for key in ("host_base_url", "container_base_url"):
issues.require(nonempty_string(model_route.get(key)), f"{path}.expected.model_route.{key}", "must be a non-empty string")
validate_generated_configs(issues, expected.get("generated_configs"), f"{path}.expected.generated_configs")
validate_health_checks(issues, expected.get("health_checks"), f"{path}.expected.health_checks")
return scenario_id if isinstance(scenario_id, str) else None
def main(argv: list[str]) -> int:
path = Path(argv[0]) if argv else DEFAULT_PATH
if not path.exists():
print(f"[FAIL] golden path file not found: {path}")
return 1
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"[FAIL] invalid JSON in {path}: {exc}")
return 1
issues = Issues()
root = as_dict(data)
if root is None:
issues.add("$", "must be an object")
issues.exit_if_any()
return 1
issues.require(root.get("version") == 1, "$.version", "must be 1")
scenarios = as_list(root.get("scenarios"))
if scenarios is None:
issues.add("$.scenarios", "must be an array")
else:
issues.require(len(scenarios) == 4, "$.scenarios", "must contain exactly the four golden paths")
seen: list[str] = []
for index, scenario in enumerate(scenarios):
scenario_id = validate_scenario(issues, scenario, f"$.scenarios[{index}]")
if scenario_id:
seen.append(scenario_id)
issues.require(len(seen) == len(set(seen)), "$.scenarios", "scenario ids must be unique")
expected_ids = {"linux-nvidia", "windows-wsl2-nvidia", "windows-wsl2-amd-lemonade", "apple-silicon"}
issues.require(set(seen) == expected_ids, "$.scenarios", f"must define exactly {sorted(expected_ids)}")
issues.exit_if_any()
print("[PASS] golden path contracts")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+197
View File
@@ -0,0 +1,197 @@
#!/bin/bash
# validate-manifest-schema.sh - Comprehensive manifest schema validator
# Part of: scripts/
# Purpose: Validate extension manifests against schema requirements
#
# Usage: ./validate-manifest-schema.sh [--strict] [--verbose]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXTENSIONS_DIR="${SCRIPT_DIR}/../extensions/services"
STRICT_MODE=false
VERBOSE=false
ERRORS=0
WARNINGS=0
# Colors
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
usage() {
cat << EOF
Extension Manifest Schema Validator
Usage: $(basename "$0") [OPTIONS]
OPTIONS:
-h, --help Show this help message
-s, --strict Treat warnings as errors
-v, --verbose Show detailed validation output
DESCRIPTION:
Validates all extension manifest.yaml files against schema requirements.
Checks required fields, types, formats, and logical consistency.
EXAMPLES:
$(basename "$0") # Validate all manifests
$(basename "$0") --strict # Fail on warnings
$(basename "$0") --verbose # Show all checks
EOF
}
error() {
echo -e "${RED}✗ ERROR:${NC} $*" >&2
((ERRORS++)) || true
}
warn() {
echo -e "${YELLOW}⚠ WARNING:${NC} $*" >&2
((WARNINGS++)) || true
}
info() {
[[ "$VERBOSE" == "true" ]] && echo -e "${BLUE}${NC} $*"
}
success() {
[[ "$VERBOSE" == "true" ]] && echo -e "${GREEN}${NC} $*"
}
# Validate a single manifest
validate_manifest() {
local manifest_path="$1"
local service_name
service_name=$(basename "$(dirname "$manifest_path")")
info "Validating: $service_name"
# Check YAML syntax
if ! python3 -c "import yaml; yaml.safe_load(open('$manifest_path'))" 2>/dev/null; then
error "$service_name: Invalid YAML syntax"
return 1
fi
# Comprehensive validation
python3 - "$manifest_path" "$service_name" "$VERBOSE" <<'PYEOF'
import yaml, sys, re, os
manifest_path, service_name, verbose = sys.argv[1:4]
errors, warnings = [], []
def error(msg): errors.append(msg); print(f"ERROR: {service_name}: {msg}", file=sys.stderr)
def warn(msg): warnings.append(msg); print(f"WARNING: {service_name}: {msg}", file=sys.stderr)
def info(msg): verbose == "true" and print(f"INFO: {service_name}: {msg}")
try:
manifest = yaml.safe_load(open(manifest_path))
if not isinstance(manifest, dict): error("Not a valid YAML mapping"); sys.exit(1)
# schema_version
if manifest.get("schema_version") != "ods.services.v1":
error(f"Invalid schema_version: {manifest.get('schema_version')}")
else: info("schema_version: OK")
service = manifest.get("service", {})
if not isinstance(service, dict): error("Missing/invalid 'service' section"); sys.exit(1)
# Required fields
for field, typ in {"id": str, "name": str, "port": int, "health": str, "type": str, "category": str}.items():
val = service.get(field)
if val is None: error(f"Missing service.{field}")
elif not isinstance(val, typ): error(f"Invalid type for service.{field}")
else: info(f"service.{field}: OK")
# Validate formats
if service.get("id") and not re.match(r'^[a-z0-9_-]+$', service["id"]):
error(f"Invalid service.id format: {service['id']}")
if service.get("category") not in ["core", "recommended", "optional", None]:
error(f"Invalid category: {service.get('category')}")
if service.get("type") not in ["docker", "native", "external", None]:
error(f"Invalid type: {service.get('type')}")
port = service.get("port", 0)
if isinstance(port, int) and not (0 <= port <= 65535):
error(f"Invalid port: {port}")
if service.get("health") and not service["health"].startswith("/"):
warn(f"health should start with '/': {service['health']}")
# Validate lists
for alias in service.get("aliases", []):
if not re.match(r'^[a-z0-9_-]+$', str(alias)):
error(f"Invalid alias: {alias}")
for dep in service.get("depends_on", []):
if not re.match(r'^[a-z0-9_-]+$', str(dep)):
error(f"Invalid dependency: {dep}")
for backend in service.get("gpu_backends", []):
if backend not in ["amd", "nvidia", "apple", "cpu", "all"]:
error(f"Invalid gpu_backend: {backend}")
# Check compose_file exists
if service.get("compose_file"):
compose_path = os.path.join(os.path.dirname(manifest_path), service["compose_file"])
if not os.path.exists(compose_path):
warn(f"compose_file not found: {service['compose_file']}")
sys.exit(1 if errors else (2 if warnings else 0))
except Exception as e:
print(f"ERROR: {service_name}: {e}", file=sys.stderr); sys.exit(1)
PYEOF
case $? in
0) success "$service_name: Valid"; return 0 ;;
1) ((ERRORS++)) || true; return 1 ;;
2) ((WARNINGS++)) || true; return 0 ;;
esac
}
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-s|--strict) STRICT_MODE=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
*) echo "Unknown: $1" >&2; usage; exit 2 ;;
esac
done
# Main
echo "Validating manifests in: $EXTENSIONS_DIR"
echo ""
[[ ! -d "$EXTENSIONS_DIR" ]] && { echo -e "${RED}ERROR:${NC} Not found: $EXTENSIONS_DIR" >&2; exit 1; }
TOTAL=0 VALID=0
for dir in "$EXTENSIONS_DIR"/*/; do
[[ ! -d "$dir" ]] && continue
manifest=""
for name in manifest.yaml manifest.yml; do
[[ -f "$dir/$name" ]] && manifest="$dir/$name" && break
done
[[ -z "$manifest" ]] && { warn "$(basename "$dir"): No manifest"; continue; }
((TOTAL++)) || true
validate_manifest "$manifest" && { ((VALID++)) || true; }
done
# Summary
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Summary: $TOTAL total, $VALID valid, $ERRORS errors, $WARNINGS warnings"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}✗ FAILED${NC} ($ERRORS errors)"; exit 1
elif [[ $WARNINGS -gt 0 && "$STRICT_MODE" == "true" ]]; then
echo -e "${YELLOW}✗ FAILED${NC} ($WARNINGS warnings in strict mode)"; exit 1
elif [[ $WARNINGS -gt 0 ]]; then
echo -e "${YELLOW}⚠ Passed with warnings${NC}"; exit 0
else
echo -e "${GREEN}✓ All valid${NC}"; exit 0
fi
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# Validate extension service manifests against the v1 schema and
# check ODS version compatibility metadata.
#
# Usage:
# scripts/validate-manifests.sh
#
# Behavior:
# - Loads core version and extension schema path from manifest.json
# - Scans extensions/services/*/manifest.{yaml,yml,json}
# - Validates structure against extensions/schema/service-manifest.v1.json
# when python3 + PyYAML + jsonschema are available (otherwise warns)
# - Reads optional per-manifest compatibility block:
# compatibility:
# ods_min: "2.0.0"
# ods_max: "2.0.99"
# and compares it to the current ODS version.
# - Prints a human-readable summary and exits non-zero only on
# hard failures (schema errors, IO problems).
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST_FILE="${ROOT_DIR}/manifest.json"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
fail() { echo -e "${RED}[FAIL]${NC} $1"; exit 1; }
pass() { echo -e "${GREEN}[PASS]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
command -v jq >/dev/null 2>&1 || fail "jq is required"
test -f "$MANIFEST_FILE" || fail "manifest.json not found"
CORE_VERSION="$(jq -r '.release.version' "$MANIFEST_FILE")"
SCHEMA_PATH_REL="$(jq -r '.contracts.extensions.serviceManifestSchema' "$MANIFEST_FILE")"
EXT_DIR_REL="$(jq -r '.contracts.extensions.serviceDirectory' "$MANIFEST_FILE")"
test -n "$CORE_VERSION" || fail "release.version missing in manifest.json"
test -n "$SCHEMA_PATH_REL" || fail "contracts.extensions.serviceManifestSchema missing in manifest.json"
test -n "$EXT_DIR_REL" || fail "contracts.extensions.serviceDirectory missing in manifest.json"
SCHEMA_PATH="${ROOT_DIR}/${SCHEMA_PATH_REL}"
EXT_DIR="${ROOT_DIR}/${EXT_DIR_REL%/}"
test -f "$SCHEMA_PATH" || fail "extension schema not found at ${SCHEMA_PATH_REL}"
test -d "$EXT_DIR" || fail "extensions directory not found at ${EXT_DIR_REL}"
info "Core version: ${CORE_VERSION}"
info "Extensions directory: ${EXT_DIR_REL}"
info "Schema: ${SCHEMA_PATH_REL}"
PYTHON_OK=true
if ! command -v python3 >/dev/null 2>&1; then
PYTHON_OK=false
fi
if $PYTHON_OK; then
# Probe for required modules; fall back gracefully if missing.
if ! python3 - <<'PY' >/dev/null 2>&1
import sys
import json # noqa
import importlib
importlib.import_module("yaml")
importlib.import_module("jsonschema")
PY
then
warn "python3 yaml/jsonschema modules not available — skipping schema validation (compatibility checks only)"
PYTHON_OK=false
fi
else
warn "python3 not found — skipping schema validation (compatibility checks only)"
fi
py_exit=0
python3 - "$ROOT_DIR" "$EXT_DIR_REL" "$SCHEMA_PATH_REL" "$CORE_VERSION" "$PYTHON_OK" <<'PY' || py_exit=$?
import json
import sys
import textwrap
from pathlib import Path
root_dir = Path(sys.argv[1])
ext_dir_rel = sys.argv[2]
schema_rel = sys.argv[3]
core_version = sys.argv[4]
python_ok = sys.argv[5].lower() == "true"
ext_dir = root_dir / ext_dir_rel
schema_path = root_dir / schema_rel
results = []
schema_errors = False
def parse_version(v: str):
"""Parse "2.0.0" into (2, 0, 0). Non-numeric segments become 0."""
parts = []
for part in v.split("."):
try:
parts.append(int(part))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
def compatibility_result(manifest, fallback_sid, core_ver_tuple):
"""Given a parsed manifest, return the single compatibility result dict."""
svc = manifest.get("service", {})
sid = svc.get("id") or fallback_sid
compat = manifest.get("compatibility", {})
ods_min = compat.get("ods_min")
ods_max = compat.get("ods_max")
if not ods_min and not ods_max:
return {
"service_id": sid,
"status": "ok-no-metadata",
"reason": "No ods_min/ods_max specified (assumed compatible)",
}
status = "ok"
reason = "Compatible with current version"
if ods_min and parse_version(str(ods_min)) > core_ver_tuple:
status = "incompatible"
reason = f"Requires ODS >= {ods_min}"
elif ods_max and parse_version(str(ods_max)) < core_ver_tuple:
status = "incompatible"
reason = f"Supports ODS <= {ods_max}"
return {"service_id": sid, "status": status, "reason": reason}
core_ver_tuple = parse_version(core_version)
if python_ok:
import yaml
import jsonschema
with schema_path.open("r", encoding="utf-8") as f:
schema = json.load(f)
else:
try:
import yaml # type: ignore[import-not-found]
except Exception:
yaml = None # type: ignore[assignment]
schema = None
for service_dir in sorted(ext_dir.iterdir()):
if not service_dir.is_dir():
continue
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
manifest = None
try:
if manifest_path.suffix == ".json":
with manifest_path.open("r", encoding="utf-8") as f:
manifest = json.load(f)
elif yaml is not None:
with manifest_path.open("r", encoding="utf-8") as f:
manifest = yaml.safe_load(f)
else:
results.append(
{
"service_id": service_dir.name,
"status": "skipped",
"reason": "Cannot parse manifest (no PyYAML/jsonschema)",
}
)
continue
except Exception as e: # noqa: BLE001
schema_errors = True
results.append(
{
"service_id": service_dir.name,
"status": "error",
"reason": f"Failed to parse manifest: {e}",
}
)
continue
if schema is not None:
try:
jsonschema.validate(manifest, schema)
except jsonschema.ValidationError as e: # type: ignore[attr-defined]
schema_errors = True
sid = manifest.get("service", {}).get("id") or service_dir.name
results.append(
{
"service_id": sid,
"status": "error",
"reason": f"Schema validation failed at {list(e.absolute_path)}: {e.message}",
}
)
continue
results.append(compatibility_result(manifest, service_dir.name, core_ver_tuple))
# Print human-readable summary
print()
print("Extension manifest validation")
print("────────────────────────────")
if not results:
print("No extension manifests found.")
sys.exit(0)
width_id = max(len(r["service_id"]) for r in results) + 2
width_status = max(len(r["status"]) for r in results) + 2
print(f"{'SERVICE'.ljust(width_id)}{'STATUS'.ljust(width_status)}REASON")
print(f"{'-' * width_id}{'-' * width_status}{'-' * 40}")
incompatible = 0
no_meta = 0
skipped = 0
errors = 0
for r in results:
sid = r["service_id"]
status = r["status"]
reason = r["reason"]
print(f"{sid.ljust(width_id)}{status.ljust(width_status)}{reason}")
if status == "incompatible":
incompatible += 1
elif status == "ok-no-metadata":
no_meta += 1
elif status == "skipped":
skipped += 1
elif status == "error":
errors += 1
print()
print(
textwrap.dedent(
f"""\
Summary:
Compatible: {len(results) - incompatible - errors - skipped}
Incompatible: {incompatible}
No metadata: {no_meta}
Skipped: {skipped}
Errors: {errors}
"""
).rstrip()
)
if schema_errors or errors:
sys.exit(1)
sys.exit(0)
PY
if [[ "$py_exit" -eq 0 ]]; then
pass "Extension manifests validated"
else
fail "Extension manifest validation failed"
fi
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Pre-flight model validation for ODS offline mode.
Ensures required models are downloaded before starting services.
"""
import sys
from pathlib import Path
# Model requirements for offline mode
REQUIRED_MODELS = {
"llm": {
"path": "data/models",
"description": "Primary LLM (GGUF model)",
"size_gb": 4,
},
"whisper": {
"path": "data/whisper/faster-whisper-base",
"description": "Whisper STT model (base)",
"size_gb": 0.15,
},
"kokoro": {
"path": "data/kokoro/voices/af_heart.pt",
"description": "Kokoro TTS voice (af_heart)",
"size_gb": 0.3,
},
"embeddings": {
"path": "data/embeddings/BAAI/bge-base-en-v1.5",
"description": "Embedding model (BGE base)",
"size_gb": 0.4,
},
}
def check_model(service, config):
"""Check if a model exists and has reasonable size."""
# Resolve base path relative to script location (scripts/ -> parent -> ods root)
base_path = Path(__file__).parent.parent
model_path = base_path / config["path"]
if not model_path.exists():
return False, f"Not found: {config['path']}"
# Check size (rough validation)
if model_path.is_file():
size_gb = model_path.stat().st_size / (1024**3)
else:
# Directory - sum all files
size_gb = sum(f.stat().st_size for f in model_path.rglob('*') if f.is_file()) / (1024**3)
min_size = config["size_gb"] * 0.5 # At least 50% of expected size
if size_gb < min_size:
return False, f"Too small: {size_gb:.2f}GB (expected ~{config['size_gb']}GB)"
return True, f"OK: {size_gb:.2f}GB"
def main():
"""Validate all required models are present."""
print("=" * 60)
print("ODS Offline Mode - Model Validation")
print("=" * 60)
all_ok = True
missing = []
for service, config in REQUIRED_MODELS.items():
ok, msg = check_model(service, config)
status = "" if ok else ""
print(f"{status} {config['description']:40s} {msg}")
if not ok:
all_ok = False
missing.append(service)
print("=" * 60)
if all_ok:
print("All models present. Ready for offline mode!")
return 0
else:
print(f"\nMISSING MODELS: {', '.join(missing)}")
print("\nDownload models before starting offline mode:")
print(" ./scripts/download-models.sh")
return 1
if __name__ == "__main__":
sys.exit(main())
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""ODS — installer simulation summary validator.
This script is a CI/automation gate for artifacts produced by:
- scripts/simulate-installers.sh
- scripts/ods-doctor.sh
- scripts/preflight-engine.sh
Why this exists:
The simulation step is meant to provide a single structured "snapshot" that
can be validated without access to Docker, GPU drivers, or platform-specific
tooling. Historically this validator was too shallow (only a few keys) and
too brittle (failed fast without showing all issues).
Design goals (senior-grade guardrails):
- Validate structure AND critical semantics (types, required subtrees)
- Provide actionable error messages with JSON-path context
- Aggregate errors (report all problems at once)
- Work without third-party dependencies
Exit codes:
0: PASS
2: FAIL (validation errors)
3: FAIL (unreadable/invalid JSON)
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
Json = Union[None, bool, int, float, str, List["Json"], Dict[str, "Json"]]
# -----------------------------
# Error model / utilities
# -----------------------------
@dataclass(frozen=True)
class ValidationIssue:
path: str
message: str
def format(self) -> str:
return f"- {self.path}: {self.message}"
class Validator:
def __init__(self, *, strict: bool = False) -> None:
self.strict = strict
self.issues: List[ValidationIssue] = []
def add(self, path: str, message: str) -> None:
self.issues.append(ValidationIssue(path=path, message=message))
def fail_if_any(self) -> None:
if not self.issues:
return
print("[FAIL] simulation summary validation")
for issue in self.issues:
print(issue.format())
raise SystemExit(2)
def _type_name(v: Any) -> str:
if v is None:
return "null"
return type(v).__name__
def _is_int(v: Any) -> bool:
# bool is a subclass of int; exclude it explicitly.
return isinstance(v, int) and not isinstance(v, bool)
def _as_mapping(v: Any) -> Optional[Mapping[str, Any]]:
return v if isinstance(v, Mapping) else None
def _as_sequence(v: Any) -> Optional[Sequence[Any]]:
return v if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)) else None
def _require_key(v: Validator, obj: Mapping[str, Any], path: str, key: str) -> Any:
if key not in obj:
v.add(path, f"missing required key '{key}'")
return None
return obj[key]
def _require_type(v: Validator, value: Any, path: str, expected: str) -> None:
ok = False
if expected == "object":
ok = isinstance(value, Mapping)
elif expected == "array":
ok = _as_sequence(value) is not None
elif expected == "string":
ok = isinstance(value, str)
elif expected == "int":
ok = _is_int(value)
elif expected == "bool":
ok = isinstance(value, bool)
elif expected == "number":
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
elif expected == "null":
ok = value is None
if not ok:
v.add(path, f"expected {expected}, got {_type_name(value)}")
def _optional_type(v: Validator, value: Any, path: str, expected: str) -> None:
if value is None:
return
_require_type(v, value, path, expected)
def _require_one_of(v: Validator, value: Any, path: str, allowed: Sequence[str]) -> None:
if not isinstance(value, str):
v.add(path, f"expected string enum {list(allowed)}, got {_type_name(value)}")
return
if value not in allowed:
v.add(path, f"invalid value '{value}', expected one of {list(allowed)}")
def _require_nonempty_string(v: Validator, value: Any, path: str) -> None:
_require_type(v, value, path, "string")
if isinstance(value, str) and not value.strip():
v.add(path, "must be a non-empty string")
def _require_path_like(v: Validator, value: Any, path: str) -> None:
# We accept absolute or relative paths, but we should not accept embedded NUL.
_require_type(v, value, path, "string")
if isinstance(value, str) and "\x00" in value:
v.add(path, "path contains NUL byte")
def _require_iso8601ish(v: Validator, value: Any, path: str) -> None:
# We intentionally avoid strict RFC3339 parsing to keep dependencies at 0.
_require_type(v, value, path, "string")
if isinstance(value, str):
# Very lightweight check: 2026-03-15T12:34:56+00:00 / Z / with fractional seconds.
if not re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$", value):
v.add(path, "expected ISO8601 timestamp (UTC or offset)")
# -----------------------------
# Schema validation
# -----------------------------
def validate_linux_dryrun(v: Validator, linux: Mapping[str, Any], path: str) -> None:
exit_code = _require_key(v, linux, path, "exit_code")
_require_type(v, exit_code, f"{path}.exit_code", "int")
signals = _require_key(v, linux, path, "signals")
_require_type(v, signals, f"{path}.signals", "object")
log_path = _require_key(v, linux, path, "log")
_require_path_like(v, log_path, f"{path}.log")
install_summary = _require_key(v, linux, path, "install_summary")
_require_type(v, install_summary, f"{path}.install_summary", "object")
if isinstance(signals, Mapping):
# Signals are boolean indicators from the linux dry-run log.
required_signals = (
"capability_loaded",
"hardware_class_logged",
"backend_contract_loaded",
"preflight_report_logged",
"compose_selection_logged",
)
for s in required_signals:
if s not in signals:
v.add(f"{path}.signals", f"missing required signal '{s}'")
else:
_require_type(v, signals.get(s), f"{path}.signals.{s}", "bool")
def validate_macos_installer(v: Validator, mac: Mapping[str, Any], path: str) -> None:
exit_code = _require_key(v, mac, path, "exit_code")
_require_type(v, exit_code, f"{path}.exit_code", "int")
log_path = _require_key(v, mac, path, "log")
_require_path_like(v, log_path, f"{path}.log")
preflight = _require_key(v, mac, path, "preflight")
_optional_type(v, preflight, f"{path}.preflight", "object")
doctor = _require_key(v, mac, path, "doctor")
_optional_type(v, doctor, f"{path}.doctor", "object")
# If preflight exists, ensure it has a summary block.
if isinstance(preflight, Mapping):
summary = preflight.get("summary")
_require_type(v, summary, f"{path}.preflight.summary", "object")
if isinstance(summary, Mapping):
blockers = summary.get("blockers")
warnings = summary.get("warnings")
_require_type(v, blockers, f"{path}.preflight.summary.blockers", "int")
_require_type(v, warnings, f"{path}.preflight.summary.warnings", "int")
def validate_windows_scenario(v: Validator, win: Mapping[str, Any], path: str) -> None:
report = _require_key(v, win, path, "report")
_require_type(v, report, f"{path}.report", "object")
if not isinstance(report, Mapping):
return
summary = report.get("summary")
_require_type(v, summary, f"{path}.report.summary", "object")
if isinstance(summary, Mapping):
blockers = summary.get("blockers")
warnings = summary.get("warnings")
_require_type(v, blockers, f"{path}.report.summary.blockers", "int")
_require_type(v, warnings, f"{path}.report.summary.warnings", "int")
def validate_doctor_snapshot(v: Validator, doctor: Mapping[str, Any], path: str) -> None:
exit_code = _require_key(v, doctor, path, "exit_code")
_require_type(v, exit_code, f"{path}.exit_code", "int")
report = _require_key(v, doctor, path, "report")
_require_type(v, report, f"{path}.report", "object")
if not isinstance(report, Mapping):
return
# Historically we require autofix_hints (used by UX / troubleshooting flows)
if "autofix_hints" not in report:
v.add(f"{path}.report", "missing required key 'autofix_hints'")
else:
_require_type(v, report.get("autofix_hints"), f"{path}.report.autofix_hints", "array")
# Newer doctor outputs include a summary block; validate if present.
if "summary" in report:
_require_type(v, report.get("summary"), f"{path}.report.summary", "object")
summary = report.get("summary")
if isinstance(summary, Mapping) and "runtime_ready" in summary:
_require_type(v, summary.get("runtime_ready"), f"{path}.report.summary.runtime_ready", "bool")
def validate_golden_paths(v: Validator, golden: Mapping[str, Any], path: str) -> None:
contract = _require_key(v, golden, path, "contract")
_require_path_like(v, contract, f"{path}.contract")
evidence = _require_key(v, golden, path, "evidence")
_require_path_like(v, evidence, f"{path}.evidence")
scenario_count = _require_key(v, golden, path, "scenario_count")
_require_type(v, scenario_count, f"{path}.scenario_count", "int")
pass_count = _require_key(v, golden, path, "pass_count")
_require_type(v, pass_count, f"{path}.pass_count", "int")
if _is_int(scenario_count) and scenario_count <= 0:
v.add(f"{path}.scenario_count", "must be greater than zero")
if _is_int(pass_count) and _is_int(scenario_count) and pass_count > scenario_count:
v.add(f"{path}.pass_count", "must be less than or equal to scenario_count")
def validate_summary(v: Validator, data: Mapping[str, Any]) -> None:
# version
version = data.get("version")
_require_nonempty_string(v, version, "$.version")
if isinstance(version, str) and version != "1":
v.add("$.version", "must be '1'")
# generated_at
if "generated_at" in data:
_require_iso8601ish(v, data.get("generated_at"), "$.generated_at")
elif v.strict:
v.add("$", "missing required key 'generated_at' (strict mode)")
# runs
runs = data.get("runs")
_require_type(v, runs, "$.runs", "object")
if not isinstance(runs, Mapping):
return
required_runs = (
"linux_dryrun",
"macos_installer_mvp",
"windows_scenario_preflight",
"doctor_snapshot",
)
for key in required_runs:
if key not in runs:
v.add("$.runs", f"missing required run '{key}'")
# Validate each known run (only if present)
linux = runs.get("linux_dryrun")
_require_type(v, linux, "$.runs.linux_dryrun", "object")
if isinstance(linux, Mapping):
validate_linux_dryrun(v, linux, "$.runs.linux_dryrun")
mac = runs.get("macos_installer_mvp")
_require_type(v, mac, "$.runs.macos_installer_mvp", "object")
if isinstance(mac, Mapping):
validate_macos_installer(v, mac, "$.runs.macos_installer_mvp")
win = runs.get("windows_scenario_preflight")
_require_type(v, win, "$.runs.windows_scenario_preflight", "object")
if isinstance(win, Mapping):
validate_windows_scenario(v, win, "$.runs.windows_scenario_preflight")
doc = runs.get("doctor_snapshot")
_require_type(v, doc, "$.runs.doctor_snapshot", "object")
if isinstance(doc, Mapping):
validate_doctor_snapshot(v, doc, "$.runs.doctor_snapshot")
if "golden_paths" in data:
_require_type(v, data.get("golden_paths"), "$.golden_paths", "object")
golden = data.get("golden_paths")
if isinstance(golden, Mapping):
validate_golden_paths(v, golden, "$.golden_paths")
# Strict mode: warn on unknown top-level keys
if v.strict:
allowed_top = {"version", "generated_at", "runs", "golden_paths"}
for k in data.keys():
if k not in allowed_top:
v.add("$", f"unknown top-level key '{k}' (strict mode)")
# -----------------------------
# CLI
# -----------------------------
def _parse_args(argv: Sequence[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="validate-sim-summary.py",
description="Validate ODS installer simulation summary JSON.",
)
p.add_argument("summary_json", help="Path to summary.json")
p.add_argument("--strict", action="store_true", help="Fail on unknown keys and require generated_at")
return p.parse_args(argv)
def main(argv: Sequence[str]) -> int:
args = _parse_args(argv)
path = Path(args.summary_json)
if not path.exists():
print(f"[FAIL] summary file not found: {path}")
return 2
try:
raw = path.read_text(encoding="utf-8")
except Exception as exc:
print(f"[FAIL] cannot read summary file: {exc}")
return 3
try:
data = json.loads(raw)
except Exception as exc:
print(f"[FAIL] invalid JSON: {exc}")
return 3
if not isinstance(data, Mapping):
print(f"[FAIL] root must be an object, got {_type_name(data)}")
return 2
v = Validator(strict=bool(args.strict))
validate_summary(v, data)
v.fail_if_any()
print("[PASS] simulation summary structure")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# ODS Validation Script
# Run after install to confirm everything is working
set -euo pipefail
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
# Source service registry
export SCRIPT_DIR="$PROJECT_DIR"
. "$PROJECT_DIR/lib/service-registry.sh"
sr_load
# Safe .env loading (no eval; use lib/safe-env.sh)
[[ -f "$PROJECT_DIR/lib/safe-env.sh" ]] && . "$PROJECT_DIR/lib/safe-env.sh"
load_env_file "$PROJECT_DIR/.env"
sr_resolve_ports
# Resolve core ports from registry (honoring any env overrides)
LLM_PORT="${OLLAMA_PORT:-${LLAMA_SERVER_PORT:-${SERVICE_PORTS[llama-server]:-11434}}}"
LLM_HEALTH="${SERVICE_HEALTH[llama-server]:-/health}"
WEBUI_PORT="${WEBUI_PORT:-${SERVICE_PORTS[open-webui]:-3000}}"
WEBUI_HEALTH="${WEBUI_HEALTH:-${SERVICE_HEALTH[open-webui]:-/}}"
# Resolve compose flags to match actual stack
COMPOSE_FLAGS=""
if [[ -x "$PROJECT_DIR/scripts/resolve-compose-stack.sh" ]]; then
# --gpu-count gates the multigpu-{backend}.yml overlay; without it,
# validation runs against the wrong stack on multi-GPU machines.
COMPOSE_FLAGS=$("$PROJECT_DIR/scripts/resolve-compose-stack.sh" \
--script-dir "$PROJECT_DIR" --tier "${TIER:-1}" --gpu-backend "${GPU_BACKEND:-nvidia}" \
--gpu-count "${GPU_COUNT:-1}")
fi
echo ""
echo "╔═══════════════════════════════════════════╗"
echo "║ ODS Validation Test ║"
echo "╚═══════════════════════════════════════════╝"
echo ""
PASSED=0
FAILED=0
check() {
local name="$1"
local cmd="$2"
printf " %-30s " "$name..."
# Run fixed command string via bash -c (no eval)
if bash -c "$cmd" > /dev/null 2>&1; then
echo -e "${GREEN}✓ PASS${NC}"
((PASSED++)) || true
else
echo -e "${RED}✗ FAIL${NC}"
((FAILED++)) || true
fi
}
echo "1. Container Status"
echo "───────────────────"
check "llama-server running" "docker compose $COMPOSE_FLAGS ps llama-server 2>/dev/null | grep -qE 'Up|running'"
check "Open WebUI running" "docker compose $COMPOSE_FLAGS ps open-webui 2>/dev/null | grep -qE 'Up|running'"
echo ""
echo "2. Health Endpoints"
echo "───────────────────"
check "llama-server health" "curl -sf --max-time 10 http://127.0.0.1:${LLM_PORT}${LLM_HEALTH}"
check "llama-server models" "curl -sf --max-time 10 http://127.0.0.1:${LLM_PORT}/v1/models | grep -q model"
check "WebUI reachable" "curl -sf --max-time 10 http://127.0.0.1:${WEBUI_PORT}${WEBUI_HEALTH} -o /dev/null"
echo ""
echo "3. Inference Test"
echo "─────────────────"
printf " %-30s " "Chat completion..."
RESPONSE=$(curl -sf --max-time 30 "http://127.0.0.1:${LLM_PORT}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$(curl -sf --max-time 10 "http://127.0.0.1:${LLM_PORT}/v1/models" | jq -r '.data[0].id // "local"')"'",
"messages": [{"role": "user", "content": "Say OK"}],
"max_tokens": 10
}' 2>/dev/null) || RESPONSE=""
if echo "$RESPONSE" | grep -q "content"; then
echo -e "${GREEN}✓ PASS${NC}"
((PASSED++)) || true
else
echo -e "${RED}✗ FAIL${NC}"
((FAILED++)) || true
fi
# Check optional services
echo ""
echo "4. Optional Services (if enabled)"
echo "──────────────────────────────────"
SCRIPT_DIR_REG="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$SCRIPT_DIR_REG/lib/service-registry.sh"
sr_load
for sid in "${SERVICE_IDS[@]}"; do
_cat="${SERVICE_CATEGORIES[$sid]}"
[[ "$_cat" == "core" ]] && continue # Core already checked above
_container="${SERVICE_CONTAINERS[$sid]}"
_health="${SERVICE_HEALTH[$sid]}"
_port_env="${SERVICE_PORT_ENVS[$sid]}"
_default_port="${SERVICE_PORTS[$sid]}"
_name="${SERVICE_NAMES[$sid]:-$sid}"
# Resolve port
_port="$_default_port"
[[ -n "$_port_env" ]] && _port="${!_port_env:-$_default_port}"
# Skip if no health endpoint or port
[[ -z "$_health" || "$_port" == "0" ]] && continue
# Check if container is running
if docker compose $COMPOSE_FLAGS ps "$sid" 2>/dev/null | grep -qE "Up|running"; then
check "$_name" "curl -sf --max-time 10 http://127.0.0.1:${_port}${_health}"
else
printf " %-30s ${YELLOW}○ SKIP (not enabled)${NC}\n" "$_name..."
fi
done
# Summary
echo ""
echo "═══════════════════════════════════════════"
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}✅ ODS is ready! ($PASSED tests passed)${NC}"
echo ""
echo " Open WebUI: http://localhost:${WEBUI_PORT}"
echo " API: http://localhost:${LLM_PORT}/v1/..."
echo ""
else
echo -e "${RED}⚠️ $FAILED test(s) failed, $PASSED passed${NC}"
echo ""
echo " Troubleshooting:"
echo " - Check logs: docker compose logs -f"
echo " - LLM logs: docker compose logs -f llama-server"
echo " - Restart: docker compose restart"
echo ""
exit 1
fi