kept second-drive copies (D25: R-1175, R-1181, R-1179); .fab import keeps the family gate (R-1191); stale restore temp (R-1188 c); the overcommit sentence made true (R-1192); escrow before the floor (R-1176); unreleased
gates / gates (push) Successful in 1m20s
gates / gates (push) Successful in 1m20s
This commit is contained in:
@@ -1167,6 +1167,11 @@ func main() {
|
||||
// Hand it to the updater and reconcile — if the box is below the floor it auto-updates to
|
||||
// the floor (reusing the Phase 1 swap). This rides the existing report cycle; no new timer.
|
||||
// latest_version stays informational (the customer's opt-in button), NOT the auto-target.
|
||||
// SLICE 3: run the escrow auto-confirm on the same ACK. R-1176 (2026-10-21): it runs BEFORE SetFloor, because
|
||||
// the off-site apply-bridge's settle gate GOes the moment the floor is known — and a rebuilt box must already
|
||||
// have recorded „the hub holds the household's package" by then, or its mint guard (R-241) cannot stop a
|
||||
// fresh repository password. Pinned by TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown.
|
||||
escrowConfirmer.Reconcile(resp.Escrow)
|
||||
if updater != nil {
|
||||
updater.SetFloor(resp.MinControllerVersion)
|
||||
updater.MaybeAutoUpdate()
|
||||
@@ -1196,9 +1201,6 @@ func main() {
|
||||
Logger: logger,
|
||||
}
|
||||
cr.Reconcile(resp.ConfigVersion)
|
||||
// SLICE 3: run the escrow auto-confirm on the same ACK (after the config refresh decision —
|
||||
// a refresh-restart re-enters here anyway on the next cycle).
|
||||
escrowConfirmer.Reconcile(resp.Escrow)
|
||||
// v0.111.0: operator log-tail requests ride the same ACK (pull pattern — the hub
|
||||
// never reaches in). The NEXT report cycle collects + ships the tails; the hub
|
||||
// clears its pending request on receipt. An empty list clears any stale local set.
|
||||
@@ -3380,6 +3382,9 @@ func (a *geoStackAdapter) GetDeployedHostnames() map[string]string {
|
||||
type exportAdapter struct {
|
||||
mgr *stacks.Manager
|
||||
encKey []byte
|
||||
// R-1191: whether the app had no record before the import (SaveEncryptedAppConfig → PrepareImportedApp).
|
||||
importFreshMu sync.Mutex
|
||||
importFresh map[string]bool
|
||||
}
|
||||
|
||||
func (a *exportAdapter) GetStackDir(name string) (string, bool) {
|
||||
@@ -3522,20 +3527,27 @@ func (a exportStopGuard) End() { a.g.End() }
|
||||
func (a exportStopGuard) ReleaseFailed(stacks ...string) { a.g.ReleaseFailed(stacks...) }
|
||||
|
||||
func (a *exportAdapter) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
|
||||
meta := stacks.LoadMetadata(stackDir)
|
||||
sensitiveVars := stacks.SensitiveEnvVars(&meta)
|
||||
cfg := &stacks.AppConfig{
|
||||
Deployed: true,
|
||||
DeployedAt: time.Now().Format(time.RFC3339),
|
||||
Env: env,
|
||||
// R-166 — a CUSTOMER-INTENT POINT, and the one that is not the API action switch. Importing
|
||||
// a `.fab` bundle is the customer installing that app on this box, and the import path starts
|
||||
// it (appexport/restore.go). Without this the app would come back from a restore with NO
|
||||
// recorded intent and fall to legacy boot behaviour — meaning a power cut days later would
|
||||
// strand it, which is exactly the failure this release exists to remove.
|
||||
DesiredState: stacks.DesiredStateRunning,
|
||||
// R-1191: the record the unit restore would build (locked fields, the prior record's life records, desired state
|
||||
// running — R-166), not a bare one; the gates follow in PrepareImportedApp, once the compose file is in place.
|
||||
fresh, err := a.mgr.SaveImportedAppConfig(stackDir, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return stacks.SaveAppConfig(stackDir, cfg, a.encKey, sensitiveVars)
|
||||
a.importFreshMu.Lock()
|
||||
if a.importFresh == nil {
|
||||
a.importFresh = map[string]bool{}
|
||||
}
|
||||
a.importFresh[stackDir] = fresh
|
||||
a.importFreshMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *exportAdapter) PrepareImportedApp(stackDir string, env map[string]string) error {
|
||||
a.importFreshMu.Lock()
|
||||
fresh := a.importFresh[stackDir]
|
||||
delete(a.importFresh, stackDir)
|
||||
a.importFreshMu.Unlock()
|
||||
return a.mgr.PrepareImportedApp(stackDir, env, fresh)
|
||||
}
|
||||
|
||||
func (a *exportAdapter) RefreshStacks() error {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1176 (2026-10-21): the off-site apply-bridge waits only until the FLOOR is known (offsiteapply.AwaitSettle), and
|
||||
// then may mint a repository password unless the box has recorded that the hub holds the household's sealed package
|
||||
// (R-241's mint guard reads that record). Both facts arrive in the same report ACK, so inside OnPushResponse the
|
||||
// escrow must be recorded BEFORE updater.SetFloor makes the floor known — otherwise a bridge poll landing between the
|
||||
// two mints a fresh key on a rebuilt box and the household's history is set aside on its first night.
|
||||
// Walks main.go's AST with comments dropped, so a commented-out call cannot satisfy it.
|
||||
// COMPANION RED-PROOF: move escrowConfirmer.Reconcile below updater.SetFloor → this fails.
|
||||
func TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var escrowPos, floorPos token.Pos
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
as, ok := n.(*ast.AssignStmt)
|
||||
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
|
||||
return true
|
||||
}
|
||||
sel, ok := as.Lhs[0].(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "OnPushResponse" {
|
||||
return true
|
||||
}
|
||||
ast.Inspect(as.Rhs[0], func(m ast.Node) bool {
|
||||
call, ok := m.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if s, ok := call.Fun.(*ast.SelectorExpr); ok {
|
||||
if x, ok := s.X.(*ast.Ident); ok {
|
||||
switch {
|
||||
case x.Name == "escrowConfirmer" && s.Sel.Name == "Reconcile" && escrowPos == 0:
|
||||
escrowPos = call.Pos()
|
||||
case x.Name == "updater" && s.Sel.Name == "SetFloor" && floorPos == 0:
|
||||
floorPos = call.Pos()
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
if escrowPos == 0 || floorPos == 0 {
|
||||
t.Fatalf("OnPushResponse no longer calls both escrowConfirmer.Reconcile (%v) and updater.SetFloor (%v) — the order is unasserted", escrowPos, floorPos)
|
||||
}
|
||||
if escrowPos > floorPos {
|
||||
t.Fatalf("escrowConfirmer.Reconcile (%s) runs AFTER updater.SetFloor (%s): a rebuilt box can mint a fresh key before it learns the hub holds the household's package",
|
||||
fset.Position(escrowPos), fset.Position(floorPos))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user