settings-3 task 7: Update now raises an increasing request id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+342
−5
internal/console/apiupdates.go+71 −1internal/console/apiupdates_test.go+255 −1internal/console/authcli.go+3 −0internal/console/claim_invariant_fixtures_test.go+5 −0internal/console/svcroutes_test.go+5 −2internal/console/testdata/claim-routes.txt+1 −0internal/provision/plan9_b8_census_test.go+2 −1
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go| index 715f32630..e9f5e2eaa 100644 |
| --- a/internal/console/apiupdates.go |
| +++ b/internal/console/apiupdates.go |
| @@ -1,5 +1,6 @@ |
| // The instance update settings doors (instance update settings spec §3.3, |
| -// §4.4, §5.1): GET and PUT /api/cli/apps/{account}/{app}/instances/{host}/updates. |
| +// §4.4, §5.1): GET and PUT /api/cli/apps/{account}/{app}/instances/{host}/updates, |
| +// and POST …/updates/install ("Update now", handleAPIUpdatesInstall). |
| // |
| // A member of the app sets an instance's preferences: updates (auto or |
| // manual), a major hold on stable, and a pin to one authorized build. They |
| @@ -27,6 +28,7 @@ import ( |
| "net/http" |
| "os" |
| "strings" |
| + "time" |
| "unicode" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -323,3 +325,71 @@ func shownValue(v string) string { |
| } |
| return v |
| } |
| + |
| +// handleAPIUpdatesInstall is POST …/instances/{host}/updates/install: "Update |
| +// now" (spec §4.2, §4.3 rule 2). It raises the record's install_request id by |
| +// one under the record's etag, retrying a lost race, so ids only ever |
| +// increase and no request is lost. Root resolves it to a build; the console |
| +// never names one. |
| +// |
| +// It is not one of the settings §4.4 holds until every box applies them: a |
| +// request a box does not yet act on asks for nothing it could not already get. |
| +// fast_track (skip the bake window) needs an owner of the account, the |
| +// authority the console's own fast-track door requires (index Q7). The API |
| +// cannot run that door's passkey ceremony, so it asks for the role alone. |
| +func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request) { |
| + principal, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + var req struct { |
| + FastTrack bool `json:"fast_track"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if req.FastTrack { |
| + role, err := s.store.Role(acct.ID, principal) |
| + if err != nil { |
| + log.Printf("console: api updates install %s/%s: fast track refused, the role could not be read: %v", acct.Sqid, app, err) |
| + } |
| + if err != nil || role != RoleOwner { |
| + jsonError(w, http.StatusForbidden, updatewords.Text("settings.console.fast-track-owner")) |
| + return |
| + } |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + for attempt := 0; attempt < 5; attempt++ { |
| + rec, etag, _, ok := s.updatesTarget(w, r, ctx, acct, app) |
| + if !ok || refuseShape(w, rec) { |
| + return |
| + } |
| + if rec.Pin != "" { |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pinned"), rec.Host, rec.Pin)) |
| + return |
| + } |
| + var id uint64 = 1 |
| + if rec.InstallRequest != nil { |
| + id = rec.InstallRequest.ID + 1 |
| + } |
| + next := *rec |
| + next.InstallRequest = &provision.InstallRequest{ID: id, FastTrack: req.FastTrack, At: time.Now().UTC().Truncate(time.Second), By: principal} |
| + err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag) |
| + if errors.Is(err, release.ErrCASConflict) { |
| + continue // another writer moved the record: read it again |
| + } |
| + if err != nil { |
| + log.Printf("console: api updates install %s/%s/%s: %v", acct.Sqid, app, rec.Host, err) |
| + jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed")) |
| + return |
| + } |
| + s.auditActivity(acct, app, principal, Activity{Action: "instance-update-now", TargetType: "instance", Target: rec.Host, |
| + Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-install"), rec.Host, id)}) |
| + // Box-consumed: root resolves the request on its next pass. |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, map[string]uint64{"request": id}) |
| + return |
| + } |
| + jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed")) |
| +} |
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go| index 573b785e4..698f9d8ae 100644 |
| --- a/internal/console/apiupdates_test.go |
| +++ b/internal/console/apiupdates_test.go |
| @@ -3,9 +3,12 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| + "io" |
| "net/http" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -26,7 +29,7 @@ type updatesFx struct { |
| t *testing.T |
| store release.Store |
| member string // a CLI bearer for alice, a plain (non-owner) member of bab |
| - owner string // a CLI bearer for paul, an owner of the ops account |
| + owner string // a CLI bearer for paul, an owner of the ops account bab: the account in updURL |
| instanceToken string // an app-wide svcrole.Instance credential for km |
| } |
| @@ -425,3 +428,254 @@ func TestTheConsoleNeverChoosesABuild(t *testing.T) { |
| } |
| _ = context.Background() |
| } |
| + |
| +func TestInstallBumpsTheRequestAndChoosesNoBuild(t *testing.T) { |
| + fx := updatesFixture(t) |
| + before := fx.record("a.example.com") |
| + serialBefore := serial(t, fx.consoleFixture) |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member) |
| + if st != http.StatusOK || !strings.Contains(out, `"request":1`) { |
| + t.Fatalf("install: %d %s", st, out) |
| + } |
| + rec := fx.record("a.example.com") |
| + if rec.InstallRequest == nil || rec.InstallRequest.ID != 1 || rec.InstallRequest.FastTrack || |
| + rec.InstallRequest.By != "alice@keymail.dev" || rec.InstallRequest.At.IsZero() { |
| + t.Fatalf("record = %+v", rec) |
| + } |
| + // The console never names a build: the pin, channel and settings are as |
| + // they were, and only the request moved. |
| + if rec.Pin != before.Pin || rec.Channel != before.Channel || rec.Updates != before.Updates || !sameMajor(rec.Major, before.Major) { |
| + t.Fatalf("Update now moved a build or a setting: %+v -> %+v", before, rec) |
| + } |
| + if got := serial(t, fx.consoleFixture); got != serialBefore+1 { |
| + t.Fatalf("serial = %d, want one bump from %d", got, serialBefore) |
| + } |
| + acts, err := fx.st.Events(OpsAccountID, "km", 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := fmt.Sprintf(updatewords.Text("settings.console.audit-install"), "a.example.com", 1) |
| + found := false |
| + for _, a := range acts { |
| + if a.Action == "instance-update-now" && a.Detail == want && a.Address == "alice@keymail.dev" { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("no activity line %q by alice in %+v", want, acts) |
| + } |
| + st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member) |
| + if st != http.StatusOK || !strings.Contains(out, `"request":2`) || fx.record("a.example.com").InstallRequest.ID != 2 { |
| + t.Fatalf("the second request did not increase the id: %d %s", st, out) |
| + } |
| +} |
| + |
| +// Update now is not one of the settings §4.4 holds until every box applies |
| +// them: it is answered while the fleet does not enforce. |
| +func TestInstallIsNotHeldForTheFleet(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(false) |
| + if st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member); st != http.StatusOK { |
| + t.Fatalf("install before the fleet enforces: %d %s", st, out) |
| + } |
| +} |
| + |
| +// installRaceStore holds the first two reads of the instance record until |
| +// both have arrived, so the two requests decide from the same etag and one |
| +// of them must lose the write and retry. |
| +type installRaceStore struct { |
| + release.Store |
| + key string |
| + mu sync.Mutex |
| + arrived int |
| + etags []string |
| + both chan struct{} |
| +} |
| + |
| +func (s *installRaceStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + body, etag, err := s.Store.Get(ctx, key) |
| + if key != s.key { |
| + return body, etag, err |
| + } |
| + s.mu.Lock() |
| + if s.arrived >= 2 { |
| + s.mu.Unlock() |
| + return body, etag, err |
| + } |
| + s.arrived++ |
| + s.etags = append(s.etags, etag) |
| + if s.arrived == 2 { |
| + close(s.both) |
| + } |
| + s.mu.Unlock() |
| + select { |
| + case <-s.both: |
| + case <-time.After(10 * time.Second): |
| + } |
| + return body, etag, err |
| +} |
| + |
| +// installNoFatal is one install request that reports rather than fails, for |
| +// use off the test goroutine. |
| +func installNoFatal(u, bearer string) (int, string, error) { |
| + req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(`{}`)) |
| + if err != nil { |
| + return 0, "", err |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + return 0, "", err |
| + } |
| + defer res.Body.Close() |
| + out, err := io.ReadAll(res.Body) |
| + return res.StatusCode, string(out), err |
| +} |
| + |
| +// Review Focus 3: two requests that read the same etag both succeed, and the |
| +// ids they leave are 1 then 2. Neither is lost and no id repeats. |
| +func TestInstallRequestsRaceToIncreasingIDs(t *testing.T) { |
| + fx := updatesFixture(t) |
| + rs := &installRaceStore{Store: fx.rel, key: provision.RecordKey(release.OpsAccount, "km", "a.example.com"), both: make(chan struct{})} |
| + fx.srv.cfg.Releases = rs |
| + type result struct { |
| + st int |
| + out string |
| + err error |
| + } |
| + done := make(chan result, 2) |
| + for i := 0; i < 2; i++ { |
| + go func() { |
| + st, out, err := installNoFatal(fx.ts.URL+updURL+"/install", fx.member) |
| + done <- result{st, out, err} |
| + }() |
| + } |
| + var outs []string |
| + for i := 0; i < 2; i++ { |
| + r := <-done |
| + if r.err != nil || r.st != http.StatusOK { |
| + t.Fatalf("a racing request failed: %d %s %v", r.st, r.out, r.err) |
| + } |
| + outs = append(outs, r.out) |
| + } |
| + rs.mu.Lock() |
| + etags := append([]string(nil), rs.etags...) |
| + rs.mu.Unlock() |
| + if len(etags) != 2 || etags[0] != etags[1] { |
| + t.Fatalf("the two requests did not race from one read: etags %q", etags) |
| + } |
| + if id := fx.record("a.example.com").InstallRequest.ID; id != 2 { |
| + t.Fatalf("after two racing requests the id is %d, want 2", id) |
| + } |
| + joined := strings.Join(outs, " ") |
| + if !strings.Contains(joined, `"request":1`) || !strings.Contains(joined, `"request":2`) { |
| + t.Fatalf("the two answers must be requests 1 and 2: %s", joined) |
| + } |
| +} |
| + |
| +// installCASStore loses every record write's race, as a store would whose |
| +// record another writer keeps moving. |
| +type installCASStore struct { |
| + release.Store |
| + key string |
| +} |
| + |
| +func (s installCASStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + body, etag, err := s.Store.Get(ctx, key) |
| + if key == s.key && err == nil { |
| + // Each read answers a fresh etag, so the write's own read never |
| + // matches the one the request decided from. |
| + etag = fmt.Sprintf("%s-%d", etag, time.Now().UnixNano()) |
| + } |
| + return body, etag, err |
| +} |
| + |
| +// R7: only a lost race retries, and five lost races are 409 changed; any |
| +// other write failure is a 502 that does not leak the store's words. |
| +func TestInstallGivesUpAfterFiveLostRaces(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.srv.cfg.Releases = installCASStore{fx.rel, provision.RecordKey(release.OpsAccount, "km", "a.example.com")} |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member) |
| + if st != http.StatusConflict || jsonErrorText(t, out) != updatewords.Text("settings.console.changed") { |
| + t.Fatalf("install that always loses: %d %s", st, out) |
| + } |
| + if fx.record("a.example.com").InstallRequest != nil { |
| + t.Fatal("a lost request wrote the record") |
| + } |
| +} |
| + |
| +func TestInstallStoreFailureIsA502(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.srv.cfg.Releases = unputtableStore{fx.rel} |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member) |
| + if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.store-failed") { |
| + t.Fatalf("install with an unwritable store: %d %s", st, out) |
| + } |
| + if strings.Contains(out, brokenStoreErr) { |
| + t.Fatalf("the refusal leaks the store's error: %s", out) |
| + } |
| +} |
| + |
| +type unputtableStore struct{ release.Store } |
| + |
| +func (u unputtableStore) Put(context.Context, string, []byte, string) (string, error) { |
| + return "", errors.New(brokenStoreErr) |
| +} |
| + |
| +func TestInstallIsRefusedOnAPinnedInstance(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"}) |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member) |
| + if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.pinned"), "a.example.com", "v1") { |
| + t.Fatalf("install on a pinned instance: %d %s", st, out) |
| + } |
| + if fx.record("a.example.com").InstallRequest != nil { |
| + t.Fatal("a refused request wrote the record") |
| + } |
| +} |
| + |
| +func TestInstallRefusesNamedUnitsOtherKindsAndUnknownHosts(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Unit: "console.service", Addr: "127.0.0.1:8080"}) |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member) |
| + if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.named-unit"), "a.example.com") { |
| + t.Fatalf("named unit: %d %s", st, out) |
| + } |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Kind: "service", Addr: "127.0.0.1:8080"}) |
| + st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member) |
| + if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.not-instance"), "a.example.com", "service") { |
| + t.Fatalf("service route: %d %s", st, out) |
| + } |
| + if fx.record("a.example.com").InstallRequest != nil { |
| + t.Fatal("a refused request wrote the record") |
| + } |
| + u := fx.ts.URL + "/api/cli/apps/bab/km/instances/nope.example.com/updates/install" |
| + if st, out := sendJSON(t, http.MethodPost, u, map[string]any{}, fx.member); st != http.StatusNotFound { |
| + t.Fatalf("install on an unknown host: %d %s", st, out) |
| + } |
| +} |
| + |
| +// R8: fast_track is an account owner's; the check is the role, made before |
| +// the store is read. |
| +func TestFastTrackInstallNeedsAnOwner(t *testing.T) { |
| + fx := updatesFixture(t) // fx.member is a plain member of bab; fx.owner an owner of bab |
| + st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": true}, fx.member) |
| + if st != http.StatusForbidden || jsonErrorText(t, out) != updatewords.Text("settings.console.fast-track-owner") { |
| + t.Fatalf("a member skipped bake: %d %s", st, out) |
| + } |
| + if fx.record("a.example.com").InstallRequest != nil { |
| + t.Fatal("a refused fast track wrote the record") |
| + } |
| + st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": true}, fx.owner) |
| + rec := fx.record("a.example.com") |
| + if st != http.StatusOK || rec.InstallRequest == nil || !rec.InstallRequest.FastTrack || rec.InstallRequest.By != "paul@keymail.dev" { |
| + t.Fatalf("an owner's fast-track request: %d %s, record %+v", st, out, rec) |
| + } |
| + // A plain member's ordinary request is still theirs to make, and clears |
| + // the fast track the next request does not ask for. |
| + st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": false}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.InstallRequest.ID != 2 || rec.InstallRequest.FastTrack { |
| + t.Fatalf("a member's ordinary request after a fast track: %d %s, record %+v", st, out, rec) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 60f169c21..6432a712c 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -648,6 +648,9 @@ func (s *Server) registerCLIRoutes() { |
| // app-wide, so it would let one instance's code set a sibling's pin. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesGet) |
| s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesPut) |
| + // "Update now" asks root for the newest build the settings allow; the |
| + // same people-only gate, and fast_track only for an account owner. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.Set{}, s.handleAPIUpdatesInstall) |
| // The logs read (`carlos logs`, apilogs.go) — same gate, same 404 |
| // discipline, and an app token may read its own app's logs. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs) |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 1106dbbd5..003a31d4f 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1212,6 +1212,11 @@ var claimFixtures = map[string]claimFixture{ |
| status: http.StatusConflict, |
| msg: claimMoveUnsupportedMsg, |
| }}, |
| + "route POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install": {door: &claimDoor{ |
| + apps: claimRefs("bab/titogo"), |
| + setup: claimWorldInstance, |
| + do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates/install", `{}`), |
| + }}, |
| "route POST /api/cli/apps/{account}/{app}/keep": {door: &claimDoor{ |
| apps: claimRefs("bab/tmp-claim-keep"), |
| setup: func(t *testing.T, cx *claimCase) { cx.temporary(t, "tmp-claim-keep") }, |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 01ab7f7a1..2dd1aa48b 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -185,6 +185,9 @@ var closedDoors = []string{ |
| // instance-bound credential that may reach these is #155 step 6. |
| "GET /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", |
| + // "Update now" (settings plan 3 Task 7) asks root to move this instance: |
| + // the same app-wide reason, and fast_track is an account owner's alone. |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", |
| // The `carlos db` replica door (apidb.go) serves the whole customer |
| // database, file by file. No credential the app holds needs it: the app |
| // reads its own database live, and CI has no business pulling |
| @@ -923,8 +926,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 50 { |
| - t.Errorf("walked %d closed doors, want all %d (and 50 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two instance update settings doors, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, and four store-entitlement doors)", |
| + if seen != len(closedDoors) || seen != 51 { |
| + t.Errorf("walked %d closed doors, want all %d (and 51 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, and four store-entitlement doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 134b17b35..e6d55ff1d 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -177,6 +177,7 @@ route GET /api/cli/apps/{account}/{app}/instances/{host}/updates |
| route PUT /api/cli/apps/{account}/{app}/instances/{host}/updates |
| route POST /api/cli/apps/{account}/{app}/instances/{host}/move |
| route POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut |
| +route POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install |
| route POST /api/cli/apps/{account}/{app}/keep |
| route POST /api/cli/apps/{account}/{app}/ledgers/publish |
| route POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index e27199bf2..0459c9ef2 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -591,9 +591,10 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"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", "d7c68e89a1a36c55d51ab0d9144bb48c57816791cc2c31655192c17265820bf1"}, |
| + {"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. |
| - {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "4206a0ca107bfb8e4fffe8b641ee2433279933103283dc667e4ef12d88993585"}, |
| + {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "f338844b02cf6a1916e8b5824ef5139fa9ea62fb2ffdfe382f6ea12d3ceda029"}, |
| {"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"}, |
| {"internal/console/channelmove.go", "Server.handleAPIInstanceChannel", "record-writer", "key:UpdateRecord", "91a2b3157bde9af832e8d95b17d0c384910229982945818f8b52f62dafddb342"}, |
| {"internal/console/channelmove.go", "Server.executePendingRouteChannel", "record-writer", "key:UpdateRecord", "b3b9a12213261a70b680ad5290957ebe7434eff633a73c2d80c75cc24d026d9a"}, |