console: return mode applies the free-disk and programs-file rules; a console without a secrets key does not refuse the target's
Fix round 1, Important 2 and minor 8.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+45
−7
internal/console/offlinereturn.go+26 −4internal/console/offlinereturn_test.go+19 −3
diff --git a/internal/console/offlinereturn.go b/internal/console/offlinereturn.go| index 6b700c26b..f3587befd 100644 |
| --- a/internal/console/offlinereturn.go |
| +++ b/internal/console/offlinereturn.go |
| @@ -247,8 +247,6 @@ func (s *Server) readReturnReport(ctx context.Context, host, returnOf string, o |
| // - the nested directory and its mount view: absent, or present and |
| // empty, for a nested forward; absent for a flat one; |
| // - park directories: absent or the forward's own source/ and sealer/; |
| -// - the programs file: present or absent (it names the forward's |
| -// programs); |
| // - everything else: the ordinary rules. |
| func returnReadyDecision(host, label string, o *moves.OpenRecord, r *moves.TargetReadyReport, want targetWant) string { |
| if r == nil || r.Leftovers == nil || r.Leftovers.ForwardID != o.MoveID { |
| @@ -302,6 +300,8 @@ func returnReadyDecision(host, label string, o *moves.OpenRecord, r *moves.Targe |
| return ssStaleLiveLinkHere(host) |
| case !r.ProgramsFile.Known: |
| return couldNotDetermine(factProgramsFile, label) |
| + case !r.ProgramsFile.OK: |
| + return ssProgramsEntry(host) |
| } |
| for _, p := range targetDataPaths(host, r.DataPaths) { |
| f, ok := r.DataPaths[p] |
| @@ -319,7 +319,7 @@ func returnReadyDecision(host, label string, o *moves.OpenRecord, r *moves.Targe |
| switch { |
| case !r.ParkDir.Known: |
| return couldNotDetermine(factParkDir, label) |
| - case !r.DiskFree.Known: |
| + case !r.DiskFree.Known || r.DiskFreeBytes < 2*returnReplicaBytes(r): |
| return ssDiskLow(host) |
| case !r.MovesPrefix.Known: |
| return couldNotDetermine(factMovesPrefix, label) |
| @@ -335,12 +335,34 @@ func returnReadyDecision(host, label string, o *moves.OpenRecord, r *moves.Targe |
| return ssLacksCapability(c) |
| } |
| } |
| - if want.secretsFP == "" || r.SecretsKeyFP != want.secretsFP { |
| + switch { |
| + case want.secretsFP == "": |
| + // This console has no secrets key configured: the target's key |
| + // cannot be compared, so it is not refused for it. |
| + log.Printf("console: offline move %s: no secrets key fingerprint on this console; the return's target key is not compared", host) |
| + case r.SecretsKeyFP != want.secretsFP: |
| return msgSelfServeKeyMismatch |
| } |
| return "" |
| } |
| +// returnReplicaBytes is the replica a return carries, as the report lists |
| +// the target bucket's instances prefix (the forward's leftover replica, |
| +// which the return's newer copy replaces): the free disk must be twice it. |
| +func returnReplicaBytes(r *moves.TargetReadyReport) int64 { |
| + var n int64 |
| + for _, o := range r.InstancesPrefix.Objects { |
| + n += o.Size |
| + } |
| + return n |
| +} |
| + |
| +// ssProgramsEntry is return mode's programs file with an entry for the host |
| +// (the ordinary rule: OK only when the host has none; pending copy review). |
| +func ssProgramsEntry(host string) string { |
| + return fmt.Sprintf("the target box's programs file has an entry for %s", host) |
| +} |
| + |
| // ssStaleLiveLinkHere is return mode's live link that does not name the |
| // channel's build (pending copy review). |
| func ssStaleLiveLinkHere(host string) string { |
diff --git a/internal/console/offlinereturn_test.go b/internal/console/offlinereturn_test.go| index 10441cab9..40528d0ed 100644 |
| --- a/internal/console/offlinereturn_test.go |
| +++ b/internal/console/offlinereturn_test.go |
| @@ -147,9 +147,6 @@ func TestOperatorReturnHandshakeAtTheConsole(t *testing.T) { |
| "flat, the sealer's park directory only": func(w *returnWorld, r *moves.TargetReadyReport) { |
| r.Leftovers.ParkDirs = []string{returnFwd + "/sealer/"} |
| }, |
| - "programs file present": func(w *returnWorld, r *moves.TargetReadyReport) { |
| - r.ProgramsFile = moves.Fact{Known: true, Detail: "./cmd/shop"} |
| - }, |
| } { |
| t.Run(name, func(t *testing.T) { |
| w := newReturnWorld(t, nil) |
| @@ -215,6 +212,12 @@ func TestOperatorReturnHandshakeAtTheConsole(t *testing.T) { |
| "a stale live link": {func(w *returnWorld, r *moves.TargetReadyReport) { |
| r.LiveLink = moves.Fact{Known: true, Detail: "/opt/carlos/versions/2026-10-01.1/bab/shop"} |
| }, "the target box has a stale live link for " + offlineHost}, |
| + "a programs file entry": {func(w *returnWorld, r *moves.TargetReadyReport) { |
| + r.ProgramsFile = moves.Fact{Known: true, Detail: "./cmd/shop"} |
| + }, "the target box's programs file has an entry for " + offlineHost}, |
| + "free disk under twice the replica": {func(w *returnWorld, r *moves.TargetReadyReport) { |
| + r.InstancesPrefix.Objects["0000/a.ltx"] = moves.PrefixObject{Size: 6 << 30, LastModified: w.open.OpenedAt.Add(-time.Minute)} |
| + }, "the target box has too little free disk for " + offlineHost}, |
| "timers left": {func(w *returnWorld, r *moves.TargetReadyReport) { r.TimersAbsent.OK = false }, |
| "the target box has timers for " + offlineHost}, |
| "leftovers of another move": {func(w *returnWorld, r *moves.TargetReadyReport) { r.Leftovers.ForwardID = "mv_other" }, |
| @@ -297,6 +300,19 @@ func TestOperatorReturnHandshakeAtTheConsole(t *testing.T) { |
| t.Fatalf("after the start: %+v", rec.Move) |
| } |
| }) |
| + t.Run("a console without a secrets key does not refuse the target's", func(t *testing.T) { |
| + w := newReturnWorld(t, nil) |
| + r := w.ready() |
| + r.SecretsKeyFP = "another" |
| + want := targetWant{caps: []string{offlineMoveCapability, offlineStageCapability}} |
| + if got := returnReadyDecision(offlineHost, "uk-box", &w.open, r, want); got != "" { |
| + t.Fatalf("no fingerprint to compare: %q", got) |
| + } |
| + want.secretsFP = testKeyFP |
| + if got := returnReadyDecision(offlineHost, "uk-box", &w.open, r, want); got != "the target box's secrets key does not match" { |
| + t.Fatalf("another key: %q", got) |
| + } |
| + }) |
| t.Run("a start or stage without return_of is unchanged", func(t *testing.T) { |
| w := newReturnWorld(t, nil) |
| if code, out := w.req(http.MethodPost, "/stage", `{"region":"uk"}`); code != http.StatusAccepted { |