Compare commits
7 Commits
211abdf08f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 017c3c38fe | |||
| 8367ec2a9f | |||
| 17ec55cb85 | |||
| c11573eeeb | |||
| 6d50f3a804 | |||
| 48f992a451 | |||
| 98cdd95b50 |
@@ -110,6 +110,24 @@ jobs:
|
||||
- name: Build live image
|
||||
run: make live-image
|
||||
|
||||
- name: Debug registry auth context
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "OWNER='${OWNER}'"
|
||||
echo "LI_VERSION='${LI_VERSION}'"
|
||||
echo "REGISTRY_URL='${REGISTRY_URL}'"
|
||||
echo "TOKEN_LEN=${#REGISTRY_TOKEN}"
|
||||
# Probe whoami via the token to confirm it authenticates and
|
||||
# resolves to the expected user. A 401 here narrows the
|
||||
# failure to token/secret injection; a 200 with a different
|
||||
# username narrows it to OWNER mismatch.
|
||||
curl -sS -o /tmp/whoami.json -w 'whoami_status=%{http_code}\n' \
|
||||
-H "Authorization: token ${REGISTRY_TOKEN}" \
|
||||
"${REGISTRY_URL}/api/v1/user"
|
||||
echo "whoami body:"
|
||||
cat /tmp/whoami.json
|
||||
echo
|
||||
|
||||
- name: Publish live-image/${{ env.LI_VERSION }}/
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -11,13 +11,70 @@ Built for solo-operator home labs: one Go binary, SQLite + flat files,
|
||||
HTMX + SSE UI, bundled dnsmasq, optional ntfy / Discord / SMTP
|
||||
notifications.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automated PXE boot** — dnsmasq proxy-DHCP serves a disposable
|
||||
Debian live image to registered MACs. No VLAN, no dedicated bridge.
|
||||
- **11-stage validation pipeline** — Inventory, Firmware, SpecValidate,
|
||||
SMART, CPUStress, Storage, Network, Burn, GPU, PSU, Reporting.
|
||||
- **Three vetting profiles** — quick (~10 min), deep (~8-12 h),
|
||||
soak (~36-40 h). Same probes and gates; only durations scale.
|
||||
- **Server-side threshold engine** — per-run rules evaluate every
|
||||
sensor batch in real time. Critical breaches (thermal runaway,
|
||||
EDAC UE, voltage sag) fail the run immediately.
|
||||
- **FailedHolding with SSH** — when a stage fails the pipeline parks
|
||||
the host and issues a one-time SSH key so you can triage in the
|
||||
live image.
|
||||
- **Real-time dashboard** — HTMX + SSE push tile updates, stage
|
||||
progress, sub-step detail, and live log tailing to the browser.
|
||||
- **Pluggable notifications** — ntfy, Discord webhooks, and SMTP with
|
||||
severity-routed delivery.
|
||||
- **Non-destructive mode** — skip badblocks + wipe for hosts with
|
||||
data you want to keep.
|
||||
- **Host-mode agent** — a persistent reporter that heartbeats from
|
||||
installed hosts and reboots into the live image on command.
|
||||
- **Self-contained HTML reports** — offline-viewable summaries with
|
||||
inlined CSS; machine-readable JSON alongside.
|
||||
- **Four-layer safety gates** — MAC allowlist, signed run token,
|
||||
wipe probe, device allowlist protect against accidental disk wipes.
|
||||
- **Janitor** — automatic retention-based cleanup of artifact files
|
||||
and log files.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Install the host-mode agent on each node (one-liner from the
|
||||
dashboard's quick-register script).
|
||||
2. Register the host in the web UI — name, MAC, expected hardware
|
||||
spec (YAML).
|
||||
3. Click **Start Vetting** and choose a profile (quick / deep / soak).
|
||||
4. The host-mode agent receives a `reboot_for_vetting` heartbeat
|
||||
command and reboots into PXE.
|
||||
5. dnsmasq serves the iPXE script; the host boots a disposable Linux
|
||||
live image containing the vetting agent.
|
||||
6. The agent claims the run (token auth), then walks through each
|
||||
stage — posting logs, sensor readings, and results back to the
|
||||
orchestrator.
|
||||
7. Thresholds are evaluated server-side on every sensor batch.
|
||||
8. **Pass** — auto-reboot to local disk, HTML report generated,
|
||||
notification fires.
|
||||
9. **Fail** — pipeline parks in FailedHolding, SSH key issued,
|
||||
notification fires. Operator triages and retries or releases.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [docs/operations.md](docs/operations.md) — install + first run +
|
||||
- [docs/operations.md](docs/operations.md) — install, first run,
|
||||
troubleshooting
|
||||
- [docs/architecture.md](docs/architecture.md) — packages, state
|
||||
machine, protocol
|
||||
machine, protocol, safety model
|
||||
- [docs/test-suite.md](docs/test-suite.md) — what each stage measures
|
||||
- [docs/configuration.md](docs/configuration.md) — every YAML config
|
||||
knob, profiles, thresholds
|
||||
- [docs/api-reference.md](docs/api-reference.md) — HTTP API with
|
||||
request/response schemas, SSE events
|
||||
- [docs/database.md](docs/database.md) — SQLite schema, tables,
|
||||
entity relationships
|
||||
- [docs/development.md](docs/development.md) — dev setup, building,
|
||||
testing, adding stages
|
||||
|
||||
## Quick start (local, against QEMU)
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Agent binary. Runs in two modes: live-image (default, no args)
|
||||
// parses /proc/cmdline and enters the claim loop; host-mode
|
||||
// ("vetting-agent host") reads /etc/vetting/host-agent.yaml and
|
||||
// becomes a persistent heartbeat reporter.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
+7
-1
@@ -1,3 +1,6 @@
|
||||
// Orchestrator binary. Wires config, stores, runner, dispatcher,
|
||||
// PXE supervisor, iperf supervisor, janitor, notifiers, and HTTP
|
||||
// router, then serves until SIGTERM/SIGINT.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -208,12 +211,15 @@ func main() {
|
||||
ui.PXE = supervisor
|
||||
}
|
||||
|
||||
router := httpserver.NewRouter(httpserver.Deps{
|
||||
router, err := httpserver.NewRouter(httpserver.Deps{
|
||||
UI: ui,
|
||||
Agent: agentAPI,
|
||||
LiveDir: cfg.PXE.LiveDir,
|
||||
AgentAssetDir: cfg.Agent.AssetDir,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("router: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Bind,
|
||||
|
||||
+255
-69
@@ -33,6 +33,172 @@
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ---- style helpers -----------------------------------------------------
|
||||
# Identical block across proxmox-install.sh, install.sh, pxe-setup.sh.
|
||||
# Inlined (not sourced) so the curl|bash entrypoint renders without a
|
||||
# prior fetch. Respects NO_COLOR, non-TTY, and non-UTF-8 locales.
|
||||
_color=0; _unicode=0
|
||||
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
|
||||
_color=1
|
||||
fi
|
||||
case "${LC_ALL:-}${LANG:-}" in
|
||||
*UTF-8*|*utf8*|*UTF8*) _unicode=1 ;;
|
||||
esac
|
||||
if (( _color )); then
|
||||
_B=$'\033[1m'; _D=$'\033[2m'; _R=$'\033[0m'
|
||||
_C=$'\033[1;36m'; _G=$'\033[32m'; _Y=$'\033[33m'; _E=$'\033[31m'
|
||||
else
|
||||
_B=""; _D=""; _R=""; _C=""; _G=""; _Y=""; _E=""
|
||||
fi
|
||||
if (( _unicode )); then
|
||||
_S_STEP="▸"; _S_OK="✓"; _S_WARN="⚠"; _S_FAIL="✗"; _S_INFO="·"
|
||||
_B_TL="╭"; _B_TR="╮"; _B_BL="╰"; _B_BR="╯"; _B_H="─"; _B_V="│"
|
||||
else
|
||||
_S_STEP="-->"; _S_OK="[ok]"; _S_WARN="[!]"; _S_FAIL="[x]"; _S_INFO="*"
|
||||
_B_TL="+"; _B_TR="+"; _B_BL="+"; _B_BR="+"; _B_H="-"; _B_V="|"
|
||||
fi
|
||||
_start_epoch="$(date +%s)"; _step_epoch=""; _quiet_log=""; _spin_pid=""
|
||||
_fmt_dur() {
|
||||
local s=$1
|
||||
if (( s < 60 )); then printf '%ds' "$s"
|
||||
elif (( s < 3600 )); then printf '%dm %ds' "$((s/60))" "$((s%60))"
|
||||
else printf '%dh %dm' "$((s/3600))" "$(((s%3600)/60))"
|
||||
fi
|
||||
}
|
||||
banner() {
|
||||
local inner=" $1 " w line="" i=0
|
||||
w=${#inner}
|
||||
while (( i < w )); do line+="${_B_H}"; i=$((i+1)); done
|
||||
printf '\n %s%s%s%s%s\n' "${_C}" "${_B_TL}" "${line}" "${_B_TR}" "${_R}" >&2
|
||||
printf ' %s%s%s%s%s%s%s%s%s\n' "${_C}" "${_B_V}" "${_R}" "${_B}" "${inner}" "${_R}" "${_C}" "${_B_V}" "${_R}" >&2
|
||||
printf ' %s%s%s%s%s\n\n' "${_C}" "${_B_BL}" "${line}" "${_B_BR}" "${_R}" >&2
|
||||
}
|
||||
step() {
|
||||
_step_epoch="$(date +%s)"
|
||||
printf '%s%s%s %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "$1" "${_R}" >&2
|
||||
}
|
||||
ok() {
|
||||
local tail=""
|
||||
if [[ -n "${_step_epoch}" ]]; then
|
||||
tail=" ${_D}($(_fmt_dur $(( $(date +%s) - _step_epoch ))))${_R}"
|
||||
fi
|
||||
printf ' %s%s%s %s%s\n' "${_G}" "${_S_OK}" "${_R}" "$1" "${tail}" >&2
|
||||
_step_epoch=""
|
||||
}
|
||||
info() { printf ' %s%s %s%s\n' "${_D}" "${_S_INFO}" "$1" "${_R}" >&2; }
|
||||
warn() { printf ' %s%s %s%s\n' "${_Y}" "${_S_WARN}" "$1" "${_R}" >&2; }
|
||||
die() {
|
||||
printf '\n%s%s %s%s\n' "${_E}" "${_S_FAIL}" "$1" "${_R}" >&2
|
||||
if [[ -n "${_quiet_log:-}" && -s "${_quiet_log}" ]]; then
|
||||
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
|
||||
tail -n 40 "${_quiet_log}" | sed 's/^/ /' >&2
|
||||
printf ' %sfull log: %s%s\n' "${_D}" "${_quiet_log}" "${_R}" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
_SPIN=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
_start_spin() {
|
||||
(( _color && _unicode )) || return 0
|
||||
local label="$1"
|
||||
(
|
||||
local i=0
|
||||
while :; do
|
||||
printf '\r %s%s%s %s ' "${_C}" "${_SPIN[i]}" "${_R}" "${label}" >&2
|
||||
i=$(( (i+1) % ${#_SPIN[@]} ))
|
||||
sleep 0.1
|
||||
done
|
||||
) &
|
||||
_spin_pid=$!
|
||||
}
|
||||
_stop_spin() {
|
||||
[[ -n "${_spin_pid}" ]] || return 0
|
||||
kill "${_spin_pid}" 2>/dev/null || true
|
||||
wait "${_spin_pid}" 2>/dev/null || true
|
||||
_spin_pid=""
|
||||
printf '\r\033[2K' >&2
|
||||
}
|
||||
# run_quiet "<label>" -- <cmd ...>
|
||||
run_quiet() {
|
||||
local label="$1"; shift
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
local log start rc=0
|
||||
log="$(mktemp -t vetting-install.XXXXXX)"
|
||||
_quiet_log="${log}"
|
||||
start="$(date +%s)"
|
||||
_start_spin "${label}"
|
||||
"$@" >"${log}" 2>&1 || rc=$?
|
||||
_stop_spin
|
||||
local dt=$(( $(date +%s) - start ))
|
||||
if (( rc == 0 )); then
|
||||
printf ' %s%s%s %s %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
rm -f "${log}"; _quiet_log=""
|
||||
return 0
|
||||
fi
|
||||
printf ' %s%s %s failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
|
||||
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
|
||||
tail -n 40 "${log}" | sed 's/^/ /' >&2
|
||||
printf ' %sfull log: %s%s\n' "${_D}" "${log}" "${_R}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
# curl_pretty --label LABEL --url URL -- [extra curl args, including -o PATH]
|
||||
curl_pretty() {
|
||||
local label="download" url=""
|
||||
local args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--label) label="$2"; shift 2 ;;
|
||||
--url) url="$2"; args+=("$2"); shift 2 ;;
|
||||
--) shift; args+=("$@"); break ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
local size_str=""
|
||||
if [[ -n "${url}" ]]; then
|
||||
local size_bytes
|
||||
size_bytes="$(curl -fsSLI --max-time 5 "${url}" 2>/dev/null \
|
||||
| awk 'tolower($1) ~ /^content-length:/ {print $2}' \
|
||||
| tr -d '\r' | tail -n1)"
|
||||
if [[ "${size_bytes}" =~ ^[0-9]+$ ]]; then
|
||||
local human
|
||||
human="$(numfmt --to=iec --suffix=B --format='%.1f' "${size_bytes}" 2>/dev/null || echo "${size_bytes}B")"
|
||||
size_str=" ${_D}(${human})${_R}"
|
||||
fi
|
||||
fi
|
||||
printf '%s%s%s %sdownloading %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "${label}" "${_R}" "${size_str}" >&2
|
||||
local start rc=0
|
||||
start="$(date +%s)"
|
||||
if [[ -t 2 ]]; then
|
||||
curl -fL --progress-bar "${args[@]}" || rc=$?
|
||||
else
|
||||
curl -fsSL "${args[@]}" || rc=$?
|
||||
fi
|
||||
local dt=$(( $(date +%s) - start ))
|
||||
if (( rc == 0 )); then
|
||||
printf ' %s%s%s %s ready %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
return 0
|
||||
fi
|
||||
printf ' %s%s %s download failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
rule_open() {
|
||||
local title="$1"
|
||||
local dashes=$(( 46 - ${#title} - 4 ))
|
||||
local tail="" i=0
|
||||
(( dashes < 2 )) && dashes=2
|
||||
while (( i < dashes )); do tail+="${_B_H}"; i=$((i+1)); done
|
||||
printf '\n%s%s%s %s %s%s\n' "${_C}" "${_B_TL}" "${_B_H}" "${title}" "${tail}" "${_R}" >&2
|
||||
}
|
||||
rule_close() {
|
||||
local line="" i=0
|
||||
while (( i < 46 )); do line+="${_B_H}"; i=$((i+1)); done
|
||||
printf '%s%s%s%s\n' "${_C}" "${_B_BL}" "${line:1}" "${_R}" >&2
|
||||
}
|
||||
total_elapsed() {
|
||||
local dt=$(( $(date +%s) - _start_epoch ))
|
||||
printf '%sinstalled in %s%s\n\n' "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
}
|
||||
# ---- end style helpers -------------------------------------------------
|
||||
|
||||
BINARY=""
|
||||
AGENT_BINARY=""
|
||||
CONFIG_DIR="/etc/vetting"
|
||||
@@ -78,8 +244,21 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "install.sh must be run as root (try: sudo $0)" >&2
|
||||
exit 1
|
||||
die "install.sh must be run as root (try: sudo $0)"
|
||||
fi
|
||||
|
||||
# Standalone invocation gets its own banner; wrapped runs share the one
|
||||
# from proxmox-install.sh. Tracked in VETTING_INSTALL_WRAPPED so we don't
|
||||
# render the banner or summary twice.
|
||||
if [[ -z "${VETTING_INSTALL_WRAPPED:-}" ]]; then
|
||||
banner "TheWrightServer · Vetting"
|
||||
fi
|
||||
|
||||
# Snapshot service state so the summary can pick between fresh-install
|
||||
# and upgrade-path wording even after we restart the service below.
|
||||
_prev_svc_enabled=0
|
||||
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
|
||||
_prev_svc_enabled=1
|
||||
fi
|
||||
|
||||
# heal_pxe_config: make sure /etc/vetting/vetting.yaml's pxe.interface
|
||||
@@ -140,18 +319,17 @@ heal_pxe_config() {
|
||||
fi
|
||||
|
||||
if [[ -z "${detected_iface}" || -z "${detected_subnet}" ]]; then
|
||||
echo "WARN: pxe is enabled in ${config} but pxe.interface=${cur_iface:-<empty>} / pxe.subnet=${cur_subnet:-<empty>} is stale," >&2
|
||||
echo " and no default-route NIC was found to auto-detect from. Edit the file manually before starting." >&2
|
||||
warn "pxe enabled but pxe.interface=${cur_iface:-<empty>} / pxe.subnet=${cur_subnet:-<empty>} is stale, and no default-route NIC was found to auto-detect from. Edit ${config} manually before starting."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local iface_to_write="${cur_iface}" subnet_to_write="${cur_subnet}"
|
||||
if (( iface_ok == 0 )); then
|
||||
echo "==> pxe.interface \"${cur_iface}\" is not present on this host; auto-patching to \"${detected_iface}\""
|
||||
info "pxe.interface \"${cur_iface}\" is not present on this host; auto-patching to \"${detected_iface}\""
|
||||
iface_to_write="${detected_iface}"
|
||||
fi
|
||||
if (( subnet_ok == 0 )); then
|
||||
echo "==> pxe.subnet \"${cur_subnet:-<empty>}\" is missing/invalid; auto-patching to \"${detected_subnet}\""
|
||||
info "pxe.subnet \"${cur_subnet:-<empty>}\" is missing/invalid; auto-patching to \"${detected_subnet}\""
|
||||
subnet_to_write="${detected_subnet}"
|
||||
fi
|
||||
|
||||
@@ -181,7 +359,7 @@ refresh_live_image() {
|
||||
local bundle_ver
|
||||
bundle_ver="$(tr -d '[:space:]' < "${pointer}" 2>/dev/null || true)"
|
||||
if [[ -z "${bundle_ver}" ]]; then
|
||||
echo "WARN: bundle's ${pointer} is empty; skipping live-image fetch" >&2
|
||||
warn "bundle's ${pointer} is empty; skipping live-image fetch"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -191,27 +369,25 @@ refresh_live_image() {
|
||||
fi
|
||||
|
||||
if [[ "${bundle_ver}" == "${installed_ver}" && "${FORCE_LIVE_IMAGE:-0}" != "1" ]]; then
|
||||
echo "==> live-image already at ${bundle_ver}; skipping fetch (FORCE_LIVE_IMAGE=1 to redownload)"
|
||||
info "live-image already at ${bundle_ver}; skipping fetch (FORCE_LIVE_IMAGE=1 to redownload)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z "${REGISTRY_URL:-}" ]]; then
|
||||
echo "WARN: REGISTRY_URL is not set; cannot fetch live-image ${bundle_ver}. Re-run via proxmox-install.sh or export REGISTRY_URL." >&2
|
||||
warn "REGISTRY_URL is not set; cannot fetch live-image ${bundle_ver}. Re-run via proxmox-install.sh or export REGISTRY_URL."
|
||||
return 0
|
||||
fi
|
||||
local owner="${PACKAGE_OWNER:-josh}"
|
||||
local base="${REGISTRY_URL%/}/api/packages/${owner}/generic/live-image/${bundle_ver}"
|
||||
|
||||
echo "==> fetching live-image ${bundle_ver} (was '${installed_ver:-none}') from ${base}"
|
||||
step "fetching live-image ${bundle_ver} (was '${installed_ver:-none}')"
|
||||
local tmp
|
||||
tmp="$(mktemp -d)"
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -rf '${tmp}'" RETURN
|
||||
|
||||
# Default curl meter shows rate + ETA, which matters for the ~300 MB
|
||||
# initrd on slow links.
|
||||
curl -fL -o "${tmp}/vmlinuz" "${base}/vmlinuz"
|
||||
curl -fL -o "${tmp}/initrd.img" "${base}/initrd.img"
|
||||
curl_pretty --label "kernel (vmlinuz)" --url "${base}/vmlinuz" -- -o "${tmp}/vmlinuz"
|
||||
curl_pretty --label "initrd (initrd.img)" --url "${base}/initrd.img" -- -o "${tmp}/initrd.img"
|
||||
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
|
||||
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
|
||||
@@ -235,8 +411,7 @@ if [[ -z "${BINARY}" ]]; then
|
||||
done
|
||||
fi
|
||||
if [[ -z "${BINARY}" || ! -x "${BINARY}" ]]; then
|
||||
echo "could not find a vetting binary to install; pass --binary PATH or run 'make orchestrator-linux' first" >&2
|
||||
exit 1
|
||||
die "could not find a vetting binary to install; pass --binary PATH or run 'make orchestrator-linux' first"
|
||||
fi
|
||||
|
||||
if [[ -z "${AGENT_BINARY}" ]]; then
|
||||
@@ -248,35 +423,41 @@ if [[ -z "${AGENT_BINARY}" ]]; then
|
||||
done
|
||||
fi
|
||||
if [[ -z "${AGENT_BINARY}" || ! -x "${AGENT_BINARY}" ]]; then
|
||||
echo "could not find a vetting-agent binary; pass --agent-binary PATH or run 'make agent-linux' first" >&2
|
||||
exit 1
|
||||
die "could not find a vetting-agent binary; pass --agent-binary PATH or run 'make agent-linux' first"
|
||||
fi
|
||||
|
||||
echo "==> installing runtime dependencies"
|
||||
step "installing runtime dependencies"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
run_quiet "apt: dnsmasq, iperf3, ca-certificates" -- bash -c '
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates dnsmasq iperf3
|
||||
apt-get install -qq -y --no-install-recommends ca-certificates dnsmasq iperf3
|
||||
'
|
||||
|
||||
echo "==> creating ${SERVICE_USER} user"
|
||||
step "creating ${SERVICE_USER} user"
|
||||
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
useradd --system \
|
||||
--home-dir "${STATE_DIR}" \
|
||||
--shell /usr/sbin/nologin \
|
||||
"${SERVICE_USER}"
|
||||
ok "${SERVICE_USER} created"
|
||||
else
|
||||
info "${SERVICE_USER} user already exists"
|
||||
fi
|
||||
|
||||
echo "==> preparing directories"
|
||||
step "preparing directories"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${STATE_DIR}"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LOG_DIR}"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${ASSET_DIR}"
|
||||
install -d -m 0755 "${CONFIG_DIR}"
|
||||
info "state: ${STATE_DIR} · logs: ${LOG_DIR} · config: ${CONFIG_DIR}"
|
||||
|
||||
echo "==> installing binary"
|
||||
step "installing binary"
|
||||
install -m 0755 "${BINARY}" /usr/local/bin/vetting
|
||||
install -m 0755 "${AGENT_BINARY}" "${ASSET_DIR}/vetting-agent-linux-amd64"
|
||||
info "orchestrator: /usr/local/bin/vetting"
|
||||
info "agent asset: ${ASSET_DIR}/vetting-agent-linux-amd64"
|
||||
|
||||
echo "==> installing config and systemd unit"
|
||||
step "installing config and systemd unit"
|
||||
# vetting.production.yaml uses absolute /var/lib/vetting + /var/log/vetting
|
||||
# paths that match the systemd unit's ReadWritePaths. vetting.example.yaml
|
||||
# uses ./var/... relatives and is only correct for `make run` in a dev tree.
|
||||
@@ -284,22 +465,21 @@ if [[ ! -f "${CONFIG_DIR}/vetting.yaml" ]]; then
|
||||
install -m 0640 -o root -g "${SERVICE_USER}" \
|
||||
"${SCRIPT_DIR}/vetting.production.yaml" \
|
||||
"${CONFIG_DIR}/vetting.yaml"
|
||||
echo " -> installed default config at ${CONFIG_DIR}/vetting.yaml"
|
||||
info "installed default config at ${CONFIG_DIR}/vetting.yaml"
|
||||
else
|
||||
echo " -> preserving existing ${CONFIG_DIR}/vetting.yaml"
|
||||
info "preserving existing ${CONFIG_DIR}/vetting.yaml"
|
||||
fi
|
||||
install -m 0644 "${SCRIPT_DIR}/vetting.service" /etc/systemd/system/vetting.service
|
||||
|
||||
# Install pxe-setup.sh + its pinned iPXE SHAs into a stable path so the
|
||||
# operator can run `vetting-pxe-setup ...` after the one-liner install.
|
||||
# The bundle's tempdir gets wiped by proxmox-install.sh on exit, so
|
||||
# without this the script would be inaccessible.
|
||||
# operator can run `vetting-pxe-setup` after the one-liner install.
|
||||
if [[ -f "${SCRIPT_DIR}/pxe-setup.sh" && -f "${SCRIPT_DIR}/ipxe-shas.txt" ]]; then
|
||||
echo "==> installing pxe-setup.sh and ipxe-shas.txt"
|
||||
step "installing pxe-setup.sh and ipxe-shas.txt"
|
||||
install -d -m 0755 /usr/local/share/vetting
|
||||
install -m 0755 "${SCRIPT_DIR}/pxe-setup.sh" /usr/local/share/vetting/pxe-setup.sh
|
||||
install -m 0644 "${SCRIPT_DIR}/ipxe-shas.txt" /usr/local/share/vetting/ipxe-shas.txt
|
||||
ln -sfn /usr/local/share/vetting/pxe-setup.sh /usr/local/sbin/vetting-pxe-setup
|
||||
info "vetting-pxe-setup -> /usr/local/share/vetting/pxe-setup.sh"
|
||||
fi
|
||||
|
||||
# Stage the live image into LIVE_DIR. Preference order:
|
||||
@@ -322,7 +502,7 @@ if [[ -z "${LIVE_IMAGE_SRC}" ]]; then
|
||||
fi
|
||||
|
||||
if [[ -n "${LIVE_IMAGE_SRC}" ]]; then
|
||||
echo "==> staging live image from ${LIVE_IMAGE_SRC} into ${LIVE_DIR}"
|
||||
step "staging live image from ${LIVE_IMAGE_SRC}"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
|
||||
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
|
||||
"${LIVE_IMAGE_SRC}/vmlinuz" "${LIVE_DIR}/vmlinuz"
|
||||
@@ -342,10 +522,11 @@ if [[ -n "${LIVE_IMAGE_SRC}" ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
ok "live image staged into ${LIVE_DIR}"
|
||||
elif [[ -f "${SCRIPT_DIR}/live-image/VERSION" ]]; then
|
||||
refresh_live_image
|
||||
else
|
||||
echo "==> no live image found (bundle/live-image or ../live-image/build); skipping live-dir staging"
|
||||
info "no live image found (bundle/live-image or ../live-image/build); skipping live-dir staging"
|
||||
fi
|
||||
|
||||
# Disable the distro's dnsmasq so only the orchestrator-supervised
|
||||
@@ -353,50 +534,55 @@ fi
|
||||
# something else can re-enable it after configuring a disjoint listen
|
||||
# address.
|
||||
if systemctl is-enabled --quiet dnsmasq 2>/dev/null; then
|
||||
echo "==> disabling distro dnsmasq (orchestrator supervises its own)"
|
||||
systemctl disable --now dnsmasq
|
||||
step "disabling distro dnsmasq (orchestrator supervises its own)"
|
||||
run_quiet "systemctl disable --now dnsmasq" -- systemctl disable --now dnsmasq
|
||||
fi
|
||||
|
||||
echo "==> validating pxe config against this host's interfaces"
|
||||
step "validating pxe config against this host's interfaces"
|
||||
heal_pxe_config "${CONFIG_DIR}/vetting.yaml"
|
||||
|
||||
systemctl daemon-reload
|
||||
run_quiet "systemctl daemon-reload" -- systemctl daemon-reload
|
||||
|
||||
# Upgrade path: if vetting.service is already enabled, restart it so the
|
||||
# new binary + live image take effect without an explicit second
|
||||
# command. First-install path (service not enabled yet) leaves the
|
||||
# service alone so the operator can edit the config before starting.
|
||||
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
|
||||
echo "==> restarting vetting.service (upgrade path)"
|
||||
if (( _prev_svc_enabled )); then
|
||||
step "restarting vetting.service (upgrade path)"
|
||||
systemctl reset-failed vetting.service 2>/dev/null || true
|
||||
systemctl restart vetting.service
|
||||
cat <<EOF
|
||||
|
||||
vetting upgraded and restarted. Tail logs with:
|
||||
journalctl -fu vetting
|
||||
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
|
||||
vetting is installed but not yet enabled.
|
||||
|
||||
Next steps:
|
||||
1. Edit ${CONFIG_DIR}/vetting.yaml and set:
|
||||
- server.bind (127.0.0.1:8080 by default; switch to
|
||||
0.0.0.0:8080 once you're ready to expose
|
||||
it on the LAN)
|
||||
- server.public_url (the URL you'll browse to)
|
||||
- pxe.* if you want PXE boot support
|
||||
- notifiers + routes (optional)
|
||||
2. Start the service:
|
||||
systemctl enable --now vetting
|
||||
3. Watch the logs:
|
||||
journalctl -fu vetting
|
||||
|
||||
The UI has no built-in auth — it trusts the LAN. If you need a
|
||||
password, front the service with a reverse proxy (Caddy/nginx
|
||||
basic-auth) instead.
|
||||
|
||||
EOF
|
||||
run_quiet "systemctl restart vetting.service" -- systemctl restart vetting.service
|
||||
fi
|
||||
|
||||
# Standalone summary (wrapped runs get the summary from proxmox-install.sh).
|
||||
if [[ -z "${VETTING_INSTALL_WRAPPED:-}" ]]; then
|
||||
li_ver="unknown"
|
||||
[[ -f "${LIVE_DIR}/VERSION" ]] && li_ver="$(tr -d '[:space:]' < "${LIVE_DIR}/VERSION")"
|
||||
svc_state="not yet enabled"
|
||||
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
|
||||
if systemctl is-active --quiet vetting.service 2>/dev/null; then
|
||||
svc_state="enabled · running"
|
||||
else
|
||||
svc_state="enabled · stopped"
|
||||
fi
|
||||
fi
|
||||
rule_open "installed"
|
||||
info "live-image ${li_ver}"
|
||||
info "service ${svc_state}"
|
||||
info "config ${CONFIG_DIR}/vetting.yaml"
|
||||
printf '\n' >&2
|
||||
if (( _prev_svc_enabled )); then
|
||||
info "upgraded · tail logs with:"
|
||||
info " journalctl -fu vetting"
|
||||
else
|
||||
info "next:"
|
||||
info " edit ${CONFIG_DIR}/vetting.yaml (server.bind, public_url, pxe.*)"
|
||||
info " systemctl enable --now vetting"
|
||||
info " journalctl -fu vetting"
|
||||
printf '\n' >&2
|
||||
info "pxe support:"
|
||||
info " sudo vetting-pxe-setup --interface <NIC> --subnet <CIDR> \\"
|
||||
info " --orchestrator-url http://<host>:8080"
|
||||
fi
|
||||
rule_close
|
||||
total_elapsed
|
||||
fi
|
||||
|
||||
+217
-35
@@ -22,6 +22,172 @@
|
||||
# (useful when the local files got corrupted).
|
||||
set -euo pipefail
|
||||
|
||||
# ---- style helpers -----------------------------------------------------
|
||||
# Identical block across proxmox-install.sh, install.sh, pxe-setup.sh.
|
||||
# Inlined (not sourced) so the curl|bash entrypoint renders without a
|
||||
# prior fetch. Respects NO_COLOR, non-TTY, and non-UTF-8 locales.
|
||||
_color=0; _unicode=0
|
||||
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
|
||||
_color=1
|
||||
fi
|
||||
case "${LC_ALL:-}${LANG:-}" in
|
||||
*UTF-8*|*utf8*|*UTF8*) _unicode=1 ;;
|
||||
esac
|
||||
if (( _color )); then
|
||||
_B=$'\033[1m'; _D=$'\033[2m'; _R=$'\033[0m'
|
||||
_C=$'\033[1;36m'; _G=$'\033[32m'; _Y=$'\033[33m'; _E=$'\033[31m'
|
||||
else
|
||||
_B=""; _D=""; _R=""; _C=""; _G=""; _Y=""; _E=""
|
||||
fi
|
||||
if (( _unicode )); then
|
||||
_S_STEP="▸"; _S_OK="✓"; _S_WARN="⚠"; _S_FAIL="✗"; _S_INFO="·"
|
||||
_B_TL="╭"; _B_TR="╮"; _B_BL="╰"; _B_BR="╯"; _B_H="─"; _B_V="│"
|
||||
else
|
||||
_S_STEP="-->"; _S_OK="[ok]"; _S_WARN="[!]"; _S_FAIL="[x]"; _S_INFO="*"
|
||||
_B_TL="+"; _B_TR="+"; _B_BL="+"; _B_BR="+"; _B_H="-"; _B_V="|"
|
||||
fi
|
||||
_start_epoch="$(date +%s)"; _step_epoch=""; _quiet_log=""; _spin_pid=""
|
||||
_fmt_dur() {
|
||||
local s=$1
|
||||
if (( s < 60 )); then printf '%ds' "$s"
|
||||
elif (( s < 3600 )); then printf '%dm %ds' "$((s/60))" "$((s%60))"
|
||||
else printf '%dh %dm' "$((s/3600))" "$(((s%3600)/60))"
|
||||
fi
|
||||
}
|
||||
banner() {
|
||||
local inner=" $1 " w line="" i=0
|
||||
w=${#inner}
|
||||
while (( i < w )); do line+="${_B_H}"; i=$((i+1)); done
|
||||
printf '\n %s%s%s%s%s\n' "${_C}" "${_B_TL}" "${line}" "${_B_TR}" "${_R}" >&2
|
||||
printf ' %s%s%s%s%s%s%s%s%s\n' "${_C}" "${_B_V}" "${_R}" "${_B}" "${inner}" "${_R}" "${_C}" "${_B_V}" "${_R}" >&2
|
||||
printf ' %s%s%s%s%s\n\n' "${_C}" "${_B_BL}" "${line}" "${_B_BR}" "${_R}" >&2
|
||||
}
|
||||
step() {
|
||||
_step_epoch="$(date +%s)"
|
||||
printf '%s%s%s %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "$1" "${_R}" >&2
|
||||
}
|
||||
ok() {
|
||||
local tail=""
|
||||
if [[ -n "${_step_epoch}" ]]; then
|
||||
tail=" ${_D}($(_fmt_dur $(( $(date +%s) - _step_epoch ))))${_R}"
|
||||
fi
|
||||
printf ' %s%s%s %s%s\n' "${_G}" "${_S_OK}" "${_R}" "$1" "${tail}" >&2
|
||||
_step_epoch=""
|
||||
}
|
||||
info() { printf ' %s%s %s%s\n' "${_D}" "${_S_INFO}" "$1" "${_R}" >&2; }
|
||||
warn() { printf ' %s%s %s%s\n' "${_Y}" "${_S_WARN}" "$1" "${_R}" >&2; }
|
||||
die() {
|
||||
printf '\n%s%s %s%s\n' "${_E}" "${_S_FAIL}" "$1" "${_R}" >&2
|
||||
if [[ -n "${_quiet_log:-}" && -s "${_quiet_log}" ]]; then
|
||||
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
|
||||
tail -n 40 "${_quiet_log}" | sed 's/^/ /' >&2
|
||||
printf ' %sfull log: %s%s\n' "${_D}" "${_quiet_log}" "${_R}" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
_SPIN=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
_start_spin() {
|
||||
(( _color && _unicode )) || return 0
|
||||
local label="$1"
|
||||
(
|
||||
local i=0
|
||||
while :; do
|
||||
printf '\r %s%s%s %s ' "${_C}" "${_SPIN[i]}" "${_R}" "${label}" >&2
|
||||
i=$(( (i+1) % ${#_SPIN[@]} ))
|
||||
sleep 0.1
|
||||
done
|
||||
) &
|
||||
_spin_pid=$!
|
||||
}
|
||||
_stop_spin() {
|
||||
[[ -n "${_spin_pid}" ]] || return 0
|
||||
kill "${_spin_pid}" 2>/dev/null || true
|
||||
wait "${_spin_pid}" 2>/dev/null || true
|
||||
_spin_pid=""
|
||||
printf '\r\033[2K' >&2
|
||||
}
|
||||
# run_quiet "<label>" -- <cmd ...>
|
||||
run_quiet() {
|
||||
local label="$1"; shift
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
local log start rc=0
|
||||
log="$(mktemp -t vetting-install.XXXXXX)"
|
||||
_quiet_log="${log}"
|
||||
start="$(date +%s)"
|
||||
_start_spin "${label}"
|
||||
"$@" >"${log}" 2>&1 || rc=$?
|
||||
_stop_spin
|
||||
local dt=$(( $(date +%s) - start ))
|
||||
if (( rc == 0 )); then
|
||||
printf ' %s%s%s %s %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
rm -f "${log}"; _quiet_log=""
|
||||
return 0
|
||||
fi
|
||||
printf ' %s%s %s failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
|
||||
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
|
||||
tail -n 40 "${log}" | sed 's/^/ /' >&2
|
||||
printf ' %sfull log: %s%s\n' "${_D}" "${log}" "${_R}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
# curl_pretty --label LABEL --url URL -- [extra curl args, including -o PATH]
|
||||
curl_pretty() {
|
||||
local label="download" url=""
|
||||
local args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--label) label="$2"; shift 2 ;;
|
||||
--url) url="$2"; args+=("$2"); shift 2 ;;
|
||||
--) shift; args+=("$@"); break ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
local size_str=""
|
||||
if [[ -n "${url}" ]]; then
|
||||
local size_bytes
|
||||
size_bytes="$(curl -fsSLI --max-time 5 "${url}" 2>/dev/null \
|
||||
| awk 'tolower($1) ~ /^content-length:/ {print $2}' \
|
||||
| tr -d '\r' | tail -n1)"
|
||||
if [[ "${size_bytes}" =~ ^[0-9]+$ ]]; then
|
||||
local human
|
||||
human="$(numfmt --to=iec --suffix=B --format='%.1f' "${size_bytes}" 2>/dev/null || echo "${size_bytes}B")"
|
||||
size_str=" ${_D}(${human})${_R}"
|
||||
fi
|
||||
fi
|
||||
printf '%s%s%s %sdownloading %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "${label}" "${_R}" "${size_str}" >&2
|
||||
local start rc=0
|
||||
start="$(date +%s)"
|
||||
if [[ -t 2 ]]; then
|
||||
curl -fL --progress-bar "${args[@]}" || rc=$?
|
||||
else
|
||||
curl -fsSL "${args[@]}" || rc=$?
|
||||
fi
|
||||
local dt=$(( $(date +%s) - start ))
|
||||
if (( rc == 0 )); then
|
||||
printf ' %s%s%s %s ready %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
return 0
|
||||
fi
|
||||
printf ' %s%s %s download failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
rule_open() {
|
||||
local title="$1"
|
||||
local dashes=$(( 46 - ${#title} - 4 ))
|
||||
local tail="" i=0
|
||||
(( dashes < 2 )) && dashes=2
|
||||
while (( i < dashes )); do tail+="${_B_H}"; i=$((i+1)); done
|
||||
printf '\n%s%s%s %s %s%s\n' "${_C}" "${_B_TL}" "${_B_H}" "${title}" "${tail}" "${_R}" >&2
|
||||
}
|
||||
rule_close() {
|
||||
local line="" i=0
|
||||
while (( i < 46 )); do line+="${_B_H}"; i=$((i+1)); done
|
||||
printf '%s%s%s%s\n' "${_C}" "${_B_BL}" "${line:1}" "${_R}" >&2
|
||||
}
|
||||
total_elapsed() {
|
||||
local dt=$(( $(date +%s) - _start_epoch ))
|
||||
printf '%sinstalled in %s%s\n\n' "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
}
|
||||
# ---- end style helpers -------------------------------------------------
|
||||
|
||||
REGISTRY_URL="${REGISTRY_URL:-https://gitea.thewrightserver.net}"
|
||||
PACKAGE_OWNER="${PACKAGE_OWNER:-josh}"
|
||||
FORCE_LIVE_IMAGE="${FORCE_LIVE_IMAGE:-0}"
|
||||
@@ -29,7 +195,7 @@ FORCE_LIVE_IMAGE="${FORCE_LIVE_IMAGE:-0}"
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
--force-live-image) FORCE_LIVE_IMAGE=1 ;;
|
||||
*) echo "unknown arg: ${arg}" >&2; exit 2 ;;
|
||||
*) die "unknown arg: ${arg}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -41,27 +207,25 @@ export REGISTRY_URL PACKAGE_OWNER FORCE_LIVE_IMAGE
|
||||
BUNDLE_URL="${REGISTRY_URL}/api/packages/${PACKAGE_OWNER}/generic/vetting/latest/vetting-bundle.tar.gz"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "proxmox-install.sh must be run as root (try: sudo bash)" >&2
|
||||
exit 1
|
||||
die "proxmox-install.sh must be run as root (try: sudo bash)"
|
||||
fi
|
||||
|
||||
echo "==> installing prerequisites"
|
||||
banner "TheWrightServer · Vetting"
|
||||
|
||||
step "installing prerequisites"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
run_quiet "apt: curl, ca-certificates" -- bash -c '
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates
|
||||
apt-get install -qq -y --no-install-recommends curl ca-certificates
|
||||
'
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp}"' EXIT
|
||||
|
||||
echo "==> fetching bundle from ${BUNDLE_URL}"
|
||||
# -f fails on HTTP errors; -L follows redirects. Default meter (rate +
|
||||
# ETA) is fine now that the bundle is ~30 MB.
|
||||
curl -fL "${BUNDLE_URL}" -o "${tmp}/vetting-bundle.tar.gz"
|
||||
curl_pretty --label "vetting bundle" --url "${BUNDLE_URL}" -- -o "${tmp}/vetting-bundle.tar.gz"
|
||||
|
||||
bundle_size="$(du -h "${tmp}/vetting-bundle.tar.gz" | cut -f1)"
|
||||
echo "==> extracting (${bundle_size})"
|
||||
tar -C "${tmp}" -xzf "${tmp}/vetting-bundle.tar.gz"
|
||||
step "extracting bundle"
|
||||
run_quiet "tar -xzf vetting-bundle.tar.gz" -- tar -C "${tmp}" -xzf "${tmp}/vetting-bundle.tar.gz"
|
||||
|
||||
# New bundle extracts to vetting-bundle/; legacy bundles used
|
||||
# vetting-bundle-<sha>/. Match both so a downgrade-pin still works.
|
||||
@@ -69,34 +233,52 @@ shopt -s nullglob
|
||||
candidates=( "${tmp}"/vetting-bundle "${tmp}"/vetting-bundle-* )
|
||||
shopt -u nullglob
|
||||
if [[ ${#candidates[@]} -ne 1 || ! -d "${candidates[0]}" ]]; then
|
||||
echo "unexpected bundle layout: expected exactly one vetting-bundle* dir" >&2
|
||||
exit 1
|
||||
die "unexpected bundle layout: expected exactly one vetting-bundle* dir"
|
||||
fi
|
||||
bundle_dir="${candidates[0]}"
|
||||
info "bundle: ${bundle_dir##*/}"
|
||||
|
||||
echo "==> handing off to install.sh (bundle ${bundle_dir##*/})"
|
||||
cd "${bundle_dir}"
|
||||
# install.sh prints its own step/info/ok/warn lines via the same style
|
||||
# helpers, so output flows seamlessly under this banner. VETTING_INSTALL_WRAPPED
|
||||
# tells install.sh to suppress its own banner + summary, since we print
|
||||
# them here.
|
||||
export VETTING_INSTALL_WRAPPED=1
|
||||
bash install.sh \
|
||||
--binary "${bundle_dir}/bin/vetting-linux-amd64" \
|
||||
--agent-binary "${bundle_dir}/bin/vetting-agent.linux-amd64"
|
||||
|
||||
orch_ver="$(cat "${bundle_dir}/VERSION" 2>/dev/null || echo unknown)"
|
||||
li_ver="$(cat "${bundle_dir}/live-image/VERSION" 2>/dev/null || echo unknown)"
|
||||
# ---- final summary -----------------------------------------------------
|
||||
li_ver="$(tr -d '[:space:]' < "${bundle_dir}/live-image/VERSION" 2>/dev/null)"
|
||||
[[ -z "${li_ver}" ]] && li_ver="unknown"
|
||||
svc_state="not yet enabled"
|
||||
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
|
||||
if systemctl is-active --quiet vetting.service 2>/dev/null; then
|
||||
svc_state="enabled · running"
|
||||
else
|
||||
svc_state="enabled · stopped"
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
vetting installed: orchestrator ${orch_ver}, live-image ${li_ver}.
|
||||
|
||||
To upgrade later, rerun this one-liner. It always pulls the current
|
||||
latest bundle; the live image is re-downloaded only when its VERSION
|
||||
has bumped (override with --force-live-image).
|
||||
|
||||
For PXE support, run:
|
||||
sudo vetting-pxe-setup \\
|
||||
--interface eth0 \\
|
||||
--subnet 192.168.1.0/24 \\
|
||||
--orchestrator-url http://<lxc-lan-ip>:8080
|
||||
|
||||
See docs/operations.md for the full flow.
|
||||
|
||||
EOF
|
||||
rule_open "installed"
|
||||
info "live-image ${li_ver}"
|
||||
info "service ${svc_state}"
|
||||
info "config /etc/vetting/vetting.yaml"
|
||||
printf '\n' >&2
|
||||
if [[ "${svc_state}" == "not yet enabled" ]]; then
|
||||
info "next:"
|
||||
info " edit /etc/vetting/vetting.yaml (bind addr, public_url, pxe.*)"
|
||||
info " systemctl enable --now vetting"
|
||||
info " journalctl -fu vetting"
|
||||
else
|
||||
info "next:"
|
||||
info " journalctl -fu vetting"
|
||||
fi
|
||||
printf '\n' >&2
|
||||
info "pxe support:"
|
||||
info " sudo vetting-pxe-setup --interface <NIC> --subnet <CIDR> \\"
|
||||
info " --orchestrator-url http://<host>:8080"
|
||||
info "upgrade later:"
|
||||
info " rerun the one-liner (--force-live-image to force refetch)"
|
||||
rule_close
|
||||
total_elapsed
|
||||
|
||||
+209
-51
@@ -28,6 +28,172 @@
|
||||
# --force overwrite a customised pxe: block
|
||||
set -euo pipefail
|
||||
|
||||
# ---- style helpers -----------------------------------------------------
|
||||
# Identical block across proxmox-install.sh, install.sh, pxe-setup.sh.
|
||||
# Inlined (not sourced) so the curl|bash entrypoint renders without a
|
||||
# prior fetch. Respects NO_COLOR, non-TTY, and non-UTF-8 locales.
|
||||
_color=0; _unicode=0
|
||||
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
|
||||
_color=1
|
||||
fi
|
||||
case "${LC_ALL:-}${LANG:-}" in
|
||||
*UTF-8*|*utf8*|*UTF8*) _unicode=1 ;;
|
||||
esac
|
||||
if (( _color )); then
|
||||
_B=$'\033[1m'; _D=$'\033[2m'; _R=$'\033[0m'
|
||||
_C=$'\033[1;36m'; _G=$'\033[32m'; _Y=$'\033[33m'; _E=$'\033[31m'
|
||||
else
|
||||
_B=""; _D=""; _R=""; _C=""; _G=""; _Y=""; _E=""
|
||||
fi
|
||||
if (( _unicode )); then
|
||||
_S_STEP="▸"; _S_OK="✓"; _S_WARN="⚠"; _S_FAIL="✗"; _S_INFO="·"
|
||||
_B_TL="╭"; _B_TR="╮"; _B_BL="╰"; _B_BR="╯"; _B_H="─"; _B_V="│"
|
||||
else
|
||||
_S_STEP="-->"; _S_OK="[ok]"; _S_WARN="[!]"; _S_FAIL="[x]"; _S_INFO="*"
|
||||
_B_TL="+"; _B_TR="+"; _B_BL="+"; _B_BR="+"; _B_H="-"; _B_V="|"
|
||||
fi
|
||||
_start_epoch="$(date +%s)"; _step_epoch=""; _quiet_log=""; _spin_pid=""
|
||||
_fmt_dur() {
|
||||
local s=$1
|
||||
if (( s < 60 )); then printf '%ds' "$s"
|
||||
elif (( s < 3600 )); then printf '%dm %ds' "$((s/60))" "$((s%60))"
|
||||
else printf '%dh %dm' "$((s/3600))" "$(((s%3600)/60))"
|
||||
fi
|
||||
}
|
||||
banner() {
|
||||
local inner=" $1 " w line="" i=0
|
||||
w=${#inner}
|
||||
while (( i < w )); do line+="${_B_H}"; i=$((i+1)); done
|
||||
printf '\n %s%s%s%s%s\n' "${_C}" "${_B_TL}" "${line}" "${_B_TR}" "${_R}" >&2
|
||||
printf ' %s%s%s%s%s%s%s%s%s\n' "${_C}" "${_B_V}" "${_R}" "${_B}" "${inner}" "${_R}" "${_C}" "${_B_V}" "${_R}" >&2
|
||||
printf ' %s%s%s%s%s\n\n' "${_C}" "${_B_BL}" "${line}" "${_B_BR}" "${_R}" >&2
|
||||
}
|
||||
step() {
|
||||
_step_epoch="$(date +%s)"
|
||||
printf '%s%s%s %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "$1" "${_R}" >&2
|
||||
}
|
||||
ok() {
|
||||
local tail=""
|
||||
if [[ -n "${_step_epoch}" ]]; then
|
||||
tail=" ${_D}($(_fmt_dur $(( $(date +%s) - _step_epoch ))))${_R}"
|
||||
fi
|
||||
printf ' %s%s%s %s%s\n' "${_G}" "${_S_OK}" "${_R}" "$1" "${tail}" >&2
|
||||
_step_epoch=""
|
||||
}
|
||||
info() { printf ' %s%s %s%s\n' "${_D}" "${_S_INFO}" "$1" "${_R}" >&2; }
|
||||
warn() { printf ' %s%s %s%s\n' "${_Y}" "${_S_WARN}" "$1" "${_R}" >&2; }
|
||||
die() {
|
||||
printf '\n%s%s %s%s\n' "${_E}" "${_S_FAIL}" "$1" "${_R}" >&2
|
||||
if [[ -n "${_quiet_log:-}" && -s "${_quiet_log}" ]]; then
|
||||
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
|
||||
tail -n 40 "${_quiet_log}" | sed 's/^/ /' >&2
|
||||
printf ' %sfull log: %s%s\n' "${_D}" "${_quiet_log}" "${_R}" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
_SPIN=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
_start_spin() {
|
||||
(( _color && _unicode )) || return 0
|
||||
local label="$1"
|
||||
(
|
||||
local i=0
|
||||
while :; do
|
||||
printf '\r %s%s%s %s ' "${_C}" "${_SPIN[i]}" "${_R}" "${label}" >&2
|
||||
i=$(( (i+1) % ${#_SPIN[@]} ))
|
||||
sleep 0.1
|
||||
done
|
||||
) &
|
||||
_spin_pid=$!
|
||||
}
|
||||
_stop_spin() {
|
||||
[[ -n "${_spin_pid}" ]] || return 0
|
||||
kill "${_spin_pid}" 2>/dev/null || true
|
||||
wait "${_spin_pid}" 2>/dev/null || true
|
||||
_spin_pid=""
|
||||
printf '\r\033[2K' >&2
|
||||
}
|
||||
# run_quiet "<label>" -- <cmd ...>
|
||||
run_quiet() {
|
||||
local label="$1"; shift
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
local log start rc=0
|
||||
log="$(mktemp -t vetting-install.XXXXXX)"
|
||||
_quiet_log="${log}"
|
||||
start="$(date +%s)"
|
||||
_start_spin "${label}"
|
||||
"$@" >"${log}" 2>&1 || rc=$?
|
||||
_stop_spin
|
||||
local dt=$(( $(date +%s) - start ))
|
||||
if (( rc == 0 )); then
|
||||
printf ' %s%s%s %s %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
rm -f "${log}"; _quiet_log=""
|
||||
return 0
|
||||
fi
|
||||
printf ' %s%s %s failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
|
||||
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
|
||||
tail -n 40 "${log}" | sed 's/^/ /' >&2
|
||||
printf ' %sfull log: %s%s\n' "${_D}" "${log}" "${_R}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
# curl_pretty --label LABEL --url URL -- [extra curl args, including -o PATH]
|
||||
curl_pretty() {
|
||||
local label="download" url=""
|
||||
local args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--label) label="$2"; shift 2 ;;
|
||||
--url) url="$2"; args+=("$2"); shift 2 ;;
|
||||
--) shift; args+=("$@"); break ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
local size_str=""
|
||||
if [[ -n "${url}" ]]; then
|
||||
local size_bytes
|
||||
size_bytes="$(curl -fsSLI --max-time 5 "${url}" 2>/dev/null \
|
||||
| awk 'tolower($1) ~ /^content-length:/ {print $2}' \
|
||||
| tr -d '\r' | tail -n1)"
|
||||
if [[ "${size_bytes}" =~ ^[0-9]+$ ]]; then
|
||||
local human
|
||||
human="$(numfmt --to=iec --suffix=B --format='%.1f' "${size_bytes}" 2>/dev/null || echo "${size_bytes}B")"
|
||||
size_str=" ${_D}(${human})${_R}"
|
||||
fi
|
||||
fi
|
||||
printf '%s%s%s %sdownloading %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "${label}" "${_R}" "${size_str}" >&2
|
||||
local start rc=0
|
||||
start="$(date +%s)"
|
||||
if [[ -t 2 ]]; then
|
||||
curl -fL --progress-bar "${args[@]}" || rc=$?
|
||||
else
|
||||
curl -fsSL "${args[@]}" || rc=$?
|
||||
fi
|
||||
local dt=$(( $(date +%s) - start ))
|
||||
if (( rc == 0 )); then
|
||||
printf ' %s%s%s %s ready %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
return 0
|
||||
fi
|
||||
printf ' %s%s %s download failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
rule_open() {
|
||||
local title="$1"
|
||||
local dashes=$(( 46 - ${#title} - 4 ))
|
||||
local tail="" i=0
|
||||
(( dashes < 2 )) && dashes=2
|
||||
while (( i < dashes )); do tail+="${_B_H}"; i=$((i+1)); done
|
||||
printf '\n%s%s%s %s %s%s\n' "${_C}" "${_B_TL}" "${_B_H}" "${title}" "${tail}" "${_R}" >&2
|
||||
}
|
||||
rule_close() {
|
||||
local line="" i=0
|
||||
while (( i < 46 )); do line+="${_B_H}"; i=$((i+1)); done
|
||||
printf '%s%s%s%s\n' "${_C}" "${_B_BL}" "${line:1}" "${_R}" >&2
|
||||
}
|
||||
total_elapsed() {
|
||||
local dt=$(( $(date +%s) - _start_epoch ))
|
||||
printf '%sinstalled in %s%s\n\n' "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
|
||||
}
|
||||
# ---- end style helpers -------------------------------------------------
|
||||
|
||||
INTERFACE=""
|
||||
SUBNET=""
|
||||
ORCH_URL=""
|
||||
@@ -63,37 +229,33 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "pxe-setup.sh must be run as root (try: sudo $0 ...)" >&2
|
||||
exit 1
|
||||
die "pxe-setup.sh must be run as root (try: sudo $0 ...)"
|
||||
fi
|
||||
|
||||
[[ -z "${INTERFACE}" ]] && { echo "ERROR: --interface is required" >&2; exit 2; }
|
||||
[[ -z "${SUBNET}" ]] && { echo "ERROR: --subnet is required (e.g. 192.168.1.0/24)" >&2; exit 2; }
|
||||
[[ -z "${ORCH_URL}" ]] && { echo "ERROR: --orchestrator-url is required" >&2; exit 2; }
|
||||
[[ -z "${INTERFACE}" ]] && die "--interface is required"
|
||||
[[ -z "${SUBNET}" ]] && die "--subnet is required (e.g. 192.168.1.0/24)"
|
||||
[[ -z "${ORCH_URL}" ]] && die "--orchestrator-url is required"
|
||||
|
||||
banner "TheWrightServer · Vetting · PXE"
|
||||
|
||||
# --- sanity checks -----------------------------------------------------
|
||||
|
||||
if ! ip link show "${INTERFACE}" >/dev/null 2>&1; then
|
||||
echo "ERROR: interface ${INTERFACE} not found on host. Check \`ip link\` — the" >&2
|
||||
echo " interface must exist *before* the orchestrator starts dnsmasq." >&2
|
||||
exit 1
|
||||
die "interface ${INTERFACE} not found on host. Check \`ip link\` — the interface must exist *before* the orchestrator starts dnsmasq."
|
||||
fi
|
||||
|
||||
# CIDR shape check — dnsmasq will re-validate, but catch the obvious
|
||||
# errors before we write anything to disk.
|
||||
if [[ ! "${SUBNET}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$ ]]; then
|
||||
echo "ERROR: --subnet must be CIDR form (e.g. 192.168.1.0/24), got '${SUBNET}'" >&2
|
||||
exit 2
|
||||
die "--subnet must be CIDR form (e.g. 192.168.1.0/24), got '${SUBNET}'"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CONFIG}" ]]; then
|
||||
echo "ERROR: ${CONFIG} not found — run deploy/install.sh first." >&2
|
||||
exit 1
|
||||
die "${CONFIG} not found — run deploy/install.sh first."
|
||||
fi
|
||||
|
||||
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
echo "ERROR: ${SERVICE_USER} user not found — run deploy/install.sh first." >&2
|
||||
exit 1
|
||||
die "${SERVICE_USER} user not found — run deploy/install.sh first."
|
||||
fi
|
||||
|
||||
# Resolve the bundle dir. When pxe-setup.sh is run from a release
|
||||
@@ -101,16 +263,11 @@ fi
|
||||
# run from the repo tree it's deploy/pxe-setup.sh and the live image is
|
||||
# under live-image/build/. Detect both.
|
||||
if [[ -z "${BUNDLE_DIR}" ]]; then
|
||||
if [[ -f "${SCRIPT_DIR}/ipxe-shas.txt" ]]; then
|
||||
BUNDLE_DIR="${SCRIPT_DIR}"
|
||||
else
|
||||
BUNDLE_DIR="${SCRIPT_DIR}"
|
||||
fi
|
||||
fi
|
||||
SHAS_FILE="${BUNDLE_DIR}/ipxe-shas.txt"
|
||||
if [[ ! -f "${SHAS_FILE}" ]]; then
|
||||
echo "ERROR: ${SHAS_FILE} not found — bundle is incomplete." >&2
|
||||
exit 1
|
||||
die "${SHAS_FILE} not found — bundle is incomplete."
|
||||
fi
|
||||
|
||||
# --- iPXE binaries: stage, verify, install ----------------------------
|
||||
@@ -120,8 +277,9 @@ fi
|
||||
# install(1) unlink-replaces, which avoids ETXTBSY and makes the whole
|
||||
# operation atomic per file.
|
||||
|
||||
echo "==> ensuring ${TFTP_ROOT} exists"
|
||||
step "ensuring ${TFTP_ROOT} exists"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${TFTP_ROOT}"
|
||||
info "tftp root: ${TFTP_ROOT}"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "${STAGE}"' EXIT
|
||||
@@ -138,30 +296,28 @@ done
|
||||
# we skip the fetch entirely; if not, re-download.
|
||||
if (( ! need_fetch )); then
|
||||
if ! ( cd "${TFTP_ROOT}" && sha256sum -c --status "${SHAS_FILE}" ); then
|
||||
echo "==> ${TFTP_ROOT} iPXE binaries don't match pinned SHAs — re-fetching"
|
||||
info "iPXE binaries in ${TFTP_ROOT} don't match pinned SHAs — re-fetching"
|
||||
need_fetch=1
|
||||
else
|
||||
echo "==> iPXE binaries already match pins — skipping fetch"
|
||||
info "iPXE binaries already match pins — skipping fetch"
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( need_fetch )); then
|
||||
echo "==> fetching iPXE binaries from boot.ipxe.org"
|
||||
curl -fsSLo "${STAGE}/ipxe.efi" "https://boot.ipxe.org/x86_64-efi/ipxe.efi"
|
||||
curl -fsSLo "${STAGE}/undionly.kpxe" "https://boot.ipxe.org/undionly.kpxe"
|
||||
step "fetching iPXE binaries from boot.ipxe.org"
|
||||
curl_pretty --label "ipxe.efi" --url "https://boot.ipxe.org/x86_64-efi/ipxe.efi" -- -o "${STAGE}/ipxe.efi"
|
||||
curl_pretty --label "undionly.kpxe" --url "https://boot.ipxe.org/undionly.kpxe" -- -o "${STAGE}/undionly.kpxe"
|
||||
|
||||
echo "==> verifying SHA256 against ${SHAS_FILE}"
|
||||
if ! ( cd "${STAGE}" && sha256sum -c "${SHAS_FILE}" ); then
|
||||
echo "ERROR: iPXE SHA256 mismatch. Upstream binaries changed, or a MITM." >&2
|
||||
echo " To accept the new binaries, regenerate ${SHAS_FILE} after" >&2
|
||||
echo " independently verifying the new checksums, then re-run." >&2
|
||||
exit 1
|
||||
step "verifying SHA256 against ${SHAS_FILE}"
|
||||
if ! run_quiet "sha256sum -c" -- bash -c "cd '${STAGE}' && sha256sum -c '${SHAS_FILE}'"; then
|
||||
die "iPXE SHA256 mismatch. Upstream binaries changed, or a MITM. To accept the new binaries, regenerate ${SHAS_FILE} after independently verifying the new checksums, then re-run."
|
||||
fi
|
||||
|
||||
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
|
||||
"${STAGE}/ipxe.efi" "${TFTP_ROOT}/ipxe.efi"
|
||||
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
|
||||
"${STAGE}/undionly.kpxe" "${TFTP_ROOT}/undionly.kpxe"
|
||||
ok "iPXE binaries staged into ${TFTP_ROOT}"
|
||||
fi
|
||||
|
||||
# --- live image: copy from bundle into live_dir -----------------------
|
||||
@@ -183,20 +339,19 @@ if [[ -z "${LIVE_SRC}" ]]; then
|
||||
# the one-liner install, so a missing bundle/live-image/ is expected
|
||||
# when pxe-setup.sh is run from /usr/local/sbin.
|
||||
if [[ -f "${LIVE_DIR}/vmlinuz" && -f "${LIVE_DIR}/initrd.img" ]]; then
|
||||
echo "==> live image already staged in ${LIVE_DIR} (from install.sh)"
|
||||
info "live image already staged in ${LIVE_DIR} (from install.sh)"
|
||||
else
|
||||
echo "WARN: no live image found under ${BUNDLE_DIR}/live-image," >&2
|
||||
echo " ${BUNDLE_DIR}/../live-image/build, or ${LIVE_DIR}." >&2
|
||||
echo " The orchestrator will fail PXE startup validation until" >&2
|
||||
echo " vmlinuz + initrd.img land in ${LIVE_DIR}." >&2
|
||||
warn "no live image found under ${BUNDLE_DIR}/live-image, ${BUNDLE_DIR}/../live-image/build, or ${LIVE_DIR}."
|
||||
warn "the orchestrator will fail PXE startup validation until vmlinuz + initrd.img land in ${LIVE_DIR}."
|
||||
fi
|
||||
else
|
||||
echo "==> staging live image from ${LIVE_SRC} into ${LIVE_DIR}"
|
||||
step "staging live image from ${LIVE_SRC}"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
|
||||
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
|
||||
"${LIVE_SRC}/vmlinuz" "${LIVE_DIR}/vmlinuz"
|
||||
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
|
||||
"${LIVE_SRC}/initrd.img" "${LIVE_DIR}/initrd.img"
|
||||
ok "live image staged into ${LIVE_DIR}"
|
||||
fi
|
||||
|
||||
# --- patch the pxe: block in vetting.yaml -----------------------------
|
||||
@@ -243,16 +398,13 @@ existing_iface="$(extract_yaml_value interface "${CONFIG}")"
|
||||
existing_subnet="$(extract_yaml_value subnet "${CONFIG}")"
|
||||
|
||||
if [[ -n "${existing_iface}" && "${existing_iface}" != "${INTERFACE}" && ${FORCE} -eq 0 ]]; then
|
||||
echo "ERROR: pxe.interface in ${CONFIG} is already set to ${existing_iface}, which" >&2
|
||||
echo " differs from --interface ${INTERFACE}. Pass --force to overwrite." >&2
|
||||
exit 1
|
||||
die "pxe.interface in ${CONFIG} is already set to ${existing_iface}, which differs from --interface ${INTERFACE}. Pass --force to overwrite."
|
||||
fi
|
||||
if [[ -n "${existing_subnet}" && "${existing_subnet}" != "${SUBNET}" && ${FORCE} -eq 0 ]]; then
|
||||
echo "ERROR: pxe.subnet in ${CONFIG} is already ${existing_subnet}, which" >&2
|
||||
echo " differs from --subnet ${SUBNET}. Pass --force to overwrite." >&2
|
||||
exit 1
|
||||
die "pxe.subnet in ${CONFIG} is already ${existing_subnet}, which differs from --subnet ${SUBNET}. Pass --force to overwrite."
|
||||
fi
|
||||
|
||||
step "patching pxe: block in ${CONFIG}"
|
||||
new_block=$(cat <<EOF
|
||||
pxe:
|
||||
enabled: true
|
||||
@@ -286,11 +438,17 @@ orig_owner="$(stat -c '%U:%G' "${CONFIG}")"
|
||||
install -m "${orig_mode}" -o "${orig_owner%:*}" -g "${orig_owner#*:}" \
|
||||
"${tmp_yaml}" "${CONFIG}"
|
||||
rm -f "${tmp_yaml}"
|
||||
ok "pxe: block written"
|
||||
|
||||
echo
|
||||
echo "==> rendered pxe: block in ${CONFIG}:"
|
||||
echo "${new_block}" | sed 's/^/ /'
|
||||
echo
|
||||
echo "Next: systemctl restart vetting && journalctl -fu vetting"
|
||||
echo "The orchestrator will refuse to start with clear errors if anything"
|
||||
echo "is still missing; you should see dnsmasq come up cleanly."
|
||||
rule_open "pxe configured"
|
||||
while IFS= read -r ln; do
|
||||
info "${ln}"
|
||||
done <<<"${new_block}"
|
||||
printf '\n' >&2
|
||||
info "next:"
|
||||
info " systemctl restart vetting"
|
||||
info " journalctl -fu vetting"
|
||||
info "the orchestrator refuses to start with clear errors if anything is still missing;"
|
||||
info "you should see dnsmasq come up cleanly."
|
||||
rule_close
|
||||
total_elapsed
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
# API reference
|
||||
|
||||
Complete HTTP API for the vetting orchestrator. Routes are assembled
|
||||
in `internal/httpserver/router.go`; handler logic lives in
|
||||
`internal/api/agent_handlers.go` (agent-facing) and
|
||||
`internal/api/ui_handlers.go` (browser + host-mode).
|
||||
|
||||
---
|
||||
|
||||
## Agent API
|
||||
|
||||
These endpoints are called by the in-image vetting agent during a
|
||||
run. Every request must carry a `Authorization: Bearer <token>`
|
||||
header. The token is issued per-run in the iPXE kernel cmdline and
|
||||
verified against a bcrypt hash stored in `runs.agent_token_hash`.
|
||||
|
||||
### `GET /ipxe/{mac}`
|
||||
|
||||
iPXE chainload script. Called by iPXE itself after dnsmasq hands it
|
||||
the chainload URL. No auth required — the MAC path parameter is the
|
||||
key.
|
||||
|
||||
**Responses:**
|
||||
|
||||
| Scenario | Script |
|
||||
|----------|--------|
|
||||
| Known MAC with an active run | Boot script: kernel + initrd + cmdline (run_id, mac, token, orchestrator_url, tls_fpr). Triggers `PXEObserved` transition. |
|
||||
| Known MAC, no active run | Poweroff script. |
|
||||
| Unknown MAC | Halt/error script. |
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/runs/{id}/hello`
|
||||
|
||||
First call the agent makes once userspace is up. Idempotent. Writes a
|
||||
log line; the authoritative transition comes from `/claim`.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "run_id": 42 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/runs/{id}/claim`
|
||||
|
||||
Binding call: the agent proves it holds the plaintext token for this
|
||||
run. In return the orchestrator seeds stage rows, transitions to
|
||||
`InventoryCheck`, and returns the stage list + per-profile config.
|
||||
Subsequent claims are idempotent (safe after transient network
|
||||
failures).
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_ip": "192.168.1.42" // optional; falls back to RemoteAddr
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"run_id": 42,
|
||||
"stages": ["Inventory", "Firmware", "SpecValidate", "SMART", "CPUStress",
|
||||
"Storage", "Network", "Burn", "GPU", "PSU", "Reporting"],
|
||||
"expected_disks": [
|
||||
{ "serial": "WD-ABC123", "size_gb": 500 }
|
||||
],
|
||||
"iperf_port": 5201,
|
||||
"non_destructive": false,
|
||||
"current_state": "InventoryCheck",
|
||||
"stage_config": {
|
||||
"profile": "quick",
|
||||
"stage_timeouts": { "CPUStress": "5m0s", "Storage": "5m0s" },
|
||||
"cpustress": { "cpu_pass": "2m", "mem_pass": "2m", "edac_poll": "10s" },
|
||||
"storage": { "mode": "fio_sample", "fio_size": "1GiB", "fio_time": "3m",
|
||||
"fio_bs": "4k", "fio_rw": "randrw", "verify": "md5" },
|
||||
"network": { "duration": "60s" },
|
||||
"burn": { "duration": "2m", "cpu_workers": "all", "mem_pct": 50,
|
||||
"fio_on_spare": true, "iperf_parallel": 2 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`stage_config` shape:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `profile` | string | `quick`, `deep`, or `soak`. |
|
||||
| `stage_timeouts` | map[string]string | Per-stage timeout durations (Go duration strings). |
|
||||
| `cpustress.cpu_pass` | string | stress-ng CPU pass duration. |
|
||||
| `cpustress.mem_pass` | string | stress-ng memory pass duration. |
|
||||
| `cpustress.edac_poll` | string | EDAC error counter polling interval. |
|
||||
| `storage.mode` | string | `fio_sample` (skip badblocks) or `full_disk`. |
|
||||
| `storage.fio_size` | string | fio test file size (fio_sample mode only). |
|
||||
| `storage.fio_time` | string | fio runtime. |
|
||||
| `storage.fio_bs` | string | fio block size. |
|
||||
| `storage.fio_rw` | string | fio I/O pattern. |
|
||||
| `storage.verify` | string | fio integrity mode (`md5` or empty). |
|
||||
| `network.duration` | string | iperf3 test duration. |
|
||||
| `burn.duration` | string | Total burn-in window. |
|
||||
| `burn.cpu_workers` | string | `all` or a numeric string. |
|
||||
| `burn.mem_pct` | int | Percentage of MemAvailable to stress. |
|
||||
| `burn.fio_on_spare` | bool | Run fio inside Burn. |
|
||||
| `burn.iperf_parallel` | int | iperf3 parallel stream count. |
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/runs/{id}/heartbeat`
|
||||
|
||||
Periodic liveness ping. The response body acts as a control channel.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "CPUStress",
|
||||
"cmd": "continue"
|
||||
}
|
||||
```
|
||||
|
||||
**`cmd` values:**
|
||||
|
||||
| cmd | When | Agent action |
|
||||
|-----|------|--------------|
|
||||
| `continue` | Normal case (including FailedHolding) | No-op; keep running current stage or wait for override. |
|
||||
| `reboot` | Run reached `Completed` | `systemctl reboot` (falls through iPXE to local disk). |
|
||||
| `abort` | Run in `Released` | Stop heartbeat loop. |
|
||||
| `retry_stage` | Operator pressed "Override wipe & retry" | Re-enter the named stage with override flags. Response includes `stage` and `override_flags`. |
|
||||
| `cancel_stage` | Operator clicked Cancel mid-stage | Kill running stage subprocess, then power off. |
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/runs/{id}/log`
|
||||
|
||||
Batch of log lines from the agent. Written to per-run flat file and
|
||||
fanned out to SSE subscribers.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"lines": [
|
||||
{
|
||||
"ts": "2026-04-21T15:32:18.123Z",
|
||||
"level": "info",
|
||||
"stage": "SMART",
|
||||
"text": "smartctl -a /dev/sda: PASSED"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `ts` | string | no | RFC 3339 timestamp. Server clock used if empty. |
|
||||
| `level` | string | no | `info`, `warn`, `error`, `debug`. |
|
||||
| `stage` | string | no | Stage tag for per-stage SSE fan-out. |
|
||||
| `text` | string | yes | Log message. |
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "written": 1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/runs/{id}/sensor`
|
||||
|
||||
Batch of numeric samples (thermals, fan RPM, PSU rails, iperf
|
||||
throughput, fio IOPS). Each sample is evaluated against the run's
|
||||
seeded thresholds — critical breaches fail the run immediately.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"samples": [
|
||||
{
|
||||
"ts": "2026-04-21T15:32:18Z",
|
||||
"kind": "temp",
|
||||
"key": "cpu/0",
|
||||
"value": 72.5,
|
||||
"unit": "C"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `ts` | string | no | RFC 3339 timestamp. Defaults to server-now. |
|
||||
| `kind` | string | yes | `temp`, `fan`, `psu_volt`, `iperf`, `fio`, `fio_p99_us`, `smart_attr`, `nic_retrans`, `edac_ue`, `edac_ce`, `mce`. |
|
||||
| `key` | string | yes | Identifies the source (e.g. `cpu/0`, `+12V`, `throughput_mbps`). |
|
||||
| `value` | float | yes | Numeric sample value. |
|
||||
| `unit` | string | no | Display unit (e.g. `C`, `V`, `Mbps`). |
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"written": 1,
|
||||
"breach": false,
|
||||
"breach_kind": ""
|
||||
}
|
||||
```
|
||||
|
||||
When a critical breach is detected, `breach` is `true` and
|
||||
`breach_kind` contains a human-readable label like
|
||||
`"temp cpu/0=92.5 breached lt 92"`. The run transitions to
|
||||
`FailedHolding`.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/runs/{id}/result`
|
||||
|
||||
Stage outcome. Drives the state machine forward (pass) or into
|
||||
`FailedHolding` (fail).
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"stage": "SMART",
|
||||
"passed": true,
|
||||
"summary": { "disks_checked": 2, "reallocated": 0 },
|
||||
"message": "",
|
||||
"inventory": null,
|
||||
"firmware": [],
|
||||
"sub_steps": []
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `stage` | string | yes | Stage name (must match `DefaultStageOrder`). |
|
||||
| `passed` | bool | yes | `true` = advance; `false` = fail. |
|
||||
| `summary` | object | no | Arbitrary JSON persisted in `stages.summary_json`. |
|
||||
| `message` | string | no | Human-readable detail (shown in notifications on failure). |
|
||||
| `inventory` | object | no | Only set for `stage=Inventory`. Full `spec.Inventory` JSON. |
|
||||
| `firmware` | array | no | Only set for `stage=Firmware`. Array of firmware snapshots. |
|
||||
| `sub_steps` | array | no | Per-disk/per-NIC/per-GPU granular results. |
|
||||
|
||||
**`firmware[]` shape:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `component` | string | `bios`, `bmc`, `nic`, `hba`, `microcode`, `nvme_fw`. |
|
||||
| `identifier` | string | Slot, serial, or device path that distinguishes this component. |
|
||||
| `version` | string | Firmware version string. |
|
||||
| `vendor` | string | Vendor name (optional). |
|
||||
| `raw` | map | Additional key-value metadata (optional). |
|
||||
|
||||
**`sub_steps[]` shape:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | string | Human-readable label (e.g. `sda SMART`, `eth0 iperf`). |
|
||||
| `passed` | bool | Sub-step result. |
|
||||
| `skipped` | bool | `true` if the sub-step was skipped (e.g. no GPU). |
|
||||
| `started_at` | string | RFC 3339 timestamp. |
|
||||
| `completed_at` | string | RFC 3339 timestamp. |
|
||||
| `summary` | object | Arbitrary JSON persisted in `sub_steps.summary_json`. |
|
||||
|
||||
**Response (200, pass):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "next_state": "CPUStress" }
|
||||
```
|
||||
|
||||
**Response (200, fail):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "next_state": "FailedHolding" }
|
||||
```
|
||||
|
||||
**Response (409, stage mismatch):**
|
||||
|
||||
Returned when the agent reports a stage that doesn't match the
|
||||
orchestrator's expected state. The run is parked in `FailedHolding`.
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": "stage mismatch: got SMART, expected CPUStress" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/runs/{id}/hold`
|
||||
|
||||
Request the per-run SSH key so the operator can SSH into a held host.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_ip": "192.168.1.42"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"authorized_key": "ssh-ed25519 AAAAC3... vetting-run-42",
|
||||
"run_id": 42
|
||||
}
|
||||
```
|
||||
|
||||
The private key is written to
|
||||
`artifacts/run-<N>/hold.key` on the orchestrator. The agent installs
|
||||
the `authorized_key` into `/root/.ssh/authorized_keys` in the live
|
||||
image.
|
||||
|
||||
---
|
||||
|
||||
## Host API
|
||||
|
||||
LAN-trusted endpoints called by the host-mode agent. No bearer token.
|
||||
Same threat model as the browser UI.
|
||||
|
||||
### `POST /api/v1/hosts`
|
||||
|
||||
JSON host registration. Called by the quick-register one-liner.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "node-01",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"wol_broadcast_ip": "192.168.1.255",
|
||||
"wol_port": 9,
|
||||
"expected_spec_yaml": "memory:\n total_gib: 64\ncpu:\n logical_cores: 16\n",
|
||||
"notes": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
|
||||
```json
|
||||
{ "ok": true, "id": 5 }
|
||||
```
|
||||
|
||||
### `POST /api/v1/hosts/{mac}/heartbeat`
|
||||
|
||||
Host-mode agent liveness ping. Stamps `hosts.last_seen_at` and
|
||||
triggers a dashboard tile refresh via SSE.
|
||||
|
||||
**Request body:** empty.
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
When a run is queued for this host:
|
||||
|
||||
```json
|
||||
{ "ok": true, "cmd": "reboot_for_vetting", "run_id": 42 }
|
||||
```
|
||||
|
||||
The agent reboots the host on receiving `cmd=reboot_for_vetting`.
|
||||
The `run_id` is informational (for agent logging).
|
||||
|
||||
---
|
||||
|
||||
## Browser UI routes
|
||||
|
||||
No auth. Bind to loopback or LAN only, or front with a reverse proxy.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | Dashboard — host tile grid. |
|
||||
| GET | `/hosts/new` | New host registration form. |
|
||||
| POST | `/hosts` | Create host (form submission). |
|
||||
| GET | `/hosts/{id}` | Host detail page (summary, actions, run history). |
|
||||
| POST | `/hosts/{id}/delete` | Delete host. |
|
||||
| POST | `/hosts/{id}/start` | Start a vetting run (queue it). |
|
||||
| POST | `/hosts/{id}/cancel` | Cancel the active run. |
|
||||
| POST | `/hosts/{id}/override-wipe` | Override the wipe-probe guard and retry Storage. |
|
||||
| GET | `/runs/{runID}` | Run detail page (stages, spec diffs, pipeline). |
|
||||
| GET | `/reports/{runID}` | HTML report artifact. |
|
||||
| GET | `/register/quick.sh` | Quick-register bash one-liner script. |
|
||||
| GET | `/events` | SSE event stream (browser subscriptions). |
|
||||
|
||||
**Static assets:**
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/static/*` | Embedded CSS + JS (`internal/web/static/`). |
|
||||
| `/live/*` | Live image files (vmlinuz + initrd.img) served from `pxe.live_dir`. |
|
||||
| `/assets/*` | Agent binary served from `agent.asset_dir`. |
|
||||
|
||||
---
|
||||
|
||||
## SSE events
|
||||
|
||||
The browser connects to `GET /events` and receives server-sent events.
|
||||
Each event has a `name` (the SSE `event:` field) and a `data` payload
|
||||
containing a pre-rendered HTML fragment with `hx-swap-oob` attributes
|
||||
that HTMX uses to swap the target DOM element.
|
||||
|
||||
### Connection events
|
||||
|
||||
| Event name | Payload | Description |
|
||||
|------------|---------|-------------|
|
||||
| `hello` | `ok` | Sent immediately on connection. |
|
||||
| `heartbeat` | `<span data-heartbeat="<unix-ts>"></span>` | 15-second keep-alive. |
|
||||
|
||||
### Dashboard events
|
||||
|
||||
| Event name | Payload | Description |
|
||||
|------------|---------|-------------|
|
||||
| `tile-{hostID}` | Host tile HTML fragment | Refreshed on state transitions, heartbeats, holds. |
|
||||
|
||||
### Host detail page events
|
||||
|
||||
| Event name | Payload | Description |
|
||||
|------------|---------|-------------|
|
||||
| `detail-summary-{hostID}` | Summary section HTML | Host metadata + latest run status. |
|
||||
| `detail-actions-{hostID}` | Actions row HTML | Start/Cancel/Override buttons. |
|
||||
| `detail-inflight-{hostID}` | In-flight banner HTML | Active run progress indicator. |
|
||||
| `runrow-{runID}` | Run history row HTML | Updated when a run completes or fails. |
|
||||
|
||||
### Run detail page events
|
||||
|
||||
| Event name | Payload | Description |
|
||||
|------------|---------|-------------|
|
||||
| `run-header-{runID}` | Run metadata HTML | State, profile, timing. |
|
||||
| `detail-hold-{runID}` | Hold banner HTML | SSH command + hold IP. |
|
||||
| `detail-specdiffs-{runID}` | Spec diffs list HTML | Expected-vs-actual divergences. |
|
||||
| `pipeline-{runID}` | Pipeline dot visualization HTML | Stage progress dots. |
|
||||
| `substep-{runID}-{stage}-{ordinal}` | Sub-step row HTML | Per-disk, per-NIC, per-GPU detail. |
|
||||
|
||||
### Log events
|
||||
|
||||
| Event name | Payload | Description |
|
||||
|------------|---------|-------------|
|
||||
| `log-{runID}` | Log line HTML | All log lines for a run. |
|
||||
| `log-{runID}-{stage}` | Log line HTML | Stage-filtered log lines. |
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### Agent bearer token lifecycle
|
||||
|
||||
1. **Issuance** — when a registered host's iPXE script is fetched
|
||||
(`GET /ipxe/{mac}`), the orchestrator generates a random token,
|
||||
hashes it with SHA-256, and stores the hash in
|
||||
`runs.agent_token_hash`. The plaintext token is embedded in the
|
||||
iPXE kernel cmdline as `token=<plaintext>`.
|
||||
|
||||
2. **Rotation** — each iPXE fetch rotates the token. Only the most
|
||||
recent PXE boot can claim the run.
|
||||
|
||||
3. **Verification** — every `/api/v1/runs/{id}/*` endpoint extracts
|
||||
the `Bearer` header, SHA-256 hashes it, and compares against the
|
||||
stored hash using `crypto/subtle.ConstantTimeCompare`.
|
||||
|
||||
4. **Scope** — the token authenticates a single run. It cannot be
|
||||
used to access other runs or host-level endpoints.
|
||||
|
||||
### LAN-trust model
|
||||
|
||||
Host-mode endpoints (`POST /api/v1/hosts`, `POST /api/v1/hosts/{mac}/heartbeat`)
|
||||
and the browser UI have no authentication. They share a LAN-trust
|
||||
assumption: anything that can reach the orchestrator's bind address is
|
||||
trusted. To add a password, front the orchestrator with a reverse
|
||||
proxy (Caddy, nginx, Traefik) that adds basic-auth or OIDC. See
|
||||
[operations.md § Exposing outside the LAN](operations.md#exposing-outside-the-lan).
|
||||
+61
-6
@@ -37,10 +37,10 @@ Operator browser (HTMX + SSE, admin login)
|
||||
|---|---|
|
||||
| `cmd/vetting` | Orchestrator entrypoint. Wires config, stores, runner, dispatcher, iperf supervisor, PXE supervisor, janitor, HTTP router. |
|
||||
| `cmd/vetting-agent` | In-image agent entrypoint. Reads kernel cmdline params, starts the agent loop. |
|
||||
| `internal/config` | YAML loader + types. |
|
||||
| `internal/config` | YAML loader + types. `ProfileRegistry` holds the quick/deep/soak profile definitions, threshold defaults, and per-stage probe knobs. |
|
||||
| `internal/db` | SQLite open + embedded migrations. Pure Go via modernc.org/sqlite. |
|
||||
| `internal/model` | Plain structs: `Host`, `Run`, `Stage`, `Measurement`, `SpecDiff`, `Artifact`. |
|
||||
| `internal/store` | Repository layer; SQL is hand-written. |
|
||||
| `internal/store` | Repository layer; SQL is hand-written (no ORM). Stores for hosts, runs, stages, sub-steps, artifacts, spec diffs, measurements, thresholds, firmware. |
|
||||
| `internal/orchestrator` | State machine, dispatcher, per-run runner, WoL sender, HMAC run tokens, iperf supervisor. |
|
||||
| `internal/api` | HTTP handlers: `agent_handlers.go` (the agent-facing API) and `ui_handlers.go` (HTMX fragments + SSE). |
|
||||
| `internal/httpserver` | chi router assembly — lives here to avoid `api ↔ orchestrator` cyclic imports. |
|
||||
@@ -66,11 +66,13 @@ Per-run state is the single source of truth; the UI is a pure
|
||||
projection of DB + event stream.
|
||||
|
||||
```
|
||||
Registered → Queued → WaitingWoL → Booting → InventoryCheck
|
||||
→ SpecValidate → SMART → CPUStress → Storage → Network
|
||||
→ GPU → PSU → Reporting → Completed
|
||||
Registered → Queued → WaitingWoL / WaitingReboot → Booting
|
||||
→ InventoryCheck → Firmware → SpecValidate → SMART
|
||||
→ CPUStress → Storage → Network → Burn → GPU → PSU
|
||||
→ Reporting → Completed
|
||||
|
||||
any stage → Failed → FailedHolding → Released
|
||||
any active state → Cancelled
|
||||
```
|
||||
|
||||
Key points:
|
||||
@@ -97,7 +99,10 @@ POST /api/v1/runs/{id}/result → stage result; response says next_state
|
||||
POST /api/v1/runs/{id}/hold → on FailedHolding, receive authorized_key
|
||||
```
|
||||
|
||||
Auth on every `/api/v1/*` call: the bearer token is stored as a bcrypt
|
||||
See [api-reference.md](api-reference.md) for full request/response
|
||||
schemas and SSE event types.
|
||||
|
||||
Auth on every `/api/v1/runs/*` call: the bearer token is stored as a bcrypt
|
||||
hash in `runs.agent_token_hash` and compared in constant time. The
|
||||
plaintext is in the kernel cmdline — unforgeable by anyone not on the
|
||||
trusted bridge, because the iPXE script is issued per-MAC and the MAC
|
||||
@@ -165,6 +170,56 @@ The janitor goroutine (`internal/janitor`) runs a sweep every
|
||||
**never** deleted by the janitor — host histories and aggregate
|
||||
metrics survive cleanups.
|
||||
|
||||
## Threshold engine
|
||||
|
||||
Every `/sensor` batch is evaluated against rules seeded per-run at
|
||||
creation time from the `ProfileRegistry` + per-host overrides. Rules
|
||||
are immutable for the life of a run — a late config edit can't
|
||||
retroactively pass or fail an in-flight run.
|
||||
|
||||
Operators: `lt`, `lte`, `gt`, `gte`, `within_pct`. Key matching is
|
||||
glob-ish: `*` matches all keys, `cpu/*` matches any key starting with
|
||||
`cpu/`, exact strings for specific keys. Stage matching works the same
|
||||
way (`*` for global, exact name for stage-specific).
|
||||
|
||||
Severity drives the action:
|
||||
|
||||
- **critical** — fail the run immediately. The current stage is marked
|
||||
failed, the run enters `FailedHolding`, and a `StageFailed`
|
||||
notification fires.
|
||||
- **warning** — record the breach for the report. The stage continues.
|
||||
|
||||
Every evaluation (pass or fail) is persisted as a
|
||||
`threshold_evaluations` row so the report can render per-sample
|
||||
verdict badges. See [configuration.md § thresholds](configuration.md#vettingthresholds)
|
||||
for the config-level reference.
|
||||
|
||||
## Host-mode agent
|
||||
|
||||
The `vetting-agent host` binary runs as a systemd service on
|
||||
installed hosts. It heartbeats to `POST /api/v1/hosts/{mac}/heartbeat`
|
||||
every 30 s so the dashboard shows online/offline status.
|
||||
|
||||
The quick-register one-liner (`GET /register/quick.sh`) downloads the
|
||||
agent binary from `/assets/vetting-agent-linux-amd64`, installs it as
|
||||
a systemd service, and auto-POSTs to `POST /api/v1/hosts` to register
|
||||
the host — no manual MAC entry needed.
|
||||
|
||||
When the operator clicks **Start Vetting**, the orchestrator's
|
||||
dispatcher sets `cmd=reboot_for_vetting` on the next heartbeat
|
||||
response. The host-mode agent reboots the host, which PXE-boots into
|
||||
the live image and enters the normal vetting flow.
|
||||
|
||||
## Host API
|
||||
|
||||
These endpoints are LAN-trusted (no bearer token) and share the same
|
||||
threat model as the browser UI:
|
||||
|
||||
```
|
||||
POST /api/v1/hosts → JSON host registration (quick-register)
|
||||
POST /api/v1/hosts/{mac}/heartbeat → host-mode liveness + command channel
|
||||
```
|
||||
|
||||
## Reproducible builds
|
||||
|
||||
The orchestrator and agent are pure Go; `make orchestrator-linux`
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
# Configuration reference
|
||||
|
||||
The orchestrator reads a single YAML file at startup. Production
|
||||
installs use `/etc/vetting/vetting.yaml`; the dev default is
|
||||
`deploy/vetting.example.yaml`. Pass the path with `--config`:
|
||||
|
||||
```
|
||||
vetting --config /etc/vetting/vetting.yaml
|
||||
```
|
||||
|
||||
Every key has a compile-time default (see `internal/config/config.go`),
|
||||
so an empty file produces a working orchestrator bound to
|
||||
`127.0.0.1:8080` with PXE disabled.
|
||||
|
||||
---
|
||||
|
||||
## `server`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `bind` | string | `127.0.0.1:8080` | Address and port the HTTP server listens on. |
|
||||
| `public_url` | string | *(empty)* | External URL the orchestrator is reachable at from a browser. Used in notification click-throughs (e.g. `https://vetting.lan:8443`). |
|
||||
| `tls.enabled` | bool | `false` | Terminate TLS at the orchestrator. |
|
||||
| `tls.cert_file` | string | *(empty)* | Path to the PEM-encoded certificate. |
|
||||
| `tls.key_file` | string | *(empty)* | Path to the PEM-encoded private key. |
|
||||
|
||||
## `database`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `path` | string | `./var/vetting.db` | SQLite database file. Created on first run. |
|
||||
|
||||
## `artifacts`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `dir` | string | `./var/artifacts` | Directory for per-run files (reports, fio logs, iperf logs, hold keys). |
|
||||
| `retention_days` | int | `30` | Days to keep artifact files before the janitor prunes them. `0` = keep forever. DB rows are never pruned. |
|
||||
|
||||
## `logs`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `dir` | string | `./var/logs` | Directory for per-run append-only log files. |
|
||||
| `retention_days` | int | `30` | Days to keep log files. `0` = keep forever. |
|
||||
|
||||
## `janitor`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `interval_minutes` | int | `60` | Minutes between cleanup sweeps. `0` defaults to `60`. |
|
||||
|
||||
## `dispatcher`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `max_concurrent_runs` | int | `3` | Semaphore limiting how many vetting runs execute in parallel. |
|
||||
|
||||
## `network`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `iperf_port` | int | `5201` | Port the orchestrator-supervised `iperf3 -s` binds to. The agent connects here during the Network stage. |
|
||||
|
||||
## `pxe`
|
||||
|
||||
PXE is disabled by default. Enable it after running
|
||||
[`vetting-pxe-setup`](operations.md#pxe-enablement).
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable dnsmasq + iPXE serving. |
|
||||
| `interface` | string | *(empty)* | LAN NIC the dnsmasq proxy-DHCP binds to (e.g. `eth0`). |
|
||||
| `subnet` | string | *(empty)* | LAN CIDR (e.g. `192.168.1.0/24`). Scopes the proxy-DHCP responses. |
|
||||
| `orchestrator_url` | string | *(empty)* | URL the live-image agent uses to reach the orchestrator (e.g. `http://192.168.1.135:8080`). Baked into the iPXE kernel cmdline. |
|
||||
| `tftp_root` | string | *(empty)* | Directory containing `ipxe.efi` + `undionly.kpxe`. |
|
||||
| `live_dir` | string | *(empty)* | Directory containing `vmlinuz` + `initrd.img`. Served at `/live/*`. |
|
||||
|
||||
dnsmasq runs in **proxy-DHCP mode**: it coexists with your existing
|
||||
router's DHCP server and only supplements PXE options. See
|
||||
[operations.md](operations.md#pxe-enablement) for the full setup
|
||||
walkthrough.
|
||||
|
||||
## `agent`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `asset_dir` | string | `<database.dir>/../assets` | Directory containing `vetting-agent-linux-amd64`. Served at `/assets/*` so the quick-register one-liner can download the agent binary. Empty string disables the route. |
|
||||
|
||||
## `notifiers`
|
||||
|
||||
An array of notification targets. Each entry declares a named notifier
|
||||
with a type-specific set of fields. Delivery is fire-and-forget (one
|
||||
attempt per event, 10 s timeout, failures logged).
|
||||
|
||||
### ntfy
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
- name: ops-ntfy
|
||||
type: ntfy
|
||||
server: https://ntfy.sh
|
||||
topic: vetting-YOUR-TOPIC
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | string | Identifier referenced by `routes[].notifier`. |
|
||||
| `type` | string | `ntfy` |
|
||||
| `server` | string | ntfy server URL. |
|
||||
| `topic` | string | Topic to publish to. |
|
||||
|
||||
### Discord
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
- name: ops-discord
|
||||
type: discord
|
||||
webhook_url: https://discord.com/api/webhooks/XXX/YYY
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | string | Identifier referenced by `routes[].notifier`. |
|
||||
| `type` | string | `discord` |
|
||||
| `webhook_url` | string | Discord webhook URL. |
|
||||
|
||||
### SMTP
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
- name: ops-email
|
||||
type: smtp
|
||||
smtp:
|
||||
host: mail.lan
|
||||
port: 25
|
||||
from: vetting@lan.local
|
||||
to: [ops@lan.local]
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | string | Identifier referenced by `routes[].notifier`. |
|
||||
| `type` | string | `smtp` |
|
||||
| `smtp.host` | string | SMTP server hostname. |
|
||||
| `smtp.port` | int | SMTP server port. |
|
||||
| `smtp.from` | string | Sender address. |
|
||||
| `smtp.to` | string[] | Recipient addresses. |
|
||||
|
||||
## `routes`
|
||||
|
||||
Routes map notification events to notifiers by kind and severity.
|
||||
Each route is evaluated independently; an event can match multiple
|
||||
routes and fire on multiple notifiers.
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
- match_severity: [critical]
|
||||
notifier: ops-ntfy
|
||||
- match_severity: [critical]
|
||||
notifier: ops-discord
|
||||
- match_kind: [RunCompleted]
|
||||
notifier: ops-ntfy
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `match_kind` | string[] | Event kinds to match: `StageFailed`, `SpecMismatch`, `HoldingOpened`, `RunCompleted`. Omit to match all kinds. |
|
||||
| `match_severity` | string[] | Severities to match: `critical`, `warning`, `info`. Omit to match all severities. |
|
||||
| `notifier` | string | Name of a declared notifier to deliver to. |
|
||||
|
||||
## `vetting`
|
||||
|
||||
Shared pipeline defaults that apply to all profiles.
|
||||
|
||||
### `vetting.stages`
|
||||
|
||||
Ordered list of stage names the pipeline walks. Default:
|
||||
|
||||
```yaml
|
||||
vetting:
|
||||
stages:
|
||||
- Inventory
|
||||
- Firmware
|
||||
- SpecValidate
|
||||
- SMART
|
||||
- CPUStress
|
||||
- Storage
|
||||
- Network
|
||||
- Burn
|
||||
- GPU
|
||||
- PSU
|
||||
- Reporting
|
||||
```
|
||||
|
||||
### `vetting.thresholds`
|
||||
|
||||
Array of threshold rules evaluated against every `/sensor` batch.
|
||||
Rules apply across all profiles — a 92 C CPU limit fails both a
|
||||
2-minute quick run and a 12-hour soak.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `stage` | string | Stage selector. `*` matches any stage; exact name (e.g. `PSU`) limits to that stage. |
|
||||
| `kind` | string | Measurement kind to match: `temp`, `psu_volt`, `iperf`, `fio_p99_us`, `nic_retrans`, `edac_ue`, `edac_ce`, `mce`, `smart_attr`, `fan`. |
|
||||
| `key` | string | Key selector. Glob-ish matching: `*` matches all, `cpu/*` matches keys starting with `cpu/`, exact string for specific keys. |
|
||||
| `op` | string | Comparison operator (see table below). |
|
||||
| `value` | float | Threshold limit. |
|
||||
| `nominal` | float | Reference value, only used by `within_pct` (e.g. `12.0` for a +12 V rail). |
|
||||
| `unit` | string | Display unit (e.g. `C`, `V`, `Mbps`). Informational only. |
|
||||
| `severity` | string | `critical` = fail the run immediately. `warning` = record for the report only. |
|
||||
|
||||
**Threshold operators:**
|
||||
|
||||
| Operator | Pass condition | Typical use |
|
||||
|----------|---------------|-------------|
|
||||
| `lt` | `observed < value` | CPU temp < 92 C |
|
||||
| `lte` | `observed <= value` | EDAC UE count <= 0 |
|
||||
| `gt` | `observed > value` | — |
|
||||
| `gte` | `observed >= value` | iperf throughput >= 900 Mbps |
|
||||
| `within_pct` | `abs(observed - nominal) / nominal * 100 <= value` | +12 V rail within 5 % of 12.0 V |
|
||||
|
||||
**Default thresholds** (from `deploy/vetting.example.yaml`):
|
||||
|
||||
```yaml
|
||||
thresholds:
|
||||
- { stage: "*", kind: temp, key: "cpu/*", op: lt, value: 92, unit: C, severity: critical }
|
||||
- { stage: PSU, kind: psu_volt, key: "+12V", op: within_pct, value: 5, nominal: 12.0, severity: critical }
|
||||
- { stage: PSU, kind: psu_volt, key: "+5V", op: within_pct, value: 5, nominal: 5.0, severity: critical }
|
||||
- { stage: PSU, kind: psu_volt, key: "+3.3V", op: within_pct, value: 5, nominal: 3.3, severity: critical }
|
||||
- { stage: Storage, kind: fio_p99_us, key: "*", op: lt, value: 50000, severity: warning }
|
||||
- { stage: Network, kind: iperf, key: throughput_mbps, op: gte, value: 900, severity: critical }
|
||||
- { stage: Network, kind: nic_retrans, key: "*/rate", op: lt, value: 0.001, severity: warning }
|
||||
- { stage: CPUStress, kind: edac_ue, key: "*", op: lte, value: 0, severity: critical }
|
||||
- { stage: CPUStress, kind: mce, key: "*", op: lte, value: 0, severity: critical }
|
||||
```
|
||||
|
||||
## `profiles`
|
||||
|
||||
Three built-in profiles control per-stage durations and probe knobs.
|
||||
Every profile exercises every probe and gate — only the durations
|
||||
scale. Quick is a ~10-minute same-day sanity check; deep is the
|
||||
8-12 hour overnight soak; soak is the opt-in 36-40 hour extreme run.
|
||||
|
||||
### Profile inheritance
|
||||
|
||||
A profile can declare `inherit: <parent>` to merge the parent's
|
||||
timeouts and defaults before applying its own overrides. Child keys
|
||||
win. The default `soak` profile inherits from `deep`.
|
||||
|
||||
### `stage_timeouts`
|
||||
|
||||
Per-stage time limits. The orchestrator kills the agent's stage
|
||||
subprocess when a timeout fires.
|
||||
|
||||
| Stage | quick | deep | soak |
|
||||
|-------|-------|------|------|
|
||||
| CPUStress | 5 m | 2 h | 14 h |
|
||||
| Storage | 5 m | 4 h | 8 h |
|
||||
| Network | 2 m | 35 m | 2 h 30 m |
|
||||
| Burn | 3 m | 3 h | 20 h |
|
||||
| PSU | 1 m | 10 m | 15 m |
|
||||
|
||||
### `defaults`
|
||||
|
||||
Per-stage probe knobs shipped to the agent on `/claim`. Empty values
|
||||
mean "fall back to the agent's compile-time default".
|
||||
|
||||
#### `cpustress`
|
||||
|
||||
| Knob | Type | Description | quick | deep | soak |
|
||||
|------|------|-------------|-------|------|------|
|
||||
| `cpu_pass` | duration | `stress-ng --cpu` duration | 2 m | 60 m | 12 h |
|
||||
| `mem_pass` | duration | `stress-ng --vm` duration | 2 m | 60 m | *(inherit)* |
|
||||
| `edac_poll` | duration | EDAC error counter polling interval | 10 s | 10 s | *(inherit)* |
|
||||
|
||||
#### `storage`
|
||||
|
||||
| Knob | Type | Description | quick | deep | soak |
|
||||
|------|------|-------------|-------|------|------|
|
||||
| `mode` | string | `fio_sample` (skip badblocks) or `full_disk` (badblocks + fio) | fio_sample | full_disk | full_disk |
|
||||
| `fio_size` | string | fio test file size (only in `fio_sample` mode) | 1 GiB | *(inherit)* | *(inherit)* |
|
||||
| `fio_time` | duration | fio runtime | 3 m | 2 h | 6 h |
|
||||
| `fio_bs` | string | fio block size | 4 k | 4 k | *(inherit)* |
|
||||
| `fio_rw` | string | fio I/O pattern | randrw | randrw | *(inherit)* |
|
||||
| `verify` | string | fio integrity mode (`md5` or empty) | md5 | md5 | *(inherit)* |
|
||||
|
||||
#### `network`
|
||||
|
||||
| Knob | Type | Description | quick | deep | soak |
|
||||
|------|------|-------------|-------|------|------|
|
||||
| `duration` | duration | `iperf3` test duration | 60 s | 30 m | 2 h |
|
||||
|
||||
#### `burn`
|
||||
|
||||
| Knob | Type | Description | quick | deep | soak |
|
||||
|------|------|-------------|-------|------|------|
|
||||
| `duration` | duration | Total burn-in window (CPU + mem + disk + net simultaneously) | 2 m | 2 h | 18 h |
|
||||
| `cpu_workers` | string | `all` (= `runtime.NumCPU()`) or a numeric string | all | all | *(inherit)* |
|
||||
| `mem_pct` | int | Percentage of MemAvailable to stress | 50 | 70 | *(inherit)* |
|
||||
| `fio_on_spare` | bool | Run fio inside Burn (requires a spare partition) | true | true | *(inherit)* |
|
||||
| `iperf_parallel` | int | Parallel stream count fed to `iperf3 -P` | 2 | 4 | 8 |
|
||||
|
||||
### Example profile block
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
quick:
|
||||
stage_timeouts:
|
||||
CPUStress: 5m
|
||||
Storage: 5m
|
||||
Network: 2m
|
||||
defaults:
|
||||
cpustress: { cpu_pass: 2m, mem_pass: 2m, edac_poll: 10s }
|
||||
storage: { mode: fio_sample, fio_size: 1GiB, fio_time: 3m, fio_bs: 4k, fio_rw: randrw, verify: md5 }
|
||||
network: { duration: 60s }
|
||||
burn: { duration: 2m, cpu_workers: all, mem_pct: 50, fio_on_spare: true, iperf_parallel: 2 }
|
||||
deep:
|
||||
stage_timeouts:
|
||||
CPUStress: 2h
|
||||
Storage: 4h
|
||||
Network: 35m
|
||||
defaults:
|
||||
cpustress: { cpu_pass: 60m, mem_pass: 60m, edac_poll: 10s }
|
||||
storage: { mode: full_disk, fio_time: 2h, fio_bs: 4k, fio_rw: randrw, verify: md5 }
|
||||
network: { duration: 30m }
|
||||
burn: { duration: 2h, cpu_workers: all, mem_pct: 70, fio_on_spare: true, iperf_parallel: 4 }
|
||||
soak:
|
||||
inherit: deep
|
||||
stage_timeouts:
|
||||
CPUStress: 14h
|
||||
Storage: 8h
|
||||
Network: 2h30m
|
||||
defaults:
|
||||
cpustress: { cpu_pass: 12h }
|
||||
storage: { mode: full_disk, fio_time: 6h }
|
||||
network: { duration: 2h }
|
||||
burn: { duration: 18h, iperf_parallel: 8 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Host-mode agent config
|
||||
|
||||
The persistent host-mode agent reads a separate file at
|
||||
`/etc/vetting/host-agent.yaml`. This is installed by the
|
||||
quick-register one-liner and is distinct from the orchestrator config.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `orchestrator_url` | string | *(required)* | URL of the orchestrator (e.g. `http://192.168.1.135:8080`). |
|
||||
| `mac` | string | *(auto-detected)* | MAC address to heartbeat as. Auto-detected from the default route NIC if omitted. |
|
||||
| `interval` | duration | `30s` | Heartbeat interval. |
|
||||
@@ -0,0 +1,279 @@
|
||||
# Database schema
|
||||
|
||||
The orchestrator uses SQLite via
|
||||
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — a pure
|
||||
Go driver with no cgo dependency. The database file is created on
|
||||
first startup at the path in `database.path`
|
||||
(default `./var/vetting.db`).
|
||||
|
||||
**Pragmas set at open time:**
|
||||
|
||||
- `PRAGMA journal_mode = WAL` — write-ahead logging for concurrent
|
||||
readers.
|
||||
- `PRAGMA foreign_keys = ON` — enforced referential integrity.
|
||||
|
||||
**Migrations** are embedded via `go:embed` in `internal/db/` and
|
||||
applied in filename order at startup. A `schema_migrations` table
|
||||
tracks which migrations have run.
|
||||
|
||||
---
|
||||
|
||||
## Tables
|
||||
|
||||
### `hosts`
|
||||
|
||||
Registered hardware nodes in the vetting cluster.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `name` | TEXT | NOT NULL UNIQUE | | Human-readable host name. |
|
||||
| `mac` | TEXT | NOT NULL UNIQUE | | Lowercase colon form (e.g. `aa:bb:cc:dd:ee:ff`). |
|
||||
| `wol_broadcast_ip` | TEXT | NOT NULL | | LAN broadcast IP for Wake-on-LAN magic packets. |
|
||||
| `wol_port` | INTEGER | NOT NULL | `9` | WoL UDP port. |
|
||||
| `expected_spec_yaml` | TEXT | NOT NULL | | YAML describing expected hardware (CPU, memory, disks, firmware). |
|
||||
| `pdu_config_json` | TEXT | | | PDU power control config (future use). |
|
||||
| `ipmi_config_json` | TEXT | | | IPMI config (future use). |
|
||||
| `notes` | TEXT | NOT NULL | `''` | Operator notes. |
|
||||
| `created_at` | TIMESTAMP | NOT NULL | `CURRENT_TIMESTAMP` | |
|
||||
| `updated_at` | TIMESTAMP | NOT NULL | `CURRENT_TIMESTAMP` | |
|
||||
| `last_seen_at` | TIMESTAMP | | | Host-mode agent heartbeat timestamp. NULL = never seen. |
|
||||
|
||||
### `runs`
|
||||
|
||||
Vetting run instances. Each run belongs to one host and walks through
|
||||
the state machine.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `host_id` | INTEGER | NOT NULL FK → hosts(id) CASCADE | | |
|
||||
| `state` | TEXT | NOT NULL | | Current `RunState` (see `internal/model`). |
|
||||
| `result` | TEXT | | | `pass` or `fail` once terminal. |
|
||||
| `failed_stage` | TEXT | | | Stage name that halted the pipeline. |
|
||||
| `next_boot_target` | TEXT | | | `linux`, `memtest`, etc. (future use). |
|
||||
| `agent_token_hash` | TEXT | NOT NULL | | SHA-256 hash of the bearer token. |
|
||||
| `started_at` | TIMESTAMP | NOT NULL | `CURRENT_TIMESTAMP` | |
|
||||
| `completed_at` | TIMESTAMP | | | Set when run reaches a terminal state. |
|
||||
| `report_path` | TEXT | | | Path to `report.json` on disk. |
|
||||
| `hold_ip` | TEXT | | | Agent IP during FailedHolding (for SSH command). |
|
||||
| `override_flags_json` | TEXT | | | JSON blob (e.g. `{"wipe": true}`). |
|
||||
| `non_destructive` | INTEGER | NOT NULL | `0` | `1` = skip badblocks + wipe probe. |
|
||||
| `profile` | TEXT | NOT NULL | `'quick'` | `quick`, `deep`, or `soak`. |
|
||||
|
||||
**Indices:**
|
||||
- `idx_runs_host` on `(host_id)`
|
||||
- `idx_runs_state` on `(state)`
|
||||
|
||||
### `stages`
|
||||
|
||||
Per-stage results within a run. Seeded at `/claim` time with one row
|
||||
per stage in `DefaultStageOrder`.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `name` | TEXT | NOT NULL | | Stage name (e.g. `SMART`, `CPUStress`). |
|
||||
| `ordinal` | INTEGER | NOT NULL | | 0-based position in the pipeline. |
|
||||
| `state` | TEXT | NOT NULL | | `pending`, `running`, `passed`, `failed`, `skipped`. |
|
||||
| `started_at` | TIMESTAMP | | | Set when the stage begins. |
|
||||
| `completed_at` | TIMESTAMP | | | Set when the stage finishes. |
|
||||
| `summary_json` | TEXT | | | Arbitrary JSON from the agent's result. |
|
||||
|
||||
**Indices:**
|
||||
- `idx_stages_run_ordinal` on `(run_id, ordinal)`
|
||||
|
||||
### `sub_steps`
|
||||
|
||||
Finer-grained units within a stage (per-disk SMART, per-NIC iperf,
|
||||
CPU/memory pass, per-GPU run). Not every stage has sub-steps.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `stage_name` | TEXT | NOT NULL | | Parent stage name. |
|
||||
| `ordinal` | INTEGER | NOT NULL | | 0-based within `(run_id, stage_name)`. |
|
||||
| `name` | TEXT | NOT NULL | | Human label (e.g. `sda SMART`, `eth0 iperf`). |
|
||||
| `state` | TEXT | NOT NULL | `'pending'` | `pending`, `running`, `passed`, `failed`, `skipped`. |
|
||||
| `started_at` | TIMESTAMP | | | |
|
||||
| `completed_at` | TIMESTAMP | | | |
|
||||
| `summary_json` | TEXT | NOT NULL | `'{}'` | |
|
||||
|
||||
**Constraints:** `UNIQUE (run_id, stage_name, ordinal)`
|
||||
**Indices:** `idx_sub_steps_run` on `(run_id, stage_name, ordinal)`
|
||||
|
||||
### `measurements`
|
||||
|
||||
Time-series sensor data from the thermal sidecar and stage executors.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `stage_id` | INTEGER | FK → stages(id) SET NULL | | Optional link to a specific stage. |
|
||||
| `ts` | TIMESTAMP | NOT NULL | | Sample timestamp. |
|
||||
| `kind` | TEXT | NOT NULL | | `temp`, `power`, `iperf`, `fio`, `smart_attr`, `psu_volt`, `fan`, etc. |
|
||||
| `key` | TEXT | NOT NULL | | Source identifier (e.g. `cpu/0`, `+12V`). |
|
||||
| `value` | REAL | | | Numeric sample. |
|
||||
| `unit` | TEXT | | | Display unit. |
|
||||
|
||||
**Indices:** `idx_measurements_run_kind_ts` on `(run_id, kind, ts)`
|
||||
|
||||
### `artifacts`
|
||||
|
||||
On-disk file references (reports, fio logs, iperf logs, hold keys).
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `stage_id` | INTEGER | FK → stages(id) SET NULL | | |
|
||||
| `kind` | TEXT | NOT NULL | | `inventory`, `report`, `report_html`, `hold_key`, `fio`, `iperf`. |
|
||||
| `path` | TEXT | NOT NULL | | Absolute path on disk. |
|
||||
| `sha256` | TEXT | NOT NULL | | SHA-256 hex digest. |
|
||||
| `size_bytes` | INTEGER | NOT NULL | | File size. |
|
||||
|
||||
### `spec_diffs`
|
||||
|
||||
Expected-vs-actual hardware divergences from SpecValidate.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `field` | TEXT | NOT NULL | | Dotted path (e.g. `memory.total_gib`, `cpu.logical_cores`). |
|
||||
| `expected` | TEXT | | | Expected value from the host's spec YAML. |
|
||||
| `actual` | TEXT | | | Observed value from the inventory probe. |
|
||||
| `severity` | TEXT | NOT NULL | | `critical`, `warning`, `info`. |
|
||||
| `ignored` | INTEGER | NOT NULL | `0` | `1` = operator chose to ignore this diff. |
|
||||
|
||||
### `thresholds`
|
||||
|
||||
Per-run threshold rules, seeded from the `ProfileRegistry` + per-host
|
||||
overrides at run creation. Immutable for the run's lifetime.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `stage_name` | TEXT | NOT NULL | | `*` matches any stage. |
|
||||
| `kind` | TEXT | NOT NULL | | Measurement kind to match. |
|
||||
| `key` | TEXT | NOT NULL | | Key selector (glob-ish). |
|
||||
| `op` | TEXT | NOT NULL | | `lt`, `lte`, `gt`, `gte`, `within_pct`. |
|
||||
| `threshold` | REAL | NOT NULL | | Limit value. |
|
||||
| `nominal` | REAL | NOT NULL | `0` | Reference for `within_pct`. |
|
||||
| `unit` | TEXT | NOT NULL | `''` | Display unit. |
|
||||
| `severity` | TEXT | NOT NULL | | `critical` or `warning`. |
|
||||
| `source` | TEXT | NOT NULL | | `profile` or `host_override`. |
|
||||
|
||||
**Indices:**
|
||||
- `idx_thresholds_run` on `(run_id)`
|
||||
- `idx_thresholds_kind` on `(run_id, stage_name, kind)`
|
||||
|
||||
### `threshold_evaluations`
|
||||
|
||||
Per-sample pass/fail results from threshold evaluation. Drives
|
||||
report badges and pipeline verdict rendering.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `threshold_id` | INTEGER | NOT NULL FK → thresholds(id) CASCADE | | |
|
||||
| `stage_name` | TEXT | NOT NULL | | Stage the sample belongs to. |
|
||||
| `kind` | TEXT | NOT NULL | | Measurement kind. |
|
||||
| `key` | TEXT | NOT NULL | | Source key. |
|
||||
| `ts` | TIMESTAMP | NOT NULL | | Sample timestamp. |
|
||||
| `observed` | REAL | NOT NULL | | Observed value. |
|
||||
| `passed` | INTEGER | NOT NULL | | `1` = within threshold, `0` = breach. |
|
||||
|
||||
**Indices:** `idx_threshold_evals_run` on `(run_id, passed)`
|
||||
|
||||
### `firmware_snapshots`
|
||||
|
||||
Per-run firmware version captures (BIOS, BMC, NIC, HBA, microcode,
|
||||
NVMe). Populated by the Firmware stage; consumed by SpecValidate for
|
||||
firmware version diffing.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
|
||||
| `component` | TEXT | NOT NULL | | `bios`, `bmc`, `nic`, `hba`, `microcode`, `nvme_fw`. |
|
||||
| `identifier` | TEXT | NOT NULL | | Slot, serial, or device path distinguishing this component. |
|
||||
| `version` | TEXT | NOT NULL | | Firmware version string. |
|
||||
| `vendor` | TEXT | NOT NULL | `''` | |
|
||||
| `raw_json` | TEXT | NOT NULL | `'{}'` | Additional metadata. |
|
||||
|
||||
**Indices:** `idx_firmware_run` on `(run_id, component)`
|
||||
|
||||
### `events`
|
||||
|
||||
Event log table. Reserved for future use.
|
||||
|
||||
| Column | Type | Constraints | Default | Description |
|
||||
|--------|------|-------------|---------|-------------|
|
||||
| `id` | INTEGER | PK AUTOINCREMENT | | |
|
||||
| `run_id` | INTEGER | FK → runs(id) CASCADE | | |
|
||||
| `host_id` | INTEGER | FK → hosts(id) CASCADE | | |
|
||||
| `ts` | TIMESTAMP | NOT NULL | | |
|
||||
| `level` | TEXT | NOT NULL | | |
|
||||
| `kind` | TEXT | NOT NULL | | |
|
||||
| `message` | TEXT | NOT NULL | | |
|
||||
| `data_json` | TEXT | | | |
|
||||
|
||||
### `settings`
|
||||
|
||||
Key-value store for orchestrator-level settings.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `key` | TEXT | PK | |
|
||||
| `value` | TEXT | NOT NULL | |
|
||||
|
||||
---
|
||||
|
||||
## Entity relationships
|
||||
|
||||
```
|
||||
hosts 1───N runs
|
||||
├──N stages
|
||||
│ └──(FK) measurements (stage_id, SET NULL)
|
||||
│ └──(FK) artifacts (stage_id, SET NULL)
|
||||
├──N sub_steps
|
||||
├──N measurements (run_id)
|
||||
├──N artifacts (run_id)
|
||||
├──N spec_diffs
|
||||
├──N thresholds
|
||||
│ └──N threshold_evaluations
|
||||
└──N firmware_snapshots
|
||||
```
|
||||
|
||||
All foreign keys use `ON DELETE CASCADE` (except `stage_id` references
|
||||
which use `SET NULL`). Deleting a host cascades through its runs and
|
||||
all dependent rows.
|
||||
|
||||
## Data retention
|
||||
|
||||
The janitor goroutine prunes **on-disk files** (artifacts, logs) based
|
||||
on `artifacts.retention_days` and `logs.retention_days`. **Database
|
||||
rows are never deleted** by the janitor — run histories, measurement
|
||||
time-series, spec diffs, and threshold evaluations survive cleanups
|
||||
indefinitely.
|
||||
|
||||
See [architecture.md § Data retention](architecture.md#data-retention)
|
||||
and [configuration.md § janitor](configuration.md#janitor).
|
||||
|
||||
## Migration history
|
||||
|
||||
| File | What it adds |
|
||||
|------|-------------|
|
||||
| `0001_init.sql` | Core schema: `hosts`, `runs`, `stages`, `measurements`, `artifacts`, `spec_diffs`, `events`, `settings`. |
|
||||
| `0002_add_hosts_last_seen_at.sql` | `hosts.last_seen_at` column for host-mode agent heartbeats. |
|
||||
| `0003_add_runs_non_destructive.sql` | `runs.non_destructive` boolean flag. |
|
||||
| `0004_add_sub_steps.sql` | `sub_steps` table for per-disk/per-NIC granular stage detail. |
|
||||
| `0005_profiles_thresholds_firmware.sql` | `runs.profile` column, `thresholds` + `threshold_evaluations` tables, `firmware_snapshots` table. |
|
||||
|
||||
All migrations are additive — no schema deletions or renames.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Development guide
|
||||
|
||||
How to build, test, and contribute to the vetting orchestrator and
|
||||
agent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Notes |
|
||||
|------|---------|-------|
|
||||
| Go | 1.22+ | Pure Go — no cgo required. |
|
||||
| templ | latest | `go install github.com/a-h/templ/cmd/templ@latest` |
|
||||
| make | any | GNU Make on Linux/macOS/WSL; `make` ships with Git for Windows. |
|
||||
| mkosi | 25.3+ | Only needed for `make live-image`. Linux/WSL only. |
|
||||
|
||||
Windows hosts can build and test everything except `live-image` and
|
||||
`e2e`. Those targets require a real Linux userspace — use WSL:
|
||||
`wsl make live-image`.
|
||||
|
||||
## Repository structure
|
||||
|
||||
```
|
||||
cmd/
|
||||
vetting/ orchestrator binary — HTTP server, dispatcher, runner
|
||||
vetting-agent/ agent binary — dual-mode (live-image + host-mode)
|
||||
internal/
|
||||
config/ YAML loader, ProfileRegistry (quick/deep/soak)
|
||||
db/ SQLite open + embedded migrations (pure Go via modernc.org/sqlite)
|
||||
model/ Plain structs: Host, Run, Stage, SubStep, Measurement, SpecDiff
|
||||
store/ Repository layer — hand-written SQL, no ORM
|
||||
orchestrator/ State machine, dispatcher, runner, WoL, HMAC tokens, iperf supervisor
|
||||
api/ HTTP handlers — agent_handlers.go + ui_handlers.go
|
||||
httpserver/ chi router assembly (exists to break api ↔ orchestrator import cycle)
|
||||
web/ Embedded static assets + compiled Templ templates
|
||||
pxe/ dnsmasq subprocess supervisor + per-MAC iPXE script generator
|
||||
events/ In-process SSE hub (fan-out to browser clients)
|
||||
logs/ Per-run flat-file writer + SSE fan-out
|
||||
spec/ Expected-vs-actual hardware diff engine
|
||||
notify/ Pluggable notifier registry (ntfy, Discord, SMTP)
|
||||
report/ HTML + JSON report generation
|
||||
hold/ Per-run SSH key issuance for FailedHolding
|
||||
janitor/ Retention-based cleanup (artifact + log files)
|
||||
agent/
|
||||
runner.go In-image agent: claim loop, stage dispatch, heartbeat, log forwarder
|
||||
client.go HTTP client for orchestrator API
|
||||
sensor_mux.go Thermal + performance metric sidecar
|
||||
bootstate/ Kernel cmdline parser (run_id, mac, orchestrator_url, token)
|
||||
hostmode/ Persistent host-mode reporter (systemd service)
|
||||
probes/ Hardware interrogation (lshw, dmidecode, smartctl, etc.)
|
||||
tests/ Per-stage test implementations
|
||||
live-image/ mkosi config + scripts for Debian live image
|
||||
deploy/ systemd unit, install.sh, pxe-setup.sh, example config
|
||||
docs/ You are here
|
||||
test/e2e/ Build-tagged QEMU + PXE full-stack integration test
|
||||
```
|
||||
|
||||
**Key architectural insight:** `internal/httpserver` exists solely to
|
||||
break the `api ↔ orchestrator` import cycle. The `internal/` tree is
|
||||
the orchestrator binary's code; the `agent/` tree is the agent
|
||||
binary's code. They share only `internal/model` (plain structs) and
|
||||
`internal/spec` (diff engine, used by the agent's inventory probe and
|
||||
the orchestrator's SpecValidate resolver).
|
||||
|
||||
## Building
|
||||
|
||||
| Target | Command | Description |
|
||||
|--------|---------|-------------|
|
||||
| Everything | `make all` | Build orchestrator + agent for host OS. |
|
||||
| Orchestrator | `make orchestrator` | Host OS binary (`bin/vetting`). |
|
||||
| Orchestrator (Linux) | `make orchestrator-linux` | Cross-compile to `bin/vetting-linux-amd64`. |
|
||||
| Agent | `make agent` | Host OS binary (dev/testing only). |
|
||||
| Agent (Linux) | `make agent-linux` | Cross-compile to `bin/vetting-agent.linux-amd64`. |
|
||||
| Templates | `make templ` | Regenerate `.templ` → `.go` files. Run before build if templates changed. |
|
||||
| Live image | `make live-image` | Build Debian live image via mkosi (Linux/WSL only). |
|
||||
| Release bundle | `make release` | Slim tarball: binaries + deploy scripts + VERSION pointer. |
|
||||
| Tidy | `make tidy` | `go mod tidy`. |
|
||||
| Format | `make fmt` | `go fmt ./...`. |
|
||||
| Lint | `make vet` | `go vet ./...`. |
|
||||
| Clean | `make clean` | Remove `bin/`, `build/`, `tmp/`, `out/`, `dist/`. |
|
||||
|
||||
Build flags: the git SHA is baked into the binary via
|
||||
`-ldflags -X vetting/internal/version.GitSHA=<sha>`.
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
make run
|
||||
# → builds orchestrator, launches with deploy/vetting.example.yaml
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
The example config binds to `127.0.0.1:8080`, disables PXE, and uses
|
||||
`./var/` relative paths for the database, artifacts, and logs. Edit
|
||||
`deploy/vetting.example.yaml` to tune for your dev environment.
|
||||
|
||||
For a QEMU walkthrough (register a host, PXE-boot a VM, watch the
|
||||
pipeline), see [operations.md § First vetting run](operations.md#first-vetting-run).
|
||||
|
||||
## Testing
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `make test` | Unit + smoke tests across all packages. Cross-platform. |
|
||||
| `make test-race` | Same tests with Go's race detector (`-race -count=1`). |
|
||||
| `make vet` | `go vet ./...` — catches common mistakes. |
|
||||
| `make e2e` | QEMU + PXE full-stack integration test. Requires Linux root, a built live image, and a running orchestrator with a registered host and queued run. |
|
||||
|
||||
**Test design:**
|
||||
|
||||
- Tests use real SQLite (in-memory or temp file) — no mocking the
|
||||
database.
|
||||
- The `agent/tests/fakes/` directory contains mock binaries
|
||||
(`dmidecode`, `stress-ng`, etc.) used by agent probe tests.
|
||||
- E2E tests are build-tagged with `-tags=e2e` and live in
|
||||
`test/e2e/qemu_test.go`.
|
||||
|
||||
## Adding a new test stage
|
||||
|
||||
1. Add a `State<Name>` constant to `internal/model/model.go`.
|
||||
2. Wire it into `internal/orchestrator/statemachine.go` — both the
|
||||
forward transition table and the stage-for-state lookup.
|
||||
3. Add the stage name to `DefaultStages()` in
|
||||
`internal/config/profiles.go`.
|
||||
4. Add a `case "<Name>":` to the `runStage` switch in
|
||||
`agent/runner.go`.
|
||||
5. Drop the implementation into `agent/tests/<name>.go`.
|
||||
6. If the stage is **orchestrator-owned** (like SpecValidate or
|
||||
Reporting), add a `resolve<Name>` helper to
|
||||
`internal/api/agent_handlers.go` and call it from `resultAdvance`.
|
||||
7. Add the stage to `vetting.stages` in
|
||||
`deploy/vetting.example.yaml`.
|
||||
|
||||
See [test-suite.md](test-suite.md) for what each existing stage
|
||||
measures and its pass/fail criteria.
|
||||
|
||||
## Adding a new notifier
|
||||
|
||||
1. Implement the `notify.Notifier` interface (single `Send` method)
|
||||
in a new file under `internal/notify/`.
|
||||
2. Register the new type in the notifier builder (the switch in
|
||||
`internal/notify/build.go` or equivalent factory).
|
||||
3. Add the type-specific config fields to the `Notifier` struct in
|
||||
`internal/config/config.go`.
|
||||
4. Document the new notifier type in
|
||||
[configuration.md § notifiers](configuration.md#notifiers).
|
||||
|
||||
## Code conventions
|
||||
|
||||
- **No cgo** — the SQLite driver is `modernc.org/sqlite` (pure Go).
|
||||
Builds cross-compile to Linux from Windows/macOS without a C
|
||||
toolchain.
|
||||
- **Hand-written SQL** — no ORM. Queries are explicit and testable.
|
||||
Each store method is a single SQL statement or a short transaction.
|
||||
- **Templ for UI** — `.templ` files compile to type-safe Go functions.
|
||||
The report module uses `html/template` instead (self-contained HTML
|
||||
with inlined CSS).
|
||||
- **chi for routing** — `github.com/go-chi/chi/v5`. Standard
|
||||
middleware stack: `RealIP`, `Recoverer`, `Logger`.
|
||||
- **Error handling** — fail-soft in SSE/tile paths (log and skip),
|
||||
fail-hard in store/migration paths (return error up).
|
||||
- **Log convention** — `log.Printf` with a context prefix
|
||||
(e.g. `"claim: seed stages run %d: %v"`).
|
||||
|
||||
## CI/CD
|
||||
|
||||
Three Gitea Actions workflows in `.gitea/workflows/`:
|
||||
|
||||
| Workflow | Trigger | What it does |
|
||||
|----------|---------|--------------|
|
||||
| `ci.yml` | Push to main + PRs | Templ generate, tidy check, vet, build (native + linux), test with race detector + coverage. |
|
||||
| `release.yml` | Push to main (skips doc/test paths) | Detects `live-image/VERSION` changes → builds + publishes live image to registry. Always builds slim bundle → publishes to `vetting/latest/`. |
|
||||
| `e2e.yml` | Manual dispatch | Builds live image + orchestrator, installs QEMU + deps, runs `make e2e`. |
|
||||
|
||||
**Release bundle structure:**
|
||||
|
||||
```
|
||||
vetting-bundle/
|
||||
bin/
|
||||
vetting-linux-amd64
|
||||
vetting-agent.linux-amd64
|
||||
live-image/
|
||||
VERSION # pointer — actual vmlinuz/initrd.img fetched on install
|
||||
install.sh
|
||||
pxe-setup.sh
|
||||
vetting.service
|
||||
vetting.production.yaml
|
||||
ipxe-shas.txt
|
||||
VERSION # git SHA
|
||||
```
|
||||
|
||||
The ~30 MB bundle is published on every push to main. The ~300 MB live
|
||||
image (`vmlinuz` + `initrd.img`) is published separately under
|
||||
`live-image/<version>/` and only rebuilds when `live-image/VERSION`
|
||||
changes.
|
||||
+73
-2
@@ -8,8 +8,8 @@ to fix, override, or abandon.
|
||||
## Stage order
|
||||
|
||||
```
|
||||
Inventory → SpecValidate → SMART → CPUStress → Storage
|
||||
→ Network → GPU → PSU → Reporting
|
||||
Inventory → Firmware → SpecValidate → SMART → CPUStress → Storage
|
||||
→ Network → Burn → GPU → PSU → Reporting
|
||||
```
|
||||
|
||||
Stages marked *orchestrator-owned* resolve inside `/result` and never
|
||||
@@ -27,6 +27,20 @@ merged into a single JSON blob.
|
||||
`nvidia-smi` on a GPU-less host) are tolerated.
|
||||
**Artifacts:** `inventory.json` under `artifacts/run-<N>/`.
|
||||
|
||||
## Firmware
|
||||
|
||||
**Owner:** agent.
|
||||
**What it does:** probes firmware versions across all discoverable
|
||||
components: BIOS (`dmidecode -t bios`), BMC (`ipmitool mc info`), NIC
|
||||
firmware (`ethtool -i` per interface), NVMe firmware (`nvme id-ctrl`),
|
||||
HBA firmware (`lspci -vv`), and CPU microcode (`/proc/cpuinfo`).
|
||||
Missing tools are tolerated — a GPU-less server won't have
|
||||
`nvidia-smi`, a consumer board won't have `ipmitool`.
|
||||
**Pass:** always passes. Firmware is advisory-only; SpecValidate is the
|
||||
gate that fails on version mismatches.
|
||||
**Artifacts:** `firmware_snapshots` table rows (one per component,
|
||||
keyed by `(run_id, component, identifier)`).
|
||||
|
||||
## SpecValidate *(orchestrator-owned)*
|
||||
|
||||
**Owner:** orchestrator (resolves inline inside the `/result` for the
|
||||
@@ -93,6 +107,40 @@ binds to the configured `network.iperf_port`.
|
||||
for 10GbE).
|
||||
**Artifacts:** `iperf-<nic>.json`.
|
||||
|
||||
## Burn
|
||||
|
||||
**Owner:** agent.
|
||||
**What it does:** runs CPU stress, memory stress, disk I/O, and
|
||||
network throughput **simultaneously** for the profile's burn duration.
|
||||
The goal is to stress every subsystem at once and surface failures that
|
||||
only appear under combined load (thermal throttling, PSU voltage sag,
|
||||
memory errors under thermal pressure).
|
||||
|
||||
Sub-workloads run as parallel goroutines:
|
||||
|
||||
- **CPU** — `stress-ng --cpu <workers>` for the burn duration.
|
||||
- **Memory** — `stress-ng --vm --vm-bytes <mem_pct>%` for the burn
|
||||
duration.
|
||||
- **Disk** — `fio` against a spare partition (when `fio_on_spare` is
|
||||
enabled).
|
||||
- **Network** — `iperf3 -c <orchestrator> -P <parallel>` for the burn
|
||||
duration.
|
||||
|
||||
**Pass:** all four sub-workloads exit 0 and no critical threshold
|
||||
breach fires during the window.
|
||||
**Configurable knobs** (per profile):
|
||||
|
||||
| Knob | Description |
|
||||
|------|-------------|
|
||||
| `duration` | Total burn-in window. |
|
||||
| `cpu_workers` | `all` = `runtime.NumCPU()`, or a fixed count. |
|
||||
| `mem_pct` | Percentage of MemAvailable to stress. |
|
||||
| `fio_on_spare` | Run fio inside Burn (requires a spare partition). |
|
||||
| `iperf_parallel` | Parallel stream count for `iperf3 -P`. |
|
||||
|
||||
See [configuration.md § burn](configuration.md#burn) for per-profile
|
||||
default values.
|
||||
|
||||
## GPU
|
||||
|
||||
**Owner:** agent.
|
||||
@@ -153,6 +201,29 @@ the next batch.
|
||||
- `artifacts` — on-disk files (report, fio logs, iperf logs, etc).
|
||||
- `spec_diffs` — one row per expected-vs-actual divergence.
|
||||
|
||||
## Profile duration summary
|
||||
|
||||
Three profiles scale every stage's duration. Probes and gates are
|
||||
identical across profiles — only the work size changes. See
|
||||
[configuration.md § profiles](configuration.md#profiles) for the full
|
||||
knob reference.
|
||||
|
||||
| Stage | quick (~10 min) | deep (~8-12 h) | soak (~36-40 h) |
|
||||
|-------|----------------|----------------|-----------------|
|
||||
| Inventory | seconds | seconds | seconds |
|
||||
| Firmware | seconds | seconds | seconds |
|
||||
| SpecValidate | instant (server) | instant (server) | instant (server) |
|
||||
| SMART | seconds per disk | seconds per disk | seconds per disk |
|
||||
| CPUStress | 2 m cpu + 2 m mem | 60 m cpu + 60 m mem | 12 h cpu + 12 h mem |
|
||||
| Storage | 3 m fio (sample) | badblocks + 2 h fio | badblocks + 6 h fio |
|
||||
| Network | 60 s iperf | 30 m iperf | 2 h iperf |
|
||||
| Burn | 2 m all-at-once | 2 h all-at-once | 18 h all-at-once |
|
||||
| GPU | seconds | seconds | seconds |
|
||||
| PSU | 1 m load burst | 10 m load burst | 15 m load burst |
|
||||
| Reporting | instant (server) | instant (server) | instant (server) |
|
||||
|
||||
---
|
||||
|
||||
## Adding a new stage
|
||||
|
||||
1. Add the name to `store.DefaultStageOrder`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package api contains the HTTP handlers for both the agent-facing
|
||||
// endpoints (/api/v1/runs/:id/*) and the browser-facing UI routes.
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -170,7 +172,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
|
||||
if len(mustListStages(a.Stages, r, runID)) == 0 {
|
||||
if err := a.Stages.Seed(r.Context(), runID); err != nil {
|
||||
log.Printf("claim: seed stages run %d: %v", runID, err)
|
||||
http.Error(w, "seed stages", http.StatusInternalServerError)
|
||||
writeJSONErr(w, http.StatusInternalServerError, "seed stages")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -180,7 +182,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
|
||||
if run.State == model.StateWaitingWoL || run.State == model.StateBooting {
|
||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerAgentClaimed); err != nil {
|
||||
log.Printf("claim: transition run %d: %v", runID, err)
|
||||
http.Error(w, "transition", http.StatusConflict)
|
||||
writeJSONErr(w, http.StatusConflict, "transition")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -369,6 +371,10 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeJSONErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]any{"ok": false, "error": msg})
|
||||
}
|
||||
|
||||
// mustListStages is a small wrapper that hides the error path from
|
||||
// /claim — a DB read failure just pretends there are zero stages, and
|
||||
// the subsequent Seed will surface the real error.
|
||||
@@ -408,12 +414,12 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var batch LogBatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
writeJSONErr(w, http.StatusBadRequest, "bad json")
|
||||
return
|
||||
}
|
||||
writer, err := a.Logs.WriterFor(runID)
|
||||
if err != nil {
|
||||
http.Error(w, "open log: "+err.Error(), http.StatusInternalServerError)
|
||||
writeJSONErr(w, http.StatusInternalServerError, "open log: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, l := range batch.Lines {
|
||||
@@ -470,9 +476,7 @@ type SubStepResultLine struct {
|
||||
// Result receives a stage's outcome. Flow:
|
||||
// 1. Mark the stage row passed/failed + record summary JSON.
|
||||
// 2. For Inventory: persist the inventory artifact.
|
||||
// 3. For Inventory (on pass): run spec diff server-side, persist rows,
|
||||
// bump the run into SpecValidate and immediately resolve SpecValidate
|
||||
// from that diff — the agent isn't involved in SpecValidate at all.
|
||||
// 3. For Firmware: persist firmware snapshots.
|
||||
// 4. Transition the run via StageCompleted/StageFailed.
|
||||
func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
runID, ok := runIDFromURL(w, r)
|
||||
@@ -485,28 +489,55 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var body StageResult
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
writeJSONErr(w, http.StatusBadRequest, "bad json")
|
||||
return
|
||||
}
|
||||
body.Stage = strings.TrimSpace(body.Stage)
|
||||
if _, ok := orchestrator.StateForStage(body.Stage); !ok {
|
||||
http.Error(w, "unknown stage: "+body.Stage, http.StatusBadRequest)
|
||||
writeJSONErr(w, http.StatusBadRequest, "unknown stage: "+body.Stage)
|
||||
return
|
||||
}
|
||||
|
||||
// Silent-skip guard. Orchestrator advances the run state via
|
||||
// TriggerStageCompleted against the *current* state, not against
|
||||
// body.Stage — so an Inventory result posted while the run is in
|
||||
// StateCPUStress would silently advance CPUStress → Storage and mark
|
||||
// CPUStress as passed without it ever running. That's exactly what
|
||||
// happened on Orion when the agent OOM-crashed mid-CPUStress,
|
||||
// systemd restarted it, and the restarted agent (which hardcoded
|
||||
// "Inventory" as its first stage) re-ran Inventory and reported it.
|
||||
// Guard: if body.Stage doesn't match the stage the run is currently
|
||||
// in, park the run in FailedHolding so the operator can investigate
|
||||
// rather than trusting the claim and cascading silent passes.
|
||||
if a.resultStageMismatch(w, r, runID, run, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
thresholdDetail := a.resultCheckThresholds(r.Context(), runID, &body)
|
||||
|
||||
stageState := model.StagePassed
|
||||
if !body.Passed {
|
||||
stageState = model.StageFailed
|
||||
}
|
||||
summaryJSON := ""
|
||||
if len(body.Summary) > 0 {
|
||||
summaryJSON = string(body.Summary)
|
||||
}
|
||||
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
|
||||
writeJSONErr(w, http.StatusInternalServerError, "complete stage: "+err.Error())
|
||||
return
|
||||
}
|
||||
if thresholdDetail != "" && body.Message == "" {
|
||||
body.Message = thresholdDetail
|
||||
}
|
||||
|
||||
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
|
||||
a.resultPersistArtifacts(r, run, runID, &body)
|
||||
|
||||
if !body.Passed {
|
||||
a.resultHandleFailed(w, r, runID, run, &body)
|
||||
return
|
||||
}
|
||||
a.resultAdvance(w, r, runID, &body)
|
||||
}
|
||||
|
||||
// resultStageMismatch parks the run in FailedHolding when the reported
|
||||
// stage doesn't match what the orchestrator expects. Returns true if the
|
||||
// response has been written (caller should return).
|
||||
func (a *Agent) resultStageMismatch(w http.ResponseWriter, r *http.Request, runID int64, run *model.Run, body *StageResult) bool {
|
||||
expectedStage := orchestrator.StageNameForState(run.State)
|
||||
if expectedStage != "" && body.Stage != expectedStage {
|
||||
if expectedStage == "" || body.Stage == expectedStage {
|
||||
return false
|
||||
}
|
||||
failedLabel := fmt.Sprintf("%s (expected %s)", body.Stage, expectedStage)
|
||||
if err := a.Runs.SetFailedStage(r.Context(), runID, failedLabel); err != nil {
|
||||
log.Printf("result: set failed stage on mismatch run %d: %v", runID, err)
|
||||
@@ -526,68 +557,49 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
URL: a.runLinkURL(runID),
|
||||
})
|
||||
log.Printf("result: stage mismatch run=%d got=%s expected=%s — parked", runID, body.Stage, expectedStage)
|
||||
http.Error(w, "stage mismatch: got "+body.Stage+", expected "+expectedStage, http.StatusConflict)
|
||||
return
|
||||
writeJSONErr(w, http.StatusConflict, "stage mismatch: got "+body.Stage+", expected "+expectedStage)
|
||||
return true
|
||||
}
|
||||
|
||||
// Aggregate threshold gate: flip Passed=false server-side when any
|
||||
// critical breach landed for this stage. The agent's verdict is
|
||||
// advisory — a stage-executor can miss a runaway sample that the
|
||||
// sidecar caught. We check this *before* writing the stage state
|
||||
// so the DB reflects the server-side decision.
|
||||
thresholdDetail := ""
|
||||
if body.Passed {
|
||||
if breached, detail := a.stageHadCriticalBreach(r.Context(), runID, body.Stage); breached {
|
||||
body.Passed = false
|
||||
thresholdDetail = detail
|
||||
a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
|
||||
}
|
||||
}
|
||||
|
||||
stageState := model.StagePassed
|
||||
// resultCheckThresholds flips body.Passed to false when the server-side
|
||||
// threshold sidecar recorded a critical breach the agent missed.
|
||||
func (a *Agent) resultCheckThresholds(ctx context.Context, runID int64, body *StageResult) string {
|
||||
if !body.Passed {
|
||||
stageState = model.StageFailed
|
||||
return ""
|
||||
}
|
||||
summaryJSON := ""
|
||||
if len(body.Summary) > 0 {
|
||||
summaryJSON = string(body.Summary)
|
||||
breached, detail := a.stageHadCriticalBreach(ctx, runID, body.Stage)
|
||||
if !breached {
|
||||
return ""
|
||||
}
|
||||
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
|
||||
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if thresholdDetail != "" && body.Message == "" {
|
||||
body.Message = thresholdDetail
|
||||
body.Passed = false
|
||||
a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
|
||||
return detail
|
||||
}
|
||||
|
||||
// Agent-authored sub-steps: persist in slice order (ordinal = index)
|
||||
// and fan out a per-row SSE event each so the detail pane shows them
|
||||
// without a reload. Best-effort — a persistence error is logged but
|
||||
// doesn't fail the whole /result.
|
||||
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
|
||||
|
||||
// Inventory-specific: persist artifact + compute spec diff.
|
||||
// resultPersistArtifacts handles stage-specific artifact persistence
|
||||
// (inventory JSON, firmware snapshots). Best-effort — errors are logged.
|
||||
func (a *Agent) resultPersistArtifacts(r *http.Request, run *model.Run, runID int64, body *StageResult) {
|
||||
if body.Stage == "Inventory" && body.Inventory != nil {
|
||||
if err := a.persistInventory(r, run, body.Inventory); err != nil {
|
||||
log.Printf("persist inventory run %d: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware-specific: persist each snapshot into firmware_snapshots.
|
||||
// SpecValidate reads them back to diff against expected_firmware.
|
||||
if body.Stage == "Firmware" && len(body.Firmware) > 0 {
|
||||
if err := a.persistFirmware(r.Context(), runID, body.Firmware); err != nil {
|
||||
log.Printf("persist firmware run %d: %v", runID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !body.Passed {
|
||||
// resultHandleFailed transitions a failed stage into FailedHolding and
|
||||
// fires the failure notification.
|
||||
func (a *Agent) resultHandleFailed(w http.ResponseWriter, r *http.Request, runID int64, run *model.Run, body *StageResult) {
|
||||
if err := a.Runs.SetFailedStage(r.Context(), runID, body.Stage); err != nil {
|
||||
log.Printf("set failed stage: %v", err)
|
||||
}
|
||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
|
||||
log.Printf("result: failed-transition run %d: %v", runID, err)
|
||||
http.Error(w, "transition", http.StatusConflict)
|
||||
writeJSONErr(w, http.StatusConflict, "transition")
|
||||
return
|
||||
}
|
||||
hostName := a.hostNameFor(r.Context(), run.HostID)
|
||||
@@ -605,21 +617,18 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
URL: a.runLinkURL(runID),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": "FailedHolding"})
|
||||
return
|
||||
}
|
||||
|
||||
// Passed: advance to the next stage in the pipeline.
|
||||
// resultAdvance transitions a passed stage to the next pipeline state,
|
||||
// auto-resolving server-owned stages (SpecValidate, Reporting).
|
||||
func (a *Agent) resultAdvance(w http.ResponseWriter, r *http.Request, runID int64, body *StageResult) {
|
||||
next, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted)
|
||||
if err != nil {
|
||||
http.Error(w, "advance: "+err.Error(), http.StatusConflict)
|
||||
writeJSONErr(w, http.StatusConflict, "advance: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("result: run %d stage %s passed → %s", runID, body.Stage, next)
|
||||
|
||||
// If the just-advanced-into state is SpecValidate or Reporting, the
|
||||
// orchestrator owns those stages entirely. The resolve function may
|
||||
// transition further (→ next stage on pass, → FailedHolding on fail,
|
||||
// → Completed for Reporting), so we re-read the run after each.
|
||||
if next == model.StateSpecValidate {
|
||||
a.resolveSpecValidate(r, runID)
|
||||
if after, err := a.Runs.Get(r.Context(), runID); err == nil {
|
||||
@@ -912,13 +921,13 @@ func (a *Agent) Hold(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
kp, err := hold.Issue(runID)
|
||||
if err != nil {
|
||||
http.Error(w, "generate key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeJSONErr(w, http.StatusInternalServerError, "generate key: "+err.Error())
|
||||
return
|
||||
}
|
||||
keyPath := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", runID), "hold.key")
|
||||
abs, err := kp.WritePrivateTo(keyPath)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeJSONErr(w, http.StatusInternalServerError, "write key: "+err.Error())
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(kp.PrivatePEM)
|
||||
@@ -1021,12 +1030,12 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if a.Measurements == nil {
|
||||
http.Error(w, "measurements store not wired", http.StatusInternalServerError)
|
||||
writeJSONErr(w, http.StatusInternalServerError, "measurements store not wired")
|
||||
return
|
||||
}
|
||||
var body SensorBatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
writeJSONErr(w, http.StatusBadRequest, "bad json")
|
||||
return
|
||||
}
|
||||
rows := make([]model.Measurement, 0, len(body.Samples))
|
||||
@@ -1050,7 +1059,7 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
|
||||
sampleStages = append(sampleStages, orchestrator.StageNameForState(run.State))
|
||||
}
|
||||
if err := a.Measurements.CreateBatch(r.Context(), rows); err != nil {
|
||||
http.Error(w, "write samples: "+err.Error(), http.StatusInternalServerError)
|
||||
writeJSONErr(w, http.StatusInternalServerError, "write samples: "+err.Error())
|
||||
return
|
||||
}
|
||||
critical := a.evaluateSensorBatch(r.Context(), runID, rows, sampleStages)
|
||||
|
||||
@@ -70,6 +70,11 @@ func (u *UI) reloadPXE(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func renderNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = templates.NotFound().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
var macRe = regexp.MustCompile(`^[0-9a-f]{2}(:[0-9a-f]{2}){5}$`)
|
||||
|
||||
// quickRegisterTmpl is parsed once at startup — a malformed template
|
||||
@@ -126,7 +131,7 @@ func (u *UI) HostPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := u.LoadHostPageData(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
renderNotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -190,7 +195,7 @@ func (u *UI) RunPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := u.LoadRunPageData(r.Context(), runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
renderNotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -293,7 +298,7 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
|
||||
host, err := u.Hosts.Get(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
renderNotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -685,7 +690,7 @@ func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if err := u.Hosts.Delete(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
renderNotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -722,7 +727,7 @@ func (u *UI) Report(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
http.NotFound(w, r)
|
||||
renderNotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Package config loads the orchestrator's YAML configuration file and
|
||||
// exposes typed structs for every config block. The ProfileRegistry
|
||||
// (quick/deep/soak) is built during Load from the vetting: and
|
||||
// profiles: top-level blocks.
|
||||
package config
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package db opens the SQLite database and applies embedded SQL
|
||||
// migrations in filename order at startup. Uses modernc.org/sqlite
|
||||
// (pure Go, no cgo).
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package events provides an in-process SSE fan-out hub. Browser
|
||||
// clients subscribe via GET /events; the orchestrator publishes
|
||||
// pre-rendered HTML fragments that HTMX swaps into the DOM.
|
||||
package events
|
||||
|
||||
import (
|
||||
@@ -22,6 +25,11 @@ type subscriber struct {
|
||||
ch chan Event
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSubscriberBuffer = 32
|
||||
heartbeatInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// Hub is an in-process fan-out for SSE subscribers.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
@@ -29,13 +37,16 @@ type Hub struct {
|
||||
subs map[int64]*subscriber
|
||||
buffer int
|
||||
heartbeat time.Duration
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
subs: map[int64]*subscriber{},
|
||||
buffer: 32,
|
||||
heartbeat: 15 * time.Second,
|
||||
buffer: defaultSubscriberBuffer,
|
||||
heartbeat: heartbeatInterval,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go h.heartbeatLoop()
|
||||
return h
|
||||
@@ -70,13 +81,18 @@ func (h *Hub) Subscribe() (id int64, ch <-chan Event, cancel func()) {
|
||||
func (h *Hub) heartbeatLoop() {
|
||||
t := time.NewTicker(h.heartbeat)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
for {
|
||||
select {
|
||||
case <-h.done:
|
||||
return
|
||||
case <-t.C:
|
||||
h.Publish(Event{
|
||||
Name: "heartbeat",
|
||||
Payload: fmt.Sprintf(`<span data-heartbeat="%d"></span>`, time.Now().Unix()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeSSE writes server-sent events for a single subscriber for the
|
||||
// lifetime of the request. Each Event becomes one SSE message.
|
||||
@@ -140,5 +156,16 @@ func splitLines(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Shutdown is a no-op placeholder wired into graceful shutdown.
|
||||
func (h *Hub) Shutdown(_ context.Context) error { return nil }
|
||||
// Shutdown stops the heartbeat goroutine and closes all subscriber channels.
|
||||
func (h *Hub) Shutdown(_ context.Context) error {
|
||||
h.closeOnce.Do(func() {
|
||||
close(h.done)
|
||||
h.mu.Lock()
|
||||
for id, s := range h.subs {
|
||||
close(s.ch)
|
||||
delete(h.subs, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
@@ -21,7 +22,7 @@ type Deps struct {
|
||||
AgentAssetDir string // directory containing vetting-agent-linux-amd64; "" disables /assets
|
||||
}
|
||||
|
||||
func NewRouter(d Deps) http.Handler {
|
||||
func NewRouter(d Deps) (http.Handler, error) {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
@@ -29,7 +30,7 @@ func NewRouter(d Deps) http.Handler {
|
||||
|
||||
staticFS, err := fs.Sub(web.Static, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, fmt.Errorf("extract static assets: %w", err)
|
||||
}
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
@@ -80,5 +81,5 @@ func NewRouter(d Deps) http.Handler {
|
||||
r.Get("/register/quick.sh", d.UI.QuickRegisterScript)
|
||||
r.Get("/events", d.UI.SSE)
|
||||
|
||||
return r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -72,7 +72,10 @@ func TestSSE_EndToEnd(t *testing.T) {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(Deps{UI: ui, Agent: agent})
|
||||
router, err := NewRouter(Deps{UI: ui, Agent: agent})
|
||||
if err != nil {
|
||||
t.Fatalf("router: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
@@ -178,7 +181,10 @@ func TestSSE_SubStepEvent(t *testing.T) {
|
||||
SpecDiffs: diffs, Runner: runner, EventHub: hub,
|
||||
}
|
||||
|
||||
router := NewRouter(Deps{UI: ui, Agent: agent})
|
||||
router, err := NewRouter(Deps{UI: ui, Agent: agent})
|
||||
if err != nil {
|
||||
t.Fatalf("router: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Package model defines the domain value types shared across the
|
||||
// orchestrator: Host, Run, Stage, SubStep, Measurement, and SpecDiff.
|
||||
// These are plain structs with no behaviour beyond state classification.
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Host is a registered hardware node in the vetting cluster.
|
||||
type Host struct {
|
||||
ID int64
|
||||
Name string
|
||||
@@ -17,6 +21,7 @@ type Host struct {
|
||||
LastSeenAt *time.Time // host-mode agent heartbeat; nil = never seen
|
||||
}
|
||||
|
||||
// RunState is the current position of a run in the state machine.
|
||||
type RunState string
|
||||
|
||||
const (
|
||||
@@ -51,6 +56,7 @@ func (s RunState) IsTerminal() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Run is a single vetting pass on a host, walking through the stage pipeline.
|
||||
type Run struct {
|
||||
ID int64
|
||||
HostID int64
|
||||
@@ -68,6 +74,7 @@ type Run struct {
|
||||
Profile string // quick|deep|soak; empty is treated as "quick"
|
||||
}
|
||||
|
||||
// StageState tracks whether a stage is pending, running, passed, failed, or skipped.
|
||||
type StageState string
|
||||
|
||||
const (
|
||||
@@ -78,6 +85,7 @@ const (
|
||||
StageSkipped StageState = "skipped"
|
||||
)
|
||||
|
||||
// Stage is a single test step within a run (e.g. SMART, CPUStress, Storage).
|
||||
type Stage struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
@@ -107,6 +115,7 @@ type SubStep struct {
|
||||
SummaryJSON string
|
||||
}
|
||||
|
||||
// Measurement is a single time-series sample from the thermal sidecar or a stage executor.
|
||||
type Measurement struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
@@ -118,6 +127,7 @@ type Measurement struct {
|
||||
Unit string
|
||||
}
|
||||
|
||||
// SpecDiff records a single expected-vs-actual hardware divergence from SpecValidate.
|
||||
type SpecDiff struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// doesn't block dispatch. Used by the StartRun preflight and the
|
||||
// dispatcher itself — both must agree or the operator's click-time
|
||||
// validation wouldn't match the dispatch-time check.
|
||||
const HostHeartbeatStaleAfter = 60 * time.Second
|
||||
const (
|
||||
HostHeartbeatStaleAfter = 60 * time.Second
|
||||
dispatchTickInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// Dispatcher picks Queued runs off the DB and drives them to
|
||||
// WaitingReboot — the happy path is heartbeat-first: we transition and
|
||||
@@ -76,7 +79,7 @@ func (d *Dispatcher) Stop() {
|
||||
}
|
||||
|
||||
func (d *Dispatcher) loop(ctx context.Context) {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
t := time.NewTicker(dispatchTickInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package orchestrator contains the run state machine, dispatcher,
|
||||
// per-run runner, WoL sender, HMAC token issuer, threshold evaluator,
|
||||
// and iperf3 supervisor.
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package pxe supervises a dnsmasq subprocess for proxy-DHCP PXE
|
||||
// boot and generates per-MAC iPXE scripts that chainload the live
|
||||
// image with run-specific kernel cmdline parameters.
|
||||
package pxe
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package store is the repository layer for the orchestrator's SQLite
|
||||
// database. Each store type (Hosts, Runs, Stages, etc.) wraps a
|
||||
// *sql.DB and exposes hand-written SQL queries — no ORM.
|
||||
package store
|
||||
|
||||
import (
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
const defaultRunListLimit = 20
|
||||
|
||||
type Runs struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
@@ -182,7 +184,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
|
||||
// can't scan the whole history into memory.
|
||||
func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
limit = defaultRunListLimit
|
||||
}
|
||||
rows, err := r.DB.QueryContext(ctx, `
|
||||
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package web embeds the static assets (CSS, JS) and compiled Templ
|
||||
// templates served by the orchestrator's HTTP routes.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
+143
-156
@@ -117,14 +117,8 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
||||
text-decoration: none;
|
||||
}
|
||||
.tile > *:not(.tile-link) { position: relative; z-index: 1; }
|
||||
.tile-primary-action { display: flex; gap: 8px; }
|
||||
.tile-primary-action .inline { margin: 0; }
|
||||
.tile-primary-action:empty { display: none; }
|
||||
.tile-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.tile-name { font-weight: 600; }
|
||||
.tile-header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.tile-status { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.tile-idle .tile-status { color: var(--text-dim); }
|
||||
|
||||
.tile-last-seen {
|
||||
font-family: var(--mono);
|
||||
@@ -146,58 +140,6 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
||||
.tile-last-seen.stale::before { background: var(--warn); }
|
||||
.tile-last-seen.offline::before { background: var(--text-dim); opacity: .5; }
|
||||
|
||||
.tile-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; margin: 0; font-size: 13px; }
|
||||
.tile-meta div { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.tile-meta dt { color: var(--text-dim); }
|
||||
.tile-meta dd { margin: 0; font-family: var(--mono); }
|
||||
|
||||
.tile-actions { display: flex; gap: 8px; }
|
||||
.tile-actions .inline { margin: 0; flex: 0; }
|
||||
|
||||
.tile-meta dd.bad { color: var(--danger); }
|
||||
|
||||
.tile-hold {
|
||||
background: rgba(229,100,102,.08);
|
||||
border: 1px solid rgba(229,100,102,.35);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.tile-hold .hold-title {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.tile-hold .hold-ssh {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.tile-log {
|
||||
background: #0b0d12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.tile-log:empty { display: none; }
|
||||
.tile-log .log-line { white-space: pre-wrap; }
|
||||
.tile-log .log-warn { color: var(--warn); }
|
||||
.tile-log .log-error { color: var(--danger); }
|
||||
|
||||
.tile-fail { border-color: rgba(229,100,102,.6); }
|
||||
.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||
.tile-active { border-color: var(--accent); }
|
||||
@@ -273,31 +215,14 @@ body.bare main { max-width: none; }
|
||||
.quick-register .one-liner code { white-space: pre; }
|
||||
|
||||
.manual-register-card { padding-top: 10px; padding-bottom: 14px; }
|
||||
.manual-register summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.manual-register summary::before {
|
||||
content: "▸";
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
transition: transform .1s ease;
|
||||
}
|
||||
.manual-register[open] > summary::before { transform: rotate(90deg); }
|
||||
.manual-register summary h2 {
|
||||
margin: 0;
|
||||
.manual-register-card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.manual-register summary:hover h2 { color: var(--text); }
|
||||
.manual-register[open] summary { margin-bottom: 12px; }
|
||||
|
||||
/* ===== Host detail page ===== */
|
||||
.detail { display: flex; flex-direction: column; gap: 20px; }
|
||||
@@ -320,7 +245,6 @@ body.bare main { max-width: none; }
|
||||
.detail-summary.tile-active { border-color: var(--accent); }
|
||||
.detail-summary-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||
.detail-name { margin: 0; font-size: 22px; }
|
||||
.detail-status-row { display: flex; align-items: center; gap: 12px; }
|
||||
.detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
@@ -345,11 +269,6 @@ body.bare main { max-width: none; }
|
||||
.detail-section details[open] > summary::before { transform: rotate(90deg); }
|
||||
.detail-section details > summary h2 { margin: 0; }
|
||||
|
||||
.detail-hold {
|
||||
background: rgba(229,100,102,.08);
|
||||
border-color: rgba(229,100,102,.35);
|
||||
}
|
||||
.detail-hold h2 { color: var(--danger); }
|
||||
.hold-ssh {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
@@ -366,25 +285,6 @@ body.bare main { max-width: none; }
|
||||
.detail-actions-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.detail-actions-row .inline { margin: 0; }
|
||||
|
||||
.detail-log {
|
||||
background: #0b0d12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.detail-log:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; }
|
||||
.detail-log .log-line { white-space: pre-wrap; }
|
||||
.detail-log .log-warn { color: var(--warn); }
|
||||
.detail-log .log-error { color: var(--danger); }
|
||||
|
||||
/* ===== Log tabs (CSS-only radio switch) ===== */
|
||||
/* Radios are visually hidden but still functional: checked state is read
|
||||
by sibling selectors below to flip the active label + pane. */
|
||||
@@ -570,37 +470,6 @@ body.bare main { max-width: none; }
|
||||
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
|
||||
}
|
||||
|
||||
/* ===== Host detail v2 — GitHub-Actions-style layout ===== */
|
||||
|
||||
.detail-v2 { gap: 12px; }
|
||||
|
||||
.host-meta-drawer {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.host-meta-drawer > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.host-meta-drawer > summary::before {
|
||||
content: "▸";
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
transition: transform .1s ease;
|
||||
}
|
||||
.host-meta-drawer[open] > summary::before { transform: rotate(90deg); }
|
||||
.host-meta-drawer .meta-summary-label { color: var(--text); font-weight: 600; }
|
||||
.host-meta-drawer .meta-summary-mac { font-family: var(--mono); margin-left: auto; }
|
||||
.host-meta-drawer[open] > summary { margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||
|
||||
.run-header {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
@@ -679,25 +548,7 @@ body.bare main { max-width: none; }
|
||||
}
|
||||
.detail-hold-placeholder { display: none; }
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.detail-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
.active-step-pane { display: flex; flex-direction: column; gap: 8px; }
|
||||
.detail-empty {
|
||||
padding: 24px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
@@ -909,14 +760,14 @@ body.bare main { max-width: none; }
|
||||
.host-profile-picker {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 10px;
|
||||
display: inline-flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
.host-profile-picker legend { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; padding: 0 4px; }
|
||||
.host-profile-picker label { display: inline-flex; gap: 4px; align-items: center; font-family: var(--mono); font-size: 13px; cursor: pointer; }
|
||||
.host-profile-picker label { cursor: pointer; }
|
||||
|
||||
.in-flight-banner-wrap { display: contents; }
|
||||
.in-flight-banner {
|
||||
@@ -996,3 +847,139 @@ body.bare main { max-width: none; }
|
||||
.run-page { display: flex; flex-direction: column; gap: 12px; }
|
||||
.run-body { display: flex; flex-direction: column; gap: 10px; }
|
||||
.run-header-name { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
|
||||
/* ---------- UX fixes ------------------------------------------------ */
|
||||
|
||||
/* #1: Active nav indicator */
|
||||
.topbar nav a.nav-active { color: var(--text); }
|
||||
|
||||
/* #3: Copy button for code blocks */
|
||||
.copyable-wrap { position: relative; display: flex; align-items: stretch; gap: 0; }
|
||||
.copyable-wrap .one-liner,
|
||||
.copyable-wrap .hold-ssh { flex: 1; margin: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
||||
.copy-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.copy-btn:hover { color: var(--text); background: var(--bg-elev); }
|
||||
.copy-btn.copied { color: var(--success); }
|
||||
|
||||
/* #4: Profile picker descriptions */
|
||||
.host-profile-picker label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.host-profile-picker label > .profile-label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.host-profile-picker label > .profile-desc { font-family: var(--font); font-size: 11px; color: var(--text-dim); padding-left: 18px; }
|
||||
|
||||
/* #5: Non-destructive hint */
|
||||
.nd-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding-left: 20px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* #6: Offline guidance */
|
||||
.offline-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.offline-hint span { font-size: 13px; color: var(--text-dim); }
|
||||
.offline-hint a { color: var(--accent); }
|
||||
|
||||
/* #7: SSE connection indicator */
|
||||
.heartbeat { transition: color .3s ease; }
|
||||
.heartbeat-live { color: var(--success) !important; }
|
||||
.heartbeat-stale { color: var(--danger) !important; }
|
||||
|
||||
/* #9: Diff badge on collapsed spec diffs */
|
||||
.diff-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
line-height: 1;
|
||||
}
|
||||
.diff-badge-critical { background: rgba(229,100,102,.2); color: var(--danger); border: 1px solid rgba(229,100,102,.5); }
|
||||
.diff-badge-warn { background: rgba(228,169,75,.15); color: var(--warn); border: 1px solid rgba(228,169,75,.4); }
|
||||
|
||||
/* #10: Cancelled run state */
|
||||
.run-status-cancelled { background: rgba(154,162,177,.12); border-color: rgba(154,162,177,.4); color: var(--text-dim); }
|
||||
.tile-cancelled { border-color: rgba(154,162,177,.3); }
|
||||
|
||||
/* #11: Small status badge on tiles */
|
||||
.run-status-badge-sm { font-size: 10px; padding: 2px 7px; }
|
||||
.tile-status { display: flex; }
|
||||
|
||||
/* #12: Log search match count */
|
||||
.log-match-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
}
|
||||
.log-match-count:empty { display: none; }
|
||||
|
||||
/* #8: Inline confirm */
|
||||
.confirm-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(229,100,102,.1);
|
||||
border: 1px solid rgba(229,100,102,.5);
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.confirm-overlay .confirm-msg { flex: 1; }
|
||||
.confirm-overlay .confirm-yes {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
.confirm-overlay .confirm-no {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text-dim);
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
/* #13: 404 page */
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 80px 24px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.not-found h1 { font-size: 24px; color: var(--text); margin: 0 0 8px; }
|
||||
.not-found p { margin: 0 0 20px; }
|
||||
.not-found .button { display: inline-block; }
|
||||
|
||||
+119
-31
@@ -1,16 +1,5 @@
|
||||
// Detail-page client behaviors. Loaded in layout.templ with `defer` so the
|
||||
// DOM is parsed before any listeners fire. Three jobs:
|
||||
//
|
||||
// 1. Auto-advance: when a substep-* SSE event lands with state=running,
|
||||
// open the parent step panel and collapse any previously-running step
|
||||
// that's now completed. Keeps the operator's attention on the thing
|
||||
// that's currently moving without manual clicks.
|
||||
// 2. In-step search: filter `.log-line` rows inside the current step by
|
||||
// substring match. Client-side only — the log pane's `<details>` ancestor
|
||||
// scopes the filter naturally.
|
||||
// 3. Permalink scroll + highlight: when the URL carries `#L{run}-{stage}-{ord}`
|
||||
// on load, scroll that log line into view; anchor clicks update
|
||||
// `location.hash` without a reload.
|
||||
// Client behaviors for the Vetting UI. Loaded in layout.templ with `defer`
|
||||
// so the DOM is parsed before any listeners fire.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -21,9 +10,6 @@
|
||||
if (!name || name.indexOf('substep-') !== 0) {
|
||||
return;
|
||||
}
|
||||
// After htmx has applied the swap, check which step the just-updated
|
||||
// substep belongs to. We scan *after* the swap so we see the new
|
||||
// class ("substep-running" / "substep-passed") rather than the old.
|
||||
setTimeout(function () {
|
||||
autoAdvance();
|
||||
}, 0);
|
||||
@@ -40,24 +26,19 @@
|
||||
if (!runningStep) {
|
||||
return;
|
||||
}
|
||||
// Open the running step; collapse any other open step that no longer
|
||||
// has a running substep. The default-open step picked server-side
|
||||
// stays open if nothing is running yet.
|
||||
steps.forEach(function (step) {
|
||||
if (step === runningStep) {
|
||||
if (!step.open) { step.open = true; }
|
||||
return;
|
||||
}
|
||||
if (step.open && !step.querySelector('.substep-running')) {
|
||||
// Leave the "currently-failed" step open even when we
|
||||
// auto-advance — operator still wants to see what broke.
|
||||
if (step.classList.contains('step-failed')) { return; }
|
||||
step.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. in-step search ----------------------------------------------
|
||||
// --- 2. in-step search + match count --------------------------------
|
||||
|
||||
document.body.addEventListener('input', function (ev) {
|
||||
var el = ev.target;
|
||||
@@ -67,6 +48,7 @@
|
||||
var step = el.closest('.step');
|
||||
if (!step) { return; }
|
||||
var query = el.value.trim().toLowerCase();
|
||||
var matchCount = 0;
|
||||
step.querySelectorAll('.log-line').forEach(function (line) {
|
||||
if (!query) {
|
||||
line.style.display = '';
|
||||
@@ -80,17 +62,22 @@
|
||||
} else {
|
||||
line.style.display = '';
|
||||
line.classList.add('log-hit');
|
||||
matchCount++;
|
||||
}
|
||||
});
|
||||
var counter = el.closest('.log-search-wrap').querySelector('.log-match-count');
|
||||
if (counter) {
|
||||
if (!query) {
|
||||
counter.textContent = '';
|
||||
} else if (matchCount === 0) {
|
||||
counter.textContent = 'No matches';
|
||||
} else {
|
||||
counter.textContent = matchCount + (matchCount === 1 ? ' match' : ' matches');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- 3. live duration tick ------------------------------------------
|
||||
//
|
||||
// .run-duration spans carry data-started-at (RFC3339) while the run is
|
||||
// non-terminal. Every second we rewrite their text with the current
|
||||
// elapsed so the header timer ticks between SSE pushes. When an SSE
|
||||
// swap drops the attribute (run finished), the tick silently skips it
|
||||
// and the server-rendered final value stays put.
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 0) { ms = 0; }
|
||||
@@ -126,7 +113,6 @@
|
||||
if (!hash) { return; }
|
||||
var target = document.getElementById(hash);
|
||||
if (!target) { return; }
|
||||
// Open the enclosing step so the target is actually visible.
|
||||
var step = target.closest('.step');
|
||||
if (step && !step.open) { step.open = true; }
|
||||
target.scrollIntoView({ block: 'center' });
|
||||
@@ -135,8 +121,6 @@
|
||||
window.addEventListener('load', scrollToHash);
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
|
||||
// Anchor clicks update location.hash without triggering navigation;
|
||||
// the hashchange listener above handles the scroll + highlight.
|
||||
document.body.addEventListener('click', function (ev) {
|
||||
var a = ev.target.closest && ev.target.closest('.log-anchor');
|
||||
if (!a) { return; }
|
||||
@@ -147,4 +131,108 @@
|
||||
scrollToHash();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 5. active nav indicator ----------------------------------------
|
||||
|
||||
(function setActiveNav() {
|
||||
var path = location.pathname;
|
||||
var nav = document.querySelector('[data-nav]');
|
||||
if (!nav) { return; }
|
||||
nav.querySelectorAll('a').forEach(function (a) {
|
||||
var href = a.getAttribute('href');
|
||||
if (href === '/hosts/new' && path === '/hosts/new') {
|
||||
a.classList.add('nav-active');
|
||||
} else if (href === '/' && path !== '/hosts/new') {
|
||||
a.classList.add('nav-active');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// --- 6. copy-to-clipboard buttons -----------------------------------
|
||||
|
||||
document.body.addEventListener('click', function (ev) {
|
||||
var btn = ev.target.closest && ev.target.closest('.copy-btn');
|
||||
if (!btn) { return; }
|
||||
var wrap = btn.closest('.copyable-wrap');
|
||||
if (!wrap) { return; }
|
||||
var source = wrap.querySelector('.one-liner code, .hold-ssh');
|
||||
if (!source) { return; }
|
||||
var text = source.textContent || '';
|
||||
navigator.clipboard.writeText(text.trim()).then(function () {
|
||||
btn.textContent = 'Copied';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function () {
|
||||
btn.textContent = 'Copy';
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// --- 7. SSE connection health indicator ------------------------------
|
||||
|
||||
var lastSSE = 0;
|
||||
var HEARTBEAT_INTERVAL = 15000;
|
||||
var STALE_THRESHOLD = HEARTBEAT_INTERVAL * 2.5;
|
||||
|
||||
document.body.addEventListener('htmx:sseMessage', function () {
|
||||
lastSSE = Date.now();
|
||||
var hb = document.querySelector('.heartbeat');
|
||||
if (hb) {
|
||||
hb.classList.add('heartbeat-live');
|
||||
hb.classList.remove('heartbeat-stale');
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(function () {
|
||||
if (!lastSSE) { return; }
|
||||
var hb = document.querySelector('.heartbeat');
|
||||
if (!hb) { return; }
|
||||
if (Date.now() - lastSSE > STALE_THRESHOLD) {
|
||||
hb.classList.remove('heartbeat-live');
|
||||
hb.classList.add('heartbeat-stale');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// --- 8. inline confirmation for destructive actions ------------------
|
||||
|
||||
document.body.addEventListener('submit', function (ev) {
|
||||
var form = ev.target;
|
||||
if (!form || !form.hasAttribute('data-confirm')) { return; }
|
||||
if (form.dataset.confirmed === 'yes') {
|
||||
form.removeAttribute('data-confirmed');
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
if (form.querySelector('.confirm-overlay')) { return; }
|
||||
|
||||
var msg = form.getAttribute('data-confirm');
|
||||
var btn = form.querySelector('button[type="submit"]');
|
||||
if (!btn) { return; }
|
||||
btn.style.display = 'none';
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML =
|
||||
'<span class="confirm-msg">' + escapeHtml(msg) + '</span>' +
|
||||
'<button type="button" class="confirm-yes">Confirm</button>' +
|
||||
'<button type="button" class="confirm-no">Cancel</button>';
|
||||
|
||||
form.appendChild(overlay);
|
||||
|
||||
overlay.querySelector('.confirm-yes').addEventListener('click', function () {
|
||||
form.dataset.confirmed = 'yes';
|
||||
form.requestSubmit();
|
||||
});
|
||||
overlay.querySelector('.confirm-no').addEventListener('click', function () {
|
||||
overlay.remove();
|
||||
btn.style.display = '';
|
||||
});
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -2,7 +2,6 @@ package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
@@ -40,7 +39,8 @@ templ ActiveStep(d ActiveStepData) {
|
||||
</ol>
|
||||
}
|
||||
<div class="log-search-wrap">
|
||||
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name }/>
|
||||
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name } aria-label={ "Search " + d.Stage.Name + " logs" }/>
|
||||
<span class="log-match-count"></span>
|
||||
</div>
|
||||
<div
|
||||
class="log-pane"
|
||||
@@ -67,30 +67,9 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||
return out
|
||||
}
|
||||
|
||||
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||
// formatting rules, different input shape.
|
||||
func stageDurationFromStage(s model.Stage) string {
|
||||
if s.StartedAt == nil {
|
||||
if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if s.CompletedAt != nil {
|
||||
end = *s.CompletedAt
|
||||
}
|
||||
d := end.Sub(*s.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
@@ -88,7 +87,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 28, Col: 102}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 27, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -123,7 +122,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 29, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -136,7 +135,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -149,7 +148,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 32, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -182,39 +181,52 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 43, Col: 99}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 42, Col: 99}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></div><div class=\"log-pane\" id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("Search " + d.Stage.Name + " logs")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 42, Col: 149}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" sse-swap=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"> <span class=\"log-match-count\"></span></div><div class=\"log-pane\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-swap=\"beforeend show:bottom\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-swap=\"beforeend show:bottom\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -222,7 +234,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div></details>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -243,32 +255,11 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||
return out
|
||||
}
|
||||
|
||||
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||
// formatting rules, different input shape.
|
||||
func stageDurationFromStage(s model.Stage) string {
|
||||
if s.StartedAt == nil {
|
||||
if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if s.CompletedAt != nil {
|
||||
end = *s.CompletedAt
|
||||
}
|
||||
d := end.Sub(*s.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func elapsed(start, end *time.Time) time.Duration {
|
||||
if start == nil {
|
||||
return -1
|
||||
}
|
||||
e := time.Now()
|
||||
if end != nil {
|
||||
e = *end
|
||||
}
|
||||
d := e.Sub(*start)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func fmtElapsed(d time.Duration, long bool) string {
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
if long {
|
||||
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
|
||||
}
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
if long {
|
||||
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
|
||||
}
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
@@ -104,31 +104,38 @@ templ HostActions(d HostPageData) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline host-start-form">
|
||||
<fieldset class="host-profile-picker">
|
||||
<legend>Profile</legend>
|
||||
<label title="~10 min — post-repair sanity: all probes + gates, short budgets">
|
||||
<label>
|
||||
<input type="radio" name="profile" value="quick" checked/>
|
||||
quick
|
||||
<span class="profile-label">quick</span>
|
||||
<span class="profile-desc">~10 min — post-repair sanity check</span>
|
||||
</label>
|
||||
<label title="~8–12 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network">
|
||||
<label>
|
||||
<input type="radio" name="profile" value="deep"/>
|
||||
deep
|
||||
<span class="profile-label">deep</span>
|
||||
<span class="profile-desc">~8–12 h — overnight full-disk verify</span>
|
||||
</label>
|
||||
<label title="≥24 h — week-long burn-in; opt-in when you suspect intermittent faults">
|
||||
<label>
|
||||
<input type="radio" name="profile" value="soak"/>
|
||||
soak
|
||||
<span class="profile-label">soak</span>
|
||||
<span class="profile-desc">≥24 h — burn-in for intermittent faults</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="host-nd-toggle">
|
||||
<input type="checkbox" name="non_destructive" value="1"/>
|
||||
Non-destructive (skip wipe-probe + disk writes)
|
||||
<span>Non-destructive</span>
|
||||
<span class="nd-hint">Skips the Storage wipe-and-verify stage. All other stages run normally.</span>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Start vetting</button>
|
||||
</form>
|
||||
} else if hostCanStartIfOnline(d) {
|
||||
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||
<div class="offline-hint">
|
||||
<button type="button" disabled>Start vetting</button>
|
||||
<span>Host is offline — <a href="/hosts/new">run the reporter script</a> on the target host to bring it online.</span>
|
||||
</div>
|
||||
} else {
|
||||
<button type="button" disabled>Run in flight</button>
|
||||
}
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)) } class="inline" data-confirm="Delete this host and all its runs?">
|
||||
<button type="submit" class="btn-danger">Delete host</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -168,7 +175,10 @@ templ HostEmptyState(d HostPageData) {
|
||||
<button type="submit" class="btn-primary big">Start vetting</button>
|
||||
</form>
|
||||
} else {
|
||||
<button type="button" class="btn-primary big" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||
<div class="offline-hint">
|
||||
<button type="button" class="btn-primary big" disabled>Start vetting</button>
|
||||
<span>Host is offline — <a href="/hosts/new">run the reporter script</a> on the target host to bring it online.</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -283,9 +293,6 @@ func profileChipValue(p string) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// runDuration formats the elapsed time for a run using the same buckets
|
||||
// as stageDuration. In-flight runs clock from StartedAt to now so the
|
||||
// run-page header + runs-table row keep ticking on each SSE push.
|
||||
func runDuration(r *model.Run) string {
|
||||
if r == nil || r.StartedAt.IsZero() {
|
||||
return ""
|
||||
@@ -298,18 +305,7 @@ func runDuration(r *model.Run) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
|
||||
default:
|
||||
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
|
||||
}
|
||||
return fmtElapsed(d, true)
|
||||
}
|
||||
|
||||
// stageForName returns the persisted Stage row for a given name, or a
|
||||
@@ -336,6 +332,15 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func diffBadgeClass(diffs []model.SpecDiff) string {
|
||||
for _, d := range diffs {
|
||||
if d.Severity == "critical" && !d.Ignored {
|
||||
return "diff-badge-critical"
|
||||
}
|
||||
}
|
||||
return "diff-badge-warn"
|
||||
}
|
||||
|
||||
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
|
||||
// Future times (clock skew) render as "now" so the runs table never
|
||||
// shows nonsense when a host's clock is ahead of the orchestrator.
|
||||
|
||||
@@ -361,12 +361,12 @@ func HostActions(d HostPageData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><fieldset class=\"host-profile-picker\"><legend>Profile</legend> <label title=\"~10 min — post-repair sanity: all probes + gates, short budgets\"><input type=\"radio\" name=\"profile\" value=\"quick\" checked> quick</label> <label title=\"~8–12 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network\"><input type=\"radio\" name=\"profile\" value=\"deep\"> deep</label> <label title=\"≥24 h — week-long burn-in; opt-in when you suspect intermittent faults\"><input type=\"radio\" name=\"profile\" value=\"soak\"> soak</label></fieldset><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><fieldset class=\"host-profile-picker\"><legend>Profile</legend> <label><input type=\"radio\" name=\"profile\" value=\"quick\" checked> <span class=\"profile-label\">quick</span> <span class=\"profile-desc\">~10 min — post-repair sanity check</span></label> <label><input type=\"radio\" name=\"profile\" value=\"deep\"> <span class=\"profile-label\">deep</span> <span class=\"profile-desc\">~8–12 h — overnight full-disk verify</span></label> <label><input type=\"radio\" name=\"profile\" value=\"soak\"> <span class=\"profile-label\">soak</span> <span class=\"profile-desc\">≥24 h — burn-in for intermittent faults</span></label></fieldset><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> <span>Non-destructive</span> <span class=\"nd-hint\">Skips the Storage wipe-and-verify stage. All other stages run normally.</span></label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if hostCanStartIfOnline(d) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"offline-hint\"><button type=\"button\" disabled>Start vetting</button> <span>Host is offline — <a href=\"/hosts/new\">run the reporter script</a> on the target host to bring it online.</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -383,13 +383,13 @@ func HostActions(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var19 templ.SafeURL
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 131, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 138, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\" onsubmit=\"return confirm('Delete host and all its runs?');\"><button type=\"submit\" class=\"btn-danger\">Delete host</button></form></div></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\" data-confirm=\"Delete this host and all its runs?\"><button type=\"submit\" class=\"btn-danger\">Delete host</button></form></div></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -428,7 +428,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 143, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 150, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -441,7 +441,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 145, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 152, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -459,7 +459,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var23 templ.SafeURL
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 149, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 156, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -472,7 +472,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ActiveRun.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 150, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 157, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -485,7 +485,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.ActiveRun))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 151, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 158, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -541,7 +541,7 @@ func HostEmptyState(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var27 templ.SafeURL
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 167, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 174, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -552,7 +552,7 @@ func HostEmptyState(d HostPageData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<button type=\"button\" class=\"btn-primary big\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"offline-hint\"><button type=\"button\" class=\"btn-primary big\" disabled>Start vetting</button> <span>Host is offline — <a href=\"/hosts/new\">run the reporter script</a> on the target host to bring it online.</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -655,7 +655,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 219, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 229, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -681,7 +681,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 221, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 231, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -694,7 +694,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var34 templ.SafeURL
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 235, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -707,7 +707,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 235, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -742,7 +742,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 228, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 238, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -755,7 +755,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(relativeTime(d.Run.StartedAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 230, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 240, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -768,7 +768,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 231, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 241, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -805,7 +805,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 236, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 246, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -823,7 +823,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var44 templ.SafeURL
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 241, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 251, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -877,9 +877,6 @@ func profileChipValue(p string) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// runDuration formats the elapsed time for a run using the same buckets
|
||||
// as stageDuration. In-flight runs clock from StartedAt to now so the
|
||||
// run-page header + runs-table row keep ticking on each SSE push.
|
||||
func runDuration(r *model.Run) string {
|
||||
if r == nil || r.StartedAt.IsZero() {
|
||||
return ""
|
||||
@@ -892,18 +889,7 @@ func runDuration(r *model.Run) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
|
||||
default:
|
||||
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
|
||||
}
|
||||
return fmtElapsed(d, true)
|
||||
}
|
||||
|
||||
// stageForName returns the persisted Stage row for a given name, or a
|
||||
@@ -930,6 +916,15 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func diffBadgeClass(diffs []model.SpecDiff) string {
|
||||
for _, d := range diffs {
|
||||
if d.Severity == "critical" && !d.Ignored {
|
||||
return "diff-badge-critical"
|
||||
}
|
||||
}
|
||||
return "diff-badge-warn"
|
||||
}
|
||||
|
||||
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
|
||||
// Future times (clock skew) render as "now" so the runs table never
|
||||
// shows nonsense when a host's clock is ahead of the orchestrator.
|
||||
|
||||
@@ -9,10 +9,9 @@ import (
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// HostTile renders a single dashboard card: hostname, heartbeat badge,
|
||||
// latest run status, and the primary action (Start / Cancel / View
|
||||
// report). The whole tile is a link to /hosts/{id} via a CSS-overlay
|
||||
// <a>; every deeper control lives on the host page or the run page.
|
||||
// HostTile renders a single dashboard card: hostname + heartbeat badge
|
||||
// only. Everything else (run status, controls, reports) lives on the
|
||||
// host page — the whole tile is a link there via a CSS-overlay <a>.
|
||||
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
|
||||
templ HostTile(t TileData) {
|
||||
<article
|
||||
@@ -24,30 +23,13 @@ templ HostTile(t TileData) {
|
||||
<a class="tile-link" href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)) } aria-label={ "Open " + t.Host.Name }></a>
|
||||
<header class="tile-head">
|
||||
<div class="tile-name">{ t.Host.Name }</div>
|
||||
<div class="tile-header-right">
|
||||
<span class={ "tile-last-seen", lastSeenClass(t.LastSeenAt) }>{ lastSeenLabel(t.LastSeenAt) }</span>
|
||||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tile-primary-action">
|
||||
if canStart(t) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
|
||||
<label class="tile-nd-toggle">
|
||||
<input type="checkbox" name="non_destructive" value="1"/>
|
||||
Non-destructive
|
||||
</label>
|
||||
<button type="submit">Start vetting</button>
|
||||
</form>
|
||||
} else if canStartIfOnline(t.Latest) {
|
||||
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||
} else if canCancel(t.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)) } class="inline tile-cancel-form" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
|
||||
<button type="submit" class="danger">Cancel run</button>
|
||||
</form>
|
||||
} else if hasReport(t.Latest) {
|
||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||||
}
|
||||
if t.Latest != nil {
|
||||
<div class="tile-status">
|
||||
<span class={ "run-status-badge", "run-status-badge-sm", "run-status-" + tileMood(t.Latest) }>{ tileStatus(t.Latest) }</span>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
|
||||
@@ -65,30 +47,6 @@ func hasReport(r *model.Run) bool {
|
||||
return r != nil && r.State == model.StateCompleted
|
||||
}
|
||||
|
||||
// canStart gates the Start button on two things: the run is in a state
|
||||
// that accepts a fresh start, AND the host is currently heartbeating.
|
||||
// The heartbeat check mirrors the StartRun handler's preflight so the
|
||||
// button never offers a click that the server would reject with 409.
|
||||
func canStart(t TileData) bool {
|
||||
if !canStartIfOnline(t.Latest) {
|
||||
return false
|
||||
}
|
||||
if t.LastSeenAt == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(*t.LastSeenAt) <= 60*time.Second
|
||||
}
|
||||
|
||||
// canStartIfOnline is the run-state half of canStart, split out so the
|
||||
// template can distinguish "waiting on run to end" (no button) from
|
||||
// "run is done but host is offline" (disabled button with tooltip).
|
||||
func canStartIfOnline(r *model.Run) bool {
|
||||
if r == nil {
|
||||
return true
|
||||
}
|
||||
return r.State.IsTerminal()
|
||||
}
|
||||
|
||||
// canCancel is true for any non-terminal run, plus FailedHolding —
|
||||
// a held run technically classifies as terminal for the pipeline but
|
||||
// the host is still live on the SSH hold prompt, and the operator
|
||||
@@ -131,7 +89,9 @@ func tileMood(r *model.Run) string {
|
||||
return "pass"
|
||||
case model.StateFailed, model.StateFailedHolding:
|
||||
return "fail"
|
||||
case model.StateReleased, model.StateCancelled:
|
||||
case model.StateCancelled:
|
||||
return "cancelled"
|
||||
case model.StateReleased:
|
||||
return "idle"
|
||||
}
|
||||
return "active"
|
||||
|
||||
@@ -17,10 +17,9 @@ import (
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// HostTile renders a single dashboard card: hostname, heartbeat badge,
|
||||
// latest run status, and the primary action (Start / Cancel / View
|
||||
// report). The whole tile is a link to /hosts/{id} via a CSS-overlay
|
||||
// <a>; every deeper control lives on the host page or the run page.
|
||||
// HostTile renders a single dashboard card: hostname + heartbeat badge
|
||||
// only. Everything else (run status, controls, reports) lives on the
|
||||
// host page — the whole tile is a link there via a CSS-overlay <a>.
|
||||
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
|
||||
func HostTile(t TileData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
@@ -55,7 +54,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 18, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -81,7 +80,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 21, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 20, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -94,7 +93,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 23, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -107,7 +106,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 23, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -120,13 +119,13 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 25, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"tile-header-right\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -155,90 +154,58 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 28, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><div class=\"tile-status\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></header>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if t.Latest != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"tile-status\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header><div class=\"tile-primary-action\">")
|
||||
var templ_7745c5c3_Var12 = []any{"run-status-badge", "run-status-badge-sm", "run-status-" + tileMood(t.Latest)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canStart(t) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 34, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canStartIfOnline(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canCancel(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 44, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 30, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if hasReport(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></article>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</article>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -260,30 +227,6 @@ func hasReport(r *model.Run) bool {
|
||||
return r != nil && r.State == model.StateCompleted
|
||||
}
|
||||
|
||||
// canStart gates the Start button on two things: the run is in a state
|
||||
// that accepts a fresh start, AND the host is currently heartbeating.
|
||||
// The heartbeat check mirrors the StartRun handler's preflight so the
|
||||
// button never offers a click that the server would reject with 409.
|
||||
func canStart(t TileData) bool {
|
||||
if !canStartIfOnline(t.Latest) {
|
||||
return false
|
||||
}
|
||||
if t.LastSeenAt == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(*t.LastSeenAt) <= 60*time.Second
|
||||
}
|
||||
|
||||
// canStartIfOnline is the run-state half of canStart, split out so the
|
||||
// template can distinguish "waiting on run to end" (no button) from
|
||||
// "run is done but host is offline" (disabled button with tooltip).
|
||||
func canStartIfOnline(r *model.Run) bool {
|
||||
if r == nil {
|
||||
return true
|
||||
}
|
||||
return r.State.IsTerminal()
|
||||
}
|
||||
|
||||
// canCancel is true for any non-terminal run, plus FailedHolding —
|
||||
// a held run technically classifies as terminal for the pipeline but
|
||||
// the host is still live on the SSH hold prompt, and the operator
|
||||
@@ -326,7 +269,9 @@ func tileMood(r *model.Run) string {
|
||||
return "pass"
|
||||
case model.StateFailed, model.StateFailedHolding:
|
||||
return "fail"
|
||||
case model.StateReleased, model.StateCancelled:
|
||||
case model.StateCancelled:
|
||||
return "cancelled"
|
||||
case model.StateReleased:
|
||||
return "idle"
|
||||
}
|
||||
return "active"
|
||||
|
||||
@@ -40,17 +40,15 @@ func TestHumanAgoFrom(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestHostTile_OverlayLink asserts the tile includes the tile-link <a>
|
||||
// that makes the whole card clickable. The action button stays a
|
||||
// sibling element, so CSS (z-index) keeps it on top of the overlay.
|
||||
//
|
||||
// Heartbeat must be fresh because canStart now gates on LastSeenAt —
|
||||
// an offline host renders a disabled button (no form), which is
|
||||
// covered by TestHostTile_DisabledStartWhenOffline below.
|
||||
// that makes the whole card clickable, and that the dashboard tile is
|
||||
// stripped down to just hostname + last-seen badge — no action controls
|
||||
// or run-state UI clogging the dashboard at scale.
|
||||
func TestHostTile_OverlayLink(t *testing.T) {
|
||||
now := time.Now()
|
||||
data := TileData{
|
||||
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||
LastSeenAt: &now,
|
||||
Latest: &model.Run{State: model.StateCompleted},
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||
@@ -63,39 +61,22 @@ func TestHostTile_OverlayLink(t *testing.T) {
|
||||
if !strings.Contains(html, `class="tile-link"`) {
|
||||
t.Fatalf("tile missing tile-link class: %s", html)
|
||||
}
|
||||
// Fresh heartbeat + no run → Start form must render.
|
||||
if !strings.Contains(html, `/hosts/42/start`) {
|
||||
t.Fatalf("expected Start vetting form in tile: %s", html)
|
||||
}
|
||||
// Dropped content that used to live on the tile — confirm it has
|
||||
// actually moved off so the slim-down is real.
|
||||
for _, dropped := range []string{`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`} {
|
||||
// actually moved off so the slim-down is real. tile-status is
|
||||
// intentionally re-added as a minimal status badge (issue #11).
|
||||
for _, dropped := range []string{
|
||||
`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`,
|
||||
`tile-primary-action`, `tile-start-form`,
|
||||
`tile-nd-toggle`, `tile-cancel-form`,
|
||||
`/hosts/42/start`, `/hosts/42/cancel`,
|
||||
`Start vetting`, `Non-destructive`, `Cancel run`, `View report`,
|
||||
} {
|
||||
if strings.Contains(html, dropped) {
|
||||
t.Errorf("slim tile still contains dropped class %q", dropped)
|
||||
t.Errorf("slim tile still contains dropped content %q", dropped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostTile_DisabledStartWhenOffline: no heartbeat → disabled button
|
||||
// with the quick.sh tooltip, not a submittable form. Mirrors the
|
||||
// server-side StartRun 409 so the UI matches the handler.
|
||||
func TestHostTile_DisabledStartWhenOffline(t *testing.T) {
|
||||
data := TileData{
|
||||
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
html := buf.String()
|
||||
if strings.Contains(html, `/hosts/42/start`) {
|
||||
t.Fatalf("offline host should not expose a Start form: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `disabled`) || !strings.Contains(html, `quick.sh`) {
|
||||
t.Fatalf("expected disabled Start button with quick.sh tooltip: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostTile_NoStageStrip: the tile no longer carries the Phase 3
|
||||
// per-stage mini run-view — the runs-table on /hosts/{id} owns the
|
||||
// stage-strip now. Guards against the regression that would bring
|
||||
@@ -149,10 +130,10 @@ func TestTileStatusCancelledFromHold(t *testing.T) {
|
||||
wantMood: "fail",
|
||||
},
|
||||
{
|
||||
name: "mid-stage cancel stays plain cancelled",
|
||||
name: "mid-stage cancel gets cancelled mood",
|
||||
run: &model.Run{State: model.StateCancelled},
|
||||
wantStatus: "Cancelled",
|
||||
wantMood: "idle",
|
||||
wantMood: "cancelled",
|
||||
},
|
||||
{
|
||||
name: "failed-holding itself still reads as FailedHolding",
|
||||
|
||||
@@ -15,12 +15,12 @@ templ Layout(title string) {
|
||||
<body hx-boost="true">
|
||||
<header class="topbar">
|
||||
<div class="brand">Vetting</div>
|
||||
<nav>
|
||||
<nav data-nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/hosts/new">Register host</a>
|
||||
</nav>
|
||||
<div class="session">
|
||||
<span class="heartbeat" hx-ext="sse" sse-connect="/events" sse-swap="heartbeat">·</span>
|
||||
<span class="heartbeat" hx-ext="sse" sse-connect="/events" sse-swap="heartbeat" title="Server connection">·</span>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -42,7 +42,7 @@ func Layout(title string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script><script src=\"/static/app.js\" defer></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script><script src=\"/static/app.js\" defer></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav data-nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\" title=\"Server connection\">·</span></div></header><main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package templates
|
||||
|
||||
templ NotFound() {
|
||||
@Layout("Not found") {
|
||||
<section class="not-found">
|
||||
<h1>Page not found</h1>
|
||||
<p>The host or run you're looking for doesn't exist or has been deleted.</p>
|
||||
<a class="button" href="/">Back to dashboard</a>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func NotFound() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"not-found\"><h1>Page not found</h1><p>The host or run you're looking for doesn't exist or has been deleted.</p><a class=\"button\" href=\"/\">Back to dashboard</a></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = Layout("Not found").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -223,33 +223,12 @@ func stageStateByName(name string) (model.RunState, bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
|
||||
// string when the node hasn't started or hasn't finished.
|
||||
func stageDuration(n PipelineNode) string {
|
||||
if n.StartedAt == nil {
|
||||
if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if n.CompletedAt != nil {
|
||||
end = *n.CompletedAt
|
||||
}
|
||||
d := end.Sub(*n.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// stageDisplayName turns the internal single-word state/stage identifier
|
||||
// into a human-readable label by inserting spaces before interior capital
|
||||
|
||||
@@ -231,33 +231,12 @@ func stageStateByName(name string) (model.RunState, bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
|
||||
// string when the node hasn't started or hasn't finished.
|
||||
func stageDuration(n PipelineNode) string {
|
||||
if n.StartedAt == nil {
|
||||
if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if n.CompletedAt != nil {
|
||||
end = *n.CompletedAt
|
||||
}
|
||||
d := end.Sub(*n.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// stageDisplayName turns the internal single-word state/stage identifier
|
||||
// into a human-readable label by inserting spaces before interior capital
|
||||
@@ -406,7 +385,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 307, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 286, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -419,7 +398,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDisplayName(n.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 308, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 287, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -432,7 +411,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 309, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 288, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -486,7 +465,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 324, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 303, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -499,7 +478,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 326, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 305, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -25,13 +25,15 @@ templ Registration(form RegistrationForm) {
|
||||
<section class="detail-section quick-register">
|
||||
<h2>Quick register <span class="muted">(recommended)</span></h2>
|
||||
<p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p>
|
||||
<div class="copyable-wrap">
|
||||
<pre class="one-liner"><code>{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }</code></pre>
|
||||
<button type="button" class="copy-btn" data-copy-target="previousSibling">Copy</button>
|
||||
</div>
|
||||
<p class="muted">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p>
|
||||
</section>
|
||||
}
|
||||
<section class="detail-section manual-register-card">
|
||||
<details class="manual-register">
|
||||
<summary><h2>Register manually</h2></summary>
|
||||
<h2>Register manually</h2>
|
||||
<form method="post" action="/hosts" class="host-form">
|
||||
<label>
|
||||
Name
|
||||
@@ -64,7 +66,6 @@ templ Registration(form RegistrationForm) {
|
||||
<a class="button-secondary" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -76,32 +76,32 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
}
|
||||
}
|
||||
if form.QuickRegisterURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<section class=\"detail-section quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<section class=\"detail-section quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><div class=\"copyable-wrap\"><pre class=\"one-liner\"><code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 28, Col: 108}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 29, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><button type=\"button\" class=\"copy-btn\" data-copy-target=\"previousSibling\">Copy</button></div><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<section class=\"detail-section manual-register-card\"><details class=\"manual-register\"><summary><h2>Register manually</h2></summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<section class=\"detail-section manual-register-card\"><h2>Register manually</h2><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 40, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 42, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 44, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 49, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 51, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 53, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 127}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 58, Col: 127}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,13 +166,13 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 60, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 62, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\" class=\"btn-primary\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\" class=\"btn-primary\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></section></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -101,11 +101,11 @@ templ RunHeader(d RunPageData) {
|
||||
<div class="run-header-right">
|
||||
if canCancel(&d.Run) {
|
||||
if d.Run.State == model.StateFailedHolding {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" onsubmit="return confirm('Cancel held run? The host will reboot to local disk.');">
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" data-confirm="Cancel held run? The host will reboot to local disk.">
|
||||
<button type="submit" class="btn-danger">Cancel & reboot</button>
|
||||
</form>
|
||||
} else {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" data-confirm="Cancel run? Destructive stages may leave the host mid-operation.">
|
||||
<button type="submit" class="btn-danger">Cancel run</button>
|
||||
</form>
|
||||
}
|
||||
@@ -140,7 +140,10 @@ templ HoldBanner(d RunPageData) {
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<span class="hold-banner-label">Host is holding — SSH available:</span>
|
||||
<div class="copyable-wrap">
|
||||
<code class="hold-ssh">{ sshInvocation(d.HoldKeyPath, d.Run.HoldIP) }</code>
|
||||
<button type="button" class="copy-btn" data-copy-target="previousSibling">Copy</button>
|
||||
</div>
|
||||
</section>
|
||||
} else {
|
||||
<section
|
||||
@@ -164,7 +167,10 @@ templ RunSpecDiffs(d RunPageData) {
|
||||
>
|
||||
if len(d.SpecDiffs) > 0 {
|
||||
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
|
||||
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
|
||||
<summary>
|
||||
<h2>Spec diffs</h2>
|
||||
<span class={ "diff-badge", diffBadgeClass(d.SpecDiffs) }>{ fmt.Sprintf("%d", len(d.SpecDiffs)) }</span>
|
||||
</summary>
|
||||
<ul class="diff-list">
|
||||
for _, diff := range d.SpecDiffs {
|
||||
<li class={ "diff-row", "diff-" + diff.Severity }>
|
||||
|
||||
@@ -419,7 +419,7 @@ func RunHeader(d RunPageData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"inline\" onsubmit=\"return confirm('Cancel held run? The host will reboot to local disk.');\"><button type=\"submit\" class=\"btn-danger\">Cancel & reboot</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"inline\" data-confirm=\"Cancel held run? The host will reboot to local disk.\"><button type=\"submit\" class=\"btn-danger\">Cancel & reboot</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -437,7 +437,7 @@ func RunHeader(d RunPageData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"inline\" data-confirm=\"Cancel run? Destructive stages may leave the host mid-operation.\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -560,20 +560,20 @@ func HoldBanner(d RunPageData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span> <code class=\"hold-ssh\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span><div class=\"copyable-wrap\"><code class=\"hold-ssh\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 143, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 144, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code> <button type=\"button\" class=\"copy-btn\" data-copy-target=\"previousSibling\">Copy</button></div></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -585,7 +585,7 @@ func HoldBanner(d RunPageData) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 147, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 150, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -598,7 +598,7 @@ func HoldBanner(d RunPageData) templ.Component {
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 149, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 152, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -644,7 +644,7 @@ func RunSpecDiffs(d RunPageData) templ.Component {
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 163, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -657,7 +657,7 @@ func RunSpecDiffs(d RunPageData) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 162, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 165, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -678,92 +678,114 @@ func RunSpecDiffs(d RunPageData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><summary><h2>Spec diffs (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><summary><h2>Spec diffs</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 167, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
var templ_7745c5c3_Var36 = []any{"diff-badge", diffBadgeClass(d.SpecDiffs)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ")</h2></summary><ul class=\"diff-list\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, diff := range d.SpecDiffs {
|
||||
var templ_7745c5c3_Var37 = []any{"diff-row", "diff-" + diff.Severity}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var36).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<li class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var37).String())
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 172, Col: 100}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"><div class=\"diff-field\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span></summary><ul class=\"diff-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 171, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
for _, diff := range d.SpecDiffs {
|
||||
var templ_7745c5c3_Var39 = []any{"diff-row", "diff-" + diff.Severity}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div><div class=\"diff-expected\">expected: <code>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<li class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var39).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 172, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</code></div><div class=\"diff-actual\">actual: <code>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"><div class=\"diff-field\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 173, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 177, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</code></div></li>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div><div class=\"diff-expected\">expected: <code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 178, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</code></div><div class=\"diff-actual\">actual: <code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 179, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</code></div></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</ul></details>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</ul></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -4,38 +4,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// subStepDuration formats a sub-step's elapsed time the same way
|
||||
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||
func subStepDuration(ss model.SubStep) string {
|
||||
if ss.StartedAt == nil {
|
||||
if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if ss.CompletedAt != nil {
|
||||
end = *ss.CompletedAt
|
||||
}
|
||||
d := end.Sub(*ss.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||
// state badge. StageState values reused verbatim for sub-steps.
|
||||
|
||||
@@ -12,38 +12,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// subStepDuration formats a sub-step's elapsed time the same way
|
||||
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||
func subStepDuration(ss model.SubStep) string {
|
||||
if ss.StartedAt == nil {
|
||||
if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if ss.CompletedAt != nil {
|
||||
end = *ss.CompletedAt
|
||||
}
|
||||
d := end.Sub(*ss.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||
// state badge. StageState values reused verbatim for sub-steps.
|
||||
@@ -99,7 +77,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 63, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 41, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -125,7 +103,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 65, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 43, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -160,7 +138,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 68, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 46, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -173,7 +151,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 69, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 47, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -186,7 +164,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 70, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 48, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.1.0
|
||||
v0.1.1
|
||||
|
||||
Reference in New Issue
Block a user