R-366 slice 2 (decision 168): one operator line when the box's whole-guest archives include another key's (edge-triggered)

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:
2026-10-07 10:18:50 +02:00
parent e03b18ea21
commit 9c94a9c6c0
5 changed files with 169 additions and 1 deletions
+74 -1
View File
@@ -11,6 +11,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
@@ -688,7 +689,13 @@ type hostReportPayload struct {
Backups []hostBackup `json:"backups"` // slice 6
RestoreTests []hostRestoreTest `json:"restore_tests"` // slice 6
PBSSnapshots []hostPBSSnapshot `json:"pbs_snapshots"` // slice 6 Phase B
Cloudflared struct {
// ForeignKeyArchives (R-366 slice 2, agent >= the 2026-10-07 release): per tier, the whole-guest archives the
// restore-test skipped as written with another key. Absent = not evaluated / an older agent → the hub keeps its
// state; `tiers: []` = evaluated, none.
ForeignKeyArchives *struct {
Tiers []hostForeignKeyArchives `json:"tiers"`
} `json:"foreign_key_archives"`
Cloudflared struct {
Status string `json:"status"` // agent >= 0.141.0: running | not_running | unknown (older: active | inactive | …)
Detail string `json:"detail,omitempty"`
} `json:"cloudflared"`
@@ -703,6 +710,14 @@ type hostReportPayload struct {
} `json:"log_tail"`
}
// hostForeignKeyArchives mirrors the agent's hub.ForeignKeyArchives (R-366 slice 2).
type hostForeignKeyArchives struct {
Target string `json:"target"`
Count int `json:"count"`
Oldest string `json:"oldest"`
Newest string `json:"newest"`
}
// drRecipeVersionOnly extracts just recipe_version from a half's JSON (ignore-unknown). 0 if absent.
type drRecipeVersionOnly struct {
RecipeVersion int `json:"recipe_version"`
@@ -926,6 +941,9 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
}
}
if rep.ForeignKeyArchives != nil {
h.noteForeignKeyArchives(custID, hostID, rep.ForeignKeyArchives.Tiers)
}
for _, bk := range rep.Backups {
if !bk.Success {
h.logger.Printf("[WARN] host %s backup FAILED: target=%s vmid=%d err=%q",
@@ -1316,6 +1334,61 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa
// registered operator-only in notify.operatorOnlyEvents.
const eventRepoKeyChanged = "offsite_repo_key_changed"
// eventForeignKeyArchives (R-366 slice 2, `09` §3 decision 168) — the box's whole-guest copies include archives an
// earlier install wrote with another key, which this box cannot open. Hub-internal, severity info: ONE line on the
// operator's timeline per change of the set (not per archive, not per report); never mailed, never the household's.
const eventForeignKeyArchives = "restore_test_foreign_key_archives"
// noteForeignKeyArchives turns a CHANGE of the reported set into one operator event (the caller skips an absent
// stanza, which keeps the state); an empty set clears it without an event. Pinned by TestR366_ForeignKeyArchives*.
func (h *Handler) noteForeignKeyArchives(customerID, hostID string, set []hostForeignKeyArchives) {
parts := make([]string, 0, len(set))
for _, f := range set {
if f.Count > 0 {
parts = append(parts, fmt.Sprintf("%s:%d:%s:%s", f.Target, f.Count, f.Oldest, f.Newest))
}
}
sort.Strings(parts)
sig := strings.Join(parts, ";")
if sig == h.store.ForeignKeyArchiveSignature(hostID) {
return
}
if err := h.store.SetForeignKeyArchiveSignature(hostID, sig); err != nil {
h.logger.Printf("[WARN] host %s: storing the foreign-key archive state failed: %v", hostID, err)
return
}
if sig == "" {
h.logger.Printf("[INFO] host %s: no whole-guest archive written with another key any more (R-366)", hostID)
return
}
var lines []string
total := 0
for _, f := range set {
if f.Count > 0 {
total += f.Count
lines = append(lines, fmt.Sprintf("%d on %s (%s … %s)", f.Count, f.Target, dayOf(f.Oldest), dayOf(f.Newest)))
}
}
retained, _ := h.store.CountSupersededEscrow(hostID)
msg := fmt.Sprintf("Host %s: %d whole-guest backup archive(s) were written with ANOTHER key (an earlier install of this box) — %s. "+
"This box cannot open them, so its restore test skips them. The hub holds %d retained escrow blob(s) for this host; "+
"the server-side prune removes the old archives as new copies land (R-366).", hostID, total, strings.Join(lines, "; "), retained)
details, _ := json.Marshal(map[string]any{"host_id": hostID, "archives": set, "retained_escrow": retained})
if _, err := h.store.SaveEvent(customerID, eventForeignKeyArchives, "info", msg, string(details), "hub"); err != nil {
h.logger.Printf("[WARN] %s event save failed for %s: %v", eventForeignKeyArchives, hostID, err)
return
}
h.logger.Printf("[INFO] host %s: %d whole-guest archive(s) written with another key — operator event recorded (R-366)", hostID, total)
}
// dayOf returns the date part of an RFC3339 time ("" stays "").
func dayOf(ts string) string {
if len(ts) >= 10 {
return ts[:10]
}
return ts
}
// eventEscrowBlobServed (R-199) — a host retrieved its own sealed identity blob. Hub-internal,
// operator-only. See handleHostEscrowGet for why every retrieval is loud.
const eventEscrowBlobServed = "escrow_blob_served"