carlos instances token, and its docs (copy drafts) (#155)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+108
−0
apimode.go+20 −0cli.go+3 −0docs/site/cli.md+8 −0instances_cli.go+33 −0instances_token_test.go+44 −0
CI cancelled — superseded by a newer push — run details
diff --git a/apimode.go b/apimode.go| index 91ada2412..838b23d04 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -3853,6 +3853,26 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st |
| return nil |
| } |
| +// instanceToken mints a token bound to one instance (#155): POST |
| +// …/instances/{host}/token. The token comes back once and is returned, |
| +// never stored or logged. |
| +func (s *apiSession) instanceToken(ctx context.Context, a apiAcct, app, host, label string) (string, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/token"), map[string]string{"label": label}) |
| + if err != nil { |
| + return "", err |
| + } |
| + if status != http.StatusCreated { |
| + return "", apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Token string `json:"token"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil || out.Token == "" { |
| + return "", fmt.Errorf("instance token: unreadable response") |
| + } |
| + return out.Token, nil |
| +} |
| + |
| // instanceGet reads ONE instance record (GET …/instances/{host}). found is |
| // false only on a 404, the console's answer for a host with no record; any |
| // other failure is an error, never "absent" (canary --region relies on |
diff --git a/cli.go b/cli.go| index c806199a2..f0af46232 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -403,6 +403,9 @@ func runInstances(args []string) { |
| case "retry": |
| runInstancesRetry(args[1:]) |
| return |
| + case "token": |
| + runInstancesToken(args[1:]) |
| + return |
| } |
| } |
| fs := flag.NewFlagSet("instances", flag.ExitOnError) |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 07c4d2939..4d1554e3b 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -928,6 +928,14 @@ Asks the instance's box to install the newest build it considers ready, even wit |
| carlos instances update a.shop.example.com |
| ``` |
| +### carlos instances token |
| + |
| +Mints a token that acts for one instance and nothing else. With it, the instance can read its own record, change its own upstreams and probe, read its own move status, and change the update settings the app lets instances change. It can't touch any other instance of the app. It stops working when the instance is deleted, even if the same host is created again. The token is printed once, on its own line; the account's credentials pane lists it and can revoke it. |
| + |
| +```sh |
| +carlos instances token --app shop --host a.shop.example.com |
| +``` |
| + |
| ### carlos sidecars |
| A sidecar is a member that nothing can dial: a process the platform runs for |
diff --git a/instances_cli.go b/instances_cli.go| index 1824a8012..b558b9e08 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -220,6 +220,7 @@ var instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> |
| carlos instances set-channel --app <a> --host <h> --channel <c> |
| carlos instances set-environment --app <a> --host <h> --environment <e> |
| carlos instances retry --app <a> <host> |
| + carlos instances token --app <a> --host <h> [--label <l>] |
| ` + updatewords.Text("settings.cli.instances-usage-lines") + ` |
| Signed-in only (carlos auth login): instance records are console-authorized |
| @@ -632,6 +633,38 @@ func runInstancesDelete(args []string) { |
| colorFor(stdout).ok("deleted instance"), *host) |
| } |
| +// runInstancesToken is `carlos instances token` (#155, spec |
| +// 2026-10-16-instance-bound-tokens §5): mint a token bound to one instance. |
| +// The token goes to stdout alone, so $(carlos instances token ...) captures |
| +// it; the notice goes to stderr. |
| +func runInstancesToken(args []string) { |
| + fs := flag.NewFlagSet("instances token", flag.ExitOnError) |
| + app := appFlag(fs, "app the instance belongs to") |
| + host := fs.String("host", "", "instance host the token is bound to") |
| + label := fs.String("label", "", "label shown on the account's credentials pane") |
| + consoleFlag := consoleOption(fs, "console to mint through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + fs.Usage = flagUsage(fs, instancesRecordsUsage) |
| + fs.Parse(args) |
| + if *host == "" { |
| + log.Fatal(instancesRecordsUsage) |
| + } |
| + sess := requireAPISession("token", *consoleFlag) |
| + printUsing(os.Stderr, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + tok, err := sess.instanceToken(context.Background(), acct, app2, strings.TrimSpace(*host), *label) |
| + if err != nil { |
| + log.Fatalf("instances token: %v", err) |
| + } |
| + fmt.Fprintln(stdout, tok) |
| + fmt.Fprintf(os.Stderr, instancesTokenNotice+"\n", strings.TrimSpace(*host)) |
| +} |
| + |
| +// instancesTokenNotice is a copy-review draft (#155, plan Task 8). |
| +const instancesTokenNotice = "this token acts only on %s, and only while this instance exists. It is shown once" |
| + |
| // runInstancesSetUpstreams is `carlos instances set-upstreams` — the CLI |
| // front over handleAPIInstanceUpdate's PATCH (Task 7, issue #152): repoint a |
| // live instance's upstream set and/or its probe without a box shell. Unlike |
diff --git a/instances_token_test.go b/instances_token_test.go| new file mode 100644 |
| index 000000000..64f4fb084 |
| --- /dev/null |
| +++ b/instances_token_test.go |
| @@ -0,0 +1,44 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +// carlos instances token (#155): the client posts the label to the host's |
| +// token door and returns the one-time token. |
| +func TestInstanceTokenClient(t *testing.T) { |
| + var gotPath, gotMethod string |
| + var gotBody map[string]string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.EscapedPath(), r.Method |
| + b, _ := io.ReadAll(r.Body) |
| + _ = json.Unmarshal(b, &gotBody) |
| + w.WriteHeader(http.StatusCreated) |
| + _, _ = w.Write([]byte(`{"token":"tok-123","account":"bab","app":"shop","host":"a.example.com"}`)) |
| + })) |
| + defer ts.Close() |
| + tok, err := newTestSession(ts.URL).instanceToken(context.Background(), apiAcct{Seg: "bab"}, "shop", "a.example.com", "worker") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if tok != "tok-123" || gotMethod != http.MethodPost || gotPath != "/api/cli/apps/bab/shop/instances/a.example.com/token" || gotBody["label"] != "worker" { |
| + t.Fatalf("token %q from %s %s body %v", tok, gotMethod, gotPath, gotBody) |
| + } |
| +} |
| + |
| +func TestInstanceTokenClientRefusal(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(http.StatusConflict) |
| + _, _ = w.Write([]byte(`{"error":"no creation time"}`)) |
| + })) |
| + defer ts.Close() |
| + if tok, err := newTestSession(ts.URL).instanceToken(context.Background(), apiAcct{Seg: "bab"}, "shop", "a.example.com", ""); err == nil || tok != "" { |
| + t.Fatalf("a 409 answered %q, %v", tok, err) |
| + } |
| +} |