carlos / platform Public

settings-1 task 11: console doors for resign and pointer digests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 44912c42e9fd0b677f3d18d702a622b5628822a0 parent 7cb633b
11 files changed, +604 −6
  • internal/console/apicli.go +18 −4
  • internal/console/apidigests.go +197 −0
  • internal/console/apidigests_test.go +247 −0
  • internal/console/authcli.go +9 −0
  • internal/console/claim_invariant_fixtures_test.go +41 −0
  • internal/console/claim_invariant_probes_nonapp_test.go +11 −0
  • internal/console/claim_invariant_probes_read_test.go +2 −0
  • internal/console/svcroutes_test.go +10 −2
  • internal/console/testdata/claim-routes.txt +3 −0
  • internal/hostops/digestmarker_classifier_test.go +50 −0
  • internal/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