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

This commit is contained in:
admin committed 2026-10-29 16:36:34 +01:00
1 parent dd3e4137ee
commit 636022dc31
10 files changed
+223 -16

No files matched your search

+19
View File
@@ -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())
}
}
+9
View File
@@ -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).
+39 -1
View File
@@ -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")
}
}
+8 -5
View File
@@ -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>