// Which mode the CLI runs in, and the console API client for the mode that // doesn't touch the deployment bucket directly. // // Every verb that used to reach straight for CARLOS_DEPLOYMENT_BUCKET / // CARLOS_DEPLOYMENT_DIR now checks resolveMode first. Direct-mode wins // whenever either env var is set — that is unchanged behaviour, and it wins // unconditionally: a box configured for direct bucket access keeps working // exactly as it always has, whether or not the same machine also happens to // hold a console credential (`carlos auth login` is meant for a human's // laptop, not a box; see auth.go's HEADLESS AND BOX-SIDE USE note). Only when // neither env var is set does the CLI need a credential at all, and it is // credential-first from there: no local registry, no guessing an account by // name against a bucket the caller may not even be able to reach — the // console resolves the account (by sqid OR name) against the caller's own // memberships, which is also the authorization check. package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" "github.com/carlosframework/platform/internal/analytics" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/apperr" "github.com/carlosframework/platform/internal/applog" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/dbdoor" "github.com/carlosframework/platform/internal/ledger" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/schedule" "github.com/carlosframework/platform/internal/updatewords" ) // serviceSessionLabel stands in for the address in "using X as Y" lines // when the session is a service token: the CLI has no door that tells it // the credential's name, and a person's whoami does not answer for one. const serviceSessionLabel = "a service credential (CARLOS_SERVICE_TOKEN)" // apiSession is the resolved API mode: which console, as whom. console and // address are carried for messages (Task 5's "logged in to X as Y" lines), // not used by the calls below. type apiSession struct { c *consoleClient console string // origin, for messages address string // defAcct memoizes defaultAccount's answer: resolution happens once per // invocation however many times a verb asks (runSecretsSet asks twice — // once for the seal key, once for the write; two chooser prompts there // once sealed a value with one account's key and wrote it to another). defAcct *apiAcct // The memberships response, fetched at most once (listAccounts): // account resolution reads the accounts, app resolution reads each // account's apps — same wire bytes, one request. accts []apiAccountEntry acctsSupported bool acctsFetched bool // defApp memoizes defaultApp's answer, defAcct's rule for the sibling // flag: one resolution — and at most one chooser — per invocation. defApp string // appPrompted records that defaultApp's answer came from the CHOOSER — // only interactively-resolved values belong in a save offer (a silent // single-app auto-resolve prompting "save?" on every run would nag). appPrompted bool // deferSaves suppresses the inline save offers (account chooser, app // chooser): set by zero-arg deploy, whose ONE combined offer at the end // of first-run resolution covers everything (spec: one save offer). deferSaves bool // shipSwap is the release's own swap demand for ship ("hold" or ""), // set from --swap by runShip and runDeploy. A field for deferSaves' // reason: ship has many callers and only those two take the flag. shipSwap string } // resolveMode decides which mode the CLI is in. (nil, nil) means direct mode // — the store env is set, and the caller proceeds exactly as it does today, // straight against the bucket. A non-nil session means API mode: every call // goes through the console named in the credential loggedInCredential // resolved, as the address that credential belongs to. // // consoleFlag is the verb's --console value ("" meaning "the only one" — // pick's ordinary disambiguation rule, unchanged here). // // An explicit --console alongside either store env is refused outright // (design ruling, review round 2026-08-03) rather than silently winning as // direct mode: the spec's "the two modes never mix in one invocation" intent // (§1) would otherwise be violated invisibly — someone who typed --console // meaning it gets bucket access instead, with no signal that their flag was // ignored. A BARE env with no --console still wins silently, unchanged: that // half of §1 (operators, CI, box-side scripts) is deliberately untouched. // // CARLOS_SERVICE_TOKEN is CI's way in (spec §8): with it set, API mode runs // as that token against the console named by --console or CARLOS_CONSOLE, // and nothing else is consulted: no config default, no stored credential, // no credentials file. A token beside a store env is refused, as --console // is, because the two name different places to send the work. func resolveMode(consoleFlag string) (*apiSession, error) { bucketSet := os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" dirSet := os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" if consoleFlag != "" && (bucketSet || dirSet) { return nil, errors.New("--console conflicts with CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR " + "— unset the env to use the console, or drop --console") } token := os.Getenv("CARLOS_SERVICE_TOKEN") if token != "" && (bucketSet || dirSet) { return nil, errors.New("CARLOS_SERVICE_TOKEN and CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are both set. The token works through the console and the store variables go straight to the bucket. Unset one of them.") } if bucketSet || dirSet { return nil, nil } if token != "" { // CI's path (spec §8): the console is named outright, never taken // from a config default or a stored credential, and the credentials // file is not read. console := consoleFlag if console == "" { console = os.Getenv("CARLOS_CONSOLE") } if console == "" { return nil, errors.New("CARLOS_SERVICE_TOKEN is set but no console is named: set CARLOS_CONSOLE or pass --console") } origin, err := normalizeConsole(console) if err != nil { return nil, err } return &apiSession{c: newConsoleClient(origin, token), console: origin, address: serviceSessionLabel}, nil } cred, err := loggedInCredential(consoleFlag) if err != nil { // loggedInCredential's own errors already name their own fix (log in // to the right console, re-log in, disambiguate with --console) — // except the plain "there are no credentials at all" case (pick's // exact, non-interpolated wording below), which never mentions // direct mode because pick() has no idea direct mode exists. That's // the one case worth widening here: a fresh checkout with neither a // bucket/dir nor a login has TWO ways to fix it, and the naked "not // logged in" message only offers one. if err.Error() == noCredentialsMsg { // But "not logged in" is also what pick() says when a credential // EXISTS but every entry on disk has expired — its live-credential // filter treats an expired entry as absent, so that case reaches // this same string. Those are different problems for a person to // fix ("configure a target" vs. "log back in"), and conflating // them here would tell someone who has in fact logged in before // that nothing is configured at all. if expired := expiredCredentialFirst(); expired != nil { err = ¬LoggedInError{Console: expired.Console, Expired: true, msg: fmt.Sprintf("the credential for %s expired — run: carlos auth login --console %s", expired.Console, expired.Console)} } else { err = errNoDeploymentTarget } } // A terminal gets the remedy offered, not just named (#162): log in // right here and CONTINUE the command the user actually typed. // Scripts, n, and EOF keep the exact errors built above. cred, err = offerLogin(err, consoleFlag) if err != nil { return nil, err } } return &apiSession{ c: newConsoleClient(cred.Console, cred.Token), console: cred.Console, address: cred.Address, }, nil } // consoleMovedError is a permanent-redirect answer from a console (#160): // the host this CLI was pointed at now lives somewhere else, and the // message carries the whole story — a script that hits it needs no oracle. type consoleMovedError struct{ From, To string } func (e *consoleMovedError) Error() string { return fmt.Sprintf("this console has moved: %s -> %s — run: carlos auth login --console %s", e.From, e.To, e.To) } // consoleMoved inspects one response for a console-shaped redirect. Only a // PERMANENT redirect (301/308) whose Location names an ABSOLUTE origin // normalizeConsole would accept (https, or http strictly on-box) and that // differs from the one dialed counts — 302/307 are what SSO walls and // identity proxies answer with, and reading those as "the console moved" // would offer a login to accounts.google.com; a same-origin or path-level // redirect stays a plain unexpected status; and a Location the CLI would // refuse to log in to is never surfaced as a destination. The Location is // trusted for DISPLAY AND PROMPTING only: nothing follows it, and no // credential exists until the human confirms the device flow against it. func consoleMoved(from string, status int, location string) *consoleMovedError { switch status { case http.StatusMovedPermanently, http.StatusPermanentRedirect: default: return nil } u, err := url.Parse(location) if err != nil || !u.IsAbs() || u.Host == "" { return nil } origin, err := normalizeConsole(u.Scheme + "://" + u.Host) if err != nil || origin == from { return nil } return &consoleMovedError{From: from, To: origin} } // consoleMovedOffered makes the offer once-per-process: polling verbs // (`logs -f`, authLogin's approval loop) hit the same 3xx every interval, // and prompting on each would eat the terminal's stdin; and the offer // itself runs authLogin, whose polls reach these same response sites — the // guard is also the re-entrancy stop for a redirect chain. After the one // offer (taken or declined), every further hit is the typed error. var consoleMovedOffered bool // quietConsoleOffers suppresses the offer entirely — set around calls // whose contract is to degrade silently (whoami's memberships bonus line), // where even one prompt would make a working command look broken. var quietConsoleOffers bool // offerConsoleMoved is the terminal's version of consoleMovedError: explain // the move, offer the standard device flow against the new host, and — when // a .carlos/config names the old console — offer to repoint it (keeping // every other key, account included: the pair moves together). The verb // still ends nonzero: its request died against the old host mid-flight, and // "logged in — re-run your command" is the honest state. func offerConsoleMoved(e *consoleMovedError) error { if !interactiveTerminal() || quietConsoleOffers || consoleMovedOffered { return e } consoleMovedOffered = true fmt.Fprintf(stdout, "this console has moved: %s -> %s\n", e.From, e.To) if !promptYes(stdout, fmt.Sprintf("log in to %s now? [y/N] ", e.To)) { return e } if err := authLogin(stdout, []string{"--console", e.To}); err != nil { return err } if cfgConsole, src, err := configDefault(); err == nil && cfgConsole == e.From && src != "" { // The destination is named in the prompt, not only the file: a y // here changes where future ships go, and that must never rest on // remembering what the previous prompt said. if promptYes(stdout, fmt.Sprintf("update %s to point at %s too? [y/N] ", src, e.To)) { if err := saveConfigFile(src, e.To, 0o755); err != nil { warnf(stdout, "not updated (%s): %v", src, err) } else { mutedf(stdout, "updated: %s now names %s", src, e.To) } } } return fmt.Errorf("logged in to %s — re-run your command against it", e.To) } // errNoDeploymentTarget is resolveMode's "nothing configured at all" // refusal — a var so offerLogin can recognize it by identity rather than // by matching its wording. var errNoDeploymentTarget = errors.New("no deployment target configured: " + "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " + "or run carlos auth login --console to work through a console") // offerLogin is the interactive half of resolveMode's refusals (#162). On // a terminal, a refusal that names a console offers to run the device flow // against it right now; the nothing-configured refusal asks for a URL // (typing one IS the consent — no second prompt). Everything else — and // every non-terminal caller — gets the original error back verbatim. The // login flow itself is authLogin, unchanged: browser approval, per-origin // credential, no shortcuts. func offerLogin(origErr error, consoleFlag string) (*credential, error) { if !interactiveTerminal() { return nil, origErr } var target string var nle *notLoggedInError switch { case errors.As(origErr, &nle): if nle.Expired { fmt.Fprintf(stdout, "your login for %s has expired\n", nle.Console) if !promptYes(stdout, "log in again now? [y/N] ") { return nil, origErr } } else { fmt.Fprintf(stdout, "not logged in to %s\n", nle.Console) if !promptYes(stdout, "log in now? [y/N] ") { return nil, origErr } } target = nle.Console case errors.Is(origErr, errNoDeploymentTarget): fmt.Fprintln(stdout, "no console configured and no login found.") raw := promptLine(stdout, "console URL (e.g. https://console.carloku.com): ") if raw == "" { return nil, origErr } n, err := normalizeConsole(raw) if err != nil { return nil, err } target = n default: return nil, origErr } if err := authLogin(stdout, []string{"--console", target}); err != nil { return nil, err } return loggedInCredential(consoleFlag) } // noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for // "no --console given (and no CARLOS_CONSOLE either) and there is nothing // live to pick from" — the one pick() refusal that never mentions direct // mode, since pick has no idea direct mode exists. Matched by exact string // rather than a sentinel error because pick constructs a fresh error each // call; this is the one pick() message with no interpolated value, so an // exact match is precise, not fragile. // // It is ALSO the one case pick() reaches both when the credentials file is // genuinely empty and when it holds only expired entries (pick's "live" // filter excludes expired credentials before counting), which is why // resolveMode calls expiredCredentialFirst before treating it as "nothing // configured". const noCredentialsMsg = "not logged in — run: carlos auth login --console " // expiredCredentialFirst reports whether the credentials file holds at // least one entry and every one of them is expired, returning one such // entry if so (and nil otherwise, including when the file is empty or // unreadable). Only ever consulted from the noCredentialsMsg branch above, // where consoleFlag and CARLOS_CONSOLE are already known to both be empty — // pick() would have taken its console!="" branch, and returned its OWN // (already specific) expired-credential message, otherwise. func expiredCredentialFirst() *credential { f, err := loadCredentials() if err != nil || len(f.Credentials) == 0 { return nil } now := time.Now() for i := range f.Credentials { if !f.Credentials[i].Expired(now) { return nil } } return &f.Credentials[0] } // apiChannel is one row of a channel ladder read back through the console // API, mirroring the runChannels per-row ERROR behaviour: a channel whose // pointer failed verification is a row with Err set, not a failed call. type apiChannel struct { Channel string Version string PromotedAt string Rung string Err string // Digests is whether the pointer carries signed manifest digests; // Straggler is a pointer without them written after digests went on // (#253). An older console omits both, which reads as false. Digests bool Straggler bool } // apiChannelsResult is what `channels` returns: the channel rows plus the // app's declared pipeline (release pipelines v2, Task 11), folded down to // the two facts a deploy-side caller actually needs — where a release // enters and the full order — rather than re-exposing release.Pipeline's // whole shape over the wire a second time. // // PipelineEntry == "" means "no pipeline" — a legacy app on the implicit // edge/beta/stable ladder, OR a console whose pipeline read failed // (handleAPIChannels omits both pipeline_entry/pipeline_channels rather // than failing the listing; this client-side type does not need to tell // the two apart, since either way there is no entry channel to fall back // to and the caller's existing no-pipeline behaviour is exactly right). type apiChannelsResult struct { Channels []apiChannel PipelineEntry string PipelineChannels []string } // apiPromoteResult and apiShipResult carry the one fact Task 5 needs to // print a parity message: whether the console did nothing because the // request already matched what's live (a promote of a version already on // that channel; a re-ship of bytes already shipped under that version). // Every other field of the console's response (rung names, artifact // hashes) is deliberately not modelled here — the CLI already knows the // version/channel it asked for and prints those, never the server's echo of // them. // // apiShipResult is the one exception: Version (release versioning spec). // When a ship's meta part carries no version, the CONSOLE mints one from the // app's target — the caller sent no version to already know, so the // server's answer is the only place it exists. type apiPromoteResult struct { NoOp bool // Held mirrors the console's additive "held" field (Task 6, spec §6.6): // true when this is a production app promoting into a rung that bakes. // Present on BOTH the promoted and the no-op response — a repeated // `carlos deploy` against an unheld app must still watch (the channel // already points at the version, but an instance may not have converged // yet), so deploy needs this fact on the no-op path too, not only on a // fresh promotion. Held bool `json:"held"` // HeldUntil is when a held promotion lifts, RFC 3339, from a console // that knows; "" from an older one. HeldUntil string `json:"held_until"` } type apiShipResult struct { NoOp bool Version string // BuildProbe is the app's build-report path, echoed by the ship door // (handleAPIShip) and empty for an app that declares none — including // against any console built before the field existed, where the key is // simply absent and the deploy watch keeps reading the edge's header. // `carlos deploy` learns it here rather than through a door of its own: // it always ships before it watches, so the value arrives with no extra // round trip on a response already scoped to this one app. BuildProbe string } // 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. Both flags are trimmed exactly as // resolveAccount trims them, so `--account " ops "` means the same thing in // either mode, and --account-id passes the same checkAccountID gate — a name // there is refused on this side of the wire, not turned into a 404 the // console cannot explain. func apiAccount(raw, rawID *string) apiAcct { if id := checkAccountID(*rawID); id != "" { return apiAcct{Seg: id, Exact: true} } return apiAcct{Seg: strings.TrimSpace(*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. // --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 { sep := "?" if strings.Contains(p, "?") { sep = "&" } p += sep + "exact=sqid" } return p } // storeEnvTail names the environment a store verb is aimed at, as the query // parameter every store door reads. "" is the app's own store and adds // nothing at all, so this CLI's request to an older console is byte-identical // to the one it has always sent. // // It goes in the TAIL rather than after apiPath, because apiPath appends // `exact=sqid` with its own ?/& check — building the query here keeps one // place deciding how these paths are punctuated. func storeEnvTail(tail, env string) string { if env == "" { return tail } return tail + "?environment=" + url.QueryEscape(env) } func apiAccountStorePath(a apiAcct, tail string) string { p := "/api/cli/accounts/" + url.PathEscape(a.Seg) + "/store/" + tail if a.Exact { p += "?exact=sqid" } return p } // listAccounts fetches the caller's own memberships: GET /api/cli/apps' // "accounts" array (handleAPIApps — live since main 5856dc5, 2026-08-03). // supported=false means the console predates the endpoint: the bare mux // 404, which cannot be the membership gate's 404 because this path carries // no account or app segment for a gate to refuse. Every other non-200 is // a real answer (401 expired token, 500) and surfaces via apiErrorFromBody // — never swallowed into a fallback. func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) { // Memoized: account resolution and app resolution both read this, and // one invocation makes at most one fetch, however many of them run. if s.acctsFetched { return s.accts, s.acctsSupported, nil } status, body, err := s.getJSON(ctx, "/api/cli/apps") if err != nil { return nil, false, err } if status == http.StatusNotFound { s.accts, s.acctsSupported, s.acctsFetched = nil, false, true return nil, false, nil } if status != http.StatusOK { return nil, false, apiErrorFromBody(status, body) } var out struct { Accounts []apiAccountEntry `json:"accounts"` } if err := json.Unmarshal(body, &out); err != nil { return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err) } s.accts, s.acctsSupported, s.acctsFetched = out.Accounts, true, true return out.Accounts, true, nil } // defaultAccount answers an omitted --account in API mode (spec: // docs/superpowers/specs/2026-08-12-account-default-resolution-design.md): // the config default when its file's console pairs with this session, else // the caller's one membership, else the chooser (terminal) or the naming // refusal (script). Whatever is decided for the caller is printed — a // guessed account that stayed invisible would be the wrong-deployment // mistake this file already refuses everywhere else. func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) { if s.defAcct != nil { return *s.defAcct, nil } a, err := s.defaultAccountOnce(ctx) if err == nil { s.defAcct = &a } return a, err } func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) { name, src, fileConsole, err := configAccount() if err != nil { return apiAcct{}, err } // Honored ONLY when the file names THIS console: the pair is one fact. // A consoleless {"account": …} is deliberately not a wildcard — the // console key is nearly inert as committed input (no credential, no // request), but an account that applied to every console would let a // checked-in file silently redirect a ship between stacks the caller // really is a member of. saveConfigAccount always writes the pair, so // only a hand-written file can even be consoleless. if name != "" && fileConsole == s.console { // The name travels verbatim, like a typed --account: the console // resolves it against the caller's memberships, so a stale config // value fails with the same membership 404 a typo would. mutedf(stdout, "account: %s (the default from %s)", name, src) return apiAcct{Seg: name}, nil } accts, supported, err := s.listAccounts(ctx) if err != nil { return apiAcct{}, err } if !supported { // No memberships answer — a console from before the listing // endpoint, or any 404-shaped thing in front of one. Keep the // pre-discovery default so old consoles regress nothing, but SAY // so: a silent ops here followed by the membership 404 is exactly // the three-failures-deep riddle #157 was filed about. mutedf(stdout, "account: ops (%s did not answer with your memberships)", s.console) return apiAcct{Seg: "ops"}, nil } switch len(accts) { case 0: return apiAcct{}, fmt.Errorf("your address belongs to no account on %s — create one with: carlos accounts create --name ", s.console) case 1: mutedf(stdout, "account: %s (%s)", accts[0].Name, accts[0].Sqid) return apiAcct{Seg: accts[0].Sqid, Exact: true}, nil } if !interactiveTerminal() { return apiAcct{}, severalAccountsErr(accts) } // ./.carlos/config in the working directory — the same "run it where // you want the file" rule as `auth default --project`. Except from // $HOME, where that path IS ~/.carlos/config: a y there would silently // repoint the machine-wide default console from inside a ship, so the // save is not offered at all. dest := filepath.Join(".carlos", "config") if abs, err := filepath.Abs(dest); err == nil { if gp, gerr := globalConfigPath(); gerr == nil && abs == gp { dest = "" } } if s.deferSaves { // Zero-arg deploy makes ONE combined save offer at the end of // first-run resolution — the chooser's own prompt would be a // second question about the same file. dest = "" } picked, save, err := chooseAccount(chooserIn, stdout, s.console, accts, dest) if err != nil { return apiAcct{}, err } if save && dest != "" { // The save is bookkeeping, never a precondition: the human already // answered, the account is known, and a read-only checkout must // not turn that answer into a dead ship. if err := saveConfigAccount(dest, s.console, picked.Name, 0o755); err != nil { warnf(stdout, "not saved (%s): %v", dest, err) } else { mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, dest) } } return apiAcct{Seg: picked.Sqid, Exact: true}, nil } // noAppsError is defaultApp's "this account owns nothing yet" refusal — // typed so zero-arg deploy can turn it into the create-an-app offer while // every other verb just prints it. type noAppsError struct{ Account string } func (e *noAppsError) Error() string { return fmt.Sprintf("account %s has no apps — create one with: carlos apps create --app ", e.Account) } // defaultApp answers an omitted --app (2026-08-13 project-defaults spec): // the config default when its file's console pairs with this session, // else the resolved account's one app, else the chooser (terminal) or the // naming refusal (script). "" with a nil error means this console cannot // answer (pre-listing console, or an account the memberships response // does not name) — the caller falls back to its own usage error, exactly // the pre-spec behavior. offerSave=false lets zero-arg deploy fold the // answer into its one combined save instead of prompting twice. func (s *apiSession) defaultApp(ctx context.Context, acct apiAcct, offerSave bool) (string, error) { if s.defApp != "" { return s.defApp, nil } name, src, fileConsole, err := configApp() if err != nil { return "", err } if name != "" && fileConsole == s.console { mutedf(stdout, "app: %s (the default from %s)", name, src) s.defApp = name return name, nil } accts, supported, err := s.listAccounts(ctx) if err != nil { return "", err } if !supported { return "", nil } var entry *apiAccountEntry for i := range accts { if accts[i].Sqid == acct.Seg || accts[i].Name == acct.Seg { entry = &accts[i] break } } if entry == nil { // An explicit --account this token's memberships don't name: the // verb's own request will answer that (the membership 404) — app // resolution has nothing to add. return "", nil } switch len(entry.Apps) { case 0: return "", &noAppsError{Account: entry.Name} case 1: mutedf(stdout, "app: %s", entry.Apps[0]) s.defApp = entry.Apps[0] return entry.Apps[0], nil } if !interactiveTerminal() { return "", severalAppsErr(entry.Name, entry.Apps) } picked, err := chooseOne(chooserIn, stdout, fmt.Sprintf("account %s has several apps:", entry.Name), "app", entry.Apps, severalAppsErr(entry.Name, entry.Apps)) if err != nil { return "", err } s.defApp = picked s.appPrompted = true if offerSave && !s.deferSaves { offerSaveProjectDefaults(s.console, entry.Name, map[string]any{"app": picked}, fmt.Sprintf("save %s as this project's default app", picked)) } return picked, nil } // offerSaveProjectDefaults is the one save prompt every resolution path // shares: console and account always land with whatever extra keys the // caller resolved (the file is read as pairs, and a default without its // console would silently stop applying). Skipped from $HOME (the // "project" file would be the global config) and never fatal — the // answers are already in hand, and bookkeeping must not kill the verb. func offerSaveProjectDefaults(console, account string, extra map[string]any, question string) { dest := filepath.Join(".carlos", "config") if abs, err := filepath.Abs(dest); err == nil { if gp, gerr := globalConfigPath(); gerr == nil && abs == gp { return } } if !promptYes(stdout, question+" ("+dest+")? [y/N] ") { return } set := map[string]any{"console": console} if account != "" { set["account"] = account } for k, v := range extra { set[k] = v } if err := writeConfigKeys(dest, set, 0o755); err != nil { warnf(stdout, "not saved (%s): %v", dest, err) } else { mutedf(stdout, "saved: project defaults in %s", dest) } } // resolvedApp is defaultApp's verb-facing wrapper, resolvedAccount's rule // for the sibling flag: explicit --app travels verbatim; an empty one // resolves or dies on the verb's own usage message when this console // cannot answer. Fatal for the same reason resolvedAccount is — every // caller's next line is the request itself. func resolvedApp(sess *apiSession, acct apiAcct, app, usage string) string { if app != "" { return app } r, err := sess.defaultApp(context.Background(), acct, true) if err != nil { log.Fatal(err) } if r == "" { log.Fatal(usage) } return r } // resolvedAccount is apiAccount plus the omitted-flag default: the seam // every API-mode verb reaches through. Explicit flags travel verbatim and // never touch the network; only an empty pair asks defaultAccount. Fatal // on refusal because every caller is a verb body whose next line would be // the request itself — there is nothing for it to do with the error but // print it and stop. func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct { a := apiAccount(raw, rawID) if a.Seg != "" { return a } if sess == nil { // Unreachable from any current verb (each guards its mode before // resolving), and kept LOUD: returning the empty segment would // build /api/cli/apps// and answer a client-side bug with the // membership-404 riddle. log.Fatal("resolvedAccount: no session — a direct-mode path reached API account resolution (a bug in this binary)") } r, err := sess.defaultAccount(context.Background()) if err != nil { log.Fatal(err) } return r } // notFoundErr is what every call here returns for a 404 whose body carries // no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers // an unknown account, an unknown app, and someone else's account with the // SAME http.NotFound — a byte-identical text/plain body, not JSON, by design // (a token holder must not learn which of those three it was). This is the // one sane message for all three. A 404 that DOES carry an "error" field // (an unshipped version on promote, a channel with no history on rollback) // is a genuine server diagnosis, and apiErrorFromBody surfaces THAT text // instead — never rewritten into this one. func notFoundErr() error { return errMembershipNotFound } // errMembershipNotFound is notFoundErr's one value, so a caller that can // narrow the riddle (recordNotFound, instances_cli.go) can recognise it with // errors.Is rather than by matching its text. var errMembershipNotFound = errors.New("not found: check --account and --app, and that your token's address is a member") // hasJSONError reports whether a body carries the {"error": "..."} shape — // the difference between a door's own diagnosis and the mux's byte-identical // text/plain refusal. apiErrorFromBody asks it to decide which message to // give; a caller with a better sentence for the second case (relayRotate) // asks the same question rather than string-matching the answer. func hasJSONError(body []byte) bool { var e struct { Error string `json:"error"` } return json.Unmarshal(body, &e) == nil && e.Error != "" } // apiErrorFromBody turns a non-2xx status and its raw body into an error. // The JSON {"error": "..."} shape is tried FIRST, whatever the status code — // several doors (promote's unshipped-version 404, rollback's no-history 409) // answer a real diagnosis worth showing verbatim. Only when the body has no // such field at all — exactly what apiAccountApp's gate sends, a text/plain // "404 page not found" — does a 404 fall back to the generic membership // message; every other unreadable body just reports its status. func apiErrorFromBody(status int, body []byte) error { var e struct { Error string `json:"error"` } if json.Unmarshal(body, &e) == nil && e.Error != "" { return errors.New(e.Error) } if status == http.StatusNotFound { return notFoundErr() } return fmt.Errorf("unexpected response (status %d)", status) } // getJSON runs one JSON GET and hands back the status and raw body without // interpreting a non-200 as an error itself, for the same reason postJSON // doesn't: a caller here needs the FULL body (to try apiErrorFromBody's // json-first decode), not just a pre-picked message. func (s *apiSession) getJSON(ctx context.Context, path string) (int, []byte, error) { status, _, body, err := s.getJSONHeader(ctx, path) return status, body, err } // getJSONHeader is getJSON that also hands back the response headers, for // the caller that reads one (Retry-After). func (s *apiSession) getJSONHeader(ctx context.Context, path string) (int, http.Header, []byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.c.base+path, nil) if err != nil { return 0, nil, nil, err } if s.c.token != "" { req.Header.Set("Authorization", "Bearer "+s.c.token) } res, err := s.c.httpc.Do(req) if err != nil { return 0, nil, nil, err } defer res.Body.Close() if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { return res.StatusCode, res.Header, nil, offerConsoleMoved(merr) } body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return res.StatusCode, res.Header, nil, err } return res.StatusCode, res.Header, body, nil } // sendJSON runs one JSON request over the given method and hands back the // status and raw body without interpreting a non-200 as an error itself — // the one request-building body postJSON/putJSON/patchJSON share (Task 8 // review: three byte-identical copies, method aside, was past the line). // Unlike (*consoleClient).do, which keeps only a bare "error" string, some // callers (promote, rollback) need the FULL body on a stable refusal — its // console_url would otherwise be discarded before the caller ever sees it — // which is why this returns the raw body rather than pre-deciding what // counts as an error. func (s *apiSession) sendJSON(ctx context.Context, method, path string, in any) (int, []byte, error) { raw, err := json.Marshal(in) if err != nil { return 0, nil, err } req, err := http.NewRequestWithContext(ctx, method, s.c.base+path, bytes.NewReader(raw)) if err != nil { return 0, nil, err } req.Header.Set("Content-Type", "application/json") if s.c.token != "" { req.Header.Set("Authorization", "Bearer "+s.c.token) } res, err := s.c.httpc.Do(req) if err != nil { return 0, nil, err } defer res.Body.Close() if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { return res.StatusCode, nil, offerConsoleMoved(merr) } body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return res.StatusCode, nil, err } return res.StatusCode, body, nil } // postJSON is sendJSON pinned to POST — the console door shape a create or // an action verb (promote, restart, ...) always speaks. func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []byte, error) { return s.sendJSON(ctx, http.MethodPost, path, in) } // consoleRefusal decodes the generalized {"error", "console_url"} 403 shape // every guarded write door in this family answers with — handleAPIPromote // and handleAPIRollback's stable/passkey refusal (apicli.go) and // handleAPIPipelinePut's own passkey-rule refusal (release pipelines v2 // §5) alike — and, when it matches, returns the error the caller should // see: the console's own refusal text plus where to finish the job. A // passkey step-up has no bearer-token equivalent, so a channel that demands // one always ends here, never on this machine. Named for what it now covers // (not only stable) since Task 10 widened its call sites beyond promote and // rollback to `carlos pipeline`'s own writes. func consoleRefusal(status int, body []byte) (error, bool) { if status != http.StatusForbidden { return nil, false } var refusal struct { Error string `json:"error"` ConsoleURL string `json:"console_url"` } if json.Unmarshal(body, &refusal) != nil || refusal.Error == "" { return nil, false } return fmt.Errorf("%s\n %s", refusal.Error, refusal.ConsoleURL), true } // apiProposedError is what a guarded write (promote, rollback, or a // `carlos pipeline` edit that touches an already-guarded channel) returns // instead of landing: the console answered 202, spec §4's bearer-token // shape — a token can never carry a passkey quorum, so the change is // recorded at zero confirmations and must be finished in the console. Every // caller here must check for this with errors.As and print it rather than // let it fall through to apiErrorFromBody, which would otherwise report the // misleading "unexpected response (status 202)" (the CLI-lies-about-its- // outcome bug this type exists to close). type apiProposedError struct { PendingID string Needed int Confirmed int ConsoleURL string } func (e *apiProposedError) Error() string { return fmt.Sprintf("proposed — needs %d confirmation(s); finish at %s", e.Needed, e.ConsoleURL) } // proposedFromBody decodes a 202's body — proposeOverAPI's // {"result","pending_id","needed","confirmed","console_url"} shape // (pendinghandlers.go) — into an *apiProposedError. The body is decoded // best-effort: even an unreadable one still yields a usable error, because // the status code alone already says "proposed", and dropping the whole // thing here would lose the console_url an operator needs to actually // finish the job. func proposedFromBody(body []byte) error { var res struct { PendingID string `json:"pending_id"` Needed int `json:"needed"` Confirmed int `json:"confirmed"` ConsoleURL string `json:"console_url"` } json.Unmarshal(body, &res) return &apiProposedError{PendingID: res.PendingID, Needed: res.Needed, Confirmed: res.Confirmed, ConsoleURL: res.ConsoleURL} } // promote asks the console to promote version onto channel. The response's // "result" is the one field read here — "noop" when the ladder already had // this version on this channel — because everything else the response // 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. // // hotfix rides along as a plain field (release pipelines v2, Task 10): the // console's promote door reads it strictly after its own rules decision // (handleAPIPromote's doc comment), so sending it is never itself a bypass // of anything this call site doesn't already ask for. func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string, hotfix, retry bool, messages ...string) (apiPromoteResult, error) { message := firstMutationMessage(messages) in := struct { Version string `json:"version"` Channel string `json:"channel"` Hotfix bool `json:"hotfix,omitempty"` Retry bool `json:"retry,omitempty"` Message string `json:"message,omitempty"` }{version, channel, hotfix, retry, message} status, body, err := s.postJSON(ctx, apiPath(a, app, "/promote"), in) if err != nil { return apiPromoteResult{}, err } if status == http.StatusOK { var res struct { Result string `json:"result"` Held bool `json:"held"` HeldUntil string `json:"held_until"` } if err := json.Unmarshal(body, &res); err != nil { return apiPromoteResult{}, fmt.Errorf("promote: unreadable response: %w", err) } return apiPromoteResult{NoOp: res.Result == "noop", Held: res.Held, HeldUntil: res.HeldUntil}, nil } if status == http.StatusAccepted { return apiPromoteResult{}, proposedFromBody(body) } if refErr, ok := consoleRefusal(status, body); ok { return apiPromoteResult{}, refErr } return apiPromoteResult{}, apiErrorFromBody(status, body) } // apiCreateAppResult carries what `carlos apps create` prints: the console's // canonical account sqid (the segment the caller typed may have been a // name), the normalized app name, and the app's console page. // Placement is what the console actually stored for the "place" field this // call's request carried — "" for both "no place was requested" and "a // place WAS requested but did not stick" (an older console that never // echoes the field at all leaves this at the same "" zero value // encoding/json gives an explicit empty string, which is exactly what makes // it usable as the one signal runAppsCreate needs — see // placementMismatchErr, apps.go). ControlPlane and BuildProbe are the same // kind of echo for the two claim-time flags (Task 3): what the console // actually stamped on the row, read back after the write, not merely what // the request asked for. type apiCreateAppResult struct { Account string `json:"account"` App string `json:"app"` URL string `json:"url"` Placement string `json:"placement"` ControlPlane bool `json:"control_plane"` BuildProbe string `json:"build_probe"` // The app region the console STORED (nearest-region spec §9.1), under // its name. Absent from an older console, which the CLI reads as "no // app region recorded". Region string `json:"region"` RegionName string `json:"region_name"` RegionSource string `json:"region_source"` RegionsAvailable int `json:"regions_available"` } // createApp claims one app name through the console — the API door onto the // same claim the browser's New app form makes. The path is the literal // `new` segment (mirroring /a/{sqid}/apps/new), which apiPath builds when // handed "new" as the app. // // place is "" or a "fleet/" target (runAppsCreate's --place flag, // already grammar-checked client-side before this is ever called) — the // server (handleAPICreateApp, internal/console/apicli.go) applies the SAME // two checks `carlos apps place` uses on its own target (grammar again, // server-side, plus fleet ownership) before the app is created, so an // invalid or foreign place refuses the whole create rather than silently // creating an unplaced app. // // controlPlane and buildProbe are the claim-time flags Task 3 adds // (-control-plane, -build-probe): carried in the SAME request that claims // the name, so no SECOND round trip is needed to flag the row. That is not // the same as atomicity — server-side (handleAPICreateApp, // internal/console/apicli.go) it is still two writes inside one handler, // the claim then the stamps, and that handler's own comment is the honest // account of the window that leaves. The client's job is only to make sure // the flags travel with the claim rather than after it. // // controlPlane costs an owner-only 403 // (server-side; there is no client-side role check possible here) and // buildProbe is grammar-checked client-side before this is ever called // (validBuildProbe, apps.go), same "a shape the server is certain to // refuse should cost nothing" principle as place. func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error) { in := struct { Name string `json:"name"` Place string `json:"place,omitempty"` ControlPlane bool `json:"control_plane,omitempty"` BuildProbe string `json:"build_probe,omitempty"` Region string `json:"region,omitempty"` Timezone string `json:"timezone,omitempty"` }{app, place, controlPlane, buildProbe, region, timezone} status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in) if err != nil { return apiCreateAppResult{}, err } if status == http.StatusNotFound { // Unlike every other verb's 404 there is no --app to second-guess: // the create door only 404s on the account. return apiCreateAppResult{}, errors.New("not found: check --account, and that your token's address is a member") } if status != http.StatusOK { return apiCreateAppResult{}, apiErrorFromBody(status, body) } var res apiCreateAppResult if err := json.Unmarshal(body, &res); err != nil { return apiCreateAppResult{}, fmt.Errorf("apps create: unreadable response: %w", err) } return res, nil } // apiAppDefault, apiRegionEntry and apiRegionsResult are the CLI's view of // GET …/regions (nearest-region spec §9.3). AppDefault is nil from a // console that predates it. Region is the STORED region in the console's own // vocabulary: a home app reports the home's code, never "". type apiAppDefault struct { Region string `json:"region"` Name string `json:"name"` Source string `json:"source"` Effective string `json:"effective"` Reason string `json:"reason"` Available *bool `json:"available"` RegionKnown bool `json:"region_known"` PlacementKnown bool `json:"placement_known"` InstanceDomain string `json:"instance_domain"` } type apiRegionEntry struct { Code string `json:"code"` Name string `json:"name"` } type apiRegionsResult struct { Regions []apiRegionEntry `json:"regions"` Default string `json:"default"` AppDefault *apiAppDefault `json:"app_default"` } // nameOf is the place name the console gave code, else the code itself. func (r apiRegionsResult) nameOf(code string) string { for _, e := range r.Regions { if e.Code == code && e.Name != "" { return e.Name } } return code } // appRegions reads GET …/regions for app: the regions the console knows and, // from a console that has it, the app's own default. func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/regions")) if err != nil { return apiRegionsResult{}, err } if status != http.StatusOK { return apiRegionsResult{}, apiErrorFromBody(status, body) } var out apiRegionsResult if err := json.Unmarshal(body, &out); err != nil { return apiRegionsResult{}, fmt.Errorf("regions: unreadable response: %w", err) } return out, nil } // 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. It DOES have a proposed case, exactly // as promote does: a pipelined channel that demands promote_approvals turns // this into a 202 (handleAPIRollback, apicli.go), which is why this can no // longer stay error-or-nil — *apiProposedError is a real, expected return // alongside a genuine failure, and runRollback tells them apart with // errors.As. func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel string, messages ...string) error { message := firstMutationMessage(messages) in := struct { Channel string `json:"channel"` Message string `json:"message,omitempty"` }{channel, message} status, body, err := s.postJSON(ctx, apiPath(a, app, "/rollback"), in) if err != nil { return err } if status == http.StatusOK { return nil } if status == http.StatusAccepted { return proposedFromBody(body) } if refErr, ok := consoleRefusal(status, body); ok { return refErr } return apiErrorFromBody(status, body) } // apiRestartResult is what `carlos restart` prints: the console's CANONICAL // account sqid and app name, since the segment the caller typed may have been // a name resolved server-side. type apiRestartResult struct { Account string `json:"account"` App string `json:"app"` } // dbClient is the `carlos db` replica client for one instance (db.go): a // read-only litestream.ReplicaClient whose every request is a GET through // this session's console, as this session's person. The account segment // and its ?exact=sqid switch ride on apiPath, exactly as every other verb's // do; the client itself only ever names a coordinate. func (s *apiSession) dbClient(a apiAcct, app, host string) *dbdoor.Client { return dbdoor.NewClient(func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { return s.c.doStream(ctx, http.MethodGet, apiPath(a, app, "/db/"+url.PathEscape(host)+tail), nil, -1, h) }) } // restart asks the console to request a process cycle for app on every box // that serves it (issue #113). Mirrors trashApp's shape — postJSON, a 200 // decodes, anything else is apiErrorFromBody's job — with ONE addition, the // 404. // // A 404 here is genuinely ambiguous, and pretending otherwise would be a lie // in one direction or the other. An old console has no /restart route at all, // so net/http's mux answers exactly the same byte-identical "404 page not // found" apiAccountApp gives an unknown account, someone else's account, or an // app that isn't there — there is nothing in the response to tell them apart. // So the message names BOTH, shortest fix first, the same way restoreApp // widens its own 404 rather than inventing certainty it doesn't have. func (s *apiSession) restart(ctx context.Context, a apiAcct, app string, messages ...string) (apiRestartResult, error) { message := firstMutationMessage(messages) in := struct { Message string `json:"message,omitempty"` }{message} status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart"), in) if err != nil { return apiRestartResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiRestartResult{}, fmt.Errorf("%w — or this console predates `carlos restart` "+ "(no restart door on it at all), in which case redeploy the console first", notFoundErr()) } return apiRestartResult{}, apiErrorFromBody(status, body) } var res apiRestartResult if err := json.Unmarshal(body, &res); err != nil { return apiRestartResult{}, fmt.Errorf("restart: unreadable response: %w", err) } return res, nil } // apiRestartScopeRoute is one route a narrowed restart addressed — the // console's answer to "what exactly is about to stop". Hibernate is not // decoration: a hibernating route is named AND left asleep, and an operator // who sees it listed with no explanation would read that as a failure. type apiRestartScopeRoute struct { Host string `json:"host"` Environment string `json:"environment"` Unit string `json:"unit"` Hibernate bool `json:"hibernate"` } // apiRestartScopeResult is what the narrowed door answers with. Requested is // FALSE for a dry run and true otherwise, so the CLI never has to infer from // its own flags what the console did — the console says. type apiRestartScopeResult struct { Account string `json:"account"` App string `json:"app"` Scope struct { Kind string `json:"kind"` Value string `json:"value"` } `json:"scope"` DryRun bool `json:"dry_run"` Requested bool `json:"requested"` Routes []apiRestartScopeRoute `json:"routes"` } // restartScoped asks the console to cycle ONE route, or everything reading one // config environment — or, with dryRun, to say what that would be and touch // nothing (discussion #16). // // A separate path from restart() above, not a body on it, and the 404 message // is where that shows. The old door reads no body at all: a narrowed request // sent there would be answered by restarting every instance the app owns, // which is the accident this exists to prevent. So the CLI asks a path an old // console does not serve, gets net/http's byte-identical "404 page not found", // and says all three things it could mean — shortest fix first — the way // restart() widens its own. func (s *apiSession) restartScoped(ctx context.Context, a apiAcct, app, host, environment string, dryRun bool, messages ...string) (apiRestartScopeResult, error) { message := firstMutationMessage(messages) in := struct { Host string `json:"host"` Environment string `json:"environment"` DryRun bool `json:"dry_run"` Message string `json:"message,omitempty"` }{host, environment, dryRun, message} status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart/scope"), in) if err != nil { return apiRestartScopeResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiRestartScopeResult{}, fmt.Errorf("%w — or this console predates narrowed restarts "+ "(--host, --environment, --dry-run), in which case redeploy the console first; "+ "`carlos restart --app` still works on it, but cycles every instance the app has", notFoundErr()) } return apiRestartScopeResult{}, apiErrorFromBody(status, body) } var res apiRestartScopeResult if err := json.Unmarshal(body, &res); err != nil { return apiRestartScopeResult{}, fmt.Errorf("restart: unreadable response: %w", err) } return res, nil } // apiScheduleList mirrors handleAPIScheduleList's wire shape // (internal/console/apischedule.go's scheduleAnswer) byte for byte — this // binary never imports internal/console, so the shape is redeclared here // rather than shared. Three of the four schedule doors answer with this; // scheduleRun answers with its own small {"requested":...} shape instead, // since a run touches no declaration. type apiScheduleList struct { Account string `json:"account"` App string `json:"app"` Schedules []schedule.Schedule `json:"schedules"` Instances []schedule.Status `json:"instances"` UpdatedAt string `json:"updated_at"` } // scheduleDoorNotFoundErr is restart's own 404 widening, renamed: ls and // set carry no {name} segment, so a 404 from either can only be the // account/app membership gate (apiAccountApp) or a console that predates // this door at all. func scheduleDoorNotFoundErr() error { return fmt.Errorf("%w — or this console predates `carlos schedule` "+ "(no schedule door on it at all), in which case redeploy the console first", notFoundErr()) } // scheduleNameNotFoundErr widens scheduleDoorNotFoundErr for rm and run: // their {name} segment adds a THIRD cause behind the same byte-identical // 404 (declaredSchedule, internal/console/apischedule.go, answers an // undeclared name with the same http.NotFound apiAccountApp's membership // gate uses) — a probe must not be able to tell any of the three apart, so // the message names all of them, cheapest fix first. func scheduleNameNotFoundErr() error { return fmt.Errorf("%w, or --name is not declared — or this console predates `carlos schedule` "+ "(no schedule door on it at all), in which case redeploy the console first", notFoundErr()) } // scheduleList asks the console what app has declared plus what every // instance last reported (handleAPIScheduleList). A read: no write budget, // no audit line on the console side. func (s *apiSession) scheduleList(ctx context.Context, a apiAcct, app string) (apiScheduleList, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/schedules")) if err != nil { return apiScheduleList{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiScheduleList{}, scheduleDoorNotFoundErr() } return apiScheduleList{}, apiErrorFromBody(status, body) } var res apiScheduleList if err := json.Unmarshal(body, &res); err != nil { return apiScheduleList{}, fmt.Errorf("schedule ls: unreadable response: %w", err) } return res, nil } // scheduleSet asks the console to declare or replace sc by name // (handleAPIScheduleSet). The console re-validates sc.Validate() itself — // this call's caller (schedule.go's runScheduleSet) has already run the // same check, so most bad requests never reach here at all, but the // console's answer is what actually lands, never assumed. func (s *apiSession) scheduleSet(ctx context.Context, a apiAcct, app string, sc schedule.Schedule) (apiScheduleList, error) { status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules"), sc) if err != nil { return apiScheduleList{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiScheduleList{}, scheduleDoorNotFoundErr() } return apiScheduleList{}, apiErrorFromBody(status, body) } var res apiScheduleList if err := json.Unmarshal(body, &res); err != nil { return apiScheduleList{}, fmt.Errorf("schedule set: unreadable response: %w", err) } return res, nil } // scheduleRemove asks the console to drop name (handleAPIScheduleRemove). func (s *apiSession) scheduleRemove(ctx context.Context, a apiAcct, app, name string) (apiScheduleList, error) { status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/delete"), struct{}{}) if err != nil { return apiScheduleList{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiScheduleList{}, scheduleNameNotFoundErr() } return apiScheduleList{}, apiErrorFromBody(status, body) } var res apiScheduleList if err := json.Unmarshal(body, &res); err != nil { return apiScheduleList{}, fmt.Errorf("schedule rm: unreadable response: %w", err) } return res, nil } // apiScheduleRunResult is handleAPIScheduleRun's answer: // {"requested":true,"account","app","name"} — the console's CANONICAL // account sqid and app name, since the segment the caller typed may have // been a name resolved server-side (restart's apiRestartResult, verbatim // reasoning; review finding on 23966c9: printing the locally-resolved // segment here diverged from ls/set/rm, which all print what the console // echoed back). type apiScheduleRunResult struct { Requested bool `json:"requested"` Account string `json:"account"` App string `json:"app"` Name string `json:"name"` } // scheduleRun asks the console to touch name's run signal // (handleAPIScheduleRun) — TouchRun's etag movement, never its content, is // the signal every box's converge pass acts on, restart-gen's own // mechanism. The response's canonical account/app/name are decoded and // returned so the caller prints the SAME kind of canonical value // ls/set/rm do, never the segment this terminal happened to resolve. func (s *apiSession) scheduleRun(ctx context.Context, a apiAcct, app, name string) (apiScheduleRunResult, error) { status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/run"), struct{}{}) if err != nil { return apiScheduleRunResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiScheduleRunResult{}, scheduleNameNotFoundErr() } return apiScheduleRunResult{}, apiErrorFromBody(status, body) } var res apiScheduleRunResult if err := json.Unmarshal(body, &res); err != nil { return apiScheduleRunResult{}, fmt.Errorf("schedule run: unreadable response: %w", err) } return res, nil } // apiFeaturesResult mirrors the console's features answer (apifeatures.go): // the app's map as the platform now serves it, who last changed it and // when, and — on a set — whether this call changed anything. type apiFeaturesResult struct { Account string `json:"account"` App string `json:"app"` Features map[string]string `json:"features"` SetBy string `json:"set_by"` SetAt string `json:"set_at"` Changed bool `json:"changed"` } func (s *apiSession) features(ctx context.Context, a apiAcct, app string) (apiFeaturesResult, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/features")) return decodeFeatures(status, body, err) } func (s *apiSession) setFeatures(ctx context.Context, a apiAcct, app string, set map[string]string, unset []string, messages ...string) (apiFeaturesResult, error) { message := firstMutationMessage(messages) in := struct { Set map[string]string `json:"set,omitempty"` Unset []string `json:"unset,omitempty"` Message string `json:"message,omitempty"` }{set, unset, message} status, body, err := s.postJSON(ctx, apiPath(a, app, "/features"), in) return decodeFeatures(status, body, err) } func decodeFeatures(status int, body []byte, err error) (apiFeaturesResult, error) { if err != nil { return apiFeaturesResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiFeaturesResult{}, fmt.Errorf("%w — or this console predates `carlos features` "+ "(deploy a console built from current main)", apiErrorFromBody(status, body)) } return apiFeaturesResult{}, apiErrorFromBody(status, body) } var res apiFeaturesResult if err := json.Unmarshal(body, &res); err != nil { return apiFeaturesResult{}, fmt.Errorf("features: unreadable response: %w", err) } return res, nil } // apiRetentionResult mirrors the console's retention answer: the policy as // recorded, plus the console's own count of currently-eligible versions — // computed console-side because this terminal has no bucket. Eligible is a // pointer: nil means the console could not compute the count, which the CLI // says out loud rather than printing a lying zero. type apiRetentionResult struct { Account string `json:"account"` App string `json:"app"` Keep int `json:"keep"` Off bool `json:"off"` DryRun bool `json:"dry_run"` Eligible *int `json:"eligible"` Total int `json:"total"` EligibleVersions []string `json:"eligible_versions"` } func (s *apiSession) retention(ctx context.Context, a apiAcct, app string) (apiRetentionResult, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/retention")) return decodeRetention(status, body, err) } func (s *apiSession) setRetention(ctx context.Context, a apiAcct, app string, keep int, off, dryRun bool) (apiRetentionResult, error) { in := struct { Keep int `json:"keep,omitempty"` Off bool `json:"off,omitempty"` DryRun bool `json:"dry_run,omitempty"` }{keep, off, dryRun} status, body, err := s.postJSON(ctx, apiPath(a, app, "/retention"), in) return decodeRetention(status, body, err) } func decodeRetention(status int, body []byte, err error) (apiRetentionResult, error) { if err != nil { return apiRetentionResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiRetentionResult{}, fmt.Errorf("%w — or this console predates `carlos releases retention` "+ "(deploy a console built from current main)", apiErrorFromBody(status, body)) } return apiRetentionResult{}, apiErrorFromBody(status, body) } var res apiRetentionResult if err := json.Unmarshal(body, &res); err != nil { return apiRetentionResult{}, fmt.Errorf("retention: unreadable response: %w", err) } return res, nil } // channels reads one app's channel ladder — and its pipeline envelope, when // the console carries one — through the console. func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiChannelsResult, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/channels")) if err != nil { return apiChannelsResult{}, err } if status != http.StatusOK { return apiChannelsResult{}, apiErrorFromBody(status, body) } var res struct { Channels []struct { Channel string `json:"channel"` Version string `json:"version"` PromotedAt string `json:"promoted_at"` Rung string `json:"rung"` Error string `json:"error"` Digests bool `json:"digests"` Straggler bool `json:"straggler"` } `json:"channels"` // PipelineEntry/PipelineChannels are two flat, top-level keys — NOT // nested under a "pipeline" object — deliberately distinct from the // full release.Pipeline object `carlos pipeline` reads under that // name (review fix round 1: the same JSON key meaning an object on // one door and a string on another was a trap for any future // consumer). Both are absent together for BOTH "no pipeline key at // all" (an older console) and "this app has no pipeline" (a // confirmed-legacy app) AND a pipeline read failure server-side — // this client has no need to tell those apart; see // apiChannelsResult's doc comment. PipelineEntry string `json:"pipeline_entry"` PipelineChannels []string `json:"pipeline_channels"` } if err := json.Unmarshal(body, &res); err != nil { return apiChannelsResult{}, fmt.Errorf("channels: unreadable response: %w", err) } out := apiChannelsResult{ Channels: make([]apiChannel, len(res.Channels)), PipelineEntry: res.PipelineEntry, PipelineChannels: res.PipelineChannels, } for i, ch := range res.Channels { out.Channels[i] = apiChannel{ Channel: ch.Channel, Version: ch.Version, PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error, Digests: ch.Digests, Straggler: ch.Straggler, } } return out, nil } type apiResignPlan struct { Channel string `json:"channel"` Version string `json:"version"` Digest string `json:"digest"` History []string `json:"history"` HistoryDigests []string `json:"history_digests"` Computed []bool `json:"computed"` } // resignPlan asks the console what `channels resign` would sign. Nothing is // written. func (s *apiSession) resignPlan(ctx context.Context, a apiAcct, app, channel string) (apiResignPlan, error) { status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"), map[string]string{"channel": channel}) if err != nil { return apiResignPlan{}, err } if status != http.StatusOK { return apiResignPlan{}, apiErrorFromBody(status, body) } var p apiResignPlan if err := json.Unmarshal(body, &p); err != nil { return apiResignPlan{}, fmt.Errorf("resign: unreadable response: %w", err) } if len(p.Computed) != len(p.History)+1 || len(p.HistoryDigests) != len(p.History) { return apiResignPlan{}, fmt.Errorf("resign: the console's plan is malformed") } return p, nil } // resignSign sends back the digests the operator confirmed; the console // signs them only if a plan made now is exactly that plan. func (s *apiSession) resignSign(ctx context.Context, a apiAcct, app, channel, digest string, hd []string) error { status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"), map[string]any{"channel": channel, "digest": digest, "history_digests": hd}) if err != nil { return err } if status == http.StatusOK { return nil } return apiErrorFromBody(status, body) } type apiDigestAccount struct { Account string `json:"account"` On bool `json:"on"` Since string `json:"since"` Error string `json:"error"` } type apiDigestChannel struct { Account string `json:"account"` App string `json:"app"` Channel string `json:"channel"` Version string `json:"version"` Straggler bool `json:"straggler"` } type apiPointerDigests struct { Accounts []apiDigestAccount `json:"accounts"` Channels []apiDigestChannel `json:"channels"` } // pointerDigests reads the pointer-digest report (verb ""), or turns the // marker on or off for the named accounts (every account when none) and // reads the report back. func (s *apiSession) pointerDigests(ctx context.Context, verb string, accounts []string) (apiPointerDigests, error) { var status int var body []byte var err error if verb == "" { status, body, err = s.getJSON(ctx, "/api/cli/system/pointer-digests") } else { status, body, err = s.postJSON(ctx, "/api/cli/system/pointer-digests", map[string]any{"on": verb == "on", "accounts": accounts}) } if err != nil { return apiPointerDigests{}, err } if status != http.StatusOK { return apiPointerDigests{}, apiErrorFromBody(status, body) } var rep apiPointerDigests if err := json.Unmarshal(body, &rep); err != nil { return apiPointerDigests{}, fmt.Errorf("pointer digests: unreadable response: %w", err) } return rep, nil } // --- version target, tags, releases (release versioning spec, 2026-08-08) // // The four read/write doors internal/console/apiversion.go opens, in the // same shape as channels/promote above: decode only the fields the CLI // prints, and let apiErrorFromBody speak for every non-2xx — the console's // own refusals ("already released as tag 0.5.0", "want MAJOR.MINOR.PATCH") // are better sentences than anything this side could re-derive. // apiVersionTarget is GET .../version-target's shape. An UNSET target is // Target == "" and a 200, not a 404: unset is a state, exactly as // release.GetTarget's own contract has it, and this client keeps that shape // rather than inventing a sentinel error for it. type apiVersionTarget struct { Target string `json:"target"` SetBy string `json:"set_by"` SetAt string `json:"set_at"` } func (s *apiSession) versionTarget(ctx context.Context, a apiAcct, app string) (apiVersionTarget, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/version-target")) if err != nil { return apiVersionTarget{}, err } if status != http.StatusOK { return apiVersionTarget{}, apiErrorFromBody(status, body) } var out apiVersionTarget if err := json.Unmarshal(body, &out); err != nil { return apiVersionTarget{}, fmt.Errorf("version target: unreadable response: %w", err) } return out, nil } // setVersionTarget writes the target and returns what the console stored — // its echo, not the argument sent, so a server that normalised the string is // the one the operator is shown. func (s *apiSession) setVersionTarget(ctx context.Context, a apiAcct, app, target string) (string, error) { in := struct { Target string `json:"target"` }{target} status, body, err := s.putJSON(ctx, apiPath(a, app, "/version-target"), in) if err != nil { return "", err } if status != http.StatusOK { return "", apiErrorFromBody(status, body) } var out struct { Target string `json:"target"` } if err := json.Unmarshal(body, &out); err != nil { return "", fmt.Errorf("version target: unreadable response: %w", err) } return out.Target, nil } // apiTag is one row of GET .../tags. Release is the ITERATION the tag was cut // from ("0.5.0-3"), Version the name it earned ("0.5.0") — the direction // matters, because every join in this file goes release -> tag. type apiTag struct { Version string `json:"version"` Release string `json:"release"` TaggedAt string `json:"tagged_at"` Actor string `json:"actor"` } func (s *apiSession) tags(ctx context.Context, a apiAcct, app string) ([]apiTag, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/tags")) if err != nil { return nil, err } if status != http.StatusOK { return nil, apiErrorFromBody(status, body) } var res struct { Tags []apiTag `json:"tags"` } if err := json.Unmarshal(body, &res); err != nil { return nil, fmt.Errorf("tags: unreadable response: %w", err) } return res.Tags, nil } // apiRelease is one row of GET .../releases: what `carlos releases` prints, // already joined against the tags server-side (handleAPIReleasesList), so // this client never issues a second call to fill the TAG column. type apiRelease struct { Version string `json:"version"` Kind string `json:"kind"` BuiltAt string `json:"built_at"` Label string `json:"label"` Tag string `json:"tag"` } func (s *apiSession) releases(ctx context.Context, a apiAcct, app string) ([]apiRelease, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/releases")) if err != nil { return nil, err } if status != http.StatusOK { return nil, apiErrorFromBody(status, body) } var res struct { Releases []apiRelease `json:"releases"` } if err := json.Unmarshal(body, &res); err != nil { return nil, fmt.Errorf("releases: unreadable response: %w", err) } return res.Releases, nil } // apiTrashResult is handleAPITrashApp's success shape: the confirmed cooloff // deadline. The SERVER computes it (it re-reads the marker's own DeletedAt // after writing — TrashApp is create-only, so an app already in the trash // keeps its ORIGINAL clock), so this client prints exactly what the server // confirmed rather than a locally-guessed window. type apiTrashResult struct { RestorableUntil string } // apiRestoreResult is handleAPIRestoreApp's success shape: the server's own // reminder that restoring resurrects no routes (spec §3), carried verbatim // so the signed-in and headless paths do not drift apart on wording by // accident — one is the console's copy, the other this binary's own. type apiRestoreResult struct { Note string } // trashApp asks the console to move app to the Trash (carlos apps delete, // signed-in mode). Mirrors promote/rollback's shape exactly: postJSON, a 200 // decodes, anything else is apiErrorFromBody's job — trash's 409 (production // on or unreadable) and 502 (trash window unconfirmed; retrying is safe, // since TrashApp is create-only and idempotent) both carry a real // {"error": ...} body, so the json-first decode surfaces them verbatim. func (s *apiSession) trashApp(ctx context.Context, a apiAcct, app string) (apiTrashResult, error) { status, body, err := s.postJSON(ctx, apiPath(a, app, "/delete"), struct{}{}) if err != nil { return apiTrashResult{}, err } if status != http.StatusOK { return apiTrashResult{}, apiErrorFromBody(status, body) } var res struct { RestorableUntil string `json:"restorable_until"` } if err := json.Unmarshal(body, &res); err != nil { return apiTrashResult{}, fmt.Errorf("apps delete: unreadable response: %w", err) } return apiTrashResult{RestorableUntil: res.RestorableUntil}, nil } // restoreApp asks the console to restore app from the Trash (carlos apps // restore, signed-in mode). It resolves through apiTrashedApp server-side, so // an app that is not (or no longer) in the trash gets the same // byte-identical 404 apiAccountApp's gate answers for an unknown account or // app — notFoundErr's generic membership text. That 404 is ALSO what a // headlessly-trashed app produces while this console's own row has not yet // materialized from the bucket (the KNOWN WINDOW the brief calls out), so // this is the one call in this file that widens a 404 rather than passing // notFoundErr through unchanged — naming the headless retry as a second way // through, not just "try again". func (s *apiSession) restoreApp(ctx context.Context, a apiAcct, app string) (apiRestoreResult, error) { status, body, err := s.postJSON(ctx, apiPath(a, app, "/restore"), struct{}{}) if err != nil { return apiRestoreResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return apiRestoreResult{}, fmt.Errorf("%w — if %s was trashed headlessly (bucket-direct, no console), "+ "this console's own record of it may not have caught up yet; try again shortly, or restore it "+ "headlessly instead (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR and --account-id)", notFoundErr(), app) } return apiRestoreResult{}, apiErrorFromBody(status, body) } var res struct { Note string `json:"note"` } if err := json.Unmarshal(body, &res); err != nil { return apiRestoreResult{}, fmt.Errorf("apps restore: unreadable response: %w", err) } return apiRestoreResult{Note: res.Note}, nil } // ---- temporary apps (temporary static sites spec §3, §7) -------------------- // apiCreateTemporaryResult is handleAPICreateTemporaryApp's success shape, // with expires_at parsed. It is only returned once the expiry is confirmed. type apiCreateTemporaryResult struct { Account string App string URL string ExpiresAt time.Time // Region is the region echo /new-temporary shares with /new, in /new's // own shape so printAppRegion reads it unchanged. Region apiCreateAppResult } // apiAppLife is one row of GET /api/cli/apps/{account}/list. type apiAppLife struct { App string `json:"app"` DeletedAt *time.Time `json:"deleted_at"` ExpiresAt *time.Time `json:"expires_at"` PurgeAfter *time.Time `json:"purge_after"` } var ( errTemporaryUnavailable = errors.New("this console does not offer temporary apps, or the account was not found (it answered 404). Nothing was created") // C11.16 errTemporaryNameTaken = errors.New("that name is taken") // C11.17 (sentinel only — see sentinelError) errTemporaryNotConfirmed = errors.New("the console did not confirm an expiry") // sentinel only — see sentinelError errListUnavailable = errors.New("this console does not offer carlos apps list yet (it answered 404). The console needs upgrading first") // C11.18 ) // sentinelError pairs a message meant for a person with a sentinel meant for // errors.Is: Error() returns ONLY msg, so a sentinel's own generic text // (errTemporaryNameTaken's "that name is taken", errTemporaryNotConfirmed's // "the console did not confirm an expiry") never leaks into what actually // gets printed by being appended after an approved string or the console's // own 409 reason (review round 1, finding 3). errors.Is still works: it // unwraps to the sentinel and compares that. type sentinelError struct { msg string sentinel error } func (e *sentinelError) Error() string { return e.msg } func (e *sentinelError) Unwrap() error { return e.sentinel } // createTemporaryApp is POST /new-temporary. A 404 is an older console (or a // switched-off one, or a wrong account) and nothing was created: the caller // stops there (spec §3, astra 2.7). A 409 with code name_taken is // errTemporaryNameTaken, which a generated name retries; what gets PRINTED // for it is the console's own reason (apiErrorFromBody), existing console // copy already reviewed in Task 5 — "that name is reserved", "a route in // this account already serves …", and so on all say more than a flat "that // name is taken" would (review round 1, finding 4). A 200 without a usable // expires_at is a console that did not make the app temporary, and the // caller must not ship. // // region and timezone are /new's optional fields, sent the same way. func (s *apiSession) createTemporaryApp(ctx context.Context, a apiAcct, app string, ttl time.Duration, region, timezone string) (apiCreateTemporaryResult, error) { in := struct { Name string `json:"name"` TTLSeconds int64 `json:"ttl_seconds"` Region string `json:"region,omitempty"` Timezone string `json:"timezone,omitempty"` }{app, int64(ttl / time.Second), region, timezone} status, body, err := s.postJSON(ctx, apiPath(a, "new-temporary", ""), in) if err != nil { return apiCreateTemporaryResult{}, err } switch status { case http.StatusOK: case http.StatusNotFound: return apiCreateTemporaryResult{}, errTemporaryUnavailable case http.StatusConflict: var e struct { Code string `json:"code"` } if json.Unmarshal(body, &e) == nil && e.Code == "name_taken" { return apiCreateTemporaryResult{}, &sentinelError{msg: apiErrorFromBody(status, body).Error(), sentinel: errTemporaryNameTaken} } return apiCreateTemporaryResult{}, apiErrorFromBody(status, body) default: return apiCreateTemporaryResult{}, apiErrorFromBody(status, body) } var res struct { Account string `json:"account"` App string `json:"app"` URL string `json:"url"` ExpiresAt string `json:"expires_at"` } if err := json.Unmarshal(body, &res); err != nil { return apiCreateTemporaryResult{}, fmt.Errorf("deploy: unreadable response from the console: %w", err) } var reg apiCreateAppResult _ = json.Unmarshal(body, ®) // the same body; it just parsed if res.App == "" { res.App = app } exp, perr := time.Parse(time.RFC3339, res.ExpiresAt) if perr != nil || exp.Unix() <= 0 { return apiCreateTemporaryResult{App: res.App}, &sentinelError{ msg: fmt.Sprintf("the console created %s but did not confirm it is temporary, so nothing was shipped. "+ "If you do not want it, delete it with: carlos apps delete %s", res.App, res.App), // C11.19 sentinel: errTemporaryNotConfirmed, } } return apiCreateTemporaryResult{Account: res.Account, App: res.App, URL: res.URL, ExpiresAt: exp, Region: reg}, nil } // appTTL is POST {app}/ttl: the new expiry, as the console set it. func (s *apiSession) appTTL(ctx context.Context, a apiAcct, app string, ttl time.Duration) (time.Time, error) { in := struct { TTLSeconds int64 `json:"ttl_seconds"` }{int64(ttl / time.Second)} status, body, err := s.postJSON(ctx, apiPath(a, app, "/ttl"), in) if err != nil { return time.Time{}, err } if status != http.StatusOK { return time.Time{}, apiErrorFromBody(status, body) } var res struct { ExpiresAt string `json:"expires_at"` } if err := json.Unmarshal(body, &res); err != nil { return time.Time{}, fmt.Errorf("apps ttl: unreadable response: %w", err) } return time.Parse(time.RFC3339, res.ExpiresAt) } // appKeep is POST {app}/keep. func (s *apiSession) appKeep(ctx context.Context, a apiAcct, app string) error { status, body, err := s.postJSON(ctx, apiPath(a, app, "/keep"), struct{}{}) if err != nil { return err } if status != http.StatusOK { return apiErrorFromBody(status, body) } return nil } // appList is GET /api/cli/apps/{account}/list. func (s *apiSession) appList(ctx context.Context, a apiAcct) ([]apiAppLife, error) { status, body, err := s.getJSON(ctx, apiPath(a, "list", "")) if err != nil { return nil, err } if status == http.StatusNotFound { return nil, errListUnavailable } if status != http.StatusOK { return nil, apiErrorFromBody(status, body) } var out []apiAppLife if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("apps list: unreadable response: %w", err) } return out, nil } // ---- object storage (issue #55) --------------------------------------------- // // apiStoreReply mirrors (*console.Server).storeReply's JSON exactly // (internal/console/apistore.go): app, this DEPLOYMENT's backend ("" // unconfigured), the three record blocks (declared/grant/provisioned, // null-or-present — a client reads them rather than testing for absence), // the env names this store delivers, whether a rotation is only half // finished, and which delivered variables have not reached the app's config // yet. // // The three block types are redeclared here rather than imported from // internal/objstore: this package (main, the CLI binary) does not otherwise // import that package, and pulling it in for three struct shapes this client // only ever decodes (never constructs server-side logic from) would be // coupling this binary to the console's storage internals for no reason a // wire client has. Field names and JSON tags are kept byte-for-byte // identical to objstore.Declared/GrantBlock/Provisioned so a decode here can // never silently drop a field the server sends. type apiStoreDeclared struct { EnvPrefix string `json:"env_prefix"` At time.Time `json:"at"` By string `json:"by"` } type apiStoreGrantBlock struct { Backend string `json:"backend"` Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish) At time.Time `json:"at"` By string `json:"by"` Source string `json:"source"` } type apiStoreProvisioned struct { Bucket string `json:"bucket"` Region string `json:"region"` Endpoint string `json:"endpoint,omitempty"` IAMUser string `json:"iam_user"` UserARN string `json:"user_arn"` KeyID string `json:"key_id"` PrevKeyID string `json:"prev_key_id,omitempty"` At time.Time `json:"at"` } type apiStoreReply struct { App string `json:"app"` // Environment is the config environment this store belongs to, "" for the // app's own. Always sent by a console that knows about environment // stores; absent from an older one's reply, which decodes as "" — the // only store such a console has. Environment string `json:"environment"` // Environments names every environment this app has a store for, and is // sent ONLY on a reply about the app's own store. A reply aimed at one // environment answers about that environment and lists nothing. Environments []string `json:"environments,omitempty"` Backend string `json:"backend"` Declared *apiStoreDeclared `json:"declared"` Grant *apiStoreGrantBlock `json:"grant"` Provisioned *apiStoreProvisioned `json:"provisioned"` EnvNames []string `json:"env_names"` PendingFinish bool `json:"pending_finish"` MissingVars []string `json:"missing_vars"` // Scanning is nil on every store that never opted in — which is all of // them by default — so an older console that does not send it renders // exactly as it always did. Scanning *apiScanBlock `json:"scanning,omitempty"` Entitlement *objstore.Entitlement `json:"entitlement"` // Size is the bucket's size from the console's daily storage sweep, nil // before a sweep has measured it (and from an older console). Size *apiStoreSize `json:"size"` } // apiStoreSize mirrors the console's storeSizeReply (internal/console/apistore.go). type apiStoreSize struct { AsOf string `json:"as_of"` StoreBytes int64 `json:"store_bytes"` Unswept bool `json:"unswept,omitempty"` BucketBytes int64 `json:"bucket_bytes"` } const storeProvisionHTTPTimeout = 75 * time.Second type apiStoreAccountReply struct { Account string `json:"account"` Entitlement *objstore.Entitlement `json:"entitlement"` } type apiStoreApprovalsReply struct { Applications []*objstore.Entitlement `json:"applications"` } func decodeStoreAccountReply(action string, status int, body []byte, err error) (apiStoreAccountReply, error) { if err != nil { return apiStoreAccountReply{}, err } if status != http.StatusOK { return apiStoreAccountReply{}, storeOperatorError(status, body) } var out apiStoreAccountReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreAccountReply{}, fmt.Errorf("store %s: unreadable response: %w", action, err) } return out, nil } // ---- deployment-wide system settings (system.go) ---------------------- // apiSystemAntivirusReply reports WIRING and POLICY as separate fields on // purpose: an engine can be wired with scanning off, and scanning can be on // with the engine unreachable, and an operator debugging "nothing is scanning" // needs to know which half is missing. type apiSystemAntivirusReply struct { Wired bool `json:"wired"` Enabled bool `json:"enabled"` QueueAppsWithWork int `json:"queue_apps_with_work"` Engine string `json:"engine,omitempty"` // the engine the RECORD names At string `json:"at,omitempty"` By string `json:"by,omitempty"` // EngineWired is what the wired engine calls itself, which can differ from // the record's name — that difference is engine drift, and it is why the // two are separate fields rather than one. EngineWired string `json:"engine_wired,omitempty"` EngineError string `json:"engine_error,omitempty"` RecordError string `json:"record_error,omitempty"` Signature string `json:"signature,omitempty"` MaxSignatureAgeSeconds int64 `json:"max_signature_age_seconds,omitempty"` } func (s *apiSession) systemAntivirusStatus(ctx context.Context) (apiSystemAntivirusReply, error) { status, body, err := s.getJSON(ctx, "/api/cli/system/antivirus") if err != nil { return apiSystemAntivirusReply{}, err } if status != http.StatusOK { return apiSystemAntivirusReply{}, apiErrorFromBody(status, body) } var out apiSystemAntivirusReply if err := json.Unmarshal(body, &out); err != nil { return apiSystemAntivirusReply{}, fmt.Errorf("system antivirus status: unreadable response: %w", err) } return out, nil } func storeOperatorError(status int, body []byte) error { var e struct { Error string `json:"error"` } if json.Unmarshal(body, &e) == nil && e.Error != "" { return errors.New(e.Error) } if status == http.StatusNotFound { return errors.New("not found: check the account sqid and that your login has object storage operator access") } return fmt.Errorf("unexpected response (status %d)", status) } func (s *apiSession) storeApply(ctx context.Context, a apiAcct, note string) (apiStoreAccountReply, error) { status, body, err := s.postJSON(ctx, apiAccountStorePath(a, "apply"), struct { Note string `json:"note,omitempty"` }{note}) if err != nil { return apiStoreAccountReply{}, err } if status != http.StatusOK { return apiStoreAccountReply{}, apiErrorFromBody(status, body) } var out apiStoreAccountReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreAccountReply{}, fmt.Errorf("store apply: unreadable response: %w", err) } return out, nil } // systemAntivirusSet sends enabled explicitly — never omitted. The door // refuses a body that says neither, because there is no safe default for a // switch that decides whether tenant bytes get read. func (s *apiSession) systemAntivirusSet(ctx context.Context, on bool) (apiSystemAntivirusReply, error) { in := struct { Enabled bool `json:"enabled"` }{on} status, body, err := s.postJSON(ctx, "/api/cli/system/antivirus", in) if err != nil { return apiSystemAntivirusReply{}, err } if status != http.StatusOK { return apiSystemAntivirusReply{}, apiErrorFromBody(status, body) } var out apiSystemAntivirusReply if err := json.Unmarshal(body, &out); err != nil { return apiSystemAntivirusReply{}, fmt.Errorf("system antivirus: unreadable response: %w", err) } return out, nil } func (s *apiSession) storeApprove(ctx context.Context, account string) (apiStoreAccountReply, error) { status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/store/approve", struct{}{}) return decodeStoreAccountReply("approve", status, body, err) } func (s *apiSession) storeDecline(ctx context.Context, account, reason string) (apiStoreAccountReply, error) { status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/store/decline", struct { Reason string `json:"reason"` }{reason}) return decodeStoreAccountReply("decline", status, body, err) } func (s *apiSession) storeApprovals(ctx context.Context) (apiStoreApprovalsReply, error) { status, body, err := s.getJSON(ctx, "/api/cli/store/approvals") if err != nil { return apiStoreApprovalsReply{}, err } if status != http.StatusOK { return apiStoreApprovalsReply{}, storeOperatorError(status, body) } var out apiStoreApprovalsReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreApprovalsReply{}, fmt.Errorf("store approvals: unreadable response: %w", err) } return out, nil } type apiWebhookRotateReply struct { Rotated int `json:"rotated"` Remaining int `json:"remaining"` } func (s *apiSession) systemWebhookRotate(ctx context.Context) (apiWebhookRotateReply, error) { status, body, err := s.postJSON(ctx, "/api/cli/system/antivirus/rotate-webhook-key", struct{}{}) if err != nil { return apiWebhookRotateReply{}, err } if status != http.StatusOK { return apiWebhookRotateReply{}, apiErrorFromBody(status, body) } var out apiWebhookRotateReply if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("rotate webhook key: unreadable response: %w", err) } return out, nil } // ---- store scanning (scan.go) ---------------------------------------- // The scan reply's blocks, mirrored here rather than imported from // internal/objstore, the way apiStoreProvisioned mirrors its own: this binary // decodes what the console sent, and a shared struct would make a server-side // field rename a client compile error instead of a wire question. type apiScanRequested struct { At time.Time `json:"at"` By string `json:"by"` Encrypted bool `json:"encrypted,omitempty"` } type apiScanEnabled struct { Engine string `json:"engine"` At time.Time `json:"at"` By string `json:"by"` } type apiScanEnforce struct { Key string `json:"key"` Value string `json:"value"` } type apiScanBlock struct { Requested *apiScanRequested `json:"requested,omitempty"` Enabled *apiScanEnabled `json:"enabled,omitempty"` Enforce *apiScanEnforce `json:"enforce,omitempty"` } // apiSweep is one reconciliation pass. Scanned and Unscanned are NOT // complements — skipped, denied and failed objects were never judged — which // is why the status line has to name two numbers. type apiSweep struct { StartedAt time.Time `json:"started_at"` At time.Time `json:"at"` Engine string `json:"engine"` Signature string `json:"signature,omitempty"` Objects int64 `json:"objects"` Scanned int64 `json:"scanned"` Infected int64 `json:"infected"` Skipped int64 `json:"skipped"` Denied int64 `json:"denied"` Failed int64 `json:"failed"` Unscanned int64 `json:"unscanned"` // Fresh and Reused split Scanned by WHEN the verdict was reached. A clean // tag is kept while the object's bytes do not change, so a rollup can be // built almost entirely from old judgements against an older database. Fresh int64 `json:"fresh"` Reused int64 `json:"reused"` Bytes int64 `json:"bytes"` After string `json:"after,omitempty"` } type apiScanCoverage struct { NotApplicable bool `json:"not_applicable,omitempty"` Current *apiSweep `json:"current,omitempty"` LastComplete *apiSweep `json:"last_complete,omitempty"` } type apiScanReply struct { App string `json:"app"` Backend string `json:"backend"` Engine string `json:"engine"` EngineError string `json:"engine_error,omitempty"` Signature string `json:"signature,omitempty"` MaxSignatureAgeSeconds int64 `json:"max_signature_age_seconds"` Scanning *apiScanBlock `json:"scanning"` Coverage *apiScanCoverage `json:"coverage"` QueueDepth int `json:"queue_depth"` } // scanCall is every scan door's round trip: they all answer the same shape, so // there is one decoder and one place a reply could be misread. func (s *apiSession) scanCall(ctx context.Context, verb, path string, in any) (apiScanReply, error) { var status int var body []byte var err error if in == nil { status, body, err = s.getJSON(ctx, path) } else { status, body, err = s.postJSON(ctx, path, in) } if err != nil { return apiScanReply{}, err } if status != http.StatusOK { return apiScanReply{}, apiErrorFromBody(status, body) } var out apiScanReply if err := json.Unmarshal(body, &out); err != nil { return apiScanReply{}, fmt.Errorf("%s: unreadable response: %w", verb, err) } return out, nil } // scanStatus reads what is claimed about this store's objects. func (s *apiSession) scanStatus(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { return s.scanCall(ctx, "store scan status", apiPath(a, app, "/store/scan"), nil) } // scanRequest records the member's request AND their attestation. encrypted is // sent explicitly, never omitted: the server refuses a body without it, // because there is no safe default for "can a scanner read these bytes". func (s *apiSession) scanRequest(ctx context.Context, a apiAcct, app string, encrypted bool) (apiScanReply, error) { in := struct { Encrypted bool `json:"encrypted"` }{encrypted} return s.scanCall(ctx, "store scan request", apiPath(a, app, "/store/scan"), in) } func (s *apiSession) scanGrant(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { return s.scanCall(ctx, "store scan grant", apiPath(a, app, "/store/scan/grant"), struct{}{}) } func (s *apiSession) scanRevoke(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { return s.scanCall(ctx, "store scan revoke", apiPath(a, app, "/store/scan/revoke"), struct{}{}) } func (s *apiSession) scanSweep(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { return s.scanCall(ctx, "store scan sweep", apiPath(a, app, "/store/scan/sweep"), struct{}{}) } func (s *apiSession) scanEnforce(ctx context.Context, a apiAcct, app string, on bool) (apiScanReply, error) { in := struct { On bool `json:"on"` }{on} return s.scanCall(ctx, "store scan enforce", apiPath(a, app, "/store/scan/enforce"), in) } func (s *apiSession) scanRotateCallbackKey(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { return s.scanCall(ctx, "store scan rotate-webhook-key", apiPath(a, app, "/store/scan/callback-key/rotate"), struct{}{}) } // storeCreate asks the console to declare (or re-declare) a store for app, // under envPrefix ("" takes the console's own default — objstore. // DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a // 200 decodes, anything else is apiErrorFromBody's job. // // The request omits env_prefix entirely (json:",omitempty") when envPrefix // is "", so a bare `carlos store create --app x` sends the literal `{}` body // handleAPIStoreCreate documents as its own default case — never a // zero-length body, which that door's readJSON would refuse as malformed. func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, env, envPrefix string) (apiStoreReply, error) { in := struct { EnvPrefix string `json:"env_prefix,omitempty"` }{envPrefix} status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail("/store", env)), in) if err != nil { return apiStoreReply{}, err } if status != http.StatusOK { return apiStoreReply{}, apiErrorFromBody(status, body) } var out apiStoreReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreReply{}, fmt.Errorf("store create: unreadable response: %w", err) } return out, nil } // storeProvision runs the account-approved provisioning walk. The server may // spend its full 60-second storeGrantTimeout on backend work, so this one JSON // door gets a scoped client with a slightly longer deadline; mutating the // session's ordinary 30-second client would silently widen every later call. func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app, env string) (apiStoreReply, error) { clone := *s client := *s.c httpc := *s.c.httpc httpc.Timeout = storeProvisionHTTPTimeout client.httpc = &httpc clone.c = &client status, body, err := clone.postJSON(ctx, apiPath(a, app, storeEnvTail("/store/provision", env)), struct{}{}) if err != nil { return apiStoreReply{}, err } if status != http.StatusOK { return apiStoreReply{}, apiErrorFromBody(status, body) } var out apiStoreReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreReply{}, fmt.Errorf("store create: unreadable provision response: %w", err) } return out, nil } // storeGet asks the console for app's store record (`carlos store status`). // A 404 here is a real diagnosis, not the byte-identical membership 404 // apiAccountApp gives — handleAPIStoreGet answers it with an actual // {"error": "no store is declared..."} body once membership is already // proven, so apiErrorFromBody's json-first decode surfaces it verbatim like // any other server diagnosis; no special case is needed here. func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app, env string) (apiStoreReply, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, storeEnvTail("/store", env))) if err != nil { return apiStoreReply{}, err } if status != http.StatusOK { return apiStoreReply{}, apiErrorFromBody(status, body) } var out apiStoreReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreReply{}, fmt.Errorf("store status: unreadable response: %w", err) } return out, nil } // storeGrant asks the console to provision app's declared store and deliver // its credentials (`carlos store grant`) — the one call in this file that // spends money in the deployment's own cloud account, gated server-side on // CARLOS_STORE_OPERATORS (a 403 for anyone else, member or not, surfaced // verbatim by apiErrorFromBody like every other server diagnosis here: this // client never pattern-matches the message, only prints it). // // bucket == "" mints a fresh bucket; a non-empty bucket asks the walk to // ADOPT that existing one instead. Same omitempty shape as storeCreate's // prefix: a bare `--app x` with no `--bucket` sends `{}`. func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, env, bucket string) (apiStoreReply, error) { in := struct { Bucket string `json:"bucket,omitempty"` }{bucket} status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail("/store/grant", env)), in) if err != nil { return apiStoreReply{}, err } if status != http.StatusOK { return apiStoreReply{}, apiErrorFromBody(status, body) } var out apiStoreReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreReply{}, fmt.Errorf("store grant: unreadable response: %w", err) } return out, nil } // storeRotate asks the console to cycle app's store credential // (`carlos store rotate`), or — with finish — to delete the previous key and // close the two-key window a rotation leaves open // (`carlos store rotate --finish`). Two different doors // (POST .../store/rotate vs POST .../store/rotate/finish), chosen here // rather than by a body field, matching the server's own route table // (internal/console/authcli.go) and restart's "no body, the whole request is // which door you POST to" shape (handleAPIStoreRotate's doc comment). func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app, env string, finish bool) (apiStoreReply, error) { tail := "/store/rotate" if finish { tail = "/store/rotate/finish" } status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail(tail, env)), struct{}{}) if err != nil { return apiStoreReply{}, err } if status != http.StatusOK { return apiStoreReply{}, apiErrorFromBody(status, body) } var out apiStoreReply if err := json.Unmarshal(body, &out); err != nil { return apiStoreReply{}, fmt.Errorf("store rotate: unreadable response: %w", err) } return out, nil } // ---- the STUN/TURN relay (relay.go's member and operator verbs) ----------- // // The reply shapes mirror internal/console/apirelay.go's JSON exactly, and // they are redeclared here rather than imported for apiStoreReply's own // reason: this binary decodes what the console sent, and sharing a struct // would turn a server-side field rename into a client compile error instead // of the wire question it actually is. // // NOTHING KEY-SHAPED CROSSES THESE. The only identity in the reply is the // username suffix the console issued per region — account, app, incarnation // and kid — which is the same public half the app's own CARLOS_RELAY carries // and the thing a member matches a failing call against. The key exists only // sealed in the app's config bundle, and this CLI never sees it. type apiRelayRoute struct { Host string `json:"host"` Shape string `json:"shape"` // unit | socket-exec | tcp-exec-file | no-delivery | unknown Delivery string `json:"delivery"` AckedAt string `json:"acked_at,omitempty"` Cutover bool `json:"cutover"` // ConfigAckAt and ConfigAckShape echo config sync's own per-route // acknowledgement (spec §5, internal/appconfig/configack.go) when one // names the identities currently issued — distinct from AckedAt/Cutover // above, which are a MEMBER's own `carlos relay adopted`. Neither this // CLI's printRelayStatus nor anything else here reads these fields // today: Delivery already carries the full sentence either state earns // (see internal/console/apirelay.go's relayMatchingAck); they exist so // a future consumer of the wire reply is not left inventing the ack's // own timestamp out of Delivery's prose. ConfigAckAt string `json:"config_acked_at,omitempty"` ConfigAckShape string `json:"config_ack_shape,omitempty"` } type apiRelayRegion struct { Region string `json:"region"` Box string `json:"box"` HeartbeatAge string `json:"heartbeat_age"` Current bool `json:"current"` LeaseLive bool `json:"lease_live"` Listeners []string `json:"listeners"` Kids []string `json:"kids"` // kid -> RFC3339 retire_at, for the loaded masters that carry one. Retiring map[string]string `json:"retiring,omitempty"` Stale bool `json:"stale"` // The relay's own last two errors: why it stopped reading. Printed on a // stale row, because the state without the reason is not actionable. MastersError string `json:"masters_error,omitempty"` AuthorityError string `json:"authority_error,omitempty"` } type apiRelayReply struct { App string `json:"app"` Enabled bool `json:"enabled"` Incarnation string `json:"incarnation,omitempty"` Regions []string `json:"regions,omitempty"` Op int64 `json:"op"` Phase string `json:"phase,omitempty"` Issued map[string]string `json:"issued,omitempty"` Routes []apiRelayRoute `json:"routes,omitempty"` RegionStatus []apiRelayRegion `json:"region_status,omitempty"` DisablePending []string `json:"disable_pending,omitempty"` } // apiRelayRotateReply is rotate's own answer: a region and a kid, not an // app's record. type apiRelayRotateReply struct { Region string `json:"region"` Kid string `json:"kid"` Apps int `json:"apps"` RetireNoEarlierThan string `json:"retire_no_earlier_than"` } // relayCall is the four app-scoped verbs' one round trip — they all answer // with the same record shape, so writing the decode four times would only // create four places for it to drift. in == nil is the GET. func (s *apiSession) relayCall(ctx context.Context, verb, path string, in any) (apiRelayReply, error) { var ( status int body []byte err error ) if in == nil { status, body, err = s.getJSON(ctx, path) } else { status, body, err = s.postJSON(ctx, path, in) } if err != nil { return apiRelayReply{}, err } if status != http.StatusOK { return apiRelayReply{}, apiErrorFromBody(status, body) } var out apiRelayReply if err := json.Unmarshal(body, &out); err != nil { return apiRelayReply{}, fmt.Errorf("%s: unreadable response: %w", verb, err) } return out, nil } // relayEnable turns the relay on for app, in regions or — with none named — // every region the deployment arms, in the console's own order. The list is // omitted entirely when empty, so a bare `carlos relay enable` sends the // literal `{}` the door documents as its default rather than a null the // server would have to guess at. func (s *apiSession) relayEnable(ctx context.Context, a apiAcct, app string, regions []string) (apiRelayReply, error) { in := struct { Regions []string `json:"regions,omitempty"` }{regions} return s.relayCall(ctx, "relay enable", apiPath(a, app, "/relay"), in) } // relayGet is `carlos relay status`. Its 404 is a real diagnosis ("the relay // has never been enabled..."), not the membership gate's byte-identical one, // and apiErrorFromBody surfaces it verbatim like any other server sentence. func (s *apiSession) relayGet(ctx context.Context, a apiAcct, app string) (apiRelayReply, error) { return s.relayCall(ctx, "relay status", apiPath(a, app, "/relay"), nil) } func (s *apiSession) relayDisable(ctx context.Context, a apiAcct, app string) (apiRelayReply, error) { return s.relayCall(ctx, "relay disable", apiPath(a, app, "/relay/disable"), struct{}{}) } // relayAdopted tells the console that host — a TCP-address route this // platform cannot restart — now runs the identities it last issued. func (s *apiSession) relayAdopted(ctx context.Context, a apiAcct, app, host string) (apiRelayReply, error) { in := struct { Host string `json:"host"` }{host} return s.relayCall(ctx, "relay adopted", apiPath(a, app, "/relay/adopted"), in) } // relayRotate is the operator's verb, and the ONE call in this file with no // account or app in its path: it moves a REGION's issuer pointer. The path is // therefore a literal, not an apiPath — there is no account segment for // apiPath to escape into it. A caller who is not on CARLOS_RELAY_OPERATORS // meets the mux's own 404 rather than a 403, so this client cannot tell // "not allowed" from "no such console door", and does not try to. func (s *apiSession) relayRotate(ctx context.Context, region string) (apiRelayRotateReply, error) { in := struct { Region string `json:"region"` }{region} status, body, err := s.postJSON(ctx, "/api/cli/relay/rotate", in) if err != nil { return apiRelayRotateReply{}, err } if status != http.StatusOK { // A 404 with no JSON body is either gate: a console too old to have // this door, or apiRelayOperator refusing an address that is not on // CARLOS_RELAY_OPERATORS — deliberately indistinguishable on the // wire, so the message states both possibilities rather than // notFoundErr()'s advice to check --account and --app, two flags this // verb does not have. Saying both leaks nothing: a caller who is on // the list finds out by the call succeeding. if status == http.StatusNotFound && !hasJSONError(body) { return apiRelayRotateReply{}, errors.New( "no such door on this console, or your address is not on CARLOS_RELAY_OPERATORS") } return apiRelayRotateReply{}, apiErrorFromBody(status, body) } var out apiRelayRotateReply if err := json.Unmarshal(body, &out); err != nil { return apiRelayRotateReply{}, fmt.Errorf("relay rotate: unreadable response: %w", err) } return out, nil } // ledgerAppend asks the console to CAS-append one entry to name's chain, // stamping writer from the caller's own authenticated identity server-side // (never client-supplied — apiledger.go). A 503 here is contention (too many // writers accruing the same chain at once, ledger.ErrBusy) — retry-worthy, // 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, a apiAcct, app, name string, payload json.RawMessage) (ledger.Appended, error) { var out ledger.Appended 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 } if status != http.StatusOK { return out, apiErrorFromBody(status, body) } return out, json.Unmarshal(body, &out) } // ledgerHead reads name's HEAD.json through the console. Its 404 is answered // with a dedicated message rather than apiErrorFromBody's generic membership // 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, a apiAcct, app, name string) (ledger.Head, error) { var out ledger.Head status, body, err := s.getJSON(ctx, apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/head")) if err != nil { return out, err } if status == http.StatusNotFound { return out, fmt.Errorf("no %q ledger yet for this app", name) } if status != http.StatusOK { return out, apiErrorFromBody(status, body) } return out, json.Unmarshal(body, &out) } // ledgerList reads an app's ledger names and its publish manifest through // the console. The raw body is handed back rather than a decoded struct: // {"manifest": Manifest|null, "ledgers": [...]} is the caller's shape to // 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, a apiAcct, app string) ([]byte, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/ledgers")) if err != nil { return nil, err } if status != http.StatusOK { return nil, apiErrorFromBody(status, body) } return body, nil } // ledgerPublish adds (or, with public:false, removes) named ledgers from the // set an app serves publicly. The console applies it as a read-modify-write // on the stored manifest, so the request says what to CHANGE and only the // response says what is public NOW — a set that may well name ledgers this // call never mentioned. That resulting manifest is the return value: without // 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, 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 } if status != http.StatusOK { return ledger.Manifest{}, apiErrorFromBody(status, body) } var out ledger.Manifest if err := json.Unmarshal(body, &out); err != nil { return ledger.Manifest{}, fmt.Errorf("ledger publish: unreadable response: %w", err) } return out, nil } // ledgerBlob posts raw bytes (not JSON) to the ledger's blobs door — a // content-addressed, create-only upload (ledger.PutBlob; a re-post of the // same bytes is a no-op, matching ship's artifact semantics). It follows // getJSON's own request plumbing exactly — same base URL field, same bearer // 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, a apiAcct, app, name string, body []byte) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.c.base+apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/blobs"), bytes.NewReader(body)) if err != nil { return "", err } if s.c.token != "" { req.Header.Set("Authorization", "Bearer "+s.c.token) } res, err := s.c.httpc.Do(req) if err != nil { return "", err } defer res.Body.Close() if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { return "", offerConsoleMoved(merr) } respBody, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return "", err } if res.StatusCode != http.StatusOK { return "", apiErrorFromBody(res.StatusCode, respBody) } var out struct { SHA256 string `json:"sha256"` } if err := json.Unmarshal(respBody, &out); err != nil { return "", fmt.Errorf("ledger blob: unreadable response: %w", err) } return out.SHA256, nil } // putJSON is sendJSON pinned to PUT: the config door's write route is PUT // (`PUT /api/cli/apps/{account}/{app}/config`, handleAPIConfigPut) — "here is // the state this bundle should carry", not an append. func (s *apiSession) putJSON(ctx context.Context, path string, in any) (int, []byte, error) { return s.sendJSON(ctx, http.MethodPut, path, in) } // patchJSON is sendJSON pinned to PATCH: the instance upstream-repoint door // is PATCH (`PATCH /api/cli/apps/{account}/{app}/instances/{host}`, // handleAPIInstanceUpdate) — "here is what changed about this record", not // the whole state (PUT) or a new one (POST). func (s *apiSession) patchJSON(ctx context.Context, path string, in any) (int, []byte, error) { return s.sendJSON(ctx, http.MethodPatch, path, in) } // apiConfigVar is one row of a config bundle read back through the console — // GET /api/cli/apps/{account}/{app}/config's "vars" entries. Value is only // ever populated for Kind=="env": a secret row's value is structurally // absent on the wire (the console couldn't read it if it wanted to), so // Value simply decodes to "" for those rows, same as an env var's own empty // string would. type apiConfigVar struct { Key, Kind, Value string } // apiConfig is one bundle's config read back through the console, mirroring // the GET door's response shape exactly. ExecDelivery and SecretsPubkey // decode to "" when the wire response omits them — exec_delivery for a // named-environment read (app-level only) or an unset default bundle; // secrets_pubkey when the console has no CARLOS_SECRETS_PUBKEY of its own // (see secretsPubKey's API-mode resolution order in appconfig_cli.go). // Environments is always present (possibly empty). type apiConfig struct { Bundle string ExecDelivery string SecretsPubkey string // SecretsAccount is the account sqid secrets for this app are sealed // for; "" from a console that predates bound envelopes (see // handleAPIConfigGet). SecretsAccount string Environments []string Vars []apiConfigVar } // configGet reads one app's config bundle through the console — the default // bundle when environment=="", the named environment's own bundle // otherwise (matching loadBundle's own environment=="" convention in // appconfig_cli.go). The query parameter is sent ONLY when environment is // non-empty: the server's own zero value for "no ?environment=" is an // absent parameter, not one carrying "". func (s *apiSession) configGet(ctx context.Context, a apiAcct, app, environment string) (apiConfig, error) { // The one caller-side query append in this package: apiPath's own // invariant ("no tail carries a query string") still holds — the tail // here is a bare "/config" — but apiPath itself may have added // ?exact=sqid for an --account-id call, so this append has to pick its // separator instead of assuming "?". path := apiPath(a, app, "/config") if environment != "" { sep := "?" if strings.Contains(path, "?") { sep = "&" } path += sep + "environment=" + url.QueryEscape(environment) } status, body, err := s.getJSON(ctx, path) if err != nil { return apiConfig{}, err } if status != http.StatusOK { return apiConfig{}, apiErrorFromBody(status, body) } var res struct { Bundle string `json:"bundle"` ExecDelivery string `json:"exec_delivery"` SecretsPubkey string `json:"secrets_pubkey"` SecretsAccount string `json:"secrets_account"` Environments []string `json:"environments"` Vars []struct { Key string `json:"key"` Kind string `json:"kind"` Value string `json:"value"` } `json:"vars"` } if err := json.Unmarshal(body, &res); err != nil { return apiConfig{}, fmt.Errorf("config: unreadable response: %w", err) } out := apiConfig{ Bundle: res.Bundle, ExecDelivery: res.ExecDelivery, SecretsPubkey: res.SecretsPubkey, SecretsAccount: res.SecretsAccount, Environments: res.Environments, Vars: make([]apiConfigVar, len(res.Vars)), } for i, v := range res.Vars { out.Vars[i] = apiConfigVar{Key: v.Key, Kind: v.Kind, Value: v.Value} } return out, nil } // apiConfigApply is one config write through the console's config door, // mirroring PUT /api/cli/apps/{account}/{app}/config's body exactly. Set's // values marshal through appconfig.Var's OWN json tags // (`{"kind","value","envelope"}`, value/envelope omitempty) — the wire // shape the spec pins for both env and secret vars, so no separate wire // struct is needed here. ExecDelivery is a pointer because "absent" (no // change requested) and "" are different requests on the wire, but the CLI // itself never sends "": SetExecDelivery("off") is always spelled "off", // the wire vocabulary handleAPIConfigPut maps onto SetExecDelivery("") — // see runEnvExecDelivery. type apiConfigApply struct { Environment string `json:"environment"` Set map[string]appconfig.Var `json:"set,omitempty"` Unset []string `json:"unset,omitempty"` ExecDelivery *string `json:"exec_delivery,omitempty"` Message string `json:"message,omitempty"` } // configApply sends one PUT to the console's config door — a set, an unset, // and an exec-delivery change all in one CAS write server-side // (appconfig.Apply / ApplyEnvironment carry both halves of a mixed // set+unset in one call; see internal/appconfig/generation.go). removed is // the server's count of unsets ACTUALLY present, never the argument count — // the same rule unsetVars already follows for direct mode (a count of // arguments reads as success for a typo'd key). func (s *apiSession) configApply(ctx context.Context, a apiAcct, app string, req apiConfigApply) (apiConfigApplyResult, error) { status, body, err := s.putJSON(ctx, apiPath(a, app, "/config"), req) if err != nil { return apiConfigApplyResult{}, err } if status != http.StatusOK { return apiConfigApplyResult{}, apiErrorFromBody(status, body) } var res struct { Result string `json:"result"` Removed int `json:"removed"` Restart *apiConfigRestart `json:"restart"` } if err := json.Unmarshal(body, &res); err != nil { return apiConfigApplyResult{}, fmt.Errorf("config: unreadable response: %w", err) } return apiConfigApplyResult{Removed: res.Removed, Restart: res.Restart}, nil } // apiConfigApplyResult is one config write's answer — a struct rather than // a bare count for the reason apiPromoteResult is one: a second thing worth // returning arrived, and growing a third return value would have meant // touching every call site to ignore it. type apiConfigApplyResult struct { // Removed is how many vars the unset half ACTUALLY removed, which is // not necessarily how many were asked for. Removed int // Restart is what the write landed on that will not read it, and it // has three states, all meaningful. NIL means the console never // mentioned it — a console older than this field, whose silence must // not be rendered as "nothing to restart", because it is not a claim // at all (the same precedent apiRetentionResult.Eligible sets for a // console that could not compute a number). Present with Known false // means the console looked and could not tell. Present with Known true // means these are the routes, and an empty Hosts there really does mean // none. Restart *apiConfigRestart } // apiConfigRestart mirrors the console's own restart notice across the wire // (internal/console/apiconfig.go). Additive: an older console omits it // entirely and a newer one may add fields this binary ignores. type apiConfigRestart struct { Known bool `json:"known"` Hosts []apiConfigRestartHost `json:"hosts"` Unresolved []string `json:"unresolved"` } // apiConfigRestartHost is one delivered-to route. Unit decides which remedy // the renderer offers, so it is carried rather than derived: a plain // TCP-addr route is one `carlos restart` cannot cycle at all. type apiConfigRestartHost struct { Host string `json:"host"` Unit string `json:"unit"` } // configSync asks the console to touch app's generation object — the remote // half of `carlos env sync` (appconfig.TouchGeneration is the direct-mode // half; see runEnvSync in appconfig_cli.go). The response carries nothing // this client needs beyond success/failure: {"result": "requested"} is the // only body handleAPIConfigSync ever sends. func (s *apiSession) configSync(ctx context.Context, a apiAcct, app string) error { status, body, err := s.postJSON(ctx, apiPath(a, app, "/config/sync"), struct{}{}) if err != nil { return err } if status != http.StatusOK { return apiErrorFromBody(status, body) } return nil } // ---- pipeline + pending (release pipelines v2, Task 10) ------------------- // apiPipelineChannel mirrors release.PipelineChannel/ChannelRules across the // wire, but FLAT rather than nested — Passkey/PromoteApprovals/ // ChangeApprovals sit alongside Name/Bake here, one struct a `carlos // pipeline` verb can build and mutate directly, rather than a Rules // sub-struct every call site would have to reach through. MarshalJSON/ // UnmarshalJSON below fold that flat shape back onto the server's nested // {"name","bake","rules":{"passkey","promote_approvals","change_approvals"}} // wire shape (internal/release/pipeline.go) exactly, field for field and // omitempty for omitempty — a template PUT (ruling: any template that sets a // rule also sets change_approvals) depends on this producing exactly the // same bytes handleAPIPipelinePut already accepts from the console's own // settings form. type apiPipelineChannel struct { Name string Bake string Passkey bool PromoteApprovals int ChangeApprovals int } func (c apiPipelineChannel) MarshalJSON() ([]byte, error) { type rules struct { Passkey bool `json:"passkey,omitempty"` PromoteApprovals int `json:"promote_approvals,omitempty"` ChangeApprovals int `json:"change_approvals,omitempty"` } type wire struct { Name string `json:"name"` Bake string `json:"bake,omitempty"` Rules *rules `json:"rules,omitempty"` } w := wire{Name: c.Name, Bake: c.Bake} if c.Passkey || c.PromoteApprovals != 0 || c.ChangeApprovals != 0 { w.Rules = &rules{Passkey: c.Passkey, PromoteApprovals: c.PromoteApprovals, ChangeApprovals: c.ChangeApprovals} } return json.Marshal(w) } func (c *apiPipelineChannel) UnmarshalJSON(b []byte) error { var w struct { Name string `json:"name"` Bake string `json:"bake"` Rules struct { Passkey bool `json:"passkey"` PromoteApprovals int `json:"promote_approvals"` ChangeApprovals int `json:"change_approvals"` } `json:"rules"` } if err := json.Unmarshal(b, &w); err != nil { return err } c.Name, c.Bake = w.Name, w.Bake c.Passkey, c.PromoteApprovals, c.ChangeApprovals = w.Rules.Passkey, w.Rules.PromoteApprovals, w.Rules.ChangeApprovals return nil } // apiPipeline is GET/PUT .../pipeline's whole-body shape: the ordered // channel list, nothing else — Account/App/Actor/SetAt (release.Pipeline's // own extra fields) are server bookkeeping this client has no use for and // never sends back. type apiPipeline struct { Channels []apiPipelineChannel `json:"channels"` } // pipelineGet reads an app's declared pipeline. A nil return (with a nil // error) is spec §2's genuine "no pipeline.json" case — GET answers 200 with // a null "pipeline" field for it (handleAPIPipelineGet) — never conflated // with a read failure, which comes back as a non-nil error instead. func (s *apiSession) pipelineGet(ctx context.Context, a apiAcct, app string) (*apiPipeline, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/pipeline")) if err != nil { return nil, err } if status != http.StatusOK { return nil, apiErrorFromBody(status, body) } var res struct { Pipeline *apiPipeline `json:"pipeline"` } if err := json.Unmarshal(body, &res); err != nil { return nil, fmt.Errorf("pipeline: unreadable response: %w", err) } return res.Pipeline, nil } // pipelinePut replaces an app's channel list. Three outcomes, and a // `carlos pipeline` verb must tell them apart: // // - 200: the edit landed. The returned *apiPipeline is the console's own // echo of what it stored (SetAt included server-side, though this // client only reads Channels back out of it). // - 202: the edit touched a channel the CURRENTLY STORED pipeline already // guards with change_approvals (guardedEdit, apipipeline.go) — it // becomes a proposal at zero confirmations rather than landing. Returned // as *apiProposedError, not folded into the generic error path, so the // caller can print the "proposed" line instead of failing outright. // - anything else: a genuine refusal — validation (422), a lost CAS race // (409), a passkey-only channel with no approvals to propose against // (403, via consoleRefusal), or a store failure (502). func (s *apiSession) pipelinePut(ctx context.Context, a apiAcct, app string, channels []apiPipelineChannel) (*apiPipeline, error) { in := struct { Channels []apiPipelineChannel `json:"channels"` }{channels} status, body, err := s.putJSON(ctx, apiPath(a, app, "/pipeline"), in) if err != nil { return nil, err } switch status { case http.StatusOK: var res struct { Pipeline apiPipeline `json:"pipeline"` } if err := json.Unmarshal(body, &res); err != nil { return nil, fmt.Errorf("pipeline: unreadable response: %w", err) } return &res.Pipeline, nil case http.StatusAccepted: return nil, proposedFromBody(body) } if refErr, ok := consoleRefusal(status, body); ok { return nil, refErr } return nil, apiErrorFromBody(status, body) } // apiPending is one row of `carlos pipeline`'s pending-changes list — the // CLI's read-only window onto a quorum in progress, since the CLI itself can // never confirm one (spec §4: confirmation is passkey-backed, browser-only). type apiPending struct { ID string `json:"id"` Kind string `json:"kind"` Summary string `json:"summary"` Confirmed int `json:"confirmed"` Needed int `json:"needed"` } // pendingList reads what an app is waiting on. Always read-only and // Publish-reachable, like channels/releases (handleAPIPending's own doc // comment) — a pending change names no secret. func (s *apiSession) pendingList(ctx context.Context, a apiAcct, app string) ([]apiPending, error) { status, body, err := s.getJSON(ctx, apiPath(a, app, "/pending")) if err != nil { return nil, err } if status != http.StatusOK { return nil, apiErrorFromBody(status, body) } var res struct { Pending []apiPending `json:"pending"` } if err := json.Unmarshal(body, &res); err != nil { return nil, fmt.Errorf("pending: unreadable response: %w", err) } return res.Pending, nil } // deleteJSON runs one DELETE and hands back the status and raw body without // interpreting a non-200 as an error itself — getJSON's plumbing with the // method swapped, for the instance-record retire door (issue #66), the one // DELETE this client speaks. func (s *apiSession) deleteJSON(ctx context.Context, path string, messages ...string) (int, []byte, error) { message := firstMutationMessage(messages) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.c.base+path, nil) if err != nil { return 0, nil, err } if s.c.token != "" { req.Header.Set("Authorization", "Bearer "+s.c.token) } if message != "" { req.Header.Set("X-Carlos-Message", message) } res, err := s.c.httpc.Do(req) if err != nil { return 0, nil, err } defer res.Body.Close() if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { return res.StatusCode, nil, offerConsoleMoved(merr) } body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return res.StatusCode, nil, err } return res.StatusCode, body, nil } // apiInstanceManifest is the instances-manifest door's response — the // manifest as the console now holds it, echoed so the CLI prints the domain // the SERVER pinned (the account's platform subdomain), never one this side // guessed. type apiInstanceManifest struct { App string `json:"app"` Domain string `json:"domain"` Pool string `json:"pool"` Channel string `json:"channel"` Regions []string `json:"regions"` Seed []string `json:"seed"` // PrivateStorage is the app's standing policy that every instance it // creates gets a config environment of its own and an object store // bound to it (issue #412). Echoed by the door so `carlos instances // enable` can print the policy in force rather than the one just // asked for. PrivateStorage bool `json:"private_storage"` } // enableError carries the instances-manifest door's STATUS alongside its // message. apiErrorFromBody collapses a refusal to its sentence, which is // right for a message a member reads and wrong for a decision a caller has // to make: the deploy mint must tell "this deployment has no platform host // to give" from "your credential just lost authority", and reading English // to do it is the mistake errAmbiguousChannel's comment records. Same shape // as domainListError, for the same reason. type enableError struct { status int msg string } func (e *enableError) Error() string { return e.msg } // enableUnavailable reports whether the deployment simply has no platform // host for this app: no apps domain configured at all (503), or an // instance domain an operator pinned elsewhere in store mode (409), which // this door refuses to speak for. Both mean "ask the member for a hostname // instead"; every other status is a real failure and must not be swallowed. // // The 409 is deliberately imprecise: the door also answers 409 for a lost // CAS race ("instance settings changed while this request was in flight"), // which is transient and would be better retried than fallen back from. // Telling the two apart needs the message, and reading English to make a // control-flow decision is the mistake this type exists to avoid — so both // take the fallback, and the caller PRINTS the door's own sentence, which // says "try again" in the transient case. A wrong branch here costs one // re-run, never a wrong record. func enableUnavailable(err error) bool { var ee *enableError return errors.As(err, &ee) && (ee.status == http.StatusServiceUnavailable || ee.status == http.StatusConflict) } // instancesEnable asks the console to write (or update) the app's instances // manifest under the account's own platform subdomain — the member-facing // half of `carlos instances enable` (issue #66). channel and seed are // pointers for the same reason apiConfigApply's ExecDelivery is: absent // means "preserve what the manifest carries", and seed's explicit empty // slice means "clear" — three requests the wire must keep apart. func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string, channel *string, seed *[]string, privateStorage *bool) (apiInstanceManifest, error) { in := struct { Channel *string `json:"channel,omitempty"` Seed *[]string `json:"seed,omitempty"` PrivateStorage *bool `json:"private_storage,omitempty"` }{channel, seed, privateStorage} status, body, err := s.putJSON(ctx, apiPath(a, app, "/instances-manifest"), in) if err != nil { return apiInstanceManifest{}, err } if status != http.StatusOK { return apiInstanceManifest{}, &enableError{status: status, msg: apiErrorFromBody(status, body).Error()} } var out apiInstanceManifest if err := json.Unmarshal(body, &out); err != nil { return apiInstanceManifest{}, fmt.Errorf("instances enable: unreadable response: %w", err) } return out, nil } // apiInstanceRecord mirrors provision.Record's wire shape — redeclared here // rather than imported because this client renders records, never writes // them, and the fields it renders are the contract worth pinning. type apiInstanceRecord struct { Host string `json:"host"` Slug string `json:"slug"` Pool string `json:"pool"` Region string `json:"region"` Channel string `json:"channel"` CreatedAt string `json:"created_at"` CreatedBy string `json:"created_by"` // Kind is the record's route class ("instance" | "service" | "static"; // empty means instance — provision.Record's own contract). The wire has // carried it since the route-records design (the list embeds the whole // record); the client decodes it so deploy can tell a first-route // STATIC record (#82's attach writes one) from a real instance — a // static record is a static-deploy target, not an instance to adopt. Kind string `json:"kind"` // StagedVersion is the console's additive staged_version join (Task 8, // issue #78): whichever version a box's adopt pass last reported for // this host, via status/boxes/