settings-1 task 11: console doors for resign and pointer digests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+604
−6
internal/console/apicli.go+18 −4internal/console/apidigests.go+197 −0internal/console/apidigests_test.go+247 −0internal/console/authcli.go+9 −0internal/console/claim_invariant_fixtures_test.go+41 −0internal/console/claim_invariant_probes_nonapp_test.go+11 −0internal/console/claim_invariant_probes_read_test.go+2 −0internal/console/svcroutes_test.go+10 −2internal/console/testdata/claim-routes.txt+3 −0internal/hostops/digestmarker_classifier_test.go+50 −0internal/hostops/mutation_classifier.go+16 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index dbd25cb52..abedf8e67 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -669,6 +669,13 @@ func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadGateway, "could not list channels") |
| return |
| } |
| + // The account's pointer digest marker (#253, spec §4.6), read once: a |
| + // row's straggler flag is measured against it. An unreadable marker is |
| + // logged and read as off; the rows still answer. |
| + marker, merr := release.ReadDigestMarker(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid) |
| + if merr != nil { |
| + log.Printf("console: api channels %s/%s: digest marker: %v", acct.Sqid, app, merr) |
| + } |
| out := make([]map[string]any, 0, len(keys)) |
| for _, k := range keys { |
| name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| @@ -681,14 +688,21 @@ func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) { |
| // ladder's rungs — a typed client (later tasks) can rely on the key |
| // existing rather than testing for its absence. |
| rung, _ := release.Rung(name) |
| + st := release.DigestStateOf(cp, marker) |
| out = append(out, map[string]any{ |
| - "channel": name, |
| - "version": cp.Version, |
| - "promoted_at": cp.PromotedAt.UTC().Format(time.RFC3339), |
| - "rung": rung, |
| + "channel": name, |
| + "version": cp.Version, |
| + "promoted_at": cp.PromotedAt.UTC().Format(time.RFC3339), |
| + "rung": rung, |
| + "digests": st.HeadDigest, |
| + "history_missing": st.HistoryMissing, |
| + "straggler": st.Straggler, |
| }) |
| } |
| resp := map[string]any{"channels": out} |
| + if marker != nil { |
| + resp["pointer_digests"] = map[string]any{"on": true, "since": marker.At.UTC().Format(time.RFC3339)} |
| + } |
| // pipeline_entry/pipeline_channels: two ADDITIVE top-level keys (release |
| // pipelines v2, Task 11 — review fix round 1) — old CLIs decode |
| // "channels" and ignore the rest. They are a REDUCED view of the |
diff --git a/internal/console/apidigests.go b/internal/console/apidigests.go| new file mode 100644 |
| index 000000000..dd356808f |
| --- /dev/null |
| +++ b/internal/console/apidigests.go |
| @@ -0,0 +1,197 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "slices" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// handleAPIChannelResign answers `carlos channels resign` (#253, spec §4.6). |
| +// A body naming only the channel gets the plan; a body that also carries |
| +// the digests the operator confirmed signs them, if a plan made now is |
| +// exactly that plan. The console signs with its root key for an app member, |
| +// as it does for a promotion. |
| +// |
| +// Member-level, like rollback, and on the same write budget, drawn before |
| +// any store work. It is closed to service credentials (the zero Set): the |
| +// confirmation is a person's. |
| +func (s *Server) handleAPIChannelResign(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct { |
| + Channel string `json:"channel"` |
| + Digest string `json:"digest"` |
| + HistoryDigests []string `json:"history_digests"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if !release.ValidChannel(req.Channel) { |
| + jsonError(w, http.StatusBadRequest, "invalid channel name") |
| + return |
| + } |
| + if len(s.cfg.Key) == 0 { |
| + jsonError(w, http.StatusServiceUnavailable, "this console has no release key") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| + defer cancel() |
| + sg := release.RootSigner(s.cfg.Key) |
| + plan, err := sg.PlanResign(ctx, s.cfg.Releases, acct.Sqid, app, req.Channel) |
| + if err != nil { |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + } |
| + if req.Digest == "" { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "channel": plan.Channel, "version": plan.Version, "digest": plan.Digest, |
| + "history": plan.History, "history_digests": plan.HistoryDigests, "computed": plan.Computed, |
| + }) |
| + return |
| + } |
| + if req.Digest != plan.Digest || !slices.Equal(req.HistoryDigests, plan.HistoryDigests) { |
| + jsonError(w, http.StatusConflict, fmtResignChanged(req.Channel)) |
| + return |
| + } |
| + if err := sg.Resign(ctx, s.cfg.Releases, plan, addr); err != nil { |
| + if errors.Is(err, release.ErrResignChanged) { |
| + jsonError(w, http.StatusConflict, fmtResignChanged(req.Channel)) |
| + return |
| + } |
| + log.Printf("console: api resign %s/%s %s: %v", acct.Sqid, app, req.Channel, err) |
| + jsonError(w, http.StatusBadGateway, "could not write the pointer") |
| + return |
| + } |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "resign", TargetType: "channel", Target: req.Channel, |
| + Detail: "re-signed " + req.Channel + " at " + plan.Version + " with manifest digests (cli)", |
| + }) |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, map[string]string{"result": "resigned"}) |
| +} |
| + |
| +func fmtResignChanged(channel string) string { |
| + return fmt.Sprintf(release.TextResignChanged, channel) |
| +} |
| + |
| +// handleAPISystemPointerDigests is GET and POST /api/cli/system/pointer-digests: |
| +// the emission marker per account and the channels still without digests |
| +// (spec §4.6 "Watch for stragglers", "The fleet-wide switch"). A POST turns |
| +// the marker on or off for the accounts it names, or every account the |
| +// console knows, and answers the report as the GET does. |
| +func (s *Server) handleAPISystemPointerDigests(w http.ResponseWriter, r *http.Request) { |
| + addr, _, ok := s.apiSystemRoller(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.systemBucket(w) { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) |
| + defer cancel() |
| + if r.Method == http.MethodPost { |
| + p, _ := principalFrom(r.Context()) |
| + if !s.allowWrite(p, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct { |
| + On bool `json:"on"` |
| + Accounts []string `json:"accounts"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if len(s.cfg.Key) == 0 { |
| + jsonError(w, http.StatusServiceUnavailable, "this console has no release key") |
| + return |
| + } |
| + accts, err := s.store.Accounts() |
| + if err != nil { |
| + jsonError(w, http.StatusInternalServerError, "could not load accounts") |
| + return |
| + } |
| + for _, a := range accts { |
| + if len(req.Accounts) > 0 && !slices.Contains(req.Accounts, a.Sqid) { |
| + continue |
| + } |
| + action := "pointer-digests-on" |
| + if req.On { |
| + err = release.WriteDigestMarker(ctx, s.cfg.Releases, release.RootSigner(s.cfg.Key), a.Sqid, addr) |
| + } else { |
| + action = "pointer-digests-off" |
| + err = release.RemoveDigestMarker(ctx, s.cfg.Releases, a.Sqid) |
| + } |
| + if err != nil { |
| + log.Printf("console: pointer digests %s: %v", a.Sqid, err) |
| + jsonError(w, http.StatusBadGateway, "could not write the marker for "+a.Sqid) |
| + return |
| + } |
| + s.auditSystem(addr, action, "account "+a.Sqid+" (cli)") |
| + } |
| + } |
| + accts, err := s.store.Accounts() |
| + if err != nil { |
| + jsonError(w, http.StatusInternalServerError, "could not load accounts") |
| + return |
| + } |
| + type acctRow struct { |
| + Account string `json:"account"` |
| + On bool `json:"on"` |
| + Since string `json:"since,omitempty"` |
| + Error string `json:"error,omitempty"` |
| + } |
| + type chRow struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + Straggler bool `json:"straggler"` |
| + } |
| + acctRows, chRows := []acctRow{}, []chRow{} |
| + for _, a := range accts { |
| + row := acctRow{Account: a.Sqid} |
| + m, err := release.ReadDigestMarker(ctx, s.cfg.Releases, s.cfg.ReleasePub, a.Sqid) |
| + if err != nil { |
| + row.Error = err.Error() |
| + acctRows = append(acctRows, row) |
| + continue |
| + } |
| + if m != nil { |
| + row.On, row.Since = true, m.At.UTC().Format(time.RFC3339) |
| + } |
| + apps, err := s.store.AppsIn(a.ID) |
| + if err != nil { |
| + row.Error = "could not load apps" |
| + acctRows = append(acctRows, row) |
| + continue |
| + } |
| + for _, app := range apps { |
| + states, _, err := release.DigestReport(ctx, s.cfg.Releases, s.cfg.ReleasePub, a.Sqid, app) |
| + if err != nil { |
| + row.Error = err.Error() |
| + break |
| + } |
| + for _, st := range states { |
| + if !st.HeadDigest { |
| + chRows = append(chRows, chRow{Account: a.Sqid, App: app, Channel: st.Channel, Version: st.Version, Straggler: st.Straggler}) |
| + } |
| + } |
| + } |
| + acctRows = append(acctRows, row) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"accounts": acctRows, "channels": chRows}) |
| +} |
diff --git a/internal/console/apidigests_test.go b/internal/console/apidigests_test.go| new file mode 100644 |
| index 000000000..76903375b |
| --- /dev/null |
| +++ b/internal/console/apidigests_test.go |
| @@ -0,0 +1,247 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// The console's pointer digest doors (#253, spec §4.6): `carlos channels |
| +// resign`, `carlos channels digests` and the digest state on `carlos |
| +// channels`. |
| + |
| +func TestResignDoorPlansThenSigns(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": "stable"}, fx.member) |
| + if st != http.StatusOK { |
| + t.Fatalf("plan: %d %s", st, out) |
| + } |
| + var plan struct { |
| + Version string `json:"version"` |
| + Digest string `json:"digest"` |
| + History []string `json:"history"` |
| + HistoryDigests []string `json:"history_digests"` |
| + Computed []bool `json:"computed"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.Digest == "" { |
| + t.Fatalf("plan body %s: %v", out, err) |
| + } |
| + if plan.Version != "v2" || len(plan.History) != 1 || plan.History[0] != "v1" || len(plan.HistoryDigests) != 1 || |
| + plan.HistoryDigests[0] == "" || len(plan.Computed) != 2 || !plan.Computed[0] || !plan.Computed[1] { |
| + t.Fatalf("plan = %+v, want v2 over v1, both digests read from the bucket now", plan) |
| + } |
| + if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "stable"); p.Digest != "" { |
| + t.Fatal("planning signed something") |
| + } |
| + st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{ |
| + "channel": "stable", "digest": plan.Digest, "history_digests": plan.HistoryDigests}, fx.member) |
| + if st != http.StatusOK || !strings.Contains(out, `"result":"resigned"`) { |
| + t.Fatalf("sign: %d %s", st, out) |
| + } |
| + p, err := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "stable") |
| + if err != nil || p.Digest != plan.Digest || len(p.HistoryDigests) != 1 || p.HistoryDigests[0] != plan.HistoryDigests[0] { |
| + t.Fatalf("pointer after resign = %+v, %v; want the plan's digests", p, err) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "km", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action == "resign" && e.Address == "alice@keymail.dev" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a resign row by the member that names the CLI", events) |
| + } |
| +} |
| + |
| +func TestResignDoorRefusesDigestsThatAreNotThePlan(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]any{ |
| + "channel": "stable", "digest": release.ManifestDigest([]byte("not it")), "history_digests": []string{""}}, fx.member) |
| + if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(release.TextResignChanged, "stable") { |
| + t.Fatalf("mismatched confirmation: %d %s", st, out) |
| + } |
| + if p, _ := release.Channel(context.Background(), fx.store, fx.pub, "bab", "km", "stable"); p.Digest != "" { |
| + t.Fatal("a refused confirmation signed something") |
| + } |
| +} |
| + |
| +func TestResignDoorRefusesServiceCredentials(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": "stable"}, fx.serviceToken) |
| + if st != http.StatusNotFound && st != http.StatusForbidden { |
| + t.Fatalf("a service credential reached resign: %d", st) |
| + } |
| +} |
| + |
| +// TestResignDoorSpendsTheWriteBudget: resign signs a pointer, so it draws on |
| +// the same per-person write budget as rollback, before any store work. |
| +func TestResignDoorSpendsTheWriteBudget(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| + body := map[string]string{"channel": "stable"} |
| + if st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", body, fx.member); st != http.StatusOK { |
| + t.Fatalf("first request: %d %s", st, out) |
| + } |
| + if st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", body, fx.member); st != http.StatusTooManyRequests { |
| + t.Fatalf("second request over the budget: %d %s", st, out) |
| + } |
| +} |
| + |
| +func TestSystemDigestsDoorTurnsOnAndListsStragglers(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + ctx := context.Background() |
| + st, out := postRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", map[string]any{"on": false}, fx.member) |
| + if st != http.StatusNotFound { |
| + t.Fatalf("a member who is not an operator turned digests off: %d %s", st, out) |
| + } |
| + if m, err := release.ReadDigestMarker(ctx, fx.store, fx.pub, "bab"); m == nil || err != nil { |
| + t.Fatalf("the member's refused request moved the marker: %+v, %v", m, err) |
| + } |
| + st, out = postRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", map[string]any{"on": false}, fx.operator) |
| + if st != http.StatusOK || !strings.Contains(out, `"account":"bab","on":false`) { |
| + t.Fatalf("operator off: %d %s", st, out) |
| + } |
| + if m, err := release.ReadDigestMarker(ctx, fx.store, fx.pub, "bab"); m != nil || err != nil { |
| + t.Fatalf("marker after off = %+v, %v", m, err) |
| + } |
| + st, out = postRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", map[string]any{"on": true, "accounts": []string{"bab"}}, fx.operator) |
| + if st != http.StatusOK || !strings.Contains(out, `"account":"bab","on":true`) { |
| + t.Fatalf("operator on: %d %s", st, out) |
| + } |
| + if m, err := release.ReadDigestMarker(ctx, fx.store, fx.pub, "bab"); m == nil || err != nil { |
| + t.Fatalf("marker = %+v, %v", m, err) |
| + } |
| + st, out = getRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", fx.operator) |
| + if st != http.StatusOK || !strings.Contains(out, `"account":"bab","app":"km","channel":"stable"`) { |
| + t.Fatalf("stable has no digests yet and must be listed: %d %s", st, out) |
| + } |
| + if st, out := getRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", fx.member); st != http.StatusNotFound { |
| + t.Fatalf("a member who is not an operator read the report: %d %s", st, out) |
| + } |
| +} |
| + |
| +// TestSystemDigestsDoorListsOnlyHeadsWithoutDigests: once canary/x's head |
| +// carries a digest it leaves the list, and stable stays on it. |
| +func TestSystemDigestsDoorListsOnlyHeadsWithoutDigests(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + fx.markerThenPromote(t, "v2") |
| + st, out := getRaw(t, fx.ts.URL+"/api/cli/system/pointer-digests", fx.operator) |
| + if st != http.StatusOK { |
| + t.Fatalf("report: %d %s", st, out) |
| + } |
| + var rep struct { |
| + Channels []struct{ Account, App, Channel string } `json:"channels"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &rep); err != nil { |
| + t.Fatalf("report body %s: %v", out, err) |
| + } |
| + listed := map[string]bool{} |
| + for _, c := range rep.Channels { |
| + listed[c.Account+"/"+c.App+"/"+c.Channel] = true |
| + } |
| + if !listed["bab/km/stable"] || listed["bab/km/canary/x"] { |
| + t.Fatalf("listed = %v, want km's stable and not its canary/x", listed) |
| + } |
| +} |
| + |
| +func TestChannelsDoorCarriesDigestState(t *testing.T) { |
| + fx := newDigestsFixture(t) |
| + st, out := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/channels", fx.member) |
| + if st != http.StatusOK || !strings.Contains(out, `"digests":false`) || !strings.Contains(out, `"straggler":false`) || |
| + !strings.Contains(out, `"history_missing":1`) || !strings.Contains(out, `"pointer_digests":{"on":true`) { |
| + t.Fatalf("channels: %d %s", st, out) |
| + } |
| +} |
| + |
| +func TestRollbackDoorNamesResign(t *testing.T) { |
| + fx := newDigestsFixture(t) // v1 on canary/x before the marker |
| + fx.markerThenPromote(t, "v2") |
| + st, out := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/rollback", map[string]string{"channel": "canary/x"}, fx.member) |
| + want := (&release.ResignNeededError{App: "km", Channel: "canary/x", Version: "v1"}).Error() |
| + if st != http.StatusConflict || jsonErrorText(t, out) != want { |
| + t.Fatalf("rollback onto a digest-less entry: %d %s, want 409 %q", st, out, want) |
| + } |
| + if cp := fx.channelVersion(t, "km", "canary/x"); cp.Version != "v2" { |
| + t.Fatalf("canary/x after a refused rollback = %q, want v2", cp.Version) |
| + } |
| +} |
| + |
| +// jsonErrorText is the "error" of a jsonError body. |
| +func jsonErrorText(t *testing.T, body string) string { |
| + t.Helper() |
| + var e struct { |
| + Error string `json:"error"` |
| + } |
| + if err := json.Unmarshal([]byte(body), &e); err != nil { |
| + t.Fatalf("not a JSON error body: %s: %v", body, err) |
| + } |
| + return e.Error |
| +} |
| + |
| +// digestsFixture is newConsoleFixture with app km in the ops account bab: |
| +// v1 and v2 shipped, promoted v1 then v2 on stable and v1 on canary/x, all |
| +// before the account's digest marker went on, so none of them carries a |
| +// digest. Then the marker, written with the root key (resign needs it, Astra |
| +// I3 #4). |
| +type digestsFixture struct { |
| + *consoleFixture |
| + store release.Store |
| + member string // a CLI bearer for alice, a member of bab |
| + operator string // a CLI bearer for paul, an owner of the ops account |
| + serviceToken string // an app-scoped svcrole.Publish credential for km |
| +} |
| + |
| +func newDigestsFixture(t *testing.T) *digestsFixture { |
| + 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") |
| + seedChannel(t, fx, "km", "stable", "v2") |
| + seedChannel(t, fx, "km", "canary/x", "v1") |
| + dx := &digestsFixture{ |
| + consoleFixture: fx, |
| + store: fx.rel, |
| + member: cliToken(t, fx, "alice@keymail.dev", "code-digests-member"), |
| + operator: cliToken(t, fx, "paul@keymail.dev", "code-digests-operator"), |
| + serviceToken: serviceToken(t, fx.st, OpsAccountID, "digests-ci", "km", svcrole.Publish), |
| + } |
| + dx.writeMarker(t) |
| + return dx |
| +} |
| + |
| +func (fx *digestsFixture) writeMarker(t *testing.T) { |
| + t.Helper() |
| + if err := release.WriteDigestMarker(context.Background(), fx.store, release.RootSigner(fx.priv), release.OpsAccount, "paul@keymail.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// markerThenPromote writes the marker (a no-op when it already verifies) and |
| +// promotes v to canary/x, which therefore carries v's digest. |
| +func (fx *digestsFixture) markerThenPromote(t *testing.T, v string) { |
| + t.Helper() |
| + fx.writeMarker(t) |
| + seedChannel(t, fx.consoleFixture, "km", "canary/x", v) |
| + if cp := fx.channelVersion(t, "km", "canary/x"); cp.Digest == "" { |
| + t.Fatalf("canary/x promoted to %s with the marker on carries no digest", v) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index d7aa40b9a..cd717aa4f 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -524,6 +524,10 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback) |
| + // `carlos channels resign` (#253, spec §4.6): a person confirms the |
| + // manifest digests the console then signs into the channel's pointer, so |
| + // no credential reaches it. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/channels/resign", svcrole.Set{}, s.handleAPIChannelResign) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip) |
| // `carlos restart` (issue #113): a member's "cycle this app's processes" |
| // with no version and no config to push — the same write-door class as |
| @@ -930,6 +934,11 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("PUT /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipPut) |
| s.cliRoute("GET /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipGet) |
| s.cliRoute("DELETE /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipDelete) |
| + // Pointer digests (#253, spec §4.6): each account's emission marker and |
| + // the channels still without digests. apiSystemRoller, like the update |
| + // doors. |
| + s.cliRoute("GET /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests) |
| + s.cliRoute("POST /api/cli/system/pointer-digests", svcrole.Set{}, s.handleAPISystemPointerDigests) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index ee95ba83d..1763d515a 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -217,6 +217,27 @@ func (cx *claimCase) expand(s string) string { |
| // ---- staging (setup only; every one of these runs with apps open) ----------- |
| +// resignPlan turns the account's pointer digest marker on and keeps the |
| +// resign plan for kass's channel in vars, as `carlos channels resign` shows |
| +// it before the person confirms. |
| +func (cx *claimCase) resignPlan(t *testing.T, channel string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + sg := release.RootSigner(cx.priv) |
| + if err := release.WriteDigestMarker(ctx, cx.rel, sg, "bab", claimFxOwner); err != nil { |
| + t.Fatalf("stage the digest marker: %v", err) |
| + } |
| + plan, err := sg.PlanResign(ctx, cx.rel, "bab", "kass", channel) |
| + if err != nil { |
| + t.Fatalf("stage a resign plan: %v", err) |
| + } |
| + hd, err := json.Marshal(plan.HistoryDigests) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cx.vars["digest"], cx.vars["history_digests"] = plan.Digest, string(hd) |
| +} |
| + |
| func (cx *claimCase) trash(t *testing.T, app string) { |
| t.Helper() |
| cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/"+app+"/delete", "", http.StatusOK) |
| @@ -680,6 +701,7 @@ var claimFixtures = map[string]claimFixture{ |
| "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"), |
| "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"), |
| "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"), |
| + "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"), |
| "route GET /api/cli/system/status": claimRead("reads platform release status"), |
| "route GET /apps": claimRead("legacy redirect into the account"), |
| "route GET /apps/{app}": claimRead("legacy redirect into the account"), |
| @@ -992,6 +1014,7 @@ var claimFixtures = map[string]claimFixture{ |
| "route POST /api/cli/ops/hosts/{host}/resolve/inspect": claimRead("operator host-loss inspection: reads the claim, the record and the roster and signs a challenge; writes nothing"), |
| "route POST /api/cli/system/antivirus": claimNonApp("the deployment scanning switch (system/antivirus.json)"), |
| "route POST /api/cli/system/layout": claimNonApp("the deployment layout intent (control/layout.json)"), |
| + "route POST /api/cli/system/pointer-digests": claimNonApp("operator switch: writes or removes each account's root-signed pointer digest marker (releases/<acct>/pointer-digests), classified exactly by the mutation boundary"), |
| "route POST /api/cli/system/rollback": claimNonApp("rolls the platform system pointer back"), |
| "route POST /api/cli/system/update": claimNonApp("moves the platform system pointer to a signed release"), |
| "route PUT /api/cli/system/upload": claimNonApp("uploads and signs a platform system release"), |
| @@ -1253,6 +1276,24 @@ var claimFixtures = map[string]claimFixture{ |
| apps: claimRefs("bab/titogo"), |
| do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/retention", `{"keep": 50}`), |
| }}, |
| + "route POST /api/cli/apps/{account}/{app}/channels/resign": {door: &claimDoor{ |
| + apps: claimRefs("bab/kass"), |
| + setup: func(t *testing.T, cx *claimCase) { cx.resignPlan(t, "canary/x") }, |
| + do: claimAPIVar(http.MethodPost, "/api/cli/apps/bab/kass/channels/resign", |
| + `{"channel":"canary/x","digest":"<digest>","history_digests":<history_digests>}`), |
| + check: func(t *testing.T, cx *claimCase, open bool) string { |
| + p, err := release.Channel(context.Background(), cx.rel, cx.pub, "bab", "kass", "canary/x") |
| + switch { |
| + case err != nil: |
| + return err.Error() |
| + case open && p.Digest != cx.vars["digest"]: |
| + return "canary/x was not re-signed with the confirmed digest" |
| + case !open && p.Digest != "": |
| + return "canary/x was re-signed while kass was foreign-owned" |
| + } |
| + return "" |
| + }, |
| + }}, |
| "route POST /api/cli/apps/{account}/{app}/rollback": {door: &claimDoor{ |
| apps: claimRefs("bab/kass"), |
| setup: func(t *testing.T, cx *claimCase) { |
diff --git a/internal/console/claim_invariant_probes_nonapp_test.go b/internal/console/claim_invariant_probes_nonapp_test.go| index 0b5facdd0..0a436ab95 100644 |
| --- a/internal/console/claim_invariant_probes_nonapp_test.go |
| +++ b/internal/console/claim_invariant_probes_nonapp_test.go |
| @@ -660,6 +660,17 @@ var claimNonAppProbes = map[string]claimProbe{ |
| return "" |
| }, |
| }, |
| + "route POST /api/cli/system/pointer-digests": { |
| + do: claimAPIReply(http.MethodPost, "/api/cli/system/pointer-digests", `{"on":true,"accounts":["bab"]}`), |
| + body: []string{`"account":"bab","on":true`}, |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + m, err := release.ReadDigestMarker(context.Background(), cx.rel, cx.pub, "bab") |
| + if err != nil || m == nil || m.By != claimFxOwner { |
| + return "bab's pointer digest marker is not on, written by the owner" |
| + } |
| + return "" |
| + }, |
| + }, |
| "route PUT /api/cli/system/upload": { |
| do: func(t *testing.T, cx *claimCase) claimReply { return cx.systemUploadReply(t, "66587fa") }, |
| body: []string{`"upload"`, `"size"`}, |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index 68ef57548..176b3bb2f 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -229,6 +229,8 @@ var claimReadProbes = map[string]claimProbe{ |
| body: []string{`"steps"`, `"done":true`}, |
| }, |
| "route GET /api/cli/system/layout": claimAPIRead(nil, "/api/cli/system/layout", `"mode":"none"`), |
| + "route GET /api/cli/system/pointer-digests": claimAPIRead(nil, "/api/cli/system/pointer-digests", |
| + `"account":"bab"`, `"app":"kass","channel":"stable"`), |
| "route GET /api/cli/system/status": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.systemRelease(t, "canary", "66587fa") }, |
| "/api/cli/system/status", `"pointers"`, `"version":"66587fa"`), |
| "route GET /auth/cli/whoami": claimAPIRead(nil, "/auth/cli/whoami", `"address":"`+claimFxOwner+`"`), |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 04175b8e6..9e7e6ef88 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -216,6 +216,14 @@ var closedDoors = []string{ |
| // substrate every tenant sits inside, the same cut as the five above. |
| "GET /api/cli/system/layout", |
| "POST /api/cli/system/layout", |
| + // Pointer digests (#253, spec §4.6): every account's emission marker, |
| + // and the report of channels still without digests. Deployment-wide and |
| + // root-signed, the same cut as the update doors. |
| + "GET /api/cli/system/pointer-digests", |
| + "POST /api/cli/system/pointer-digests", |
| + // `carlos channels resign`: the console signs digests a person has |
| + // confirmed. A credential never confirms on a person's behalf. |
| + "POST /api/cli/apps/{account}/{app}/channels/resign", |
| } |
| // TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the |
| @@ -899,8 +907,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 42 { |
| - t.Errorf("walked %d closed doors, want all %d (and 42 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, seven system doors, two deployment-wide antivirus, three platform geoip doors, and four store-entitlement doors)", |
| + if seen != len(closedDoors) || seen != 45 { |
| + t.Errorf("walked %d closed doors, want all %d (and 45 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, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, 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 6148a6bc8..392cdeaf2 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -84,6 +84,7 @@ route GET /api/cli/system/antivirus |
| route GET /api/cli/system/geoip |
| route GET /api/cli/system/jobs/{id} |
| route GET /api/cli/system/layout |
| +route GET /api/cli/system/pointer-digests |
| route GET /api/cli/system/status |
| route GET /apps |
| route GET /apps/{app} |
| @@ -154,6 +155,7 @@ route POST /api/cli/accounts/{account}/store/decline |
| route POST /api/cli/apps/{account}/new |
| route POST /api/cli/apps/{account}/new-temporary |
| route POST /api/cli/apps/{account}/{app}/build-probe |
| +route POST /api/cli/apps/{account}/{app}/channels/resign |
| route POST /api/cli/apps/{account}/{app}/config/sync |
| route POST /api/cli/apps/{account}/{app}/delete |
| route POST /api/cli/apps/{account}/{app}/domains |
| @@ -217,6 +219,7 @@ route POST /api/cli/services/{account}/{name}/revoke |
| route POST /api/cli/services/{account}/{name}/rotate |
| route POST /api/cli/system/antivirus |
| route POST /api/cli/system/layout |
| +route POST /api/cli/system/pointer-digests |
| route POST /api/cli/system/rollback |
| route POST /api/cli/system/update |
| route POST /api/ses/events |
diff --git a/internal/hostops/digestmarker_classifier_test.go b/internal/hostops/digestmarker_classifier_test.go| new file mode 100644 |
| index 000000000..1dd92f543 |
| --- /dev/null |
| +++ b/internal/hostops/digestmarker_classifier_test.go |
| @@ -0,0 +1,50 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestClassifyPointerDigestMarkerIsExact: the account's pointer digest marker |
| +// (#253) is a reviewed non-app family, put and deleted; nothing else under |
| +// releases/ is. |
| +func TestClassifyPointerDigestMarkerIsExact(t *testing.T) { |
| + ctx := context.Background() |
| + key := release.PointerDigestsKey("bab") |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"marker put", MutationTarget{Surface: "bucket", Method: "Put", Key: key, After: []byte(`{}`)}}, |
| + {"marker put over an existing one", MutationTarget{Surface: "bucket", Method: "Put", Key: key, ETag: "x", Before: []byte(`{}`), After: []byte(`{}`)}}, |
| + {"marker delete", MutationTarget{Surface: "bucket", Method: "Delete", Key: key}}, |
| + } { |
| + got, err := ClassifyMutation(ctx, nil, nil, c.target) |
| + if err != nil { |
| + t.Errorf("%s: %v", c.name, err) |
| + continue |
| + } |
| + if got.Family != "pointer-digest-marker" || got.NonApp == "" || len(got.Scopes) != 0 || got.MailGate { |
| + t.Errorf("%s: classified %+v, want non-app family pointer-digest-marker with no scopes", c.name, got) |
| + } |
| + } |
| + for _, c := range []struct { |
| + name string |
| + target MutationTarget |
| + }{ |
| + {"an invalid account", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/BAB!/pointer-digests", After: []byte(`{}`)}}, |
| + {"an empty account", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases//pointer-digests", After: []byte(`{}`)}}, |
| + {"a fourth segment", MutationTarget{Surface: "bucket", Method: "Put", Key: key + "/x", After: []byte(`{}`)}}, |
| + {"another name under the account", MutationTarget{Surface: "bucket", Method: "Put", Key: "releases/bab/other", After: []byte(`{}`)}}, |
| + {"a lookalike name", MutationTarget{Surface: "bucket", Method: "Put", Key: key + ".json", After: []byte(`{}`)}}, |
| + {"the account prefix itself", MutationTarget{Surface: "bucket", Method: "Delete", Key: "releases/bab"}}, |
| + {"a delete-if-match", MutationTarget{Surface: "bucket", Method: "DeleteIfMatch", Key: key, ETag: "x"}}, |
| + } { |
| + if got, err := ClassifyMutation(ctx, nil, nil, c.target); !errors.Is(err, ErrUnclassified) { |
| + t.Errorf("%s: classified %+v, err %v; want ErrUnclassified", c.name, got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go| index 6a80ba912..e9694df65 100644 |
| --- a/internal/hostops/mutation_classifier.go |
| +++ b/internal/hostops/mutation_classifier.go |
| @@ -148,6 +148,8 @@ func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter, |
| return classifyGeoip(t) |
| case strings.HasPrefix(t.Key, "store/entitlements/") && len(p) == 3: |
| return Classification{Family: "store-entitlement", NonApp: "operator-set account entitlement; no app state"}, nil |
| + case p[0] == "releases": |
| + return classifyPointerDigestMarker(t, p) |
| } |
| if c, ok, err := classifyDeploymentFamily(w, t); ok { |
| return c, err |
| @@ -163,6 +165,20 @@ func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter, |
| return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key) |
| } |
| +// classifyPointerDigestMarker admits exactly one key under releases/: the |
| +// account's pointer digest marker, releases/<acct>/pointer-digests (#253, |
| +// spec §4.6), written by `carlos channels digests on` and deleted by `off`. |
| +// It is a root-signed switch that holds no app state. Anything else under |
| +// releases/ stays unreviewed. |
| +func classifyPointerDigestMarker(t MutationTarget, p []string) (Classification, error) { |
| + if len(p) != 3 || !controlname.ValidAccount(p[1]) || t.Key != release.PointerDigestsKey(p[1]) || |
| + (t.Method != "Put" && t.Method != "Delete") { |
| + return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key) |
| + } |
| + return Classification{Family: "pointer-digest-marker", |
| + NonApp: "root-signed account switch for pointer digest emission; no app state"}, nil |
| +} |
| + |
| func appScoped(family, account, app string) (Classification, error) { |
| if !controlname.ValidAccount(account) || !controlname.ValidApp(app) { |
| return Classification{}, ErrIdentity |