v0.162.0 — R-71(a): the apply-bridge settle-gate (kills the F10 day-0 race)
The day-0 race (DIAG-f10): a fresh box boots below the operator floor, the apply-bridge consumes the single-use offsite password, then ~35s later the managed auto-floor update replaces the container mid-install -> the new process finds no installed key -> consume -> 404 -> offsite dead until an operator Re-issue. Recurs on every onboarding whose ISO floor lags the managed floor. Ordering-only fix (consume/install/persist internals + the 404-no-oracle contract + the Consumer UNTOUCHED; R-71(b) rejected-by-design): - New seam offsiteapply.SettleProvider.SettleState() + SettleFunc adapter over the self-updater's own GetFloor()/IsUpdateRunning() (no second floor path). - Bridge.AwaitSettle polls 10s BEFORE the 3-min Reconcile ctx: defers while an update runs or the box is below the known floor; GOes at/above floor on the first poll with zero added latency (B'). Bounds 90s floor sub-bound / 5min overall, both GO+WARN (hub that can't serve a floor can't serve a consume -> no burn risk; R-71c is the belt). ReconcileWhenSettled = gate then reconcile. - main.go: bridge goroutine moved after the updater is built; wired only when an updater exists (nil Settle = reconcile immediately, old behavior). Finding: the floor is in-memory (report-ACK ~5-10s), NOT persisted -> unknown on any restart until the first ACK; the 90s sub-bound is sized to that. Tests (injectable clock, fake SettleState, recorded Consumer): A-E + nil-provider + cancelled-gate. Four red-proofs all observed FAIL then restored: gate removed / updateRunning branch / floor sub-bound / overall bound. Deferral paths ship unit-proven + red-proofed, NOT live-fired -- their precondition is now structurally prevented by the v1.25.0 build gate. Layering: gate prevents, (a) defers, (c) heals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7Drmtm2RzoqbkJZCNSFNQ
This commit is contained in:
@@ -287,39 +287,9 @@ func main() {
|
||||
backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba)
|
||||
}
|
||||
|
||||
// SLICE 2: the offsite apply-bridge — on startup (async, non-blocking) reconcile the hub-served offsite
|
||||
// descriptor into a configured key-only offbox target (fail-safe, idempotent, no blind TOFU). The
|
||||
// config_refresh self-restart re-runs this after a descriptor change (new process → startup).
|
||||
if backupMgr != nil && cfg.Offsite.Enabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
|
||||
bridge := &offsiteapply.Bridge{
|
||||
Cfg: cfg,
|
||||
Consumer: offsiteapply.HTTPConsumer{HubURL: cfg.Hub.URL, CustomerID: cfg.Customer.ID, APIKey: cfg.Hub.APIKey},
|
||||
Scanner: offsiteapply.KeyscanScanner{},
|
||||
KeyGen: offsiteapply.ED25519KeyGen{},
|
||||
Installer: offsiteapply.SSHCopyIDInstaller{},
|
||||
Prober: offsiteapply.SFTPKeyAuthProber{KeyPath: filepath.Join(cfg.Paths.DataDir, "offbox", "ssh_key")},
|
||||
Enabler: offsiteapply.EnablerFunc(func(ctx context.Context, host, user string, port int, repoPath, priv, kh string, quotaGB int) error {
|
||||
tgt := &settings.OffboxTarget{Enabled: true, Host: host, User: user, Port: port, RepoPath: repoPath, Schedule: "daily", QuotaGB: quotaGB}
|
||||
stage := func(ctx context.Context, pw string) error {
|
||||
ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.StageEscrowSecret(ctx, pw)
|
||||
}
|
||||
return backupMgr.ApplyOffsiteTarget(ctx, tgt, priv, kh, stage)
|
||||
}),
|
||||
MarkerPath: filepath.Join(cfg.Paths.DataDir, "offbox", "applied_marker"),
|
||||
Logger: logger,
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
if err := bridge.Reconcile(ctx); err != nil {
|
||||
logger.Printf("[WARN] [offsite-apply] reconcile: %v (retries on next config refresh/restart)", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// SLICE 2: the offsite apply-bridge is launched further down, AFTER the self-updater is constructed
|
||||
// (R-71a: the bridge's settle-gate reads the updater's floor/update-running state to defer the
|
||||
// consume past a managed day-0 floor-update). See "offsite apply-bridge" below.
|
||||
|
||||
// --- Wire the data-migration engine (B1) + backup↔migration mutual exclusion (Change 3) ---
|
||||
stackMgr.SetMigrationDeps(sett, func() bool { return backupMgr != nil && backupMgr.IsRunning() })
|
||||
@@ -383,6 +353,54 @@ func main() {
|
||||
cfg.SelfUpdate.CheckInterval, cfg.SelfUpdate.AutoUpdate, cfg.SelfUpdate.AutoUpdateTime)
|
||||
}
|
||||
|
||||
// SLICE 2: the offsite apply-bridge — on startup (async, non-blocking) reconcile the hub-served
|
||||
// offsite descriptor into a configured key-only offbox target (fail-safe, idempotent, no blind
|
||||
// TOFU). The config_refresh self-restart re-runs this after a descriptor change (new process →
|
||||
// startup). Launched HERE (after the self-updater is built) so the R-71a settle-gate can read the
|
||||
// updater's floor/update-running state and defer the one-time-password consume past a managed
|
||||
// day-0 floor-update (the F10 race). The gate is wired ONLY when an updater exists — with no update
|
||||
// mechanism there is no floor-update to race, so the bridge reconciles immediately (Settle nil).
|
||||
if backupMgr != nil && cfg.Offsite.Enabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
|
||||
bridge := &offsiteapply.Bridge{
|
||||
Cfg: cfg,
|
||||
Consumer: offsiteapply.HTTPConsumer{HubURL: cfg.Hub.URL, CustomerID: cfg.Customer.ID, APIKey: cfg.Hub.APIKey},
|
||||
Scanner: offsiteapply.KeyscanScanner{},
|
||||
KeyGen: offsiteapply.ED25519KeyGen{},
|
||||
Installer: offsiteapply.SSHCopyIDInstaller{},
|
||||
Prober: offsiteapply.SFTPKeyAuthProber{KeyPath: filepath.Join(cfg.Paths.DataDir, "offbox", "ssh_key")},
|
||||
Enabler: offsiteapply.EnablerFunc(func(ctx context.Context, host, user string, port int, repoPath, priv, kh string, quotaGB int) error {
|
||||
tgt := &settings.OffboxTarget{Enabled: true, Host: host, User: user, Port: port, RepoPath: repoPath, Schedule: "daily", QuotaGB: quotaGB}
|
||||
stage := func(ctx context.Context, pw string) error {
|
||||
ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.StageEscrowSecret(ctx, pw)
|
||||
}
|
||||
return backupMgr.ApplyOffsiteTarget(ctx, tgt, priv, kh, stage)
|
||||
}),
|
||||
MarkerPath: filepath.Join(cfg.Paths.DataDir, "offbox", "applied_marker"),
|
||||
Logger: logger,
|
||||
}
|
||||
if updater != nil {
|
||||
// Thin adapter over the updater's OWN knowledge (StackDataProvider pattern) — the bridge
|
||||
// never fetches the floor a second way. floorKnown = the floor has been learned from a
|
||||
// report ACK yet (GetFloor() != "").
|
||||
u := updater
|
||||
bridge.Settle = offsiteapply.SettleFunc(func() (string, string, bool, bool) {
|
||||
floor := u.GetFloor()
|
||||
return Version, floor, u.IsUpdateRunning(), floor != ""
|
||||
})
|
||||
}
|
||||
go func() {
|
||||
// ReconcileWhenSettled runs the settle-gate FIRST (its own bounds), then Reconcile under a
|
||||
// fresh 3-minute context — the gate's wait never eats the reconcile budget.
|
||||
if err := bridge.ReconcileWhenSettled(context.Background()); err != nil {
|
||||
logger.Printf("[WARN] [offsite-apply] reconcile: %v (retries on next config refresh/restart)", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// --- Initialize scheduler ---
|
||||
sched := scheduler.New(logger)
|
||||
sched.SetDebug(cfg.Logging.Level == "debug")
|
||||
|
||||
Reference in New Issue
Block a user