carlos / platform Public

settings-2 task 8: carlos releases authorize

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 47037e31473dd1b0539a814b6e6404aaa8135548 parent 85c23a6
6 files changed, +229 −4
  • apimode.go +41 −0
  • docs/site/cli.md +7 −1
  • internal/updatewords/words_authorize.go +12 −0
  • main.go +8 −3
  • releases_authorize.go +117 −0
  • releases_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)
+ }
+}