hub 0.177.0: Reboot guest on the host page (D65 = A, reboot_guest in the agent's closed list, 30-minute expiry); R-1346: the slow-request line and the vouch save's per-leg timing
gates / gates (push) Successful in 1m52s
gates / gates (push) Successful in 1m52s
This commit is contained in:
1 parent
dd3e4137ee
commit
636022dc31
10 files changed
+223
-16
No files matched your search
@@ -1,3 +1,25 @@
|
||||
## v0.177.0 (2026-10-29) — „Reboot guest" from the hub (D65 = A); the slow-request line and the vouch save's timing (R-1346)
|
||||
|
||||
- **D65 = A (operator ruling 2026-10-29 15:50; `04` §6.1).** The AGENT's closed list (`hostOperatorActionNames`) is
|
||||
now `restart_controller` and `reboot_guest` (arg: the guest's vmid, a guest of THIS host — refused before anything is
|
||||
stored otherwise). The host page shows „Reboot guest" beside „Restart controller" for every guest of a customer host,
|
||||
behind a confirm step (`hosts:write`, CSRF) that says every app stops for about a minute and when it is refused. The
|
||||
agent's result („rebooted; the guest was up after 41 s, the controller answered after 63 s" / „refused: …") closes
|
||||
the row and saves ONE `operator_action` event, as for a restart. A reboot the agent did not pick up in **30
|
||||
minutes** expires (`HostOperatorActionRebootTTL`) — a box that was off must not reboot by itself hours later; a
|
||||
restart keeps the day. Tests `TestHostOperatorActions_*` (the list, the refusals, the 30-minute expiry),
|
||||
`TestHostPage_RebootGuestRendersAndPosts`, `TestHostPage_NoRebootWithoutACustomer`, `TestHandleHostDetail` (8
|
||||
buttons) — red first, then by mutation.
|
||||
- **R-1346 — the slow-request line.** The access-log middleware writes one operator log line for a request over 5 s:
|
||||
`[WARN] slow request: <METHOD> <path> <status> <ms>` — the path only (no query value, no capability), the box
|
||||
long-poll `/api/v1/wait` exempt (slow by design). `TestAccessLog_ASlowRequestIsNamed`.
|
||||
- **R-1346 — the vouch save names its time.** The hub's own access log holds seven vouch saves (POST
|
||||
`/configuration/artifacts`) of 33–170 s since 2026-10-25, while every registry call it makes measured under 0.3 s
|
||||
(from DooPlex and from inside the hub pod, 2026-10-29) and the page itself loads in 6 ms (in the cluster) / 65–255 ms
|
||||
(through the public name). ingress-nginx's default 60-s read timeout turns a save over 60 s into the operator's
|
||||
504 while the save completes behind it. Each save now logs one line with each leg's time (registry probes, sha
|
||||
lookups, bundle sha, fleet check + save) so the next slow save names its leg. `TestVouchSaveLogsItsTiming`.
|
||||
|
||||
## v0.176.1 (2026-10-29) — „Restart controller" for every guest of a customer host (0.176.0 showed it only with a reported controller version, which the agent does not report) — DEPLOYED on the operator's yes (image sha256:c105cb9a…, from `2ccf180b`, CI 2509)
|
||||
|
||||
- Found live on 0.176.0 (Tester 1's host page): no button — the agent's host report carries no controller version, so the
|
||||
|
||||
@@ -53,6 +53,8 @@ type Log struct {
|
||||
keep time.Duration
|
||||
now func() time.Time
|
||||
logf func(format string, a ...any)
|
||||
// slowAfter: R-1346's slow-request line.
|
||||
slowAfter time.Duration
|
||||
|
||||
ch chan string
|
||||
done chan struct{}
|
||||
@@ -75,13 +77,25 @@ type Options struct {
|
||||
Keep time.Duration
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
// SlowAfter (R-1346): a request slower than this gets one operator log line (method, path, status, duration);
|
||||
// zero → DefaultSlowAfter.
|
||||
SlowAfter time.Duration
|
||||
}
|
||||
|
||||
// DefaultSlowAfter is when a request counts as slow (R-1346).
|
||||
const DefaultSlowAfter = 5 * time.Second
|
||||
|
||||
// slowExempt are the paths slow BY DESIGN: the box's long-poll waits up to 4 minutes for a change.
|
||||
var slowExempt = map[string]bool{"/api/v1/wait": true}
|
||||
|
||||
// New starts the writer and prunes once. The directory is created on first write, never at construction, so a
|
||||
// missing volume costs a logged error and nothing else.
|
||||
func New(o Options) *Log {
|
||||
l := &Log{dir: o.Dir, keep: o.Keep, now: o.Now, logf: o.Logf,
|
||||
l := &Log{dir: o.Dir, keep: o.Keep, now: o.Now, logf: o.Logf, slowAfter: o.SlowAfter,
|
||||
ch: make(chan string, bufferLines), done: make(chan struct{})}
|
||||
if l.slowAfter <= 0 {
|
||||
l.slowAfter = DefaultSlowAfter
|
||||
}
|
||||
if l.keep <= 0 {
|
||||
l.keep = DefaultKeep
|
||||
}
|
||||
@@ -282,7 +296,14 @@ func (l *Log) Middleware(next http.Handler) http.Handler {
|
||||
e.mu.Lock()
|
||||
auth := e.auth
|
||||
e.mu.Unlock()
|
||||
l.enqueue(FormatLine(start, r, status, l.now().Sub(start), auth))
|
||||
dur := l.now().Sub(start)
|
||||
l.enqueue(FormatLine(start, r, status, dur, auth))
|
||||
if dur > l.slowAfter && !slowExempt[r.URL.Path] {
|
||||
// R-1346: the slow page names itself in the operator log — the path only (SafePath: no query, no
|
||||
// capability), never a value.
|
||||
l.logf("[WARN] slow request: %s %s %s %dms (over %s)", safeToken(r.Method, 10), SafePath(r), status,
|
||||
dur.Milliseconds(), l.slowAfter)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(rec, r.WithContext(context.WithValue(r.Context(), ctxKey{}, e)))
|
||||
finished = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -233,3 +234,46 @@ func TestAccessLog_SkipsOnlyTheKubeletProbe(t *testing.T) {
|
||||
t.Fatalf("want exactly the ingress /healthz logged, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1346: a request slower than 5 s gets one operator log line — method, path (no query value), status, duration — so a
|
||||
// slow page names itself in the hub's log. The box long-poll (/api/v1/wait) is slow by design and is not named.
|
||||
// RED-PROOF: drop the slow check → no line → FAILS; drop the wait exception → the 240 s poll is named → FAILS.
|
||||
func TestAccessLog_ASlowRequestIsNamed(t *testing.T) {
|
||||
c := &clock{t: time.Date(2026, 10, 29, 13, 3, 19, 0, time.UTC)}
|
||||
var mu sync.Mutex
|
||||
var lines []string
|
||||
l := New(Options{Dir: t.TempDir(), Now: c.now, Logf: func(f string, a ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lines = append(lines, fmt.Sprintf(f, a...))
|
||||
}})
|
||||
defer l.Close()
|
||||
took := 0 * time.Second
|
||||
h := l.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.set(c.now().Add(took))
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
}))
|
||||
took = 170 * time.Second
|
||||
serve(h, httptest.NewRequest(http.MethodPost, "/configuration/artifacts?agent_version=0.169.0", nil))
|
||||
took = 4 * time.Second
|
||||
serve(h, httptest.NewRequest(http.MethodGet, "/customers/tester-1", nil))
|
||||
took = 240 * time.Second
|
||||
serve(h, httptest.NewRequest(http.MethodGet, "/api/v1/wait?gen=7", nil))
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var slow []string
|
||||
for _, ln := range lines {
|
||||
if strings.Contains(ln, "slow request") {
|
||||
slow = append(slow, ln)
|
||||
}
|
||||
}
|
||||
if len(slow) != 1 {
|
||||
t.Fatalf("slow lines = %q, want exactly the 170 s save", slow)
|
||||
}
|
||||
if want := "[WARN] slow request: POST /configuration/artifacts 303 170000ms"; !strings.HasPrefix(slow[0], want) {
|
||||
t.Fatalf("line %q, want prefix %q", slow[0], want)
|
||||
}
|
||||
if strings.Contains(slow[0], "0.169.0") {
|
||||
t.Fatalf("a query value reached the log: %q", slow[0])
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,13 @@ const (
|
||||
// HostOperatorActionRestartController (operator brief 2026-10-29, Part 1): the AGENT restarts a guest's controller
|
||||
// (the supervisor's own act and guards). The agent's list holds this one verb.
|
||||
HostOperatorActionRestartController = "restart_controller"
|
||||
// HostOperatorActionRebootGuest (D65 = A, operator ruling 2026-10-29 15:50; `04` §6.1): the AGENT reboots one guest
|
||||
// (`pct reboot` of THAT guest, never the host) with its own guards — no backup, restore, update or swap; at most
|
||||
// once an hour; the restart brake. No signature: a reboot destroys nothing.
|
||||
HostOperatorActionRebootGuest = "reboot_guest"
|
||||
// HostOperatorActionRebootTTL: a reboot the agent did not pick up in 30 minutes expires — a box that was off must not
|
||||
// reboot by itself when it comes back hours later.
|
||||
HostOperatorActionRebootTTL = 30 * time.Minute
|
||||
|
||||
// Outcomes: the box's three, plus two the hub sets itself.
|
||||
OperatorActionDone = "done"
|
||||
@@ -64,7 +71,7 @@ var operatorActionNames = []string{OperatorActionOffsiteBackupNow, OperatorActio
|
||||
|
||||
// hostOperatorActionNames is the AGENT's closed list (the host report's reply). Pinned by
|
||||
// TestHostOperatorActions_ClosedList; never shares a verb with the controller's list.
|
||||
var hostOperatorActionNames = []string{HostOperatorActionRestartController}
|
||||
var hostOperatorActionNames = []string{HostOperatorActionRestartController, HostOperatorActionRebootGuest}
|
||||
|
||||
// stackNameRe is an app's stack name as the controller names it (a catalog slug).
|
||||
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||
@@ -75,11 +82,11 @@ func HostOperatorActionNames() []string { return sortedStrings(hostOperatorActio
|
||||
// IsHostOperatorAction reports whether the verb belongs to the agent's list.
|
||||
func IsHostOperatorAction(action string) bool { return inList(hostOperatorActionNames, action) }
|
||||
|
||||
// ValidateHostOperatorAction refuses anything outside the agent's closed list. restart_controller takes the guest's
|
||||
// vmid (Proxmox: 100–999999999).
|
||||
// ValidateHostOperatorAction refuses anything outside the agent's closed list. Both verbs take the guest's vmid
|
||||
// (Proxmox: 100–999999999).
|
||||
func ValidateHostOperatorAction(action, arg string) error {
|
||||
switch action {
|
||||
case HostOperatorActionRestartController:
|
||||
case HostOperatorActionRestartController, HostOperatorActionRebootGuest:
|
||||
v, err := strconv.Atoi(arg)
|
||||
if err != nil || strconv.Itoa(v) != arg || v < 100 || v > 999999999 {
|
||||
return fmt.Errorf("the guest id must be a number 100-999999999")
|
||||
@@ -215,6 +222,12 @@ func (s *Store) PendingHostOperatorActions(hostID string) ([]OperatorActionDirec
|
||||
now, OperatorActionExpired, "the agent did not answer within a day", hostID, now.Add(-OperatorActionTTL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE operator_actions SET done_at = ?, outcome = ?, message = ?
|
||||
WHERE host_id = ? AND action = ? AND done_at IS NULL AND requested_at < ?`,
|
||||
now, OperatorActionExpired, "the agent did not pick the reboot up within 30 minutes — press again if it is still needed",
|
||||
hostID, HostOperatorActionRebootGuest, now.Add(-HostOperatorActionRebootTTL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT id, action, arg FROM operator_actions
|
||||
WHERE host_id = ? AND done_at IS NULL ORDER BY id LIMIT ?`, hostID, operatorActionsListed)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -110,9 +111,10 @@ func TestOperatorActions_ExpireAndResetCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The AGENT's closed list (operator brief 2026-10-29, Part 1): one verb. RED-PROOF: seen failing before the list existed.
|
||||
// The AGENT's closed list (operator brief 2026-10-29, Part 1; D65 = A adds reboot_guest). RED-PROOF: seen failing before
|
||||
// the list existed, and again before reboot_guest joined it.
|
||||
func TestHostOperatorActions_ClosedList(t *testing.T) {
|
||||
if got, want := HostOperatorActionNames(), []string{"restart_controller"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := HostOperatorActionNames(), []string{"reboot_guest", "restart_controller"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("host operator actions = %v, want exactly %v — a new verb needs the operator's word", got, want)
|
||||
}
|
||||
for _, a := range OperatorActionNames() {
|
||||
@@ -127,8 +129,8 @@ func TestHostOperatorActions_RefusedBeforeStoring(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
bad := []struct{ action, arg string }{
|
||||
{"restart_controller", ""}, {"restart_controller", "abc"}, {"restart_controller", "99"}, {"restart_controller", "9201 "},
|
||||
{"restart_controller", "-1"}, {"restart_controller", "10000000000"}, {"reboot_guest", "9201"}, {"clear_hold", "radicale"},
|
||||
{"offsite_backup_now", ""},
|
||||
{"restart_controller", "-1"}, {"restart_controller", "10000000000"}, {"reboot_host", "9201"}, {"clear_hold", "radicale"},
|
||||
{"offsite_backup_now", ""}, {"reboot_guest", ""}, {"reboot_guest", "abc"}, {"reboot_guest", "99"},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if _, err := s.CreateHostOperatorAction("c1", "h1", b.action, b.arg, "operator"); err == nil {
|
||||
@@ -147,6 +149,31 @@ func TestHostOperatorActions_RefusedBeforeStoring(t *testing.T) {
|
||||
if _, err := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator"); err != nil {
|
||||
t.Fatalf("restart_controller 9201 refused: %v", err)
|
||||
}
|
||||
if _, err := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator"); err != nil {
|
||||
t.Fatalf("reboot_guest 9201 refused: %v", err)
|
||||
}
|
||||
if _, err := s.CreateOperatorAction("c1", "reboot_guest", "9201", "operator"); err == nil {
|
||||
t.Error("reboot_guest reached the controller's list")
|
||||
}
|
||||
}
|
||||
|
||||
// D65 = A: a reboot the agent has not picked up in 30 minutes expires — a box that was off must not reboot by itself
|
||||
// when it comes back hours later. A restart keeps the day. RED-PROOF: drop the reboot TTL → the 31-minute-old reboot is
|
||||
// still listed → FAILS.
|
||||
func TestHostOperatorActions_ARebootExpiresAfter30Minutes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
rb, _ := s.CreateHostOperatorAction("c1", "h1", "reboot_guest", "9201", "operator")
|
||||
rs, _ := s.CreateHostOperatorAction("c1", "h1", "restart_controller", "9201", "operator")
|
||||
if _, err := s.db.Exec(`UPDATE operator_actions SET requested_at = ? WHERE id IN (?, ?)`, time.Now().UTC().Add(-31*time.Minute), rb, rs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := s.PendingHostOperatorActions("h1")
|
||||
if len(p) != 1 || p[0].ID != rs {
|
||||
t.Fatalf("pending = %+v, want only the restart #%d", p, rs)
|
||||
}
|
||||
if r, _ := s.getOperatorAction(rb); r == nil || r.Outcome != OperatorActionExpired || !strings.Contains(r.Message, "30 minutes") {
|
||||
t.Fatalf("the old reboot = %+v, want expired, naming 30 minutes", r)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent row reaches only its host's envelope, a controller row only the customer's ACK; a result closes only a row of
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -80,3 +83,19 @@ func TestVouchGoldenChangedToOlderIsStillRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-1346: a vouch save names the time of each leg in one line, so the next slow save says where its time went.
|
||||
// RED-PROOF: drop the timing line → FAILS.
|
||||
func TestVouchSaveLogsItsTiming(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
var logs bytes.Buffer
|
||||
s.logger = log.New(&logs, "", 0)
|
||||
seedReport(t, st, "demo", "0.335.0")
|
||||
w := postArtifacts(t, s, url.Values{"agent_version": {"0.160.0"}, "agent_sha256": {strings.Repeat("c", 64)}})
|
||||
if loc := w.Header().Get("Location"); !strings.Contains(loc, "flash=artifacts_set") {
|
||||
t.Fatalf("vouch refused: %q", loc)
|
||||
}
|
||||
if !regexp.MustCompile(`artifact vouch timing \(R-1346\): registry probes \d+ms, sha lookups \d+ms, bundle sha \d+ms, fleet check \+ save \d+ms, total \d+ms`).MatchString(logs.String()) {
|
||||
t.Fatalf("no timing line:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
@@ -1440,6 +1440,9 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
// read as a success, and this project has the scars; so an unreachable registry refuses too,
|
||||
// with its own message. There is NO OVERRIDE: the registry is the operator's own server, and if
|
||||
// it cannot be reached then vouching is moot rather than urgent.
|
||||
// R-1346: each leg's time, in one line at the save — the save took 37–170 s on four days (the hub's access log) while
|
||||
// every registry call measured under 0.3 s, so the next slow vouch must name its leg.
|
||||
began := time.Now()
|
||||
if s.gitea != nil {
|
||||
ctx := r.Context()
|
||||
type probeTarget struct{ label, pkg, file, version, repo string }
|
||||
@@ -1484,6 +1487,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
probesDone := time.Now()
|
||||
// R-292: each failure names its own cause — version missing, registry unreachable, no sha listed, or a
|
||||
// bad typed sha — instead of one flash that conflated all of them.
|
||||
agentSHA, flashAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
|
||||
@@ -1492,6 +1496,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
shaDone := time.Now()
|
||||
// R-840: the vouched agent's CONFIG BUNDLE is resolved from the registry by exact name, like the binary. A version
|
||||
// without one (older than v0.143.0) vouches none — the installer then falls back to its per-file fetches.
|
||||
bundleSHA := ""
|
||||
@@ -1504,6 +1509,7 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
bundleSHA = b
|
||||
}
|
||||
bundleDone := time.Now()
|
||||
// R-50b(a): the PBS-DR wrapper hash is operator-typed, not resolved from the package registry —
|
||||
// unlike the agent binary and the golden, this artifact is not published there at all. It is
|
||||
// installed from raw/branch/main, which is exactly the drift this field makes visible.
|
||||
@@ -1575,6 +1581,9 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q wrapper_sha=%t bundle_sha=%q", agentVer, goldenVer, minAgent, wrapperSHA != "", bundleSHA)
|
||||
s.logger.Printf("[INFO] artifact vouch timing (R-1346): registry probes %dms, sha lookups %dms, bundle sha %dms, fleet check + save %dms, total %dms",
|
||||
probesDone.Sub(began).Milliseconds(), shaDone.Sub(probesDone).Milliseconds(), bundleDone.Sub(shaDone).Milliseconds(),
|
||||
time.Since(bundleDone).Milliseconds(), time.Since(began).Milliseconds())
|
||||
// Agent-plane immediate-sync (Direction-2a, v0.59.0): a MinAgent-floor / vouched-agent change is
|
||||
// a fleet-wide agent-plane intent shift. Fire-and-forget nudge every box so it re-reports at
|
||||
// once (the self-update train's signed op / floor re-evaluation lands in seconds, not ≤15 min).
|
||||
|
||||
@@ -84,7 +84,8 @@ func TestHostPage_RestartAndClearHoldPosts(t *testing.T) {
|
||||
{"action": {"clear_hold"}, "arg": {"vikunja"}}, // not held
|
||||
{"action": {"restart_controller"}, "arg": {"9202"}}, // not a guest of this host
|
||||
{"action": {"restart_controller"}, "arg": {"abc"}},
|
||||
{"action": {"reboot_guest"}, "arg": {"9201"}},
|
||||
{"action": {"reboot_host"}, "arg": {"9201"}},
|
||||
{"action": {"reboot_guest"}, "arg": {"9202"}}, // not a guest of this host
|
||||
} {
|
||||
if rr := postOperatorAction(t, s, "h1", f); rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("%v: status %d, want 400", f, rr.Code)
|
||||
@@ -122,3 +123,40 @@ func TestOperatorActor_NamesTheAccessToken(t *testing.T) {
|
||||
t.Fatalf("actor = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// D65 = A: „Reboot guest" beside „Restart controller", behind a confirm step that says the apps stop for about a minute;
|
||||
// the press stores a row for THIS host's agent only. RED-PROOF: seen failing before the button and the route existed.
|
||||
func TestHostPage_RebootGuestRendersAndPosts(t *testing.T) {
|
||||
s, st := heldFixture(t)
|
||||
body := renderHost(t, s, "h1")
|
||||
for _, want := range []string{`data-reboot-guest="9201"`, `name="action" value="reboot_guest"`,
|
||||
"Reboot guest 9201 on h1 (c1)?", "every app of the household stops for about a minute"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("host page lacks %q", want)
|
||||
}
|
||||
}
|
||||
if rr := postOperatorAction(t, s, "h1", url.Values{"action": {"reboot_guest"}, "arg": {"9201"}}); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("reboot: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if a, _ := st.PendingHostOperatorActions("h1"); len(a) != 1 || a[0].Action != "reboot_guest" || a[0].Arg != "9201" {
|
||||
t.Fatalf("the agent's reply would list %+v", a)
|
||||
}
|
||||
if c, _ := st.PendingOperatorActions("c1"); len(c) != 0 {
|
||||
t.Fatalf("the reboot reached the controller's ACK: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A host with no customer has no buttons (no controller, no household) — the other branch of the template gate.
|
||||
func TestHostPage_NoRebootWithoutACustomer(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h9", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := st.UpsertGuestFromReport(&store.Guest{GuestID: "h9-9201", HostID: "h9", VMID: 9201, Status: "running", LastSeenAt: &now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body := renderHost(t, s, "h9"); strings.Contains(body, "data-reboot-guest") {
|
||||
t.Fatal("a host with no customer shows Reboot guest")
|
||||
}
|
||||
}
|
||||
@@ -176,12 +176,15 @@ func TestHandleHostDetail(t *testing.T) {
|
||||
// remains absent: 2 log requests + 4 operator actions, and the 4 all post to /operator-action.
|
||||
// 2026-10-29 (operator brief, Part 1): plus ONE „Restart controller" per guest of a customer host (the AGENT's closed
|
||||
// list, behind a confirm step; this fixture has one guest). „Clear hold" appears only per held app (none
|
||||
// here). No other button.
|
||||
if got := strings.Count(strings.ToLower(body), "<button"); got != 7 {
|
||||
t.Errorf("host detail has %d buttons, want exactly the 2 log-request + 4 operator-action + 1 restart-controller buttons", got)
|
||||
// here). D65 = A (2026-10-29): plus ONE „Reboot guest" per guest, beside it. No other button.
|
||||
if got := strings.Count(strings.ToLower(body), "<button"); got != 8 {
|
||||
t.Errorf("host detail has %d buttons, want exactly the 2 log-request + 4 operator-action + 1 restart-controller + 1 reboot-guest buttons", got)
|
||||
}
|
||||
if got := strings.Count(body, `action="/hosts/demo-felhom-01/operator-action"`); got != 5 {
|
||||
t.Errorf("operator-action forms = %d, want 5", got)
|
||||
if got := strings.Count(body, `action="/hosts/demo-felhom-01/operator-action"`); got != 6 {
|
||||
t.Errorf("operator-action forms = %d, want 6", got)
|
||||
}
|
||||
if got := strings.Count(body, `value="reboot_guest"`); got != 1 {
|
||||
t.Errorf("reboot_guest forms = %d, want 1 (one guest)", got)
|
||||
}
|
||||
if got := strings.Count(body, `value="restart_controller"`); got != 1 {
|
||||
t.Errorf("restart_controller forms = %d, want 1 (one guest)", got)
|
||||
|
||||
@@ -144,6 +144,17 @@
|
||||
<p>Restart the controller of guest {{.VMID}} on {{$.HostID}} ({{$.CustomerID}})? The box's agent restarts it, even when the controller does not answer. The household's dashboard is away for about a minute. At most 3 restarts in 15 minutes; the result shows under Operator Actions.</p>
|
||||
<button type="submit" class="btn btn-sm btn-danger">Restart the controller</button>
|
||||
</form>
|
||||
</details>
|
||||
{{/* D65 = A (2026-10-29): the AGENT reboots this guest (pct reboot — never the host). Refused during a backup, restore, update or swap, more than once an hour, or under the restart brake; the result says when the guest and the controller were back. */}}
|
||||
<details class="op-confirm" data-reboot-guest="{{.VMID}}">
|
||||
<summary class="btn btn-sm btn-outline">Reboot guest</summary>
|
||||
<form method="POST" action="/hosts/{{$.HostID}}/operator-action">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="action" value="reboot_guest">
|
||||
<input type="hidden" name="arg" value="{{.VMID}}">
|
||||
<p>Reboot guest {{.VMID}} on {{$.HostID}} ({{$.CustomerID}})? The box's agent reboots the whole guest — use it when Docker or the network inside the box is stuck. While it reboots, every app of the household stops for about a minute; the dashboard then says Felhom support restarted the box. Refused during a backup, a restore, an update or a controller swap, and more than once an hour. The result shows under Operator Actions.</p>
|
||||
<button type="submit" class="btn btn-sm btn-danger">Reboot the guest</button>
|
||||
</form>
|
||||
</details>{{end}}
|
||||
</td>
|
||||
<td>{{timeAgoPtr .LastSeenAt}}</td>
|
||||
|
||||
Reference in new issue
Block a user