settings-2 task 8: carlos releases authorize
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+229
−4
apimode.go+41 −0docs/site/cli.md+7 −1internal/updatewords/words_authorize.go+12 −0main.go+8 −3releases_authorize.go+117 −0releases_authorize_test.go+44 −0
diff --git a/apimode.go b/apimode.go| index 67ecb8b8b..05cbf26f0 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -40,7 +40,9 @@ import ( |
| "github.com/carlosframework/platform/internal/dbdoor" |
| "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/schedule" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| ) |
| // serviceSessionLabel stands in for the address in "using X as Y" lines |
| @@ -4753,3 +4755,42 @@ func (s *apiSession) revokeService(ctx context.Context, a apiAcct, name string) |
| } |
| return res.Matched, nil |
| } |
| + |
| +type apiAuthorizePlan struct { |
| + Version string `json:"version"` |
| + ManifestDigest string `json:"manifest_digest"` |
| + Artifacts map[string]release.Artifact `json:"artifacts"` |
| +} |
| + |
| +func (s *apiSession) authorizePlan(ctx context.Context, a apiAcct, app, version string) (apiAuthorizePlan, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/releases/"+version+"/authorize")) |
| + if err != nil { |
| + return apiAuthorizePlan{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiAuthorizePlan{}, apiErrorFromBody(status, body) |
| + } |
| + var p apiAuthorizePlan |
| + if err := json.Unmarshal(body, &p); err != nil || p.ManifestDigest == "" { |
| + return apiAuthorizePlan{}, fmt.Errorf("authorize: unreadable response") |
| + } |
| + return p, nil |
| +} |
| + |
| +func (s *apiSession) authorizeSign(ctx context.Context, a apiAcct, app, version, digest string) (already bool, err error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/releases/"+version+"/authorize"), map[string]string{"manifest_digest": digest}) |
| + if err != nil { |
| + return false, err |
| + } |
| + if status == http.StatusConflict { |
| + return false, errors.New(fmt.Sprintf(updatewords.Text("settings.authorize.changed"), version)) |
| + } |
| + if status != http.StatusOK { |
| + return false, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Result string `json:"result"` |
| + } |
| + _ = json.Unmarshal(body, &res) |
| + return res.Result == "already", nil |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 947739008..145715248 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -568,13 +568,19 @@ to run". |
| carlos releases --app hello |
| ``` |
| -There is one sub-verb. `carlos releases retention --keep 20` sets an ambient |
| +There are two sub-verbs. `carlos releases retention --keep 20` sets an ambient |
| prune policy, and `--dry-run` shows you what it would remove before you commit |
| to it. The safety list always wins over the number: channel pointers, |
| rollback history, tagged releases, anything inside the bake window, and each |
| box's own adopted versions are never pruned, so nothing promoted or recent |
| disappears by policy. `--off` goes back to keeping everything. |
| +`carlos releases authorize <app> <version>` shows a build's manifest and file |
| +digests and, when you type yes, signs a promotion record for exactly those |
| +bytes. Every promotion writes one; this is for a build promoted before |
| +records existed, so an instance can be pinned to it or recovered onto it on |
| +a box that does not already have it. |
| + |
| ### carlos version |
| `carlos version` prints the build id of the binary in front of you. It is the |
diff --git a/internal/updatewords/words_authorize.go b/internal/updatewords/words_authorize.go| index e1daacab9..dc4c43d41 100644 |
| --- a/internal/updatewords/words_authorize.go |
| +++ b/internal/updatewords/words_authorize.go |
| @@ -42,5 +42,17 @@ func init() { |
| Draft{ID: "settings.authorize.write-failed", |
| Text: "could not write the promotion record. Nothing was signed. Try again", |
| Context: "Returned by the console's authorize door when the bucket refused the record write. Shown by carlos releases authorize."}, |
| + Draft{ID: "settings.authorize.usage", |
| + Text: "usage: carlos releases authorize [--app <a>] [--account <sqid-or-name>] [--yes] <version>", |
| + Context: "Printed when carlos releases authorize is run with the wrong arguments."}, |
| + Draft{ID: "settings.authorize.flag-yes", |
| + Text: "sign without asking", |
| + Context: "Help text for the --yes flag of carlos releases authorize."}, |
| + Draft{ID: "settings.authorize.flag-console", |
| + Text: "console origin to authorize through (API mode only; default: the only logged-in one)", |
| + Context: "Help text for the --console flag of carlos releases authorize."}, |
| + Draft{ID: "settings.authorize.docs", |
| + Text: "`carlos releases authorize <app> <version>` shows a build's manifest and file digests and, when you type yes, signs a promotion record for exactly those bytes. Every promotion writes one; this is for a build promoted before records existed, so an instance can be pinned to it or recovered onto it on a box that does not already have it.", |
| + Context: "The CLI docs page's paragraph about carlos releases authorize."}, |
| ) |
| } |
diff --git a/main.go b/main.go| index 97b4fa892..309bc5878 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -112,13 +112,18 @@ func main() { |
| case "skills": |
| runSkills(args) |
| case "releases": |
| - // One sub-verb, `retention` โ the `carlos version target` judgment |
| - // call: flags-only verbs stay flat until a word genuinely names a |
| - // different action, and a policy write is not a listing. |
| + // Two sub-verbs, `retention` and `authorize` โ the `carlos version |
| + // target` judgment call: flags-only verbs stay flat until a word |
| + // genuinely names a different action, and a policy write or a |
| + // signature is not a listing. |
| if len(args) > 0 && args[0] == "retention" { |
| runReleasesRetention(args[1:]) |
| return |
| } |
| + if len(args) > 0 && args[0] == "authorize" { |
| + runReleasesAuthorize(args[1:]) |
| + return |
| + } |
| runReleases(args) |
| case "rollback": |
| runRollback(args) |
diff --git a/releases_authorize.go b/releases_authorize.go| new file mode 100644 |
| index 000000000..ebc005164 |
| --- /dev/null |
| +++ b/releases_authorize.go |
| @@ -0,0 +1,117 @@ |
| +package main |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +var authorizeInput io.Reader = os.Stdin |
| + |
| +// runReleasesAuthorize is `carlos releases authorize [--app <a>] <version>` |
| +// (also `<app> <version>`, as the spec writes it): show a build's manifest |
| +// and artifact digests, and on "yes" sign a promotion record for them |
| +// (settings spec ยง4.1). Never automatic: nothing else backfills a record. |
| +func runReleasesAuthorize(args []string) { |
| + fs := flag.NewFlagSet("releases authorize", flag.ExitOnError) |
| + app := appFlag(fs, "") |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| + yes := fs.Bool("yes", false, updatewords.Text("settings.authorize.flag-yes")) |
| + consoleFlag := consoleOption(fs, updatewords.Text("settings.authorize.flag-console")) |
| + fs.Usage = flagUsage(fs, "") |
| + fs.Parse(args) |
| + usage := updatewords.Text("settings.authorize.usage") |
| + pos := fs.Args() |
| + if len(pos) == 2 && *app == "" { |
| + *app, pos = pos[0], pos[1:] |
| + } |
| + if len(pos) != 1 { |
| + log.Fatal(usage) |
| + } |
| + version := pos[0] |
| + show := func(digest string, arts map[string]release.Artifact) bool { |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.intro")+"\n", *app, version) |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.manifest-row")+"\n", digest) |
| + keys := make([]string, 0, len(arts)) |
| + for k := range arts { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + for _, k := range keys { |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.artifact-row")+"\n", k, arts[k].SHA256) |
| + } |
| + if *yes { |
| + return true |
| + } |
| + fmt.Fprint(stdout, updatewords.Text("settings.authorize.prompt")) |
| + line, _ := bufio.NewReader(authorizeInput).ReadString('\n') |
| + if strings.TrimSpace(line) != "yes" { |
| + fmt.Fprintln(stdout, updatewords.Text("settings.authorize.aborted")) |
| + return false |
| + } |
| + return true |
| + } |
| + done := func(already bool) { |
| + if already { |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.already")+"\n", *app, version) |
| + return |
| + } |
| + fmt.Fprintf(stdout, updatewords.Text("settings.authorize.done")+"\n", *app, version) |
| + } |
| + |
| + ctx := context.Background() |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess != nil { |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + *app = resolvedApp(sess, acct, *app, usage) |
| + plan, err := sess.authorizePlan(ctx, acct, *app, version) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if !show(plan.ManifestDigest, plan.Artifacts) { |
| + return |
| + } |
| + already, err := sess.authorizeSign(ctx, acct, *app, version, plan.ManifestDigest) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + done(already) |
| + return |
| + } |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| + s, acct := openStore(), acctOf() |
| + plan, err := release.PlanAuthorize(ctx, s, acct, *app, version) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if !show(plan.ManifestDigest, plan.Artifacts) { |
| + return |
| + } |
| + already, err := releaseSigner().Authorize(ctx, s, plan, promoteActor()) |
| + if errors.Is(err, release.ErrAuthorizeRootOnly) { |
| + log.Fatal(updatewords.Text("settings.authorize.root-only")) |
| + } |
| + if errors.Is(err, release.ErrAuthorizeChanged) { |
| + log.Fatalf(updatewords.Text("settings.authorize.changed"), version) |
| + } |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + done(already) |
| +} |
diff --git a/releases_authorize_test.go b/releases_authorize_test.go| new file mode 100644 |
| index 000000000..c9c0e6134 |
| --- /dev/null |
| +++ b/releases_authorize_test.go |
| @@ -0,0 +1,44 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +func runAuthorize(t *testing.T, answer string, args ...string) string { |
| + t.Helper() |
| + var out bytes.Buffer |
| + prevOut, prevIn := stdout, authorizeInput |
| + stdout, authorizeInput = &out, strings.NewReader(answer) |
| + t.Cleanup(func() { stdout, authorizeInput = prevOut, prevIn }) |
| + runReleasesAuthorize(args) |
| + return out.String() |
| +} |
| + |
| +func TestReleasesAuthorizeAsksThenSigns(t *testing.T) { |
| + s, pub := directDigestStore(t) // plan 1's helper: km v1 shipped and promoted to stable |
| + ctx := context.Background() |
| + if err := s.Delete(ctx, release.PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + out := runAuthorize(t, "no\n", "--account-id", "bab", "km", "v1") |
| + if !strings.Contains(out, "linux-amd64") || !strings.Contains(out, updatewords.Text("settings.authorize.aborted")) { |
| + t.Fatalf("answering no:\n%s", out) |
| + } |
| + if _, err := release.ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); err == nil { |
| + t.Fatal("answering no wrote a record") |
| + } |
| + runAuthorize(t, "yes\n", "--account-id", "bab", "--app", "km", "v1") |
| + if rec, err := release.ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); err != nil || !rec.Authorized { |
| + t.Fatalf("answering yes: %+v, %v", rec, err) |
| + } |
| + out = runAuthorize(t, "", "--account-id", "bab", "--yes", "km", "v1") |
| + if !strings.Contains(out, "already") { |
| + t.Fatalf("a second authorize:\n%s", out) |
| + } |
| +} |