R-204 item 4 (box half): a rebuilt box DECLARES that it needs a credential (v0.199.0)
An absent off-site object has four meanings — never configured, mid-restart, a transient config read failure, and rebuilt-and-stranded — and the hub cannot tell them apart. The box can, from two local facts it holds with certainty, so it says so instead of leaving the hub to deduce it from a silence (operator ruling). The ACK's identity_blob_present is now recorded on EVERY ACK, before the gates that used to discard it: on a box with no off-site target the auto-confirm returns immediately, which is exactly a rebuilt box, so the one fact distinguishing it from a box that never had off-site backups was thrown away every cycle. The declaration needs BOTH halves — a fresh data area AND a hub-held recovery package. Freshness alone is a box that never had off-site backups; dropping that condition makes the whole fleet ask for credentials, which is what the Scenario B test exists to catch. The object carries enabled:false and zero sizes, which is what makes it inert to the hub's existing fill and staleness checkers and to a pre-upgrade hub. A configured box's JSON is byte-identical to v0.198.0's.
This commit is contained in:
@@ -1188,6 +1188,15 @@ func offboxAnchorAfterRun(prev, at string, runErr error) string {
|
||||
type OffboxReportStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EscrowState string `json:"escrow_state"`
|
||||
// State (v0.199.0, R-204 item 4 / R-193) is a DECLARED condition — the box naming its own
|
||||
// situation rather than the hub deducing it from a silence. Empty on every configured box, so a
|
||||
// healthy report's JSON is byte-identical to v0.198.0's.
|
||||
//
|
||||
// WHY A DECLARATION AND NOT AN INFERENCE (the operator ruling, 2026-08-05). An ABSENT off-site
|
||||
// object has FOUR meanings — never configured, mid-restart, a transient config read failure, and
|
||||
// rebuilt-and-stranded — and the hub cannot tell them apart. The BOX can, from two local facts it
|
||||
// holds with certainty. So it says so.
|
||||
State string `json:"state,omitempty"`
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
LastStatus string `json:"last_status,omitempty"` // "ok" | "incomplete" (R-203) | "error" | "running"
|
||||
// LastSuccess (R-100) is the last run that SUCCEEDED — the hub's staleness anchor. Absent on a
|
||||
@@ -1199,11 +1208,63 @@ type OffboxReportStatus struct {
|
||||
QuotaGB int `json:"quota_gb"`
|
||||
}
|
||||
|
||||
// OffboxReportStatus returns the offsite summary for the hub report (nil = not configured; the hub's
|
||||
// checker treats absence as "nothing to watch" — pre-v0.109 reports look the same).
|
||||
// OffsiteStateNeedsCredential is the ONE declared state (v0.199.0, R-204 item 4 / R-193): this box
|
||||
// has no off-site tier, holds no repository password, and the hub says it is keeping a sealed
|
||||
// recovery package for it — i.e. it is a REBUILT box whose predecessor spent the one-time provider
|
||||
// password, and it cannot configure its off-site tier without a credential it has no way to obtain.
|
||||
// That was the last of the four manual interventions the 2026-08-04 drill needed.
|
||||
const OffsiteStateNeedsCredential = "needs_credential"
|
||||
|
||||
// needsOffsiteCredential is the stranded-rebuild predicate. BOTH facts are required and neither is
|
||||
// sufficient on its own — this is the whole correctness of the feature:
|
||||
//
|
||||
// - the data area is FRESH (no repository password on disk). Alone, this is simply a box that never
|
||||
// had off-site backups, and declaring on it would make every un-configured box in the fleet ask
|
||||
// for a credential.
|
||||
// - the HUB holds a sealed recovery package (the ACK's identity_blob_present, cached in settings).
|
||||
// Alone, this is a healthy box that has run its ceremony.
|
||||
//
|
||||
// Only together do they mean "this box HAD an off-site tier, and no longer has what it needs to use
|
||||
// it". A target that exists but is DISABLED is not stranded either — the customer switched it off —
|
||||
// so the caller only consults this when there is no enabled target, and a non-nil disabled target
|
||||
// short-circuits to false here.
|
||||
func (m *Manager) needsOffsiteCredential(t *settings.OffboxTarget) bool {
|
||||
if t != nil {
|
||||
return false // a target exists (merely disabled) — the customer's own choice, not a rebuild
|
||||
}
|
||||
if m.settings == nil || !m.settings.GetHubEscrowIdentityPresent() {
|
||||
return false // the hub holds nothing for us: never had off-site backups
|
||||
}
|
||||
if _, ok := m.OffboxRepoPasswordHash(); ok {
|
||||
return false // we still hold our repository password: not a fresh data area
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OffboxReportStatus returns the offsite summary for the hub report.
|
||||
//
|
||||
// nil = nothing to say (not configured, and nothing to ask for) — the hub's checker treats absence as
|
||||
// "nothing to watch"; pre-v0.109 reports look the same.
|
||||
//
|
||||
// v0.199.0: there is now ONE case where an UNCONFIGURED box still reports an object — the stranded
|
||||
// rebuild, which DECLARES OffsiteStateNeedsCredential rather than leaving the hub to deduce it from a
|
||||
// silence. An absent object has FOUR meanings (never configured / mid-restart / a transient config
|
||||
// read failure / rebuilt-and-stranded) and the hub cannot tell them apart; the box can.
|
||||
//
|
||||
// WHY THE DECLARATION IS INERT TO EVERY EXISTING READER, established from their code rather than
|
||||
// assumed: it carries Enabled=false and zero quota/size, and the hub's OffsiteChecker gates
|
||||
// `isStale` on `!off.Enabled` (returns false) and `fillBand` on a zero quota/size (returns OK). So it
|
||||
// raises no staleness and no fill alarm on a new hub OR an old one, and an unknown `state` string is
|
||||
// ignored by encoding/json. The ONE reader that would have misread it is the store's
|
||||
// `reportHasOffsite` ("presence == applied-on-the-box"), which hub v0.96.0 tightens to require
|
||||
// enabled=true — provably a no-op for every report shape that exists today, because this function has
|
||||
// never emitted a disabled object before.
|
||||
func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || !t.Enabled {
|
||||
if m.needsOffsiteCredential(t) {
|
||||
return &OffboxReportStatus{Enabled: false, State: OffsiteStateNeedsCredential}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &OffboxReportStatus{
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-204 item 4 / R-193 — a REBUILT box declares that it needs an off-site credential, instead of
|
||||
// reporting an absence the hub cannot interpret.
|
||||
//
|
||||
// THE POINT OF THESE TESTS is the conjunction. An absent off-site object has FOUR meanings (never
|
||||
// configured / mid-restart / a transient read failure / rebuilt-and-stranded). The declaration has
|
||||
// one, and it is only sound because BOTH halves are required: a fresh data area AND a hub-held
|
||||
// recovery package. Scenario B is the one that matters most — drop the escrow half and every
|
||||
// un-configured box in the fleet starts asking for a credential.
|
||||
|
||||
// bareManager builds a Manager with NO off-site target and NO repository password — the shape of a
|
||||
// freshly rebuilt box before anything is configured.
|
||||
func bareManager(t *testing.T) (*Manager, *settings.Settings) {
|
||||
t.Helper()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
dataDir := t.TempDir()
|
||||
sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dataDir
|
||||
cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys")
|
||||
return NewManager(cfg, sett, lg), sett
|
||||
}
|
||||
|
||||
// SCENARIO A — a rebuilt box (fresh data area + a hub-held escrow) DECLARES the state.
|
||||
//
|
||||
// RED-PROOF: remove the `GetHubEscrowIdentityPresent()` condition from needsOffsiteCredential —
|
||||
// Scenario A still passes (it has an escrow), and SCENARIO B FAILS, which is the point: the plausible
|
||||
// wrong fix is to declare on freshness alone, and that would make every un-configured box in the
|
||||
// fleet ask for a credential.
|
||||
func TestOffsiteDeclare_RebuiltBoxDeclaresNeedsCredential(t *testing.T) {
|
||||
m, sett := bareManager(t)
|
||||
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := m.OffboxReportStatus()
|
||||
if st == nil {
|
||||
t.Fatal("a rebuilt box reported NO off-site object — the hub cannot distinguish it from a box that never had off-site backups (this is the defect)")
|
||||
}
|
||||
if st.State != OffsiteStateNeedsCredential {
|
||||
t.Fatalf("declared state = %q, want %q", st.State, OffsiteStateNeedsCredential)
|
||||
}
|
||||
// Enabled MUST be false and the sizes zero — that is what makes the declaration inert to the
|
||||
// hub's existing fill and staleness checkers (and to a pre-upgrade hub).
|
||||
if st.Enabled {
|
||||
t.Error("a declaration must not claim the tier is enabled — the hub's staleness check keys on it")
|
||||
}
|
||||
if st.QuotaGB != 0 || st.RepoSizeBytes != 0 || st.SnapshotCount != 0 {
|
||||
t.Errorf("a declaration must carry zero sizes (fill band keys on them): %+v", st)
|
||||
}
|
||||
// And it must be on the off-site object, not a new top-level field.
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"state":"needs_credential"`) {
|
||||
t.Fatalf("declared state absent from the marshalled off-site object: %s", b)
|
||||
}
|
||||
if !strings.Contains(string(b), `"enabled":false`) {
|
||||
t.Fatalf("marshalled object must carry enabled:false: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO B — a box that never had off-site backups says NOTHING. This is the guard on the
|
||||
// conjunction; without it the feature churns credentials fleet-wide.
|
||||
func TestOffsiteDeclare_NeverHadOffsiteSaysNothing(t *testing.T) {
|
||||
m, _ := bareManager(t) // fresh data area, but NO hub-held escrow
|
||||
|
||||
if st := m.OffboxReportStatus(); st != nil {
|
||||
t.Fatalf("a box that never had off-site backups DECLARED a need: %+v — every un-configured box in the fleet would now ask for a credential", st)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the conjunction: a box that still holds its repository password is NOT stranded,
|
||||
// even though the hub holds an escrow for it. That is simply a healthy box between configurations.
|
||||
func TestOffsiteDeclare_BoxThatStillHoldsItsRepoPasswordDoesNotDeclare(t *testing.T) {
|
||||
m, sett := bareManager(t)
|
||||
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Place a repository password exactly where the manager looks for it.
|
||||
if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(m.offboxPwPath(), []byte("a-repository-password"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if st := m.OffboxReportStatus(); st != nil {
|
||||
t.Fatalf("a box holding its repository password declared a need: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// A DISABLED target is the customer's own choice, not a rebuild — it must not declare either.
|
||||
func TestOffsiteDeclare_DisabledTargetIsNotStranded(t *testing.T) {
|
||||
m, sett := bareManager(t)
|
||||
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if st := m.OffboxReportStatus(); st != nil {
|
||||
t.Fatalf("a deliberately DISABLED target declared a need: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// A CONFIGURED box's report object must be byte-identical to v0.198.0's — no `state` key at all.
|
||||
// This is what lets a pre-upgrade hub and every existing checker read the fleet unchanged.
|
||||
func TestOffsiteDeclare_ConfiguredBoxJSONIsUnchanged(t *testing.T) {
|
||||
m, sett := bareManager(t)
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
|
||||
Schedule: "daily", EscrowState: "escrowed", LastStatus: "ok",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := m.OffboxReportStatus()
|
||||
if st == nil {
|
||||
t.Fatal("a configured box must still report an off-site object")
|
||||
}
|
||||
if st.State != "" {
|
||||
t.Errorf("a configured box must declare NO state, got %q", st.State)
|
||||
}
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), `"state"`) {
|
||||
t.Fatalf("a healthy report's JSON gained a `state` key — it must stay byte-compatible: %s", b)
|
||||
}
|
||||
if !strings.Contains(string(b), `"enabled":true`) {
|
||||
t.Fatalf("a configured box must report enabled:true: %s", b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user