settings-3 task 6: GET and PUT an instance's update settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+823
−3
internal/console/apiupdates.go+321 −0internal/console/apiupdates_test.go+389 −0internal/console/authcli.go+5 −0internal/console/claim_invariant_fixtures_test.go+9 −0internal/console/claim_invariant_probes_read_test.go+2 −0internal/console/svcroutes_test.go+8 −2internal/console/testdata/claim-routes.txt+2 −0internal/provision/plan9_b8_census_test.go+2 −1internal/updatewords/words_approved_test.go+4 −0internal/updatewords/words_console.go+81 −0
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go| new file mode 100644 |
| index 000000000..eb5ed3bd6 |
| --- /dev/null |
| +++ b/internal/console/apiupdates.go |
| @@ -0,0 +1,321 @@ |
| +// 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. |
| +// |
| +// 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 |
| +// are preferences among builds §4.1 already authorizes, not a security |
| +// boundary (§2 decision 5), and the console never chooses a build: it writes |
| +// the record, and root decides on its next pass. |
| +// |
| +// Who: people only. Every service credential is refused at the role gate |
| +// (svcrole.Set{}): svcrole.Instance is app-wide, and the instance-bound |
| +// credential §3.3 limits to instance_control is #155 step 6. |
| +// |
| +// When: pin, manual and major are turned ON only while every box runs a |
| +// binary that applies them (§4.4, updatesEnforced); turning them off is |
| +// never refused. The member is told how many boxes are not ready; which |
| +// boxes is operator detail, logged here and never shown. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "math" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "unicode" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +type updatesSettings struct { |
| + Updates string `json:"updates"` |
| + Channel string `json:"channel"` |
| + Major *int `json:"major"` |
| + Pin string `json:"pin"` |
| +} |
| + |
| +// updatesView is what both doors answer with. Settings are the effective |
| +// values, Explicit the record's own, and Enforced whether every box applies |
| +// them (plan 5 renders its absence as "settings not enforced"). |
| +type updatesView struct { |
| + Host string `json:"host"` |
| + Settings updatesSettings `json:"settings"` |
| + Explicit updatesSettings `json:"explicit"` |
| + Effective release.UpdateSettings `json:"effective"` |
| + Control []string `json:"control"` |
| + InstallRequest *provision.InstallRequest `json:"install_request"` |
| + Enforced bool `json:"enforced"` |
| + MajorIgnored bool `json:"major_ignored"` |
| +} |
| + |
| +func (s *Server) updatesViewOf(ctx context.Context, rec provision.Record, policy *provision.UpdatePolicy) updatesView { |
| + eff := provision.EffectiveUpdates(rec, policy) |
| + ok, _, _ := s.updatesEnforced(ctx) |
| + return updatesView{ |
| + Host: rec.Host, |
| + Settings: updatesSettings{Updates: eff.Updates, Channel: eff.Channel, Major: eff.Major, Pin: eff.Pin}, |
| + Explicit: updatesSettings{Updates: rec.Updates, Channel: rec.Channel, Major: rec.Major, Pin: rec.Pin}, |
| + Effective: eff, |
| + Control: append([]string{}, eff.Control...), |
| + InstallRequest: rec.InstallRequest, |
| + Enforced: ok, |
| + MajorIgnored: rec.Major != nil && eff.Major == nil, |
| + } |
| +} |
| + |
| +// updatesTarget reads the record and the app policy for a settings door, |
| +// answering the family's 404 for a host with no record. ok=false means a |
| +// response has been written. |
| +func (s *Server) updatesTarget(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string) (*provision.Record, string, *provision.UpdatePolicy, bool) { |
| + host := r.PathValue("host") |
| + rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + http.NotFound(w, r) |
| + return nil, "", nil, false |
| + case err != nil: |
| + log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.read-failed")) |
| + return nil, "", nil, false |
| + } |
| + m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: api updates %s/%s: manifest: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed")) |
| + return nil, "", nil, false |
| + } |
| + var policy *provision.UpdatePolicy |
| + if m != nil { |
| + policy = m.Updates |
| + } |
| + return rec, etag, policy, true |
| +} |
| + |
| +// refuseShape answers §4.5's refusals: a named-unit route keeps today's |
| +// adoption, and only instances have update settings. |
| +func refuseShape(w http.ResponseWriter, rec *provision.Record) bool { |
| + switch { |
| + case rec.Unit != "": |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.named-unit"), rec.Host)) |
| + return true |
| + case rec.KindOrDefault() != "instance": |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-instance"), rec.Host, rec.KindOrDefault())) |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// handleAPIUpdatesGet is GET …/instances/{host}/updates (spec §5.1). |
| +func (s *Server) handleAPIUpdatesGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, _, policy, ok := s.updatesTarget(w, r, ctx, acct, app) |
| + if !ok { |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, *rec, policy)) |
| +} |
| + |
| +// handleAPIUpdatesPut is PUT …/instances/{host}/updates: a member sets any of |
| +// updates, major and pin; a key present with "" or null clears it, and any |
| +// other value of the wrong type is refused rather than read as a clear. It |
| +// writes preferences only: the console never chooses a build (spec §4). |
| +func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) { |
| + principal, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + var req map[string]any |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if _, has := req["channel"]; has { |
| + jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.channel-elsewhere")) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, etag, policy, ok := s.updatesTarget(w, r, ctx, acct, app) |
| + if !ok || refuseShape(w, rec) { |
| + return |
| + } |
| + next := *rec |
| + enabling := false |
| + majorSet := false // this request sets major to a number |
| + if v, has := req["updates"]; has { |
| + str, isStr := v.(string) |
| + if (v != nil && !isStr) || (str != "" && str != release.UpdatesAuto && str != release.UpdatesManual) { |
| + jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates")) |
| + return |
| + } |
| + next.Updates = str |
| + enabling = enabling || str == release.UpdatesManual |
| + } |
| + if v, has := req["major"]; has { |
| + switch n := v.(type) { |
| + case nil: |
| + next.Major = nil |
| + case float64: |
| + if n < 0 || n > math.MaxInt32 || n != math.Trunc(n) { |
| + jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")) |
| + return |
| + } |
| + i := int(n) |
| + next.Major = &i |
| + enabling, majorSet = true, true |
| + case string: |
| + if n != "" { |
| + jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")) |
| + return |
| + } |
| + next.Major = nil |
| + default: |
| + jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major")) |
| + return |
| + } |
| + } |
| + if v, has := req["pin"]; has { |
| + var str string |
| + switch p := v.(type) { |
| + case nil: |
| + case string: |
| + str = p |
| + default: |
| + raw, _ := json.Marshal(v) |
| + jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(string(raw)))) |
| + return |
| + } |
| + if str != "" && (!release.ValidVersion(str) || str == "dev") { |
| + jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(str))) |
| + 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 |
| + // neither blocks an unrelated change nor is silently cleared by one. |
| + if majorSet && next.Channel != "stable" { |
| + jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel)) |
| + return |
| + } |
| + check := next |
| + if !majorSet && check.Channel != "stable" { |
| + check.Major = nil |
| + } |
| + if err := provision.ValidateUpdates(check); err != nil { |
| + // Every shape ValidateUpdates refuses is refused above with reviewed |
| + // words; reaching this is a new shape, and its plain text is API detail. |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| + changes := updatesChanges(*rec, next) |
| + if len(changes) == 0 { |
| + // Nothing changed: no write, no serial bump, no audit line, and |
| + // nothing to refuse. |
| + writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, *rec, policy)) |
| + return |
| + } |
| + if next.Pin != "" && next.Pin != rec.Pin { |
| + if _, err := release.ReadPromotionRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, next.Pin); err != nil { |
| + if errors.Is(err, release.ErrNoPromotionRecord) || errors.Is(err, os.ErrNotExist) { |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pin-unpromoted"), next.Pin)) |
| + return |
| + } |
| + log.Printf("console: api updates %s/%s/%s: promotion record %s: %v", acct.Sqid, app, rec.Host, next.Pin, err) |
| + jsonError(w, http.StatusBadGateway, fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), next.Pin)) |
| + return |
| + } |
| + } |
| + if enabling { |
| + ok, blocking, err := s.updatesEnforced(ctx) |
| + switch { |
| + case err != nil: |
| + log.Printf("console: api updates %s/%s/%s: enable refused, the box inventory could not be read: %v", acct.Sqid, app, rec.Host, err) |
| + jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.enforcement-unknown")) |
| + return |
| + case !ok: |
| + log.Printf("console: api updates %s/%s/%s: enable refused, not every box applies update settings: %s", acct.Sqid, app, rec.Host, strings.Join(blocking, "; ")) |
| + jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), len(blocking))) |
| + return |
| + } |
| + } |
| + if err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed")) |
| + return |
| + } |
| + log.Printf("console: api updates %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-updates", TargetType: "instance", Target: rec.Host, |
| + Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-set"), rec.Host, strings.Join(changes, ", "))}) |
| + // A box-consumed write: reconcile resolves the settings into the |
| + // registry from this record, so announcing it is what makes it prompt. |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, next, policy)) |
| +} |
| + |
| +// updatesChanges names what differs between the record before and after, in |
| +// the activity line's words: "updates manual", "major 2", "pin cleared". |
| +func updatesChanges(before, after provision.Record) []string { |
| + var out []string |
| + if before.Updates != after.Updates { |
| + out = append(out, "updates "+orCleared(after.Updates)) |
| + } |
| + if !sameMajor(before.Major, after.Major) { |
| + if after.Major == nil { |
| + out = append(out, "major cleared") |
| + } else { |
| + out = append(out, fmt.Sprintf("major %d", *after.Major)) |
| + } |
| + } |
| + if before.Pin != after.Pin { |
| + out = append(out, "pin "+orCleared(after.Pin)) |
| + } |
| + return out |
| +} |
| + |
| +func sameMajor(a, b *int) bool { |
| + if a == nil || b == nil { |
| + return a == nil && b == nil |
| + } |
| + return *a == *b |
| +} |
| + |
| +func orCleared(v string) string { |
| + if v == "" { |
| + return "cleared" |
| + } |
| + return v |
| +} |
| + |
| +// shownValue is a refused value as a member may be shown it: printable |
| +// characters only, at most 64 of them, and "that" when nothing is left. |
| +func shownValue(v string) string { |
| + v = strings.Map(func(r rune) rune { |
| + if unicode.IsPrint(r) { |
| + return r |
| + } |
| + return -1 |
| + }, v) |
| + if rs := []rune(v); len(rs) > 64 { |
| + v = string(rs[:64]) + "..." |
| + } |
| + if v == "" { |
| + return "that" |
| + } |
| + return v |
| +} |
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go| new file mode 100644 |
| index 000000000..4485dcc6a |
| --- /dev/null |
| +++ b/internal/console/apiupdates_test.go |
| @@ -0,0 +1,389 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +const updURL = "/api/cli/apps/bab/km/instances/a.example.com/updates" |
| + |
| +// updatesFx is newConsoleFixture with app km in the ops account bab: v1 and |
| +// v2 shipped, v1 promoted to stable (so v1 has a promotion record and v2 has |
| +// none), and one instance record, a.example.com on stable with no settings. |
| +type updatesFx struct { |
| + *consoleFixture |
| + 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 |
| + instanceToken string // an app-wide svcrole.Instance credential for km |
| +} |
| + |
| +func updatesFixture(t *testing.T) *updatesFx { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "km", "v1") |
| + shipVersion(t, fx, "km", "v2") |
| + seedChannel(t, fx, "km", "stable", "v1") |
| + ux := &updatesFx{ |
| + consoleFixture: fx, |
| + t: t, |
| + store: fx.rel, |
| + member: cliToken(t, fx, "alice@keymail.dev", "code-updates-member"), |
| + owner: cliToken(t, fx, "paul@keymail.dev", "code-updates-owner"), |
| + instanceToken: serviceToken(t, fx.st, OpsAccountID, "router", "km", svcrole.Instance), |
| + } |
| + ux.putRecord(provision.Record{Schema: 1, Host: "a.example.com", Slug: "a", App: "km", |
| + Account: release.OpsAccount, Channel: "stable", CreatedAt: now.UTC()}) |
| + return ux |
| +} |
| + |
| +// putRecord replaces (or creates) the record at rec's key with rec. |
| +func (fx *updatesFx) putRecord(rec provision.Record) { |
| + fx.t.Helper() |
| + ctx := context.Background() |
| + if err := fx.store.Delete(ctx, provision.RecordKey(rec.Account, rec.App, rec.Host)); err != nil { |
| + fx.t.Fatal(err) |
| + } |
| + if err := provision.CreateRecord(ctx, fx.store, rec); err != nil { |
| + fx.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (fx *updatesFx) record(host string) provision.Record { |
| + fx.t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), fx.store, release.OpsAccount, "km", host) |
| + if err != nil { |
| + fx.t.Fatal(err) |
| + } |
| + return *rec |
| +} |
| + |
| +// manifest writes km's instances manifest (and so its update policy). |
| +func (fx *updatesFx) manifest(m provision.Manifest) { |
| + fx.t.Helper() |
| + enableInstances(fx.t, fx.consoleFixture, "km", m) |
| +} |
| + |
| +// boxesEnforce makes the deployment one box, ie-1: an edges record and a |
| +// fresh heartbeat whose binary does (on) or does not apply update settings. |
| +func (fx *updatesFx) boxesEnforce(on bool) { |
| + fx.t.Helper() |
| + if _, _, err := fx.store.Get(context.Background(), "control/edges/ie-1.json"); err != nil { |
| + seedEdgesRecord(fx.t, fx.consoleFixture, "ie-1") |
| + } |
| + m := &status.BoxMetrics{} |
| + if on { |
| + m.UpdateSettings = status.UpdateSettingsProtocol |
| + } |
| + seedSettingsBox(fx.t, fx.consoleFixture, "ie-1", time.Now(), m) |
| +} |
| + |
| +// sendJSON is rawReq with body marshalled to JSON. |
| +func sendJSON(t *testing.T, method, u string, body any, bearer string) (int, string) { |
| + t.Helper() |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _, out := rawReq(t, method, u, bearer, string(raw)) |
| + return st, out |
| +} |
| + |
| +func TestUpdatesGetShowsDefaults(t *testing.T) { |
| + fx := updatesFixture(t) |
| + st, out := getRaw(t, fx.ts.URL+updURL, fx.member) |
| + if st != http.StatusOK || !strings.Contains(out, `"updates":"auto"`) || !strings.Contains(out, `"enforced":false`) { |
| + t.Fatalf("GET: %d %s", st, out) |
| + } |
| +} |
| + |
| +func TestUpdatesPutIsRefusedUntilEveryBoxEnforces(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(false) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member) |
| + if st != http.StatusConflict || !strings.Contains(out, "every box") { |
| + t.Fatalf("manual before the fleet enforces: %d %s", st, out) |
| + } |
| + // R3: a member is told how many boxes, never which. |
| + if strings.Contains(out, "ie-1") || !strings.Contains(out, "1 boxes") { |
| + t.Fatalf("the refusal must give a count and name no box: %s", out) |
| + } |
| + if fx.record("a.example.com").Updates != "" { |
| + t.Fatal("a refused enable wrote the record") |
| + } |
| + fx.boxesEnforce(true) |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member) |
| + if st != http.StatusOK || fx.record("a.example.com").Updates != release.UpdatesManual { |
| + t.Fatalf("manual once enforced: %d %s", st, out) |
| + } |
| +} |
| + |
| +// R3: when the gate cannot read the boxes at all, enabling is refused with |
| +// its own words, and the store's error text never reaches the member. |
| +func TestUpdatesPutIsRefusedWhenEnforcementIsUnknown(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + fx.srv.cfg.Releases = unlistableStore{fx.rel} |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member) |
| + if st != http.StatusBadGateway || !strings.Contains(out, updatewords.Text("settings.console.enforcement-unknown")) { |
| + t.Fatalf("enable with an unreadable inventory: %d %s", st, out) |
| + } |
| + if strings.Contains(out, brokenStoreErr) { |
| + t.Fatalf("the refusal leaks the store's error: %s", out) |
| + } |
| + // Clearing is still allowed. |
| + if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "auto"}, fx.member); st != http.StatusOK { |
| + t.Fatalf("disabling with an unreadable inventory: %d %s", st, out) |
| + } |
| +} |
| + |
| +// Review Focus 5. |
| +func TestClearingIsNeverRefused(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"}) |
| + fx.boxesEnforce(false) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": ""}, fx.member) |
| + if st != http.StatusOK || fx.record("a.example.com").Pin != "" { |
| + t.Fatalf("clearing a pin: %d %s", st, out) |
| + } |
| +} |
| + |
| +func TestPinMustHaveAPromotionRecord(t *testing.T) { |
| + fx := updatesFixture(t) // ships km v1 and v2 and promotes v1 to stable |
| + fx.boxesEnforce(true) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v2"}, fx.member) |
| + if st != http.StatusConflict || !strings.Contains(out, "promotion record") { |
| + t.Fatalf("pin of an unpromoted build: %d %s", st, out) |
| + } |
| + st, _ = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v1"}, fx.member) |
| + if st != http.StatusOK || fx.record("a.example.com").Pin != "v1" { |
| + t.Fatalf("pin of a promoted build: %d", st) |
| + } |
| +} |
| + |
| +// R7: a record that is there but cannot be verified is not "no record": it |
| +// is a 502 that asks for a retry, and the pin is not written. |
| +func TestPinCheckFailureIsNotNoRecord(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + if _, err := fx.store.Put(context.Background(), release.PromotionRecordKey("bab", "km", "v2"), []byte(`{"payload":"x","sig":"AAAA"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v2"}, fx.member) |
| + want := fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), "v2") |
| + if st != http.StatusBadGateway || jsonErrorText(t, out) != want { |
| + t.Fatalf("pin with an unverifiable record: %d %s", st, out) |
| + } |
| + if fx.record("a.example.com").Pin != "" { |
| + t.Fatal("the pin was written") |
| + } |
| +} |
| + |
| +// Strict input: a value of the wrong type is refused, never read as a clear. |
| +func TestUpdatesPutRefusesWrongTypes(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", |
| + Updates: release.UpdatesManual, Pin: "v1"}) |
| + for _, c := range []struct { |
| + body string |
| + want string |
| + }{ |
| + {`{"updates":5}`, updatewords.Text("settings.console.bad-updates")}, |
| + {`{"updates":true}`, updatewords.Text("settings.console.bad-updates")}, |
| + {`{"updates":"sometimes"}`, updatewords.Text("settings.console.bad-updates")}, |
| + {`{"pin":true}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "true")}, |
| + {`{"pin":5}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "5")}, |
| + {`{"pin":"dev"}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "dev")}, |
| + {`{"pin":"../v1"}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "../v1")}, |
| + {`{"major":-1}`, updatewords.Text("settings.console.bad-major")}, |
| + {`{"major":1.5}`, updatewords.Text("settings.console.bad-major")}, |
| + {`{"major":"2"}`, updatewords.Text("settings.console.bad-major")}, |
| + {`{"major":1e300}`, updatewords.Text("settings.console.bad-major")}, |
| + } { |
| + st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+updURL, fx.member, c.body) |
| + if st != http.StatusBadRequest || jsonErrorText(t, out) != c.want { |
| + t.Errorf("%s: %d %s, want 400 %q", c.body, st, out, c.want) |
| + } |
| + } |
| + if rec := fx.record("a.example.com"); rec.Updates != release.UpdatesManual || rec.Pin != "v1" || rec.Major != nil { |
| + t.Fatalf("a refused request changed the record: %+v", rec) |
| + } |
| + // null and "" clear. |
| + if st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+updURL, fx.member, `{"updates":null,"pin":""}`); st != http.StatusOK { |
| + t.Fatalf("clearing with null and empty: %d %s", st, out) |
| + } |
| + if rec := fx.record("a.example.com"); rec.Updates != "" || rec.Pin != "" { |
| + t.Fatalf("null and empty did not clear: %+v", rec) |
| + } |
| +} |
| + |
| +func TestMajorOnlyOnStable(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "beta"}) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2}, fx.member) |
| + if st != http.StatusBadRequest || !strings.Contains(out, "stable") { |
| + t.Fatalf("major off stable: %d %s", st, out) |
| + } |
| +} |
| + |
| +// R4: a major left behind by a channel move is ignored by reconcile and shown |
| +// as major_ignored; it neither blocks an unrelated change nor is cleared by it. |
| +func TestStaleMajorOffStableIsLeftAlone(t *testing.T) { |
| + fx := updatesFixture(t) |
| + two := 2 |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "beta", Major: &two}) |
| + st, out := getRaw(t, fx.ts.URL+updURL, fx.member) |
| + if st != http.StatusOK || !strings.Contains(out, `"major_ignored":true`) { |
| + t.Fatalf("GET of a stale major: %d %s", st, out) |
| + } |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "auto"}, fx.member) |
| + if st != http.StatusOK { |
| + t.Fatalf("an unrelated change on a record with a stale major: %d %s", st, out) |
| + } |
| + if rec := fx.record("a.example.com"); rec.Updates != release.UpdatesAuto || rec.Major == nil || *rec.Major != 2 { |
| + t.Fatalf("record after the change: %+v", rec) |
| + } |
| + // Clearing the stale major is allowed too. |
| + if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": nil}, fx.member); st != http.StatusOK || fx.record("a.example.com").Major != nil { |
| + t.Fatalf("clearing a stale major: %d %s", st, out) |
| + } |
| +} |
| + |
| +func TestMajorOnStableIsSetAndCleared(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 3}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Major == nil || *rec.Major != 3 { |
| + t.Fatalf("major on stable: %d %s", st, out) |
| + } |
| + if !strings.Contains(out, `"major":3`) || !strings.Contains(out, `"major_ignored":false`) { |
| + t.Fatalf("the view after the change: %s", out) |
| + } |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": ""}, fx.member) |
| + if st != http.StatusOK || fx.record("a.example.com").Major != nil { |
| + t.Fatalf("clearing major with empty: %d %s", st, out) |
| + } |
| +} |
| + |
| +// A change bumps the serial once and leaves an activity line; a request that |
| +// changes nothing does neither. |
| +func TestUpdatesPutAnnouncesOnlyChanges(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + before := serial(t, fx.consoleFixture) |
| + if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{}, fx.member); st != http.StatusOK { |
| + t.Fatalf("empty PUT: %d %s", st, out) |
| + } |
| + if got := serial(t, fx.consoleFixture); got != before { |
| + t.Fatalf("an empty PUT bumped the serial: %d -> %d", before, got) |
| + } |
| + if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member); st != http.StatusOK { |
| + t.Fatalf("manual: %d %s", st, out) |
| + } |
| + if got := serial(t, fx.consoleFixture); got != before+1 { |
| + t.Fatalf("serial = %d, want one bump from %d", got, before) |
| + } |
| + acts, err := fx.st.Events(OpsAccountID, "km", 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := fmt.Sprintf(updatewords.Text("settings.console.audit-set"), "a.example.com", "updates manual") |
| + found := false |
| + for _, a := range acts { |
| + if a.Action == "instance-updates" && a.Detail == want && a.Address == "alice@keymail.dev" { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("no activity line %q by alice in %+v", want, acts) |
| + } |
| +} |
| + |
| +func TestUpdatesDoorsRefuseNamedUnitsAndOtherKinds(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + 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.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member) |
| + if st != http.StatusConflict || !strings.Contains(out, "systemd unit") { |
| + 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.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, 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) |
| + } |
| + // Clearing is refused on these too: the door does not apply to them at all. |
| + if rec := fx.record("a.example.com"); rec.Updates != "" { |
| + t.Fatalf("a refused request wrote the record: %+v", rec) |
| + } |
| +} |
| + |
| +func TestUpdatesDoorsRefuseChannel(t *testing.T) { |
| + fx := updatesFixture(t) |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"channel": "beta"}, fx.member) |
| + if st != http.StatusBadRequest || !strings.Contains(out, updatewords.Text("settings.console.channel-elsewhere")) { |
| + t.Fatalf("channel through /updates: %d %s", st, out) |
| + } |
| +} |
| + |
| +// An unknown host is the family's 404, for both doors. |
| +func TestUpdatesDoorsUnknownHostIs404(t *testing.T) { |
| + fx := updatesFixture(t) |
| + u := fx.ts.URL + "/api/cli/apps/bab/km/instances/nope.example.com/updates" |
| + if st, out := getRaw(t, u, fx.member); st != http.StatusNotFound { |
| + t.Fatalf("GET of an unknown host: %d %s", st, out) |
| + } |
| + if st, out := sendJSON(t, http.MethodPut, u, map[string]string{"updates": "auto"}, fx.member); st != http.StatusNotFound { |
| + t.Fatalf("PUT of an unknown host: %d %s", st, out) |
| + } |
| +} |
| + |
| +// The instance-bound credential is #155 step 6: until then no service |
| +// credential reaches any of the four doors. |
| +func TestUpdatesDoorsRefuseAppWideCredentials(t *testing.T) { |
| + fx := updatesFixture(t) |
| + for _, c := range []struct{ method, url string }{ |
| + {http.MethodGet, updURL}, {http.MethodPut, updURL}, |
| + {http.MethodPost, updURL + "/install"}, {http.MethodPut, "/api/cli/apps/bab/km/updates"}, |
| + } { |
| + st, _ := sendJSON(t, c.method, fx.ts.URL+c.url, map[string]string{}, fx.instanceToken) |
| + if st != http.StatusNotFound && st != http.StatusForbidden { |
| + t.Errorf("%s %s with an app-wide instance credential: %d", c.method, c.url, st) |
| + } |
| + } |
| +} |
| + |
| +func TestTheConsoleNeverChoosesABuild(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + before := fx.record("a.example.com") |
| + if st, _ := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member); st != http.StatusOK { |
| + t.Fatal(st) |
| + } |
| + after := fx.record("a.example.com") |
| + if after.Pin != before.Pin || after.Channel != before.Channel { |
| + t.Fatalf("a preference change moved a build: %+v -> %+v", before, after) |
| + } |
| + _ = context.Background() |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index f2012a7a6..60f169c21 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -643,6 +643,11 @@ func (s *Server) registerCLIRoutes() { |
| // onto a channel whose rules it never met. Widening it later is easy; |
| // narrowing an authority already granted is not. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel) |
| + // Update settings (instance update settings spec §5.1). Person-only until |
| + // #155 step 6 adds an instance-bound credential: svcrole.Instance is |
| + // 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) |
| // 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 25609419c..1106dbbd5 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -792,6 +792,10 @@ var claimFixtures = map[string]claimFixture{ |
| }, |
| }}, |
| + // The update settings read (settings plan 3), apart so its long pattern |
| + // leaves the block above aligned as it was. |
| + "route GET /api/cli/apps/{account}/{app}/instances/{host}/updates": claimRead("reads one instance record, the app manifest and the box heartbeats"), |
| + |
| // ---- PATCH ---- |
| "route PATCH /api/cli/apps/{account}/{app}/instances/{host}": {door: &claimDoor{ |
| apps: claimRefs("bab/titogo"), |
| @@ -1451,6 +1455,11 @@ var claimFixtures = map[string]claimFixture{ |
| apps: claimRefs("bab/titogo"), |
| do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/instances-manifest", `{}`), |
| }}, |
| + "route PUT /api/cli/apps/{account}/{app}/instances/{host}/updates": {door: &claimDoor{ |
| + apps: claimRefs("bab/titogo"), |
| + setup: claimWorldInstance, |
| + do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates", `{"updates":"auto"}`), |
| + }}, |
| "route PUT /api/cli/apps/{account}/{app}/pipeline": {door: &claimDoor{ |
| apps: claimRefs("bab/titogo"), |
| do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/pipeline", `{"channels":[{"name":"edge"}]}`), |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index c94acb2ac..c801bd0b6 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -190,6 +190,8 @@ var claimReadProbes = map[string]claimProbe{ |
| "/api/cli/apps/bab/titogo/instances/acme.go.tito.io", "acme.go.tito.io"), |
| "route GET /api/cli/apps/{account}/{app}/instances/{host}/move": claimAPIRead(claimWorldMove, |
| "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move", `"state":"none"`), |
| + "route GET /api/cli/apps/{account}/{app}/instances/{host}/updates": claimAPIRead(claimWorldInstance, |
| + "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates", `"host":"acme.go.tito.io"`, `"updates":"auto"`), |
| "route GET /api/cli/apps/{account}/{app}/ledgers": claimAPIRead(claimWorldLedger, "/api/cli/apps/bab/titogo/ledgers", `"carbon"`), |
| "route GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head": claimAPIRead(claimWorldLedger, |
| "/api/cli/apps/bab/titogo/ledgers/carbon/head", `"head_sha256"`), |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 61a55de7d..01ab7f7a1 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -179,6 +179,12 @@ var closedDoors = []string{ |
| // whose rules it never met. Shut for the same reason the manifest door |
| // is — an app may not rewrite the bounds it runs inside. |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/channel", |
| + // Instance update settings (settings spec §3.3): a member's preference |
| + // about which authorized build an instance runs. svcrole.Instance is |
| + // app-wide, so it would let one instance's code pin a sibling; the |
| + // 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", |
| // 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 |
| @@ -917,8 +923,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 48 { |
| - t.Errorf("walked %d closed doors, want all %d (and 48 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, 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 != 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)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 789d2c392..134b17b35 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -173,6 +173,8 @@ route POST /api/cli/apps/{account}/{app}/features |
| route POST /api/cli/apps/{account}/{app}/instance-token |
| route POST /api/cli/apps/{account}/{app}/instances |
| route POST /api/cli/apps/{account}/{app}/instances/{host}/channel |
| +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}/keep |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index e432903d3..01f38140e 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -590,9 +590,10 @@ 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/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", "1b93d0d7d358aeec836f88b9f6ee4920fee76a8dad52d347c47affe51557a4dc"}, |
| + {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "4206a0ca107bfb8e4fffe8b641ee2433279933103283dc667e4ef12d88993585"}, |
| {"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"}, |
diff --git a/internal/updatewords/words_approved_test.go b/internal/updatewords/words_approved_test.go| index b18168ccc..64aecbb5d 100644 |
| --- a/internal/updatewords/words_approved_test.go |
| +++ b/internal/updatewords/words_approved_test.go |
| @@ -49,6 +49,10 @@ func TestApprovedCopyIsApplied(t *testing.T) { |
| } |
| } |
| for _, d := range All() { |
| + // Plan 3's settings.console.* drafts get their own approved record after Paul's copy review. |
| + if strings.HasPrefix(d.ID, "settings.console.") { |
| + continue |
| + } |
| want = append(want, d.ID) |
| } |
| sort.Strings(got) |
diff --git a/internal/updatewords/words_console.go b/internal/updatewords/words_console.go| new file mode 100644 |
| index 000000000..71d7a92f9 |
| --- /dev/null |
| +++ b/internal/updatewords/words_console.go |
| @@ -0,0 +1,81 @@ |
| +package updatewords |
| + |
| +// Plan 3's member-facing strings: the console's update settings doors, the |
| +// install request and the app policy. Every one is a draft until Paul's copy |
| +// review; plan 3's approved record pins them after that. |
| +func init() { |
| + add( |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.bad-updates", Text: "updates must be auto or manual", |
| + Context: "Returned by carlos instances set and the console when updates is anything else."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.bad-major", Text: "major must be a whole number, 0 or more, or empty to clear it", |
| + Context: "Returned when the major hold is not a number."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.bad-pin", Text: "%s is not a build version", |
| + Context: "Returned when the pin is not a build version: not text, or text no build could be named. %s is the value sent, printable characters only, or the word that when none are left."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.major-off-stable", Text: "a major hold applies only to instances on the stable channel. %s is on %s", |
| + Context: "Returned when major is set on an instance that follows another channel. Host, channel."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.pin-unpromoted", Text: "%s cannot be pinned: it has no promotion record. Pick a newer build, or ask an operator to authorize this one", |
| + Context: "Returned when the pin names a build that was never promoted, or was promoted before records existed. %s is the version. Only an operator can run carlos releases authorize."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.pin-check-failed", Text: "could not check the promotion record of %s. Try again", |
| + Context: "Returned when the console could not read or verify the promotion record of the build being pinned. %s is the version."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.not-enforced", Text: "pin, manual and major can be turned on once every box runs a release that applies them. %d boxes are not ready yet", |
| + Context: "Returned when someone turns on pin, manual or major before every box is upgraded. %d is how many boxes are still on an older release. Clearing these settings is always allowed."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.enforcement-unknown", Text: "could not check whether every box applies update settings, so pin, manual and major stay off for now. Try again", |
| + Context: "Returned when someone turns on pin, manual or major and the console could not read which release each box runs. Clearing these settings is always allowed."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.named-unit", Text: "%s is run by its own systemd unit, so it always takes its channel's builds. Update settings do not apply to it", |
| + Context: "Returned for an instance whose process the platform does not start, such as a console. %s is the host."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.not-instance", Text: "update settings apply to instances. %s is a %s route", |
| + Context: "Returned for a static site or a service route. Host, kind."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.channel-elsewhere", Text: "change the channel with carlos instances set --channel, which applies the app's pipeline rules", |
| + Context: "Returned when a request to the settings door names channel. The channel has its own door with passkey and approval rules."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.app-keeps", Text: "%s is kept by the app for itself. Ask someone who can change the app's settings", |
| + Context: "Returned, once instances can act for themselves, when an instance tries to change a setting the app's policy keeps. %s is the setting. Not reachable until instance credentials ship."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.pinned", Text: "%s is pinned to %s, so Update now would not move it. Clear the pin first", |
| + Context: "Returned by carlos instances update and the Update now button on a pinned instance. Host, pinned version."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.fast-track-owner", Text: "only an account owner can skip the safety delay", |
| + Context: "Returned when someone who is not an account owner asks Update now to skip the bake window."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.changed", Text: "this instance changed while the request was in flight. Try again", |
| + Context: "Returned when two people changed the same instance at once and this request lost."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.store-failed", Text: "could not save the update settings. Try again", |
| + Context: "Returned when the console could not write the record."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.read-failed", Text: "could not read this instance. Try again", |
| + Context: "Returned by the update settings doors when the console could not read the instance's record."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.policy-read-failed", Text: "could not read the app's update policy. Try again", |
| + Context: "Returned by the update settings doors when the console could not read the app's instances manifest, which holds its update policy."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.no-manifest", Text: "this app does not create instances yet. Run carlos instances enable first", |
| + Context: "Returned by carlos apps set's update flags for an app with no instance provisioning."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.unknown-policy-key", Text: "%s is not an app update setting. The app sets instance_control, default_updates, default_channel and default_major; there is no default pin", |
| + Context: "Returned by the app policy door for any other key, for example a default pin. %s is the key."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.bad-control", Text: "instance_control may name updates, channel, major and pin, each once", |
| + Context: "Returned by carlos apps set --instance-control for anything else."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.audit-set", Text: "changed the update settings of %s: %s", |
| + Context: "Activity line after a settings change. Host, then what changed, for example updates manual, pin cleared."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.audit-install", Text: "asked %s to update now (request %d)", |
| + Context: "Activity line after Update now. Host, request number."}, |
| + // TODO-COPY: draft, awaiting Paul's copy review |
| + Draft{ID: "settings.console.audit-policy", Text: "changed the instance update policy of %s: %s", |
| + Context: "Activity line after an app policy change. App, then what changed."}, |
| + ) |
| +} |