Add host-mode heartbeat: vetting-agent host + last-seen badge
CI / Lint + build + test (push) Has been cancelled
CI / Lint + build + test (push) Has been cancelled
vetting-agent gains a `host` subcommand that runs as a systemd service
installed by the quick-register one-liner, POSTing every 30s to
/api/v1/hosts/{mac}/heartbeat so the dashboard tile shows "online" or
"Nm ago" without waiting on WoL. Ships dormant client code for the
Phase 2 reboot_for_vetting command so the server can flip it on later
without a binary redeploy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -67,8 +67,8 @@ run: orchestrator ## Build and run orchestrator with example config
|
||||
./bin/vetting$(if $(filter Windows%,$(UNAME_S)),.exe,) --config deploy/vetting.example.yaml
|
||||
|
||||
.PHONY: install
|
||||
install: orchestrator-linux ## Run deploy/install.sh (must be run on the target LXC as root)
|
||||
sudo ./deploy/install.sh --binary ./bin/vetting-linux-amd64
|
||||
install: orchestrator-linux agent-linux ## Run deploy/install.sh (must be run on the target LXC as root)
|
||||
sudo ./deploy/install.sh --binary ./bin/vetting-linux-amd64 --agent-binary ./bin/vetting-agent.linux-amd64
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Remove build artifacts
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package hostmode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// setPXEBootNext points the next boot at a PXE-capable BootOrder
|
||||
// entry via efibootmgr --bootnext. Best-effort: absent efibootmgr,
|
||||
// non-UEFI firmware, or zero PXE entries all fall through silently —
|
||||
// the operator's BIOS/DHCP chain will still PXE-boot on most hosts.
|
||||
func setPXEBootNext(ctx context.Context) {
|
||||
if _, err := os.Stat("/sys/firmware/efi"); err != nil {
|
||||
log.Printf("hostmode: not a UEFI system; skipping efibootmgr")
|
||||
return
|
||||
}
|
||||
bin, err := exec.LookPath("efibootmgr")
|
||||
if err != nil {
|
||||
log.Printf("hostmode: efibootmgr not installed; skipping")
|
||||
return
|
||||
}
|
||||
boots, err := exec.CommandContext(ctx, bin, "-v").Output()
|
||||
if err != nil {
|
||||
log.Printf("hostmode: efibootmgr -v: %v", err)
|
||||
return
|
||||
}
|
||||
num := findPXEBootNum(string(boots))
|
||||
if num == "" {
|
||||
log.Printf("hostmode: no PXE boot entry found")
|
||||
return
|
||||
}
|
||||
if err := exec.CommandContext(ctx, bin, "--bootnext", num).Run(); err != nil {
|
||||
log.Printf("hostmode: efibootmgr --bootnext %s: %v", num, err)
|
||||
return
|
||||
}
|
||||
log.Printf("hostmode: efibootmgr --bootnext %s", num)
|
||||
}
|
||||
|
||||
// findPXEBootNum picks the first BootXXXX entry whose description
|
||||
// looks like a network boot. efibootmgr -v output lines look like:
|
||||
//
|
||||
// Boot0003* UEFI: IPv4 Intel I225-V PciRoot(0x0)/Pci(...)/MAC(...)
|
||||
// Boot0001* ubuntu HD(1,GPT,...)/File(\EFI\ubuntu\shimx64.efi)
|
||||
func findPXEBootNum(out string) string {
|
||||
scan := bufio.NewScanner(strings.NewReader(out))
|
||||
for scan.Scan() {
|
||||
line := scan.Text()
|
||||
if !strings.HasPrefix(line, "Boot") || len(line) < 8 {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(line)
|
||||
if !(strings.Contains(low, "pxe") ||
|
||||
strings.Contains(low, "ipv4") ||
|
||||
strings.Contains(low, "ipv6") ||
|
||||
strings.Contains(low, "network")) {
|
||||
continue
|
||||
}
|
||||
return line[4:8]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package hostmode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// heartbeatResponse is what the orchestrator sends back.
|
||||
// Phase 1 only populates Ok. Phase 2 adds Cmd + RunID.
|
||||
type heartbeatResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
RunID int64 `json:"run_id,omitempty"`
|
||||
}
|
||||
|
||||
type hostClient struct {
|
||||
base string
|
||||
h *http.Client
|
||||
}
|
||||
|
||||
func newHostClient(base string) *hostClient {
|
||||
return &hostClient{
|
||||
base: base,
|
||||
h: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hostClient) heartbeat(ctx context.Context, mac string) (*heartbeatResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/hosts/%s/heartbeat", c.base, mac)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url,
|
||||
bytes.NewReader([]byte(`{}`)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.h.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var out heartbeatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package hostmode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// cmdRebootForVetting is the Phase 2 command the orchestrator sends
|
||||
// when the operator clicked "Start vetting" and the host is actively
|
||||
// heartbeating — the agent redirects next boot to PXE and reboots
|
||||
// itself, obviating WoL.
|
||||
const cmdRebootForVetting = "reboot_for_vetting"
|
||||
|
||||
// handleResponse dispatches on the heartbeat response. Phase 1 never
|
||||
// sees a non-empty Cmd (the server omits the field). Phase 2 adds
|
||||
// reboot_for_vetting handling.
|
||||
func handleResponse(ctx context.Context, resp *heartbeatResponse) {
|
||||
if resp == nil || resp.Cmd == "" {
|
||||
return
|
||||
}
|
||||
switch resp.Cmd {
|
||||
case cmdRebootForVetting:
|
||||
log.Printf("hostmode: orchestrator requested reboot_for_vetting (run=%d)", resp.RunID)
|
||||
rebootForVetting(ctx)
|
||||
default:
|
||||
log.Printf("hostmode: unknown cmd %q, ignoring", resp.Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// rebootForVetting redirects next boot to PXE (best-effort on UEFI
|
||||
// via efibootmgr) and triggers a clean reboot. BIOS/legacy hosts
|
||||
// typically PXE-boot via DHCP chain on every boot, so efibootmgr
|
||||
// missing is non-fatal.
|
||||
func rebootForVetting(ctx context.Context) {
|
||||
setPXEBootNext(ctx)
|
||||
log.Printf("hostmode: executing systemctl reboot")
|
||||
if err := exec.CommandContext(ctx, "systemctl", "reboot").Run(); err != nil {
|
||||
log.Printf("hostmode: systemctl reboot failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package hostmode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// primaryMAC resolves the MAC of the iface that carries the default
|
||||
// IPv4 route. Mirrors quick.sh.tmpl's primary_iface so the agent
|
||||
// reports the same MAC that was registered (important on Proxmox
|
||||
// where vmbr0 inherits its physical NIC's MAC).
|
||||
func primaryMAC() (string, error) {
|
||||
iface, err := defaultRouteIface()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := os.ReadFile(fmt.Sprintf("/sys/class/net/%s/address", iface))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read mac for %s: %w", iface, err)
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(string(raw))), nil
|
||||
}
|
||||
|
||||
// defaultRouteIface shells out to `ip` because reading /proc/net/route
|
||||
// requires hex-swap logic and still misses the IPv4-only "dev"
|
||||
// qualification. The service runs as root on a Linux box; `ip` is
|
||||
// always present.
|
||||
func defaultRouteIface() (string, error) {
|
||||
out, err := exec.Command("ip", "-o", "-4", "route", "show", "default").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ip route: %w", err)
|
||||
}
|
||||
scan := bufio.NewScanner(strings.NewReader(string(out)))
|
||||
for scan.Scan() {
|
||||
fields := strings.Fields(scan.Text())
|
||||
for i, f := range fields {
|
||||
if f == "dev" && i+1 < len(fields) {
|
||||
return fields[i+1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("no default IPv4 route")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package hostmode implements the "persistent reporter" mode of
|
||||
// vetting-agent. It runs as a systemd service on the host (not in
|
||||
// the live image), heartbeats to the orchestrator every ~30s, and
|
||||
// in Phase 2 accepts commands — most importantly reboot-for-vetting.
|
||||
package hostmode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config mirrors /etc/vetting/host-agent.yaml. All fields are
|
||||
// optional except OrchestratorURL — the rest have reasonable
|
||||
// defaults so a single `orchestrator_url:` line works.
|
||||
type Config struct {
|
||||
OrchestratorURL string `yaml:"orchestrator_url"`
|
||||
MAC string `yaml:"mac,omitempty"`
|
||||
Interval time.Duration `yaml:"-"`
|
||||
IntervalRaw string `yaml:"interval,omitempty"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
c.OrchestratorURL = strings.TrimRight(strings.TrimSpace(c.OrchestratorURL), "/")
|
||||
if c.OrchestratorURL == "" {
|
||||
return nil, errors.New("orchestrator_url is required")
|
||||
}
|
||||
c.MAC = strings.ToLower(strings.TrimSpace(c.MAC))
|
||||
if c.IntervalRaw == "" {
|
||||
c.Interval = 30 * time.Second
|
||||
} else {
|
||||
d, err := time.ParseDuration(c.IntervalRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse interval: %w", err)
|
||||
}
|
||||
if d < time.Second {
|
||||
return nil, fmt.Errorf("interval %s is too aggressive", d)
|
||||
}
|
||||
c.Interval = d
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled, heartbeating on an interval.
|
||||
// Errors never abort the loop — the service is `Restart=on-failure`
|
||||
// in systemd, and a transient HTTP failure is not a reason to exit.
|
||||
func Run(ctx context.Context, cfgPath string) error {
|
||||
cfg, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.MAC == "" {
|
||||
mac, err := primaryMAC()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve primary MAC: %w", err)
|
||||
}
|
||||
cfg.MAC = mac
|
||||
}
|
||||
log.Printf("hostmode: reporting to %s as %s every %s",
|
||||
cfg.OrchestratorURL, cfg.MAC, cfg.Interval)
|
||||
|
||||
client := newHostClient(cfg.OrchestratorURL)
|
||||
|
||||
// Fire one heartbeat immediately so the dashboard lights up on
|
||||
// service start, without waiting for the first tick.
|
||||
tick(ctx, client, cfg)
|
||||
|
||||
t := time.NewTicker(cfg.Interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
tick(ctx, client, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tick(ctx context.Context, c *hostClient, cfg *Config) {
|
||||
resp, err := c.heartbeat(ctx, cfg.MAC)
|
||||
if err != nil {
|
||||
log.Printf("hostmode: heartbeat: %v", err)
|
||||
return
|
||||
}
|
||||
handleResponse(ctx, resp)
|
||||
}
|
||||
+30
-10
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
@@ -10,18 +11,10 @@ import (
|
||||
|
||||
"vetting/agent"
|
||||
"vetting/agent/bootstate"
|
||||
"vetting/agent/hostmode"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmdlinePath := flag.String("cmdline", "/proc/cmdline", "path to kernel cmdline (override for local testing)")
|
||||
flag.Parse()
|
||||
|
||||
p, err := bootstate.ParseCmdline(*cmdlinePath)
|
||||
if err != nil {
|
||||
log.Fatalf("bootstate: %v", err)
|
||||
}
|
||||
log.Printf("vetting-agent starting: run=%d mac=%s orchestrator=%s", p.RunID, p.MAC, p.OrchestratorURL)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -33,7 +26,34 @@ func main() {
|
||||
cancel()
|
||||
}()
|
||||
|
||||
if err := agent.Run(ctx, p); err != nil && err != context.Canceled {
|
||||
// `vetting-agent host` = persistent reporter (systemd service on
|
||||
// the installed host). No-arg = live-image agent that parses the
|
||||
// boot cmdline — keeping the default preserves PXE/initrd scripts.
|
||||
if len(os.Args) >= 2 && os.Args[1] == "host" {
|
||||
runHost(ctx, os.Args[2:])
|
||||
return
|
||||
}
|
||||
runLive(ctx)
|
||||
}
|
||||
|
||||
func runLive(ctx context.Context) {
|
||||
cmdlinePath := flag.String("cmdline", "/proc/cmdline", "path to kernel cmdline (override for local testing)")
|
||||
flag.Parse()
|
||||
p, err := bootstate.ParseCmdline(*cmdlinePath)
|
||||
if err != nil {
|
||||
log.Fatalf("bootstate: %v", err)
|
||||
}
|
||||
log.Printf("vetting-agent starting: run=%d mac=%s orchestrator=%s", p.RunID, p.MAC, p.OrchestratorURL)
|
||||
if err := agent.Run(ctx, p); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Fatalf("agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runHost(ctx context.Context, args []string) {
|
||||
fs := flag.NewFlagSet("host", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "/etc/vetting/host-agent.yaml", "path to host-agent.yaml")
|
||||
_ = fs.Parse(args)
|
||||
if err := hostmode.Run(ctx, *cfgPath); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Fatalf("hostmode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ func main() {
|
||||
UI: ui,
|
||||
Agent: agentAPI,
|
||||
LiveDir: cfg.PXE.LiveDir,
|
||||
AgentAssetDir: cfg.Agent.AssetDir,
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
+22
-1
@@ -25,18 +25,23 @@
|
||||
set -euo pipefail
|
||||
|
||||
BINARY=""
|
||||
AGENT_BINARY=""
|
||||
CONFIG_DIR="/etc/vetting"
|
||||
STATE_DIR="/var/lib/vetting"
|
||||
LOG_DIR="/var/log/vetting"
|
||||
ASSET_DIR="/var/lib/vetting/assets"
|
||||
SERVICE_USER="vetting"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [--binary PATH] [--config-dir DIR]
|
||||
Usage: $0 [--binary PATH] [--agent-binary PATH] [--config-dir DIR]
|
||||
|
||||
--binary PATH Path to a pre-built vetting binary (default:
|
||||
auto-detect ../bin/vetting-linux-amd64 relative to
|
||||
this script).
|
||||
--agent-binary PATH Path to a pre-built vetting-agent linux-amd64 binary
|
||||
served at /assets/vetting-agent-linux-amd64 for the
|
||||
quick-register one-liner (default: auto-detect).
|
||||
--config-dir DIR Where to install vetting.yaml + systemd unit drop
|
||||
(default: /etc/vetting).
|
||||
-h, --help Print this message.
|
||||
@@ -46,6 +51,7 @@ EOF
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--binary) BINARY="$2"; shift 2 ;;
|
||||
--agent-binary) AGENT_BINARY="$2"; shift 2 ;;
|
||||
--config-dir) CONFIG_DIR="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
@@ -73,6 +79,19 @@ if [[ -z "${BINARY}" || ! -x "${BINARY}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${AGENT_BINARY}" ]]; then
|
||||
for cand in \
|
||||
"${REPO_ROOT}/bin/vetting-agent.linux-amd64" \
|
||||
"${REPO_ROOT}/bin/vetting-agent-linux-amd64" \
|
||||
"${SCRIPT_DIR}/vetting-agent-linux-amd64"; do
|
||||
if [[ -x "${cand}" ]]; then AGENT_BINARY="${cand}"; break; fi
|
||||
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
|
||||
fi
|
||||
|
||||
echo "==> installing runtime dependencies"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
@@ -90,10 +109,12 @@ fi
|
||||
echo "==> 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}"
|
||||
|
||||
echo "==> installing binary"
|
||||
install -m 0755 "${BINARY}" /usr/local/bin/vetting
|
||||
install -m 0755 "${AGENT_BINARY}" "${ASSET_DIR}/vetting-agent-linux-amd64"
|
||||
|
||||
echo "==> installing config and systemd unit"
|
||||
# vetting.production.yaml uses absolute /var/lib/vetting + /var/log/vetting
|
||||
|
||||
@@ -41,6 +41,13 @@ pxe:
|
||||
tftp_root: "" # holds ipxe.efi + undionly.kpxe
|
||||
live_dir: "" # holds vmlinuz + initrd.img; served at /live/*
|
||||
|
||||
agent:
|
||||
# Directory containing vetting-agent-linux-amd64. The quick-register
|
||||
# one-liner downloads from /assets/vetting-agent-linux-amd64 and
|
||||
# installs it as a systemd service so the host keeps heartbeating.
|
||||
# Leave empty to disable the /assets/* route.
|
||||
asset_dir: "./var/assets"
|
||||
|
||||
# Notifications fire on StageFailed, SpecMismatch, HoldingOpened,
|
||||
# RunCompleted. Declare one or more notifiers and route each event
|
||||
# kind (and optionally severity) to a notifier by name. Delivery is
|
||||
|
||||
@@ -39,6 +39,11 @@ pxe:
|
||||
tftp_root: "/var/lib/vetting/tftp" # holds ipxe.efi + undionly.kpxe
|
||||
live_dir: "/var/lib/vetting/live" # holds vmlinuz + initrd.img; served at /live/*
|
||||
|
||||
agent:
|
||||
# Directory holding vetting-agent-linux-amd64, served at
|
||||
# /assets/vetting-agent-linux-amd64. install.sh drops the binary here.
|
||||
asset_dir: "/var/lib/vetting/assets"
|
||||
|
||||
# Notifications fire on StageFailed, SpecMismatch, HoldingOpened,
|
||||
# RunCompleted. Declare one or more notifiers and route each event
|
||||
# kind (and optionally severity) to a notifier by name. Delivery is
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"vetting/internal/api"
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// setupHeartbeat wires just enough of UI to exercise the heartbeat
|
||||
// handler. Runner is left nil — the handler no-ops the SSE publish in
|
||||
// that case, which matches "tests don't assert on SSE" (covered by
|
||||
// integration-style runner tests).
|
||||
func setupHeartbeat(t *testing.T) (*api.UI, *store.Hosts) {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
hosts := &store.Hosts{DB: conn}
|
||||
return &api.UI{Hosts: hosts}, hosts
|
||||
}
|
||||
|
||||
func heartbeatReq(mac string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/hosts/"+mac+"/heartbeat", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("mac", mac)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestUIHeartbeat_Success(t *testing.T) {
|
||||
ui, hosts := setupHeartbeat(t)
|
||||
id, err := hosts.Create(context.Background(), model.Host{
|
||||
Name: "hb-host",
|
||||
MAC: "aa:bb:cc:dd:ee:10",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
before := time.Now().UTC().Add(-time.Second)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.Heartbeat(rr, heartbeatReq("aa:bb:cc:dd:ee:10"))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["ok"] != true {
|
||||
t.Fatalf("response = %v, want ok:true", resp)
|
||||
}
|
||||
|
||||
got, err := hosts.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.LastSeenAt == nil {
|
||||
t.Fatalf("LastSeenAt not stamped")
|
||||
}
|
||||
if got.LastSeenAt.Before(before) {
|
||||
t.Fatalf("LastSeenAt = %v, want >= %v", got.LastSeenAt, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIHeartbeat_UnknownMAC(t *testing.T) {
|
||||
ui, _ := setupHeartbeat(t)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.Heartbeat(rr, heartbeatReq("aa:bb:cc:dd:ee:ff"))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp["error"] == "" {
|
||||
t.Fatalf("missing error body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIHeartbeat_BadMAC(t *testing.T) {
|
||||
ui, _ := setupHeartbeat(t)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.Heartbeat(rr, heartbeatReq("not-a-mac"))
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ type TileEnricher struct {
|
||||
// fall back to a tile without the extra fields rather than breaking
|
||||
// the whole dashboard.
|
||||
func (e *TileEnricher) Build(ctx context.Context, host model.Host, latest *model.Run) templates.TileData {
|
||||
t := templates.TileData{Host: host, Latest: latest}
|
||||
t := templates.TileData{Host: host, Latest: latest, LastSeenAt: host.LastSeenAt}
|
||||
if latest == nil {
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -240,6 +241,37 @@ func (u *UI) CreateHostJSON(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Heartbeat is called every ~30s by a host-mode vetting-agent running
|
||||
// as a systemd service on the registered host. LAN-trusted, no auth —
|
||||
// same threat model as the browser UI and quick-register. Phase 1
|
||||
// just stamps last_seen_at and flips the dashboard tile to "online".
|
||||
func (u *UI) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
mac := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "mac")))
|
||||
if !macRe.MatchString(mac) {
|
||||
writeJSONError(w, http.StatusBadRequest,
|
||||
"MAC address must be in the form aa:bb:cc:dd:ee:ff")
|
||||
return
|
||||
}
|
||||
host, err := u.Hosts.GetByMAC(r.Context(), mac)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, http.StatusNotFound, "unknown host")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := u.Hosts.UpdateLastSeen(r.Context(), mac, time.Now().UTC()); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if u.Runner != nil {
|
||||
u.Runner.PublishTileUpdate(r.Context(), host.ID)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
Janitor Janitor `yaml:"janitor"`
|
||||
PXE PXE `yaml:"pxe"`
|
||||
Network Network `yaml:"network"`
|
||||
Agent Agent `yaml:"agent"`
|
||||
Notifiers []Notifier `yaml:"notifiers"`
|
||||
Routes []Route `yaml:"routes"`
|
||||
}
|
||||
@@ -70,6 +71,14 @@ type PXE struct {
|
||||
LiveDir string `yaml:"live_dir"` // holds vmlinuz + initrd.img; served at /live
|
||||
}
|
||||
|
||||
// Agent holds settings related to the host-mode vetting-agent binary
|
||||
// that operators install on their hosts. AssetDir is served at
|
||||
// /assets/*, which is where the quick-register script downloads
|
||||
// `vetting-agent-linux-amd64` from.
|
||||
type Agent struct {
|
||||
AssetDir string `yaml:"asset_dir"` // directory containing vetting-agent-linux-amd64; "" disables /assets
|
||||
}
|
||||
|
||||
type Notifier struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Persistent host-mode agent heartbeats land here. NULL = never seen.
|
||||
ALTER TABLE hosts ADD COLUMN last_seen_at TIMESTAMP;
|
||||
@@ -18,6 +18,7 @@ type Deps struct {
|
||||
UI *api.UI
|
||||
Agent *api.Agent
|
||||
LiveDir string // directory containing vmlinuz + initrd.img; "" disables /live
|
||||
AgentAssetDir string // directory containing vetting-agent-linux-amd64; "" disables /assets
|
||||
}
|
||||
|
||||
func NewRouter(d Deps) http.Handler {
|
||||
@@ -36,6 +37,12 @@ func NewRouter(d Deps) http.Handler {
|
||||
r.Handle("/live/*", http.StripPrefix("/live/", http.FileServer(http.Dir(d.LiveDir))))
|
||||
}
|
||||
|
||||
// Host-mode agent binary is served here so the quick-register
|
||||
// one-liner can curl it without the operator pre-staging anything.
|
||||
if d.AgentAssetDir != "" {
|
||||
r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.Dir(d.AgentAssetDir))))
|
||||
}
|
||||
|
||||
// Agent / PXE endpoints — authenticated per-request by bearer token
|
||||
// or by the unforgeable MAC path parameter.
|
||||
r.Get("/ipxe/{mac}", d.Agent.IPXEScript)
|
||||
@@ -54,6 +61,10 @@ func NewRouter(d Deps) http.Handler {
|
||||
// as the browser UI.
|
||||
r.Post("/api/v1/hosts", d.UI.CreateHostJSON)
|
||||
|
||||
// Host-mode agent heartbeat. Keyed by MAC (no bearer token), same
|
||||
// LAN-trust model as /api/v1/hosts.
|
||||
r.Post("/api/v1/hosts/{mac}/heartbeat", d.UI.Heartbeat)
|
||||
|
||||
// Browser UI — no auth; bind to loopback or LAN only, or front
|
||||
// with a reverse proxy if you need a password.
|
||||
r.Get("/", d.UI.Dashboard)
|
||||
|
||||
@@ -14,6 +14,7 @@ type Host struct {
|
||||
Notes string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastSeenAt *time.Time // host-mode agent heartbeat; nil = never seen
|
||||
}
|
||||
|
||||
type RunState string
|
||||
|
||||
@@ -50,6 +50,13 @@ func (r *Runner) StartStage(ctx context.Context, runID int64, name string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishTileUpdate is the exported entry point for non-orchestrator
|
||||
// callers (the UI heartbeat handler) that change tile-visible state
|
||||
// without going through Transition.
|
||||
func (r *Runner) PublishTileUpdate(ctx context.Context, hostID int64) {
|
||||
r.publishTileUpdate(ctx, hostID)
|
||||
}
|
||||
|
||||
func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
|
||||
host, err := r.Hosts.Get(ctx, hostID)
|
||||
if err != nil {
|
||||
|
||||
+59
-12
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
@@ -16,6 +17,26 @@ type Hosts struct {
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
const hostColumns = `id, name, mac, wol_broadcast_ip, wol_port, expected_spec_yaml,
|
||||
COALESCE(pdu_config_json,''), COALESCE(ipmi_config_json,''),
|
||||
notes, created_at, updated_at, last_seen_at`
|
||||
|
||||
func scanHost(row interface {
|
||||
Scan(dest ...any) error
|
||||
}, h *model.Host) error {
|
||||
var lastSeen sql.NullTime
|
||||
if err := row.Scan(&h.ID, &h.Name, &h.MAC, &h.WoLBroadcastIP, &h.WoLPort,
|
||||
&h.ExpectedSpecYAML, &h.PDUConfigJSON, &h.IPMIConfigJSON,
|
||||
&h.Notes, &h.CreatedAt, &h.UpdatedAt, &lastSeen); err != nil {
|
||||
return err
|
||||
}
|
||||
if lastSeen.Valid {
|
||||
t := lastSeen.Time
|
||||
h.LastSeenAt = &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hosts) Create(ctx context.Context, in model.Host) (int64, error) {
|
||||
in.MAC = normalizeMAC(in.MAC)
|
||||
res, err := h.DB.ExecContext(ctx, `
|
||||
@@ -30,9 +51,7 @@ func (h *Hosts) Create(ctx context.Context, in model.Host) (int64, error) {
|
||||
|
||||
func (h *Hosts) List(ctx context.Context) ([]model.Host, error) {
|
||||
rows, err := h.DB.QueryContext(ctx, `
|
||||
SELECT id, name, mac, wol_broadcast_ip, wol_port, expected_spec_yaml,
|
||||
COALESCE(pdu_config_json,''), COALESCE(ipmi_config_json,''),
|
||||
notes, created_at, updated_at
|
||||
SELECT `+hostColumns+`
|
||||
FROM hosts
|
||||
ORDER BY name COLLATE NOCASE
|
||||
`)
|
||||
@@ -44,9 +63,7 @@ func (h *Hosts) List(ctx context.Context) ([]model.Host, error) {
|
||||
var out []model.Host
|
||||
for rows.Next() {
|
||||
var host model.Host
|
||||
if err := rows.Scan(&host.ID, &host.Name, &host.MAC, &host.WoLBroadcastIP, &host.WoLPort,
|
||||
&host.ExpectedSpecYAML, &host.PDUConfigJSON, &host.IPMIConfigJSON,
|
||||
&host.Notes, &host.CreatedAt, &host.UpdatedAt); err != nil {
|
||||
if err := scanHost(rows, &host); err != nil {
|
||||
return nil, fmt.Errorf("scan host: %w", err)
|
||||
}
|
||||
out = append(out, host)
|
||||
@@ -56,15 +73,11 @@ func (h *Hosts) List(ctx context.Context) ([]model.Host, error) {
|
||||
|
||||
func (h *Hosts) Get(ctx context.Context, id int64) (*model.Host, error) {
|
||||
row := h.DB.QueryRowContext(ctx, `
|
||||
SELECT id, name, mac, wol_broadcast_ip, wol_port, expected_spec_yaml,
|
||||
COALESCE(pdu_config_json,''), COALESCE(ipmi_config_json,''),
|
||||
notes, created_at, updated_at
|
||||
SELECT `+hostColumns+`
|
||||
FROM hosts WHERE id = ?
|
||||
`, id)
|
||||
var host model.Host
|
||||
err := row.Scan(&host.ID, &host.Name, &host.MAC, &host.WoLBroadcastIP, &host.WoLPort,
|
||||
&host.ExpectedSpecYAML, &host.PDUConfigJSON, &host.IPMIConfigJSON,
|
||||
&host.Notes, &host.CreatedAt, &host.UpdatedAt)
|
||||
err := scanHost(row, &host)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -74,6 +87,40 @@ func (h *Hosts) Get(ctx context.Context, id int64) (*model.Host, error) {
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// GetByMAC looks up a host by its normalized MAC. Used by the host-mode
|
||||
// heartbeat endpoint, which only has a MAC to go on.
|
||||
func (h *Hosts) GetByMAC(ctx context.Context, mac string) (*model.Host, error) {
|
||||
row := h.DB.QueryRowContext(ctx, `
|
||||
SELECT `+hostColumns+`
|
||||
FROM hosts WHERE mac = ?
|
||||
`, normalizeMAC(mac))
|
||||
var host model.Host
|
||||
err := scanHost(row, &host)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get host by mac: %w", err)
|
||||
}
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// UpdateLastSeen stamps the host row with the most recent heartbeat.
|
||||
// Targeted UPDATE so it doesn't race with UI edits of other fields.
|
||||
func (h *Hosts) UpdateLastSeen(ctx context.Context, mac string, t time.Time) error {
|
||||
res, err := h.DB.ExecContext(ctx,
|
||||
`UPDATE hosts SET last_seen_at = ? WHERE mac = ?`,
|
||||
t.UTC(), normalizeMAC(mac))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update last_seen_at: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hosts) Delete(ctx context.Context, id int64) error {
|
||||
res, err := h.DB.ExecContext(ctx, `DELETE FROM hosts WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
func newHosts(t *testing.T) *store.Hosts {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return &store.Hosts{DB: conn}
|
||||
}
|
||||
|
||||
func TestHostsGetByMAC(t *testing.T) {
|
||||
hosts := newHosts(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "mac-host",
|
||||
MAC: "AA:BB:CC:DD:EE:01",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Lookup normalizes case — upper-case MAC resolves same row.
|
||||
got, err := hosts.GetByMAC(ctx, "Aa:Bb:Cc:Dd:Ee:01")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByMAC: %v", err)
|
||||
}
|
||||
if got.ID != id || got.Name != "mac-host" {
|
||||
t.Fatalf("wrong row: %+v", got)
|
||||
}
|
||||
if got.LastSeenAt != nil {
|
||||
t.Fatalf("LastSeenAt = %v, want nil on fresh host", got.LastSeenAt)
|
||||
}
|
||||
|
||||
if _, err := hosts.GetByMAC(ctx, "aa:bb:cc:dd:ee:99"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("GetByMAC unknown = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostsUpdateLastSeen(t *testing.T) {
|
||||
hosts := newHosts(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "ls-host",
|
||||
MAC: "aa:bb:cc:dd:ee:02",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
||||
Notes: "keep me",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
stamp := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)
|
||||
if err := hosts.UpdateLastSeen(ctx, "AA:BB:CC:DD:EE:02", stamp); err != nil {
|
||||
t.Fatalf("UpdateLastSeen: %v", err)
|
||||
}
|
||||
|
||||
got, err := hosts.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.LastSeenAt == nil || !got.LastSeenAt.Equal(stamp) {
|
||||
t.Fatalf("LastSeenAt = %v, want %v", got.LastSeenAt, stamp)
|
||||
}
|
||||
// Other fields untouched — targeted UPDATE must not stomp anything.
|
||||
if got.Name != "ls-host" || got.Notes != "keep me" || got.WoLPort != 9 {
|
||||
t.Fatalf("row damaged: %+v", got)
|
||||
}
|
||||
|
||||
// A second update advances the timestamp.
|
||||
later := stamp.Add(45 * time.Second)
|
||||
if err := hosts.UpdateLastSeen(ctx, got.MAC, later); err != nil {
|
||||
t.Fatalf("second UpdateLastSeen: %v", err)
|
||||
}
|
||||
got, _ = hosts.Get(ctx, id)
|
||||
if !got.LastSeenAt.Equal(later) {
|
||||
t.Fatalf("LastSeenAt not advanced: %v", got.LastSeenAt)
|
||||
}
|
||||
|
||||
// Unknown MAC is an error, not a silent no-op — a stale agent on a
|
||||
// re-registered box should complain loudly.
|
||||
if err := hosts.UpdateLastSeen(ctx, "aa:bb:cc:dd:ee:ff", later); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("UpdateLastSeen unknown = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@
|
||||
# WOL_PORT WoL UDP port (default: 9)
|
||||
# NOTES Free-text notes
|
||||
# ORCH_URL Override orchestrator base URL
|
||||
# INSTALL_AGENT 1=install vetting-reporter systemd service (default)
|
||||
# 0=skip the agent install (registration only)
|
||||
# Pass via: curl ... | sudo INSTALL_AGENT=0 bash
|
||||
set -euo pipefail
|
||||
|
||||
ORCH_URL="${ORCH_URL:-{{.OrchestratorURL}}}"
|
||||
@@ -175,5 +178,52 @@ resp="$(curl -fsS -X POST \
|
||||
-d "${payload}" \
|
||||
"${ORCH_URL}/api/v1/hosts")"
|
||||
echo "OK: ${resp}"
|
||||
|
||||
# --- Optional: install the vetting-reporter systemd service so the
|
||||
# host keeps heartbeating to the orchestrator long-term. Skipped when
|
||||
# INSTALL_AGENT=0 or when systemctl isn't present (non-systemd hosts).
|
||||
install_agent() {
|
||||
if [[ "${INSTALL_AGENT:-1}" == "0" ]]; then
|
||||
echo "Skipping agent install (INSTALL_AGENT=0)."
|
||||
return
|
||||
fi
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "systemctl not found — skipping agent install."
|
||||
return
|
||||
fi
|
||||
echo "Installing vetting-reporter service..."
|
||||
install -d /etc/vetting /usr/local/bin
|
||||
if ! curl -fsSL "${ORCH_URL}/assets/vetting-agent-linux-amd64" \
|
||||
-o /usr/local/bin/vetting-agent; then
|
||||
echo "WARN: could not download agent from ${ORCH_URL}/assets/vetting-agent-linux-amd64"
|
||||
echo "WARN: registration succeeded but the host won't heartbeat."
|
||||
return
|
||||
fi
|
||||
chmod +x /usr/local/bin/vetting-agent
|
||||
cat >/etc/vetting/host-agent.yaml <<YAML
|
||||
orchestrator_url: "${ORCH_URL}"
|
||||
mac: "${MAC}"
|
||||
interval: "30s"
|
||||
YAML
|
||||
cat >/etc/systemd/system/vetting-reporter.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Vetting host-mode reporter
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/vetting-agent host -config /etc/vetting/host-agent.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now vetting-reporter.service
|
||||
echo "vetting-reporter.service enabled."
|
||||
}
|
||||
install_agent
|
||||
|
||||
echo
|
||||
echo "Open ${ORCH_URL}/ and click 'Start vetting' on ${NAME}."
|
||||
|
||||
@@ -107,9 +107,30 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
||||
}
|
||||
.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);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.tile-last-seen::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
.tile-last-seen.online { color: var(--success); }
|
||||
.tile-last-seen.online::before { background: var(--success); }
|
||||
.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); }
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package templates
|
||||
|
||||
import "vetting/internal/model"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// TileData pairs a host with its latest run and the derived fields the
|
||||
// tile needs to render: spec-diff count (server-side diff result) and
|
||||
// the on-disk path to the hold-key artifact when the run is holding.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
||||
type TileData struct {
|
||||
Host model.Host
|
||||
Latest *model.Run
|
||||
SpecDiffCritical int
|
||||
HoldKeyPath string
|
||||
LastSeenAt *time.Time
|
||||
}
|
||||
|
||||
templ Dashboard(tiles []TileData) {
|
||||
|
||||
@@ -8,16 +8,22 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "vetting/internal/model"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// TileData pairs a host with its latest run and the derived fields the
|
||||
// tile needs to render: spec-diff count (server-side diff result) and
|
||||
// the on-disk path to the hold-key artifact when the run is holding.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
||||
type TileData struct {
|
||||
Host model.Host
|
||||
Latest *model.Run
|
||||
SpecDiffCritical int
|
||||
HoldKeyPath string
|
||||
LastSeenAt *time.Time
|
||||
}
|
||||
|
||||
func Dashboard(tiles []TileData) templ.Component {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
@@ -19,7 +20,10 @@ templ HostTile(t TileData) {
|
||||
>
|
||||
<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>
|
||||
<dl class="tile-meta">
|
||||
<div>
|
||||
@@ -142,3 +146,46 @@ func RenderTileString(t TileData) string {
|
||||
_ = HostTile(t).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// lastSeenLabel renders the host-mode agent's liveness into a short
|
||||
// badge: "never" if the host has never heartbeated, "online" within
|
||||
// a 2×heartbeat grace window (60s, since agents heartbeat every 30s),
|
||||
// "Nm ago" / "Nh ago" / "Nd ago" otherwise.
|
||||
func lastSeenLabel(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "never"
|
||||
}
|
||||
return humanAgoFrom(time.Now(), *t)
|
||||
}
|
||||
|
||||
// lastSeenClass pairs with lastSeenLabel to drive the badge color
|
||||
// without the template having to carry its own logic.
|
||||
func lastSeenClass(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "offline"
|
||||
}
|
||||
if time.Since(*t) < 60*time.Second {
|
||||
return "online"
|
||||
}
|
||||
return "stale"
|
||||
}
|
||||
|
||||
// humanAgoFrom formats (now - t) as a short "Nm ago" style string.
|
||||
// Buckets: <60s -> "online", <60m -> minutes, <24h -> hours, else days.
|
||||
// Split on `now` so callers can hold time for tests.
|
||||
func humanAgoFrom(now time.Time, t time.Time) string {
|
||||
d := now.Sub(t)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
if d < 60*time.Second {
|
||||
return "online"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm ago", int(d/time.Minute))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
@@ -51,7 +52,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: `host_tile.templ`, Line: 15, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 16, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -77,7 +78,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: `host_tile.templ`, Line: 17, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 18, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -90,228 +91,263 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 21, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 22, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"tile-status\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"tile-header-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 22, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></header><dl class=\"tile-meta\"><div><dt>MAC</dt><dd>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.MAC)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 27, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, "</dd></div><div><dt>WoL</dt><dd>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", t.Host.WoLBroadcastIP, t.Host.WoLPort))
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 31, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 24, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</dd></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if t.Latest != nil && t.Latest.FailedStage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div><dt>Failed at</dt><dd>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div class=\"tile-status\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(t.Latest.FailedStage)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 36, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 25, 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, 10, "</dd></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if t.SpecDiffCritical > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div><dt>Spec diffs</dt><dd class=\"bad\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></div></header><dl class=\"tile-meta\"><div><dt>MAC</dt><dd>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", t.SpecDiffCritical))
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.MAC)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 42, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 31, Col: 20}
|
||||
}
|
||||
_, 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, 12, "</dd></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</dl>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"tile-hold\"><div class=\"hold-title\">Host is holding — SSH available</div><code class=\"hold-ssh\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</dd></div><div><dt>WoL</dt><dd>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(t.HoldKeyPath, t.Latest.HoldIP))
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", t.Host.WoLBroadcastIP, t.Host.WoLPort))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 49, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 35, Col: 69}
|
||||
}
|
||||
_, 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, 15, "</code></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</dd></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if t.Latest != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"tile-log\" id=\"")
|
||||
if t.Latest != nil && t.Latest.FailedStage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div><dt>Failed at</dt><dd>")
|
||||
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", t.Latest.ID))
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t.Latest.FailedStage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 55, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 40, Col: 31}
|
||||
}
|
||||
_, 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, "\" sse-swap=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</dd></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if t.SpecDiffCritical > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div><dt>Spec diffs</dt><dd class=\"bad\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", t.Latest.ID))
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", t.SpecDiffCritical))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 56, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 46, Col: 69}
|
||||
}
|
||||
_, 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, 18, "\" hx-swap=\"beforeend\"></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</dd></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"tile-actions\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</dl>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canStart(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<form method=\"post\" action=\"")
|
||||
if t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"tile-hold\"><div class=\"hold-title\">Host is holding — SSH available</div><code class=\"hold-ssh\">")
|
||||
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("/hosts/%d/start", t.Host.ID)))
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(t.HoldKeyPath, t.Latest.HoldIP))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 62, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 53, Col: 74}
|
||||
}
|
||||
_, 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, 21, "\" class=\"inline\"><button type=\"submit\">Start vetting</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button type=\"button\" disabled>Run in flight</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</code></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if canOverrideWipe(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<form method=\"post\" action=\"")
|
||||
if t.Latest != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"tile-log\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 templ.SafeURL
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", t.Host.ID)))
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", t.Latest.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 69, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 59, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Override wipe-probe</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if hasReport(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<a class=\"button-like\" href=\"")
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", t.Latest.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, 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: `host_tile.templ`, Line: 74, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 60, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-swap=\"beforeend\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<form method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"tile-actions\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canStart(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 templ.SafeURL
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", t.Host.ID)))
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 76, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 66, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Delete</button></form></div></article>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"inline\"><button type=\"submit\">Start vetting</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<button type=\"button\" disabled>Run in flight</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if canOverrideWipe(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 templ.SafeURL
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 73, Col: 97}
|
||||
}
|
||||
_, 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, 27, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Override wipe-probe</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if hasReport(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"button-like\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 templ.SafeURL
|
||||
templ_7745c5c3_Var20, 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: `host_tile.templ`, Line: 78, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 templ.SafeURL
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 80, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Delete</button></form></div></article>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -382,4 +418,47 @@ func RenderTileString(t TileData) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// lastSeenLabel renders the host-mode agent's liveness into a short
|
||||
// badge: "never" if the host has never heartbeated, "online" within
|
||||
// a 2×heartbeat grace window (60s, since agents heartbeat every 30s),
|
||||
// "Nm ago" / "Nh ago" / "Nd ago" otherwise.
|
||||
func lastSeenLabel(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "never"
|
||||
}
|
||||
return humanAgoFrom(time.Now(), *t)
|
||||
}
|
||||
|
||||
// lastSeenClass pairs with lastSeenLabel to drive the badge color
|
||||
// without the template having to carry its own logic.
|
||||
func lastSeenClass(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "offline"
|
||||
}
|
||||
if time.Since(*t) < 60*time.Second {
|
||||
return "online"
|
||||
}
|
||||
return "stale"
|
||||
}
|
||||
|
||||
// humanAgoFrom formats (now - t) as a short "Nm ago" style string.
|
||||
// Buckets: <60s -> "online", <60m -> minutes, <24h -> hours, else days.
|
||||
// Split on `now` so callers can hold time for tests.
|
||||
func humanAgoFrom(now time.Time, t time.Time) string {
|
||||
d := now.Sub(t)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
if d < 60*time.Second {
|
||||
return "online"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm ago", int(d/time.Minute))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHumanAgoFrom(t *testing.T) {
|
||||
now := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
ago time.Duration
|
||||
want string
|
||||
}{
|
||||
{"just now", 5 * time.Second, "online"},
|
||||
{"edge-just-under-minute", 59 * time.Second, "online"},
|
||||
{"one minute", 60 * time.Second, "1m ago"},
|
||||
{"five minutes", 5 * time.Minute, "5m ago"},
|
||||
{"fifty-nine minutes", 59 * time.Minute, "59m ago"},
|
||||
{"one hour", 1 * time.Hour, "1h ago"},
|
||||
{"eight hours", 8 * time.Hour, "8h ago"},
|
||||
{"one day", 24 * time.Hour, "1d ago"},
|
||||
{"three days", 72 * time.Hour, "3d ago"},
|
||||
// Clock skew: "future" heartbeat clamps to "online" rather than
|
||||
// printing "-3m ago" or panicking.
|
||||
{"future clamps to online", -5 * time.Second, "online"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := humanAgoFrom(now, now.Add(-tc.ago))
|
||||
if got != tc.want {
|
||||
t.Fatalf("humanAgoFrom(%v) = %q, want %q", tc.ago, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastSeenLabelAndClass(t *testing.T) {
|
||||
if got := lastSeenLabel(nil); got != "never" {
|
||||
t.Fatalf("label nil = %q, want never", got)
|
||||
}
|
||||
if got := lastSeenClass(nil); got != "offline" {
|
||||
t.Fatalf("class nil = %q, want offline", got)
|
||||
}
|
||||
recent := time.Now().Add(-5 * time.Second)
|
||||
if got := lastSeenClass(&recent); got != "online" {
|
||||
t.Fatalf("class recent = %q, want online", got)
|
||||
}
|
||||
stale := time.Now().Add(-10 * time.Minute)
|
||||
if got := lastSeenClass(&stale); got != "stale" {
|
||||
t.Fatalf("class stale = %q, want stale", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user