settings-3 task 6: only a change that turns a setting on waits for the fleet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+47
−5
internal/console/apiupdates.go+8 −4internal/console/apiupdates_test.go+38 −0internal/provision/plan9_b8_census_test.go+1 −1
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go| index eb5ed3bd6..715f32630 100644 |
| --- a/internal/console/apiupdates.go |
| +++ b/internal/console/apiupdates.go |
| @@ -151,7 +151,6 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| next := *rec |
| - enabling := false |
| majorSet := false // this request sets major to a number |
| if v, has := req["updates"]; has { |
| str, isStr := v.(string) |
| @@ -160,7 +159,6 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| next.Updates = str |
| - enabling = enabling || str == release.UpdatesManual |
| } |
| if v, has := req["major"]; has { |
| switch n := v.(type) { |
| @@ -173,7 +171,7 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| } |
| i := int(n) |
| next.Major = &i |
| - enabling, majorSet = true, true |
| + majorSet = true |
| case string: |
| if n != "" { |
| jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")) |
| @@ -201,7 +199,6 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| next.Pin = str |
| - enabling = enabling || str != "" |
| } |
| // Only a major THIS request sets is refused off stable. A major left by |
| // a channel move is ignored by reconcile (and shown as major_ignored); it |
| @@ -238,6 +235,13 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + // Only a change that turns a setting ON waits for the fleet: manual newly |
| + // set, a different pin, or a new major. A value re-sent unchanged beside |
| + // a clear (a form that sends its full state) is not an enable, so the |
| + // clear is never refused (§4.4). |
| + enabling := (next.Updates == release.UpdatesManual && rec.Updates != release.UpdatesManual) || |
| + (next.Pin != "" && next.Pin != rec.Pin) || |
| + (next.Major != nil && !sameMajor(rec.Major, next.Major)) |
| if enabling { |
| ok, blocking, err := s.updatesEnforced(ctx) |
| switch { |
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go| index 4485dcc6a..573b785e4 100644 |
| --- a/internal/console/apiupdates_test.go |
| +++ b/internal/console/apiupdates_test.go |
| @@ -167,6 +167,44 @@ func TestClearingIsNeverRefused(t *testing.T) { |
| } |
| } |
| +// Fix round 1: a value re-sent unchanged beside a clear is not an enable, so |
| +// a client that sends its full state can still clear while the fleet does not |
| +// enforce. A changed value still waits. |
| +func TestClearingBesideAnUnchangedEnableIsNeverRefused(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(false) |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", |
| + Updates: release.UpdatesManual, Pin: "v1"}) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual", "pin": ""}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "" || rec.Updates != release.UpdatesManual { |
| + t.Fatalf("manual re-sent, pin cleared: %d %s, record %+v", st, out, rec) |
| + } |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", |
| + Updates: release.UpdatesManual, Pin: "v1"}) |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v1", "updates": "auto"}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "v1" || rec.Updates != release.UpdatesAuto { |
| + t.Fatalf("pin re-sent, manual turned off: %d %s, record %+v", st, out, rec) |
| + } |
| + two := 2 |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", |
| + Updates: release.UpdatesManual, Major: &two}) |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "updates": "auto"}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Major == nil || *rec.Major != 2 || rec.Updates != release.UpdatesAuto { |
| + t.Fatalf("major re-sent, manual turned off: %d %s, record %+v", st, out, rec) |
| + } |
| + // A changed value is still an enable, and still waits. |
| + for _, body := range []map[string]any{{"major": 3}, {"pin": "v1", "updates": "auto"}, {"updates": "manual"}} { |
| + before := fx.record("a.example.com") |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, body, fx.member) |
| + if st != http.StatusConflict || !strings.Contains(out, "every box") { |
| + t.Errorf("%v before the fleet enforces: %d %s", body, st, out) |
| + } |
| + if after := fx.record("a.example.com"); after.Updates != before.Updates || after.Pin != before.Pin || !sameMajor(after.Major, before.Major) { |
| + t.Errorf("%v: a refused enable wrote the record: %+v -> %+v", body, before, after) |
| + } |
| + } |
| +} |
| + |
| func TestPinMustHaveAPromotionRecord(t *testing.T) { |
| fx := updatesFixture(t) // ships km v1 and v2 and promotes v1 to stable |
| fx.boxesEnforce(true) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 01f38140e..e27199bf2 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -590,7 +590,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "a7d5eb534b540499b9355b455966e733d61c904be5cc5a76a168833df59de1c3"}, |
| {"internal/console/apistatusinstances.go", "Server.handleAPIStatusInstances", "key-site", "lit:instances", "2b4b9a2bea5f2fc5727869a5dcb8ae96c020e72571621f19898b195ca3b61aaa"}, |
| {"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"}, |
| - {"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "347f0cc73ce75d7fceb4df96fdee0b0439230fd11bff9bc46681abc3142cb47b"}, |
| + {"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "d7c68e89a1a36c55d51ab0d9144bb48c57816791cc2c31655192c17265820bf1"}, |
| {"internal/console/appsettings.go", "Server.handleAppGate", "record-writer", "key:UpdateRecord", "011431f5459529c0abf354edc2cb772cb6a7f2816d3bb50d91598d7d241838ce"}, |
| // Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers. |
| {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "4206a0ca107bfb8e4fffe8b641ee2433279933103283dc667e4ef12d88993585"}, |