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>
3 files changed,
+58
−11
internal/console/selfserve_checks.go+43 −5internal/console/selfserve_checks_test.go+13 −4internal/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 { |