settings-3 final review: an unchanged off-stable major never blocks a full-state clear
The instance PUT refused any major sent off stable, so a client sending
its full state ({"major":2,"pin":""}) on a beta record that kept a
stale major 2 could not clear its pin. Only a major the request newly
sets or changes is refused off stable now; an unchanged one is masked
during validation and kept (R4). The census digest for
handleAPIUpdatesPut is refreshed: the edit adds no record write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+43
−6
internal/console/apiupdates.go+10 −5internal/console/apiupdates_test.go+32 −0internal/provision/plan9_b8_census_test.go+1 −1
CI passed — run details
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go| index 5a53c9e6e..1a9fc9389 100644 |
| --- a/internal/console/apiupdates.go |
| +++ b/internal/console/apiupdates.go |
| @@ -157,7 +157,10 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| next := *rec |
| - majorSet := false // this request sets major to a number |
| + // majorSet: this request sets major to a number the record does not |
| + // already hold. A major re-sent unchanged (a form that sends its full |
| + // state) is not a new major, so it is not refused off stable either. |
| + majorSet := false |
| if v, has := req["updates"]; has { |
| str, ok := updatesValue(v) |
| if !ok { |
| @@ -172,7 +175,7 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")) |
| return |
| } |
| - next.Major, majorSet = major, major != nil |
| + next.Major, majorSet = major, major != nil && !sameMajor(rec.Major, major) |
| } |
| if v, has := req["pin"]; has { |
| var str string |
| @@ -191,9 +194,11 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| } |
| next.Pin = 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 |
| - // neither blocks an unrelated change nor is silently cleared by one. |
| + // Only a major THIS request sets or changes is refused off stable. A |
| + // major left by a channel move is ignored by reconcile (and shown as |
| + // major_ignored); it neither blocks an unrelated change, nor a clear sent |
| + // beside it unchanged, nor is it silently cleared by one (R4). Validation |
| + // below runs with it masked. |
| if majorSet && next.Channel != "stable" { |
| jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel)) |
| return |
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go| index 9f4b3be03..075aeb6ad 100644 |
| --- a/internal/console/apiupdates_test.go |
| +++ b/internal/console/apiupdates_test.go |
| @@ -332,6 +332,38 @@ func TestStaleMajorOffStableIsLeftAlone(t *testing.T) { |
| } |
| } |
| +// Astra r1 #2: a client that sends its full state resends the stale major |
| +// beside a clear. The unchanged major is not a new major off stable: the |
| +// clear goes through, the major is kept (R4), and that holds while the fleet |
| +// gate is closed too. A changed major off stable is still refused. |
| +func TestFullStateClearBesideAStaleMajor(t *testing.T) { |
| + for _, enforced := range []bool{true, false} { |
| + t.Run(fmt.Sprintf("enforced=%v", enforced), func(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(enforced) |
| + two := 2 |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "beta", |
| + Updates: release.UpdatesManual, Major: &two, Pin: "v1"}) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "pin": ""}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "" || rec.Major == nil || *rec.Major != 2 { |
| + t.Fatalf("pin cleared beside the unchanged stale major: %d %s, record %+v", st, out, rec) |
| + } |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "pin": "", "updates": "auto"}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Updates != release.UpdatesAuto || rec.Major == nil || *rec.Major != 2 { |
| + t.Fatalf("manual turned off beside the unchanged stale major: %d %s, record %+v", st, out, rec) |
| + } |
| + // A changed major off stable is still refused, before the gate. |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 3, "pin": ""}, fx.member) |
| + if st != http.StatusBadRequest || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), "a.example.com", "beta") { |
| + t.Fatalf("a changed major off stable: %d %s", st, out) |
| + } |
| + if rec := fx.record("a.example.com"); rec.Major == nil || *rec.Major != 2 { |
| + t.Fatalf("a refused major was written: %+v", rec) |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestMajorOnStableIsSetAndCleared(t *testing.T) { |
| fx := updatesFixture(t) |
| fx.boxesEnforce(true) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index cdb87b4a9..9312d3d6b 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", "eb201dd17f0d407af087c974e14d0f713e9a8e3eedb5575d8f27ec5824c2244f"}, |
| + {"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "53044a537795302e7ffd8fa72799b7a77e7bed277f00ad17ed1fd465b7f7cfaa"}, |
| {"internal/console/apiupdates.go", "Server.handleAPIUpdatesInstall", "record-writer", "key:UpdateRecord", "880c396f0c9d3365ac216f15e75a44d3e81e24191240f041132854e9b7f6e78a"}, |
| {"internal/console/appsettings.go", "Server.handleAppGate", "record-writer", "key:UpdateRecord", "011431f5459529c0abf354edc2cb772cb6a7f2816d3bb50d91598d7d241838ce"}, |
| // Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers. |