hub v0.72.0 — R-70 + R-71c: offsite delivery-state detector, card, stuck event, R-39(a)-guarded self-heal restage
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NKSN3gSg4TKVBBqkwW2djR
This commit is contained in:
@@ -518,6 +518,80 @@ type configFormView struct {
|
||||
Error string
|
||||
CSRFField template.HTML
|
||||
PBSDR pbsDRView
|
||||
Delivery *deliveryView
|
||||
}
|
||||
|
||||
// deliveryView is the R-70 customer-card rendering of offsite.DeliveryStateFor — the hub's real
|
||||
// delivery knowledge replacing the static "delivered to the controller once" copy that let a
|
||||
// burned credential hide for 2 days (DIAG-f10-demo-hp-offsite-2026-07-23). All strings are
|
||||
// precomputed operator-tier English (this page's existing language).
|
||||
type deliveryView struct {
|
||||
State string // offsite.DeliveryState (template branch key + test anchor)
|
||||
Badge string // short badge text
|
||||
BadgeClass string // n-ok | n-warn | n-neutral
|
||||
Line string // the state sentence, with age ("mióta" — every state carries its timestamp)
|
||||
StaleLine string // non-empty ONLY for applied+stale-staged (the demo-felhom shape)
|
||||
}
|
||||
|
||||
// agoHuman renders a duration as a coarse operator-friendly age.
|
||||
func agoHuman(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "under a minute"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d min", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%.1f h", d.Hours())
|
||||
default:
|
||||
return fmt.Sprintf("%d days", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
// deliveryViewFor derives the card view; nil for brand-new configs (nothing staged yet) or on a
|
||||
// detector error (the card then simply omits the state line — never a fabricated one).
|
||||
func (s *Server) deliveryViewFor(customerID string) *deliveryView {
|
||||
if customerID == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
status, err := offsite.DeliveryStateFor(s.store, customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] delivery view %s: %v", customerID, err)
|
||||
return nil
|
||||
}
|
||||
v := &deliveryView{State: string(status.State)}
|
||||
age := agoHuman(now.Sub(status.Since))
|
||||
switch status.State {
|
||||
case offsite.DeliveryApplied:
|
||||
v.Badge, v.BadgeClass = "applied", "n-ok"
|
||||
v.Line = "offsite active on the box (last report " + age + " ago)"
|
||||
if !status.StaleStagedSince.IsZero() {
|
||||
v.StaleLine = fmt.Sprintf("Note: an unconsumed one-time secret has been staged since %s (%s ago) — superseded by the working install (key-auth-first never consumes); harmless, replaced by the next re-issue.",
|
||||
status.StaleStagedSince.UTC().Format("2006-01-02 15:04 UTC"), agoHuman(now.Sub(status.StaleStagedSince)))
|
||||
}
|
||||
case offsite.DeliveryConsumedAwaitingApply:
|
||||
// Amber past 30 min: consume→apply is a seconds-scale hop; half an hour of it is the
|
||||
// burned-credential shape taking form (the monitor turns it into an event at 1 h).
|
||||
v.Badge = "consumed"
|
||||
if now.Sub(status.Since) > 30*time.Minute {
|
||||
v.BadgeClass = "n-warn"
|
||||
v.Line = fmt.Sprintf("password consumed %s ago and the box still reports no offsite target — likely burned mid-apply; Re-issue delivers a fresh one", age)
|
||||
} else {
|
||||
v.BadgeClass = "n-neutral"
|
||||
v.Line = "password consumed — apply in progress (" + age + ")"
|
||||
}
|
||||
case offsite.DeliveryStagedAwaitingConsume:
|
||||
v.Badge, v.BadgeClass = "staged", "n-neutral"
|
||||
v.Line = "one-time password staged " + age + " ago — the box consumes it on its next config refresh"
|
||||
if now.Sub(status.Since) > 30*time.Minute {
|
||||
v.BadgeClass = "n-warn"
|
||||
v.Line = "one-time password staged " + age + " ago and NOT yet consumed — the box has not re-pulled its config (is it reporting?)"
|
||||
}
|
||||
default: // DeliveryNoSecret
|
||||
v.Badge, v.BadgeClass = "missing", "n-warn"
|
||||
v.Line = "offsite is enabled but no credential is staged — needs attention (Re-issue stages a fresh one)"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// configFormData assembles the config form's view model. overrides carries SUBMITTED form values
|
||||
@@ -538,6 +612,7 @@ func (s *Server) configFormData(r *http.Request, isNew bool, cfg *store.Customer
|
||||
Error: errMsg,
|
||||
CSRFField: s.csrfField(r),
|
||||
PBSDR: s.pbsDRViewFor(cfg.CustomerID, cfg.DRTier),
|
||||
Delivery: s.deliveryViewFor(cfg.CustomerID),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-70 card render tests — one per template branch (the v0.70.1 seam-wiring lesson: a
|
||||
// conditional affordance ships with a render test per branch of its gate; handler tests prove
|
||||
// nothing about reachability). The card replaces the static "delivered to the controller once"
|
||||
// copy, so the tests also pin that the old static claim is GONE.
|
||||
|
||||
func renderConfigFormWith(t *testing.T, delivery *deliveryView) string {
|
||||
t.Helper()
|
||||
s, _ := newTestServer(t)
|
||||
data := configFormView{
|
||||
Config: &store.CustomerConfig{CustomerID: "c1"},
|
||||
Overrides: map[string]interface{}{
|
||||
"offsite": map[string]interface{}{
|
||||
"enabled": true, "type": "shared", "host": "u-sub1.example.de",
|
||||
"user": "u-sub1", "repo_path": "/home/felhom-repo",
|
||||
},
|
||||
},
|
||||
ActiveNav: "configs",
|
||||
Delivery: delivery,
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := s.templates.ExecuteTemplate(&b, "config_form.html", data); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestDeliveryCard_AppliedRenders(t *testing.T) {
|
||||
out := renderConfigFormWith(t, &deliveryView{
|
||||
State: "applied", Badge: "applied", BadgeClass: "n-ok",
|
||||
Line: "offsite active on the box (last report 2 min ago)",
|
||||
})
|
||||
if !strings.Contains(out, `data-delivery-state="applied"`) || !strings.Contains(out, "offsite active on the box") {
|
||||
t.Fatalf("applied state not rendered:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `class="n n-ok"`) {
|
||||
t.Fatal("applied badge must use n-ok")
|
||||
}
|
||||
if strings.Contains(out, "the transient password is delivered to the controller once") {
|
||||
t.Fatal("the old static 'delivered once' claim must be GONE — it hid a burned credential for 2 days (DIAG-f10)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCard_ConsumedAwaitingApplyAmberRenders(t *testing.T) {
|
||||
out := renderConfigFormWith(t, &deliveryView{
|
||||
State: "consumed_awaiting_apply", Badge: "consumed", BadgeClass: "n-warn",
|
||||
Line: "password consumed 2.0 h ago and the box still reports no offsite target — likely burned mid-apply; Re-issue delivers a fresh one",
|
||||
})
|
||||
if !strings.Contains(out, `data-delivery-state="consumed_awaiting_apply"`) ||
|
||||
!strings.Contains(out, "likely burned mid-apply") || !strings.Contains(out, `class="n n-warn"`) {
|
||||
t.Fatalf("amber consumed_awaiting_apply state not rendered:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCard_StagedRenders(t *testing.T) {
|
||||
out := renderConfigFormWith(t, &deliveryView{
|
||||
State: "staged_awaiting_consume", Badge: "staged", BadgeClass: "n-neutral",
|
||||
Line: "one-time password staged 3 min ago — the box consumes it on its next config refresh",
|
||||
})
|
||||
if !strings.Contains(out, `data-delivery-state="staged_awaiting_consume"`) ||
|
||||
!strings.Contains(out, "the box consumes it on its next config refresh") {
|
||||
t.Fatalf("staged state not rendered:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The demo-felhom shape: applied + the stale-staged info line — BOTH must render.
|
||||
func TestDeliveryCard_AppliedWithStaleStagedInfoLine(t *testing.T) {
|
||||
out := renderConfigFormWith(t, &deliveryView{
|
||||
State: "applied", Badge: "applied", BadgeClass: "n-ok",
|
||||
Line: "offsite active on the box (last report 2 min ago)",
|
||||
StaleLine: "Note: an unconsumed one-time secret has been staged since 2026-07-21 08:29 UTC (2 days ago) — superseded by the working install (key-auth-first never consumes); harmless, replaced by the next re-issue.",
|
||||
})
|
||||
if !strings.Contains(out, `data-delivery-state="applied"`) ||
|
||||
!strings.Contains(out, "an unconsumed one-time secret has been staged since 2026-07-21") {
|
||||
t.Fatalf("applied + stale-staged info line not rendered:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Nil Delivery (brand-new config / detector error): the card is simply absent — never a
|
||||
// fabricated state line.
|
||||
func TestDeliveryCard_NilDeliveryOmitted(t *testing.T) {
|
||||
out := renderConfigFormWith(t, nil)
|
||||
if strings.Contains(out, "data-delivery-state") {
|
||||
t.Fatalf("nil Delivery must render no state line:\n%s", out)
|
||||
}
|
||||
// the target line survives (it is descriptor truth, not delivery state)
|
||||
if !strings.Contains(out, "u-sub1@u-sub1.example.de:/home/felhom-repo") {
|
||||
t.Fatal("provisioned target line must still render")
|
||||
}
|
||||
}
|
||||
|
||||
// deliveryViewFor derives from the real detector: the burned shape past 30 min renders amber.
|
||||
func TestDeliveryViewFor_BurnedShapeGoesAmber(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c9", APIKey: "k", RetrievalPassword: "p"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveOneTimeSecret("c9", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetOneTimeSecretTimesForTest("c9", "2026-01-01 00:00:00", "2026-01-01 00:03:00"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveReport("c9", []byte(`{"health":{"status":"ok"}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := s.deliveryViewFor("c9")
|
||||
if v == nil || v.State != "consumed_awaiting_apply" || v.BadgeClass != "n-warn" {
|
||||
t.Fatalf("view = %+v, want consumed_awaiting_apply with n-warn (amber past 30 min)", v)
|
||||
}
|
||||
if !strings.Contains(v.Line, "Re-issue") {
|
||||
t.Fatalf("amber line must point at the remedy, got %q", v.Line)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{{with .Overrides}}{{with index . "offsite"}}{{if index . "host"}}
|
||||
<p class="form-hint" style="margin-top:.5rem">Provisioned: {{index . "user"}}@{{index . "host"}}:{{index . "repo_path"}} — the transient password is delivered to the controller once (never shown here).</p>
|
||||
<p class="form-hint" style="margin-top:.5rem">Provisioned: {{index . "user"}}@{{index . "host"}}:{{index . "repo_path"}} (the transient password is never shown here).</p>
|
||||
<!-- R-70: the delivery state replaces the old static "delivered once" claim — the hub
|
||||
now RENDERS what it knows (one_time_secrets × report offbox presence) instead of
|
||||
asserting a delivery it never checked (DIAG-f10: a burned credential hid 2 days). -->
|
||||
{{with $.Delivery}}
|
||||
<p class="form-hint" style="margin-top:.35rem" data-delivery-state="{{.State}}"><span class="n {{.BadgeClass}}">{{.Badge}}</span> {{.Line}}</p>
|
||||
{{if .StaleLine}}<p class="form-hint" style="margin-top:.35rem">{{.StaleLine}}</p>{{end}}
|
||||
{{end}}
|
||||
<!-- F4: explicit operator recovery for a consumed-password dead-end (fresh-guest DR).
|
||||
Rides the parent form (nested forms are invalid HTML) via formaction; the _csrf field
|
||||
submits with it. Resets the box credential + stages a fresh one-time password. -->
|
||||
|
||||
Reference in New Issue
Block a user