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