settings-1 task 12: carlos channels resign and digests, and the DIGESTS column
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+641
−9
apimode.go+94 −0apimode_test.go+3 −2channels_digests.go+289 −0channels_digests_test.go+236 −0cli.go+8 −6cli_test.go+1 −1main.go+10 −0
diff --git a/apimode.go b/apimode.go| index c4ce9029d..e321da09c 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -355,6 +355,11 @@ type apiChannel struct { |
| PromotedAt string |
| Rung string |
| Err string |
| + // Digests is whether the pointer carries signed manifest digests; |
| + // Straggler is a pointer without them written after digests went on |
| + // (#253). An older console omits both, which reads as false. |
| + Digests bool |
| + Straggler bool |
| } |
| // apiChannelsResult is what `channels` returns: the channel rows plus the |
| @@ -1510,6 +1515,8 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiCh |
| PromotedAt string `json:"promoted_at"` |
| Rung string `json:"rung"` |
| Error string `json:"error"` |
| + Digests bool `json:"digests"` |
| + Straggler bool `json:"straggler"` |
| } `json:"channels"` |
| // PipelineEntry/PipelineChannels are two flat, top-level keys — NOT |
| // nested under a "pipeline" object — deliberately distinct from the |
| @@ -1536,11 +1543,98 @@ func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiCh |
| out.Channels[i] = apiChannel{ |
| Channel: ch.Channel, Version: ch.Version, |
| PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error, |
| + Digests: ch.Digests, Straggler: ch.Straggler, |
| } |
| } |
| return out, nil |
| } |
| +type apiResignPlan struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + Digest string `json:"digest"` |
| + History []string `json:"history"` |
| + HistoryDigests []string `json:"history_digests"` |
| + Computed []bool `json:"computed"` |
| +} |
| + |
| +// resignPlan asks the console what `channels resign` would sign. Nothing is |
| +// written. |
| +func (s *apiSession) resignPlan(ctx context.Context, a apiAcct, app, channel string) (apiResignPlan, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"), map[string]string{"channel": channel}) |
| + if err != nil { |
| + return apiResignPlan{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiResignPlan{}, apiErrorFromBody(status, body) |
| + } |
| + var p apiResignPlan |
| + if err := json.Unmarshal(body, &p); err != nil { |
| + return apiResignPlan{}, fmt.Errorf("resign: unreadable response: %w", err) |
| + } |
| + if len(p.Computed) != len(p.History)+1 || len(p.HistoryDigests) != len(p.History) { |
| + return apiResignPlan{}, fmt.Errorf("resign: the console's plan is malformed") |
| + } |
| + return p, nil |
| +} |
| + |
| +// resignSign sends back the digests the operator confirmed; the console |
| +// signs them only if a plan made now is exactly that plan. |
| +func (s *apiSession) resignSign(ctx context.Context, a apiAcct, app, channel, digest string, hd []string) error { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"), |
| + map[string]any{"channel": channel, "digest": digest, "history_digests": hd}) |
| + if err != nil { |
| + return err |
| + } |
| + if status == http.StatusOK { |
| + return nil |
| + } |
| + return apiErrorFromBody(status, body) |
| +} |
| + |
| +type apiDigestAccount struct { |
| + Account string `json:"account"` |
| + On bool `json:"on"` |
| + Since string `json:"since"` |
| + Error string `json:"error"` |
| +} |
| +type apiDigestChannel struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + Straggler bool `json:"straggler"` |
| +} |
| +type apiPointerDigests struct { |
| + Accounts []apiDigestAccount `json:"accounts"` |
| + Channels []apiDigestChannel `json:"channels"` |
| +} |
| + |
| +// pointerDigests reads the pointer-digest report (verb ""), or turns the |
| +// marker on or off for the named accounts (every account when none) and |
| +// reads the report back. |
| +func (s *apiSession) pointerDigests(ctx context.Context, verb string, accounts []string) (apiPointerDigests, error) { |
| + var status int |
| + var body []byte |
| + var err error |
| + if verb == "" { |
| + status, body, err = s.getJSON(ctx, "/api/cli/system/pointer-digests") |
| + } else { |
| + status, body, err = s.postJSON(ctx, "/api/cli/system/pointer-digests", map[string]any{"on": verb == "on", "accounts": accounts}) |
| + } |
| + if err != nil { |
| + return apiPointerDigests{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiPointerDigests{}, apiErrorFromBody(status, body) |
| + } |
| + var rep apiPointerDigests |
| + if err := json.Unmarshal(body, &rep); err != nil { |
| + return apiPointerDigests{}, fmt.Errorf("pointer digests: unreadable response: %w", err) |
| + } |
| + return rep, nil |
| +} |
| + |
| // --- version target, tags, releases (release versioning spec, 2026-08-08) |
| // |
| // The four read/write doors internal/console/apiversion.go opens, in the |
diff --git a/apimode_test.go b/apimode_test.go| index 157887e7d..33abb4f9d 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -1328,8 +1328,9 @@ func TestChannelsVerbAPIMode(t *testing.T) { |
| canaryRow = strings.TrimRight(line, " ") |
| } |
| } |
| - if !strings.HasSuffix(stableRow, "0.5.0") { |
| - t.Fatalf("stable row = %q, want it to end in the tag 0.5.0", stableRow) |
| + // The DIGESTS cell closes every row now; the tag is the cell before it. |
| + if !strings.HasSuffix(stableRow, "0.5.0 "+textStateNo) { |
| + t.Fatalf("stable row = %q, want the tag 0.5.0 then the DIGESTS cell", stableRow) |
| } |
| if strings.Contains(canaryRow, "0.5.0") { |
| t.Fatalf("canary row = %q, want no tag on an untagged version", canaryRow) |
diff --git a/channels_digests.go b/channels_digests.go| new file mode 100644 |
| index 000000000..5a65d28ef |
| --- /dev/null |
| +++ b/channels_digests.go |
| @@ -0,0 +1,289 @@ |
| +package main |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The strings below are DRAFTS from copy-review/strings.json until the copy |
| +// review (plan Task 1) approves them. Each carries a TODO-COPY marker. |
| +const ( |
| + // TODO-COPY digests.resign-intro |
| + textResignIntro = "Re-sign %s %s with these manifest digests:" |
| + // TODO-COPY digests.resign-row |
| + textResignRow = " %s %s %s" |
| + // TODO-COPY digests.resign-computed |
| + textResignComputed = "from the bucket now: check it" |
| + // TODO-COPY digests.resign-kept |
| + textResignKept = "already signed" |
| + // TODO-COPY digests.resign-missing |
| + textResignMissing = "no manifest: left without a digest" |
| + // TODO-COPY digests.resign-prompt |
| + textResignPrompt = "Sign this pointer? Type yes to continue: " |
| + // TODO-COPY digests.resign-done |
| + textResignDone = "re-signed %s %s with digests" |
| + // TODO-COPY digests.resign-aborted |
| + textResignAborted = "nothing signed" |
| + // TODO-COPY digests.channels-header |
| + textChannelsHeader = "CHANNEL\tVERSION\tPROMOTED\tTAG\tDIGESTS" |
| + // TODO-COPY digests.state-yes |
| + textStateYes = "yes" |
| + // TODO-COPY digests.state-no |
| + textStateNo = "no" |
| + // TODO-COPY digests.state-straggler |
| + textStateStraggler = "no (written after digests were turned on: an old writer may still be in use)" |
| + // TODO-COPY digests.status-on |
| + textStatusOn = "pointer digests: on for %s since %s" |
| + // TODO-COPY digests.status-off |
| + textStatusOff = "pointer digests: off for %s" |
| + // TODO-COPY digests.status-header |
| + textStatusHeader = "ACCOUNT\tAPP\tCHANNEL\tVERSION\tDIGESTS" |
| + // TODO-COPY digests.status-none-left |
| + textStatusNoneLeft = "every channel's pointer carries digests: CARLOS_REQUIRE_POINTER_DIGEST can be turned on" |
| + // TODO-COPY digests.turned-on |
| + textTurnedOn = "pointer digests on for %s. Upgraded writers sign digests from now on" |
| + // TODO-COPY digests.turned-off |
| + textTurnedOff = "pointer digests off for %s. Boxes that have already seen digests keep refusing pointers without them" |
| + // TODO-COPY digests.all-accounts |
| + textAllAccounts = "every account" |
| + // TODO-COPY digests.unknown-account (NEW: not in strings.json) |
| + textUnknownAccount = "no account %s on this console, so nothing was changed for it" |
| +) |
| + |
| +// resignInput is where resign reads its confirmation; tests swap it. |
| +var resignInput io.Reader = os.Stdin |
| + |
| +func digestCell(head, straggler bool) string { |
| + switch { |
| + case head: |
| + return textStateYes |
| + case straggler: |
| + return textStateStraggler |
| + } |
| + return textStateNo |
| +} |
| + |
| +// runChannelsResign is `carlos channels resign [--app <a>] <channel>` (also |
| +// `<app> <channel>`, as the spec writes it). It shows every digest it would |
| +// sign and signs only on "yes" (or --yes). |
| +func runChannelsResign(args []string) { |
| + fs := flag.NewFlagSet("channels resign", flag.ExitOnError) |
| + app := appFlag(fs, "") |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| + yes := fs.Bool("yes", false, "sign without asking") |
| + consoleFlag := consoleOption(fs, "console origin to resign through (API mode only; default: the only logged-in one)") |
| + fs.Usage = flagUsage(fs, "") |
| + fs.Parse(args) |
| + usage := "usage: carlos channels resign [--app <a>] [--account <sqid-or-name>] [--yes] <channel>" |
| + pos := fs.Args() |
| + if len(pos) == 2 && *app == "" { |
| + *app, pos = pos[0], pos[1:] |
| + } |
| + if len(pos) != 1 { |
| + log.Fatal(usage) |
| + } |
| + channel := pos[0] |
| + confirm := func(version, digest string, history, hd []string, computed []bool) bool { |
| + fmt.Fprintf(stdout, textResignIntro+"\n", *app, channel) |
| + row := func(v, d string, c bool) { |
| + src := textResignKept |
| + switch { |
| + case d == "": |
| + src = textResignMissing |
| + case c: |
| + src = textResignComputed |
| + } |
| + fmt.Fprintf(stdout, textResignRow+"\n", v, d, src) |
| + } |
| + row(version, digest, computed[0]) |
| + for i, v := range history { |
| + row(v, hd[i], computed[i+1]) |
| + } |
| + if *yes { |
| + return true |
| + } |
| + fmt.Fprint(stdout, textResignPrompt) |
| + line, _ := bufio.NewReader(resignInput).ReadString('\n') |
| + if strings.TrimSpace(line) != "yes" { |
| + fmt.Fprintln(stdout, textResignAborted) |
| + return false |
| + } |
| + return true |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + if sess != nil { |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + *app = resolvedApp(sess, acct, *app, usage) |
| + plan, err := sess.resignPlan(ctx, acct, *app, channel) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if !confirm(plan.Version, plan.Digest, plan.History, plan.HistoryDigests, plan.Computed) { |
| + return |
| + } |
| + if err := sess.resignSign(ctx, acct, *app, channel, plan.Digest, plan.HistoryDigests); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, textResignDone+"\n", *app, channel) |
| + return |
| + } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| + sg := releaseSigner() |
| + s, acct := openStore(), acctOf() |
| + plan, err := sg.PlanResign(ctx, s, acct, *app, channel) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if !confirm(plan.Version, plan.Digest, plan.History, plan.HistoryDigests, plan.Computed) { |
| + return |
| + } |
| + if err := sg.Resign(ctx, s, plan, promoteActor()); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, textResignDone+"\n", *app, channel) |
| +} |
| + |
| +// runChannelsDigests is `carlos channels digests [on|off] [--account-id <sqid>]...`. |
| +// Bare, it reports; on and off write or remove the emission marker. Through a |
| +// console these are platform-operator doors; direct-store they need the root |
| +// key in CARLOS_RELEASE_KEY and name each account. |
| +func runChannelsDigests(args []string) { |
| + fs := flag.NewFlagSet("channels digests", flag.ExitOnError) |
| + var ids multiFlag |
| + fs.Var(&ids, "account-id", "an account sqid; repeat for several (direct-store: required)") |
| + consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") |
| + fs.Usage = flagUsage(fs, "") |
| + usage := "usage: carlos channels digests [on|off] [--account-id <sqid>]..." |
| + // The verb comes first (`digests on --account-id bab`, the form the |
| + // refusal texts print), and the flag package stops at the first |
| + // non-flag, so it is peeled off before the flags are parsed. |
| + verb := "" |
| + if len(args) > 0 && (args[0] == "on" || args[0] == "off") { |
| + verb, args = args[0], args[1:] |
| + } |
| + fs.Parse(args) |
| + if fs.NArg() != 0 { |
| + log.Fatal(usage) |
| + } |
| + ctx := context.Background() |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var rep apiPointerDigests |
| + if sess != nil { |
| + printUsing(stdout, sess) |
| + rep, err = sess.pointerDigests(ctx, verb, ids) |
| + } else { |
| + if len(ids) == 0 { |
| + log.Fatal(usage) |
| + } |
| + rep, err = localPointerDigests(ctx, openStore(), verb, ids) |
| + } |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess != nil && verb != "" { |
| + // The console's system door skips an account id it does not know and |
| + // still answers 200; an operator who named one must not read that as |
| + // success. |
| + for _, id := range ids { |
| + known := false |
| + for _, a := range rep.Accounts { |
| + known = known || a.Account == id |
| + } |
| + if !known { |
| + log.Fatalf(textUnknownAccount, id) |
| + } |
| + } |
| + } |
| + names := strings.Join(ids, ", ") |
| + if names == "" { |
| + names = textAllAccounts |
| + } |
| + switch verb { |
| + case "on": |
| + fmt.Fprintf(stdout, textTurnedOn+"\n", names) |
| + case "off": |
| + fmt.Fprintf(stdout, textTurnedOff+"\n", names) |
| + } |
| + for _, a := range rep.Accounts { |
| + if a.On { |
| + fmt.Fprintf(stdout, textStatusOn+"\n", a.Account, a.Since) |
| + } else { |
| + fmt.Fprintf(stdout, textStatusOff+"\n", a.Account) |
| + } |
| + } |
| + if len(rep.Channels) == 0 { |
| + fmt.Fprintln(stdout, textStatusNoneLeft) |
| + return |
| + } |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, textStatusHeader) |
| + for _, c := range rep.Channels { |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", c.Account, c.App, c.Channel, c.Version, digestCell(false, c.Straggler)) |
| + } |
| + w.Flush() |
| +} |
| + |
| +// localPointerDigests is the direct-store form: the root key writes or |
| +// removes each named account's marker, and the bucket is listed for the |
| +// report. |
| +func localPointerDigests(ctx context.Context, s release.Store, verb string, ids []string) (apiPointerDigests, error) { |
| + pub := publicKey() |
| + var rep apiPointerDigests |
| + for _, acct := range ids { |
| + var err error |
| + switch verb { |
| + case "on": |
| + err = release.WriteDigestMarker(ctx, s, releaseSigner(), acct, promoteActor()) |
| + case "off": |
| + err = release.RemoveDigestMarker(ctx, s, acct) |
| + } |
| + if err != nil { |
| + return rep, err |
| + } |
| + dirs, err := s.ListDirs(ctx, "apps/"+acct+"/") |
| + if err != nil { |
| + return rep, err |
| + } |
| + row := apiDigestAccount{Account: acct} |
| + for _, d := range dirs { |
| + app := strings.TrimSuffix(strings.TrimPrefix(d, "apps/"+acct+"/"), "/") |
| + if !release.ValidApp(app) { |
| + continue |
| + } |
| + states, marker, err := release.DigestReport(ctx, s, pub, acct, app) |
| + if err != nil { |
| + return rep, err |
| + } |
| + if marker != nil { |
| + row.On, row.Since = true, marker.At.UTC().Format(time.RFC3339) |
| + } |
| + for _, st := range states { |
| + if !st.HeadDigest { |
| + rep.Channels = append(rep.Channels, apiDigestChannel{Account: acct, App: app, Channel: st.Channel, Version: st.Version, Straggler: st.Straggler}) |
| + } |
| + } |
| + } |
| + rep.Accounts = append(rep.Accounts, row) |
| + } |
| + return rep, nil |
| +} |
diff --git a/channels_digests_test.go b/channels_digests_test.go| new file mode 100644 |
| index 000000000..f8e19f634 |
| --- /dev/null |
| +++ b/channels_digests_test.go |
| @@ -0,0 +1,236 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestDigestCell(t *testing.T) { |
| + if digestCell(true, false) != textStateYes || digestCell(false, false) != textStateNo || digestCell(false, true) != textStateStraggler { |
| + t.Fatal("digestCell does not map the three states to their approved texts") |
| + } |
| +} |
| + |
| +// directDigestStore is a deployment dir with the root key in the env: the |
| +// shape `carlos` takes with no console. CARLOS_DEPLOYMENT_DIR selects |
| +// direct-store mode (resolveMode, apimode.go:104); HOME and |
| +// CARLOS_CREDENTIALS are masked so no real login is ever consulted. |
| +func directDigestStore(t *testing.T) (release.Store, ed25519.PublicKey) { |
| + t.Helper() |
| + dir := t.TempDir() |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("HOME", t.TempDir()) |
| + t.Setenv("CARLOS_CREDENTIALS", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| + t.Setenv("CARLOS_RELEASE_KEY", seed) |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", pubB64) |
| + priv, _ := release.ParseSigningKey(seed, "test") |
| + pub, _ := release.ParsePublicKey(pubB64, "test") |
| + s := release.NewDirStore(dir) |
| + m := release.Manifest{Schema: 1, App: "km", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]release.Artifact{"linux-amd64": {}}} |
| + if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-amd64": []byte("one")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.Promote(context.Background(), s, priv, "bab", "km", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Resign needs the verified marker (Astra I3 #4); written after the |
| + // promotion, so the pointer still has no digests to resign. |
| + if err := release.WriteDigestMarker(context.Background(), s, release.RootSigner(priv), "bab", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s, pub |
| +} |
| + |
| +func runResign(t *testing.T, answer string, args ...string) string { |
| + t.Helper() |
| + var out bytes.Buffer |
| + prevOut, prevIn := stdout, resignInput |
| + stdout, resignInput = &out, strings.NewReader(answer) |
| + t.Cleanup(func() { stdout, resignInput = prevOut, prevIn }) |
| + runChannelsResign(args) |
| + return out.String() |
| +} |
| + |
| +// Direct-store resign: the plan is printed, "no" signs nothing, "yes" signs. |
| +func TestResignDirectStoreAsksBeforeSigning(t *testing.T) { |
| + s, pub := directDigestStore(t) |
| + ctx := context.Background() |
| + out := runResign(t, "no\n", "--account-id", "bab", "--app", "km", "stable") |
| + if !strings.Contains(out, "v1") || !strings.Contains(out, textResignAborted) { |
| + t.Fatalf("answering no:\n%s", out) |
| + } |
| + if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest != "" { |
| + t.Fatal("answering no signed the pointer") |
| + } |
| + out = runResign(t, "yes\n", "--account-id", "bab", "km", "stable") // the spec's positional form |
| + if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest == "" { |
| + t.Fatalf("answering yes did not sign:\n%s", out) |
| + } |
| +} |
| + |
| +// The DIGESTS column through a console: yes, plain no, and the straggler |
| +// text, one row each; an older console's rows (no keys) read as no. |
| +func TestChannelsVerbAPIModeDigestsColumn(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + base := "/api/cli/apps/" + apiTestAccount + "/titogo" |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch r.URL.Path { |
| + case base + "/channels": |
| + json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{ |
| + {"channel": "stable", "version": "1", "promoted_at": "2026-08-04T00:00:00Z", "digests": true}, |
| + {"channel": "beta", "version": "2", "promoted_at": "2026-08-04T00:00:00Z", "straggler": true}, |
| + {"channel": "edge", "version": "3", "promoted_at": "2026-08-04T00:00:00Z"}, |
| + {"channel": "broken", "error": "signature mismatch"}, |
| + }}) |
| + case base + "/tags": |
| + json.NewEncoder(w).Encode(map[string]any{"tags": []map[string]any{}}) |
| + default: |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runChannels([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + |
| + rows := map[string]string{} |
| + for _, line := range strings.Split(buf.String(), "\n") { |
| + if f := strings.Fields(line); len(f) > 0 { |
| + rows[f[0]] = strings.TrimSpace(line) |
| + } |
| + } |
| + if !strings.Contains(rows["CHANNEL"], "DIGESTS") { |
| + t.Fatalf("header = %q", rows["CHANNEL"]) |
| + } |
| + for ch, want := range map[string]string{"stable": textStateYes, "beta": textStateStraggler, "edge": textStateNo} { |
| + if !strings.HasSuffix(rows[ch], want) { |
| + t.Fatalf("%s row = %q, want it to end in %q", ch, rows[ch], want) |
| + } |
| + } |
| + if !strings.Contains(rows["broken"], "ERROR: signature mismatch") { |
| + t.Fatalf("broken row = %q", rows["broken"]) |
| + } |
| +} |
| + |
| +// `carlos channels digests` through a console: the report is read with a GET, |
| +// and on posts the named accounts. |
| +func TestChannelsDigestsAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + var posted map[string]any |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/system/pointer-digests" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + if r.Method == http.MethodPost { |
| + json.NewDecoder(r.Body).Decode(&posted) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "accounts": []map[string]any{ |
| + {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"}, |
| + {"account": "bah", "on": false}, |
| + }, |
| + "channels": []map[string]any{ |
| + {"account": "bab", "app": "km", "channel": "stable", "version": "v1", "straggler": true}, |
| + {"account": "bab", "app": "km", "channel": "beta", "version": "v2"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runChannelsDigests(nil) |
| + out := buf.String() |
| + for _, want := range []string{ |
| + "pointer digests: on for bab since 2026-10-07T00:00:00Z", |
| + "pointer digests: off for bah", |
| + strings.Split(textStatusHeader, "\t")[0], textStateStraggler, "beta", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("listing lacks %q:\n%s", want, out) |
| + } |
| + } |
| + if posted != nil { |
| + t.Fatalf("a bare listing posted %v", posted) |
| + } |
| + |
| + buf.Reset() |
| + runChannelsDigests([]string{"on", "--account-id", "bab"}) |
| + if posted["on"] != true || len(posted["accounts"].([]any)) != 1 { |
| + t.Fatalf("posted = %v", posted) |
| + } |
| + if want := "pointer digests on for bab."; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("on output lacks %q:\n%s", want, buf.String()) |
| + } |
| +} |
| + |
| +// Resign through a console: the plan is posted with only the channel, then |
| +// "yes" posts the same digests back to be signed; "no" posts nothing more. |
| +func TestResignAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + path := "/api/cli/apps/" + apiTestAccount + "/titogo/channels/resign" |
| + var bodies []map[string]any |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != path || r.Method != http.MethodPost { |
| + t.Errorf("request = %s %s", r.Method, r.URL.Path) |
| + } |
| + var b map[string]any |
| + json.NewDecoder(r.Body).Decode(&b) |
| + bodies = append(bodies, b) |
| + if _, signing := b["digest"]; signing { |
| + json.NewEncoder(w).Encode(map[string]string{"result": "resigned"}) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "channel": "stable", "version": "v2", "digest": strings.Repeat("a", 64), |
| + "history": []string{"v1"}, "history_digests": []string{""}, "computed": []bool{true, false}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + var out bytes.Buffer |
| + prevIn := resignInput |
| + t.Cleanup(func() { resignInput = prevIn }) |
| + captured := captureStdout(t) |
| + |
| + resignInput = strings.NewReader("no\n") |
| + runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"}) |
| + if len(bodies) != 1 || !strings.Contains(captured.String(), textResignAborted) { |
| + t.Fatalf("answering no: %d requests\n%s", len(bodies), captured.String()) |
| + } |
| + |
| + captured.Reset() |
| + bodies = nil |
| + resignInput = strings.NewReader("yes\n") |
| + runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"}) |
| + out.WriteString(captured.String()) |
| + if len(bodies) != 2 { |
| + t.Fatalf("answering yes: %d requests, want a plan and a signing\n%s", len(bodies), out.String()) |
| + } |
| + if bodies[1]["digest"] != strings.Repeat("a", 64) || bodies[1]["channel"] != "stable" { |
| + t.Fatalf("signing body = %v", bodies[1]) |
| + } |
| + for _, want := range []string{textResignComputed, textResignMissing, "re-signed titogo stable with digests"} { |
| + if !strings.Contains(out.String(), want) { |
| + t.Fatalf("output lacks %q:\n%s", want, out.String()) |
| + } |
| + } |
| +} |
diff --git a/cli.go b/cli.go| index a4020850d..bfb6d23fc 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -2688,13 +2688,13 @@ func runChannels(args []string) { |
| byRelease[t.Release] = t.Version |
| } |
| w := newTable(stdout) |
| - fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG") |
| + fmt.Fprintln(w, textChannelsHeader) |
| for _, ch := range chans { |
| if ch.Err != "" { |
| - fmt.Fprintf(w, "%s\tERROR: %s\t\t\n", ch.Channel, ch.Err) |
| + fmt.Fprintf(w, "%s\tERROR: %s\t\t\t\n", ch.Channel, ch.Err) |
| continue |
| } |
| - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt, byRelease[ch.Version]) |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt, byRelease[ch.Version], digestCell(ch.Digests, ch.Straggler)) |
| } |
| w.Flush() |
| return |
| @@ -2721,16 +2721,18 @@ func runChannels(args []string) { |
| byRelease[t.Release] = t.Version |
| } |
| pub := publicKey() |
| + marker, _ := release.ReadDigestMarker(context.Background(), s, pub, acct) |
| w := newTable(stdout) |
| - fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED\tTAG") |
| + fmt.Fprintln(w, textChannelsHeader) |
| for _, k := range keys { |
| name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| cp, err := release.Channel(context.Background(), s, pub, acct, *app, name) |
| if err != nil { |
| - fmt.Fprintf(w, "%s\tERROR: %v\t\t\n", name, err) |
| + fmt.Fprintf(w, "%s\tERROR: %v\t\t\t\n", name, err) |
| continue |
| } |
| - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339), byRelease[cp.Version]) |
| + st := release.DigestStateOf(cp, marker) |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339), byRelease[cp.Version], digestCell(st.HeadDigest, st.Straggler)) |
| } |
| w.Flush() |
| } |
diff --git a/cli_test.go b/cli_test.go| index 40acb8877..3c13a84d2 100644 |
| --- a/cli_test.go |
| +++ b/cli_test.go |
| @@ -1142,7 +1142,7 @@ func TestPromoteStableTagOutputDirect(t *testing.T) { |
| if stableRow == "" { |
| t.Fatalf("channels has no stable row:\n%s", chans) |
| } |
| - if !strings.Contains(stableRow, "0.5.0-1") || !strings.HasSuffix(stableRow, "0.5.0") { |
| + if !strings.Contains(stableRow, "0.5.0-1") || !strings.HasSuffix(stableRow, "0.5.0 "+textStateNo) { |
| t.Fatalf("the stable row does not carry the tag of the version it serves: %q\n%s", stableRow, chans) |
| } |
| } |
diff --git a/main.go b/main.go| index 15078b40f..97b4fa892 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -96,6 +96,16 @@ func main() { |
| case "analytics": |
| runAnalytics(args) |
| case "channels": |
| + // Two sub-verbs, resign and digests (#253); bare `carlos channels` |
| + // still lists, the `carlos releases retention` precedent. |
| + if len(args) > 0 && args[0] == "resign" { |
| + runChannelsResign(args[1:]) |
| + return |
| + } |
| + if len(args) > 0 && args[0] == "digests" { |
| + runChannelsDigests(args[1:]) |
| + return |
| + } |
| runChannels(args) |
| case "pipeline": |
| runPipeline(args) |