OS updates host fast lane + true tunnel status + fast leg: wrapper host layer (R12 appliance proof from the root-owned install record, R14 kernel/boot/firmware refused), select pending-fast, one call per layer, host-side version checks, restart scan only after an install, reboot-needed for PID 1/lxc-start; the leg runs the host step after a healthy guest step; GuestTunnelProber reads the cloudflared container + its readiness check (R-841)
gates / gates (push) Successful in 19s
gates / gates (push) Successful in 19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+97
-31
@@ -2,45 +2,111 @@ package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
)
|
||||
|
||||
// CloudflaredProber reports the cloudflared tunnel service health. It is a
|
||||
// READ-ONLY probe: the agent does NOT manage or restart cloudflared in this slice
|
||||
// (that is the tunnel-management slice — this is the seam for it). Injectable so
|
||||
// tests use a fake and never exec.
|
||||
// Tunnel states the agent reports (R-841, agent v0.141.0). THREE, never two: a probe that could not ask is
|
||||
// `unknown`, which the hub never shows as up or down and never alarms on (R-96 rule 3).
|
||||
const (
|
||||
TunnelRunning = "running" // the cloudflared container runs AND its readiness check says CONNECTED
|
||||
TunnelNotRunning = "not_running" // stopped / exited / absent, or running but NOT connected (Detail says which)
|
||||
TunnelUnknown = "unknown" // the probe could not ask (guest down, pct/sudo error, health still starting)
|
||||
)
|
||||
|
||||
// CloudflaredProber reports the box's tunnel. Injectable so tests use a fake and never exec.
|
||||
type CloudflaredProber interface {
|
||||
// Status returns one of: "active" | "inactive" | "failed" | "unknown".
|
||||
Status(ctx context.Context) (string, error)
|
||||
// Status returns one of the Tunnel* states and a short detail (why not_running / why unknown).
|
||||
Status(ctx context.Context) (status, detail string)
|
||||
}
|
||||
|
||||
// SystemctlProber runs `systemctl is-active cloudflared`. This is NOT a Privileged
|
||||
// (root-CLI) op — `is-active` is non-root readable and is not one of the three
|
||||
// proven root exceptions, so it does not go through internal/proxmox.Privileged.
|
||||
type SystemctlProber struct {
|
||||
Unit string // defaults to "cloudflared"
|
||||
// GuestTunnelProber reads the REAL tunnel: the `cloudflared` container in the box's own customer guest.
|
||||
//
|
||||
// Before v0.141.0 the agent ran `systemctl is-active cloudflared` on the HOST — a unit that does not exist (cloudflared
|
||||
// is a guest container, `11-os-updates.md` C8), so every box reported `inactive` (R-841).
|
||||
//
|
||||
// It uses ONLY the existing sudoers line `pct exec [0-9]* -- docker inspect -f *` (03 §3): the container's state, exit
|
||||
// code and Docker health status. The health status comes from the compose health check controller v0.292.0 adds
|
||||
// (`cloudflared tunnel --metrics localhost:20241 ready` → /ready: 200 only with ≥ 1 connection). Measured 2026-10-04:
|
||||
// with a wrong token the container stays "running" while /ready answers 503 — so the container state alone would lie.
|
||||
// A container with no health check (an older controller) is judged on its state alone, and Detail says so.
|
||||
type GuestTunnelProber struct {
|
||||
Runner proxmox.Runner
|
||||
// Guests returns the box's customer guest vmids (running pool guests that bind /mnt/felhom-drives).
|
||||
Guests func(ctx context.Context) ([]int, error)
|
||||
}
|
||||
|
||||
// Status maps `systemctl is-active` output to the report vocabulary. systemctl
|
||||
// exits non-zero for inactive/failed, so the output string is authoritative over
|
||||
// the exit code; any exec error (binary missing, etc.) maps to "unknown".
|
||||
func (p SystemctlProber) Status(ctx context.Context) (string, error) {
|
||||
unit := p.Unit
|
||||
if unit == "" {
|
||||
unit = "cloudflared"
|
||||
const tunnelInspect = `{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}`
|
||||
|
||||
// Status probes every customer guest and reports the worst state (normally there is exactly one guest).
|
||||
func (p GuestTunnelProber) Status(ctx context.Context) (string, string) {
|
||||
if p.Runner == nil || p.Guests == nil {
|
||||
return TunnelUnknown, "no probe wired"
|
||||
}
|
||||
out, _ := exec.CommandContext(ctx, "systemctl", "is-active", unit).Output()
|
||||
switch strings.TrimSpace(string(out)) {
|
||||
case "active":
|
||||
return "active", nil
|
||||
case "failed":
|
||||
return "failed", nil
|
||||
case "inactive", "deactivating", "activating":
|
||||
return "inactive", nil
|
||||
case "":
|
||||
return "unknown", nil // no output → systemctl/exec problem
|
||||
default:
|
||||
return "unknown", nil
|
||||
vmids, err := p.Guests(ctx)
|
||||
if err != nil {
|
||||
return TunnelUnknown, "could not list the customer guest: " + err.Error()
|
||||
}
|
||||
if len(vmids) == 0 {
|
||||
return TunnelUnknown, "no running customer guest"
|
||||
}
|
||||
worst, wdetail := "", ""
|
||||
rank := map[string]int{TunnelRunning: 0, TunnelUnknown: 1, TunnelNotRunning: 2}
|
||||
for _, v := range vmids {
|
||||
out, errOut, err := p.Runner.Run(ctx, "/usr/sbin/pct", "exec", fmt.Sprint(v), "--", "docker", "inspect", "-f", tunnelInspect, "cloudflared")
|
||||
st, d := ClassifyTunnel(string(out), string(errOut), err)
|
||||
if len(vmids) > 1 {
|
||||
d = fmt.Sprintf("guest %d: %s", v, d)
|
||||
}
|
||||
if worst == "" || rank[st] > rank[worst] {
|
||||
worst, wdetail = st, d
|
||||
}
|
||||
}
|
||||
return worst, wdetail
|
||||
}
|
||||
|
||||
// ClassifyTunnel maps one `docker inspect` answer to a state. Pure; pinned by TestClassifyTunnel.
|
||||
func ClassifyTunnel(stdout, stderr string, err error) (string, string) {
|
||||
out := strings.TrimSpace(stdout)
|
||||
if err != nil || out == "" {
|
||||
if strings.Contains(stderr, "No such object") || strings.Contains(stderr, "No such container") {
|
||||
return TunnelNotRunning, "no cloudflared container in the guest"
|
||||
}
|
||||
return TunnelUnknown, "could not ask the guest: " + firstLine(stderr, err)
|
||||
}
|
||||
parts := strings.Split(out, "|")
|
||||
if len(parts) != 3 {
|
||||
return TunnelUnknown, "unreadable docker answer: " + out
|
||||
}
|
||||
state, code, health := parts[0], parts[1], parts[2]
|
||||
if state != "running" {
|
||||
return TunnelNotRunning, fmt.Sprintf("container %s, exit code %s", state, code)
|
||||
}
|
||||
switch health {
|
||||
case "healthy":
|
||||
return TunnelRunning, "connected"
|
||||
case "unhealthy":
|
||||
return TunnelNotRunning, "container running but the tunnel is NOT connected (cloudflared /ready fails)"
|
||||
case "starting":
|
||||
return TunnelUnknown, "container running, readiness check still starting"
|
||||
case "none":
|
||||
return TunnelRunning, "container running (no readiness check on this controller — connection not checked)"
|
||||
}
|
||||
return TunnelUnknown, "unknown health state " + health
|
||||
}
|
||||
|
||||
func firstLine(stderr string, err error) string {
|
||||
s := strings.TrimSpace(stderr)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if s == "" && err != nil {
|
||||
s = err.Error()
|
||||
}
|
||||
if len(s) > 160 {
|
||||
s = s[:160]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-841: the three states from one `docker inspect` answer. Red-proof: map "unhealthy" to running (the container
|
||||
// state alone — what a plain "is it running" probe would say) and the "running but not connected" case fails.
|
||||
func TestClassifyTunnel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, out, errOut string
|
||||
err error
|
||||
want string
|
||||
detail string
|
||||
}{
|
||||
{"connected", "running|0|healthy\n", "", nil, TunnelRunning, "connected"},
|
||||
{"running but not connected", "running|0|unhealthy\n", "", nil, TunnelNotRunning, "NOT connected"},
|
||||
{"stopped", "exited|137|unhealthy\n", "", nil, TunnelNotRunning, "exit code 137"},
|
||||
{"absent", "", "Error: No such object: cloudflared", errors.New("exit status 1"), TunnelNotRunning, "no cloudflared container"},
|
||||
{"still starting", "running|0|starting\n", "", nil, TunnelUnknown, "starting"},
|
||||
{"no health check (older controller)", "running|0|none\n", "", nil, TunnelRunning, "connection not checked"},
|
||||
{"guest not running", "", "CT 9201 not running", errors.New("exit status 255"), TunnelUnknown, "could not ask"},
|
||||
{"sudo refused", "", "sudo: a password is required", errors.New("exit status 1"), TunnelUnknown, "could not ask"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st, d := ClassifyTunnel(c.out, c.errOut, c.err)
|
||||
if st != c.want || !strings.Contains(d, c.detail) {
|
||||
t.Errorf("%s: got %q (%s), want %q (…%s…)", c.name, st, d, c.want, c.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type tunnelRunner struct {
|
||||
calls []string
|
||||
out map[string]string
|
||||
}
|
||||
|
||||
func (r *tunnelRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
r.calls = append(r.calls, line)
|
||||
return []byte(r.out[args[1]]), nil, nil
|
||||
}
|
||||
func (r *tunnelRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// The probe uses EXACTLY the existing sudoers shape `pct exec <vmid> -- docker inspect -f <tmpl> cloudflared`, and
|
||||
// with no customer guest it is unknown, never down.
|
||||
func TestGuestTunnelProber(t *testing.T) {
|
||||
r := &tunnelRunner{out: map[string]string{"9201": "running|0|healthy"}}
|
||||
p := GuestTunnelProber{Runner: r, Guests: func(context.Context) ([]int, error) { return []int{9201}, nil }}
|
||||
if st, d := p.Status(context.Background()); st != TunnelRunning || d != "connected" {
|
||||
t.Fatalf("got %q %q", st, d)
|
||||
}
|
||||
want := "/usr/sbin/pct exec 9201 -- docker inspect -f " + tunnelInspect + " cloudflared"
|
||||
if len(r.calls) != 1 || r.calls[0] != want {
|
||||
t.Fatalf("command = %q, want %q", r.calls, want)
|
||||
}
|
||||
none := GuestTunnelProber{Runner: r, Guests: func(context.Context) ([]int, error) { return nil, nil }}
|
||||
if st, _ := none.Status(context.Background()); st != TunnelUnknown {
|
||||
t.Fatalf("no guest → %q, want unknown", st)
|
||||
}
|
||||
}
|
||||
+10
-8
@@ -280,7 +280,7 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
|
||||
PBSSnapshots: c.collectPBSSnapshots(ctx),
|
||||
|
||||
AuditTail: []AuditEntry{},
|
||||
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
|
||||
Cloudflared: c.cloudflared(ctx),
|
||||
Capabilities: c.capabilities(ctx),
|
||||
LeafFingerprint: c.leafFP,
|
||||
Addresses: c.collectAddresses(),
|
||||
@@ -555,16 +555,18 @@ func (c *Collector) collectPBSSnapshots(ctx context.Context) []PBSSnapshot {
|
||||
return []PBSSnapshot{}
|
||||
}
|
||||
|
||||
func (c *Collector) cloudflaredStatus(ctx context.Context) string {
|
||||
func (c *Collector) cloudflared(ctx context.Context) Cloudflared {
|
||||
if c.cf == nil {
|
||||
return "unknown"
|
||||
return Cloudflared{Status: TunnelUnknown, Detail: "no probe wired"}
|
||||
}
|
||||
st, err := c.cf.Status(ctx)
|
||||
if err != nil || st == "" {
|
||||
c.logger.Warn("hub: cloudflared probe failed", "err", err)
|
||||
return "unknown"
|
||||
st, d := c.cf.Status(ctx)
|
||||
if st == "" {
|
||||
st = TunnelUnknown
|
||||
}
|
||||
return st
|
||||
if st == TunnelUnknown {
|
||||
c.logger.Debug("hub: tunnel probe could not decide", "detail", d)
|
||||
}
|
||||
return Cloudflared{Status: st, Detail: d}
|
||||
}
|
||||
|
||||
func percent(used, total int64) float64 {
|
||||
|
||||
@@ -16,7 +16,7 @@ func (f fakeGuestNet) GuestNetStatus(context.Context) *GuestNetStatus { return f
|
||||
|
||||
func TestCollect_GuestNetOmittedWhenReporterNil(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{}, nil, nil, nil, "h", "0.92.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{}, nil, nil, nil, "h", "0.92.0", quietLogger())
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Collect: %v", err)
|
||||
@@ -38,7 +38,7 @@ func TestCollect_GuestNetOmittedWhenReporterNil(t *testing.T) {
|
||||
|
||||
func TestCollect_GuestNetPopulatedWhenWired(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{}, nil, nil, nil, "h", "0.92.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{}, nil, nil, nil, "h", "0.92.0", quietLogger())
|
||||
c.SetGuestNetReporter(fakeGuestNet{st: &GuestNetStatus{
|
||||
CheckedAt: "2026-07-21T10:00:00Z",
|
||||
Guests: []GuestNetGuest{{
|
||||
|
||||
@@ -12,7 +12,7 @@ func (f fakeMgmtPlane) MgmtPlaneStatus(context.Context) *MgmtPlaneStatus { retur
|
||||
|
||||
func TestCollect_MgmtPlaneOmittedWhenReporterNil(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{}, nil, nil, nil, "h", "0.71.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{}, nil, nil, nil, "h", "0.71.0", quietLogger())
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Collect: %v", err)
|
||||
@@ -24,7 +24,7 @@ func TestCollect_MgmtPlaneOmittedWhenReporterNil(t *testing.T) {
|
||||
|
||||
func TestCollect_MgmtPlanePopulatedWhenWired(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{}, nil, nil, nil, "h", "0.71.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{}, nil, nil, nil, "h", "0.71.0", quietLogger())
|
||||
c.SetMgmtPlaneReporter(fakeMgmtPlane{st: &MgmtPlaneStatus{
|
||||
PrivsepDirOK: true, SshdReachable: true, HealedRecently: true, PrivsepHealedAt: "2026-07-05T16:42:17Z",
|
||||
}})
|
||||
@@ -47,7 +47,7 @@ func (f fakeOOB) OOBStatus(context.Context) *OOBStatus { return f.st }
|
||||
|
||||
func TestCollect_OOBOmittedWhenNil(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{}, nil, nil, nil, "h", "0.72.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{}, nil, nil, nil, "h", "0.72.0", quietLogger())
|
||||
r, _ := c.Collect(context.Background())
|
||||
if r.OOB != nil {
|
||||
t.Fatalf("no reporter → oob omitted, got %+v", r.OOB)
|
||||
@@ -56,7 +56,7 @@ func TestCollect_OOBOmittedWhenNil(t *testing.T) {
|
||||
|
||||
func TestCollect_OOBPopulatedWhenWired(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{}, nil, nil, nil, "h", "0.72.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{}, nil, nil, nil, "h", "0.72.0", quietLogger())
|
||||
c.SetOOBReporter(fakeOOB{st: &OOBStatus{FelhomSshdActive: true, FelhomSshdPort: 8822, Reachable: true}})
|
||||
r, _ := c.Collect(context.Background())
|
||||
if r.OOB == nil || r.OOB.FelhomSshdPort != 8822 || !r.OOB.Reachable {
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestCollect_StorageTargetsFromObserver(t *testing.T) {
|
||||
obs := fakeObserver{targets: []StorageTarget{
|
||||
{Name: "local-lvm", Type: StorageTypeLVMThin, State: StorageStateAttached, Reachable: true},
|
||||
}}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, obs, nil, nil, nil, "h", "0.5.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, obs, nil, nil, nil, "h", "0.5.0", quietLogger())
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Collect: %v", err)
|
||||
@@ -45,7 +45,7 @@ func TestCollect_StorageTargetsFromObserver(t *testing.T) {
|
||||
|
||||
func TestCollect_StorageObserverErrorDegradesToEmpty(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{err: errors.New("proxmox down")}, nil, nil, nil, "h", "0.5.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{err: errors.New("proxmox down")}, nil, nil, nil, "h", "0.5.0", quietLogger())
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("a storage observe error must not sink the heartbeat: %v", err)
|
||||
@@ -64,7 +64,7 @@ func TestCollect_HostAndGuests(t *testing.T) {
|
||||
},
|
||||
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2, Memory: 2048}},
|
||||
}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "demo-host-01", "0.3.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "demo-host-01", "0.3.0", quietLogger())
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Collect: %v", err)
|
||||
@@ -88,7 +88,7 @@ func TestCollect_HostAndGuests(t *testing.T) {
|
||||
if g.Spec.Cores != 2 || g.Spec.MemoryBytes != 2147483648 || g.Spec.DiskBytes != 21474836480 {
|
||||
t.Errorf("spec = %+v", g.Spec)
|
||||
}
|
||||
if r.Cloudflared.Status != "active" {
|
||||
if r.Cloudflared.Status != "running" || r.Cloudflared.Detail != "connected" {
|
||||
t.Errorf("cloudflared = %q", r.Cloudflared.Status)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestCollect_GuestConfigFailureKeepsStatusOmitsSpec(t *testing.T) {
|
||||
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
|
||||
cfgErr: map[int]error{200: errors.New("config read failed")},
|
||||
}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.3.1", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.3.1", quietLogger())
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("a per-guest failure must NOT fail the whole report: %v", err)
|
||||
@@ -125,7 +125,7 @@ func TestCollect_GuestConfigFailureKeepsStatusOmitsSpec(t *testing.T) {
|
||||
|
||||
func TestCollect_NodeStatusFailureIsHardError(t *testing.T) {
|
||||
px := &fakePx{node: "n", nsErr: errors.New("proxmox down")}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
|
||||
if _, err := c.Collect(context.Background()); err == nil {
|
||||
t.Fatal("NodeStatus failure must be a hard error (no useful report)")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestCollect_NodeStatusFailureIsHardError(t *testing.T) {
|
||||
|
||||
func TestCollect_CloudflaredProbeErrorIsUnknown(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{err: errors.New("no systemctl")}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "", detail: "could not ask"}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("cloudflared failure must not be fatal: %v", err)
|
||||
@@ -153,7 +153,7 @@ func TestCollect_LeafFingerprint(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
const fp = "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"
|
||||
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
|
||||
c.SetLeafFingerprint(fp)
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
@@ -164,7 +164,7 @@ func TestCollect_LeafFingerprint(t *testing.T) {
|
||||
}
|
||||
|
||||
// Companion: no SetLeafFingerprint (local API disabled) → empty, never a fabricated value.
|
||||
c2 := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
|
||||
c2 := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
|
||||
r2, _ := c2.Collect(context.Background())
|
||||
if r2.LeafFingerprint != "" {
|
||||
t.Fatalf("unset leaf_fingerprint = %q, want empty", r2.LeafFingerprint)
|
||||
|
||||
@@ -384,7 +384,7 @@ func TestCollectDRRecipe_ProductionPath(t *testing.T) {
|
||||
obs := fakeObserver{targets: capturedDemoFelhomTargets()}
|
||||
pbsRep := fakePBSReporter{snaps: capturedDemoFelhomSnapshots()}
|
||||
|
||||
c := NewCollector(px, fakeProber{status: "active"}, obs, nil, nil, pbsRep, "h", "0.118.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, obs, nil, nil, pbsRep, "h", "0.118.0", quietLogger())
|
||||
c.SetBackupTargetResolver(func() ConfiguredBackupTarget {
|
||||
return ConfiguredBackupTarget{StorageID: "felhom-backup", Known: true}
|
||||
})
|
||||
@@ -409,7 +409,7 @@ func TestCollectDRRecipe_ProductionPath(t *testing.T) {
|
||||
// test that would have caught shipping the seam without wiring it.
|
||||
func TestCollectDRRecipe_UnwiredSeamReportsUnknown(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{targets: capturedDemoFelhomTargets()},
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, fakeObserver{targets: capturedDemoFelhomTargets()},
|
||||
nil, nil, nil, "h", "0.118.0", quietLogger())
|
||||
|
||||
r, err := c.Collect(context.Background())
|
||||
|
||||
@@ -16,7 +16,7 @@ func intp(v int) *int { return &v }
|
||||
// HostMetricsNow returns a fresh host block with cpu% from NodeStatus and the temp from the reader.
|
||||
func TestHostMetricsNow_PopulatesTemp(t *testing.T) {
|
||||
px := &fakePx{node: "demo-felhom", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger()).
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger()).
|
||||
SetTempReader(fakeTemp{c: intp(46)})
|
||||
h, err := c.HostMetricsNow(context.Background())
|
||||
if err != nil {
|
||||
@@ -36,7 +36,7 @@ func TestHostMetricsNow_PopulatesTemp(t *testing.T) {
|
||||
// A missing temp sensor gracefully nulls cpu_temp_c without failing the host read.
|
||||
func TestHostMetricsNow_GracefulNullTemp(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger()).
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger()).
|
||||
SetTempReader(fakeTemp{c: nil})
|
||||
h, err := c.HostMetricsNow(context.Background())
|
||||
if err != nil {
|
||||
@@ -50,7 +50,7 @@ func TestHostMetricsNow_GracefulNullTemp(t *testing.T) {
|
||||
// A NodeStatus failure is a hard error (no useful host view).
|
||||
func TestHostMetricsNow_NodeStatusErrorIsHard(t *testing.T) {
|
||||
px := &fakePx{node: "n", nsErr: errors.New("proxmox down")}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger())
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger())
|
||||
if _, err := c.HostMetricsNow(context.Background()); err == nil {
|
||||
t.Fatal("NodeStatus failure must be a hard error")
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestHostMetricsNow_NodeStatusErrorIsHard(t *testing.T) {
|
||||
// Collect() (the hub report) also carries the temp now — the operator freebie.
|
||||
func TestCollect_HostReportCarriesTemp(t *testing.T) {
|
||||
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
||||
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger()).
|
||||
c := NewCollector(px, fakeProber{status: "running", detail: "connected"}, nil, nil, nil, nil, "h", "0.14.0", quietLogger()).
|
||||
SetTempReader(fakeTemp{c: intp(51)})
|
||||
r, err := c.Collect(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -56,7 +56,7 @@ func (f *fakePx) GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig
|
||||
// fakeProber is a fake CloudflaredProber.
|
||||
type fakeProber struct {
|
||||
status string
|
||||
err error
|
||||
detail string
|
||||
}
|
||||
|
||||
func (p fakeProber) Status(ctx context.Context) (string, error) { return p.status, p.err }
|
||||
func (p fakeProber) Status(ctx context.Context) (string, string) { return p.status, p.detail }
|
||||
|
||||
@@ -289,9 +289,10 @@ type GuestSpec struct {
|
||||
DiskBytes int64 `json:"disk_bytes"`
|
||||
}
|
||||
|
||||
// Cloudflared is the tunnel service health (read-only probe this slice).
|
||||
// Cloudflared is the box's tunnel (R-841, agent v0.141.0): the cloudflared container in the customer guest.
|
||||
type Cloudflared struct {
|
||||
Status string `json:"status"` // active | inactive | failed | unknown
|
||||
Status string `json:"status"` // running | not_running | unknown (TunnelRunning …)
|
||||
Detail string `json:"detail,omitempty"` // why not_running / unknown, or "connected"
|
||||
}
|
||||
|
||||
// The following element types are declared now so the empty collections above are
|
||||
@@ -559,6 +560,9 @@ type WireOSUpdate struct {
|
||||
Ring int `json:"ring"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Release *WireOSRelease `json:"release,omitempty"`
|
||||
// HostRelease is the newest approved HOST release (hub v0.131.0, `11` §8 step 3) — a separate set: a version
|
||||
// approved for the guest is not approved for the host by that fact alone.
|
||||
HostRelease *WireOSRelease `json:"host_release,omitempty"`
|
||||
}
|
||||
|
||||
// WireOSRelease is an approved version set; Snapshot is the approval time (YYYYMMDDTHHMMSSZ) the wrapper uses
|
||||
|
||||
Reference in New Issue
Block a user