carlos / platform Public

settings-3 task 8: the app's instance update policy door

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0913833ca2f9375fd87023be5978e022b26b599f parent 86784bc
7 files changed, +657 −40
  • internal/console/apiupdates.go +262 −33
  • internal/console/apiupdates_test.go +354 −2
  • internal/console/authcli.go +4 −0
  • internal/console/claim_invariant_fixtures_test.go +22 −0
  • internal/console/svcroutes_test.go +12 −3
  • internal/console/testdata/claim-routes.txt +1 −0
  • internal/provision/plan9_b8_census_test.go +2 −2
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go
index e9f5e2eaa..5a53c9e6e 100644
--- a/internal/console/apiupdates.go
+++ b/internal/console/apiupdates.go
@@ -1,6 +1,8 @@
// 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,
-// and POST …/updates/install ("Update now", handleAPIUpdatesInstall).
+// and POST …/updates/install ("Update now", handleAPIUpdatesInstall); and the
+// app's policy door, PUT /api/cli/apps/{account}/{app}/updates (§3.2,
+// handleAPIUpdatesPolicy), which writes the instances manifest.
//
// 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 +29,8 @@ import (
"math"
"net/http"
"os"
+ "slices"
+ "sort"
"strings"
"time"
"unicode"
@@ -155,35 +159,20 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
next := *rec
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) {
+ str, ok := updatesValue(v)
+ if !ok {
jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates"))
return
}
next.Updates = str
}
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
- majorSet = true
- case string:
- if n != "" {
- jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major"))
- return
- }
- next.Major = nil
- default:
+ major, ok := majorValue(v)
+ if !ok {
jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major"))
return
}
+ next.Major, majorSet = major, major != nil
}
if v, has := req["pin"]; has {
var str string
@@ -244,18 +233,8 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
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 {
- 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 enabling && !s.enableAllowed(ctx, w, acct.Sqid+"/"+app+"/"+rec.Host) {
+ return
}
if err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag); err != nil {
if errors.Is(err, release.ErrCASConflict) {
@@ -274,6 +253,55 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, next, policy))
}
+// enableAllowed is the §4.4 gate both settings doors put in front of a change
+// that turns a setting ON. It answers true when every box applies update
+// settings; otherwise it writes the refusal and answers false. A member is
+// told how many boxes are not ready (R3); which boxes is operator detail,
+// logged with what (the target the log line names) and never shown.
+func (s *Server) enableAllowed(ctx context.Context, w http.ResponseWriter, what string) bool {
+ ok, blocking, err := s.updatesEnforced(ctx)
+ switch {
+ case err != nil:
+ log.Printf("console: api updates %s: enable refused, the box inventory could not be read: %v", what, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.enforcement-unknown"))
+ return false
+ case !ok:
+ log.Printf("console: api updates %s: enable refused, not every box applies update settings: %s", what, strings.Join(blocking, "; "))
+ jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), len(blocking)))
+ return false
+ }
+ return true
+}
+
+// updatesValue reads an updates value from a JSON value: "auto" or "manual"
+// sets it, null or "" clears it (""), and anything else is refused.
+func updatesValue(v any) (string, bool) {
+ str, isStr := v.(string)
+ if (v != nil && !isStr) || (str != "" && str != release.UpdatesAuto && str != release.UpdatesManual) {
+ return "", false
+ }
+ return str, true
+}
+
+// majorValue reads a major hold from a JSON value: a whole number from 0 to
+// MaxInt32 sets it, null or "" clears it (nil), and anything else is refused.
+func majorValue(v any) (*int, bool) {
+ switch n := v.(type) {
+ case nil:
+ return nil, true
+ case float64:
+ if n < 0 || n > math.MaxInt32 || n != math.Trunc(n) {
+ return nil, false
+ }
+ i := int(n)
+ return &i, true
+ case string:
+ return nil, n == ""
+ default:
+ return nil, false
+ }
+}
+
// 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 {
@@ -393,3 +421,204 @@ func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request)
}
jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed"))
}
+
+// handleAPIUpdatesPolicy is PUT /api/cli/apps/{account}/{app}/updates: the
+// app's instance update policy (spec §3.2), on its instances manifest. The
+// policy is four keys: instance_control (which settings an instance may
+// change itself), default_updates, default_major, and default_channel, which
+// is the manifest's own Channel, the one new instances are created on. A key
+// present with null or "" clears it, except default_channel, which can be
+// changed but never cleared, as at the instances-manifest door. Any other
+// value of the wrong type is refused rather than read as a clear.
+//
+// Who: app members, people only. It writes the manifest, standing policy
+// about the bounds the app's instances run inside, so it is closed to every
+// service credential for the instances-manifest door's reason (svcrole.Set{}
+// at the registration, apiPerson here as the second wall).
+//
+// When: a default manual or a default major is turned ON only while every box
+// applies update settings (§4.4, enableAllowed); clearing is never refused,
+// and instance_control and default_channel never wait.
+func (s *Server) handleAPIUpdatesPolicy(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var req map[string]any
+ if !readJSON(w, r, &req) {
+ return
+ }
+ // The policy has exactly these four keys (spec rev 11 §3.2): there is no
+ // default pin, and an unknown key is refused rather than ignored, so a
+ // caller who sends one learns it did nothing. Checked before the store is
+ // read, and in key order so the answer names the same key every time.
+ keys := make([]string, 0, len(req))
+ for k := range req {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ switch k {
+ case "instance_control", "default_updates", "default_channel", "default_major":
+ default:
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.unknown-policy-key"), shownValue(k)))
+ return
+ }
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ jsonError(w, http.StatusConflict, updatewords.Text("settings.console.no-manifest"))
+ return
+ case err != nil:
+ log.Printf("console: api updates policy %s/%s: manifest: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed"))
+ return
+ }
+ var before provision.UpdatePolicy
+ if m.Updates != nil {
+ before = *m.Updates
+ }
+ next := before
+ nextChannel := m.Channel
+ if v, has := req["instance_control"]; has {
+ control, ok := controlValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-control"))
+ return
+ }
+ next.InstanceControl = control
+ }
+ if v, has := req["default_updates"]; has {
+ str, ok := updatesValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates"))
+ return
+ }
+ next.DefaultUpdates = str
+ }
+ if v, has := req["default_major"]; has {
+ major, ok := majorValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major"))
+ return
+ }
+ next.DefaultMajor = major
+ }
+ if v, has := req["default_channel"]; has {
+ // The instances-manifest door's check and its words: a manifest's
+ // channel is what new instances are created on, and "clearing" it is
+ // not a policy act this door offers.
+ str, isStr := v.(string)
+ if !isStr || !release.ValidChannel(str) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ nextChannel = str
+ }
+ if err := provision.ValidatePolicy(&next); err != nil {
+ // Every shape ValidatePolicy 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 := policyChanges(before, next, m.Channel, nextChannel)
+ if len(changes) == 0 {
+ // Nothing changed: no write, no serial bump, no audit line, and
+ // nothing to refuse.
+ writeJSON(w, http.StatusOK, policyView(m.Channel, before))
+ return
+ }
+ // Only a change that turns a default ON waits for the fleet: manual newly
+ // set, or a new major. A value re-sent unchanged beside a clear is not an
+ // enable, so the clear is never refused (§4.4).
+ enabling := (next.DefaultUpdates == release.UpdatesManual && before.DefaultUpdates != release.UpdatesManual) ||
+ (next.DefaultMajor != nil && !sameMajor(before.DefaultMajor, next.DefaultMajor))
+ if enabling && !s.enableAllowed(ctx, w, acct.Sqid+"/"+app+" policy") {
+ return
+ }
+ out := *m
+ out.Channel = nextChannel
+ out.Updates = nil
+ if next.InstanceControl != nil || next.DefaultUpdates != "" || next.DefaultMajor != nil {
+ p := next
+ out.Updates = &p
+ }
+ if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, out, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed"))
+ return
+ }
+ log.Printf("console: api updates policy %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed"))
+ return
+ }
+ s.auditActivity(acct, app, principal, Activity{Action: "app-update-policy", TargetType: "app", Target: app,
+ Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-policy"), app, strings.Join(changes, ", "))})
+ // Box-consumed: reconcile reads the manifest's policy and channel on its
+ // next pass, so announcing the write is what makes it prompt.
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, policyView(nextChannel, next))
+}
+
+// controlValue reads instance_control from a JSON value: an array of setting
+// names, each once, sets it; null or [] clears it (nil); anything else,
+// including an array holding a non-string, is refused.
+func controlValue(v any) ([]string, bool) {
+ switch list := v.(type) {
+ case nil:
+ return nil, true
+ case []any:
+ var out []string
+ for _, x := range list {
+ str, ok := x.(string)
+ if !ok {
+ return nil, false
+ }
+ out = append(out, str)
+ }
+ return out, release.ValidControl(out)
+ default:
+ return nil, false
+ }
+}
+
+// policyChanges names what differs between the policy before and after, in
+// the activity line's words: "instance_control updates,pin", "default
+// updates cleared", "default major 2", "default channel beta".
+func policyChanges(before, after provision.UpdatePolicy, channelBefore, channelAfter string) []string {
+ var out []string
+ if !slices.Equal(before.InstanceControl, after.InstanceControl) {
+ out = append(out, "instance_control "+orCleared(strings.Join(after.InstanceControl, ",")))
+ }
+ if before.DefaultUpdates != after.DefaultUpdates {
+ out = append(out, "default updates "+orCleared(after.DefaultUpdates))
+ }
+ if !sameMajor(before.DefaultMajor, after.DefaultMajor) {
+ if after.DefaultMajor == nil {
+ out = append(out, "default major cleared")
+ } else {
+ out = append(out, fmt.Sprintf("default major %d", *after.DefaultMajor))
+ }
+ }
+ if channelBefore != channelAfter {
+ out = append(out, "default channel "+channelAfter)
+ }
+ return out
+}
+
+// policyView is the policy door's answer. instance_control is always an
+// array, [] when no setting is the instance's own.
+func policyView(channel string, p provision.UpdatePolicy) map[string]any {
+ return map[string]any{
+ "instance_control": append([]string{}, p.InstanceControl...),
+ "default_updates": p.DefaultUpdates,
+ "default_channel": channel,
+ "default_major": p.DefaultMajor,
+ }
+}
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go
index 698f9d8ae..9f4b3be03 100644
--- a/internal/console/apiupdates_test.go
+++ b/internal/console/apiupdates_test.go
@@ -54,6 +54,7 @@ func updatesFixture(t *testing.T) *updatesFx {
owner: cliToken(t, fx, "paul@keymail.dev", "code-updates-owner"),
instanceToken: serviceToken(t, fx.st, OpsAccountID, "router", "km", svcrole.Instance),
}
+ ux.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable"})
ux.putRecord(provision.Record{Schema: 1, Host: "a.example.com", Slug: "a", App: "km",
Account: release.OpsAccount, Channel: "stable", CreatedAt: now.UTC()})
return ux
@@ -80,12 +81,32 @@ func (fx *updatesFx) record(host string) provision.Record {
return *rec
}
-// manifest writes km's instances manifest (and so its update policy).
-func (fx *updatesFx) manifest(m provision.Manifest) {
+// setManifest writes km's instances manifest (and so its update policy).
+func (fx *updatesFx) setManifest(m provision.Manifest) {
fx.t.Helper()
enableInstances(fx.t, fx.consoleFixture, "km", m)
}
+// manifest reads km's instances manifest back.
+func (fx *updatesFx) manifest() provision.Manifest {
+ fx.t.Helper()
+ m, err := provision.GetManifest(context.Background(), fx.store, release.OpsAccount, "km")
+ if err != nil {
+ fx.t.Fatal(err)
+ }
+ return *m
+}
+
+// createInstance creates host through the console's create door as the
+// member, naming no channel, so the record takes the manifest's.
+func (fx *updatesFx) createInstance(t *testing.T, host string) {
+ t.Helper()
+ st, _, out := rawReq(t, http.MethodPost, instancesURL(fx.consoleFixture, "bab", "km"), fx.member, `{"host":"`+host+`"}`)
+ if st != http.StatusCreated {
+ t.Fatalf("create %s: %d %s", host, st, out)
+ }
+}
+
// 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) {
@@ -679,3 +700,334 @@ func TestFastTrackInstallNeedsAnOwner(t *testing.T) {
t.Fatalf("a member's ordinary request after a fast track: %d %s, record %+v", st, out, rec)
}
}
+
+const policyURL = "/api/cli/apps/bab/km/updates"
+
+func TestPolicyDoorSetsDefaults(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL,
+ map[string]any{"instance_control": []string{"updates"}, "default_updates": "manual"}, fx.member)
+ if st != http.StatusOK || !strings.Contains(out, `"default_updates":"manual"`) {
+ t.Fatalf("policy: %d %s", st, out)
+ }
+ if m := fx.manifest(); m.Updates == nil || m.Updates.DefaultUpdates != "manual" || m.Channel != "stable" {
+ t.Fatalf("manifest = %+v", m)
+ }
+}
+
+func TestPolicyDefaultsAreGatedLikeSettings(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(false)
+ st, _ := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": 2}, fx.member)
+ if st != http.StatusConflict {
+ t.Fatalf("a default major before every box enforces: %d", st)
+ }
+ st, _ = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"instance_control": []string{"pin"}}, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("instance_control enables nothing by itself: %d", st)
+ }
+ st, _ = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"instance_control": []string{"bake"}}, fx.member)
+ if st != http.StatusBadRequest {
+ t.Fatalf("an unknown control name: %d", st)
+ }
+}
+
+// Spec rev 11 §3.2: there is no default pin.
+func TestPolicyDoorHasNoDefaultPin(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_pin": "v1"}, fx.member)
+ if st != http.StatusBadRequest || !strings.Contains(out, "default_pin") {
+ t.Fatalf("a default pin: %d %s", st, out)
+ }
+ if m := fx.manifest(); m.Updates != nil {
+ t.Fatalf("the refused request wrote a policy: %+v", m.Updates)
+ }
+}
+
+// Spec rev 11 §3.2: the channel default applies only when an instance is
+// created; every record carries its channel from creation.
+func TestDefaultChannelReachesOnlyNewInstances(t *testing.T) {
+ fx := updatesFixture(t) // a.example.com exists on stable
+ st, _ := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "beta"}, fx.member)
+ if st != http.StatusOK {
+ t.Fatal(st)
+ }
+ if ch := fx.record("a.example.com").Channel; ch != "stable" {
+ t.Fatalf("an existing instance moved to %s", ch)
+ }
+ rec := fx.record("a.example.com")
+ if u := provision.EffectiveUpdates(rec, fx.manifest().Updates); u.Channel != "stable" {
+ t.Fatalf("effective channel %s", u.Channel)
+ }
+ fx.createInstance(t, "b.example.com") // through the console's create door, no channel given
+ if ch := fx.record("b.example.com").Channel; ch != "beta" {
+ t.Fatalf("a new instance got %s, want the default beta", ch)
+ }
+}
+
+// Decision 7: the key check comes before any store read, so an unknown key
+// is the 400 even for an app with no instances manifest.
+func TestPolicyUnknownKeyIsRefusedBeforeTheStore(t *testing.T) {
+ fx := updatesFixture(t)
+ if err := fx.store.Delete(context.Background(), provision.ManifestKey(release.OpsAccount, "km")); err != nil {
+ t.Fatal(err)
+ }
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_pin": "v1", "default_updates": "auto"}, fx.member)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.unknown-policy-key"), "default_pin") {
+ t.Fatalf("an unknown key on an app with no manifest: %d %s", st, out)
+ }
+}
+
+// Decision 1: only a change that turns a default ON waits for the fleet. A
+// value re-sent unchanged beside a clear is not an enable, and clearing is
+// never refused.
+func TestPolicyClearingBesideAnUnchangedEnableIsNeverRefused(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(false)
+ two := 2
+ policy := &provision.UpdatePolicy{InstanceControl: []string{"pin"}, DefaultUpdates: release.UpdatesManual, DefaultMajor: &two}
+ fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", Updates: policy})
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL,
+ map[string]any{"default_updates": "manual", "default_major": 2, "instance_control": nil}, fx.member)
+ if p := fx.manifest().Updates; st != http.StatusOK || p == nil || p.InstanceControl != nil ||
+ p.DefaultUpdates != release.UpdatesManual || p.DefaultMajor == nil || *p.DefaultMajor != 2 {
+ t.Fatalf("manual and major re-sent, control cleared: %d %s, policy %+v", st, out, p)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": 2, "default_updates": "auto"}, fx.member)
+ if p := fx.manifest().Updates; st != http.StatusOK || p == nil || p.DefaultUpdates != release.UpdatesAuto || p.DefaultMajor == nil || *p.DefaultMajor != 2 {
+ t.Fatalf("major re-sent, manual turned off: %d %s, policy %+v", st, out, p)
+ }
+ // instance_control and default_channel never enable.
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"instance_control": []string{"updates", "major"}, "default_channel": "beta"}, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("control and channel before the fleet enforces: %d %s", st, out)
+ }
+ // A changed value is still an enable, and still waits.
+ for _, body := range []map[string]any{{"default_major": 3}, {"default_updates": "manual"}, {"default_major": 3, "default_updates": "auto"}} {
+ before := fx.manifest()
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, body, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), 1) {
+ t.Errorf("%v before the fleet enforces: %d %s", body, st, out)
+ }
+ if strings.Contains(out, "ie-1") {
+ t.Errorf("%v: the refusal names a box: %s", body, out)
+ }
+ after := fx.manifest()
+ if after.Updates.DefaultUpdates != before.Updates.DefaultUpdates || !sameMajor(after.Updates.DefaultMajor, before.Updates.DefaultMajor) {
+ t.Errorf("%v: a refused enable wrote the manifest: %+v -> %+v", body, before.Updates, after.Updates)
+ }
+ }
+ // Clearing them is never refused.
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": nil, "default_updates": nil, "instance_control": []string{}}, fx.member)
+ if m := fx.manifest(); st != http.StatusOK || m.Updates != nil || m.Channel != "beta" {
+ t.Fatalf("clearing every default: %d %s, manifest %+v", st, out, m)
+ }
+}
+
+// R3 at the policy door: an unreadable inventory refuses an enable with its
+// own words, and a clear still goes through.
+func TestPolicyEnableIsRefusedWhenEnforcementIsUnknown(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+policyURL, map[string]any{"default_updates": "manual"}, fx.member)
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.enforcement-unknown") {
+ t.Fatalf("enable with an unreadable inventory: %d %s", st, out)
+ }
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "auto"}, fx.member); st != http.StatusOK {
+ t.Fatalf("a non-enabling change with an unreadable inventory: %d %s", st, out)
+ }
+}
+
+// Decision 2: strict input. A value of the wrong type is refused, never read
+// as a clear, and a refused request writes nothing.
+func TestPolicyDoorRefusesWrongTypes(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ one := 1
+ policy := &provision.UpdatePolicy{InstanceControl: []string{"pin"}, DefaultUpdates: release.UpdatesManual, DefaultMajor: &one}
+ fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", Updates: policy})
+ badControl := updatewords.Text("settings.console.bad-control")
+ badUpdates := updatewords.Text("settings.console.bad-updates")
+ badMajor := updatewords.Text("settings.console.bad-major")
+ for _, c := range []struct{ body, want string }{
+ {`{"instance_control":"updates"}`, badControl},
+ {`{"instance_control":""}`, badControl},
+ {`{"instance_control":5}`, badControl},
+ {`{"instance_control":{"updates":true}}`, badControl},
+ {`{"instance_control":[1]}`, badControl},
+ {`{"instance_control":["updates",null]}`, badControl},
+ {`{"instance_control":["updates","updates"]}`, badControl},
+ {`{"instance_control":["bake"]}`, badControl},
+ {`{"default_updates":5}`, badUpdates},
+ {`{"default_updates":true}`, badUpdates},
+ {`{"default_updates":"sometimes"}`, badUpdates},
+ {`{"default_major":-1}`, badMajor},
+ {`{"default_major":1.5}`, badMajor},
+ {`{"default_major":"2"}`, badMajor},
+ {`{"default_major":1e300}`, badMajor},
+ {`{"default_major":true}`, badMajor},
+ {`{"default_major":[2]}`, badMajor},
+ } {
+ st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, 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 p := fx.manifest().Updates; p == nil || len(p.InstanceControl) != 1 || p.DefaultUpdates != release.UpdatesManual || p.DefaultMajor == nil || *p.DefaultMajor != 1 {
+ t.Fatalf("a refused request changed the policy: %+v", p)
+ }
+ // null and "" clear, for every key that clears.
+ if st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, fx.member, `{"instance_control":null,"default_updates":"","default_major":""}`); st != http.StatusOK {
+ t.Fatalf("clearing with null and empty: %d %s", st, out)
+ }
+ if m := fx.manifest(); m.Updates != nil || m.Channel != "stable" {
+ t.Fatalf("null and empty did not clear: %+v", m)
+ }
+ fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", Updates: policy})
+ if st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, fx.member, `{"default_updates":null,"default_major":null}`); st != http.StatusOK {
+ t.Fatalf("clearing with null: %d %s", st, out)
+ }
+ if p := fx.manifest().Updates; p == nil || p.DefaultUpdates != "" || p.DefaultMajor != nil || len(p.InstanceControl) != 1 {
+ t.Fatalf("null did not clear only what it named: %+v", p)
+ }
+}
+
+// Decision 3: default_channel is checked as the instances-manifest door
+// checks its channel, and the manifest's channel cannot be cleared here.
+func TestDefaultChannelIsValidatedAndNeverCleared(t *testing.T) {
+ fx := updatesFixture(t)
+ for _, body := range []string{
+ `{"default_channel":""}`, `{"default_channel":null}`, `{"default_channel":5}`,
+ `{"default_channel":"../x"}`, `{"default_channel":"a/b/c"}`, `{"default_channel":"Beta Two"}`,
+ } {
+ st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, fx.member, body)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != "invalid channel name" {
+ t.Errorf("%s: %d %s, want 400 invalid channel name", body, st, out)
+ }
+ }
+ if ch := fx.manifest().Channel; ch != "stable" {
+ t.Fatalf("a refused channel changed the manifest: %s", ch)
+ }
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "canary/x"}, fx.member); st != http.StatusOK || fx.manifest().Channel != "canary/x" {
+ t.Fatalf("a two-part channel: %d %s", st, out)
+ }
+}
+
+// policyFailingStore fails every read of one key with brokenStoreErr.
+type policyFailingStore struct {
+ release.Store
+ key string
+}
+
+func (s policyFailingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == s.key {
+ return nil, "", errors.New(brokenStoreErr)
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// Decision 4: no manifest is 409 no-manifest; a manifest that cannot be read
+// is a 502 that does not leak the store's words.
+func TestPolicyDoorManifestReadFailures(t *testing.T) {
+ fx := updatesFixture(t)
+ key := provision.ManifestKey(release.OpsAccount, "km")
+ fx.srv.cfg.Releases = policyFailingStore{fx.rel, key}
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "auto"}, fx.member)
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.policy-read-failed") {
+ t.Fatalf("an unreadable manifest: %d %s", st, out)
+ }
+ if strings.Contains(out, brokenStoreErr) {
+ t.Fatalf("the refusal leaks the store's error: %s", out)
+ }
+ fx.srv.cfg.Releases = fx.rel
+ if err := fx.store.Delete(context.Background(), key); err != nil {
+ t.Fatal(err)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "auto"}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != updatewords.Text("settings.console.no-manifest") {
+ t.Fatalf("no manifest: %d %s", st, out)
+ }
+ if _, _, err := fx.store.Get(context.Background(), key); err == nil {
+ t.Fatal("the refused request wrote a manifest")
+ }
+}
+
+// Decision 5: a lost race is 409 changed; any other write failure is a 502
+// that does not leak the store's words.
+func TestPolicyDoorWriteFailures(t *testing.T) {
+ fx := updatesFixture(t)
+ key := provision.ManifestKey(release.OpsAccount, "km")
+ fx.srv.cfg.Releases = installCASStore{fx.rel, key}
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "beta"}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != updatewords.Text("settings.console.changed") {
+ t.Fatalf("a lost race: %d %s", st, out)
+ }
+ fx.srv.cfg.Releases = unputtableStore{fx.rel}
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "beta"}, fx.member)
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.store-failed") {
+ t.Fatalf("an unwritable store: %d %s", st, out)
+ }
+ if strings.Contains(out, brokenStoreErr) {
+ t.Fatalf("the refusal leaks the store's error: %s", out)
+ }
+ fx.srv.cfg.Releases = fx.rel
+ if ch := fx.manifest().Channel; ch != "stable" {
+ t.Fatalf("a failed write changed the manifest: %s", ch)
+ }
+}
+
+// Decisions 6 and 8: a change bumps the serial once and leaves one activity
+// line; a request that changes nothing writes, bumps and audits nothing, and
+// answers the current policy. instance_control is [] when empty, never null.
+func TestPolicyDoorAnnouncesOnlyChanges(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ key := provision.ManifestKey(release.OpsAccount, "km")
+ _, etag, err := fx.store.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ before := serial(t, fx.consoleFixture)
+ for _, body := range []map[string]any{{}, {"default_channel": "stable"}, {"default_updates": nil, "default_major": "", "instance_control": []string{}}} {
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, body, fx.member)
+ want := `{"default_channel":"stable","default_major":null,"default_updates":"","instance_control":[]}`
+ if st != http.StatusOK || strings.TrimSpace(out) != want {
+ t.Fatalf("%v: %d %s, want 200 %s", body, st, out, want)
+ }
+ }
+ if _, now, _ := fx.store.Get(context.Background(), key); now != etag {
+ t.Fatal("a request that changed nothing wrote the manifest")
+ }
+ if got := serial(t, fx.consoleFixture); got != before {
+ t.Fatalf("a request that changed nothing bumped the serial: %d -> %d", before, got)
+ }
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL,
+ map[string]any{"instance_control": []string{"updates", "pin"}, "default_major": 2, "default_channel": "beta"}, fx.member)
+ want := `{"default_channel":"beta","default_major":2,"default_updates":"","instance_control":["updates","pin"]}`
+ if st != http.StatusOK || strings.TrimSpace(out) != want {
+ t.Fatalf("a change: %d %s, want %s", st, out, want)
+ }
+ 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)
+ }
+ wantLine := fmt.Sprintf(updatewords.Text("settings.console.audit-policy"), "km", "instance_control updates,pin, default major 2, default channel beta")
+ n := 0
+ for _, a := range acts {
+ if a.Action == "app-update-policy" {
+ n++
+ if a.Detail != wantLine || a.Address != "alice@keymail.dev" {
+ t.Errorf("activity line %+v, want %q by alice", a, wantLine)
+ }
+ }
+ }
+ if n != 1 {
+ t.Fatalf("%d policy activity lines, want 1: %+v", n, acts)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 6432a712c..3feb2dc43 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -651,6 +651,10 @@ func (s *Server) registerCLIRoutes() {
// "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 app's instance update policy (spec §3.2) lives on the instances
+ // manifest, so it is closed for the manifest door's reason: a credential
+ // the app holds must not rewrite the bounds its instances run inside.
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/updates", svcrole.Set{}, s.handleAPIUpdatesPolicy)
// 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 003a31d4f..d8fa4daeb 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1477,6 +1477,28 @@ var claimFixtures = map[string]claimFixture{
apps: claimRefs("bab/titogo"),
do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/telemetry", `{"errors":{"log_level":"warn"}}`),
}},
+ // The app's instance update policy (settings plan 3 Task 8) writes the
+ // instances manifest, as the instances-manifest door does.
+ "route PUT /api/cli/apps/{account}/{app}/updates": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: func(t *testing.T, cx *claimCase) {
+ t.Helper()
+ cx.instanceWorld(t)
+ },
+ do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/updates", `{"default_channel":"beta","instance_control":["updates"]}`),
+ check: func(t *testing.T, cx *claimCase, open bool) string {
+ m, err := provision.GetManifest(context.Background(), cx.rel, "bab", "titogo")
+ switch {
+ case err != nil:
+ return err.Error()
+ case open && (m.Channel != "beta" || m.Updates == nil):
+ return "the titogo manifest carries no new policy after the open run: the handler never reached its write"
+ case !open && (m.Channel != "stable" || m.Updates != nil):
+ return "the titogo manifest's policy changed while titogo was closed under a foreign exclusive operation"
+ }
+ return ""
+ },
+ }},
"route PUT /api/cli/apps/{account}/{app}/version-target": {door: &claimDoor{
apps: claimRefs("bab/titogo"),
do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/version-target", `{"target":"0.5.0"}`),
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 2dd1aa48b..efcab1ca3 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -48,6 +48,9 @@ import (
// which domain, pool, regions and channel an app may ever be brought up on.
// A credential the app itself holds must not rewrite the bounds it runs
// inside, which is a fact about the door's subject and not about any role.
+// /{app}/updates is the same manifest's update policy (settings spec §3.2),
+// including which settings an instance may change itself: the bound on the
+// instance credential, which that credential must never widen.
//
// The email family adds one entry to each list, and both are facts about the
// door's subject rather than about any role:
@@ -75,7 +78,7 @@ import (
var (
mintDoors = []string{"/instance-token", "/door-keys", "/api/cli/services/", "/email/credentials"}
fleetDoors = []string{"/api/cli/fleets/"}
- policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe"}
+ policyDoors = []string{"/instances-manifest", "/{app}/updates", "/{app}/routes", "/email/pause", "/build-probe"}
systemDoors = []string{"/api/cli/system/"}
)
@@ -118,6 +121,12 @@ var closedDoors = []string{
// the registration says so where the role table can be read.
"GET /api/cli/apps/{account}/{app}/routes",
"PUT /api/cli/apps/{account}/{app}/instances-manifest",
+ // The app's instance update policy (settings spec §3.2) is written onto
+ // that same manifest: which settings an instance may change itself, and
+ // the defaults new and existing instances follow. Shut for the manifest
+ // door's reason, and the instance-bound credential (#155 step 6) does not
+ // open it either: an instance must not widen its own control.
+ "PUT /api/cli/apps/{account}/{app}/updates",
// The fleet-wide live tenancy read (discussion #11). Shut to every
// credential for a reason that is about the door's SUBJECT rather than
// any role: the answer names every account's instances on every box.
@@ -926,8 +935,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- 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)",
+ if seen != len(closedDoors) || seen != 52 {
+ t.Errorf("walked %d closed doors, want all %d (and 52 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, 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 e6d55ff1d..ec0d0ec94 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -246,6 +246,7 @@ route PUT /api/cli/apps/{account}/{app}/instances-manifest
route PUT /api/cli/apps/{account}/{app}/pipeline
route PUT /api/cli/apps/{account}/{app}/sidecars-manifest
route PUT /api/cli/apps/{account}/{app}/telemetry
+route PUT /api/cli/apps/{account}/{app}/updates
route PUT /api/cli/apps/{account}/{app}/version-target
route PUT /api/cli/system/geoip
route PUT /api/cli/system/upload
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 0459c9ef2..cdb87b4a9 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -590,11 +590,11 @@ 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", "d7c68e89a1a36c55d51ab0d9144bb48c57816791cc2c31655192c17265820bf1"},
+ {"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "eb201dd17f0d407af087c974e14d0f713e9a8e3eedb5575d8f27ec5824c2244f"},
{"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", "f338844b02cf6a1916e8b5824ef5139fa9ea62fb2ffdfe382f6ea12d3ceda029"},
+ {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "5d0bae80378429ad7967c25e7eeae38e31402c8f44d01289c64cef1493c71804"},
{"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"},