cli: -account resolves symmetrically and loudly; -account-id is the reference that never guesses
resolveAccount's sqid-first precedence is gone. Its core is now pickAccount,
which matches a want against sqids and names in ONE candidate pool: exactly
one candidate resolves, more than one is an error that lists them all. The
precedence it replaces is how a script's `-account amadan` silently changes
target the day someone else's account materializes with the sqid "amadan" —
a conflict is now loud instead, and the message names -account-id.
-account-id is that flag: a sqid, validated client-side for shape
(release.ValidAccount) and never looked up in the registry at all, so it
cannot be shadowed by any name. It supersedes -account wherever both are
set, including over `carlos route`'s "all" escape hatch — naming one account
by its sqid means that account.
Both flags thread through both modes. Direct-bucket mode resolves locally
(resolveAccount's new third parameter). API mode resolves SERVER-side, so
the pair becomes apiAcct{Seg, Exact}: -account travels verbatim as it always
has, and -account-id travels as the segment plus ?exact=sqid, Task 3's
console-side switch that stops the segment being name-matched. Every
apiSession method takes apiAcct now, so the GET routes (channels, ledger
head/list) and the POST routes (promote, rollback, ship, ledger
append/publish/blobs) carry it alike — no tail in this package holds a query
string of its own, so apiPath still concatenates.
The unchanged non-precedence behaviours stay: the "ops" short-circuit answers
without opening anything, a sqid-shaped want passes through when nothing
matches (a fresh box may not have reconciled; CI has no registry at all), and
an unresolvable non-sqid name is still fatal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
7 files changed,
+320
−141
accounts.go+53 −34accounts_resolve_test.go+65 −0apimode.go+53 −21apimode_test.go+87 −42appconfig_cli.go+8 −8auth_e2e_test.go+1 −1cli.go+53 −35
diff --git a/accounts.go b/accounts.go| index d7e46b3c3..d01afcfcf 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -619,11 +619,55 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc |
| } |
| } |
| -// resolveAccount turns a -account value into an account sqid: an exact sqid |
| -// match wins, then a unique name match; an ambiguous name is an error that |
| -// lists the candidates rather than picking one. Friendly without being |
| -// guessy — picking the wrong account here would ship to the wrong tenant. |
| -func resolveAccount(regPath, want string) string { |
| +// pickAccount is resolveAccount's core, symmetric over the two identity |
| +// spaces: want matches an account by sqid OR by name (case-insensitively), |
| +// with NO precedence between them. Precedence is how a script's -account |
| +// silently changes target the day a colliding account materializes; a |
| +// conflict here is loud instead, and names -account-id, the reference that |
| +// never guesses. A sqid-shaped want that matches nothing passes through (a |
| +// fresh box may not have reconciled; CI has no registry at all). |
| +func pickAccount(accts []registry.Account, want string) (string, error) { |
| + var candidates []registry.Account |
| + for _, a := range accts { |
| + if a.Sqid == want || strings.EqualFold(a.Name, want) { |
| + candidates = append(candidates, a) |
| + } |
| + } |
| + switch len(candidates) { |
| + case 1: |
| + return candidates[0].Sqid, nil |
| + case 0: |
| + // A sqid this registry hasn't materialized yet is still usable: the |
| + // bucket is the control plane, and a fresh box may simply not have |
| + // reconciled. A NAME, though, can only ever be resolved here. |
| + if release.ValidAccount(want) { |
| + return want, nil |
| + } |
| + return "", fmt.Errorf("no account %q here — `carlos accounts list` shows what this box knows", want) |
| + default: |
| + alts := make([]string, len(candidates)) |
| + for i, a := range candidates { |
| + alts[i] = fmt.Sprintf("%s (%q)", a.Sqid, a.Name) |
| + } |
| + return "", fmt.Errorf("account %q is ambiguous — pass -account-id with one of: %s", |
| + want, strings.Join(alts, ", ")) |
| + } |
| +} |
| + |
| +// resolveAccount turns a -account/-account-id pair into an account sqid. |
| +// -account-id supersedes -account outright: it is a sqid, matched as a sqid |
| +// only, never consulted against the registry at all — the reference that |
| +// cannot be shadowed by a name. Otherwise -account resolves through |
| +// pickAccount, symmetrically over sqids and names, and an ambiguity is fatal |
| +// rather than guessed: picking the wrong account here would ship to the |
| +// wrong tenant. |
| +func resolveAccount(regPath, want, wantID string) string { |
| + if wantID = strings.TrimSpace(wantID); wantID != "" { |
| + if !release.ValidAccount(wantID) { |
| + log.Fatalf("-account-id %q is not a sqid — it is matched exactly, never as a name", wantID) |
| + } |
| + return wantID |
| + } |
| want = strings.TrimSpace(want) |
| if want == "" || want == "ops" { |
| return release.OpsAccount |
| @@ -644,34 +688,9 @@ func resolveAccount(regPath, want string) string { |
| if err != nil { |
| log.Fatal(err) |
| } |
| - for _, a := range accts { |
| - if a.Sqid == want { |
| - return a.Sqid |
| - } |
| - } |
| - var matches []registry.Account |
| - for _, a := range accts { |
| - if strings.EqualFold(a.Name, want) { |
| - matches = append(matches, a) |
| - } |
| - } |
| - switch len(matches) { |
| - case 1: |
| - return matches[0].Sqid |
| - case 0: |
| - // A sqid this registry hasn't materialized yet is still usable: the |
| - // bucket is the control plane, and a fresh box may simply not have |
| - // reconciled. A NAME, though, can only ever be resolved here. |
| - if release.ValidAccount(want) { |
| - return want |
| - } |
| - log.Fatalf("no account %q here — `carlos accounts list` shows what this box knows", want) |
| - default: |
| - var names []string |
| - for _, a := range matches { |
| - names = append(names, fmt.Sprintf("%s (%s)", a.Sqid, a.Name)) |
| - } |
| - log.Fatalf("account %q is ambiguous — use a sqid: %s", want, strings.Join(names, ", ")) |
| + sqid, err := pickAccount(accts, want) |
| + if err != nil { |
| + log.Fatal(err) |
| } |
| - return release.OpsAccount |
| + return sqid |
| } |
diff --git a/accounts_resolve_test.go b/accounts_resolve_test.go| new file mode 100644 |
| index 000000000..d11c728df |
| --- /dev/null |
| +++ b/accounts_resolve_test.go |
| @@ -0,0 +1,65 @@ |
| +package main |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPickAccount(t *testing.T) { |
| + accts := []registry.Account{ |
| + {ID: 1, Sqid: "bab", Name: "ops"}, |
| + {ID: 2, Sqid: "bah", Name: "amadan"}, |
| + {ID: 3, Sqid: "bad", Name: "amadan"}, |
| + {ID: 4, Sqid: "baf", Name: "bah"}, // name collides with #2's sqid |
| + } |
| + if got, err := pickAccount(accts, "bab"); err != nil || got != "bab" { |
| + t.Errorf("sqid: %q, %v", got, err) |
| + } |
| + if got, err := pickAccount(accts, "ops"); err != nil || got != "bab" { |
| + t.Errorf("unique name: %q, %v", got, err) |
| + } |
| + if got, err := pickAccount(accts, "OPS"); err != nil || got != "bab" { |
| + t.Errorf("case-insensitive name: %q, %v", got, err) |
| + } |
| + // A sqid nobody has materialized passes through — a fresh box may not |
| + // have reconciled, and CI has no registry at all. |
| + if got, err := pickAccount(accts, "zzz"); err != nil || got != "zzz" { |
| + t.Errorf("passthrough sqid: %q, %v", got, err) |
| + } |
| + if _, err := pickAccount(accts, "not-a-sqid-or-name"); err == nil { |
| + t.Error("unresolvable non-sqid name must error") |
| + } |
| + // The two ambiguity shapes: a shared name, and a name/sqid collision. |
| + // Both must refuse, and the fix-up named must be -account-id. |
| + for _, want := range []string{"amadan", "bah"} { |
| + _, err := pickAccount(accts, want) |
| + if err == nil || !strings.Contains(err.Error(), "-account-id") { |
| + t.Errorf("pickAccount(%q) = %v, want ambiguity error naming -account-id", want, err) |
| + } |
| + } |
| + if _, err := pickAccount(accts, "bah"); err == nil || |
| + !strings.Contains(err.Error(), "bah") || !strings.Contains(err.Error(), "baf") { |
| + t.Error("name/sqid conflict error must list both candidates") |
| + } |
| +} |
| + |
| +func TestApiAccountExact(t *testing.T) { |
| + acct, id := "ops", "bah" |
| + a := apiAccount(&acct, &id) |
| + if a.Seg != "bah" || !a.Exact { |
| + t.Errorf("apiAccount with -account-id = %+v, want {bah true}", a) |
| + } |
| + id = "" |
| + a = apiAccount(&acct, &id) |
| + if a.Seg != "ops" || a.Exact { |
| + t.Errorf("apiAccount without -account-id = %+v, want {ops false}", a) |
| + } |
| + if p := apiPath(apiAcct{Seg: "bah", Exact: true}, "web", "/channels"); !strings.HasSuffix(p, "?exact=sqid") { |
| + t.Errorf("apiPath exact = %q, want ?exact=sqid suffix", p) |
| + } |
| + if p := apiPath(apiAcct{Seg: "ops"}, "web", "/channels"); strings.Contains(p, "exact") { |
| + t.Errorf("apiPath non-exact = %q, must carry no exact param", p) |
| + } |
| +} |
diff --git a/apimode.go b/apimode.go| index 0f22b2679..c75aaea4f 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -22,15 +22,18 @@ import ( |
| "errors" |
| "fmt" |
| "io" |
| + "log" |
| "mime/multipart" |
| "net/http" |
| "net/url" |
| "os" |
| "path/filepath" |
| "sort" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // apiSession is the resolved API mode: which console, as whom. console and |
| @@ -166,12 +169,41 @@ type apiShipResult struct { |
| NoOp bool |
| } |
| -// apiPath builds one /api/cli/apps/{account}/{app}/... path. account is |
| +// apiAcct is one resolved -account/-account-id pair, API-mode: the segment |
| +// the console will resolve, and whether it must match as a sqid only. |
| +type apiAcct struct { |
| + Seg string |
| + Exact bool |
| +} |
| + |
| +// apiAccount is resolveAccount's API-mode counterpart. Names resolve |
| +// SERVER-side against the caller's memberships, so -account travels |
| +// verbatim; -account-id travels as the segment plus exact=sqid, so the |
| +// console never name-matches it. |
| +func apiAccount(raw, rawID *string) apiAcct { |
| + if id := strings.TrimSpace(*rawID); id != "" { |
| + if !release.ValidAccount(id) { |
| + log.Fatalf("-account-id %q is not a sqid — it is matched exactly, never as a name", id) |
| + } |
| + return apiAcct{Seg: id, Exact: true} |
| + } |
| + return apiAcct{Seg: *raw} |
| +} |
| + |
| +// apiPath builds one /api/cli/apps/{account}/{app}/... path. The segment is |
| // passed through verbatim (sqid or name, exactly what -account carried, |
| // default "ops") — the console resolves it against the caller's own |
| // memberships; this client must never resolve it against a local registry. |
| -func apiPath(account, app, tail string) string { |
| - return "/api/cli/apps/" + url.PathEscape(account) + "/" + url.PathEscape(app) + tail |
| +// -account-id adds ?exact=sqid, the console-side switch that makes the |
| +// segment match as a sqid ONLY (internal/console/apicli.go). Plain string |
| +// concatenation is safe here because no tail this package builds carries a |
| +// query string of its own — every one is a bare path. |
| +func apiPath(a apiAcct, app, tail string) string { |
| + p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail |
| + if a.Exact { |
| + p += "?exact=sqid" |
| + } |
| + return p |
| } |
| // notFoundErr is what every call here returns for a 404 whose body carries |
| @@ -286,12 +318,12 @@ func stableRefusal(status int, body []byte) (error, bool) { |
| // carries (RUNG names in From/To) is not what the caller prints: the CLI |
| // already knows the version/channel it asked for and prints those, never |
| // res.From/res.To. |
| -func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) (apiPromoteResult, error) { |
| +func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string) (apiPromoteResult, error) { |
| in := struct { |
| Version string `json:"version"` |
| Channel string `json:"channel"` |
| }{version, channel} |
| - status, body, err := s.postJSON(ctx, apiPath(account, app, "/promote"), in) |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/promote"), in) |
| if err != nil { |
| return apiPromoteResult{}, err |
| } |
| @@ -313,11 +345,11 @@ func (s *apiSession) promote(ctx context.Context, account, app, version, channel |
| // rollback asks the console to roll channel back to its previous version. |
| // There is no noop case for a rollback — it either has a previous version to |
| // land on (409 if not) or it doesn't — so this stays error-only. |
| -func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error { |
| +func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel string) error { |
| in := struct { |
| Channel string `json:"channel"` |
| }{channel} |
| - status, body, err := s.postJSON(ctx, apiPath(account, app, "/rollback"), in) |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/rollback"), in) |
| if err != nil { |
| return err |
| } |
| @@ -331,8 +363,8 @@ func (s *apiSession) rollback(ctx context.Context, account, app, channel string) |
| } |
| // channels reads one app's channel ladder through the console. |
| -func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) { |
| - status, body, err := s.getJSON(ctx, apiPath(account, app, "/channels")) |
| +func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]apiChannel, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/channels")) |
| if err != nil { |
| return nil, err |
| } |
| @@ -368,9 +400,9 @@ func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiCh |
| // which is exactly what apiErrorFromBody's json-first decode surfaces, since |
| // ledgerStoreError answers it with a real "{"error": "..."}" body, not a bare |
| // status. |
| -func (s *apiSession) ledgerAppend(ctx context.Context, account, app, name string, payload json.RawMessage) (ledger.Appended, error) { |
| +func (s *apiSession) ledgerAppend(ctx context.Context, a apiAcct, app, name string, payload json.RawMessage) (ledger.Appended, error) { |
| var out ledger.Appended |
| - status, body, err := s.postJSON(ctx, apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/append"), |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/append"), |
| map[string]json.RawMessage{"payload": payload}) |
| if err != nil { |
| return out, err |
| @@ -386,9 +418,9 @@ func (s *apiSession) ledgerAppend(ctx context.Context, account, app, name string |
| // text: an unknown LEDGER under an app the caller does belong to is a |
| // different, much more common case ("nothing appended yet") than "check |
| // -account and -app", and deserves to say so. |
| -func (s *apiSession) ledgerHead(ctx context.Context, account, app, name string) (ledger.Head, error) { |
| +func (s *apiSession) ledgerHead(ctx context.Context, a apiAcct, app, name string) (ledger.Head, error) { |
| var out ledger.Head |
| - status, body, err := s.getJSON(ctx, apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/head")) |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/head")) |
| if err != nil { |
| return out, err |
| } |
| @@ -407,8 +439,8 @@ func (s *apiSession) ledgerHead(ctx context.Context, account, app, name string) |
| // interpret (a null manifest means "never published" — a caller-side zero |
| // value would erase that distinction), and the CLI print path decodes it |
| // itself right where it decides what to show a human. |
| -func (s *apiSession) ledgerList(ctx context.Context, account, app string) ([]byte, error) { |
| - status, body, err := s.getJSON(ctx, apiPath(account, app, "/ledgers")) |
| +func (s *apiSession) ledgerList(ctx context.Context, a apiAcct, app string) ([]byte, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/ledgers")) |
| if err != nil { |
| return nil, err |
| } |
| @@ -426,8 +458,8 @@ func (s *apiSession) ledgerList(ctx context.Context, account, app string) ([]byt |
| // it the CLI could only echo the caller's own argument back at them, which |
| // is exactly how someone comes to believe they unpublished a chain they |
| // didn't. |
| -func (s *apiSession) ledgerPublish(ctx context.Context, account, app string, m ledger.Manifest) (ledger.Manifest, error) { |
| - status, body, err := s.postJSON(ctx, apiPath(account, app, "/ledgers/publish"), m) |
| +func (s *apiSession) ledgerPublish(ctx context.Context, a apiAcct, app string, m ledger.Manifest) (ledger.Manifest, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/ledgers/publish"), m) |
| if err != nil { |
| return ledger.Manifest{}, err |
| } |
| @@ -448,9 +480,9 @@ func (s *apiSession) ledgerPublish(ctx context.Context, account, app string, m l |
| // header (apimode.go:212-230) — but with http.MethodPost and a |
| // bytes.Reader body in place of GET/nil, since the wire body here is the |
| // blob's raw bytes, never JSON. |
| -func (s *apiSession) ledgerBlob(ctx context.Context, account, app, name string, body []byte) (string, error) { |
| +func (s *apiSession) ledgerBlob(ctx context.Context, a apiAcct, app, name string, body []byte) (string, error) { |
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, |
| - s.c.base+apiPath(account, app, "/ledgers/"+url.PathEscape(name)+"/blobs"), bytes.NewReader(body)) |
| + s.c.base+apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/blobs"), bytes.NewReader(body)) |
| if err != nil { |
| return "", err |
| } |
| @@ -490,7 +522,7 @@ const shipTimeout = 15 * time.Minute |
| // ({"version","kind"}) first, then one file part per entry in files (partName |
| // -> local path), in map-key-sorted order — sorted so the wire bytes are |
| // deterministic, since Go's map iteration order is not. |
| -func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) (apiShipResult, error) { |
| +func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind string, files map[string]string) (apiShipResult, error) { |
| ctx, cancel := context.WithTimeout(ctx, shipTimeout) |
| defer cancel() |
| @@ -500,7 +532,7 @@ func (s *apiSession) ship(ctx context.Context, account, app, version, kind strin |
| } |
| sort.Strings(names) |
| - status, body, err := s.c.doMultipart(ctx, apiPath(account, app, "/releases"), func(mw *multipart.Writer) error { |
| + status, body, err := s.c.doMultipart(ctx, apiPath(a, app, "/releases"), func(mw *multipart.Writer) error { |
| metaJSON, err := json.Marshal(struct { |
| Version string `json:"version"` |
| Kind string `json:"kind"` |
diff --git a/apimode_test.go b/apimode_test.go| index 8a05cecfe..de554ea5d 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -259,7 +259,7 @@ func TestAPISessionShipMultipart(t *testing.T) { |
| } |
| s := newTestSession(ts.URL) |
| - res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{ |
| + res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{ |
| "zeta-amd64": fileB, |
| "alpha-amd64": fileA, |
| }) |
| @@ -322,7 +322,7 @@ func TestAPISessionShipNoOp(t *testing.T) { |
| t.Fatal(err) |
| } |
| s := newTestSession(ts.URL) |
| - res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| if err != nil { |
| t.Fatalf("ship: %v", err) |
| } |
| @@ -346,7 +346,7 @@ func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) { |
| t.Fatal(err) |
| } |
| s := newTestSession(ts.URL) |
| - _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| if err == nil || !strings.Contains(err.Error(), "already shipped with different content") { |
| t.Fatalf("ship error = %v, want the server's conflict message", err) |
| } |
| @@ -388,7 +388,7 @@ func TestAPISessionShipSurvivesSlowServer(t *testing.T) { |
| ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| defer cancel() |
| - _, err := s.ship(ctx, "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + _, err := s.ship(ctx, apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| if err == nil { |
| t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail") |
| } |
| @@ -412,7 +412,7 @@ func TestAPISessionShipSurvivesSlowServer(t *testing.T) { |
| // shipHTTPClientTimeout is back at its zero production default here |
| // (the sibling subtest above restored it) — only the context bounds |
| // this call. |
| - res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + res, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| if err != nil { |
| t.Fatalf("ship against a slow-but-healthy server = %v, want success", err) |
| } |
| @@ -437,7 +437,7 @@ func TestAPISessionStableRefusal(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary") |
| + _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary") |
| if err == nil { |
| t.Fatal("promote against a stable refusal must error") |
| } |
| @@ -460,7 +460,7 @@ func TestAPISessionRollbackStableRefusal(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - err := s.rollback(context.Background(), "acct", "app", "stable") |
| + err := s.rollback(context.Background(), apiAcct{Seg: "acct"}, "app", "stable") |
| if err == nil || !strings.Contains(err.Error(), "http://x/a/b") { |
| t.Fatalf("rollback error = %v, want it to contain the console_url", err) |
| } |
| @@ -484,7 +484,7 @@ func TestAPISessionPromoteSuccess(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a") |
| + res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a") |
| if err != nil { |
| t.Fatalf("promote: %v", err) |
| } |
| @@ -505,7 +505,7 @@ func TestAPISessionPromoteNoOp(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a") |
| + res, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "canary/a") |
| if err != nil { |
| t.Fatalf("promote: %v", err) |
| } |
| @@ -529,7 +529,7 @@ func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.promote(context.Background(), "acct", "app", "9.9.9", "canary/a") |
| + _, err := s.promote(context.Background(), apiAcct{Seg: "acct"}, "app", "9.9.9", "canary/a") |
| if err == nil || !strings.Contains(err.Error(), "not a shipped release") { |
| t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err) |
| } |
| @@ -549,7 +549,7 @@ func TestAPISessionPromoteAccountGateNotFound(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.promote(context.Background(), "nope", "app", "1.2.3", "canary/a") |
| + _, err := s.promote(context.Background(), apiAcct{Seg: "nope"}, "app", "1.2.3", "canary/a") |
| if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { |
| t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err) |
| } |
| @@ -567,7 +567,7 @@ func TestAPISessionChannelsNotFound(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.channels(context.Background(), "nope", "app") |
| + _, err := s.channels(context.Background(), apiAcct{Seg: "nope"}, "app") |
| if err == nil { |
| t.Fatal("channels against a 404 must error") |
| } |
| @@ -588,7 +588,7 @@ func TestAPISessionChannels(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - chans, err := s.channels(context.Background(), "acct", "app") |
| + chans, err := s.channels(context.Background(), apiAcct{Seg: "acct"}, "app") |
| if err != nil { |
| t.Fatalf("channels: %v", err) |
| } |
| @@ -654,22 +654,22 @@ func requireNoRegistryTouch(t *testing.T) func() { |
| } |
| } |
| -// newShipFlagSet builds the -account/-registry flags runShipAPI's callers |
| -// register in production (accountFlags), parses account plus the given |
| -// positional file/dir args exactly as fs.Parse(os.Args) would, and returns |
| -// the FlagSet and the raw -account pointer runShipAPI expects — letting a |
| -// test drive runShipAPI directly (bypassing runShip's own flag set and, |
| -// crucially, its log.Fatal on error) so a refusal can be asserted as a |
| -// returned error instead of a process exit. |
| -func newShipFlagSet(t *testing.T, account string, args ...string) (*flag.FlagSet, *string) { |
| +// newShipFlagSet builds the -account/-account-id/-registry flags |
| +// runShipAPI's callers register in production (accountFlags), parses account |
| +// plus the given positional file/dir args exactly as fs.Parse(os.Args) |
| +// would, and returns the FlagSet and the two raw account pointers runShipAPI |
| +// expects — letting a test drive runShipAPI directly (bypassing runShip's |
| +// own flag set and, crucially, its log.Fatal on error) so a refusal can be |
| +// asserted as a returned error instead of a process exit. |
| +func newShipFlagSet(t *testing.T, account string, args ...string) (*flag.FlagSet, *string, *string) { |
| t.Helper() |
| fs := flag.NewFlagSet("ship", flag.ContinueOnError) |
| - acctRaw, _ := accountFlags(fs) |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| full := append([]string{"-account", account}, args...) |
| if err := fs.Parse(full); err != nil { |
| t.Fatalf("parse: %v", err) |
| } |
| - return fs, acctRaw |
| + return fs, acctRaw, acctIDRaw |
| } |
| // TestShipVerbAPIMode pins runShip's API-mode wiring end to end: with a live |
| @@ -827,6 +827,45 @@ func TestPromoteVerbAPIMode(t *testing.T) { |
| checkNoRegistry() |
| } |
| +// TestPromoteVerbAPIModeAccountID pins -account-id's transport on a POST |
| +// route end to end (the GET side is covered server-side by apicli_test.go, |
| +// which drives only GETs): the sqid must land in the path segment AND the |
| +// request must carry ?exact=sqid, the console-side switch that stops the |
| +// segment from ever being name-matched. -account is set to a colliding |
| +// value at the same time to pin the supersede rule — the sqid wins, and the |
| +// name never reaches the wire. |
| +func TestPromoteVerbAPIModeAccountID(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + if r.URL.Path != "/api/cli/apps/bah/titogo/promote" { |
| + t.Errorf("path = %s, want the -account-id sqid as the segment", r.URL.Path) |
| + } |
| + if got := r.URL.Query().Get("exact"); got != "sqid" { |
| + t.Errorf("exact = %q, want %q — -account-id must never be name-matched", got, "sqid") |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"result": "promoted"}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "-account-id", "bah", "abc1234", "canary/x"}) |
| + |
| + if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("stdout = %q, want %q", buf.String(), want) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| // TestPromoteVerbAPIModeNoOp pins the noop-parity amendment itself: API-mode |
| // promote must print the byte-identical "already on X — nothing to do" shape |
| // direct mode prints (runPromote's res.NoOp branch), not a differently-worded |
| @@ -1042,8 +1081,8 @@ func TestShipAPIStaticNamesSiteTarGz(t *testing.T) { |
| defer ts.Close() |
| sess := newTestSession(ts.URL) |
| - fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| - if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err != nil { |
| + fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| + if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titesite", "abc1234", "static", ""); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1089,8 +1128,8 @@ func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) { |
| } |
| sess := newTestSession(ts.URL) |
| - fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| - if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err == nil { |
| + fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| + if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titesite", "abc1234", "static", ""); err == nil { |
| t.Fatal("runShipAPI against a failing server must error") |
| } |
| @@ -1155,8 +1194,8 @@ func TestShipAPIMultiFileGoodBasenames(t *testing.T) { |
| defer ts.Close() |
| sess := newTestSession(ts.URL) |
| - fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd) |
| - if err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil { |
| + fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd) |
| + if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1197,8 +1236,8 @@ func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) { |
| defer ts.Close() |
| sess := newTestSession(ts.URL) |
| - fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad) |
| - err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| + fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad) |
| + err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| if err == nil { |
| t.Fatal("runShipAPI with a bad basename must error") |
| } |
| @@ -1232,8 +1271,8 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) { |
| defer ts.Close() |
| sess := newTestSession(ts.URL) |
| - fs, acctRaw := newShipFlagSet(t, apiTestAccount, missing) |
| - err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| + fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, missing) |
| + err := runShipAPI(sess, acctRaw, acctIDRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| if err == nil { |
| t.Fatal("runShipAPI with a missing artifact path must error") |
| } |
| @@ -1260,7 +1299,7 @@ func TestAPISessionLedgerAppend(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - a, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`)) |
| + a, err := s.ledgerAppend(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", json.RawMessage(`{"n":1}`)) |
| if err != nil { |
| t.Fatalf("ledgerAppend: %v", err) |
| } |
| @@ -1289,7 +1328,7 @@ func TestAPISessionLedgerAppendBusy(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.ledgerAppend(context.Background(), "acct", "app", "carbon", json.RawMessage(`{"n":1}`)) |
| + _, err := s.ledgerAppend(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", json.RawMessage(`{"n":1}`)) |
| if err == nil || !strings.Contains(err.Error(), "busy") { |
| t.Fatalf("ledgerAppend 503 error = %v, want it to surface the busy message", err) |
| } |
| @@ -1306,7 +1345,7 @@ func TestAPISessionLedgerHeadNotFound(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.ledgerHead(context.Background(), "acct", "app", "carbon") |
| + _, err := s.ledgerHead(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon") |
| if err == nil { |
| t.Fatal("ledgerHead against a 404 must error") |
| } |
| @@ -1325,7 +1364,7 @@ func TestAPISessionLedgerHead(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - h, err := s.ledgerHead(context.Background(), "acct", "app", "carbon") |
| + h, err := s.ledgerHead(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon") |
| if err != nil { |
| t.Fatalf("ledgerHead: %v", err) |
| } |
| @@ -1345,7 +1384,7 @@ func TestAPISessionLedgerList(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - body, err := s.ledgerList(context.Background(), "acct", "app") |
| + body, err := s.ledgerList(context.Background(), apiAcct{Seg: "acct"}, "app") |
| if err != nil { |
| t.Fatalf("ledgerList: %v", err) |
| } |
| @@ -1380,7 +1419,7 @@ func TestAPISessionLedgerPublish(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - m, err := s.ledgerPublish(context.Background(), "acct", "app", |
| + m, err := s.ledgerPublish(context.Background(), apiAcct{Seg: "acct"}, "app", |
| ledger.Manifest{Public: true, Ledgers: []string{"carbon"}, Title: "T"}) |
| if err != nil { |
| t.Fatalf("ledgerPublish: %v", err) |
| @@ -1413,7 +1452,7 @@ func TestAPISessionLedgerBlob(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - sha, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("corpus")) |
| + sha, err := s.ledgerBlob(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", []byte("corpus")) |
| if err != nil { |
| t.Fatalf("ledgerBlob: %v", err) |
| } |
| @@ -1442,7 +1481,7 @@ func TestAPISessionLedgerBlobError(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.ledgerBlob(context.Background(), "acct", "app", "carbon", []byte("x")) |
| + _, err := s.ledgerBlob(context.Background(), apiAcct{Seg: "acct"}, "app", "carbon", []byte("x")) |
| if err == nil || !strings.Contains(err.Error(), "4 MiB cap") { |
| t.Fatalf("ledgerBlob error = %v, want the server's 413 message surfaced", err) |
| } |
| @@ -1457,10 +1496,16 @@ func TestAPISessionLedgerBlobError(t *testing.T) { |
| // half of that proof). |
| func TestLedgerVerbsOfferNoRegistryFlag(t *testing.T) { |
| fs := flag.NewFlagSet("ledger", flag.ContinueOnError) |
| - ledgerAccountFlag(fs) |
| + ledgerAccountFlags(fs) |
| if fs.Lookup("account") == nil { |
| t.Fatal("ledger verbs must still take -account") |
| } |
| + // -account-id is the way out of an ambiguity the console reports (409), |
| + // so a ledger verb that offered -account without it would name a fix-up |
| + // flag its own caller cannot type. |
| + if fs.Lookup("account-id") == nil { |
| + t.Fatal("ledger verbs must take -account-id — it is what the console's ambiguity 409 tells the caller to use") |
| + } |
| if f := fs.Lookup("registry"); f != nil { |
| t.Fatalf("ledger verbs register -registry (%q) — direct-bucket mode is refused, not configured", f.Usage) |
| } |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index 749f32fc7..599ab0b8a 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -177,7 +177,7 @@ func runEnvSet(args []string) { |
| fs := flag.NewFlagSet("env set", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos env set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]") |
| @@ -197,7 +197,7 @@ func runEnvUnset(args []string) { |
| fs := flag.NewFlagSet("env unset", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() == 0 { |
| log.Fatal("usage: carlos env unset -app <a> [-environment <name>] KEY [KEY2 ...]") |
| @@ -214,7 +214,7 @@ func runEnvList(args []string) { |
| fs := flag.NewFlagSet("env list", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos env list -app <a> [-environment <name>]") |
| @@ -264,7 +264,7 @@ func runEnvExecDelivery(args []string) { |
| // below. There is no environment-scoped variant to route it to: |
| // appconfig.SetExecDelivery has no environment parameter at all. |
| environment := fs.String("environment", "", "refused: exec_delivery is app-level only (default bundle), never a named environment") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 1 { |
| log.Fatal("usage: carlos env exec-delivery -app <a> file|off") |
| @@ -299,7 +299,7 @@ func runEnvExecDelivery(args []string) { |
| func runEnvEnvironments(args []string) { |
| fs := flag.NewFlagSet("env environments", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos env environments -app <a>") |
| @@ -362,7 +362,7 @@ func runSecretsSet(args []string) { |
| fs := flag.NewFlagSet("secrets set", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]") |
| @@ -399,7 +399,7 @@ func runSecretsUnset(args []string) { |
| fs := flag.NewFlagSet("secrets unset", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() == 0 { |
| log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] KEY [KEY2 ...]") |
| @@ -416,7 +416,7 @@ func runSecretsList(args []string) { |
| fs := flag.NewFlagSet("secrets list", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)") |
| - _, acctOf := accountFlags(fs) |
| + _, _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos secrets list -app <a> [-environment <name>]") |
diff --git a/auth_e2e_test.go b/auth_e2e_test.go| index 967e34ef3..bf3bc0d07 100644 |
| --- a/auth_e2e_test.go |
| +++ b/auth_e2e_test.go |
| @@ -455,7 +455,7 @@ func TestE2ECLIThroughConsole(t *testing.T) { |
| // apiSession level instead: the exact call runPromote's API-mode branch |
| // makes, against the same real server, over the same credential. |
| sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr} |
| - if _, err := sess.promote(context.Background(), "ops", "titogo", "abc1234", "stable"); err == nil { |
| + if _, err := sess.promote(context.Background(), apiAcct{Seg: "ops"}, "titogo", "abc1234", "stable"); err == nil { |
| t.Fatal("promote to stable must refuse") |
| } else { |
| if !strings.Contains(err.Error(), ts.URL) { |
diff --git a/cli.go b/cli.go| index 3594d88b7..ff004dd54 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -67,6 +67,7 @@ func runAdd(args []string) { |
| reg := fs.String("registry", defaultRegistry(), "registry database") |
| app := fs.String("app", "", "app whose releases this route runs") |
| acct := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| slug := fs.String("slug", "", "root-domain short name (optional)") |
| kind := fs.String("kind", "instance", "instance | service | static") |
| socket := fs.String("socket", "", "unix socket upstream") |
| @@ -127,7 +128,7 @@ func runAdd(args []string) { |
| // unqualified keys, so an unmigrated app added here keeps working. |
| if err := r.Upsert(registry.Route{ |
| Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr, |
| - App: *app, Account: resolveAccount(*reg, *acct), |
| + App: *app, Account: resolveAccount(*reg, *acct, *acctID), |
| Channel: *channel, Hibernate: *hibernate, StatusNamed: *statusNamed, Backing: *backing, |
| DBPath: *dbPath, Status: "ready", |
| }); err != nil { |
| @@ -265,6 +266,7 @@ func runRoute(args []string) { |
| host := fs.String("host", "", "the one route to repoint") |
| app := fs.String("app", "", "repoint every non-platform route of this app") |
| acctFilter := fs.String("account", "ops", `account (sqid or name) to filter -app routes by; "all" for every account`) |
| + acctIDFilter := fs.String("account-id", "", "account sqid to filter -app routes by, matched as a sqid ONLY — supersedes -account") |
| channel := fs.String("channel", "", "release channel to subscribe the route(s) to") |
| environment := fs.String("environment", "", |
| `named config environment to bind the route(s) to (must already exist in the bucket); "-" clears to default`) |
| @@ -340,9 +342,15 @@ func runRoute(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // -account-id supersedes -account here as everywhere, including |
| + // over the "all" escape hatch: someone who names one account by |
| + // its sqid means that account, not every account, whatever -account |
| + // happens to still hold from a script's defaults. |
| var wantAcct string |
| - if !strings.EqualFold(*acctFilter, "all") { |
| - wantAcct = resolveAccount(*regPath, *acctFilter) |
| + if strings.TrimSpace(*acctIDFilter) != "" { |
| + wantAcct = resolveAccount(*regPath, "", *acctIDFilter) |
| + } else if !strings.EqualFold(*acctFilter, "all") { |
| + wantAcct = resolveAccount(*regPath, *acctFilter, "") |
| } |
| for _, rt := range routes { |
| if rt.App != *app { |
| @@ -719,13 +727,17 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub |
| current.Version, target.Version, fromChannel, current.Version, toChannel, target.Version), false, true |
| } |
| -// accountFlags registers the -account (and its -registry seam) every app verb |
| -// carries. -account takes a sqid OR a name, resolved against the registry's |
| -// materialized accounts; it defaults to the built-in ops account, so a |
| -// single-tenant deployment never types it. Reads fall back to the legacy |
| -// unqualified keys either way, so an unmigrated app keeps working. |
| +// accountFlags registers the -account/-account-id pair (and the -registry |
| +// seam) every app verb carries. -account takes a sqid OR a name, resolved |
| +// symmetrically against the registry's materialized accounts — neither space |
| +// wins, so a name that collides with some other account's sqid is refused, |
| +// not silently redirected. -account-id is the way out of that refusal, and |
| +// the reference a script should carry: a sqid, matched as a sqid only, never |
| +// looked up as a name. -account defaults to the built-in ops account, so a |
| +// single-tenant deployment never types either flag. Reads fall back to the |
| +// legacy unqualified keys either way, so an unmigrated app keeps working. |
| // |
| -// Returns the raw flag value alongside a resolver thunk, rather than the |
| +// Returns the raw flag values alongside a resolver thunk, rather than the |
| // resolved value alone, because resolution has to happen AFTER fs.Parse — |
| // and it opens the registry, which a verb that never uses the account should |
| // not pay for. The raw pointer exists for API mode (cli.go's four |
| @@ -735,10 +747,11 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub |
| // reading it straight off this return value keeps that path from being |
| // string-coupled to this function's internal flag name the way |
| // fs.Lookup("account") would be. |
| -func accountFlags(fs *flag.FlagSet) (raw *string, resolve func() string) { |
| +func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) { |
| acct := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; the flag ambiguity errors tell you to use") |
| regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)") |
| - return acct, func() string { return resolveAccount(*regPath, *acct) } |
| + return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) } |
| } |
| func openStore() release.Store { |
| @@ -813,7 +826,7 @@ func publicKey() ed25519.PublicKey { |
| func runShip(args []string) { |
| fs := flag.NewFlagSet("ship", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw, acctOf := accountFlags(fs) |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := fs.String("version", "", "version (git short sha)") |
| @@ -828,7 +841,7 @@ func runShip(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - if err := runShipAPI(sess, acctRaw, fs, *app, *ver, *kind, *target); err != nil { |
| + if err := runShipAPI(sess, acctRaw, acctIDRaw, fs, *app, *ver, *kind, *target); err != nil { |
| log.Fatal(err) |
| } |
| return |
| @@ -903,8 +916,8 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| // function — including the static kind's scratch temp file cleanup below, |
| // up to 256MiB per invocation. Returning lets that defer actually run on |
| // every exit path, fatal or not. |
| -func runShipAPI(sess *apiSession, acctRaw *string, fs *flag.FlagSet, app, ver, kind, target string) error { |
| - acct := *acctRaw |
| +func runShipAPI(sess *apiSession, acctRaw, acctIDRaw *string, fs *flag.FlagSet, app, ver, kind, target string) error { |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| files := map[string]string{} |
| if kind == "static" { |
| if fs.NArg() != 1 { |
| @@ -981,7 +994,7 @@ func runPromote(args []string) { |
| fs := flag.NewFlagSet("promote", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)") |
| - acctRaw, acctOf := accountFlags(fs) |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 2 { |
| @@ -1003,7 +1016,7 @@ func runPromote(args []string) { |
| // flag silently do nothing. |
| log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console") |
| } |
| - acct := *acctRaw |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| res, err := sess.promote(context.Background(), acct, *app, version, channel) |
| if err != nil { |
| @@ -1049,7 +1062,7 @@ func promoteActor() string { |
| func runChannels(args []string) { |
| fs := flag.NewFlagSet("channels", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw, acctOf := accountFlags(fs) |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" { |
| @@ -1061,7 +1074,7 @@ func runChannels(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - acct := *acctRaw |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| chans, err := sess.channels(context.Background(), acct, *app) |
| if err != nil { |
| @@ -1105,7 +1118,7 @@ func runChannels(args []string) { |
| func runRollback(args []string) { |
| fs := flag.NewFlagSet("rollback", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw, acctOf := accountFlags(fs) |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 1 { |
| @@ -1117,7 +1130,7 @@ func runRollback(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - acct := *acctRaw |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| @@ -1224,7 +1237,7 @@ func ledgerAPISession(consoleFlag string) *apiSession { |
| return sess |
| } |
| -// ledgerAccountFlag registers the ONE account flag a ledger verb has, in |
| +// ledgerAccountFlags registers the account flags a ledger verb has, in |
| // place of accountFlags. accountFlags also registers -registry, the local |
| // registry database that resolves an account name in DIRECT-bucket mode — |
| // and ledgerAPISession refuses direct mode outright (spec §2.3: no bucket |
| @@ -1232,15 +1245,20 @@ func ledgerAPISession(consoleFlag string) *apiSession { |
| // dead: nothing here ever opens a registry, and offering the flag advertises |
| // a mode that does not exist. The account is resolved server-side, by the |
| // console, against the caller's own memberships — which is why -account |
| -// takes a sqid OR a name here and needs no local database to do it. |
| -func ledgerAccountFlag(fs *flag.FlagSet) *string { |
| - return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console") |
| +// takes a sqid OR a name here and needs no local database to do it — |
| +// and why -account-id needs no local database either: it rides the wire as |
| +// ?exact=sqid, the console-side switch that makes the segment match as a |
| +// sqid only, so the console's own symmetric resolution can never name-match |
| +// it. |
| +func ledgerAccountFlags(fs *flag.FlagSet) (raw, rawID *string) { |
| + return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console"), |
| + fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| } |
| func runLedgerAppend(args []string) { |
| fs := flag.NewFlagSet("ledger append", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw := ledgerAccountFlag(fs) |
| + acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to append through (default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 2 { |
| @@ -1270,7 +1288,7 @@ func runLedgerAppend(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| - a, err := sess.ledgerAppend(context.Background(), *acctRaw, *app, name, json.RawMessage(raw)) |
| + a, err := sess.ledgerAppend(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, json.RawMessage(raw)) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -1284,7 +1302,7 @@ func runLedgerAppend(args []string) { |
| func runLedgerHead(args []string) { |
| fs := flag.NewFlagSet("ledger head", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw := ledgerAccountFlag(fs) |
| + acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 1 { |
| @@ -1296,7 +1314,7 @@ func runLedgerHead(args []string) { |
| } |
| sess := ledgerAPISession(*consoleFlag) |
| - h, err := sess.ledgerHead(context.Background(), *acctRaw, *app, name) |
| + h, err := sess.ledgerHead(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -1310,7 +1328,7 @@ func runLedgerHead(args []string) { |
| func runLedgerList(args []string) { |
| fs := flag.NewFlagSet("ledger list", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw := ledgerAccountFlag(fs) |
| + acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to read through (default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" { |
| @@ -1319,7 +1337,7 @@ func runLedgerList(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| - body, err := sess.ledgerList(context.Background(), *acctRaw, *app) |
| + body, err := sess.ledgerList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -1358,7 +1376,7 @@ func runLedgerList(args []string) { |
| func runLedgerPublish(args []string) { |
| fs := flag.NewFlagSet("ledger publish", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw := ledgerAccountFlag(fs) |
| + acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| public := fs.Bool("public", true, "serve these ledgers publicly (-public=false unpublishes)") |
| title := fs.String("title", "", "display title for the published set") |
| consoleFlag := fs.String("console", "", "console origin to publish through (default: the only logged-in one)") |
| @@ -1376,7 +1394,7 @@ func runLedgerPublish(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title} |
| - got, err := sess.ledgerPublish(context.Background(), *acctRaw, *app, m) |
| + got, err := sess.ledgerPublish(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, m) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -1400,7 +1418,7 @@ func runLedgerPublish(args []string) { |
| func runLedgerBlob(args []string) { |
| fs := flag.NewFlagSet("ledger blob", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctRaw := ledgerAccountFlag(fs) |
| + acctRaw, acctIDRaw := ledgerAccountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to upload through (default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" || fs.NArg() < 2 { |
| @@ -1422,7 +1440,7 @@ func runLedgerBlob(args []string) { |
| if len(data) > ledger.MaxBlob { |
| log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob) |
| } |
| - sha, err := sess.ledgerBlob(context.Background(), *acctRaw, *app, name, data) |
| + sha, err := sess.ledgerBlob(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, data) |
| if err != nil { |
| log.Fatal(err) |
| } |