carlos / platform Public

console: the target's stage path, capabilities and secrets key are checked (sentences pending Paul's review)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 4bd6dea24fef24a781f7943bdb42ecbb9ed47590 parent 2c9ec59
3 files changed, +58 −11
  • internal/console/selfserve_checks.go +43 −5
  • internal/console/selfserve_checks_test.go +13 −4
  • internal/console/selfserve_job.go +2 −2
diff --git a/internal/console/selfserve_checks.go b/internal/console/selfserve_checks.go
index c535ea969..2281f7e31 100644
--- a/internal/console/selfserve_checks.go
+++ b/internal/console/selfserve_checks.go
@@ -39,6 +39,7 @@ const (
factSeal = "the seal"
factLanding = "the landing"
factOpen = "the open"
+ factStagePath = "the stage path"
selfServeReadyMiB = 16 << 20
)
@@ -87,6 +88,29 @@ func ssStagedMismatch(host string) string {
}
func ssReplaced(label string) string { return fmt.Sprintf("box %s was replaced", label) }
+// The three target facts Task 8 checks (copy rows pending Paul's review).
+const (
+ msgSelfServeStageUnarmed = "the target box's stage path is not armed"
+ msgSelfServeKeyMismatch = "the target box's secrets key does not match"
+)
+
+func ssLacksCapability(capability string) string {
+ return fmt.Sprintf("the target box lacks a capability this move needs: %s", capability)
+}
+
+// targetWant is what a target's report is compared with beyond its own
+// facts: the deployment's secrets key fingerprint and the capabilities an
+// offline self-serve move needs.
+type targetWant struct {
+ secretsFP string
+ caps []string
+}
+
+// selfServeTargetWant is the deployment's.
+func (s *Server) selfServeTargetWant() targetWant {
+ return targetWant{secretsFP: s.expectedSecretsFP(), caps: []string{offlineMoveCapability, offlineStageCapability}}
+}
+
// The reasons the job's own outcomes carry (Task 7's copy rows).
const (
msgSelfServeNotStarted = "the move was not started in time"
@@ -137,11 +161,13 @@ func targetDataPaths(host string, reported map[string]moves.Fact) []string {
// fact must be known (a missing one cannot be determined) and pass:
// no route and no timers for the host, no live link naming another build,
// no entry in the programs file, none of the host's data paths, a clean
-// park directory, free disk for twice the replica, and no lock in the
-// staging bucket. beforeStaging also requires both bucket prefixes known
-// and empty; after staging the instances prefix must be (the staging
-// prefix is the staged copy, which the book comparison decides).
-func targetReadyDecision(host, label string, r *moves.TargetReadyReport, replicaBytes int64, beforeStaging bool) string {
+// park directory, free disk for twice the replica, no lock in the staging
+// bucket, root's stage path armed, every capability the move needs, and
+// the deployment's secrets key. beforeStaging also requires both bucket
+// prefixes known and empty; after staging the instances prefix must be
+// (the staging prefix is the staged copy, which the book comparison
+// decides).
+func targetReadyDecision(host, label string, r *moves.TargetReadyReport, replicaBytes int64, beforeStaging bool, want targetWant) string {
if r == nil {
return couldNotDetermine(factTargetFacts, label)
}
@@ -197,6 +223,18 @@ func targetReadyDecision(host, label string, r *moves.TargetReadyReport, replica
return couldNotDetermine(factLock, label)
case !r.Lock.OK:
return ssBucketLocked(host, r.Lock.Detail)
+ case !r.StagePathArmed.Known:
+ return couldNotDetermine(factStagePath, label)
+ case !r.StagePathArmed.OK:
+ return msgSelfServeStageUnarmed
+ }
+ for _, c := range want.caps {
+ if !containsString(r.Capabilities, c) {
+ return ssLacksCapability(c)
+ }
+ }
+ if want.secretsFP == "" || r.SecretsKeyFP != want.secretsFP {
+ return msgSelfServeKeyMismatch
}
return ""
}
diff --git a/internal/console/selfserve_checks_test.go b/internal/console/selfserve_checks_test.go
index c5cfbb30d..afce9ca2e 100644
--- a/internal/console/selfserve_checks_test.go
+++ b/internal/console/selfserve_checks_test.go
@@ -19,13 +19,14 @@ func TestSelfServeDecisionsSpeakEachFact(t *testing.T) {
ready := func() *moves.TargetReadyReport {
r := &moves.TargetReadyReport{RouteAbsent: ok, TimersAbsent: ok, LiveLink: ok, ProgramsFile: ok, ParkDir: ok, DiskFree: ok,
DiskFreeBytes: 10 << 30, MovesPrefix: moves.PrefixFacts{Known: true}, InstancesPrefix: moves.PrefixFacts{Known: true}, Lock: ok,
- DataPaths: map[string]moves.Fact{}}
+ DataPaths: map[string]moves.Fact{}, StagePathArmed: ok, Capabilities: []string{"offline-move-1", "offline-stage-1"}, SecretsKeyFP: "fp-1"}
for _, p := range targetDataPaths(host, nil) {
r.DataPaths[p] = ok
}
return r
}
- if got := targetReadyDecision(host, label, ready(), 1<<20, true); got != "" {
+ want := targetWant{secretsFP: "fp-1", caps: []string{"offline-move-1", "offline-stage-1"}}
+ if got := targetReadyDecision(host, label, ready(), 1<<20, true, want); got != "" {
t.Fatalf("a clean target: %q", got)
}
for _, c := range []struct {
@@ -59,10 +60,18 @@ func TestSelfServeDecisionsSpeakEachFact(t *testing.T) {
{"a lock", "the target bucket holds a lock for shop.example.com (moves/shop.example.com/lock.json)", func(r *moves.TargetReadyReport) {
r.Lock = moves.Fact{Known: true, Detail: "moves/shop.example.com/lock.json"}
}, 0},
+ // Task 8: the three facts Task 7 left unchecked (copy rows pending).
+ {"stage path not armed", "the target box's stage path is not armed", func(r *moves.TargetReadyReport) { r.StagePathArmed.OK = false }, 0},
+ {"stage path unknown", "could not determine the stage path on uk-box", func(r *moves.TargetReadyReport) { r.StagePathArmed = moves.Fact{} }, 0},
+ {"a capability missing", "the target box lacks a capability this move needs: offline-stage-1", func(r *moves.TargetReadyReport) {
+ r.Capabilities = []string{"offline-move-1"}
+ }, 0},
+ {"another secrets key", "the target box's secrets key does not match", func(r *moves.TargetReadyReport) { r.SecretsKeyFP = "fp-2" }, 0},
+ {"no secrets key", "the target box's secrets key does not match", func(r *moves.TargetReadyReport) { r.SecretsKeyFP = "" }, 0},
} {
r := ready()
c.edit(r)
- if got := targetReadyDecision(host, label, r, c.replica, true); got != c.want {
+ if got := targetReadyDecision(host, label, r, c.replica, true, want); got != c.want {
t.Errorf("%s: %q, want %q", c.name, got, c.want)
}
}
@@ -70,7 +79,7 @@ func TestSelfServeDecisionsSpeakEachFact(t *testing.T) {
// decides it, not the ready decision.
r := ready()
r.MovesPrefix.Objects = map[string]moves.PrefixObject{"db.ltx": {}}
- if got := targetReadyDecision(host, label, r, 0, false); got != "" {
+ if got := targetReadyDecision(host, label, r, 0, false, want); got != "" {
t.Errorf("after staging: %q", got)
}
diff --git a/internal/console/selfserve_job.go b/internal/console/selfserve_job.go
index b75674431..037c85678 100644
--- a/internal/console/selfserve_job.go
+++ b/internal/console/selfserve_job.go
@@ -960,7 +960,7 @@ func (s *Server) selfServeReportsVerdict(row *SelfServeMove, mv *provision.MoveS
if !ok || rd.Phase != moves.OfflinePhaseReady || !rd.Request.Equal(at) {
return ssVerdict{waiting: couldNotDetermine(factTargetFacts, tgt.Label)}
}
- if why := targetReadyDecision(host, tgt.Label, rd.Ready, facts.ReplicaBytes, true); why != "" {
+ if why := targetReadyDecision(host, tgt.Label, rd.Ready, facts.ReplicaBytes, true, s.selfServeTargetWant()); why != "" {
return ssVerdict{fail: why}
}
return ssVerdict{pass: true}
@@ -1146,7 +1146,7 @@ func (s *Server) selfServePrechecksVerdict(row *SelfServeMove, mv *provision.Mov
if !ok || stager.Phase != moves.OfflinePhaseStaged {
return ssVerdict{waiting: couldNotDetermine(factStagedCopy, mv.Stager)}
}
- if why := targetReadyDecision(host, tgt.Label, rd.Ready, stager.ReplicaBytes, false); why != "" {
+ if why := targetReadyDecision(host, tgt.Label, rd.Ready, stager.ReplicaBytes, false, s.selfServeTargetWant()); why != "" {
return ssVerdict{fail: why}
}
if err := moves.StagedMatches(stager.OfflineReport, mv.StageBucket, mv.StageRegion, row.StageRequest, rd.Ready.MovesPrefix, barrier); err != nil {