carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 5c5b9de3083f5d091c1a31d08adea1bcbcdde600 parent e9e1039
3 files changed, +47 −5
  • internal/console/apiupdates.go +8 −4
  • internal/console/apiupdates_test.go +38 −0
  • internal/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"},