c3d08b4821
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
113 lines
4.5 KiB
Go
113 lines
4.5 KiB
Go
package hub
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
// 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 the Tunnel* states and a short detail (why not_running / why unknown).
|
|
Status(ctx context.Context) (status, detail string)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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"
|
|
}
|
|
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
|
|
}
|