carlos / platform Public

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
Paul Campbell d1e99b3214ab3fab892182f727caf1ff99762d35 parent 1bfda75
7 files changed, +320 −141
  • accounts.go +53 −34
  • accounts_resolve_test.go +65 −0
  • apimode.go +53 −21
  • apimode_test.go +87 −42
  • appconfig_cli.go +8 −8
  • auth_e2e_test.go +1 −1
  • cli.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)
}