| 1 | // Which mode the CLI runs in, and the console API client for the mode that |
| 2 | // doesn't touch the deployment bucket directly. |
| 3 | // |
| 4 | // Every verb that used to reach straight for CARLOS_DEPLOYMENT_BUCKET / |
| 5 | // CARLOS_DEPLOYMENT_DIR now checks resolveMode first. Direct-mode wins |
| 6 | // whenever either env var is set — that is unchanged behaviour, and it wins |
| 7 | // unconditionally: a box configured for direct bucket access keeps working |
| 8 | // exactly as it always has, whether or not the same machine also happens to |
| 9 | // hold a console credential (`carlos auth login` is meant for a human's |
| 10 | // laptop, not a box; see auth.go's HEADLESS AND BOX-SIDE USE note). Only when |
| 11 | // neither env var is set does the CLI need a credential at all, and it is |
| 12 | // credential-first from there: no local registry, no guessing an account by |
| 13 | // name against a bucket the caller may not even be able to reach — the |
| 14 | // console resolves the account (by sqid OR name) against the caller's own |
| 15 | // memberships, which is also the authorization check. |
| 16 | package main |
| 17 | |
| 18 | import ( |
| 19 | "bytes" |
| 20 | "context" |
| 21 | "encoding/json" |
| 22 | "errors" |
| 23 | "fmt" |
| 24 | "io" |
| 25 | "log" |
| 26 | "mime/multipart" |
| 27 | "net/http" |
| 28 | "net/url" |
| 29 | "os" |
| 30 | "path/filepath" |
| 31 | "sort" |
| 32 | "strings" |
| 33 | "time" |
| 34 | |
| 35 | "github.com/carlosframework/platform/internal/analytics" |
| 36 | "github.com/carlosframework/platform/internal/appconfig" |
| 37 | "github.com/carlosframework/platform/internal/apperr" |
| 38 | "github.com/carlosframework/platform/internal/applog" |
| 39 | "github.com/carlosframework/platform/internal/caps" |
| 40 | "github.com/carlosframework/platform/internal/dbdoor" |
| 41 | "github.com/carlosframework/platform/internal/ledger" |
| 42 | "github.com/carlosframework/platform/internal/objstore" |
| 43 | "github.com/carlosframework/platform/internal/release" |
| 44 | "github.com/carlosframework/platform/internal/schedule" |
| 45 | "github.com/carlosframework/platform/internal/updatewords" |
| 46 | ) |
| 47 | |
| 48 | // serviceSessionLabel stands in for the address in "using X as Y" lines |
| 49 | // when the session is a service token: the CLI has no door that tells it |
| 50 | // the credential's name, and a person's whoami does not answer for one. |
| 51 | const serviceSessionLabel = "a service credential (CARLOS_SERVICE_TOKEN)" |
| 52 | |
| 53 | // apiSession is the resolved API mode: which console, as whom. console and |
| 54 | // address are carried for messages (Task 5's "logged in to X as Y" lines), |
| 55 | // not used by the calls below. |
| 56 | type apiSession struct { |
| 57 | c *consoleClient |
| 58 | console string // origin, for messages |
| 59 | address string |
| 60 | // defAcct memoizes defaultAccount's answer: resolution happens once per |
| 61 | // invocation however many times a verb asks (runSecretsSet asks twice — |
| 62 | // once for the seal key, once for the write; two chooser prompts there |
| 63 | // once sealed a value with one account's key and wrote it to another). |
| 64 | defAcct *apiAcct |
| 65 | // The memberships response, fetched at most once (listAccounts): |
| 66 | // account resolution reads the accounts, app resolution reads each |
| 67 | // account's apps — same wire bytes, one request. |
| 68 | accts []apiAccountEntry |
| 69 | acctsSupported bool |
| 70 | acctsFetched bool |
| 71 | // defApp memoizes defaultApp's answer, defAcct's rule for the sibling |
| 72 | // flag: one resolution — and at most one chooser — per invocation. |
| 73 | defApp string |
| 74 | // appPrompted records that defaultApp's answer came from the CHOOSER — |
| 75 | // only interactively-resolved values belong in a save offer (a silent |
| 76 | // single-app auto-resolve prompting "save?" on every run would nag). |
| 77 | appPrompted bool |
| 78 | // deferSaves suppresses the inline save offers (account chooser, app |
| 79 | // chooser): set by zero-arg deploy, whose ONE combined offer at the end |
| 80 | // of first-run resolution covers everything (spec: one save offer). |
| 81 | deferSaves bool |
| 82 | // shipSwap is the release's own swap demand for ship ("hold" or ""), |
| 83 | // set from --swap by runShip and runDeploy. A field for deferSaves' |
| 84 | // reason: ship has many callers and only those two take the flag. |
| 85 | shipSwap string |
| 86 | } |
| 87 | |
| 88 | // resolveMode decides which mode the CLI is in. (nil, nil) means direct mode |
| 89 | // — the store env is set, and the caller proceeds exactly as it does today, |
| 90 | // straight against the bucket. A non-nil session means API mode: every call |
| 91 | // goes through the console named in the credential loggedInCredential |
| 92 | // resolved, as the address that credential belongs to. |
| 93 | // |
| 94 | // consoleFlag is the verb's --console value ("" meaning "the only one" — |
| 95 | // pick's ordinary disambiguation rule, unchanged here). |
| 96 | // |
| 97 | // An explicit --console alongside either store env is refused outright |
| 98 | // (design ruling, review round 2026-08-03) rather than silently winning as |
| 99 | // direct mode: the spec's "the two modes never mix in one invocation" intent |
| 100 | // (§1) would otherwise be violated invisibly — someone who typed --console |
| 101 | // meaning it gets bucket access instead, with no signal that their flag was |
| 102 | // ignored. A BARE env with no --console still wins silently, unchanged: that |
| 103 | // half of §1 (operators, CI, box-side scripts) is deliberately untouched. |
| 104 | // |
| 105 | // CARLOS_SERVICE_TOKEN is CI's way in (spec §8): with it set, API mode runs |
| 106 | // as that token against the console named by --console or CARLOS_CONSOLE, |
| 107 | // and nothing else is consulted: no config default, no stored credential, |
| 108 | // no credentials file. A token beside a store env is refused, as --console |
| 109 | // is, because the two name different places to send the work. |
| 110 | func resolveMode(consoleFlag string) (*apiSession, error) { |
| 111 | bucketSet := os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" |
| 112 | dirSet := os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" |
| 113 | if consoleFlag != "" && (bucketSet || dirSet) { |
| 114 | return nil, errors.New("--console conflicts with CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR " + |
| 115 | "— unset the env to use the console, or drop --console") |
| 116 | } |
| 117 | token := os.Getenv("CARLOS_SERVICE_TOKEN") |
| 118 | if token != "" && (bucketSet || dirSet) { |
| 119 | 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.") |
| 120 | } |
| 121 | if bucketSet || dirSet { |
| 122 | return nil, nil |
| 123 | } |
| 124 | if token != "" { |
| 125 | // CI's path (spec §8): the console is named outright, never taken |
| 126 | // from a config default or a stored credential, and the credentials |
| 127 | // file is not read. |
| 128 | console := consoleFlag |
| 129 | if console == "" { |
| 130 | console = os.Getenv("CARLOS_CONSOLE") |
| 131 | } |
| 132 | if console == "" { |
| 133 | return nil, errors.New("CARLOS_SERVICE_TOKEN is set but no console is named: set CARLOS_CONSOLE or pass --console") |
| 134 | } |
| 135 | origin, err := normalizeConsole(console) |
| 136 | if err != nil { |
| 137 | return nil, err |
| 138 | } |
| 139 | return &apiSession{c: newConsoleClient(origin, token), console: origin, address: serviceSessionLabel}, nil |
| 140 | } |
| 141 | cred, err := loggedInCredential(consoleFlag) |
| 142 | if err != nil { |
| 143 | // loggedInCredential's own errors already name their own fix (log in |
| 144 | // to the right console, re-log in, disambiguate with --console) — |
| 145 | // except the plain "there are no credentials at all" case (pick's |
| 146 | // exact, non-interpolated wording below), which never mentions |
| 147 | // direct mode because pick() has no idea direct mode exists. That's |
| 148 | // the one case worth widening here: a fresh checkout with neither a |
| 149 | // bucket/dir nor a login has TWO ways to fix it, and the naked "not |
| 150 | // logged in" message only offers one. |
| 151 | if err.Error() == noCredentialsMsg { |
| 152 | // But "not logged in" is also what pick() says when a credential |
| 153 | // EXISTS but every entry on disk has expired — its live-credential |
| 154 | // filter treats an expired entry as absent, so that case reaches |
| 155 | // this same string. Those are different problems for a person to |
| 156 | // fix ("configure a target" vs. "log back in"), and conflating |
| 157 | // them here would tell someone who has in fact logged in before |
| 158 | // that nothing is configured at all. |
| 159 | if expired := expiredCredentialFirst(); expired != nil { |
| 160 | err = ¬LoggedInError{Console: expired.Console, Expired: true, |
| 161 | msg: fmt.Sprintf("the credential for %s expired — run: carlos auth login --console %s", |
| 162 | expired.Console, expired.Console)} |
| 163 | } else { |
| 164 | err = errNoDeploymentTarget |
| 165 | } |
| 166 | } |
| 167 | // A terminal gets the remedy offered, not just named (#162): log in |
| 168 | // right here and CONTINUE the command the user actually typed. |
| 169 | // Scripts, n, and EOF keep the exact errors built above. |
| 170 | cred, err = offerLogin(err, consoleFlag) |
| 171 | if err != nil { |
| 172 | return nil, err |
| 173 | } |
| 174 | } |
| 175 | return &apiSession{ |
| 176 | c: newConsoleClient(cred.Console, cred.Token), |
| 177 | console: cred.Console, |
| 178 | address: cred.Address, |
| 179 | }, nil |
| 180 | } |
| 181 | |
| 182 | // consoleMovedError is a permanent-redirect answer from a console (#160): |
| 183 | // the host this CLI was pointed at now lives somewhere else, and the |
| 184 | // message carries the whole story — a script that hits it needs no oracle. |
| 185 | type consoleMovedError struct{ From, To string } |
| 186 | |
| 187 | func (e *consoleMovedError) Error() string { |
| 188 | return fmt.Sprintf("this console has moved: %s -> %s — run: carlos auth login --console %s", |
| 189 | e.From, e.To, e.To) |
| 190 | } |
| 191 | |
| 192 | // consoleMoved inspects one response for a console-shaped redirect. Only a |
| 193 | // PERMANENT redirect (301/308) whose Location names an ABSOLUTE origin |
| 194 | // normalizeConsole would accept (https, or http strictly on-box) and that |
| 195 | // differs from the one dialed counts — 302/307 are what SSO walls and |
| 196 | // identity proxies answer with, and reading those as "the console moved" |
| 197 | // would offer a login to accounts.google.com; a same-origin or path-level |
| 198 | // redirect stays a plain unexpected status; and a Location the CLI would |
| 199 | // refuse to log in to is never surfaced as a destination. The Location is |
| 200 | // trusted for DISPLAY AND PROMPTING only: nothing follows it, and no |
| 201 | // credential exists until the human confirms the device flow against it. |
| 202 | func consoleMoved(from string, status int, location string) *consoleMovedError { |
| 203 | switch status { |
| 204 | case http.StatusMovedPermanently, http.StatusPermanentRedirect: |
| 205 | default: |
| 206 | return nil |
| 207 | } |
| 208 | u, err := url.Parse(location) |
| 209 | if err != nil || !u.IsAbs() || u.Host == "" { |
| 210 | return nil |
| 211 | } |
| 212 | origin, err := normalizeConsole(u.Scheme + "://" + u.Host) |
| 213 | if err != nil || origin == from { |
| 214 | return nil |
| 215 | } |
| 216 | return &consoleMovedError{From: from, To: origin} |
| 217 | } |
| 218 | |
| 219 | // consoleMovedOffered makes the offer once-per-process: polling verbs |
| 220 | // (`logs -f`, authLogin's approval loop) hit the same 3xx every interval, |
| 221 | // and prompting on each would eat the terminal's stdin; and the offer |
| 222 | // itself runs authLogin, whose polls reach these same response sites — the |
| 223 | // guard is also the re-entrancy stop for a redirect chain. After the one |
| 224 | // offer (taken or declined), every further hit is the typed error. |
| 225 | var consoleMovedOffered bool |
| 226 | |
| 227 | // quietConsoleOffers suppresses the offer entirely — set around calls |
| 228 | // whose contract is to degrade silently (whoami's memberships bonus line), |
| 229 | // where even one prompt would make a working command look broken. |
| 230 | var quietConsoleOffers bool |
| 231 | |
| 232 | // offerConsoleMoved is the terminal's version of consoleMovedError: explain |
| 233 | // the move, offer the standard device flow against the new host, and — when |
| 234 | // a .carlos/config names the old console — offer to repoint it (keeping |
| 235 | // every other key, account included: the pair moves together). The verb |
| 236 | // still ends nonzero: its request died against the old host mid-flight, and |
| 237 | // "logged in — re-run your command" is the honest state. |
| 238 | func offerConsoleMoved(e *consoleMovedError) error { |
| 239 | if !interactiveTerminal() || quietConsoleOffers || consoleMovedOffered { |
| 240 | return e |
| 241 | } |
| 242 | consoleMovedOffered = true |
| 243 | fmt.Fprintf(stdout, "this console has moved: %s -> %s\n", e.From, e.To) |
| 244 | if !promptYes(stdout, fmt.Sprintf("log in to %s now? [y/N] ", e.To)) { |
| 245 | return e |
| 246 | } |
| 247 | if err := authLogin(stdout, []string{"--console", e.To}); err != nil { |
| 248 | return err |
| 249 | } |
| 250 | if cfgConsole, src, err := configDefault(); err == nil && cfgConsole == e.From && src != "" { |
| 251 | // The destination is named in the prompt, not only the file: a y |
| 252 | // here changes where future ships go, and that must never rest on |
| 253 | // remembering what the previous prompt said. |
| 254 | if promptYes(stdout, fmt.Sprintf("update %s to point at %s too? [y/N] ", src, e.To)) { |
| 255 | if err := saveConfigFile(src, e.To, 0o755); err != nil { |
| 256 | warnf(stdout, "not updated (%s): %v", src, err) |
| 257 | } else { |
| 258 | mutedf(stdout, "updated: %s now names %s", src, e.To) |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | return fmt.Errorf("logged in to %s — re-run your command against it", e.To) |
| 263 | } |
| 264 | |
| 265 | // errNoDeploymentTarget is resolveMode's "nothing configured at all" |
| 266 | // refusal — a var so offerLogin can recognize it by identity rather than |
| 267 | // by matching its wording. |
| 268 | var errNoDeploymentTarget = errors.New("no deployment target configured: " + |
| 269 | "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " + |
| 270 | "or run carlos auth login --console <url> to work through a console") |
| 271 | |
| 272 | // offerLogin is the interactive half of resolveMode's refusals (#162). On |
| 273 | // a terminal, a refusal that names a console offers to run the device flow |
| 274 | // against it right now; the nothing-configured refusal asks for a URL |
| 275 | // (typing one IS the consent — no second prompt). Everything else — and |
| 276 | // every non-terminal caller — gets the original error back verbatim. The |
| 277 | // login flow itself is authLogin, unchanged: browser approval, per-origin |
| 278 | // credential, no shortcuts. |
| 279 | func offerLogin(origErr error, consoleFlag string) (*credential, error) { |
| 280 | if !interactiveTerminal() { |
| 281 | return nil, origErr |
| 282 | } |
| 283 | var target string |
| 284 | var nle *notLoggedInError |
| 285 | switch { |
| 286 | case errors.As(origErr, &nle): |
| 287 | if nle.Expired { |
| 288 | fmt.Fprintf(stdout, "your login for %s has expired\n", nle.Console) |
| 289 | if !promptYes(stdout, "log in again now? [y/N] ") { |
| 290 | return nil, origErr |
| 291 | } |
| 292 | } else { |
| 293 | fmt.Fprintf(stdout, "not logged in to %s\n", nle.Console) |
| 294 | if !promptYes(stdout, "log in now? [y/N] ") { |
| 295 | return nil, origErr |
| 296 | } |
| 297 | } |
| 298 | target = nle.Console |
| 299 | case errors.Is(origErr, errNoDeploymentTarget): |
| 300 | fmt.Fprintln(stdout, "no console configured and no login found.") |
| 301 | raw := promptLine(stdout, "console URL (e.g. https://console.carloku.com): ") |
| 302 | if raw == "" { |
| 303 | return nil, origErr |
| 304 | } |
| 305 | n, err := normalizeConsole(raw) |
| 306 | if err != nil { |
| 307 | return nil, err |
| 308 | } |
| 309 | target = n |
| 310 | default: |
| 311 | return nil, origErr |
| 312 | } |
| 313 | if err := authLogin(stdout, []string{"--console", target}); err != nil { |
| 314 | return nil, err |
| 315 | } |
| 316 | return loggedInCredential(consoleFlag) |
| 317 | } |
| 318 | |
| 319 | // noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for |
| 320 | // "no --console given (and no CARLOS_CONSOLE either) and there is nothing |
| 321 | // live to pick from" — the one pick() refusal that never mentions direct |
| 322 | // mode, since pick has no idea direct mode exists. Matched by exact string |
| 323 | // rather than a sentinel error because pick constructs a fresh error each |
| 324 | // call; this is the one pick() message with no interpolated value, so an |
| 325 | // exact match is precise, not fragile. |
| 326 | // |
| 327 | // It is ALSO the one case pick() reaches both when the credentials file is |
| 328 | // genuinely empty and when it holds only expired entries (pick's "live" |
| 329 | // filter excludes expired credentials before counting), which is why |
| 330 | // resolveMode calls expiredCredentialFirst before treating it as "nothing |
| 331 | // configured". |
| 332 | const noCredentialsMsg = "not logged in — run: carlos auth login --console <url>" |
| 333 | |
| 334 | // expiredCredentialFirst reports whether the credentials file holds at |
| 335 | // least one entry and every one of them is expired, returning one such |
| 336 | // entry if so (and nil otherwise, including when the file is empty or |
| 337 | // unreadable). Only ever consulted from the noCredentialsMsg branch above, |
| 338 | // where consoleFlag and CARLOS_CONSOLE are already known to both be empty — |
| 339 | // pick() would have taken its console!="" branch, and returned its OWN |
| 340 | // (already specific) expired-credential message, otherwise. |
| 341 | func expiredCredentialFirst() *credential { |
| 342 | f, err := loadCredentials() |
| 343 | if err != nil || len(f.Credentials) == 0 { |
| 344 | return nil |
| 345 | } |
| 346 | now := time.Now() |
| 347 | for i := range f.Credentials { |
| 348 | if !f.Credentials[i].Expired(now) { |
| 349 | return nil |
| 350 | } |
| 351 | } |
| 352 | return &f.Credentials[0] |
| 353 | } |
| 354 | |
| 355 | // apiChannel is one row of a channel ladder read back through the console |
| 356 | // API, mirroring the runChannels per-row ERROR behaviour: a channel whose |
| 357 | // pointer failed verification is a row with Err set, not a failed call. |
| 358 | type apiChannel struct { |
| 359 | Channel string |
| 360 | Version string |
| 361 | PromotedAt string |
| 362 | Rung string |
| 363 | Err string |
| 364 | // Digests is whether the pointer carries signed manifest digests; |
| 365 | // Straggler is a pointer without them written after digests went on |
| 366 | // (#253). An older console omits both, which reads as false. |
| 367 | Digests bool |
| 368 | Straggler bool |
| 369 | } |
| 370 | |
| 371 | // apiChannelsResult is what `channels` returns: the channel rows plus the |
| 372 | // app's declared pipeline (release pipelines v2, Task 11), folded down to |
| 373 | // the two facts a deploy-side caller actually needs — where a release |
| 374 | // enters and the full order — rather than re-exposing release.Pipeline's |
| 375 | // whole shape over the wire a second time. |
| 376 | // |
| 377 | // PipelineEntry == "" means "no pipeline" — a legacy app on the implicit |
| 378 | // edge/beta/stable ladder, OR a console whose pipeline read failed |
| 379 | // (handleAPIChannels omits both pipeline_entry/pipeline_channels rather |
| 380 | // than failing the listing; this client-side type does not need to tell |
| 381 | // the two apart, since either way there is no entry channel to fall back |
| 382 | // to and the caller's existing no-pipeline behaviour is exactly right). |
| 383 | type apiChannelsResult struct { |
| 384 | Channels []apiChannel |
| 385 | PipelineEntry string |
| 386 | PipelineChannels []string |
| 387 | } |
| 388 | |
| 389 | // apiPromoteResult and apiShipResult carry the one fact Task 5 needs to |
| 390 | // print a parity message: whether the console did nothing because the |
| 391 | // request already matched what's live (a promote of a version already on |
| 392 | // that channel; a re-ship of bytes already shipped under that version). |
| 393 | // Every other field of the console's response (rung names, artifact |
| 394 | // hashes) is deliberately not modelled here — the CLI already knows the |
| 395 | // version/channel it asked for and prints those, never the server's echo of |
| 396 | // them. |
| 397 | // |
| 398 | // apiShipResult is the one exception: Version (release versioning spec). |
| 399 | // When a ship's meta part carries no version, the CONSOLE mints one from the |
| 400 | // app's target — the caller sent no version to already know, so the |
| 401 | // server's answer is the only place it exists. |
| 402 | type apiPromoteResult struct { |
| 403 | NoOp bool |
| 404 | // Held mirrors the console's additive "held" field (Task 6, spec §6.6): |
| 405 | // true when this is a production app promoting into a rung that bakes. |
| 406 | // Present on BOTH the promoted and the no-op response — a repeated |
| 407 | // `carlos deploy` against an unheld app must still watch (the channel |
| 408 | // already points at the version, but an instance may not have converged |
| 409 | // yet), so deploy needs this fact on the no-op path too, not only on a |
| 410 | // fresh promotion. |
| 411 | Held bool `json:"held"` |
| 412 | // HeldUntil is when a held promotion lifts, RFC 3339, from a console |
| 413 | // that knows; "" from an older one. |
| 414 | HeldUntil string `json:"held_until"` |
| 415 | } |
| 416 | |
| 417 | type apiShipResult struct { |
| 418 | NoOp bool |
| 419 | Version string |
| 420 | // BuildProbe is the app's build-report path, echoed by the ship door |
| 421 | // (handleAPIShip) and empty for an app that declares none — including |
| 422 | // against any console built before the field existed, where the key is |
| 423 | // simply absent and the deploy watch keeps reading the edge's header. |
| 424 | // `carlos deploy` learns it here rather than through a door of its own: |
| 425 | // it always ships before it watches, so the value arrives with no extra |
| 426 | // round trip on a response already scoped to this one app. |
| 427 | BuildProbe string |
| 428 | } |
| 429 | |
| 430 | // apiAcct is one resolved --account/--account-id pair, API-mode: the segment |
| 431 | // the console will resolve, and whether it must match as a sqid only. |
| 432 | type apiAcct struct { |
| 433 | Seg string |
| 434 | Exact bool |
| 435 | } |
| 436 | |
| 437 | // apiAccount is resolveAccount's API-mode counterpart. Names resolve |
| 438 | // SERVER-side against the caller's memberships, so --account travels |
| 439 | // verbatim; --account-id travels as the segment plus exact=sqid, so the |
| 440 | // console never name-matches it. Both flags are trimmed exactly as |
| 441 | // resolveAccount trims them, so `--account " ops "` means the same thing in |
| 442 | // either mode, and --account-id passes the same checkAccountID gate — a name |
| 443 | // there is refused on this side of the wire, not turned into a 404 the |
| 444 | // console cannot explain. |
| 445 | func apiAccount(raw, rawID *string) apiAcct { |
| 446 | if id := checkAccountID(*rawID); id != "" { |
| 447 | return apiAcct{Seg: id, Exact: true} |
| 448 | } |
| 449 | return apiAcct{Seg: strings.TrimSpace(*raw)} |
| 450 | } |
| 451 | |
| 452 | // apiPath builds one /api/cli/apps/{account}/{app}/... path. The segment is |
| 453 | // passed through verbatim (sqid or name, exactly what --account carried, |
| 454 | // default "ops") — the console resolves it against the caller's own |
| 455 | // memberships; this client must never resolve it against a local registry. |
| 456 | // --account-id adds ?exact=sqid, the console-side switch that makes the |
| 457 | // segment match as a sqid ONLY (internal/console/apicli.go). Plain string |
| 458 | // concatenation is safe here because no tail this package builds carries a |
| 459 | // query string of its own — every one is a bare path. |
| 460 | func apiPath(a apiAcct, app, tail string) string { |
| 461 | p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail |
| 462 | if a.Exact { |
| 463 | sep := "?" |
| 464 | if strings.Contains(p, "?") { |
| 465 | sep = "&" |
| 466 | } |
| 467 | p += sep + "exact=sqid" |
| 468 | } |
| 469 | return p |
| 470 | } |
| 471 | |
| 472 | // storeEnvTail names the environment a store verb is aimed at, as the query |
| 473 | // parameter every store door reads. "" is the app's own store and adds |
| 474 | // nothing at all, so this CLI's request to an older console is byte-identical |
| 475 | // to the one it has always sent. |
| 476 | // |
| 477 | // It goes in the TAIL rather than after apiPath, because apiPath appends |
| 478 | // `exact=sqid` with its own ?/& check — building the query here keeps one |
| 479 | // place deciding how these paths are punctuated. |
| 480 | func storeEnvTail(tail, env string) string { |
| 481 | if env == "" { |
| 482 | return tail |
| 483 | } |
| 484 | return tail + "?environment=" + url.QueryEscape(env) |
| 485 | } |
| 486 | |
| 487 | func apiAccountStorePath(a apiAcct, tail string) string { |
| 488 | p := "/api/cli/accounts/" + url.PathEscape(a.Seg) + "/store/" + tail |
| 489 | if a.Exact { |
| 490 | p += "?exact=sqid" |
| 491 | } |
| 492 | return p |
| 493 | } |
| 494 | |
| 495 | // listAccounts fetches the caller's own memberships: GET /api/cli/apps' |
| 496 | // "accounts" array (handleAPIApps — live since main 5856dc5, 2026-08-03). |
| 497 | // supported=false means the console predates the endpoint: the bare mux |
| 498 | // 404, which cannot be the membership gate's 404 because this path carries |
| 499 | // no account or app segment for a gate to refuse. Every other non-200 is |
| 500 | // a real answer (401 expired token, 500) and surfaces via apiErrorFromBody |
| 501 | // — never swallowed into a fallback. |
| 502 | func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) { |
| 503 | // Memoized: account resolution and app resolution both read this, and |
| 504 | // one invocation makes at most one fetch, however many of them run. |
| 505 | if s.acctsFetched { |
| 506 | return s.accts, s.acctsSupported, nil |
| 507 | } |
| 508 | status, body, err := s.getJSON(ctx, "/api/cli/apps") |
| 509 | if err != nil { |
| 510 | return nil, false, err |
| 511 | } |
| 512 | if status == http.StatusNotFound { |
| 513 | s.accts, s.acctsSupported, s.acctsFetched = nil, false, true |
| 514 | return nil, false, nil |
| 515 | } |
| 516 | if status != http.StatusOK { |
| 517 | return nil, false, apiErrorFromBody(status, body) |
| 518 | } |
| 519 | var out struct { |
| 520 | Accounts []apiAccountEntry `json:"accounts"` |
| 521 | } |
| 522 | if err := json.Unmarshal(body, &out); err != nil { |
| 523 | return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err) |
| 524 | } |
| 525 | s.accts, s.acctsSupported, s.acctsFetched = out.Accounts, true, true |
| 526 | return out.Accounts, true, nil |
| 527 | } |
| 528 | |
| 529 | // defaultAccount answers an omitted --account in API mode (spec: |
| 530 | // docs/superpowers/specs/2026-08-12-account-default-resolution-design.md): |
| 531 | // the config default when its file's console pairs with this session, else |
| 532 | // the caller's one membership, else the chooser (terminal) or the naming |
| 533 | // refusal (script). Whatever is decided for the caller is printed — a |
| 534 | // guessed account that stayed invisible would be the wrong-deployment |
| 535 | // mistake this file already refuses everywhere else. |
| 536 | func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) { |
| 537 | if s.defAcct != nil { |
| 538 | return *s.defAcct, nil |
| 539 | } |
| 540 | a, err := s.defaultAccountOnce(ctx) |
| 541 | if err == nil { |
| 542 | s.defAcct = &a |
| 543 | } |
| 544 | return a, err |
| 545 | } |
| 546 | |
| 547 | func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) { |
| 548 | name, src, fileConsole, err := configAccount() |
| 549 | if err != nil { |
| 550 | return apiAcct{}, err |
| 551 | } |
| 552 | // Honored ONLY when the file names THIS console: the pair is one fact. |
| 553 | // A consoleless {"account": …} is deliberately not a wildcard — the |
| 554 | // console key is nearly inert as committed input (no credential, no |
| 555 | // request), but an account that applied to every console would let a |
| 556 | // checked-in file silently redirect a ship between stacks the caller |
| 557 | // really is a member of. saveConfigAccount always writes the pair, so |
| 558 | // only a hand-written file can even be consoleless. |
| 559 | if name != "" && fileConsole == s.console { |
| 560 | // The name travels verbatim, like a typed --account: the console |
| 561 | // resolves it against the caller's memberships, so a stale config |
| 562 | // value fails with the same membership 404 a typo would. |
| 563 | mutedf(stdout, "account: %s (the default from %s)", name, src) |
| 564 | return apiAcct{Seg: name}, nil |
| 565 | } |
| 566 | accts, supported, err := s.listAccounts(ctx) |
| 567 | if err != nil { |
| 568 | return apiAcct{}, err |
| 569 | } |
| 570 | if !supported { |
| 571 | // No memberships answer — a console from before the listing |
| 572 | // endpoint, or any 404-shaped thing in front of one. Keep the |
| 573 | // pre-discovery default so old consoles regress nothing, but SAY |
| 574 | // so: a silent ops here followed by the membership 404 is exactly |
| 575 | // the three-failures-deep riddle #157 was filed about. |
| 576 | mutedf(stdout, "account: ops (%s did not answer with your memberships)", s.console) |
| 577 | return apiAcct{Seg: "ops"}, nil |
| 578 | } |
| 579 | switch len(accts) { |
| 580 | case 0: |
| 581 | return apiAcct{}, fmt.Errorf("your address belongs to no account on %s — create one with: carlos accounts create --name <name>", s.console) |
| 582 | case 1: |
| 583 | mutedf(stdout, "account: %s (%s)", accts[0].Name, accts[0].Sqid) |
| 584 | return apiAcct{Seg: accts[0].Sqid, Exact: true}, nil |
| 585 | } |
| 586 | if !interactiveTerminal() { |
| 587 | return apiAcct{}, severalAccountsErr(accts) |
| 588 | } |
| 589 | // ./.carlos/config in the working directory — the same "run it where |
| 590 | // you want the file" rule as `auth default --project`. Except from |
| 591 | // $HOME, where that path IS ~/.carlos/config: a y there would silently |
| 592 | // repoint the machine-wide default console from inside a ship, so the |
| 593 | // save is not offered at all. |
| 594 | dest := filepath.Join(".carlos", "config") |
| 595 | if abs, err := filepath.Abs(dest); err == nil { |
| 596 | if gp, gerr := globalConfigPath(); gerr == nil && abs == gp { |
| 597 | dest = "" |
| 598 | } |
| 599 | } |
| 600 | if s.deferSaves { |
| 601 | // Zero-arg deploy makes ONE combined save offer at the end of |
| 602 | // first-run resolution — the chooser's own prompt would be a |
| 603 | // second question about the same file. |
| 604 | dest = "" |
| 605 | } |
| 606 | picked, save, err := chooseAccount(chooserIn, stdout, s.console, accts, dest) |
| 607 | if err != nil { |
| 608 | return apiAcct{}, err |
| 609 | } |
| 610 | if save && dest != "" { |
| 611 | // The save is bookkeeping, never a precondition: the human already |
| 612 | // answered, the account is known, and a read-only checkout must |
| 613 | // not turn that answer into a dead ship. |
| 614 | if err := saveConfigAccount(dest, s.console, picked.Name, 0o755); err != nil { |
| 615 | warnf(stdout, "not saved (%s): %v", dest, err) |
| 616 | } else { |
| 617 | mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, dest) |
| 618 | } |
| 619 | } |
| 620 | return apiAcct{Seg: picked.Sqid, Exact: true}, nil |
| 621 | } |
| 622 | |
| 623 | // noAppsError is defaultApp's "this account owns nothing yet" refusal — |
| 624 | // typed so zero-arg deploy can turn it into the create-an-app offer while |
| 625 | // every other verb just prints it. |
| 626 | type noAppsError struct{ Account string } |
| 627 | |
| 628 | func (e *noAppsError) Error() string { |
| 629 | return fmt.Sprintf("account %s has no apps — create one with: carlos apps create --app <name>", e.Account) |
| 630 | } |
| 631 | |
| 632 | // defaultApp answers an omitted --app (2026-08-13 project-defaults spec): |
| 633 | // the config default when its file's console pairs with this session, |
| 634 | // else the resolved account's one app, else the chooser (terminal) or the |
| 635 | // naming refusal (script). "" with a nil error means this console cannot |
| 636 | // answer (pre-listing console, or an account the memberships response |
| 637 | // does not name) — the caller falls back to its own usage error, exactly |
| 638 | // the pre-spec behavior. offerSave=false lets zero-arg deploy fold the |
| 639 | // answer into its one combined save instead of prompting twice. |
| 640 | func (s *apiSession) defaultApp(ctx context.Context, acct apiAcct, offerSave bool) (string, error) { |
| 641 | if s.defApp != "" { |
| 642 | return s.defApp, nil |
| 643 | } |
| 644 | name, src, fileConsole, err := configApp() |
| 645 | if err != nil { |
| 646 | return "", err |
| 647 | } |
| 648 | if name != "" && fileConsole == s.console { |
| 649 | mutedf(stdout, "app: %s (the default from %s)", name, src) |
| 650 | s.defApp = name |
| 651 | return name, nil |
| 652 | } |
| 653 | accts, supported, err := s.listAccounts(ctx) |
| 654 | if err != nil { |
| 655 | return "", err |
| 656 | } |
| 657 | if !supported { |
| 658 | return "", nil |
| 659 | } |
| 660 | var entry *apiAccountEntry |
| 661 | for i := range accts { |
| 662 | if accts[i].Sqid == acct.Seg || accts[i].Name == acct.Seg { |
| 663 | entry = &accts[i] |
| 664 | break |
| 665 | } |
| 666 | } |
| 667 | if entry == nil { |
| 668 | // An explicit --account this token's memberships don't name: the |
| 669 | // verb's own request will answer that (the membership 404) — app |
| 670 | // resolution has nothing to add. |
| 671 | return "", nil |
| 672 | } |
| 673 | switch len(entry.Apps) { |
| 674 | case 0: |
| 675 | return "", &noAppsError{Account: entry.Name} |
| 676 | case 1: |
| 677 | mutedf(stdout, "app: %s", entry.Apps[0]) |
| 678 | s.defApp = entry.Apps[0] |
| 679 | return entry.Apps[0], nil |
| 680 | } |
| 681 | if !interactiveTerminal() { |
| 682 | return "", severalAppsErr(entry.Name, entry.Apps) |
| 683 | } |
| 684 | picked, err := chooseOne(chooserIn, stdout, |
| 685 | fmt.Sprintf("account %s has several apps:", entry.Name), "app", |
| 686 | entry.Apps, severalAppsErr(entry.Name, entry.Apps)) |
| 687 | if err != nil { |
| 688 | return "", err |
| 689 | } |
| 690 | s.defApp = picked |
| 691 | s.appPrompted = true |
| 692 | if offerSave && !s.deferSaves { |
| 693 | offerSaveProjectDefaults(s.console, entry.Name, map[string]any{"app": picked}, |
| 694 | fmt.Sprintf("save %s as this project's default app", picked)) |
| 695 | } |
| 696 | return picked, nil |
| 697 | } |
| 698 | |
| 699 | // offerSaveProjectDefaults is the one save prompt every resolution path |
| 700 | // shares: console and account always land with whatever extra keys the |
| 701 | // caller resolved (the file is read as pairs, and a default without its |
| 702 | // console would silently stop applying). Skipped from $HOME (the |
| 703 | // "project" file would be the global config) and never fatal — the |
| 704 | // answers are already in hand, and bookkeeping must not kill the verb. |
| 705 | func offerSaveProjectDefaults(console, account string, extra map[string]any, question string) { |
| 706 | dest := filepath.Join(".carlos", "config") |
| 707 | if abs, err := filepath.Abs(dest); err == nil { |
| 708 | if gp, gerr := globalConfigPath(); gerr == nil && abs == gp { |
| 709 | return |
| 710 | } |
| 711 | } |
| 712 | if !promptYes(stdout, question+" ("+dest+")? [y/N] ") { |
| 713 | return |
| 714 | } |
| 715 | set := map[string]any{"console": console} |
| 716 | if account != "" { |
| 717 | set["account"] = account |
| 718 | } |
| 719 | for k, v := range extra { |
| 720 | set[k] = v |
| 721 | } |
| 722 | if err := writeConfigKeys(dest, set, 0o755); err != nil { |
| 723 | warnf(stdout, "not saved (%s): %v", dest, err) |
| 724 | } else { |
| 725 | mutedf(stdout, "saved: project defaults in %s", dest) |
| 726 | } |
| 727 | } |
| 728 | |
| 729 | // resolvedApp is defaultApp's verb-facing wrapper, resolvedAccount's rule |
| 730 | // for the sibling flag: explicit --app travels verbatim; an empty one |
| 731 | // resolves or dies on the verb's own usage message when this console |
| 732 | // cannot answer. Fatal for the same reason resolvedAccount is — every |
| 733 | // caller's next line is the request itself. |
| 734 | func resolvedApp(sess *apiSession, acct apiAcct, app, usage string) string { |
| 735 | if app != "" { |
| 736 | return app |
| 737 | } |
| 738 | r, err := sess.defaultApp(context.Background(), acct, true) |
| 739 | if err != nil { |
| 740 | log.Fatal(err) |
| 741 | } |
| 742 | if r == "" { |
| 743 | log.Fatal(usage) |
| 744 | } |
| 745 | return r |
| 746 | } |
| 747 | |
| 748 | // resolvedAccount is apiAccount plus the omitted-flag default: the seam |
| 749 | // every API-mode verb reaches through. Explicit flags travel verbatim and |
| 750 | // never touch the network; only an empty pair asks defaultAccount. Fatal |
| 751 | // on refusal because every caller is a verb body whose next line would be |
| 752 | // the request itself — there is nothing for it to do with the error but |
| 753 | // print it and stop. |
| 754 | func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct { |
| 755 | a := apiAccount(raw, rawID) |
| 756 | if a.Seg != "" { |
| 757 | return a |
| 758 | } |
| 759 | if sess == nil { |
| 760 | // Unreachable from any current verb (each guards its mode before |
| 761 | // resolving), and kept LOUD: returning the empty segment would |
| 762 | // build /api/cli/apps//<app> and answer a client-side bug with the |
| 763 | // membership-404 riddle. |
| 764 | log.Fatal("resolvedAccount: no session — a direct-mode path reached API account resolution (a bug in this binary)") |
| 765 | } |
| 766 | r, err := sess.defaultAccount(context.Background()) |
| 767 | if err != nil { |
| 768 | log.Fatal(err) |
| 769 | } |
| 770 | return r |
| 771 | } |
| 772 | |
| 773 | // notFoundErr is what every call here returns for a 404 whose body carries |
| 774 | // no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers |
| 775 | // an unknown account, an unknown app, and someone else's account with the |
| 776 | // SAME http.NotFound — a byte-identical text/plain body, not JSON, by design |
| 777 | // (a token holder must not learn which of those three it was). This is the |
| 778 | // one sane message for all three. A 404 that DOES carry an "error" field |
| 779 | // (an unshipped version on promote, a channel with no history on rollback) |
| 780 | // is a genuine server diagnosis, and apiErrorFromBody surfaces THAT text |
| 781 | // instead — never rewritten into this one. |
| 782 | func notFoundErr() error { |
| 783 | return errMembershipNotFound |
| 784 | } |
| 785 | |
| 786 | // errMembershipNotFound is notFoundErr's one value, so a caller that can |
| 787 | // narrow the riddle (recordNotFound, instances_cli.go) can recognise it with |
| 788 | // errors.Is rather than by matching its text. |
| 789 | var errMembershipNotFound = errors.New("not found: check --account and --app, and that your token's address is a member") |
| 790 | |
| 791 | // hasJSONError reports whether a body carries the {"error": "..."} shape — |
| 792 | // the difference between a door's own diagnosis and the mux's byte-identical |
| 793 | // text/plain refusal. apiErrorFromBody asks it to decide which message to |
| 794 | // give; a caller with a better sentence for the second case (relayRotate) |
| 795 | // asks the same question rather than string-matching the answer. |
| 796 | func hasJSONError(body []byte) bool { |
| 797 | var e struct { |
| 798 | Error string `json:"error"` |
| 799 | } |
| 800 | return json.Unmarshal(body, &e) == nil && e.Error != "" |
| 801 | } |
| 802 | |
| 803 | // apiErrorFromBody turns a non-2xx status and its raw body into an error. |
| 804 | // The JSON {"error": "..."} shape is tried FIRST, whatever the status code — |
| 805 | // several doors (promote's unshipped-version 404, rollback's no-history 409) |
| 806 | // answer a real diagnosis worth showing verbatim. Only when the body has no |
| 807 | // such field at all — exactly what apiAccountApp's gate sends, a text/plain |
| 808 | // "404 page not found" — does a 404 fall back to the generic membership |
| 809 | // message; every other unreadable body just reports its status. |
| 810 | func apiErrorFromBody(status int, body []byte) error { |
| 811 | var e struct { |
| 812 | Error string `json:"error"` |
| 813 | } |
| 814 | if json.Unmarshal(body, &e) == nil && e.Error != "" { |
| 815 | return errors.New(e.Error) |
| 816 | } |
| 817 | if status == http.StatusNotFound { |
| 818 | return notFoundErr() |
| 819 | } |
| 820 | return fmt.Errorf("unexpected response (status %d)", status) |
| 821 | } |
| 822 | |
| 823 | // getJSON runs one JSON GET and hands back the status and raw body without |
| 824 | // interpreting a non-200 as an error itself, for the same reason postJSON |
| 825 | // doesn't: a caller here needs the FULL body (to try apiErrorFromBody's |
| 826 | // json-first decode), not just a pre-picked message. |
| 827 | func (s *apiSession) getJSON(ctx context.Context, path string) (int, []byte, error) { |
| 828 | status, _, body, err := s.getJSONHeader(ctx, path) |
| 829 | return status, body, err |
| 830 | } |
| 831 | |
| 832 | // getJSONHeader is getJSON that also hands back the response headers, for |
| 833 | // the caller that reads one (Retry-After). |
| 834 | func (s *apiSession) getJSONHeader(ctx context.Context, path string) (int, http.Header, []byte, error) { |
| 835 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.c.base+path, nil) |
| 836 | if err != nil { |
| 837 | return 0, nil, nil, err |
| 838 | } |
| 839 | if s.c.token != "" { |
| 840 | req.Header.Set("Authorization", "Bearer "+s.c.token) |
| 841 | } |
| 842 | res, err := s.c.httpc.Do(req) |
| 843 | if err != nil { |
| 844 | return 0, nil, nil, err |
| 845 | } |
| 846 | defer res.Body.Close() |
| 847 | if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { |
| 848 | return res.StatusCode, res.Header, nil, offerConsoleMoved(merr) |
| 849 | } |
| 850 | body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| 851 | if err != nil { |
| 852 | return res.StatusCode, res.Header, nil, err |
| 853 | } |
| 854 | return res.StatusCode, res.Header, body, nil |
| 855 | } |
| 856 | |
| 857 | // sendJSON runs one JSON request over the given method and hands back the |
| 858 | // status and raw body without interpreting a non-200 as an error itself — |
| 859 | // the one request-building body postJSON/putJSON/patchJSON share (Task 8 |
| 860 | // review: three byte-identical copies, method aside, was past the line). |
| 861 | // Unlike (*consoleClient).do, which keeps only a bare "error" string, some |
| 862 | // callers (promote, rollback) need the FULL body on a stable refusal — its |
| 863 | // console_url would otherwise be discarded before the caller ever sees it — |
| 864 | // which is why this returns the raw body rather than pre-deciding what |
| 865 | // counts as an error. |
| 866 | func (s *apiSession) sendJSON(ctx context.Context, method, path string, in any) (int, []byte, error) { |
| 867 | raw, err := json.Marshal(in) |
| 868 | if err != nil { |
| 869 | return 0, nil, err |
| 870 | } |
| 871 | req, err := http.NewRequestWithContext(ctx, method, s.c.base+path, bytes.NewReader(raw)) |
| 872 | if err != nil { |
| 873 | return 0, nil, err |
| 874 | } |
| 875 | req.Header.Set("Content-Type", "application/json") |
| 876 | if s.c.token != "" { |
| 877 | req.Header.Set("Authorization", "Bearer "+s.c.token) |
| 878 | } |
| 879 | res, err := s.c.httpc.Do(req) |
| 880 | if err != nil { |
| 881 | return 0, nil, err |
| 882 | } |
| 883 | defer res.Body.Close() |
| 884 | if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { |
| 885 | return res.StatusCode, nil, offerConsoleMoved(merr) |
| 886 | } |
| 887 | body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| 888 | if err != nil { |
| 889 | return res.StatusCode, nil, err |
| 890 | } |
| 891 | return res.StatusCode, body, nil |
| 892 | } |
| 893 | |
| 894 | // postJSON is sendJSON pinned to POST — the console door shape a create or |
| 895 | // an action verb (promote, restart, ...) always speaks. |
| 896 | func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []byte, error) { |
| 897 | return s.sendJSON(ctx, http.MethodPost, path, in) |
| 898 | } |
| 899 | |
| 900 | // consoleRefusal decodes the generalized {"error", "console_url"} 403 shape |
| 901 | // every guarded write door in this family answers with — handleAPIPromote |
| 902 | // and handleAPIRollback's stable/passkey refusal (apicli.go) and |
| 903 | // handleAPIPipelinePut's own passkey-rule refusal (release pipelines v2 |
| 904 | // §5) alike — and, when it matches, returns the error the caller should |
| 905 | // see: the console's own refusal text plus where to finish the job. A |
| 906 | // passkey step-up has no bearer-token equivalent, so a channel that demands |
| 907 | // one always ends here, never on this machine. Named for what it now covers |
| 908 | // (not only stable) since Task 10 widened its call sites beyond promote and |
| 909 | // rollback to `carlos pipeline`'s own writes. |
| 910 | func consoleRefusal(status int, body []byte) (error, bool) { |
| 911 | if status != http.StatusForbidden { |
| 912 | return nil, false |
| 913 | } |
| 914 | var refusal struct { |
| 915 | Error string `json:"error"` |
| 916 | ConsoleURL string `json:"console_url"` |
| 917 | } |
| 918 | if json.Unmarshal(body, &refusal) != nil || refusal.Error == "" { |
| 919 | return nil, false |
| 920 | } |
| 921 | return fmt.Errorf("%s\n %s", refusal.Error, refusal.ConsoleURL), true |
| 922 | } |
| 923 | |
| 924 | // apiProposedError is what a guarded write (promote, rollback, or a |
| 925 | // `carlos pipeline` edit that touches an already-guarded channel) returns |
| 926 | // instead of landing: the console answered 202, spec §4's bearer-token |
| 927 | // shape — a token can never carry a passkey quorum, so the change is |
| 928 | // recorded at zero confirmations and must be finished in the console. Every |
| 929 | // caller here must check for this with errors.As and print it rather than |
| 930 | // let it fall through to apiErrorFromBody, which would otherwise report the |
| 931 | // misleading "unexpected response (status 202)" (the CLI-lies-about-its- |
| 932 | // outcome bug this type exists to close). |
| 933 | type apiProposedError struct { |
| 934 | PendingID string |
| 935 | Needed int |
| 936 | Confirmed int |
| 937 | ConsoleURL string |
| 938 | } |
| 939 | |
| 940 | func (e *apiProposedError) Error() string { |
| 941 | return fmt.Sprintf("proposed — needs %d confirmation(s); finish at %s", e.Needed, e.ConsoleURL) |
| 942 | } |
| 943 | |
| 944 | // proposedFromBody decodes a 202's body — proposeOverAPI's |
| 945 | // {"result","pending_id","needed","confirmed","console_url"} shape |
| 946 | // (pendinghandlers.go) — into an *apiProposedError. The body is decoded |
| 947 | // best-effort: even an unreadable one still yields a usable error, because |
| 948 | // the status code alone already says "proposed", and dropping the whole |
| 949 | // thing here would lose the console_url an operator needs to actually |
| 950 | // finish the job. |
| 951 | func proposedFromBody(body []byte) error { |
| 952 | var res struct { |
| 953 | PendingID string `json:"pending_id"` |
| 954 | Needed int `json:"needed"` |
| 955 | Confirmed int `json:"confirmed"` |
| 956 | ConsoleURL string `json:"console_url"` |
| 957 | } |
| 958 | json.Unmarshal(body, &res) |
| 959 | return &apiProposedError{PendingID: res.PendingID, Needed: res.Needed, Confirmed: res.Confirmed, ConsoleURL: res.ConsoleURL} |
| 960 | } |
| 961 | |
| 962 | // promote asks the console to promote version onto channel. The response's |
| 963 | // "result" is the one field read here — "noop" when the ladder already had |
| 964 | // this version on this channel — because everything else the response |
| 965 | // carries (RUNG names in From/To) is not what the caller prints: the CLI |
| 966 | // already knows the version/channel it asked for and prints those, never |
| 967 | // res.From/res.To. |
| 968 | // |
| 969 | // hotfix rides along as a plain field (release pipelines v2, Task 10): the |
| 970 | // console's promote door reads it strictly after its own rules decision |
| 971 | // (handleAPIPromote's doc comment), so sending it is never itself a bypass |
| 972 | // of anything this call site doesn't already ask for. |
| 973 | func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string, hotfix, retry bool, messages ...string) (apiPromoteResult, error) { |
| 974 | message := firstMutationMessage(messages) |
| 975 | in := struct { |
| 976 | Version string `json:"version"` |
| 977 | Channel string `json:"channel"` |
| 978 | Hotfix bool `json:"hotfix,omitempty"` |
| 979 | Retry bool `json:"retry,omitempty"` |
| 980 | Message string `json:"message,omitempty"` |
| 981 | }{version, channel, hotfix, retry, message} |
| 982 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/promote"), in) |
| 983 | if err != nil { |
| 984 | return apiPromoteResult{}, err |
| 985 | } |
| 986 | if status == http.StatusOK { |
| 987 | var res struct { |
| 988 | Result string `json:"result"` |
| 989 | Held bool `json:"held"` |
| 990 | HeldUntil string `json:"held_until"` |
| 991 | } |
| 992 | if err := json.Unmarshal(body, &res); err != nil { |
| 993 | return apiPromoteResult{}, fmt.Errorf("promote: unreadable response: %w", err) |
| 994 | } |
| 995 | return apiPromoteResult{NoOp: res.Result == "noop", Held: res.Held, HeldUntil: res.HeldUntil}, nil |
| 996 | } |
| 997 | if status == http.StatusAccepted { |
| 998 | return apiPromoteResult{}, proposedFromBody(body) |
| 999 | } |
| 1000 | if refErr, ok := consoleRefusal(status, body); ok { |
| 1001 | return apiPromoteResult{}, refErr |
| 1002 | } |
| 1003 | return apiPromoteResult{}, apiErrorFromBody(status, body) |
| 1004 | } |
| 1005 | |
| 1006 | // apiCreateAppResult carries what `carlos apps create` prints: the console's |
| 1007 | // canonical account sqid (the segment the caller typed may have been a |
| 1008 | // name), the normalized app name, and the app's console page. |
| 1009 | // Placement is what the console actually stored for the "place" field this |
| 1010 | // call's request carried — "" for both "no place was requested" and "a |
| 1011 | // place WAS requested but did not stick" (an older console that never |
| 1012 | // echoes the field at all leaves this at the same "" zero value |
| 1013 | // encoding/json gives an explicit empty string, which is exactly what makes |
| 1014 | // it usable as the one signal runAppsCreate needs — see |
| 1015 | // placementMismatchErr, apps.go). ControlPlane and BuildProbe are the same |
| 1016 | // kind of echo for the two claim-time flags (Task 3): what the console |
| 1017 | // actually stamped on the row, read back after the write, not merely what |
| 1018 | // the request asked for. |
| 1019 | type apiCreateAppResult struct { |
| 1020 | Account string `json:"account"` |
| 1021 | App string `json:"app"` |
| 1022 | URL string `json:"url"` |
| 1023 | Placement string `json:"placement"` |
| 1024 | ControlPlane bool `json:"control_plane"` |
| 1025 | BuildProbe string `json:"build_probe"` |
| 1026 | |
| 1027 | // The app region the console STORED (nearest-region spec §9.1), under |
| 1028 | // its name. Absent from an older console, which the CLI reads as "no |
| 1029 | // app region recorded". |
| 1030 | Region string `json:"region"` |
| 1031 | RegionName string `json:"region_name"` |
| 1032 | RegionSource string `json:"region_source"` |
| 1033 | RegionsAvailable int `json:"regions_available"` |
| 1034 | } |
| 1035 | |
| 1036 | // createApp claims one app name through the console — the API door onto the |
| 1037 | // same claim the browser's New app form makes. The path is the literal |
| 1038 | // `new` segment (mirroring /a/{sqid}/apps/new), which apiPath builds when |
| 1039 | // handed "new" as the app. |
| 1040 | // |
| 1041 | // place is "" or a "fleet/<name>" target (runAppsCreate's --place flag, |
| 1042 | // already grammar-checked client-side before this is ever called) — the |
| 1043 | // server (handleAPICreateApp, internal/console/apicli.go) applies the SAME |
| 1044 | // two checks `carlos apps place` uses on its own target (grammar again, |
| 1045 | // server-side, plus fleet ownership) before the app is created, so an |
| 1046 | // invalid or foreign place refuses the whole create rather than silently |
| 1047 | // creating an unplaced app. |
| 1048 | // |
| 1049 | // controlPlane and buildProbe are the claim-time flags Task 3 adds |
| 1050 | // (-control-plane, -build-probe): carried in the SAME request that claims |
| 1051 | // the name, so no SECOND round trip is needed to flag the row. That is not |
| 1052 | // the same as atomicity — server-side (handleAPICreateApp, |
| 1053 | // internal/console/apicli.go) it is still two writes inside one handler, |
| 1054 | // the claim then the stamps, and that handler's own comment is the honest |
| 1055 | // account of the window that leaves. The client's job is only to make sure |
| 1056 | // the flags travel with the claim rather than after it. |
| 1057 | // |
| 1058 | // controlPlane costs an owner-only 403 |
| 1059 | // (server-side; there is no client-side role check possible here) and |
| 1060 | // buildProbe is grammar-checked client-side before this is ever called |
| 1061 | // (validBuildProbe, apps.go), same "a shape the server is certain to |
| 1062 | // refuse should cost nothing" principle as place. |
| 1063 | func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error) { |
| 1064 | in := struct { |
| 1065 | Name string `json:"name"` |
| 1066 | Place string `json:"place,omitempty"` |
| 1067 | ControlPlane bool `json:"control_plane,omitempty"` |
| 1068 | BuildProbe string `json:"build_probe,omitempty"` |
| 1069 | Region string `json:"region,omitempty"` |
| 1070 | Timezone string `json:"timezone,omitempty"` |
| 1071 | }{app, place, controlPlane, buildProbe, region, timezone} |
| 1072 | status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in) |
| 1073 | if err != nil { |
| 1074 | return apiCreateAppResult{}, err |
| 1075 | } |
| 1076 | if status == http.StatusNotFound { |
| 1077 | // Unlike every other verb's 404 there is no --app to second-guess: |
| 1078 | // the create door only 404s on the account. |
| 1079 | return apiCreateAppResult{}, errors.New("not found: check --account, and that your token's address is a member") |
| 1080 | } |
| 1081 | if status != http.StatusOK { |
| 1082 | return apiCreateAppResult{}, apiErrorFromBody(status, body) |
| 1083 | } |
| 1084 | var res apiCreateAppResult |
| 1085 | if err := json.Unmarshal(body, &res); err != nil { |
| 1086 | return apiCreateAppResult{}, fmt.Errorf("apps create: unreadable response: %w", err) |
| 1087 | } |
| 1088 | return res, nil |
| 1089 | } |
| 1090 | |
| 1091 | // apiAppDefault, apiRegionEntry and apiRegionsResult are the CLI's view of |
| 1092 | // GET …/regions (nearest-region spec §9.3). AppDefault is nil from a |
| 1093 | // console that predates it. Region is the STORED region in the console's own |
| 1094 | // vocabulary: a home app reports the home's code, never "". |
| 1095 | type apiAppDefault struct { |
| 1096 | Region string `json:"region"` |
| 1097 | Name string `json:"name"` |
| 1098 | Source string `json:"source"` |
| 1099 | Effective string `json:"effective"` |
| 1100 | Reason string `json:"reason"` |
| 1101 | Available *bool `json:"available"` |
| 1102 | RegionKnown bool `json:"region_known"` |
| 1103 | PlacementKnown bool `json:"placement_known"` |
| 1104 | InstanceDomain string `json:"instance_domain"` |
| 1105 | } |
| 1106 | |
| 1107 | type apiRegionEntry struct { |
| 1108 | Code string `json:"code"` |
| 1109 | Name string `json:"name"` |
| 1110 | } |
| 1111 | |
| 1112 | type apiRegionsResult struct { |
| 1113 | Regions []apiRegionEntry `json:"regions"` |
| 1114 | Default string `json:"default"` |
| 1115 | AppDefault *apiAppDefault `json:"app_default"` |
| 1116 | } |
| 1117 | |
| 1118 | // nameOf is the place name the console gave code, else the code itself. |
| 1119 | func (r apiRegionsResult) nameOf(code string) string { |
| 1120 | for _, e := range r.Regions { |
| 1121 | if e.Code == code && e.Name != "" { |
| 1122 | return e.Name |
| 1123 | } |
| 1124 | } |
| 1125 | return code |
| 1126 | } |
| 1127 | |
| 1128 | // appRegions reads GET …/regions for app: the regions the console knows and, |
| 1129 | // from a console that has it, the app's own default. |
| 1130 | func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error) { |
| 1131 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/regions")) |
| 1132 | if err != nil { |
| 1133 | return apiRegionsResult{}, err |
| 1134 | } |
| 1135 | if status != http.StatusOK { |
| 1136 | return apiRegionsResult{}, apiErrorFromBody(status, body) |
| 1137 | } |
| 1138 | var out apiRegionsResult |
| 1139 | if err := json.Unmarshal(body, &out); err != nil { |
| 1140 | return apiRegionsResult{}, fmt.Errorf("regions: unreadable response: %w", err) |
| 1141 | } |
| 1142 | return out, nil |
| 1143 | } |
| 1144 | |
| 1145 | // rollback asks the console to roll channel back to its previous version. |
| 1146 | // There is no noop case for a rollback — it either has a previous version to |
| 1147 | // land on (409 if not) or it doesn't. It DOES have a proposed case, exactly |
| 1148 | // as promote does: a pipelined channel that demands promote_approvals turns |
| 1149 | // this into a 202 (handleAPIRollback, apicli.go), which is why this can no |
| 1150 | // longer stay error-or-nil — *apiProposedError is a real, expected return |
| 1151 | // alongside a genuine failure, and runRollback tells them apart with |
| 1152 | // errors.As. |
| 1153 | func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel string, messages ...string) error { |
| 1154 | message := firstMutationMessage(messages) |
| 1155 | in := struct { |
| 1156 | Channel string `json:"channel"` |
| 1157 | Message string `json:"message,omitempty"` |
| 1158 | }{channel, message} |
| 1159 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/rollback"), in) |
| 1160 | if err != nil { |
| 1161 | return err |
| 1162 | } |
| 1163 | if status == http.StatusOK { |
| 1164 | return nil |
| 1165 | } |
| 1166 | if status == http.StatusAccepted { |
| 1167 | return proposedFromBody(body) |
| 1168 | } |
| 1169 | if refErr, ok := consoleRefusal(status, body); ok { |
| 1170 | return refErr |
| 1171 | } |
| 1172 | return apiErrorFromBody(status, body) |
| 1173 | } |
| 1174 | |
| 1175 | // apiRestartResult is what `carlos restart` prints: the console's CANONICAL |
| 1176 | // account sqid and app name, since the segment the caller typed may have been |
| 1177 | // a name resolved server-side. |
| 1178 | type apiRestartResult struct { |
| 1179 | Account string `json:"account"` |
| 1180 | App string `json:"app"` |
| 1181 | } |
| 1182 | |
| 1183 | // dbClient is the `carlos db` replica client for one instance (db.go): a |
| 1184 | // read-only litestream.ReplicaClient whose every request is a GET through |
| 1185 | // this session's console, as this session's person. The account segment |
| 1186 | // and its ?exact=sqid switch ride on apiPath, exactly as every other verb's |
| 1187 | // do; the client itself only ever names a coordinate. |
| 1188 | func (s *apiSession) dbClient(a apiAcct, app, host string) *dbdoor.Client { |
| 1189 | return dbdoor.NewClient(func(ctx context.Context, tail string, h http.Header) (*http.Response, error) { |
| 1190 | return s.c.doStream(ctx, http.MethodGet, apiPath(a, app, "/db/"+url.PathEscape(host)+tail), nil, -1, h) |
| 1191 | }) |
| 1192 | } |
| 1193 | |
| 1194 | // restart asks the console to request a process cycle for app on every box |
| 1195 | // that serves it (issue #113). Mirrors trashApp's shape — postJSON, a 200 |
| 1196 | // decodes, anything else is apiErrorFromBody's job — with ONE addition, the |
| 1197 | // 404. |
| 1198 | // |
| 1199 | // A 404 here is genuinely ambiguous, and pretending otherwise would be a lie |
| 1200 | // in one direction or the other. An old console has no /restart route at all, |
| 1201 | // so net/http's mux answers exactly the same byte-identical "404 page not |
| 1202 | // found" apiAccountApp gives an unknown account, someone else's account, or an |
| 1203 | // app that isn't there — there is nothing in the response to tell them apart. |
| 1204 | // So the message names BOTH, shortest fix first, the same way restoreApp |
| 1205 | // widens its own 404 rather than inventing certainty it doesn't have. |
| 1206 | func (s *apiSession) restart(ctx context.Context, a apiAcct, app string, messages ...string) (apiRestartResult, error) { |
| 1207 | message := firstMutationMessage(messages) |
| 1208 | in := struct { |
| 1209 | Message string `json:"message,omitempty"` |
| 1210 | }{message} |
| 1211 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart"), in) |
| 1212 | if err != nil { |
| 1213 | return apiRestartResult{}, err |
| 1214 | } |
| 1215 | if status != http.StatusOK { |
| 1216 | if status == http.StatusNotFound { |
| 1217 | return apiRestartResult{}, fmt.Errorf("%w — or this console predates `carlos restart` "+ |
| 1218 | "(no restart door on it at all), in which case redeploy the console first", notFoundErr()) |
| 1219 | } |
| 1220 | return apiRestartResult{}, apiErrorFromBody(status, body) |
| 1221 | } |
| 1222 | var res apiRestartResult |
| 1223 | if err := json.Unmarshal(body, &res); err != nil { |
| 1224 | return apiRestartResult{}, fmt.Errorf("restart: unreadable response: %w", err) |
| 1225 | } |
| 1226 | return res, nil |
| 1227 | } |
| 1228 | |
| 1229 | // apiRestartScopeRoute is one route a narrowed restart addressed — the |
| 1230 | // console's answer to "what exactly is about to stop". Hibernate is not |
| 1231 | // decoration: a hibernating route is named AND left asleep, and an operator |
| 1232 | // who sees it listed with no explanation would read that as a failure. |
| 1233 | type apiRestartScopeRoute struct { |
| 1234 | Host string `json:"host"` |
| 1235 | Environment string `json:"environment"` |
| 1236 | Unit string `json:"unit"` |
| 1237 | Hibernate bool `json:"hibernate"` |
| 1238 | } |
| 1239 | |
| 1240 | // apiRestartScopeResult is what the narrowed door answers with. Requested is |
| 1241 | // FALSE for a dry run and true otherwise, so the CLI never has to infer from |
| 1242 | // its own flags what the console did — the console says. |
| 1243 | type apiRestartScopeResult struct { |
| 1244 | Account string `json:"account"` |
| 1245 | App string `json:"app"` |
| 1246 | Scope struct { |
| 1247 | Kind string `json:"kind"` |
| 1248 | Value string `json:"value"` |
| 1249 | } `json:"scope"` |
| 1250 | DryRun bool `json:"dry_run"` |
| 1251 | Requested bool `json:"requested"` |
| 1252 | Routes []apiRestartScopeRoute `json:"routes"` |
| 1253 | } |
| 1254 | |
| 1255 | // restartScoped asks the console to cycle ONE route, or everything reading one |
| 1256 | // config environment — or, with dryRun, to say what that would be and touch |
| 1257 | // nothing (discussion #16). |
| 1258 | // |
| 1259 | // A separate path from restart() above, not a body on it, and the 404 message |
| 1260 | // is where that shows. The old door reads no body at all: a narrowed request |
| 1261 | // sent there would be answered by restarting every instance the app owns, |
| 1262 | // which is the accident this exists to prevent. So the CLI asks a path an old |
| 1263 | // console does not serve, gets net/http's byte-identical "404 page not found", |
| 1264 | // and says all three things it could mean — shortest fix first — the way |
| 1265 | // restart() widens its own. |
| 1266 | func (s *apiSession) restartScoped(ctx context.Context, a apiAcct, app, host, environment string, dryRun bool, messages ...string) (apiRestartScopeResult, error) { |
| 1267 | message := firstMutationMessage(messages) |
| 1268 | in := struct { |
| 1269 | Host string `json:"host"` |
| 1270 | Environment string `json:"environment"` |
| 1271 | DryRun bool `json:"dry_run"` |
| 1272 | Message string `json:"message,omitempty"` |
| 1273 | }{host, environment, dryRun, message} |
| 1274 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart/scope"), in) |
| 1275 | if err != nil { |
| 1276 | return apiRestartScopeResult{}, err |
| 1277 | } |
| 1278 | if status != http.StatusOK { |
| 1279 | if status == http.StatusNotFound { |
| 1280 | return apiRestartScopeResult{}, fmt.Errorf("%w — or this console predates narrowed restarts "+ |
| 1281 | "(--host, --environment, --dry-run), in which case redeploy the console first; "+ |
| 1282 | "`carlos restart --app` still works on it, but cycles every instance the app has", notFoundErr()) |
| 1283 | } |
| 1284 | return apiRestartScopeResult{}, apiErrorFromBody(status, body) |
| 1285 | } |
| 1286 | var res apiRestartScopeResult |
| 1287 | if err := json.Unmarshal(body, &res); err != nil { |
| 1288 | return apiRestartScopeResult{}, fmt.Errorf("restart: unreadable response: %w", err) |
| 1289 | } |
| 1290 | return res, nil |
| 1291 | } |
| 1292 | |
| 1293 | // apiScheduleList mirrors handleAPIScheduleList's wire shape |
| 1294 | // (internal/console/apischedule.go's scheduleAnswer) byte for byte — this |
| 1295 | // binary never imports internal/console, so the shape is redeclared here |
| 1296 | // rather than shared. Three of the four schedule doors answer with this; |
| 1297 | // scheduleRun answers with its own small {"requested":...} shape instead, |
| 1298 | // since a run touches no declaration. |
| 1299 | type apiScheduleList struct { |
| 1300 | Account string `json:"account"` |
| 1301 | App string `json:"app"` |
| 1302 | Schedules []schedule.Schedule `json:"schedules"` |
| 1303 | Instances []schedule.Status `json:"instances"` |
| 1304 | UpdatedAt string `json:"updated_at"` |
| 1305 | } |
| 1306 | |
| 1307 | // scheduleDoorNotFoundErr is restart's own 404 widening, renamed: ls and |
| 1308 | // set carry no {name} segment, so a 404 from either can only be the |
| 1309 | // account/app membership gate (apiAccountApp) or a console that predates |
| 1310 | // this door at all. |
| 1311 | func scheduleDoorNotFoundErr() error { |
| 1312 | return fmt.Errorf("%w — or this console predates `carlos schedule` "+ |
| 1313 | "(no schedule door on it at all), in which case redeploy the console first", notFoundErr()) |
| 1314 | } |
| 1315 | |
| 1316 | // scheduleNameNotFoundErr widens scheduleDoorNotFoundErr for rm and run: |
| 1317 | // their {name} segment adds a THIRD cause behind the same byte-identical |
| 1318 | // 404 (declaredSchedule, internal/console/apischedule.go, answers an |
| 1319 | // undeclared name with the same http.NotFound apiAccountApp's membership |
| 1320 | // gate uses) — a probe must not be able to tell any of the three apart, so |
| 1321 | // the message names all of them, cheapest fix first. |
| 1322 | func scheduleNameNotFoundErr() error { |
| 1323 | return fmt.Errorf("%w, or --name is not declared — or this console predates `carlos schedule` "+ |
| 1324 | "(no schedule door on it at all), in which case redeploy the console first", notFoundErr()) |
| 1325 | } |
| 1326 | |
| 1327 | // scheduleList asks the console what app has declared plus what every |
| 1328 | // instance last reported (handleAPIScheduleList). A read: no write budget, |
| 1329 | // no audit line on the console side. |
| 1330 | func (s *apiSession) scheduleList(ctx context.Context, a apiAcct, app string) (apiScheduleList, error) { |
| 1331 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/schedules")) |
| 1332 | if err != nil { |
| 1333 | return apiScheduleList{}, err |
| 1334 | } |
| 1335 | if status != http.StatusOK { |
| 1336 | if status == http.StatusNotFound { |
| 1337 | return apiScheduleList{}, scheduleDoorNotFoundErr() |
| 1338 | } |
| 1339 | return apiScheduleList{}, apiErrorFromBody(status, body) |
| 1340 | } |
| 1341 | var res apiScheduleList |
| 1342 | if err := json.Unmarshal(body, &res); err != nil { |
| 1343 | return apiScheduleList{}, fmt.Errorf("schedule ls: unreadable response: %w", err) |
| 1344 | } |
| 1345 | return res, nil |
| 1346 | } |
| 1347 | |
| 1348 | // scheduleSet asks the console to declare or replace sc by name |
| 1349 | // (handleAPIScheduleSet). The console re-validates sc.Validate() itself — |
| 1350 | // this call's caller (schedule.go's runScheduleSet) has already run the |
| 1351 | // same check, so most bad requests never reach here at all, but the |
| 1352 | // console's answer is what actually lands, never assumed. |
| 1353 | func (s *apiSession) scheduleSet(ctx context.Context, a apiAcct, app string, sc schedule.Schedule) (apiScheduleList, error) { |
| 1354 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules"), sc) |
| 1355 | if err != nil { |
| 1356 | return apiScheduleList{}, err |
| 1357 | } |
| 1358 | if status != http.StatusOK { |
| 1359 | if status == http.StatusNotFound { |
| 1360 | return apiScheduleList{}, scheduleDoorNotFoundErr() |
| 1361 | } |
| 1362 | return apiScheduleList{}, apiErrorFromBody(status, body) |
| 1363 | } |
| 1364 | var res apiScheduleList |
| 1365 | if err := json.Unmarshal(body, &res); err != nil { |
| 1366 | return apiScheduleList{}, fmt.Errorf("schedule set: unreadable response: %w", err) |
| 1367 | } |
| 1368 | return res, nil |
| 1369 | } |
| 1370 | |
| 1371 | // scheduleRemove asks the console to drop name (handleAPIScheduleRemove). |
| 1372 | func (s *apiSession) scheduleRemove(ctx context.Context, a apiAcct, app, name string) (apiScheduleList, error) { |
| 1373 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/delete"), struct{}{}) |
| 1374 | if err != nil { |
| 1375 | return apiScheduleList{}, err |
| 1376 | } |
| 1377 | if status != http.StatusOK { |
| 1378 | if status == http.StatusNotFound { |
| 1379 | return apiScheduleList{}, scheduleNameNotFoundErr() |
| 1380 | } |
| 1381 | return apiScheduleList{}, apiErrorFromBody(status, body) |
| 1382 | } |
| 1383 | var res apiScheduleList |
| 1384 | if err := json.Unmarshal(body, &res); err != nil { |
| 1385 | return apiScheduleList{}, fmt.Errorf("schedule rm: unreadable response: %w", err) |
| 1386 | } |
| 1387 | return res, nil |
| 1388 | } |
| 1389 | |
| 1390 | // apiScheduleRunResult is handleAPIScheduleRun's answer: |
| 1391 | // {"requested":true,"account","app","name"} — the console's CANONICAL |
| 1392 | // account sqid and app name, since the segment the caller typed may have |
| 1393 | // been a name resolved server-side (restart's apiRestartResult, verbatim |
| 1394 | // reasoning; review finding on 23966c9: printing the locally-resolved |
| 1395 | // segment here diverged from ls/set/rm, which all print what the console |
| 1396 | // echoed back). |
| 1397 | type apiScheduleRunResult struct { |
| 1398 | Requested bool `json:"requested"` |
| 1399 | Account string `json:"account"` |
| 1400 | App string `json:"app"` |
| 1401 | Name string `json:"name"` |
| 1402 | } |
| 1403 | |
| 1404 | // scheduleRun asks the console to touch name's run signal |
| 1405 | // (handleAPIScheduleRun) — TouchRun's etag movement, never its content, is |
| 1406 | // the signal every box's converge pass acts on, restart-gen's own |
| 1407 | // mechanism. The response's canonical account/app/name are decoded and |
| 1408 | // returned so the caller prints the SAME kind of canonical value |
| 1409 | // ls/set/rm do, never the segment this terminal happened to resolve. |
| 1410 | func (s *apiSession) scheduleRun(ctx context.Context, a apiAcct, app, name string) (apiScheduleRunResult, error) { |
| 1411 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/run"), struct{}{}) |
| 1412 | if err != nil { |
| 1413 | return apiScheduleRunResult{}, err |
| 1414 | } |
| 1415 | if status != http.StatusOK { |
| 1416 | if status == http.StatusNotFound { |
| 1417 | return apiScheduleRunResult{}, scheduleNameNotFoundErr() |
| 1418 | } |
| 1419 | return apiScheduleRunResult{}, apiErrorFromBody(status, body) |
| 1420 | } |
| 1421 | var res apiScheduleRunResult |
| 1422 | if err := json.Unmarshal(body, &res); err != nil { |
| 1423 | return apiScheduleRunResult{}, fmt.Errorf("schedule run: unreadable response: %w", err) |
| 1424 | } |
| 1425 | return res, nil |
| 1426 | } |
| 1427 | |
| 1428 | // apiFeaturesResult mirrors the console's features answer (apifeatures.go): |
| 1429 | // the app's map as the platform now serves it, who last changed it and |
| 1430 | // when, and — on a set — whether this call changed anything. |
| 1431 | type apiFeaturesResult struct { |
| 1432 | Account string `json:"account"` |
| 1433 | App string `json:"app"` |
| 1434 | Features map[string]string `json:"features"` |
| 1435 | SetBy string `json:"set_by"` |
| 1436 | SetAt string `json:"set_at"` |
| 1437 | Changed bool `json:"changed"` |
| 1438 | } |
| 1439 | |
| 1440 | func (s *apiSession) features(ctx context.Context, a apiAcct, app string) (apiFeaturesResult, error) { |
| 1441 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/features")) |
| 1442 | return decodeFeatures(status, body, err) |
| 1443 | } |
| 1444 | |
| 1445 | func (s *apiSession) setFeatures(ctx context.Context, a apiAcct, app string, set map[string]string, unset []string, messages ...string) (apiFeaturesResult, error) { |
| 1446 | message := firstMutationMessage(messages) |
| 1447 | in := struct { |
| 1448 | Set map[string]string `json:"set,omitempty"` |
| 1449 | Unset []string `json:"unset,omitempty"` |
| 1450 | Message string `json:"message,omitempty"` |
| 1451 | }{set, unset, message} |
| 1452 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/features"), in) |
| 1453 | return decodeFeatures(status, body, err) |
| 1454 | } |
| 1455 | |
| 1456 | func decodeFeatures(status int, body []byte, err error) (apiFeaturesResult, error) { |
| 1457 | if err != nil { |
| 1458 | return apiFeaturesResult{}, err |
| 1459 | } |
| 1460 | if status != http.StatusOK { |
| 1461 | if status == http.StatusNotFound { |
| 1462 | return apiFeaturesResult{}, fmt.Errorf("%w — or this console predates `carlos features` "+ |
| 1463 | "(deploy a console built from current main)", apiErrorFromBody(status, body)) |
| 1464 | } |
| 1465 | return apiFeaturesResult{}, apiErrorFromBody(status, body) |
| 1466 | } |
| 1467 | var res apiFeaturesResult |
| 1468 | if err := json.Unmarshal(body, &res); err != nil { |
| 1469 | return apiFeaturesResult{}, fmt.Errorf("features: unreadable response: %w", err) |
| 1470 | } |
| 1471 | return res, nil |
| 1472 | } |
| 1473 | |
| 1474 | // apiRetentionResult mirrors the console's retention answer: the policy as |
| 1475 | // recorded, plus the console's own count of currently-eligible versions — |
| 1476 | // computed console-side because this terminal has no bucket. Eligible is a |
| 1477 | // pointer: nil means the console could not compute the count, which the CLI |
| 1478 | // says out loud rather than printing a lying zero. |
| 1479 | type apiRetentionResult struct { |
| 1480 | Account string `json:"account"` |
| 1481 | App string `json:"app"` |
| 1482 | Keep int `json:"keep"` |
| 1483 | Off bool `json:"off"` |
| 1484 | DryRun bool `json:"dry_run"` |
| 1485 | Eligible *int `json:"eligible"` |
| 1486 | Total int `json:"total"` |
| 1487 | EligibleVersions []string `json:"eligible_versions"` |
| 1488 | } |
| 1489 | |
| 1490 | func (s *apiSession) retention(ctx context.Context, a apiAcct, app string) (apiRetentionResult, error) { |
| 1491 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/retention")) |
| 1492 | return decodeRetention(status, body, err) |
| 1493 | } |
| 1494 | |
| 1495 | func (s *apiSession) setRetention(ctx context.Context, a apiAcct, app string, keep int, off, dryRun bool) (apiRetentionResult, error) { |
| 1496 | in := struct { |
| 1497 | Keep int `json:"keep,omitempty"` |
| 1498 | Off bool `json:"off,omitempty"` |
| 1499 | DryRun bool `json:"dry_run,omitempty"` |
| 1500 | }{keep, off, dryRun} |
| 1501 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/retention"), in) |
| 1502 | return decodeRetention(status, body, err) |
| 1503 | } |
| 1504 | |
| 1505 | func decodeRetention(status int, body []byte, err error) (apiRetentionResult, error) { |
| 1506 | if err != nil { |
| 1507 | return apiRetentionResult{}, err |
| 1508 | } |
| 1509 | if status != http.StatusOK { |
| 1510 | if status == http.StatusNotFound { |
| 1511 | return apiRetentionResult{}, fmt.Errorf("%w — or this console predates `carlos releases retention` "+ |
| 1512 | "(deploy a console built from current main)", apiErrorFromBody(status, body)) |
| 1513 | } |
| 1514 | return apiRetentionResult{}, apiErrorFromBody(status, body) |
| 1515 | } |
| 1516 | var res apiRetentionResult |
| 1517 | if err := json.Unmarshal(body, &res); err != nil { |
| 1518 | return apiRetentionResult{}, fmt.Errorf("retention: unreadable response: %w", err) |
| 1519 | } |
| 1520 | return res, nil |
| 1521 | } |
| 1522 | |
| 1523 | // channels reads one app's channel ladder — and its pipeline envelope, when |
| 1524 | // the console carries one — through the console. |
| 1525 | func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) (apiChannelsResult, error) { |
| 1526 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/channels")) |
| 1527 | if err != nil { |
| 1528 | return apiChannelsResult{}, err |
| 1529 | } |
| 1530 | if status != http.StatusOK { |
| 1531 | return apiChannelsResult{}, apiErrorFromBody(status, body) |
| 1532 | } |
| 1533 | var res struct { |
| 1534 | Channels []struct { |
| 1535 | Channel string `json:"channel"` |
| 1536 | Version string `json:"version"` |
| 1537 | PromotedAt string `json:"promoted_at"` |
| 1538 | Rung string `json:"rung"` |
| 1539 | Error string `json:"error"` |
| 1540 | Digests bool `json:"digests"` |
| 1541 | Straggler bool `json:"straggler"` |
| 1542 | } `json:"channels"` |
| 1543 | // PipelineEntry/PipelineChannels are two flat, top-level keys — NOT |
| 1544 | // nested under a "pipeline" object — deliberately distinct from the |
| 1545 | // full release.Pipeline object `carlos pipeline` reads under that |
| 1546 | // name (review fix round 1: the same JSON key meaning an object on |
| 1547 | // one door and a string on another was a trap for any future |
| 1548 | // consumer). Both are absent together for BOTH "no pipeline key at |
| 1549 | // all" (an older console) and "this app has no pipeline" (a |
| 1550 | // confirmed-legacy app) AND a pipeline read failure server-side — |
| 1551 | // this client has no need to tell those apart; see |
| 1552 | // apiChannelsResult's doc comment. |
| 1553 | PipelineEntry string `json:"pipeline_entry"` |
| 1554 | PipelineChannels []string `json:"pipeline_channels"` |
| 1555 | } |
| 1556 | if err := json.Unmarshal(body, &res); err != nil { |
| 1557 | return apiChannelsResult{}, fmt.Errorf("channels: unreadable response: %w", err) |
| 1558 | } |
| 1559 | out := apiChannelsResult{ |
| 1560 | Channels: make([]apiChannel, len(res.Channels)), |
| 1561 | PipelineEntry: res.PipelineEntry, |
| 1562 | PipelineChannels: res.PipelineChannels, |
| 1563 | } |
| 1564 | for i, ch := range res.Channels { |
| 1565 | out.Channels[i] = apiChannel{ |
| 1566 | Channel: ch.Channel, Version: ch.Version, |
| 1567 | PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error, |
| 1568 | Digests: ch.Digests, Straggler: ch.Straggler, |
| 1569 | } |
| 1570 | } |
| 1571 | return out, nil |
| 1572 | } |
| 1573 | |
| 1574 | type apiResignPlan struct { |
| 1575 | Channel string `json:"channel"` |
| 1576 | Version string `json:"version"` |
| 1577 | Digest string `json:"digest"` |
| 1578 | History []string `json:"history"` |
| 1579 | HistoryDigests []string `json:"history_digests"` |
| 1580 | Computed []bool `json:"computed"` |
| 1581 | } |
| 1582 | |
| 1583 | // resignPlan asks the console what `channels resign` would sign. Nothing is |
| 1584 | // written. |
| 1585 | func (s *apiSession) resignPlan(ctx context.Context, a apiAcct, app, channel string) (apiResignPlan, error) { |
| 1586 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"), map[string]string{"channel": channel}) |
| 1587 | if err != nil { |
| 1588 | return apiResignPlan{}, err |
| 1589 | } |
| 1590 | if status != http.StatusOK { |
| 1591 | return apiResignPlan{}, apiErrorFromBody(status, body) |
| 1592 | } |
| 1593 | var p apiResignPlan |
| 1594 | if err := json.Unmarshal(body, &p); err != nil { |
| 1595 | return apiResignPlan{}, fmt.Errorf("resign: unreadable response: %w", err) |
| 1596 | } |
| 1597 | if len(p.Computed) != len(p.History)+1 || len(p.HistoryDigests) != len(p.History) { |
| 1598 | return apiResignPlan{}, fmt.Errorf("resign: the console's plan is malformed") |
| 1599 | } |
| 1600 | return p, nil |
| 1601 | } |
| 1602 | |
| 1603 | // resignSign sends back the digests the operator confirmed; the console |
| 1604 | // signs them only if a plan made now is exactly that plan. |
| 1605 | func (s *apiSession) resignSign(ctx context.Context, a apiAcct, app, channel, digest string, hd []string) error { |
| 1606 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/channels/resign"), |
| 1607 | map[string]any{"channel": channel, "digest": digest, "history_digests": hd}) |
| 1608 | if err != nil { |
| 1609 | return err |
| 1610 | } |
| 1611 | if status == http.StatusOK { |
| 1612 | return nil |
| 1613 | } |
| 1614 | return apiErrorFromBody(status, body) |
| 1615 | } |
| 1616 | |
| 1617 | type apiDigestAccount struct { |
| 1618 | Account string `json:"account"` |
| 1619 | On bool `json:"on"` |
| 1620 | Since string `json:"since"` |
| 1621 | Error string `json:"error"` |
| 1622 | } |
| 1623 | type apiDigestChannel struct { |
| 1624 | Account string `json:"account"` |
| 1625 | App string `json:"app"` |
| 1626 | Channel string `json:"channel"` |
| 1627 | Version string `json:"version"` |
| 1628 | Straggler bool `json:"straggler"` |
| 1629 | } |
| 1630 | type apiPointerDigests struct { |
| 1631 | Accounts []apiDigestAccount `json:"accounts"` |
| 1632 | Channels []apiDigestChannel `json:"channels"` |
| 1633 | } |
| 1634 | |
| 1635 | // pointerDigests reads the pointer-digest report (verb ""), or turns the |
| 1636 | // marker on or off for the named accounts (every account when none) and |
| 1637 | // reads the report back. |
| 1638 | func (s *apiSession) pointerDigests(ctx context.Context, verb string, accounts []string) (apiPointerDigests, error) { |
| 1639 | var status int |
| 1640 | var body []byte |
| 1641 | var err error |
| 1642 | if verb == "" { |
| 1643 | status, body, err = s.getJSON(ctx, "/api/cli/system/pointer-digests") |
| 1644 | } else { |
| 1645 | status, body, err = s.postJSON(ctx, "/api/cli/system/pointer-digests", map[string]any{"on": verb == "on", "accounts": accounts}) |
| 1646 | } |
| 1647 | if err != nil { |
| 1648 | return apiPointerDigests{}, err |
| 1649 | } |
| 1650 | if status != http.StatusOK { |
| 1651 | return apiPointerDigests{}, apiErrorFromBody(status, body) |
| 1652 | } |
| 1653 | var rep apiPointerDigests |
| 1654 | if err := json.Unmarshal(body, &rep); err != nil { |
| 1655 | return apiPointerDigests{}, fmt.Errorf("pointer digests: unreadable response: %w", err) |
| 1656 | } |
| 1657 | return rep, nil |
| 1658 | } |
| 1659 | |
| 1660 | // --- version target, tags, releases (release versioning spec, 2026-08-08) |
| 1661 | // |
| 1662 | // The four read/write doors internal/console/apiversion.go opens, in the |
| 1663 | // same shape as channels/promote above: decode only the fields the CLI |
| 1664 | // prints, and let apiErrorFromBody speak for every non-2xx — the console's |
| 1665 | // own refusals ("already released as tag 0.5.0", "want MAJOR.MINOR.PATCH") |
| 1666 | // are better sentences than anything this side could re-derive. |
| 1667 | |
| 1668 | // apiVersionTarget is GET .../version-target's shape. An UNSET target is |
| 1669 | // Target == "" and a 200, not a 404: unset is a state, exactly as |
| 1670 | // release.GetTarget's own contract has it, and this client keeps that shape |
| 1671 | // rather than inventing a sentinel error for it. |
| 1672 | type apiVersionTarget struct { |
| 1673 | Target string `json:"target"` |
| 1674 | SetBy string `json:"set_by"` |
| 1675 | SetAt string `json:"set_at"` |
| 1676 | } |
| 1677 | |
| 1678 | func (s *apiSession) versionTarget(ctx context.Context, a apiAcct, app string) (apiVersionTarget, error) { |
| 1679 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/version-target")) |
| 1680 | if err != nil { |
| 1681 | return apiVersionTarget{}, err |
| 1682 | } |
| 1683 | if status != http.StatusOK { |
| 1684 | return apiVersionTarget{}, apiErrorFromBody(status, body) |
| 1685 | } |
| 1686 | var out apiVersionTarget |
| 1687 | if err := json.Unmarshal(body, &out); err != nil { |
| 1688 | return apiVersionTarget{}, fmt.Errorf("version target: unreadable response: %w", err) |
| 1689 | } |
| 1690 | return out, nil |
| 1691 | } |
| 1692 | |
| 1693 | // setVersionTarget writes the target and returns what the console stored — |
| 1694 | // its echo, not the argument sent, so a server that normalised the string is |
| 1695 | // the one the operator is shown. |
| 1696 | func (s *apiSession) setVersionTarget(ctx context.Context, a apiAcct, app, target string) (string, error) { |
| 1697 | in := struct { |
| 1698 | Target string `json:"target"` |
| 1699 | }{target} |
| 1700 | status, body, err := s.putJSON(ctx, apiPath(a, app, "/version-target"), in) |
| 1701 | if err != nil { |
| 1702 | return "", err |
| 1703 | } |
| 1704 | if status != http.StatusOK { |
| 1705 | return "", apiErrorFromBody(status, body) |
| 1706 | } |
| 1707 | var out struct { |
| 1708 | Target string `json:"target"` |
| 1709 | } |
| 1710 | if err := json.Unmarshal(body, &out); err != nil { |
| 1711 | return "", fmt.Errorf("version target: unreadable response: %w", err) |
| 1712 | } |
| 1713 | return out.Target, nil |
| 1714 | } |
| 1715 | |
| 1716 | // apiTag is one row of GET .../tags. Release is the ITERATION the tag was cut |
| 1717 | // from ("0.5.0-3"), Version the name it earned ("0.5.0") — the direction |
| 1718 | // matters, because every join in this file goes release -> tag. |
| 1719 | type apiTag struct { |
| 1720 | Version string `json:"version"` |
| 1721 | Release string `json:"release"` |
| 1722 | TaggedAt string `json:"tagged_at"` |
| 1723 | Actor string `json:"actor"` |
| 1724 | } |
| 1725 | |
| 1726 | func (s *apiSession) tags(ctx context.Context, a apiAcct, app string) ([]apiTag, error) { |
| 1727 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/tags")) |
| 1728 | if err != nil { |
| 1729 | return nil, err |
| 1730 | } |
| 1731 | if status != http.StatusOK { |
| 1732 | return nil, apiErrorFromBody(status, body) |
| 1733 | } |
| 1734 | var res struct { |
| 1735 | Tags []apiTag `json:"tags"` |
| 1736 | } |
| 1737 | if err := json.Unmarshal(body, &res); err != nil { |
| 1738 | return nil, fmt.Errorf("tags: unreadable response: %w", err) |
| 1739 | } |
| 1740 | return res.Tags, nil |
| 1741 | } |
| 1742 | |
| 1743 | // apiRelease is one row of GET .../releases: what `carlos releases` prints, |
| 1744 | // already joined against the tags server-side (handleAPIReleasesList), so |
| 1745 | // this client never issues a second call to fill the TAG column. |
| 1746 | type apiRelease struct { |
| 1747 | Version string `json:"version"` |
| 1748 | Kind string `json:"kind"` |
| 1749 | BuiltAt string `json:"built_at"` |
| 1750 | Label string `json:"label"` |
| 1751 | Tag string `json:"tag"` |
| 1752 | } |
| 1753 | |
| 1754 | func (s *apiSession) releases(ctx context.Context, a apiAcct, app string) ([]apiRelease, error) { |
| 1755 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/releases")) |
| 1756 | if err != nil { |
| 1757 | return nil, err |
| 1758 | } |
| 1759 | if status != http.StatusOK { |
| 1760 | return nil, apiErrorFromBody(status, body) |
| 1761 | } |
| 1762 | var res struct { |
| 1763 | Releases []apiRelease `json:"releases"` |
| 1764 | } |
| 1765 | if err := json.Unmarshal(body, &res); err != nil { |
| 1766 | return nil, fmt.Errorf("releases: unreadable response: %w", err) |
| 1767 | } |
| 1768 | return res.Releases, nil |
| 1769 | } |
| 1770 | |
| 1771 | // apiTrashResult is handleAPITrashApp's success shape: the confirmed cooloff |
| 1772 | // deadline. The SERVER computes it (it re-reads the marker's own DeletedAt |
| 1773 | // after writing — TrashApp is create-only, so an app already in the trash |
| 1774 | // keeps its ORIGINAL clock), so this client prints exactly what the server |
| 1775 | // confirmed rather than a locally-guessed window. |
| 1776 | type apiTrashResult struct { |
| 1777 | RestorableUntil string |
| 1778 | } |
| 1779 | |
| 1780 | // apiRestoreResult is handleAPIRestoreApp's success shape: the server's own |
| 1781 | // reminder that restoring resurrects no routes (spec §3), carried verbatim |
| 1782 | // so the signed-in and headless paths do not drift apart on wording by |
| 1783 | // accident — one is the console's copy, the other this binary's own. |
| 1784 | type apiRestoreResult struct { |
| 1785 | Note string |
| 1786 | } |
| 1787 | |
| 1788 | // trashApp asks the console to move app to the Trash (carlos apps delete, |
| 1789 | // signed-in mode). Mirrors promote/rollback's shape exactly: postJSON, a 200 |
| 1790 | // decodes, anything else is apiErrorFromBody's job — trash's 409 (production |
| 1791 | // on or unreadable) and 502 (trash window unconfirmed; retrying is safe, |
| 1792 | // since TrashApp is create-only and idempotent) both carry a real |
| 1793 | // {"error": ...} body, so the json-first decode surfaces them verbatim. |
| 1794 | func (s *apiSession) trashApp(ctx context.Context, a apiAcct, app string) (apiTrashResult, error) { |
| 1795 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/delete"), struct{}{}) |
| 1796 | if err != nil { |
| 1797 | return apiTrashResult{}, err |
| 1798 | } |
| 1799 | if status != http.StatusOK { |
| 1800 | return apiTrashResult{}, apiErrorFromBody(status, body) |
| 1801 | } |
| 1802 | var res struct { |
| 1803 | RestorableUntil string `json:"restorable_until"` |
| 1804 | } |
| 1805 | if err := json.Unmarshal(body, &res); err != nil { |
| 1806 | return apiTrashResult{}, fmt.Errorf("apps delete: unreadable response: %w", err) |
| 1807 | } |
| 1808 | return apiTrashResult{RestorableUntil: res.RestorableUntil}, nil |
| 1809 | } |
| 1810 | |
| 1811 | // restoreApp asks the console to restore app from the Trash (carlos apps |
| 1812 | // restore, signed-in mode). It resolves through apiTrashedApp server-side, so |
| 1813 | // an app that is not (or no longer) in the trash gets the same |
| 1814 | // byte-identical 404 apiAccountApp's gate answers for an unknown account or |
| 1815 | // app — notFoundErr's generic membership text. That 404 is ALSO what a |
| 1816 | // headlessly-trashed app produces while this console's own row has not yet |
| 1817 | // materialized from the bucket (the KNOWN WINDOW the brief calls out), so |
| 1818 | // this is the one call in this file that widens a 404 rather than passing |
| 1819 | // notFoundErr through unchanged — naming the headless retry as a second way |
| 1820 | // through, not just "try again". |
| 1821 | func (s *apiSession) restoreApp(ctx context.Context, a apiAcct, app string) (apiRestoreResult, error) { |
| 1822 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/restore"), struct{}{}) |
| 1823 | if err != nil { |
| 1824 | return apiRestoreResult{}, err |
| 1825 | } |
| 1826 | if status != http.StatusOK { |
| 1827 | if status == http.StatusNotFound { |
| 1828 | return apiRestoreResult{}, fmt.Errorf("%w — if %s was trashed headlessly (bucket-direct, no console), "+ |
| 1829 | "this console's own record of it may not have caught up yet; try again shortly, or restore it "+ |
| 1830 | "headlessly instead (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR and --account-id)", notFoundErr(), app) |
| 1831 | } |
| 1832 | return apiRestoreResult{}, apiErrorFromBody(status, body) |
| 1833 | } |
| 1834 | var res struct { |
| 1835 | Note string `json:"note"` |
| 1836 | } |
| 1837 | if err := json.Unmarshal(body, &res); err != nil { |
| 1838 | return apiRestoreResult{}, fmt.Errorf("apps restore: unreadable response: %w", err) |
| 1839 | } |
| 1840 | return apiRestoreResult{Note: res.Note}, nil |
| 1841 | } |
| 1842 | |
| 1843 | // ---- temporary apps (temporary static sites spec §3, §7) -------------------- |
| 1844 | |
| 1845 | // apiCreateTemporaryResult is handleAPICreateTemporaryApp's success shape, |
| 1846 | // with expires_at parsed. It is only returned once the expiry is confirmed. |
| 1847 | type apiCreateTemporaryResult struct { |
| 1848 | Account string |
| 1849 | App string |
| 1850 | URL string |
| 1851 | ExpiresAt time.Time |
| 1852 | |
| 1853 | // Region is the region echo /new-temporary shares with /new, in /new's |
| 1854 | // own shape so printAppRegion reads it unchanged. |
| 1855 | Region apiCreateAppResult |
| 1856 | } |
| 1857 | |
| 1858 | // apiAppLife is one row of GET /api/cli/apps/{account}/list. |
| 1859 | type apiAppLife struct { |
| 1860 | App string `json:"app"` |
| 1861 | DeletedAt *time.Time `json:"deleted_at"` |
| 1862 | ExpiresAt *time.Time `json:"expires_at"` |
| 1863 | PurgeAfter *time.Time `json:"purge_after"` |
| 1864 | } |
| 1865 | |
| 1866 | var ( |
| 1867 | errTemporaryUnavailable = errors.New("this console does not offer temporary apps, or the account was not found (it answered 404). Nothing was created") // C11.16 |
| 1868 | errTemporaryNameTaken = errors.New("that name is taken") // C11.17 (sentinel only — see sentinelError) |
| 1869 | errTemporaryNotConfirmed = errors.New("the console did not confirm an expiry") // sentinel only — see sentinelError |
| 1870 | errListUnavailable = errors.New("this console does not offer carlos apps list yet (it answered 404). The console needs upgrading first") // C11.18 |
| 1871 | ) |
| 1872 | |
| 1873 | // sentinelError pairs a message meant for a person with a sentinel meant for |
| 1874 | // errors.Is: Error() returns ONLY msg, so a sentinel's own generic text |
| 1875 | // (errTemporaryNameTaken's "that name is taken", errTemporaryNotConfirmed's |
| 1876 | // "the console did not confirm an expiry") never leaks into what actually |
| 1877 | // gets printed by being appended after an approved string or the console's |
| 1878 | // own 409 reason (review round 1, finding 3). errors.Is still works: it |
| 1879 | // unwraps to the sentinel and compares that. |
| 1880 | type sentinelError struct { |
| 1881 | msg string |
| 1882 | sentinel error |
| 1883 | } |
| 1884 | |
| 1885 | func (e *sentinelError) Error() string { return e.msg } |
| 1886 | func (e *sentinelError) Unwrap() error { return e.sentinel } |
| 1887 | |
| 1888 | // createTemporaryApp is POST /new-temporary. A 404 is an older console (or a |
| 1889 | // switched-off one, or a wrong account) and nothing was created: the caller |
| 1890 | // stops there (spec §3, astra 2.7). A 409 with code name_taken is |
| 1891 | // errTemporaryNameTaken, which a generated name retries; what gets PRINTED |
| 1892 | // for it is the console's own reason (apiErrorFromBody), existing console |
| 1893 | // copy already reviewed in Task 5 — "that name is reserved", "a route in |
| 1894 | // this account already serves …", and so on all say more than a flat "that |
| 1895 | // name is taken" would (review round 1, finding 4). A 200 without a usable |
| 1896 | // expires_at is a console that did not make the app temporary, and the |
| 1897 | // caller must not ship. |
| 1898 | // |
| 1899 | // region and timezone are /new's optional fields, sent the same way. |
| 1900 | func (s *apiSession) createTemporaryApp(ctx context.Context, a apiAcct, app string, ttl time.Duration, region, timezone string) (apiCreateTemporaryResult, error) { |
| 1901 | in := struct { |
| 1902 | Name string `json:"name"` |
| 1903 | TTLSeconds int64 `json:"ttl_seconds"` |
| 1904 | Region string `json:"region,omitempty"` |
| 1905 | Timezone string `json:"timezone,omitempty"` |
| 1906 | }{app, int64(ttl / time.Second), region, timezone} |
| 1907 | status, body, err := s.postJSON(ctx, apiPath(a, "new-temporary", ""), in) |
| 1908 | if err != nil { |
| 1909 | return apiCreateTemporaryResult{}, err |
| 1910 | } |
| 1911 | switch status { |
| 1912 | case http.StatusOK: |
| 1913 | case http.StatusNotFound: |
| 1914 | return apiCreateTemporaryResult{}, errTemporaryUnavailable |
| 1915 | case http.StatusConflict: |
| 1916 | var e struct { |
| 1917 | Code string `json:"code"` |
| 1918 | } |
| 1919 | if json.Unmarshal(body, &e) == nil && e.Code == "name_taken" { |
| 1920 | return apiCreateTemporaryResult{}, &sentinelError{msg: apiErrorFromBody(status, body).Error(), sentinel: errTemporaryNameTaken} |
| 1921 | } |
| 1922 | return apiCreateTemporaryResult{}, apiErrorFromBody(status, body) |
| 1923 | default: |
| 1924 | return apiCreateTemporaryResult{}, apiErrorFromBody(status, body) |
| 1925 | } |
| 1926 | var res struct { |
| 1927 | Account string `json:"account"` |
| 1928 | App string `json:"app"` |
| 1929 | URL string `json:"url"` |
| 1930 | ExpiresAt string `json:"expires_at"` |
| 1931 | } |
| 1932 | if err := json.Unmarshal(body, &res); err != nil { |
| 1933 | return apiCreateTemporaryResult{}, fmt.Errorf("deploy: unreadable response from the console: %w", err) |
| 1934 | } |
| 1935 | var reg apiCreateAppResult |
| 1936 | _ = json.Unmarshal(body, ®) // the same body; it just parsed |
| 1937 | if res.App == "" { |
| 1938 | res.App = app |
| 1939 | } |
| 1940 | exp, perr := time.Parse(time.RFC3339, res.ExpiresAt) |
| 1941 | if perr != nil || exp.Unix() <= 0 { |
| 1942 | return apiCreateTemporaryResult{App: res.App}, &sentinelError{ |
| 1943 | msg: fmt.Sprintf("the console created %s but did not confirm it is temporary, so nothing was shipped. "+ |
| 1944 | "If you do not want it, delete it with: carlos apps delete %s", res.App, res.App), // C11.19 |
| 1945 | sentinel: errTemporaryNotConfirmed, |
| 1946 | } |
| 1947 | } |
| 1948 | return apiCreateTemporaryResult{Account: res.Account, App: res.App, URL: res.URL, ExpiresAt: exp, Region: reg}, nil |
| 1949 | } |
| 1950 | |
| 1951 | // appTTL is POST {app}/ttl: the new expiry, as the console set it. |
| 1952 | func (s *apiSession) appTTL(ctx context.Context, a apiAcct, app string, ttl time.Duration) (time.Time, error) { |
| 1953 | in := struct { |
| 1954 | TTLSeconds int64 `json:"ttl_seconds"` |
| 1955 | }{int64(ttl / time.Second)} |
| 1956 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/ttl"), in) |
| 1957 | if err != nil { |
| 1958 | return time.Time{}, err |
| 1959 | } |
| 1960 | if status != http.StatusOK { |
| 1961 | return time.Time{}, apiErrorFromBody(status, body) |
| 1962 | } |
| 1963 | var res struct { |
| 1964 | ExpiresAt string `json:"expires_at"` |
| 1965 | } |
| 1966 | if err := json.Unmarshal(body, &res); err != nil { |
| 1967 | return time.Time{}, fmt.Errorf("apps ttl: unreadable response: %w", err) |
| 1968 | } |
| 1969 | return time.Parse(time.RFC3339, res.ExpiresAt) |
| 1970 | } |
| 1971 | |
| 1972 | // appKeep is POST {app}/keep. |
| 1973 | func (s *apiSession) appKeep(ctx context.Context, a apiAcct, app string) error { |
| 1974 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/keep"), struct{}{}) |
| 1975 | if err != nil { |
| 1976 | return err |
| 1977 | } |
| 1978 | if status != http.StatusOK { |
| 1979 | return apiErrorFromBody(status, body) |
| 1980 | } |
| 1981 | return nil |
| 1982 | } |
| 1983 | |
| 1984 | // appList is GET /api/cli/apps/{account}/list. |
| 1985 | func (s *apiSession) appList(ctx context.Context, a apiAcct) ([]apiAppLife, error) { |
| 1986 | status, body, err := s.getJSON(ctx, apiPath(a, "list", "")) |
| 1987 | if err != nil { |
| 1988 | return nil, err |
| 1989 | } |
| 1990 | if status == http.StatusNotFound { |
| 1991 | return nil, errListUnavailable |
| 1992 | } |
| 1993 | if status != http.StatusOK { |
| 1994 | return nil, apiErrorFromBody(status, body) |
| 1995 | } |
| 1996 | var out []apiAppLife |
| 1997 | if err := json.Unmarshal(body, &out); err != nil { |
| 1998 | return nil, fmt.Errorf("apps list: unreadable response: %w", err) |
| 1999 | } |
| 2000 | return out, nil |
| 2001 | } |
| 2002 | |
| 2003 | // ---- object storage (issue #55) --------------------------------------------- |
| 2004 | // |
| 2005 | // apiStoreReply mirrors (*console.Server).storeReply's JSON exactly |
| 2006 | // (internal/console/apistore.go): app, this DEPLOYMENT's backend ("" |
| 2007 | // unconfigured), the three record blocks (declared/grant/provisioned, |
| 2008 | // null-or-present — a client reads them rather than testing for absence), |
| 2009 | // the env names this store delivers, whether a rotation is only half |
| 2010 | // finished, and which delivered variables have not reached the app's config |
| 2011 | // yet. |
| 2012 | // |
| 2013 | // The three block types are redeclared here rather than imported from |
| 2014 | // internal/objstore: this package (main, the CLI binary) does not otherwise |
| 2015 | // import that package, and pulling it in for three struct shapes this client |
| 2016 | // only ever decodes (never constructs server-side logic from) would be |
| 2017 | // coupling this binary to the console's storage internals for no reason a |
| 2018 | // wire client has. Field names and JSON tags are kept byte-for-byte |
| 2019 | // identical to objstore.Declared/GrantBlock/Provisioned so a decode here can |
| 2020 | // never silently drop a field the server sends. |
| 2021 | type apiStoreDeclared struct { |
| 2022 | EnvPrefix string `json:"env_prefix"` |
| 2023 | At time.Time `json:"at"` |
| 2024 | By string `json:"by"` |
| 2025 | } |
| 2026 | |
| 2027 | type apiStoreGrantBlock struct { |
| 2028 | Backend string `json:"backend"` |
| 2029 | Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish) |
| 2030 | At time.Time `json:"at"` |
| 2031 | By string `json:"by"` |
| 2032 | Source string `json:"source"` |
| 2033 | } |
| 2034 | |
| 2035 | type apiStoreProvisioned struct { |
| 2036 | Bucket string `json:"bucket"` |
| 2037 | Region string `json:"region"` |
| 2038 | Endpoint string `json:"endpoint,omitempty"` |
| 2039 | IAMUser string `json:"iam_user"` |
| 2040 | UserARN string `json:"user_arn"` |
| 2041 | KeyID string `json:"key_id"` |
| 2042 | PrevKeyID string `json:"prev_key_id,omitempty"` |
| 2043 | At time.Time `json:"at"` |
| 2044 | } |
| 2045 | |
| 2046 | type apiStoreReply struct { |
| 2047 | App string `json:"app"` |
| 2048 | // Environment is the config environment this store belongs to, "" for the |
| 2049 | // app's own. Always sent by a console that knows about environment |
| 2050 | // stores; absent from an older one's reply, which decodes as "" — the |
| 2051 | // only store such a console has. |
| 2052 | Environment string `json:"environment"` |
| 2053 | // Environments names every environment this app has a store for, and is |
| 2054 | // sent ONLY on a reply about the app's own store. A reply aimed at one |
| 2055 | // environment answers about that environment and lists nothing. |
| 2056 | Environments []string `json:"environments,omitempty"` |
| 2057 | Backend string `json:"backend"` |
| 2058 | Declared *apiStoreDeclared `json:"declared"` |
| 2059 | Grant *apiStoreGrantBlock `json:"grant"` |
| 2060 | Provisioned *apiStoreProvisioned `json:"provisioned"` |
| 2061 | EnvNames []string `json:"env_names"` |
| 2062 | PendingFinish bool `json:"pending_finish"` |
| 2063 | MissingVars []string `json:"missing_vars"` |
| 2064 | // Scanning is nil on every store that never opted in — which is all of |
| 2065 | // them by default — so an older console that does not send it renders |
| 2066 | // exactly as it always did. |
| 2067 | Scanning *apiScanBlock `json:"scanning,omitempty"` |
| 2068 | Entitlement *objstore.Entitlement `json:"entitlement"` |
| 2069 | // Size is the bucket's size from the console's daily storage sweep, nil |
| 2070 | // before a sweep has measured it (and from an older console). |
| 2071 | Size *apiStoreSize `json:"size"` |
| 2072 | } |
| 2073 | |
| 2074 | // apiStoreSize mirrors the console's storeSizeReply (internal/console/apistore.go). |
| 2075 | type apiStoreSize struct { |
| 2076 | AsOf string `json:"as_of"` |
| 2077 | StoreBytes int64 `json:"store_bytes"` |
| 2078 | Unswept bool `json:"unswept,omitempty"` |
| 2079 | BucketBytes int64 `json:"bucket_bytes"` |
| 2080 | } |
| 2081 | |
| 2082 | const storeProvisionHTTPTimeout = 75 * time.Second |
| 2083 | |
| 2084 | type apiStoreAccountReply struct { |
| 2085 | Account string `json:"account"` |
| 2086 | Entitlement *objstore.Entitlement `json:"entitlement"` |
| 2087 | } |
| 2088 | |
| 2089 | type apiStoreApprovalsReply struct { |
| 2090 | Applications []*objstore.Entitlement `json:"applications"` |
| 2091 | } |
| 2092 | |
| 2093 | func decodeStoreAccountReply(action string, status int, body []byte, err error) (apiStoreAccountReply, error) { |
| 2094 | if err != nil { |
| 2095 | return apiStoreAccountReply{}, err |
| 2096 | } |
| 2097 | if status != http.StatusOK { |
| 2098 | return apiStoreAccountReply{}, storeOperatorError(status, body) |
| 2099 | } |
| 2100 | var out apiStoreAccountReply |
| 2101 | if err := json.Unmarshal(body, &out); err != nil { |
| 2102 | return apiStoreAccountReply{}, fmt.Errorf("store %s: unreadable response: %w", action, err) |
| 2103 | } |
| 2104 | return out, nil |
| 2105 | } |
| 2106 | |
| 2107 | // ---- deployment-wide system settings (system.go) ---------------------- |
| 2108 | |
| 2109 | // apiSystemAntivirusReply reports WIRING and POLICY as separate fields on |
| 2110 | // purpose: an engine can be wired with scanning off, and scanning can be on |
| 2111 | // with the engine unreachable, and an operator debugging "nothing is scanning" |
| 2112 | // needs to know which half is missing. |
| 2113 | type apiSystemAntivirusReply struct { |
| 2114 | Wired bool `json:"wired"` |
| 2115 | Enabled bool `json:"enabled"` |
| 2116 | QueueAppsWithWork int `json:"queue_apps_with_work"` |
| 2117 | Engine string `json:"engine,omitempty"` // the engine the RECORD names |
| 2118 | At string `json:"at,omitempty"` |
| 2119 | By string `json:"by,omitempty"` |
| 2120 | // EngineWired is what the wired engine calls itself, which can differ from |
| 2121 | // the record's name — that difference is engine drift, and it is why the |
| 2122 | // two are separate fields rather than one. |
| 2123 | EngineWired string `json:"engine_wired,omitempty"` |
| 2124 | EngineError string `json:"engine_error,omitempty"` |
| 2125 | RecordError string `json:"record_error,omitempty"` |
| 2126 | Signature string `json:"signature,omitempty"` |
| 2127 | MaxSignatureAgeSeconds int64 `json:"max_signature_age_seconds,omitempty"` |
| 2128 | } |
| 2129 | |
| 2130 | func (s *apiSession) systemAntivirusStatus(ctx context.Context) (apiSystemAntivirusReply, error) { |
| 2131 | status, body, err := s.getJSON(ctx, "/api/cli/system/antivirus") |
| 2132 | if err != nil { |
| 2133 | return apiSystemAntivirusReply{}, err |
| 2134 | } |
| 2135 | if status != http.StatusOK { |
| 2136 | return apiSystemAntivirusReply{}, apiErrorFromBody(status, body) |
| 2137 | } |
| 2138 | var out apiSystemAntivirusReply |
| 2139 | if err := json.Unmarshal(body, &out); err != nil { |
| 2140 | return apiSystemAntivirusReply{}, fmt.Errorf("system antivirus status: unreadable response: %w", err) |
| 2141 | } |
| 2142 | return out, nil |
| 2143 | } |
| 2144 | |
| 2145 | func storeOperatorError(status int, body []byte) error { |
| 2146 | var e struct { |
| 2147 | Error string `json:"error"` |
| 2148 | } |
| 2149 | if json.Unmarshal(body, &e) == nil && e.Error != "" { |
| 2150 | return errors.New(e.Error) |
| 2151 | } |
| 2152 | if status == http.StatusNotFound { |
| 2153 | return errors.New("not found: check the account sqid and that your login has object storage operator access") |
| 2154 | } |
| 2155 | return fmt.Errorf("unexpected response (status %d)", status) |
| 2156 | } |
| 2157 | |
| 2158 | func (s *apiSession) storeApply(ctx context.Context, a apiAcct, note string) (apiStoreAccountReply, error) { |
| 2159 | status, body, err := s.postJSON(ctx, apiAccountStorePath(a, "apply"), struct { |
| 2160 | Note string `json:"note,omitempty"` |
| 2161 | }{note}) |
| 2162 | if err != nil { |
| 2163 | return apiStoreAccountReply{}, err |
| 2164 | } |
| 2165 | if status != http.StatusOK { |
| 2166 | return apiStoreAccountReply{}, apiErrorFromBody(status, body) |
| 2167 | } |
| 2168 | var out apiStoreAccountReply |
| 2169 | if err := json.Unmarshal(body, &out); err != nil { |
| 2170 | return apiStoreAccountReply{}, fmt.Errorf("store apply: unreadable response: %w", err) |
| 2171 | } |
| 2172 | return out, nil |
| 2173 | } |
| 2174 | |
| 2175 | // systemAntivirusSet sends enabled explicitly — never omitted. The door |
| 2176 | // refuses a body that says neither, because there is no safe default for a |
| 2177 | // switch that decides whether tenant bytes get read. |
| 2178 | func (s *apiSession) systemAntivirusSet(ctx context.Context, on bool) (apiSystemAntivirusReply, error) { |
| 2179 | in := struct { |
| 2180 | Enabled bool `json:"enabled"` |
| 2181 | }{on} |
| 2182 | status, body, err := s.postJSON(ctx, "/api/cli/system/antivirus", in) |
| 2183 | if err != nil { |
| 2184 | return apiSystemAntivirusReply{}, err |
| 2185 | } |
| 2186 | if status != http.StatusOK { |
| 2187 | return apiSystemAntivirusReply{}, apiErrorFromBody(status, body) |
| 2188 | } |
| 2189 | var out apiSystemAntivirusReply |
| 2190 | if err := json.Unmarshal(body, &out); err != nil { |
| 2191 | return apiSystemAntivirusReply{}, fmt.Errorf("system antivirus: unreadable response: %w", err) |
| 2192 | } |
| 2193 | return out, nil |
| 2194 | } |
| 2195 | |
| 2196 | func (s *apiSession) storeApprove(ctx context.Context, account string) (apiStoreAccountReply, error) { |
| 2197 | status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/store/approve", struct{}{}) |
| 2198 | return decodeStoreAccountReply("approve", status, body, err) |
| 2199 | } |
| 2200 | |
| 2201 | func (s *apiSession) storeDecline(ctx context.Context, account, reason string) (apiStoreAccountReply, error) { |
| 2202 | status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/store/decline", struct { |
| 2203 | Reason string `json:"reason"` |
| 2204 | }{reason}) |
| 2205 | return decodeStoreAccountReply("decline", status, body, err) |
| 2206 | } |
| 2207 | |
| 2208 | func (s *apiSession) storeApprovals(ctx context.Context) (apiStoreApprovalsReply, error) { |
| 2209 | status, body, err := s.getJSON(ctx, "/api/cli/store/approvals") |
| 2210 | if err != nil { |
| 2211 | return apiStoreApprovalsReply{}, err |
| 2212 | } |
| 2213 | if status != http.StatusOK { |
| 2214 | return apiStoreApprovalsReply{}, storeOperatorError(status, body) |
| 2215 | } |
| 2216 | var out apiStoreApprovalsReply |
| 2217 | if err := json.Unmarshal(body, &out); err != nil { |
| 2218 | return apiStoreApprovalsReply{}, fmt.Errorf("store approvals: unreadable response: %w", err) |
| 2219 | } |
| 2220 | return out, nil |
| 2221 | } |
| 2222 | |
| 2223 | type apiWebhookRotateReply struct { |
| 2224 | Rotated int `json:"rotated"` |
| 2225 | Remaining int `json:"remaining"` |
| 2226 | } |
| 2227 | |
| 2228 | func (s *apiSession) systemWebhookRotate(ctx context.Context) (apiWebhookRotateReply, error) { |
| 2229 | status, body, err := s.postJSON(ctx, "/api/cli/system/antivirus/rotate-webhook-key", struct{}{}) |
| 2230 | if err != nil { |
| 2231 | return apiWebhookRotateReply{}, err |
| 2232 | } |
| 2233 | if status != http.StatusOK { |
| 2234 | return apiWebhookRotateReply{}, apiErrorFromBody(status, body) |
| 2235 | } |
| 2236 | var out apiWebhookRotateReply |
| 2237 | if err := json.Unmarshal(body, &out); err != nil { |
| 2238 | return out, fmt.Errorf("rotate webhook key: unreadable response: %w", err) |
| 2239 | } |
| 2240 | return out, nil |
| 2241 | } |
| 2242 | |
| 2243 | // ---- store scanning (scan.go) ---------------------------------------- |
| 2244 | |
| 2245 | // The scan reply's blocks, mirrored here rather than imported from |
| 2246 | // internal/objstore, the way apiStoreProvisioned mirrors its own: this binary |
| 2247 | // decodes what the console sent, and a shared struct would make a server-side |
| 2248 | // field rename a client compile error instead of a wire question. |
| 2249 | |
| 2250 | type apiScanRequested struct { |
| 2251 | At time.Time `json:"at"` |
| 2252 | By string `json:"by"` |
| 2253 | Encrypted bool `json:"encrypted,omitempty"` |
| 2254 | } |
| 2255 | |
| 2256 | type apiScanEnabled struct { |
| 2257 | Engine string `json:"engine"` |
| 2258 | At time.Time `json:"at"` |
| 2259 | By string `json:"by"` |
| 2260 | } |
| 2261 | |
| 2262 | type apiScanEnforce struct { |
| 2263 | Key string `json:"key"` |
| 2264 | Value string `json:"value"` |
| 2265 | } |
| 2266 | |
| 2267 | type apiScanBlock struct { |
| 2268 | Requested *apiScanRequested `json:"requested,omitempty"` |
| 2269 | Enabled *apiScanEnabled `json:"enabled,omitempty"` |
| 2270 | Enforce *apiScanEnforce `json:"enforce,omitempty"` |
| 2271 | } |
| 2272 | |
| 2273 | // apiSweep is one reconciliation pass. Scanned and Unscanned are NOT |
| 2274 | // complements — skipped, denied and failed objects were never judged — which |
| 2275 | // is why the status line has to name two numbers. |
| 2276 | type apiSweep struct { |
| 2277 | StartedAt time.Time `json:"started_at"` |
| 2278 | At time.Time `json:"at"` |
| 2279 | Engine string `json:"engine"` |
| 2280 | Signature string `json:"signature,omitempty"` |
| 2281 | Objects int64 `json:"objects"` |
| 2282 | Scanned int64 `json:"scanned"` |
| 2283 | Infected int64 `json:"infected"` |
| 2284 | Skipped int64 `json:"skipped"` |
| 2285 | Denied int64 `json:"denied"` |
| 2286 | Failed int64 `json:"failed"` |
| 2287 | Unscanned int64 `json:"unscanned"` |
| 2288 | // Fresh and Reused split Scanned by WHEN the verdict was reached. A clean |
| 2289 | // tag is kept while the object's bytes do not change, so a rollup can be |
| 2290 | // built almost entirely from old judgements against an older database. |
| 2291 | Fresh int64 `json:"fresh"` |
| 2292 | Reused int64 `json:"reused"` |
| 2293 | Bytes int64 `json:"bytes"` |
| 2294 | After string `json:"after,omitempty"` |
| 2295 | } |
| 2296 | |
| 2297 | type apiScanCoverage struct { |
| 2298 | NotApplicable bool `json:"not_applicable,omitempty"` |
| 2299 | Current *apiSweep `json:"current,omitempty"` |
| 2300 | LastComplete *apiSweep `json:"last_complete,omitempty"` |
| 2301 | } |
| 2302 | |
| 2303 | type apiScanReply struct { |
| 2304 | App string `json:"app"` |
| 2305 | Backend string `json:"backend"` |
| 2306 | Engine string `json:"engine"` |
| 2307 | EngineError string `json:"engine_error,omitempty"` |
| 2308 | Signature string `json:"signature,omitempty"` |
| 2309 | MaxSignatureAgeSeconds int64 `json:"max_signature_age_seconds"` |
| 2310 | Scanning *apiScanBlock `json:"scanning"` |
| 2311 | Coverage *apiScanCoverage `json:"coverage"` |
| 2312 | QueueDepth int `json:"queue_depth"` |
| 2313 | } |
| 2314 | |
| 2315 | // scanCall is every scan door's round trip: they all answer the same shape, so |
| 2316 | // there is one decoder and one place a reply could be misread. |
| 2317 | func (s *apiSession) scanCall(ctx context.Context, verb, path string, in any) (apiScanReply, error) { |
| 2318 | var status int |
| 2319 | var body []byte |
| 2320 | var err error |
| 2321 | if in == nil { |
| 2322 | status, body, err = s.getJSON(ctx, path) |
| 2323 | } else { |
| 2324 | status, body, err = s.postJSON(ctx, path, in) |
| 2325 | } |
| 2326 | if err != nil { |
| 2327 | return apiScanReply{}, err |
| 2328 | } |
| 2329 | if status != http.StatusOK { |
| 2330 | return apiScanReply{}, apiErrorFromBody(status, body) |
| 2331 | } |
| 2332 | var out apiScanReply |
| 2333 | if err := json.Unmarshal(body, &out); err != nil { |
| 2334 | return apiScanReply{}, fmt.Errorf("%s: unreadable response: %w", verb, err) |
| 2335 | } |
| 2336 | return out, nil |
| 2337 | } |
| 2338 | |
| 2339 | // scanStatus reads what is claimed about this store's objects. |
| 2340 | func (s *apiSession) scanStatus(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| 2341 | return s.scanCall(ctx, "store scan status", apiPath(a, app, "/store/scan"), nil) |
| 2342 | } |
| 2343 | |
| 2344 | // scanRequest records the member's request AND their attestation. encrypted is |
| 2345 | // sent explicitly, never omitted: the server refuses a body without it, |
| 2346 | // because there is no safe default for "can a scanner read these bytes". |
| 2347 | func (s *apiSession) scanRequest(ctx context.Context, a apiAcct, app string, encrypted bool) (apiScanReply, error) { |
| 2348 | in := struct { |
| 2349 | Encrypted bool `json:"encrypted"` |
| 2350 | }{encrypted} |
| 2351 | return s.scanCall(ctx, "store scan request", apiPath(a, app, "/store/scan"), in) |
| 2352 | } |
| 2353 | |
| 2354 | func (s *apiSession) scanGrant(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| 2355 | return s.scanCall(ctx, "store scan grant", apiPath(a, app, "/store/scan/grant"), struct{}{}) |
| 2356 | } |
| 2357 | |
| 2358 | func (s *apiSession) scanRevoke(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| 2359 | return s.scanCall(ctx, "store scan revoke", apiPath(a, app, "/store/scan/revoke"), struct{}{}) |
| 2360 | } |
| 2361 | |
| 2362 | func (s *apiSession) scanSweep(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| 2363 | return s.scanCall(ctx, "store scan sweep", apiPath(a, app, "/store/scan/sweep"), struct{}{}) |
| 2364 | } |
| 2365 | |
| 2366 | func (s *apiSession) scanEnforce(ctx context.Context, a apiAcct, app string, on bool) (apiScanReply, error) { |
| 2367 | in := struct { |
| 2368 | On bool `json:"on"` |
| 2369 | }{on} |
| 2370 | return s.scanCall(ctx, "store scan enforce", apiPath(a, app, "/store/scan/enforce"), in) |
| 2371 | } |
| 2372 | |
| 2373 | func (s *apiSession) scanRotateCallbackKey(ctx context.Context, a apiAcct, app string) (apiScanReply, error) { |
| 2374 | return s.scanCall(ctx, "store scan rotate-webhook-key", apiPath(a, app, "/store/scan/callback-key/rotate"), struct{}{}) |
| 2375 | } |
| 2376 | |
| 2377 | // storeCreate asks the console to declare (or re-declare) a store for app, |
| 2378 | // under envPrefix ("" takes the console's own default — objstore. |
| 2379 | // DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a |
| 2380 | // 200 decodes, anything else is apiErrorFromBody's job. |
| 2381 | // |
| 2382 | // The request omits env_prefix entirely (json:",omitempty") when envPrefix |
| 2383 | // is "", so a bare `carlos store create --app x` sends the literal `{}` body |
| 2384 | // handleAPIStoreCreate documents as its own default case — never a |
| 2385 | // zero-length body, which that door's readJSON would refuse as malformed. |
| 2386 | func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, env, envPrefix string) (apiStoreReply, error) { |
| 2387 | in := struct { |
| 2388 | EnvPrefix string `json:"env_prefix,omitempty"` |
| 2389 | }{envPrefix} |
| 2390 | status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail("/store", env)), in) |
| 2391 | if err != nil { |
| 2392 | return apiStoreReply{}, err |
| 2393 | } |
| 2394 | if status != http.StatusOK { |
| 2395 | return apiStoreReply{}, apiErrorFromBody(status, body) |
| 2396 | } |
| 2397 | var out apiStoreReply |
| 2398 | if err := json.Unmarshal(body, &out); err != nil { |
| 2399 | return apiStoreReply{}, fmt.Errorf("store create: unreadable response: %w", err) |
| 2400 | } |
| 2401 | return out, nil |
| 2402 | } |
| 2403 | |
| 2404 | // storeProvision runs the account-approved provisioning walk. The server may |
| 2405 | // spend its full 60-second storeGrantTimeout on backend work, so this one JSON |
| 2406 | // door gets a scoped client with a slightly longer deadline; mutating the |
| 2407 | // session's ordinary 30-second client would silently widen every later call. |
| 2408 | func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app, env string) (apiStoreReply, error) { |
| 2409 | clone := *s |
| 2410 | client := *s.c |
| 2411 | httpc := *s.c.httpc |
| 2412 | httpc.Timeout = storeProvisionHTTPTimeout |
| 2413 | client.httpc = &httpc |
| 2414 | clone.c = &client |
| 2415 | |
| 2416 | status, body, err := clone.postJSON(ctx, apiPath(a, app, storeEnvTail("/store/provision", env)), struct{}{}) |
| 2417 | if err != nil { |
| 2418 | return apiStoreReply{}, err |
| 2419 | } |
| 2420 | if status != http.StatusOK { |
| 2421 | return apiStoreReply{}, apiErrorFromBody(status, body) |
| 2422 | } |
| 2423 | var out apiStoreReply |
| 2424 | if err := json.Unmarshal(body, &out); err != nil { |
| 2425 | return apiStoreReply{}, fmt.Errorf("store create: unreadable provision response: %w", err) |
| 2426 | } |
| 2427 | return out, nil |
| 2428 | } |
| 2429 | |
| 2430 | // storeGet asks the console for app's store record (`carlos store status`). |
| 2431 | // A 404 here is a real diagnosis, not the byte-identical membership 404 |
| 2432 | // apiAccountApp gives — handleAPIStoreGet answers it with an actual |
| 2433 | // {"error": "no store is declared..."} body once membership is already |
| 2434 | // proven, so apiErrorFromBody's json-first decode surfaces it verbatim like |
| 2435 | // any other server diagnosis; no special case is needed here. |
| 2436 | func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app, env string) (apiStoreReply, error) { |
| 2437 | status, body, err := s.getJSON(ctx, apiPath(a, app, storeEnvTail("/store", env))) |
| 2438 | if err != nil { |
| 2439 | return apiStoreReply{}, err |
| 2440 | } |
| 2441 | if status != http.StatusOK { |
| 2442 | return apiStoreReply{}, apiErrorFromBody(status, body) |
| 2443 | } |
| 2444 | var out apiStoreReply |
| 2445 | if err := json.Unmarshal(body, &out); err != nil { |
| 2446 | return apiStoreReply{}, fmt.Errorf("store status: unreadable response: %w", err) |
| 2447 | } |
| 2448 | return out, nil |
| 2449 | } |
| 2450 | |
| 2451 | // storeGrant asks the console to provision app's declared store and deliver |
| 2452 | // its credentials (`carlos store grant`) — the one call in this file that |
| 2453 | // spends money in the deployment's own cloud account, gated server-side on |
| 2454 | // CARLOS_STORE_OPERATORS (a 403 for anyone else, member or not, surfaced |
| 2455 | // verbatim by apiErrorFromBody like every other server diagnosis here: this |
| 2456 | // client never pattern-matches the message, only prints it). |
| 2457 | // |
| 2458 | // bucket == "" mints a fresh bucket; a non-empty bucket asks the walk to |
| 2459 | // ADOPT that existing one instead. Same omitempty shape as storeCreate's |
| 2460 | // prefix: a bare `--app x` with no `--bucket` sends `{}`. |
| 2461 | func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, env, bucket string) (apiStoreReply, error) { |
| 2462 | in := struct { |
| 2463 | Bucket string `json:"bucket,omitempty"` |
| 2464 | }{bucket} |
| 2465 | status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail("/store/grant", env)), in) |
| 2466 | if err != nil { |
| 2467 | return apiStoreReply{}, err |
| 2468 | } |
| 2469 | if status != http.StatusOK { |
| 2470 | return apiStoreReply{}, apiErrorFromBody(status, body) |
| 2471 | } |
| 2472 | var out apiStoreReply |
| 2473 | if err := json.Unmarshal(body, &out); err != nil { |
| 2474 | return apiStoreReply{}, fmt.Errorf("store grant: unreadable response: %w", err) |
| 2475 | } |
| 2476 | return out, nil |
| 2477 | } |
| 2478 | |
| 2479 | // storeRotate asks the console to cycle app's store credential |
| 2480 | // (`carlos store rotate`), or — with finish — to delete the previous key and |
| 2481 | // close the two-key window a rotation leaves open |
| 2482 | // (`carlos store rotate --finish`). Two different doors |
| 2483 | // (POST .../store/rotate vs POST .../store/rotate/finish), chosen here |
| 2484 | // rather than by a body field, matching the server's own route table |
| 2485 | // (internal/console/authcli.go) and restart's "no body, the whole request is |
| 2486 | // which door you POST to" shape (handleAPIStoreRotate's doc comment). |
| 2487 | func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app, env string, finish bool) (apiStoreReply, error) { |
| 2488 | tail := "/store/rotate" |
| 2489 | if finish { |
| 2490 | tail = "/store/rotate/finish" |
| 2491 | } |
| 2492 | status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail(tail, env)), struct{}{}) |
| 2493 | if err != nil { |
| 2494 | return apiStoreReply{}, err |
| 2495 | } |
| 2496 | if status != http.StatusOK { |
| 2497 | return apiStoreReply{}, apiErrorFromBody(status, body) |
| 2498 | } |
| 2499 | var out apiStoreReply |
| 2500 | if err := json.Unmarshal(body, &out); err != nil { |
| 2501 | return apiStoreReply{}, fmt.Errorf("store rotate: unreadable response: %w", err) |
| 2502 | } |
| 2503 | return out, nil |
| 2504 | } |
| 2505 | |
| 2506 | // ---- the STUN/TURN relay (relay.go's member and operator verbs) ----------- |
| 2507 | // |
| 2508 | // The reply shapes mirror internal/console/apirelay.go's JSON exactly, and |
| 2509 | // they are redeclared here rather than imported for apiStoreReply's own |
| 2510 | // reason: this binary decodes what the console sent, and sharing a struct |
| 2511 | // would turn a server-side field rename into a client compile error instead |
| 2512 | // of the wire question it actually is. |
| 2513 | // |
| 2514 | // NOTHING KEY-SHAPED CROSSES THESE. The only identity in the reply is the |
| 2515 | // username suffix the console issued per region — account, app, incarnation |
| 2516 | // and kid — which is the same public half the app's own CARLOS_RELAY carries |
| 2517 | // and the thing a member matches a failing call against. The key exists only |
| 2518 | // sealed in the app's config bundle, and this CLI never sees it. |
| 2519 | |
| 2520 | type apiRelayRoute struct { |
| 2521 | Host string `json:"host"` |
| 2522 | Shape string `json:"shape"` // unit | socket-exec | tcp-exec-file | no-delivery | unknown |
| 2523 | Delivery string `json:"delivery"` |
| 2524 | AckedAt string `json:"acked_at,omitempty"` |
| 2525 | Cutover bool `json:"cutover"` |
| 2526 | // ConfigAckAt and ConfigAckShape echo config sync's own per-route |
| 2527 | // acknowledgement (spec §5, internal/appconfig/configack.go) when one |
| 2528 | // names the identities currently issued — distinct from AckedAt/Cutover |
| 2529 | // above, which are a MEMBER's own `carlos relay adopted`. Neither this |
| 2530 | // CLI's printRelayStatus nor anything else here reads these fields |
| 2531 | // today: Delivery already carries the full sentence either state earns |
| 2532 | // (see internal/console/apirelay.go's relayMatchingAck); they exist so |
| 2533 | // a future consumer of the wire reply is not left inventing the ack's |
| 2534 | // own timestamp out of Delivery's prose. |
| 2535 | ConfigAckAt string `json:"config_acked_at,omitempty"` |
| 2536 | ConfigAckShape string `json:"config_ack_shape,omitempty"` |
| 2537 | } |
| 2538 | |
| 2539 | type apiRelayRegion struct { |
| 2540 | Region string `json:"region"` |
| 2541 | Box string `json:"box"` |
| 2542 | HeartbeatAge string `json:"heartbeat_age"` |
| 2543 | Current bool `json:"current"` |
| 2544 | LeaseLive bool `json:"lease_live"` |
| 2545 | Listeners []string `json:"listeners"` |
| 2546 | Kids []string `json:"kids"` |
| 2547 | // kid -> RFC3339 retire_at, for the loaded masters that carry one. |
| 2548 | Retiring map[string]string `json:"retiring,omitempty"` |
| 2549 | Stale bool `json:"stale"` |
| 2550 | // The relay's own last two errors: why it stopped reading. Printed on a |
| 2551 | // stale row, because the state without the reason is not actionable. |
| 2552 | MastersError string `json:"masters_error,omitempty"` |
| 2553 | AuthorityError string `json:"authority_error,omitempty"` |
| 2554 | } |
| 2555 | |
| 2556 | type apiRelayReply struct { |
| 2557 | App string `json:"app"` |
| 2558 | Enabled bool `json:"enabled"` |
| 2559 | Incarnation string `json:"incarnation,omitempty"` |
| 2560 | Regions []string `json:"regions,omitempty"` |
| 2561 | Op int64 `json:"op"` |
| 2562 | Phase string `json:"phase,omitempty"` |
| 2563 | Issued map[string]string `json:"issued,omitempty"` |
| 2564 | Routes []apiRelayRoute `json:"routes,omitempty"` |
| 2565 | RegionStatus []apiRelayRegion `json:"region_status,omitempty"` |
| 2566 | DisablePending []string `json:"disable_pending,omitempty"` |
| 2567 | } |
| 2568 | |
| 2569 | // apiRelayRotateReply is rotate's own answer: a region and a kid, not an |
| 2570 | // app's record. |
| 2571 | type apiRelayRotateReply struct { |
| 2572 | Region string `json:"region"` |
| 2573 | Kid string `json:"kid"` |
| 2574 | Apps int `json:"apps"` |
| 2575 | RetireNoEarlierThan string `json:"retire_no_earlier_than"` |
| 2576 | } |
| 2577 | |
| 2578 | // relayCall is the four app-scoped verbs' one round trip — they all answer |
| 2579 | // with the same record shape, so writing the decode four times would only |
| 2580 | // create four places for it to drift. in == nil is the GET. |
| 2581 | func (s *apiSession) relayCall(ctx context.Context, verb, path string, in any) (apiRelayReply, error) { |
| 2582 | var ( |
| 2583 | status int |
| 2584 | body []byte |
| 2585 | err error |
| 2586 | ) |
| 2587 | if in == nil { |
| 2588 | status, body, err = s.getJSON(ctx, path) |
| 2589 | } else { |
| 2590 | status, body, err = s.postJSON(ctx, path, in) |
| 2591 | } |
| 2592 | if err != nil { |
| 2593 | return apiRelayReply{}, err |
| 2594 | } |
| 2595 | if status != http.StatusOK { |
| 2596 | return apiRelayReply{}, apiErrorFromBody(status, body) |
| 2597 | } |
| 2598 | var out apiRelayReply |
| 2599 | if err := json.Unmarshal(body, &out); err != nil { |
| 2600 | return apiRelayReply{}, fmt.Errorf("%s: unreadable response: %w", verb, err) |
| 2601 | } |
| 2602 | return out, nil |
| 2603 | } |
| 2604 | |
| 2605 | // relayEnable turns the relay on for app, in regions or — with none named — |
| 2606 | // every region the deployment arms, in the console's own order. The list is |
| 2607 | // omitted entirely when empty, so a bare `carlos relay enable` sends the |
| 2608 | // literal `{}` the door documents as its default rather than a null the |
| 2609 | // server would have to guess at. |
| 2610 | func (s *apiSession) relayEnable(ctx context.Context, a apiAcct, app string, regions []string) (apiRelayReply, error) { |
| 2611 | in := struct { |
| 2612 | Regions []string `json:"regions,omitempty"` |
| 2613 | }{regions} |
| 2614 | return s.relayCall(ctx, "relay enable", apiPath(a, app, "/relay"), in) |
| 2615 | } |
| 2616 | |
| 2617 | // relayGet is `carlos relay status`. Its 404 is a real diagnosis ("the relay |
| 2618 | // has never been enabled..."), not the membership gate's byte-identical one, |
| 2619 | // and apiErrorFromBody surfaces it verbatim like any other server sentence. |
| 2620 | func (s *apiSession) relayGet(ctx context.Context, a apiAcct, app string) (apiRelayReply, error) { |
| 2621 | return s.relayCall(ctx, "relay status", apiPath(a, app, "/relay"), nil) |
| 2622 | } |
| 2623 | |
| 2624 | func (s *apiSession) relayDisable(ctx context.Context, a apiAcct, app string) (apiRelayReply, error) { |
| 2625 | return s.relayCall(ctx, "relay disable", apiPath(a, app, "/relay/disable"), struct{}{}) |
| 2626 | } |
| 2627 | |
| 2628 | // relayAdopted tells the console that host — a TCP-address route this |
| 2629 | // platform cannot restart — now runs the identities it last issued. |
| 2630 | func (s *apiSession) relayAdopted(ctx context.Context, a apiAcct, app, host string) (apiRelayReply, error) { |
| 2631 | in := struct { |
| 2632 | Host string `json:"host"` |
| 2633 | }{host} |
| 2634 | return s.relayCall(ctx, "relay adopted", apiPath(a, app, "/relay/adopted"), in) |
| 2635 | } |
| 2636 | |
| 2637 | // relayRotate is the operator's verb, and the ONE call in this file with no |
| 2638 | // account or app in its path: it moves a REGION's issuer pointer. The path is |
| 2639 | // therefore a literal, not an apiPath — there is no account segment for |
| 2640 | // apiPath to escape into it. A caller who is not on CARLOS_RELAY_OPERATORS |
| 2641 | // meets the mux's own 404 rather than a 403, so this client cannot tell |
| 2642 | // "not allowed" from "no such console door", and does not try to. |
| 2643 | func (s *apiSession) relayRotate(ctx context.Context, region string) (apiRelayRotateReply, error) { |
| 2644 | in := struct { |
| 2645 | Region string `json:"region"` |
| 2646 | }{region} |
| 2647 | status, body, err := s.postJSON(ctx, "/api/cli/relay/rotate", in) |
| 2648 | if err != nil { |
| 2649 | return apiRelayRotateReply{}, err |
| 2650 | } |
| 2651 | if status != http.StatusOK { |
| 2652 | // A 404 with no JSON body is either gate: a console too old to have |
| 2653 | // this door, or apiRelayOperator refusing an address that is not on |
| 2654 | // CARLOS_RELAY_OPERATORS — deliberately indistinguishable on the |
| 2655 | // wire, so the message states both possibilities rather than |
| 2656 | // notFoundErr()'s advice to check --account and --app, two flags this |
| 2657 | // verb does not have. Saying both leaks nothing: a caller who is on |
| 2658 | // the list finds out by the call succeeding. |
| 2659 | if status == http.StatusNotFound && !hasJSONError(body) { |
| 2660 | return apiRelayRotateReply{}, errors.New( |
| 2661 | "no such door on this console, or your address is not on CARLOS_RELAY_OPERATORS") |
| 2662 | } |
| 2663 | return apiRelayRotateReply{}, apiErrorFromBody(status, body) |
| 2664 | } |
| 2665 | var out apiRelayRotateReply |
| 2666 | if err := json.Unmarshal(body, &out); err != nil { |
| 2667 | return apiRelayRotateReply{}, fmt.Errorf("relay rotate: unreadable response: %w", err) |
| 2668 | } |
| 2669 | return out, nil |
| 2670 | } |
| 2671 | |
| 2672 | // ledgerAppend asks the console to CAS-append one entry to name's chain, |
| 2673 | // stamping writer from the caller's own authenticated identity server-side |
| 2674 | // (never client-supplied — apiledger.go). A 503 here is contention (too many |
| 2675 | // writers accruing the same chain at once, ledger.ErrBusy) — retry-worthy, |
| 2676 | // which is exactly what apiErrorFromBody's json-first decode surfaces, since |
| 2677 | // ledgerStoreError answers it with a real "{"error": "..."}" body, not a bare |
| 2678 | // status. |
| 2679 | func (s *apiSession) ledgerAppend(ctx context.Context, a apiAcct, app, name string, payload json.RawMessage) (ledger.Appended, error) { |
| 2680 | var out ledger.Appended |
| 2681 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/append"), |
| 2682 | map[string]json.RawMessage{"payload": payload}) |
| 2683 | if err != nil { |
| 2684 | return out, err |
| 2685 | } |
| 2686 | if status != http.StatusOK { |
| 2687 | return out, apiErrorFromBody(status, body) |
| 2688 | } |
| 2689 | return out, json.Unmarshal(body, &out) |
| 2690 | } |
| 2691 | |
| 2692 | // ledgerHead reads name's HEAD.json through the console. Its 404 is answered |
| 2693 | // with a dedicated message rather than apiErrorFromBody's generic membership |
| 2694 | // text: an unknown LEDGER under an app the caller does belong to is a |
| 2695 | // different, much more common case ("nothing appended yet") than "check |
| 2696 | // --account and --app", and deserves to say so. |
| 2697 | func (s *apiSession) ledgerHead(ctx context.Context, a apiAcct, app, name string) (ledger.Head, error) { |
| 2698 | var out ledger.Head |
| 2699 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/head")) |
| 2700 | if err != nil { |
| 2701 | return out, err |
| 2702 | } |
| 2703 | if status == http.StatusNotFound { |
| 2704 | return out, fmt.Errorf("no %q ledger yet for this app", name) |
| 2705 | } |
| 2706 | if status != http.StatusOK { |
| 2707 | return out, apiErrorFromBody(status, body) |
| 2708 | } |
| 2709 | return out, json.Unmarshal(body, &out) |
| 2710 | } |
| 2711 | |
| 2712 | // ledgerList reads an app's ledger names and its publish manifest through |
| 2713 | // the console. The raw body is handed back rather than a decoded struct: |
| 2714 | // {"manifest": Manifest|null, "ledgers": [...]} is the caller's shape to |
| 2715 | // interpret (a null manifest means "never published" — a caller-side zero |
| 2716 | // value would erase that distinction), and the CLI print path decodes it |
| 2717 | // itself right where it decides what to show a human. |
| 2718 | func (s *apiSession) ledgerList(ctx context.Context, a apiAcct, app string) ([]byte, error) { |
| 2719 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/ledgers")) |
| 2720 | if err != nil { |
| 2721 | return nil, err |
| 2722 | } |
| 2723 | if status != http.StatusOK { |
| 2724 | return nil, apiErrorFromBody(status, body) |
| 2725 | } |
| 2726 | return body, nil |
| 2727 | } |
| 2728 | |
| 2729 | // ledgerPublish adds (or, with public:false, removes) named ledgers from the |
| 2730 | // set an app serves publicly. The console applies it as a read-modify-write |
| 2731 | // on the stored manifest, so the request says what to CHANGE and only the |
| 2732 | // response says what is public NOW — a set that may well name ledgers this |
| 2733 | // call never mentioned. That resulting manifest is the return value: without |
| 2734 | // it the CLI could only echo the caller's own argument back at them, which |
| 2735 | // is exactly how someone comes to believe they unpublished a chain they |
| 2736 | // didn't. |
| 2737 | func (s *apiSession) ledgerPublish(ctx context.Context, a apiAcct, app string, m ledger.Manifest) (ledger.Manifest, error) { |
| 2738 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/ledgers/publish"), m) |
| 2739 | if err != nil { |
| 2740 | return ledger.Manifest{}, err |
| 2741 | } |
| 2742 | if status != http.StatusOK { |
| 2743 | return ledger.Manifest{}, apiErrorFromBody(status, body) |
| 2744 | } |
| 2745 | var out ledger.Manifest |
| 2746 | if err := json.Unmarshal(body, &out); err != nil { |
| 2747 | return ledger.Manifest{}, fmt.Errorf("ledger publish: unreadable response: %w", err) |
| 2748 | } |
| 2749 | return out, nil |
| 2750 | } |
| 2751 | |
| 2752 | // ledgerBlob posts raw bytes (not JSON) to the ledger's blobs door — a |
| 2753 | // content-addressed, create-only upload (ledger.PutBlob; a re-post of the |
| 2754 | // same bytes is a no-op, matching ship's artifact semantics). It follows |
| 2755 | // getJSON's own request plumbing exactly — same base URL field, same bearer |
| 2756 | // header (apimode.go:212-230) — but with http.MethodPost and a |
| 2757 | // bytes.Reader body in place of GET/nil, since the wire body here is the |
| 2758 | // blob's raw bytes, never JSON. |
| 2759 | func (s *apiSession) ledgerBlob(ctx context.Context, a apiAcct, app, name string, body []byte) (string, error) { |
| 2760 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, |
| 2761 | s.c.base+apiPath(a, app, "/ledgers/"+url.PathEscape(name)+"/blobs"), bytes.NewReader(body)) |
| 2762 | if err != nil { |
| 2763 | return "", err |
| 2764 | } |
| 2765 | if s.c.token != "" { |
| 2766 | req.Header.Set("Authorization", "Bearer "+s.c.token) |
| 2767 | } |
| 2768 | res, err := s.c.httpc.Do(req) |
| 2769 | if err != nil { |
| 2770 | return "", err |
| 2771 | } |
| 2772 | defer res.Body.Close() |
| 2773 | if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { |
| 2774 | return "", offerConsoleMoved(merr) |
| 2775 | } |
| 2776 | respBody, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| 2777 | if err != nil { |
| 2778 | return "", err |
| 2779 | } |
| 2780 | if res.StatusCode != http.StatusOK { |
| 2781 | return "", apiErrorFromBody(res.StatusCode, respBody) |
| 2782 | } |
| 2783 | var out struct { |
| 2784 | SHA256 string `json:"sha256"` |
| 2785 | } |
| 2786 | if err := json.Unmarshal(respBody, &out); err != nil { |
| 2787 | return "", fmt.Errorf("ledger blob: unreadable response: %w", err) |
| 2788 | } |
| 2789 | return out.SHA256, nil |
| 2790 | } |
| 2791 | |
| 2792 | // putJSON is sendJSON pinned to PUT: the config door's write route is PUT |
| 2793 | // (`PUT /api/cli/apps/{account}/{app}/config`, handleAPIConfigPut) — "here is |
| 2794 | // the state this bundle should carry", not an append. |
| 2795 | func (s *apiSession) putJSON(ctx context.Context, path string, in any) (int, []byte, error) { |
| 2796 | return s.sendJSON(ctx, http.MethodPut, path, in) |
| 2797 | } |
| 2798 | |
| 2799 | // patchJSON is sendJSON pinned to PATCH: the instance upstream-repoint door |
| 2800 | // is PATCH (`PATCH /api/cli/apps/{account}/{app}/instances/{host}`, |
| 2801 | // handleAPIInstanceUpdate) — "here is what changed about this record", not |
| 2802 | // the whole state (PUT) or a new one (POST). |
| 2803 | func (s *apiSession) patchJSON(ctx context.Context, path string, in any) (int, []byte, error) { |
| 2804 | return s.sendJSON(ctx, http.MethodPatch, path, in) |
| 2805 | } |
| 2806 | |
| 2807 | // apiConfigVar is one row of a config bundle read back through the console — |
| 2808 | // GET /api/cli/apps/{account}/{app}/config's "vars" entries. Value is only |
| 2809 | // ever populated for Kind=="env": a secret row's value is structurally |
| 2810 | // absent on the wire (the console couldn't read it if it wanted to), so |
| 2811 | // Value simply decodes to "" for those rows, same as an env var's own empty |
| 2812 | // string would. |
| 2813 | type apiConfigVar struct { |
| 2814 | Key, Kind, Value string |
| 2815 | } |
| 2816 | |
| 2817 | // apiConfig is one bundle's config read back through the console, mirroring |
| 2818 | // the GET door's response shape exactly. ExecDelivery and SecretsPubkey |
| 2819 | // decode to "" when the wire response omits them — exec_delivery for a |
| 2820 | // named-environment read (app-level only) or an unset default bundle; |
| 2821 | // secrets_pubkey when the console has no CARLOS_SECRETS_PUBKEY of its own |
| 2822 | // (see secretsPubKey's API-mode resolution order in appconfig_cli.go). |
| 2823 | // Environments is always present (possibly empty). |
| 2824 | type apiConfig struct { |
| 2825 | Bundle string |
| 2826 | ExecDelivery string |
| 2827 | SecretsPubkey string |
| 2828 | // SecretsAccount is the account sqid secrets for this app are sealed |
| 2829 | // for; "" from a console that predates bound envelopes (see |
| 2830 | // handleAPIConfigGet). |
| 2831 | SecretsAccount string |
| 2832 | Environments []string |
| 2833 | Vars []apiConfigVar |
| 2834 | } |
| 2835 | |
| 2836 | // configGet reads one app's config bundle through the console — the default |
| 2837 | // bundle when environment=="", the named environment's own bundle |
| 2838 | // otherwise (matching loadBundle's own environment=="" convention in |
| 2839 | // appconfig_cli.go). The query parameter is sent ONLY when environment is |
| 2840 | // non-empty: the server's own zero value for "no ?environment=" is an |
| 2841 | // absent parameter, not one carrying "". |
| 2842 | func (s *apiSession) configGet(ctx context.Context, a apiAcct, app, environment string) (apiConfig, error) { |
| 2843 | // The one caller-side query append in this package: apiPath's own |
| 2844 | // invariant ("no tail carries a query string") still holds — the tail |
| 2845 | // here is a bare "/config" — but apiPath itself may have added |
| 2846 | // ?exact=sqid for an --account-id call, so this append has to pick its |
| 2847 | // separator instead of assuming "?". |
| 2848 | path := apiPath(a, app, "/config") |
| 2849 | if environment != "" { |
| 2850 | sep := "?" |
| 2851 | if strings.Contains(path, "?") { |
| 2852 | sep = "&" |
| 2853 | } |
| 2854 | path += sep + "environment=" + url.QueryEscape(environment) |
| 2855 | } |
| 2856 | status, body, err := s.getJSON(ctx, path) |
| 2857 | if err != nil { |
| 2858 | return apiConfig{}, err |
| 2859 | } |
| 2860 | if status != http.StatusOK { |
| 2861 | return apiConfig{}, apiErrorFromBody(status, body) |
| 2862 | } |
| 2863 | var res struct { |
| 2864 | Bundle string `json:"bundle"` |
| 2865 | ExecDelivery string `json:"exec_delivery"` |
| 2866 | SecretsPubkey string `json:"secrets_pubkey"` |
| 2867 | SecretsAccount string `json:"secrets_account"` |
| 2868 | Environments []string `json:"environments"` |
| 2869 | Vars []struct { |
| 2870 | Key string `json:"key"` |
| 2871 | Kind string `json:"kind"` |
| 2872 | Value string `json:"value"` |
| 2873 | } `json:"vars"` |
| 2874 | } |
| 2875 | if err := json.Unmarshal(body, &res); err != nil { |
| 2876 | return apiConfig{}, fmt.Errorf("config: unreadable response: %w", err) |
| 2877 | } |
| 2878 | out := apiConfig{ |
| 2879 | Bundle: res.Bundle, |
| 2880 | ExecDelivery: res.ExecDelivery, |
| 2881 | SecretsPubkey: res.SecretsPubkey, |
| 2882 | SecretsAccount: res.SecretsAccount, |
| 2883 | Environments: res.Environments, |
| 2884 | Vars: make([]apiConfigVar, len(res.Vars)), |
| 2885 | } |
| 2886 | for i, v := range res.Vars { |
| 2887 | out.Vars[i] = apiConfigVar{Key: v.Key, Kind: v.Kind, Value: v.Value} |
| 2888 | } |
| 2889 | return out, nil |
| 2890 | } |
| 2891 | |
| 2892 | // apiConfigApply is one config write through the console's config door, |
| 2893 | // mirroring PUT /api/cli/apps/{account}/{app}/config's body exactly. Set's |
| 2894 | // values marshal through appconfig.Var's OWN json tags |
| 2895 | // (`{"kind","value","envelope"}`, value/envelope omitempty) — the wire |
| 2896 | // shape the spec pins for both env and secret vars, so no separate wire |
| 2897 | // struct is needed here. ExecDelivery is a pointer because "absent" (no |
| 2898 | // change requested) and "" are different requests on the wire, but the CLI |
| 2899 | // itself never sends "": SetExecDelivery("off") is always spelled "off", |
| 2900 | // the wire vocabulary handleAPIConfigPut maps onto SetExecDelivery("") — |
| 2901 | // see runEnvExecDelivery. |
| 2902 | type apiConfigApply struct { |
| 2903 | Environment string `json:"environment"` |
| 2904 | Set map[string]appconfig.Var `json:"set,omitempty"` |
| 2905 | Unset []string `json:"unset,omitempty"` |
| 2906 | ExecDelivery *string `json:"exec_delivery,omitempty"` |
| 2907 | Message string `json:"message,omitempty"` |
| 2908 | } |
| 2909 | |
| 2910 | // configApply sends one PUT to the console's config door — a set, an unset, |
| 2911 | // and an exec-delivery change all in one CAS write server-side |
| 2912 | // (appconfig.Apply / ApplyEnvironment carry both halves of a mixed |
| 2913 | // set+unset in one call; see internal/appconfig/generation.go). removed is |
| 2914 | // the server's count of unsets ACTUALLY present, never the argument count — |
| 2915 | // the same rule unsetVars already follows for direct mode (a count of |
| 2916 | // arguments reads as success for a typo'd key). |
| 2917 | func (s *apiSession) configApply(ctx context.Context, a apiAcct, app string, req apiConfigApply) (apiConfigApplyResult, error) { |
| 2918 | status, body, err := s.putJSON(ctx, apiPath(a, app, "/config"), req) |
| 2919 | if err != nil { |
| 2920 | return apiConfigApplyResult{}, err |
| 2921 | } |
| 2922 | if status != http.StatusOK { |
| 2923 | return apiConfigApplyResult{}, apiErrorFromBody(status, body) |
| 2924 | } |
| 2925 | var res struct { |
| 2926 | Result string `json:"result"` |
| 2927 | Removed int `json:"removed"` |
| 2928 | Restart *apiConfigRestart `json:"restart"` |
| 2929 | } |
| 2930 | if err := json.Unmarshal(body, &res); err != nil { |
| 2931 | return apiConfigApplyResult{}, fmt.Errorf("config: unreadable response: %w", err) |
| 2932 | } |
| 2933 | return apiConfigApplyResult{Removed: res.Removed, Restart: res.Restart}, nil |
| 2934 | } |
| 2935 | |
| 2936 | // apiConfigApplyResult is one config write's answer — a struct rather than |
| 2937 | // a bare count for the reason apiPromoteResult is one: a second thing worth |
| 2938 | // returning arrived, and growing a third return value would have meant |
| 2939 | // touching every call site to ignore it. |
| 2940 | type apiConfigApplyResult struct { |
| 2941 | // Removed is how many vars the unset half ACTUALLY removed, which is |
| 2942 | // not necessarily how many were asked for. |
| 2943 | Removed int |
| 2944 | // Restart is what the write landed on that will not read it, and it |
| 2945 | // has three states, all meaningful. NIL means the console never |
| 2946 | // mentioned it — a console older than this field, whose silence must |
| 2947 | // not be rendered as "nothing to restart", because it is not a claim |
| 2948 | // at all (the same precedent apiRetentionResult.Eligible sets for a |
| 2949 | // console that could not compute a number). Present with Known false |
| 2950 | // means the console looked and could not tell. Present with Known true |
| 2951 | // means these are the routes, and an empty Hosts there really does mean |
| 2952 | // none. |
| 2953 | Restart *apiConfigRestart |
| 2954 | } |
| 2955 | |
| 2956 | // apiConfigRestart mirrors the console's own restart notice across the wire |
| 2957 | // (internal/console/apiconfig.go). Additive: an older console omits it |
| 2958 | // entirely and a newer one may add fields this binary ignores. |
| 2959 | type apiConfigRestart struct { |
| 2960 | Known bool `json:"known"` |
| 2961 | Hosts []apiConfigRestartHost `json:"hosts"` |
| 2962 | Unresolved []string `json:"unresolved"` |
| 2963 | } |
| 2964 | |
| 2965 | // apiConfigRestartHost is one delivered-to route. Unit decides which remedy |
| 2966 | // the renderer offers, so it is carried rather than derived: a plain |
| 2967 | // TCP-addr route is one `carlos restart` cannot cycle at all. |
| 2968 | type apiConfigRestartHost struct { |
| 2969 | Host string `json:"host"` |
| 2970 | Unit string `json:"unit"` |
| 2971 | } |
| 2972 | |
| 2973 | // configSync asks the console to touch app's generation object — the remote |
| 2974 | // half of `carlos env sync` (appconfig.TouchGeneration is the direct-mode |
| 2975 | // half; see runEnvSync in appconfig_cli.go). The response carries nothing |
| 2976 | // this client needs beyond success/failure: {"result": "requested"} is the |
| 2977 | // only body handleAPIConfigSync ever sends. |
| 2978 | func (s *apiSession) configSync(ctx context.Context, a apiAcct, app string) error { |
| 2979 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/config/sync"), struct{}{}) |
| 2980 | if err != nil { |
| 2981 | return err |
| 2982 | } |
| 2983 | if status != http.StatusOK { |
| 2984 | return apiErrorFromBody(status, body) |
| 2985 | } |
| 2986 | return nil |
| 2987 | } |
| 2988 | |
| 2989 | // ---- pipeline + pending (release pipelines v2, Task 10) ------------------- |
| 2990 | |
| 2991 | // apiPipelineChannel mirrors release.PipelineChannel/ChannelRules across the |
| 2992 | // wire, but FLAT rather than nested — Passkey/PromoteApprovals/ |
| 2993 | // ChangeApprovals sit alongside Name/Bake here, one struct a `carlos |
| 2994 | // pipeline` verb can build and mutate directly, rather than a Rules |
| 2995 | // sub-struct every call site would have to reach through. MarshalJSON/ |
| 2996 | // UnmarshalJSON below fold that flat shape back onto the server's nested |
| 2997 | // {"name","bake","rules":{"passkey","promote_approvals","change_approvals"}} |
| 2998 | // wire shape (internal/release/pipeline.go) exactly, field for field and |
| 2999 | // omitempty for omitempty — a template PUT (ruling: any template that sets a |
| 3000 | // rule also sets change_approvals) depends on this producing exactly the |
| 3001 | // same bytes handleAPIPipelinePut already accepts from the console's own |
| 3002 | // settings form. |
| 3003 | type apiPipelineChannel struct { |
| 3004 | Name string |
| 3005 | Bake string |
| 3006 | Passkey bool |
| 3007 | PromoteApprovals int |
| 3008 | ChangeApprovals int |
| 3009 | } |
| 3010 | |
| 3011 | func (c apiPipelineChannel) MarshalJSON() ([]byte, error) { |
| 3012 | type rules struct { |
| 3013 | Passkey bool `json:"passkey,omitempty"` |
| 3014 | PromoteApprovals int `json:"promote_approvals,omitempty"` |
| 3015 | ChangeApprovals int `json:"change_approvals,omitempty"` |
| 3016 | } |
| 3017 | type wire struct { |
| 3018 | Name string `json:"name"` |
| 3019 | Bake string `json:"bake,omitempty"` |
| 3020 | Rules *rules `json:"rules,omitempty"` |
| 3021 | } |
| 3022 | w := wire{Name: c.Name, Bake: c.Bake} |
| 3023 | if c.Passkey || c.PromoteApprovals != 0 || c.ChangeApprovals != 0 { |
| 3024 | w.Rules = &rules{Passkey: c.Passkey, PromoteApprovals: c.PromoteApprovals, ChangeApprovals: c.ChangeApprovals} |
| 3025 | } |
| 3026 | return json.Marshal(w) |
| 3027 | } |
| 3028 | |
| 3029 | func (c *apiPipelineChannel) UnmarshalJSON(b []byte) error { |
| 3030 | var w struct { |
| 3031 | Name string `json:"name"` |
| 3032 | Bake string `json:"bake"` |
| 3033 | Rules struct { |
| 3034 | Passkey bool `json:"passkey"` |
| 3035 | PromoteApprovals int `json:"promote_approvals"` |
| 3036 | ChangeApprovals int `json:"change_approvals"` |
| 3037 | } `json:"rules"` |
| 3038 | } |
| 3039 | if err := json.Unmarshal(b, &w); err != nil { |
| 3040 | return err |
| 3041 | } |
| 3042 | c.Name, c.Bake = w.Name, w.Bake |
| 3043 | c.Passkey, c.PromoteApprovals, c.ChangeApprovals = w.Rules.Passkey, w.Rules.PromoteApprovals, w.Rules.ChangeApprovals |
| 3044 | return nil |
| 3045 | } |
| 3046 | |
| 3047 | // apiPipeline is GET/PUT .../pipeline's whole-body shape: the ordered |
| 3048 | // channel list, nothing else — Account/App/Actor/SetAt (release.Pipeline's |
| 3049 | // own extra fields) are server bookkeeping this client has no use for and |
| 3050 | // never sends back. |
| 3051 | type apiPipeline struct { |
| 3052 | Channels []apiPipelineChannel `json:"channels"` |
| 3053 | } |
| 3054 | |
| 3055 | // pipelineGet reads an app's declared pipeline. A nil return (with a nil |
| 3056 | // error) is spec §2's genuine "no pipeline.json" case — GET answers 200 with |
| 3057 | // a null "pipeline" field for it (handleAPIPipelineGet) — never conflated |
| 3058 | // with a read failure, which comes back as a non-nil error instead. |
| 3059 | func (s *apiSession) pipelineGet(ctx context.Context, a apiAcct, app string) (*apiPipeline, error) { |
| 3060 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/pipeline")) |
| 3061 | if err != nil { |
| 3062 | return nil, err |
| 3063 | } |
| 3064 | if status != http.StatusOK { |
| 3065 | return nil, apiErrorFromBody(status, body) |
| 3066 | } |
| 3067 | var res struct { |
| 3068 | Pipeline *apiPipeline `json:"pipeline"` |
| 3069 | } |
| 3070 | if err := json.Unmarshal(body, &res); err != nil { |
| 3071 | return nil, fmt.Errorf("pipeline: unreadable response: %w", err) |
| 3072 | } |
| 3073 | return res.Pipeline, nil |
| 3074 | } |
| 3075 | |
| 3076 | // pipelinePut replaces an app's channel list. Three outcomes, and a |
| 3077 | // `carlos pipeline` verb must tell them apart: |
| 3078 | // |
| 3079 | // - 200: the edit landed. The returned *apiPipeline is the console's own |
| 3080 | // echo of what it stored (SetAt included server-side, though this |
| 3081 | // client only reads Channels back out of it). |
| 3082 | // - 202: the edit touched a channel the CURRENTLY STORED pipeline already |
| 3083 | // guards with change_approvals (guardedEdit, apipipeline.go) — it |
| 3084 | // becomes a proposal at zero confirmations rather than landing. Returned |
| 3085 | // as *apiProposedError, not folded into the generic error path, so the |
| 3086 | // caller can print the "proposed" line instead of failing outright. |
| 3087 | // - anything else: a genuine refusal — validation (422), a lost CAS race |
| 3088 | // (409), a passkey-only channel with no approvals to propose against |
| 3089 | // (403, via consoleRefusal), or a store failure (502). |
| 3090 | func (s *apiSession) pipelinePut(ctx context.Context, a apiAcct, app string, channels []apiPipelineChannel) (*apiPipeline, error) { |
| 3091 | in := struct { |
| 3092 | Channels []apiPipelineChannel `json:"channels"` |
| 3093 | }{channels} |
| 3094 | status, body, err := s.putJSON(ctx, apiPath(a, app, "/pipeline"), in) |
| 3095 | if err != nil { |
| 3096 | return nil, err |
| 3097 | } |
| 3098 | switch status { |
| 3099 | case http.StatusOK: |
| 3100 | var res struct { |
| 3101 | Pipeline apiPipeline `json:"pipeline"` |
| 3102 | } |
| 3103 | if err := json.Unmarshal(body, &res); err != nil { |
| 3104 | return nil, fmt.Errorf("pipeline: unreadable response: %w", err) |
| 3105 | } |
| 3106 | return &res.Pipeline, nil |
| 3107 | case http.StatusAccepted: |
| 3108 | return nil, proposedFromBody(body) |
| 3109 | } |
| 3110 | if refErr, ok := consoleRefusal(status, body); ok { |
| 3111 | return nil, refErr |
| 3112 | } |
| 3113 | return nil, apiErrorFromBody(status, body) |
| 3114 | } |
| 3115 | |
| 3116 | // apiPending is one row of `carlos pipeline`'s pending-changes list — the |
| 3117 | // CLI's read-only window onto a quorum in progress, since the CLI itself can |
| 3118 | // never confirm one (spec §4: confirmation is passkey-backed, browser-only). |
| 3119 | type apiPending struct { |
| 3120 | ID string `json:"id"` |
| 3121 | Kind string `json:"kind"` |
| 3122 | Summary string `json:"summary"` |
| 3123 | Confirmed int `json:"confirmed"` |
| 3124 | Needed int `json:"needed"` |
| 3125 | } |
| 3126 | |
| 3127 | // pendingList reads what an app is waiting on. Always read-only and |
| 3128 | // Publish-reachable, like channels/releases (handleAPIPending's own doc |
| 3129 | // comment) — a pending change names no secret. |
| 3130 | func (s *apiSession) pendingList(ctx context.Context, a apiAcct, app string) ([]apiPending, error) { |
| 3131 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/pending")) |
| 3132 | if err != nil { |
| 3133 | return nil, err |
| 3134 | } |
| 3135 | if status != http.StatusOK { |
| 3136 | return nil, apiErrorFromBody(status, body) |
| 3137 | } |
| 3138 | var res struct { |
| 3139 | Pending []apiPending `json:"pending"` |
| 3140 | } |
| 3141 | if err := json.Unmarshal(body, &res); err != nil { |
| 3142 | return nil, fmt.Errorf("pending: unreadable response: %w", err) |
| 3143 | } |
| 3144 | return res.Pending, nil |
| 3145 | } |
| 3146 | |
| 3147 | // deleteJSON runs one DELETE and hands back the status and raw body without |
| 3148 | // interpreting a non-200 as an error itself — getJSON's plumbing with the |
| 3149 | // method swapped, for the instance-record retire door (issue #66), the one |
| 3150 | // DELETE this client speaks. |
| 3151 | func (s *apiSession) deleteJSON(ctx context.Context, path string, messages ...string) (int, []byte, error) { |
| 3152 | message := firstMutationMessage(messages) |
| 3153 | req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.c.base+path, nil) |
| 3154 | if err != nil { |
| 3155 | return 0, nil, err |
| 3156 | } |
| 3157 | if s.c.token != "" { |
| 3158 | req.Header.Set("Authorization", "Bearer "+s.c.token) |
| 3159 | } |
| 3160 | if message != "" { |
| 3161 | req.Header.Set("X-Carlos-Message", message) |
| 3162 | } |
| 3163 | res, err := s.c.httpc.Do(req) |
| 3164 | if err != nil { |
| 3165 | return 0, nil, err |
| 3166 | } |
| 3167 | defer res.Body.Close() |
| 3168 | if merr := consoleMoved(s.c.base, res.StatusCode, res.Header.Get("Location")); merr != nil { |
| 3169 | return res.StatusCode, nil, offerConsoleMoved(merr) |
| 3170 | } |
| 3171 | body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| 3172 | if err != nil { |
| 3173 | return res.StatusCode, nil, err |
| 3174 | } |
| 3175 | return res.StatusCode, body, nil |
| 3176 | } |
| 3177 | |
| 3178 | // apiInstanceManifest is the instances-manifest door's response — the |
| 3179 | // manifest as the console now holds it, echoed so the CLI prints the domain |
| 3180 | // the SERVER pinned (the account's platform subdomain), never one this side |
| 3181 | // guessed. |
| 3182 | type apiInstanceManifest struct { |
| 3183 | App string `json:"app"` |
| 3184 | Domain string `json:"domain"` |
| 3185 | Pool string `json:"pool"` |
| 3186 | Channel string `json:"channel"` |
| 3187 | Regions []string `json:"regions"` |
| 3188 | Seed []string `json:"seed"` |
| 3189 | // PrivateStorage is the app's standing policy that every instance it |
| 3190 | // creates gets a config environment of its own and an object store |
| 3191 | // bound to it (issue #412). Echoed by the door so `carlos instances |
| 3192 | // enable` can print the policy in force rather than the one just |
| 3193 | // asked for. |
| 3194 | PrivateStorage bool `json:"private_storage"` |
| 3195 | } |
| 3196 | |
| 3197 | // enableError carries the instances-manifest door's STATUS alongside its |
| 3198 | // message. apiErrorFromBody collapses a refusal to its sentence, which is |
| 3199 | // right for a message a member reads and wrong for a decision a caller has |
| 3200 | // to make: the deploy mint must tell "this deployment has no platform host |
| 3201 | // to give" from "your credential just lost authority", and reading English |
| 3202 | // to do it is the mistake errAmbiguousChannel's comment records. Same shape |
| 3203 | // as domainListError, for the same reason. |
| 3204 | type enableError struct { |
| 3205 | status int |
| 3206 | msg string |
| 3207 | } |
| 3208 | |
| 3209 | func (e *enableError) Error() string { return e.msg } |
| 3210 | |
| 3211 | // enableUnavailable reports whether the deployment simply has no platform |
| 3212 | // host for this app: no apps domain configured at all (503), or an |
| 3213 | // instance domain an operator pinned elsewhere in store mode (409), which |
| 3214 | // this door refuses to speak for. Both mean "ask the member for a hostname |
| 3215 | // instead"; every other status is a real failure and must not be swallowed. |
| 3216 | // |
| 3217 | // The 409 is deliberately imprecise: the door also answers 409 for a lost |
| 3218 | // CAS race ("instance settings changed while this request was in flight"), |
| 3219 | // which is transient and would be better retried than fallen back from. |
| 3220 | // Telling the two apart needs the message, and reading English to make a |
| 3221 | // control-flow decision is the mistake this type exists to avoid — so both |
| 3222 | // take the fallback, and the caller PRINTS the door's own sentence, which |
| 3223 | // says "try again" in the transient case. A wrong branch here costs one |
| 3224 | // re-run, never a wrong record. |
| 3225 | func enableUnavailable(err error) bool { |
| 3226 | var ee *enableError |
| 3227 | return errors.As(err, &ee) && |
| 3228 | (ee.status == http.StatusServiceUnavailable || ee.status == http.StatusConflict) |
| 3229 | } |
| 3230 | |
| 3231 | // instancesEnable asks the console to write (or update) the app's instances |
| 3232 | // manifest under the account's own platform subdomain — the member-facing |
| 3233 | // half of `carlos instances enable` (issue #66). channel and seed are |
| 3234 | // pointers for the same reason apiConfigApply's ExecDelivery is: absent |
| 3235 | // means "preserve what the manifest carries", and seed's explicit empty |
| 3236 | // slice means "clear" — three requests the wire must keep apart. |
| 3237 | func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string, channel *string, seed *[]string, privateStorage *bool) (apiInstanceManifest, error) { |
| 3238 | in := struct { |
| 3239 | Channel *string `json:"channel,omitempty"` |
| 3240 | Seed *[]string `json:"seed,omitempty"` |
| 3241 | PrivateStorage *bool `json:"private_storage,omitempty"` |
| 3242 | }{channel, seed, privateStorage} |
| 3243 | status, body, err := s.putJSON(ctx, apiPath(a, app, "/instances-manifest"), in) |
| 3244 | if err != nil { |
| 3245 | return apiInstanceManifest{}, err |
| 3246 | } |
| 3247 | if status != http.StatusOK { |
| 3248 | return apiInstanceManifest{}, &enableError{status: status, msg: apiErrorFromBody(status, body).Error()} |
| 3249 | } |
| 3250 | var out apiInstanceManifest |
| 3251 | if err := json.Unmarshal(body, &out); err != nil { |
| 3252 | return apiInstanceManifest{}, fmt.Errorf("instances enable: unreadable response: %w", err) |
| 3253 | } |
| 3254 | return out, nil |
| 3255 | } |
| 3256 | |
| 3257 | // apiInstanceRecord mirrors provision.Record's wire shape — redeclared here |
| 3258 | // rather than imported because this client renders records, never writes |
| 3259 | // them, and the fields it renders are the contract worth pinning. |
| 3260 | type apiInstanceRecord struct { |
| 3261 | Host string `json:"host"` |
| 3262 | Slug string `json:"slug"` |
| 3263 | Pool string `json:"pool"` |
| 3264 | Region string `json:"region"` |
| 3265 | Channel string `json:"channel"` |
| 3266 | CreatedAt string `json:"created_at"` |
| 3267 | CreatedBy string `json:"created_by"` |
| 3268 | // Kind is the record's route class ("instance" | "service" | "static"; |
| 3269 | // empty means instance — provision.Record's own contract). The wire has |
| 3270 | // carried it since the route-records design (the list embeds the whole |
| 3271 | // record); the client decodes it so deploy can tell a first-route |
| 3272 | // STATIC record (#82's attach writes one) from a real instance — a |
| 3273 | // static record is a static-deploy target, not an instance to adopt. |
| 3274 | Kind string `json:"kind"` |
| 3275 | // StagedVersion is the console's additive staged_version join (Task 8, |
| 3276 | // issue #78): whichever version a box's adopt pass last reported for |
| 3277 | // this host, via status/boxes/<label>.json. Fresh-ish for as long as |
| 3278 | // some box keeps reporting the host, but the console drops it from the |
| 3279 | // join the moment that box goes stale (status.BoxStale) rather than |
| 3280 | // going on serving a dark box's last-known version as if it were |
| 3281 | // current — so empty here means EITHER no box has ever reported this |
| 3282 | // host, or the one that did has gone silent. Always empty on a create |
| 3283 | // response, since no box has adopted the record before this call |
| 3284 | // returns it. |
| 3285 | StagedVersion string `json:"staged_version"` |
| 3286 | // Health is the console's best-effort read of the instance's live state |
| 3287 | // (Task 9) — "running", "serving", "setting-up", "asleep", "no-readings", |
| 3288 | // "not-responding", "failed", or empty when talking to a console that |
| 3289 | // predates the field. Always empty on a create response, same reason |
| 3290 | // StagedVersion is. |
| 3291 | Health string `json:"health"` |
| 3292 | // Upstreams is the console's per-upstream probe join (Task 8's |
| 3293 | // instanceListRecord.Upstreams, apiinstances.go) — what lets the LIST's |
| 3294 | // UPSTREAMS column say "2/3" without a box session. Nil for the |
| 3295 | // overwhelming majority of records (single upstream, never probed) and |
| 3296 | // always nil on a create or set-upstreams response: the join is a |
| 3297 | // list-only best-effort read of the fleet's own snapshots, not something |
| 3298 | // either write path computes. |
| 3299 | Upstreams []apiUpstreamState `json:"upstreams,omitempty"` |
| 3300 | // Steering is the record's DNS steering opt-in (multi-region-steering |
| 3301 | // plan, Task 3): "" (today's static per-host answer) or "latency". Empty |
| 3302 | // on a pre-steering console's response too — the field predates it, not |
| 3303 | // a proof steering is off. |
| 3304 | Steering string `json:"steering"` |
| 3305 | // Environment is the named config bundle this route binds to ("" = the |
| 3306 | // app's default bundle). The list door has always carried it — the row |
| 3307 | // embeds provision.Record — but nothing on this side decoded it until |
| 3308 | // the binding became something a member could set (2026-09-04), and a |
| 3309 | // column the CLI cannot read is a column the CLI cannot show is wrong. |
| 3310 | Environment string `json:"environment"` |
| 3311 | // Gated says whether the host has a password gate (spec |
| 3312 | // 2026-09-07-password-gate-design §5.4). The hash itself never leaves |
| 3313 | // the console. A POINTER because absent and false are different facts |
| 3314 | // here: nil means the console predates the field and has told us |
| 3315 | // nothing, while false is its word that the host is open. Decoding the |
| 3316 | // silence as "open" would have `carlos gate list` print a reassuring |
| 3317 | // OPEN for hosts it knows nothing about — the one wrong answer this |
| 3318 | // column can give, since the person reading it is checking whether |
| 3319 | // something private is exposed. |
| 3320 | Gated *bool `json:"gated"` |
| 3321 | // DBBytes is the instance's SQLite database size as its box last |
| 3322 | // measured it (instanceListRecord.DBBytes). nil when unknown, or from a |
| 3323 | // console that predates the field — the DB column prints a dash. |
| 3324 | DBBytes *int64 `json:"db_bytes,omitempty"` |
| 3325 | // PrivateStoreBucket is the bucket the CREATE door provisioned for this |
| 3326 | // instance under the app's private-storage policy (issue #412). Set on |
| 3327 | // exactly one response — the create that minted it — and empty |
| 3328 | // everywhere else, including on a list, because it is a fact about what |
| 3329 | // one request did rather than a column of the record. What names the |
| 3330 | // store afterwards is Environment, and `carlos store status` is what |
| 3331 | // reads it. |
| 3332 | PrivateStoreBucket string `json:"private_store_bucket"` |
| 3333 | // Seed is the record's seed (seeded-instances spec §3.2); nil on an |
| 3334 | // unseeded record and on an older console. |
| 3335 | Seed *apiSeed `json:"seed,omitempty"` |
| 3336 | // RegionName and RegionFrom say where the create put the record and how |
| 3337 | // that was chosen (nearest-region §9). Empty from a console that predates |
| 3338 | // them, and then the CLI prints today's line. |
| 3339 | RegionName string `json:"region_name,omitempty"` |
| 3340 | RegionFrom string `json:"region_from,omitempty"` |
| 3341 | // Rollout is the instance's rollout state from its box (zero-downtime |
| 3342 | // spec §9.1; the console's rollout join). nil from an older console, and |
| 3343 | // for a host whose box reports none (CARLOS_SWAP off, or a dark box): |
| 3344 | // the deploy watch then watches the URL as it always did. |
| 3345 | Rollout *apiRollout `json:"rollout"` |
| 3346 | // Updates is the box's update report for the instance (the console's |
| 3347 | // update_report join); UpdatesSetting is the record's own "updates" |
| 3348 | // setting. The two keys differ because the list record already owns |
| 3349 | // "updates". SettingsDefault says the record and the app's policy ask |
| 3350 | // for today's behaviour, so a box that ignores settings is only worth a |
| 3351 | // word when something was asked for. |
| 3352 | Updates *apiUpdatesJoin `json:"update_report"` |
| 3353 | UpdatesSetting string `json:"updates"` |
| 3354 | SettingsDefault bool `json:"settings_default"` |
| 3355 | } |
| 3356 | |
| 3357 | // apiUpdatesJoin mirrors the console's updatesJoinView |
| 3358 | // (internal/console/apiinstances.go). |
| 3359 | type apiUpdatesJoin struct { |
| 3360 | Desired string `json:"desired"` |
| 3361 | Reason string `json:"reason"` |
| 3362 | Detail string `json:"detail"` |
| 3363 | Major *int `json:"major"` |
| 3364 | Baking string `json:"baking"` |
| 3365 | Pending uint64 `json:"pending"` |
| 3366 | CompletedFailed bool `json:"completed_failed"` |
| 3367 | Enforced bool `json:"enforced"` |
| 3368 | } |
| 3369 | |
| 3370 | // apiRollout mirrors the console's rolloutView (internal/console/apiinstances.go). |
| 3371 | type apiRollout struct { |
| 3372 | Box string `json:"box"` |
| 3373 | Installed string `json:"installed"` |
| 3374 | Serving string `json:"serving"` |
| 3375 | Good string `json:"good"` |
| 3376 | Awake bool `json:"awake"` |
| 3377 | Attempt string `json:"attempt"` |
| 3378 | AttemptBuild string `json:"attempt_build"` |
| 3379 | HeldBy string `json:"held_by"` |
| 3380 | Reason string `json:"reason"` |
| 3381 | ConfigPending bool `json:"config_pending"` |
| 3382 | // Transient: the box's process for this host is starting or stopping, so |
| 3383 | // it is neither asleep nor swapped and the deploy watch keeps it pending. |
| 3384 | Transient bool `json:"transient,omitempty"` |
| 3385 | SchedulerIdle bool `json:"scheduler_idle"` |
| 3386 | // Unavailable is the box's reason code for being unable to arm its |
| 3387 | // rollout scheduler. When set the box reports no per-host state: awake |
| 3388 | // and scheduler_idle are meaningless false. |
| 3389 | Unavailable string `json:"unavailable,omitempty"` |
| 3390 | } |
| 3391 | |
| 3392 | // apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status) |
| 3393 | // — redeclared here for the same reason apiInstanceRecord is: this client |
| 3394 | // renders the join, never writes it, and Addr/Up are the only fields the |
| 3395 | // UPSTREAMS column reads. Since/Fails/LastError exist on the wire but have no |
| 3396 | // CLI reader yet, so they are left off rather than pinning a contract nothing |
| 3397 | // here uses. |
| 3398 | type apiUpstreamState struct { |
| 3399 | Addr string `json:"addr"` |
| 3400 | Up bool `json:"up"` |
| 3401 | } |
| 3402 | |
| 3403 | // instanceProbeReq is the CLI's request-side spelling of provision.Health — |
| 3404 | // redeclared for the same reason apiInstanceRecord is (this client never |
| 3405 | // imports internal/provision): only the two fields `carlos instances |
| 3406 | // create`/`set-upstreams` expose (--health-path, --health-status) cross the |
| 3407 | // wire. Everything else the server-side Health struct carries keeps its |
| 3408 | // platform default, exactly as an omitted probe block always has. |
| 3409 | type instanceProbeReq struct { |
| 3410 | Path string `json:"path,omitempty"` |
| 3411 | Status int `json:"status,omitempty"` |
| 3412 | } |
| 3413 | |
| 3414 | // instanceReplicaReq is the #162 replica slice of an instances-create body: |
| 3415 | // the role ("" or "replica") and, for a home record, the account's replica |
| 3416 | // placement set. role() keeps the wire clean for the overwhelmingly common |
| 3417 | // no-replica create: a nil request sends neither key, byte-identical to |
| 3418 | // every body before the field existed. |
| 3419 | type instanceReplicaReq struct { |
| 3420 | Role string |
| 3421 | Set []string |
| 3422 | } |
| 3423 | |
| 3424 | func (rq *instanceReplicaReq) role() string { |
| 3425 | if rq == nil { |
| 3426 | return "" |
| 3427 | } |
| 3428 | return rq.Role |
| 3429 | } |
| 3430 | |
| 3431 | // set returns the placement set, nil-safe — a nil request sends no key. |
| 3432 | func (rq *instanceReplicaReq) set() []string { |
| 3433 | if rq == nil { |
| 3434 | return nil |
| 3435 | } |
| 3436 | return rq.Set |
| 3437 | } |
| 3438 | |
| 3439 | // instanceCreate asks the console to create one instance record — the |
| 3440 | // create-only write whose materialization (provision.Reconcile, on the box) |
| 3441 | // is what brings the host live. The console stamps pool from the manifest |
| 3442 | // and validates host against the manifest's domain; this side sends only |
| 3443 | // what a caller may choose. |
| 3444 | // |
| 3445 | // addrs and probe are the route-shape fields handleAPIInstanceCreate accepts |
| 3446 | // alongside host/region/channel (Task 8's --addr/--health-path/--health-status |
| 3447 | // on `carlos instances create`): addrs nil means the platform's own default |
| 3448 | // upstream (kind defaults to "instance", which needs no addr at all), and |
| 3449 | // probe nil means unprobed — the same "absent, not zero" contract the wire |
| 3450 | // carries for every optional field on this door. |
| 3451 | func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel, kind, environment string, addrs []string, probe *instanceProbeReq, replicas *instanceReplicaReq, messages ...string) (apiInstanceRecord, error) { |
| 3452 | message := firstMutationMessage(messages) |
| 3453 | in := struct { |
| 3454 | Host string `json:"host"` |
| 3455 | Region string `json:"region,omitempty"` |
| 3456 | Channel string `json:"channel,omitempty"` |
| 3457 | Kind string `json:"kind,omitempty"` |
| 3458 | Environment string `json:"environment,omitempty"` |
| 3459 | Addrs []string `json:"addrs,omitempty"` |
| 3460 | Probe *instanceProbeReq `json:"probe,omitempty"` |
| 3461 | Role string `json:"role,omitempty"` |
| 3462 | Replicas []string `json:"replicas,omitempty"` |
| 3463 | Message string `json:"message,omitempty"` |
| 3464 | }{host, region, channel, kind, environment, addrs, probe, replicas.role(), replicas.set(), message} |
| 3465 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in) |
| 3466 | if err != nil { |
| 3467 | return apiInstanceRecord{}, err |
| 3468 | } |
| 3469 | if status != http.StatusCreated { |
| 3470 | return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| 3471 | } |
| 3472 | var out apiInstanceRecord |
| 3473 | if err := json.Unmarshal(body, &out); err != nil { |
| 3474 | return apiInstanceRecord{}, fmt.Errorf("instances create: unreadable response: %w", err) |
| 3475 | } |
| 3476 | return out, nil |
| 3477 | } |
| 3478 | |
| 3479 | // instanceSetChannel moves one route onto a different release channel — |
| 3480 | // `carlos instances set-channel`, the CLI front over |
| 3481 | // handleAPIInstanceChannel. A gated move comes back as a 202 the same way a |
| 3482 | // gated promote does, so the caller gets an *apiProposedError naming where |
| 3483 | // to finish rather than a refusal that reads like a failure. |
| 3484 | func (s *apiSession) instanceSetChannel(ctx context.Context, a apiAcct, app, host, channel string) (apiInstanceRecord, error) { |
| 3485 | in := struct { |
| 3486 | Channel string `json:"channel"` |
| 3487 | }{channel} |
| 3488 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/channel"), in) |
| 3489 | if err != nil { |
| 3490 | return apiInstanceRecord{}, err |
| 3491 | } |
| 3492 | if status == http.StatusAccepted { |
| 3493 | return apiInstanceRecord{}, proposedFromBody(body) |
| 3494 | } |
| 3495 | if status != http.StatusOK { |
| 3496 | return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| 3497 | } |
| 3498 | var out apiInstanceRecord |
| 3499 | if err := json.Unmarshal(body, &out); err != nil { |
| 3500 | return apiInstanceRecord{}, fmt.Errorf("instances set-channel: unreadable response: %w", err) |
| 3501 | } |
| 3502 | return out, nil |
| 3503 | } |
| 3504 | |
| 3505 | // instanceSetUpstreams asks the console to repoint one instance's upstream |
| 3506 | // set and/or its probe — `carlos instances set-upstreams`, the CLI front |
| 3507 | // over handleAPIInstanceUpdate's PATCH (Task 7, issue #152). |
| 3508 | // |
| 3509 | // addrs and probe are independently optional, exactly as the door documents |
| 3510 | // (apiinstances.go): a nil addrs leaves the record's upstream set alone, and |
| 3511 | // a nil probe leaves its probe config alone — the caller may repoint one |
| 3512 | // without touching the other. addrs is a POINTER, not a plain slice, so |
| 3513 | // "flag not given" (nil, omitted from the wire) and an explicitly empty |
| 3514 | // --addrs (a pointer to an empty slice, sent as `"addrs":[]`, which clears |
| 3515 | // the set) stay two |
| 3516 | // different requests the way the door's own *[]string distinguishes them — |
| 3517 | // the same precedent apiConfigApply's optional fields set. This client has |
| 3518 | // no way to spell `"probe":null` (an explicit probe CLEAR): nothing here |
| 3519 | // exposes it, since set-upstreams' only probe-side flags ADD or replace a |
| 3520 | // probe, never retract one. |
| 3521 | func (s *apiSession) instanceSetUpstreams(ctx context.Context, a apiAcct, app, host string, addrs *[]string, probe *instanceProbeReq) (apiInstanceRecord, error) { |
| 3522 | in := struct { |
| 3523 | Addrs *[]string `json:"addrs,omitempty"` |
| 3524 | Probe *instanceProbeReq `json:"probe,omitempty"` |
| 3525 | }{addrs, probe} |
| 3526 | status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| 3527 | if err != nil { |
| 3528 | return apiInstanceRecord{}, err |
| 3529 | } |
| 3530 | if status != http.StatusOK { |
| 3531 | return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| 3532 | } |
| 3533 | var out apiInstanceRecord |
| 3534 | if err := json.Unmarshal(body, &out); err != nil { |
| 3535 | return apiInstanceRecord{}, fmt.Errorf("instances set-upstreams: unreadable response: %w", err) |
| 3536 | } |
| 3537 | return out, nil |
| 3538 | } |
| 3539 | |
| 3540 | // instanceSetSteering asks the console to repoint one instance's DNS |
| 3541 | // steering opt-in — `carlos steering`, the CLI front over |
| 3542 | // handleAPIInstanceUpdate's PATCH (Task 3, multi-region-steering plan). |
| 3543 | // steering is always sent (never omitted): unlike addrs/probe there is no |
| 3544 | // "leave it alone" spelling this verb needs — every invocation states the |
| 3545 | // value the caller wants, "latency" or "" (off). |
| 3546 | // instanceSetEnvironment rebinds one route to a named config bundle — |
| 3547 | // `carlos instances set-environment`, over the same PATCH door |
| 3548 | // set-upstreams and set-steering use. |
| 3549 | // |
| 3550 | // environment is always sent, never omitted, for the same reason steering |
| 3551 | // is: every invocation states the value the caller wants. "" is a real |
| 3552 | // request — return this route to the app's default bundle — and not a |
| 3553 | // "leave it alone", which is why the field carries no omitempty and why |
| 3554 | // this signature takes a plain string rather than a pointer. The CLI's own |
| 3555 | // flag handling is what refuses an invocation that named nothing. |
| 3556 | // |
| 3557 | // This is the door that closes the gap `carlos canary` opened. Config is |
| 3558 | // per-app, so a canary route created without it inherits the default bundle |
| 3559 | // — including the single origin the app's framework compares against — and |
| 3560 | // every POST to the canary host fails closed while every GET looks fine. |
| 3561 | func (s *apiSession) instanceSetEnvironment(ctx context.Context, a apiAcct, app, host, environment string) (apiInstanceRecord, error) { |
| 3562 | in := struct { |
| 3563 | Environment string `json:"environment"` |
| 3564 | }{environment} |
| 3565 | status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| 3566 | if err != nil { |
| 3567 | return apiInstanceRecord{}, err |
| 3568 | } |
| 3569 | if status == http.StatusAccepted { |
| 3570 | return apiInstanceRecord{}, proposedFromBody(body) |
| 3571 | } |
| 3572 | if status != http.StatusOK { |
| 3573 | return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| 3574 | } |
| 3575 | var out apiInstanceRecord |
| 3576 | if err := json.Unmarshal(body, &out); err != nil { |
| 3577 | return apiInstanceRecord{}, fmt.Errorf("instances set-environment: unreadable response: %w", err) |
| 3578 | } |
| 3579 | return out, nil |
| 3580 | } |
| 3581 | |
| 3582 | func (s *apiSession) instanceSetSteering(ctx context.Context, a apiAcct, app, host, steering string) (apiInstanceRecord, error) { |
| 3583 | in := struct { |
| 3584 | Steering string `json:"steering"` |
| 3585 | }{steering} |
| 3586 | status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| 3587 | if err != nil { |
| 3588 | return apiInstanceRecord{}, err |
| 3589 | } |
| 3590 | if status != http.StatusOK { |
| 3591 | return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| 3592 | } |
| 3593 | var out apiInstanceRecord |
| 3594 | if err := json.Unmarshal(body, &out); err != nil { |
| 3595 | return apiInstanceRecord{}, fmt.Errorf("steering: unreadable response: %w", err) |
| 3596 | } |
| 3597 | return out, nil |
| 3598 | } |
| 3599 | |
| 3600 | // instanceSetGate sets ("" clears) one instance's password gate through |
| 3601 | // the console, which hashes it; the plaintext crosses the wire once, over |
| 3602 | // TLS. A console older than the field answers 400 "nothing to update", |
| 3603 | // which is reported as exactly that. |
| 3604 | func (s *apiSession) instanceSetGate(ctx context.Context, a apiAcct, app, host, password string) (apiInstanceRecord, error) { |
| 3605 | in := struct { |
| 3606 | Gate string `json:"gate"` |
| 3607 | }{password} |
| 3608 | status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| 3609 | if err != nil { |
| 3610 | return apiInstanceRecord{}, err |
| 3611 | } |
| 3612 | if status != http.StatusOK { |
| 3613 | if status == http.StatusBadRequest && strings.Contains(string(body), "nothing to update") { |
| 3614 | return apiInstanceRecord{}, fmt.Errorf("this console does not know about password gates yet — it needs a newer carlos") |
| 3615 | } |
| 3616 | return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| 3617 | } |
| 3618 | var out apiInstanceRecord |
| 3619 | if err := json.Unmarshal(body, &out); err != nil { |
| 3620 | return apiInstanceRecord{}, fmt.Errorf("gate: unreadable response: %w", err) |
| 3621 | } |
| 3622 | return out, nil |
| 3623 | } |
| 3624 | |
| 3625 | // instanceList reads the app's instance records through the console, honest |
| 3626 | // about the server's 500-record cap. match and health are the server-side |
| 3627 | // filters Task 9 added (q and health query params) — empty strings send |
| 3628 | // neither, and the console applies no filter. total is the server's count |
| 3629 | // of ALL matching records (not just the page returned here); a console that |
| 3630 | // predates Task 9 sends no "total" field at all, which decodes as 0 — the |
| 3631 | // CLI printer treats that as "unknown" rather than "zero matches", since a |
| 3632 | // non-empty recs slice from the same response proves it isn't really zero. |
| 3633 | func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app, match, health string) ([]apiInstanceRecord, bool, int, error) { |
| 3634 | tail := "/instances" |
| 3635 | v := url.Values{} |
| 3636 | if match != "" { |
| 3637 | v.Set("q", match) |
| 3638 | } |
| 3639 | if health != "" { |
| 3640 | v.Set("health", health) |
| 3641 | } |
| 3642 | if enc := v.Encode(); enc != "" { |
| 3643 | tail += "?" + enc |
| 3644 | } |
| 3645 | status, body, err := s.getJSON(ctx, apiPath(a, app, tail)) |
| 3646 | if err != nil { |
| 3647 | return nil, false, 0, err |
| 3648 | } |
| 3649 | if status != http.StatusOK { |
| 3650 | return nil, false, 0, apiErrorFromBody(status, body) |
| 3651 | } |
| 3652 | var res struct { |
| 3653 | Instances []apiInstanceRecord `json:"instances"` |
| 3654 | Truncated bool `json:"truncated"` |
| 3655 | Total int `json:"total"` |
| 3656 | } |
| 3657 | if err := json.Unmarshal(body, &res); err != nil { |
| 3658 | return nil, false, 0, fmt.Errorf("instances list: unreadable response: %w", err) |
| 3659 | } |
| 3660 | return res.Instances, res.Truncated, res.Total, nil |
| 3661 | } |
| 3662 | |
| 3663 | // apiRouteRow is one row of the console's routes answer |
| 3664 | // (internal/console/apiroutes.go). Every field is additive on the wire: a |
| 3665 | // console that predates one sends nothing and it decodes as the zero |
| 3666 | // value, which is why an empty Environment must be printed as "takes the |
| 3667 | // app default" rather than as a binding this client failed to read. |
| 3668 | type apiRouteRow struct { |
| 3669 | Host string `json:"host"` |
| 3670 | Kind string `json:"kind"` |
| 3671 | Upstream string `json:"upstream"` |
| 3672 | App string `json:"app"` |
| 3673 | Channel string `json:"channel"` |
| 3674 | Environment string `json:"environment"` |
| 3675 | Status string `json:"status"` |
| 3676 | Health string `json:"health"` |
| 3677 | Hibernate bool `json:"hibernate"` |
| 3678 | Unit string `json:"unit"` |
| 3679 | Backing string `json:"backing"` |
| 3680 | Provisioned bool `json:"provisioned"` |
| 3681 | Replication string `json:"replication"` |
| 3682 | DBBytes *int64 `json:"db_bytes,omitempty"` // nil = unknown, printed as a dash |
| 3683 | // PlatformCanary is a route the domains reconciler owns whole; a console |
| 3684 | // that predates the field sends nothing and this reads false. |
| 3685 | PlatformCanary bool `json:"platform_canary,omitempty"` |
| 3686 | } |
| 3687 | |
| 3688 | // apiRoutePathRow is one path route of the same answer: a leading URL |
| 3689 | // segment on Host served by the route at Target rather than by Host's own |
| 3690 | // route. Additive like every field above — a console that predates path |
| 3691 | // routing sends no `paths` key and this decodes as an empty list, which is |
| 3692 | // the same thing it means on a console that has one and no rows in it. |
| 3693 | type apiRoutePathRow struct { |
| 3694 | Host string `json:"host"` |
| 3695 | Prefix string `json:"prefix"` |
| 3696 | Target string `json:"target"` |
| 3697 | } |
| 3698 | |
| 3699 | // routeList reads one app's routes on the console's own box (issue #64), and |
| 3700 | // the path routes hanging off them. Two returns rather than one joined |
| 3701 | // structure: a path row is a pointer at a route, not a kind of route, and |
| 3702 | // flattening them into one list would make every existing caller's loop |
| 3703 | // start having to ask which it was looking at. |
| 3704 | func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, []apiRoutePathRow, error) { |
| 3705 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/routes")) |
| 3706 | if err != nil { |
| 3707 | return nil, nil, err |
| 3708 | } |
| 3709 | if status != http.StatusOK { |
| 3710 | return nil, nil, apiErrorFromBody(status, body) |
| 3711 | } |
| 3712 | var res struct { |
| 3713 | Routes []apiRouteRow `json:"routes"` |
| 3714 | Paths []apiRoutePathRow `json:"paths"` |
| 3715 | } |
| 3716 | if err := json.Unmarshal(body, &res); err != nil { |
| 3717 | return nil, nil, fmt.Errorf("routes: unreadable response: %w", err) |
| 3718 | } |
| 3719 | return res.Routes, res.Paths, nil |
| 3720 | } |
| 3721 | |
| 3722 | // apiLogsResponse is the console's logs answer (internal/console/apilogs.go) |
| 3723 | // — records are applog's own wire shape, decoded as-is. |
| 3724 | type apiLogsResponse struct { |
| 3725 | Records []applog.Record `json:"records"` |
| 3726 | Truncated bool `json:"truncated"` |
| 3727 | Cursor string `json:"cursor"` |
| 3728 | } |
| 3729 | |
| 3730 | // logsGet reads one window (or one follow increment) of an app's merged log |
| 3731 | // timeline. query carries the filter params exactly as the endpoint speaks |
| 3732 | // them; apiPath may already hold a query (exact=sqid), hence the separator |
| 3733 | // dance. |
| 3734 | func (s *apiSession) logsGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiLogsResponse, error) { |
| 3735 | path := apiPath(a, app, "/logs") |
| 3736 | if enc := query.Encode(); enc != "" { |
| 3737 | if strings.Contains(path, "?") { |
| 3738 | path += "&" + enc |
| 3739 | } else { |
| 3740 | path += "?" + enc |
| 3741 | } |
| 3742 | } |
| 3743 | var res apiLogsResponse |
| 3744 | status, body, err := s.getJSON(ctx, path) |
| 3745 | if err != nil { |
| 3746 | return res, err |
| 3747 | } |
| 3748 | if status != http.StatusOK { |
| 3749 | return res, apiErrorFromBody(status, body) |
| 3750 | } |
| 3751 | if err := json.Unmarshal(body, &res); err != nil { |
| 3752 | return res, fmt.Errorf("logs: unreadable response: %w", err) |
| 3753 | } |
| 3754 | return res, nil |
| 3755 | } |
| 3756 | |
| 3757 | // apiErrorsResponse is the console's errors and availability answer (spec |
| 3758 | // 2026-09-07-errors-and-analytics-design.md §3.6) — groups and records are |
| 3759 | // apperr's own wire shapes, decoded as-is. |
| 3760 | type apiErrorsResponse struct { |
| 3761 | Groups []apperr.Group `json:"groups"` |
| 3762 | Records []apperr.Record `json:"records"` |
| 3763 | Truncated bool `json:"truncated"` |
| 3764 | LogLevel string `json:"log_level"` |
| 3765 | Sources []string `json:"sources"` |
| 3766 | } |
| 3767 | |
| 3768 | // errorsGet reads one window of an app's error groups (or, with |
| 3769 | // query["fp"] set, one group's occurrences). query carries the filter |
| 3770 | // params exactly as the endpoint speaks them; apiPath may already hold a |
| 3771 | // query (exact=sqid), hence the separator dance. |
| 3772 | func (s *apiSession) errorsGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiErrorsResponse, error) { |
| 3773 | return s.errorFamilyGet(ctx, a, app, "errors", query) |
| 3774 | } |
| 3775 | |
| 3776 | // availabilityGet is errorsGet for the Availability half (the console's |
| 3777 | // /availability): the same shape, with no log level. |
| 3778 | func (s *apiSession) availabilityGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiErrorsResponse, error) { |
| 3779 | return s.errorFamilyGet(ctx, a, app, "availability", query) |
| 3780 | } |
| 3781 | |
| 3782 | func (s *apiSession) errorFamilyGet(ctx context.Context, a apiAcct, app, family string, query url.Values) (apiErrorsResponse, error) { |
| 3783 | path := apiPath(a, app, "/"+family) |
| 3784 | if enc := query.Encode(); enc != "" { |
| 3785 | if strings.Contains(path, "?") { |
| 3786 | path += "&" + enc |
| 3787 | } else { |
| 3788 | path += "?" + enc |
| 3789 | } |
| 3790 | } |
| 3791 | var res apiErrorsResponse |
| 3792 | status, body, err := s.getJSON(ctx, path) |
| 3793 | if err != nil { |
| 3794 | return res, err |
| 3795 | } |
| 3796 | if status != http.StatusOK { |
| 3797 | return res, apiErrorFromBody(status, body) |
| 3798 | } |
| 3799 | if err := json.Unmarshal(body, &res); err != nil { |
| 3800 | return res, fmt.Errorf("%s: unreadable response: %w", family, err) |
| 3801 | } |
| 3802 | return res, nil |
| 3803 | } |
| 3804 | |
| 3805 | // apiAnalyticsResponse is the console's analytics answer — days, pages, |
| 3806 | // referers, and events are the wire shapes decoded as-is. |
| 3807 | type apiAnalyticsResponse struct { |
| 3808 | Range string `json:"range"` |
| 3809 | From string `json:"from"` |
| 3810 | To string `json:"to"` |
| 3811 | On bool `json:"on"` |
| 3812 | Count string `json:"count"` |
| 3813 | CountFrom string `json:"count_from"` |
| 3814 | PV int64 `json:"pv"` |
| 3815 | Visitors int64 `json:"visitors"` |
| 3816 | Bytes int64 `json:"bytes"` |
| 3817 | Bots int64 `json:"bots"` |
| 3818 | Pages map[string]int64 `json:"pages"` |
| 3819 | Referers map[string]int64 `json:"referers"` |
| 3820 | Events map[string]int64 `json:"events"` |
| 3821 | Countries map[string]int64 `json:"countries"` |
| 3822 | GeoSources map[string]int64 `json:"geo_sources"` |
| 3823 | PagesApprox bool `json:"pages_approx"` |
| 3824 | ReferersApprox bool `json:"referers_approx"` |
| 3825 | EventsApprox bool `json:"events_approx"` |
| 3826 | Days []analytics.DaySummary `json:"days"` |
| 3827 | Dropped analytics.Dropped `json:"dropped"` |
| 3828 | } |
| 3829 | |
| 3830 | // analyticsGet reads an app's analytics for the given range. query carries |
| 3831 | // the filter params exactly as the endpoint speaks them. |
| 3832 | func (s *apiSession) analyticsGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiAnalyticsResponse, error) { |
| 3833 | path := apiPath(a, app, "/analytics") |
| 3834 | if enc := query.Encode(); enc != "" { |
| 3835 | if strings.Contains(path, "?") { |
| 3836 | path += "&" + enc |
| 3837 | } else { |
| 3838 | path += "?" + enc |
| 3839 | } |
| 3840 | } |
| 3841 | var res apiAnalyticsResponse |
| 3842 | status, body, err := s.getJSON(ctx, path) |
| 3843 | if err != nil { |
| 3844 | return res, err |
| 3845 | } |
| 3846 | if status != http.StatusOK { |
| 3847 | return res, apiErrorFromBody(status, body) |
| 3848 | } |
| 3849 | if err := json.Unmarshal(body, &res); err != nil { |
| 3850 | return res, fmt.Errorf("analytics: unreadable response: %w", err) |
| 3851 | } |
| 3852 | return res, nil |
| 3853 | } |
| 3854 | |
| 3855 | // telemetryPut sets an app's telemetry settings: the errors log level and |
| 3856 | // the analytics switches, either or both in one body — PUT |
| 3857 | // /api/cli/apps/{account}/{app}/telemetry. |
| 3858 | func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, body any) error { |
| 3859 | status, resp, err := s.putJSON(ctx, apiPath(a, app, "/telemetry"), body) |
| 3860 | if err != nil { |
| 3861 | return err |
| 3862 | } |
| 3863 | if status != http.StatusOK { |
| 3864 | return apiErrorFromBody(status, resp) |
| 3865 | } |
| 3866 | return nil |
| 3867 | } |
| 3868 | |
| 3869 | // instanceDelete retires one instance record. Idempotent server-side (the |
| 3870 | // caller's goal is "not there"), so a 200 is the only success shape. |
| 3871 | func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host string, messages ...string) error { |
| 3872 | status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), messages...) |
| 3873 | if err != nil { |
| 3874 | return err |
| 3875 | } |
| 3876 | if status != http.StatusOK { |
| 3877 | return apiErrorFromBody(status, body) |
| 3878 | } |
| 3879 | return nil |
| 3880 | } |
| 3881 | |
| 3882 | // instanceGet reads ONE instance record (GET …/instances/{host}). found is |
| 3883 | // false only on a 404, the console's answer for a host with no record; any |
| 3884 | // other failure is an error, never "absent" (canary --region relies on |
| 3885 | // that, nearest-region plan-R3 1). |
| 3886 | func (s *apiSession) instanceGet(ctx context.Context, a apiAcct, app, host string) (apiInstanceRecord, bool, error) { |
| 3887 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host))) |
| 3888 | if err != nil { |
| 3889 | return apiInstanceRecord{}, false, err |
| 3890 | } |
| 3891 | if status == http.StatusNotFound { |
| 3892 | return apiInstanceRecord{}, false, nil |
| 3893 | } |
| 3894 | if status != http.StatusOK { |
| 3895 | return apiInstanceRecord{}, false, apiErrorFromBody(status, body) |
| 3896 | } |
| 3897 | var out apiInstanceRecord |
| 3898 | if err := json.Unmarshal(body, &out); err != nil { |
| 3899 | return apiInstanceRecord{}, false, fmt.Errorf("instance: unreadable response: %w", err) |
| 3900 | } |
| 3901 | return out, true, nil |
| 3902 | } |
| 3903 | |
| 3904 | // importBegin is the import door's begin (seeded-instances spec §5.1): the |
| 3905 | // ordinary create body plus "import". The verify token comes back once and |
| 3906 | // is returned, never stored or logged. Begin may provision a private store, |
| 3907 | // which can take the console up to a minute, so it gets storeProvision's |
| 3908 | // scoped client rather than the session's 30-second one (a cut begin would |
| 3909 | // lose the only copy of the token). |
| 3910 | func (s *apiSession) importBegin(ctx context.Context, a apiAcct, app, host, environment, sha string, bytes int64, replaces string, messages ...string) (apiInstanceRecord, string, error) { |
| 3911 | in := struct { |
| 3912 | Host string `json:"host"` |
| 3913 | Environment string `json:"environment,omitempty"` |
| 3914 | Message string `json:"message,omitempty"` |
| 3915 | Import struct { |
| 3916 | SHA256 string `json:"sha256"` |
| 3917 | Bytes int64 `json:"bytes"` |
| 3918 | Replaces string `json:"replaces"` |
| 3919 | } `json:"import"` |
| 3920 | }{Host: host, Environment: environment, Message: firstMutationMessage(messages)} |
| 3921 | in.Import.SHA256, in.Import.Bytes, in.Import.Replaces = sha, bytes, replaces |
| 3922 | clone := *s |
| 3923 | client := *s.c |
| 3924 | httpc := *s.c.httpc |
| 3925 | httpc.Timeout = storeProvisionHTTPTimeout |
| 3926 | client.httpc = &httpc |
| 3927 | clone.c = &client |
| 3928 | status, body, err := clone.postJSON(ctx, apiPath(a, app, "/instances"), in) |
| 3929 | if err != nil { |
| 3930 | return apiInstanceRecord{}, "", err |
| 3931 | } |
| 3932 | if status != http.StatusCreated { |
| 3933 | return apiInstanceRecord{}, "", apiErrorFromBody(status, body) |
| 3934 | } |
| 3935 | var out struct { |
| 3936 | apiInstanceRecord |
| 3937 | VerifyToken string `json:"verify_token"` |
| 3938 | } |
| 3939 | if err := json.Unmarshal(body, &out); err != nil { |
| 3940 | return apiInstanceRecord{}, "", fmt.Errorf("instances import: unreadable response: %w", err) |
| 3941 | } |
| 3942 | if out.VerifyToken == "" { |
| 3943 | return apiInstanceRecord{}, "", errors.New("instances import: unreadable response") |
| 3944 | } |
| 3945 | return out.apiInstanceRecord, out.VerifyToken, nil |
| 3946 | } |
| 3947 | |
| 3948 | // importUploadDeadline bounds the upload: the console reads the body for |
| 3949 | // up to 60 minutes and answers 202 straight after the last byte. |
| 3950 | const importUploadDeadline = 65 * time.Minute |
| 3951 | |
| 3952 | // importDoor is the path of host's import door, or of its verb, naming |
| 3953 | // seed id: the console acts only on the import the caller means (final |
| 3954 | // review F1), so a delayed request cannot land on a later import of the |
| 3955 | // same host. |
| 3956 | func importDoor(host, verb, id string) string { |
| 3957 | p := "/instances/" + url.PathEscape(host) + "/import" |
| 3958 | if verb != "" { |
| 3959 | p += "/" + verb |
| 3960 | } |
| 3961 | return p + "?seed=" + url.QueryEscape(id) |
| 3962 | } |
| 3963 | |
| 3964 | // importUpload streams the snapshot (spec §5.2) on doStream's client, which |
| 3965 | // has no overall timeout, under importUploadDeadline. Expect: 100-continue |
| 3966 | // lets a refusal (busy scratch, wrong state) arrive before gigabytes are |
| 3967 | // sent. A 202 means the console checks and stores it on its own; the |
| 3968 | // caller follows the record to "uploaded". |
| 3969 | func (s *apiSession) importUpload(ctx context.Context, a apiAcct, app, host, id string, f *os.File, size int64) error { |
| 3970 | ctx, cancel := context.WithTimeout(ctx, importUploadDeadline) |
| 3971 | defer cancel() |
| 3972 | res, err := s.c.doStream(ctx, http.MethodPut, apiPath(a, app, importDoor(host, "", id)), f, size, |
| 3973 | http.Header{"Content-Type": {"application/octet-stream"}, "Expect": {"100-continue"}}) |
| 3974 | if err != nil { |
| 3975 | return err |
| 3976 | } |
| 3977 | defer res.Body.Close() |
| 3978 | raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| 3979 | if res.StatusCode != http.StatusAccepted { |
| 3980 | return apiErrorFromBody(res.StatusCode, raw) |
| 3981 | } |
| 3982 | return nil |
| 3983 | } |
| 3984 | |
| 3985 | // importVerb is the import's stopped, commit or abort (spec §5.3, §5.4, |
| 3986 | // §5.6): a bodiless POST that answers 200. |
| 3987 | func (s *apiSession) importVerb(ctx context.Context, a apiAcct, app, host, id, verb string) error { |
| 3988 | status, body, err := s.postJSON(ctx, apiPath(a, app, importDoor(host, verb, id)), struct{}{}) |
| 3989 | if err != nil { |
| 3990 | return err |
| 3991 | } |
| 3992 | if status != http.StatusOK { |
| 3993 | return apiErrorFromBody(status, body) |
| 3994 | } |
| 3995 | return nil |
| 3996 | } |
| 3997 | |
| 3998 | // apiSidecarManifest mirrors the console's sidecars-manifest response |
| 3999 | // (handleAPISidecarEnable's writeJSON, internal/console/apisidecars.go) — |
| 4000 | // read back after a write so the confirmation prints what the console |
| 4001 | // actually stored, not just what the caller asked for. |
| 4002 | type apiSidecarManifest struct { |
| 4003 | App string `json:"app"` |
| 4004 | Pool string `json:"pool"` |
| 4005 | Channel string `json:"channel"` |
| 4006 | Args []string `json:"args"` |
| 4007 | Placement string `json:"placement"` |
| 4008 | Regions []string `json:"regions"` |
| 4009 | IdleSeconds int `json:"idle_seconds"` |
| 4010 | MaxRunSeconds int `json:"max_run_seconds"` |
| 4011 | Hibernate bool `json:"hibernate"` |
| 4012 | } |
| 4013 | |
| 4014 | // sidecarsEnable asks the console to write (or update) the app's sidecars |
| 4015 | // manifest — the member-facing half of `carlos sidecars enable`. Every |
| 4016 | // field is a POINTER for the same reason instancesEnable's channel/seed |
| 4017 | // are: absent means "preserve what the manifest already carries", and an |
| 4018 | // explicit empty slice on args means "clear" — three requests the wire |
| 4019 | // must keep apart (handleAPISidecarEnable's own doc, apisidecars.go). |
| 4020 | func (s *apiSession) sidecarsEnable(ctx context.Context, a apiAcct, app string, |
| 4021 | channel *string, args *[]string, placement *string, idleSeconds, maxRunSeconds *int, hibernate *bool, |
| 4022 | ) (apiSidecarManifest, error) { |
| 4023 | in := struct { |
| 4024 | Channel *string `json:"channel,omitempty"` |
| 4025 | Args *[]string `json:"args,omitempty"` |
| 4026 | Placement *string `json:"placement,omitempty"` |
| 4027 | IdleSeconds *int `json:"idle_seconds,omitempty"` |
| 4028 | MaxRunSeconds *int `json:"max_run_seconds,omitempty"` |
| 4029 | Hibernate *bool `json:"hibernate,omitempty"` |
| 4030 | }{channel, args, placement, idleSeconds, maxRunSeconds, hibernate} |
| 4031 | status, body, err := s.putJSON(ctx, apiPath(a, app, "/sidecars-manifest"), in) |
| 4032 | if err != nil { |
| 4033 | return apiSidecarManifest{}, err |
| 4034 | } |
| 4035 | if status != http.StatusOK { |
| 4036 | return apiSidecarManifest{}, apiErrorFromBody(status, body) |
| 4037 | } |
| 4038 | var out apiSidecarManifest |
| 4039 | if err := json.Unmarshal(body, &out); err != nil { |
| 4040 | return apiSidecarManifest{}, fmt.Errorf("sidecars enable: unreadable response: %w", err) |
| 4041 | } |
| 4042 | return out, nil |
| 4043 | } |
| 4044 | |
| 4045 | // apiSidecarRecord mirrors provision.SidecarRecord's wire shape plus the |
| 4046 | // console's running-version join (sidecarListRecord, apisidecars.go) — |
| 4047 | // redeclared here rather than imported because this client renders |
| 4048 | // records, never writes them. RunningVersion is empty for a sidecar that |
| 4049 | // has never called /v1/ready, and MUST be rendered "unknown" rather than |
| 4050 | // the record's own Channel — a stamp that overclaims is worse than an |
| 4051 | // absent one (design §9.2, the honesty rule this branch was built around). |
| 4052 | type apiSidecarRecord struct { |
| 4053 | Name string `json:"name"` |
| 4054 | Host string `json:"host"` |
| 4055 | App string `json:"app"` |
| 4056 | Account string `json:"account"` |
| 4057 | Pool string `json:"pool"` |
| 4058 | Region string `json:"region"` |
| 4059 | Channel string `json:"channel"` |
| 4060 | Placement string `json:"placement"` |
| 4061 | CreatedAt string `json:"created_at"` |
| 4062 | CreatedBy string `json:"created_by"` |
| 4063 | // RunningVersion is the console's best-effort join from the fleet's own |
| 4064 | // box snapshots (sidecarListRecord.RunningVersion) — see the doc above. |
| 4065 | RunningVersion string `json:"running_version"` |
| 4066 | } |
| 4067 | |
| 4068 | // sidecarCreate asks the console to create one sidecar record — the |
| 4069 | // create-only write whose materialization (the sidecar reconcile pass, on |
| 4070 | // the box) is what spawns the process. name is the sidecar's short label; |
| 4071 | // region and channel are the caller's requests, both subject to the |
| 4072 | // manifest's policy and a fleet-placed app's own placement, which |
| 4073 | // overrides either (handleAPISidecarCreate, apisidecars.go). |
| 4074 | func (s *apiSession) sidecarCreate(ctx context.Context, a apiAcct, app, name, region, channel string) (apiSidecarRecord, error) { |
| 4075 | in := struct { |
| 4076 | Name string `json:"name"` |
| 4077 | Region string `json:"region,omitempty"` |
| 4078 | Channel string `json:"channel,omitempty"` |
| 4079 | }{name, region, channel} |
| 4080 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/sidecars"), in) |
| 4081 | if err != nil { |
| 4082 | return apiSidecarRecord{}, err |
| 4083 | } |
| 4084 | if status != http.StatusCreated { |
| 4085 | return apiSidecarRecord{}, apiErrorFromBody(status, body) |
| 4086 | } |
| 4087 | var out apiSidecarRecord |
| 4088 | if err := json.Unmarshal(body, &out); err != nil { |
| 4089 | return apiSidecarRecord{}, fmt.Errorf("sidecars create: unreadable response: %w", err) |
| 4090 | } |
| 4091 | return out, nil |
| 4092 | } |
| 4093 | |
| 4094 | // sidecarList reads the app's sidecar records through the console, honest |
| 4095 | // about the server's 500-record cap (sidecarListMax, apisidecars.go). |
| 4096 | func (s *apiSession) sidecarList(ctx context.Context, a apiAcct, app string) ([]apiSidecarRecord, bool, error) { |
| 4097 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/sidecars")) |
| 4098 | if err != nil { |
| 4099 | return nil, false, err |
| 4100 | } |
| 4101 | if status != http.StatusOK { |
| 4102 | return nil, false, apiErrorFromBody(status, body) |
| 4103 | } |
| 4104 | var res struct { |
| 4105 | Sidecars []apiSidecarRecord `json:"sidecars"` |
| 4106 | Truncated bool `json:"truncated"` |
| 4107 | } |
| 4108 | if err := json.Unmarshal(body, &res); err != nil { |
| 4109 | return nil, false, fmt.Errorf("sidecars list: unreadable response: %w", err) |
| 4110 | } |
| 4111 | return res.Sidecars, res.Truncated, nil |
| 4112 | } |
| 4113 | |
| 4114 | // sidecarGet reads one sidecar record by name — what `carlos sidecars |
| 4115 | // logs` uses to resolve a short name into the synthetic host `carlos logs` |
| 4116 | // itself needs, without this client ever computing that host locally. |
| 4117 | func (s *apiSession) sidecarGet(ctx context.Context, a apiAcct, app, name string) (apiSidecarRecord, error) { |
| 4118 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/sidecars/"+url.PathEscape(name))) |
| 4119 | if err != nil { |
| 4120 | return apiSidecarRecord{}, err |
| 4121 | } |
| 4122 | if status != http.StatusOK { |
| 4123 | return apiSidecarRecord{}, apiErrorFromBody(status, body) |
| 4124 | } |
| 4125 | var out apiSidecarRecord |
| 4126 | if err := json.Unmarshal(body, &out); err != nil { |
| 4127 | return apiSidecarRecord{}, fmt.Errorf("sidecars get: unreadable response: %w", err) |
| 4128 | } |
| 4129 | return out, nil |
| 4130 | } |
| 4131 | |
| 4132 | // sidecarDelete retires one sidecar's provisioning authorization. |
| 4133 | // Idempotent server-side (the caller's goal is "not there"), so a 200 is |
| 4134 | // the only success shape. |
| 4135 | func (s *apiSession) sidecarDelete(ctx context.Context, a apiAcct, app, name string) error { |
| 4136 | status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/sidecars/"+url.PathEscape(name))) |
| 4137 | if err != nil { |
| 4138 | return err |
| 4139 | } |
| 4140 | if status != http.StatusOK { |
| 4141 | return apiErrorFromBody(status, body) |
| 4142 | } |
| 4143 | return nil |
| 4144 | } |
| 4145 | |
| 4146 | // apiSidecarWakeResult mirrors handleAPISidecarWake's response body — only |
| 4147 | // the field this client renders (the sidecar's own host, in the confirmation |
| 4148 | // line) is decoded; "result"/"name" carry nothing this side doesn't already |
| 4149 | // know from the request it just made. |
| 4150 | type apiSidecarWakeResult struct { |
| 4151 | Host string `json:"host"` |
| 4152 | } |
| 4153 | |
| 4154 | // sidecarWake asks every box serving this sidecar to wake it — pull-based, |
| 4155 | // design §7.4: nothing is pushed at a box, which is what lets it live in a |
| 4156 | // private subnet. |
| 4157 | func (s *apiSession) sidecarWake(ctx context.Context, a apiAcct, app, name string) (apiSidecarWakeResult, error) { |
| 4158 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/sidecars/"+url.PathEscape(name)+"/wake"), struct{}{}) |
| 4159 | if err != nil { |
| 4160 | return apiSidecarWakeResult{}, err |
| 4161 | } |
| 4162 | if status != http.StatusOK { |
| 4163 | return apiSidecarWakeResult{}, apiErrorFromBody(status, body) |
| 4164 | } |
| 4165 | var out apiSidecarWakeResult |
| 4166 | if err := json.Unmarshal(body, &out); err != nil { |
| 4167 | return apiSidecarWakeResult{}, fmt.Errorf("sidecars wake: unreadable response: %w", err) |
| 4168 | } |
| 4169 | return out, nil |
| 4170 | } |
| 4171 | |
| 4172 | // apiDomainRecord mirrors the domains door's per-row wire shape |
| 4173 | // (domainListItem, internal/console/apidomains.go) — DNSStatus and |
| 4174 | // CertExpiresAt are the box-stamped reading columns and stay their zero |
| 4175 | // value ("" / 0) for a claim no live box has reported on yet ("pending"), |
| 4176 | // the same absent-stays-absent convention status.AliasReading uses. |
| 4177 | // Wildcard/Catchall are always present (true or false, never omitted — the |
| 4178 | // door knows both for every claim); DelegationStatus is "delegated"/ |
| 4179 | // "missing", empty when the deployment has no delegation zone configured or |
| 4180 | // the claim doesn't have Wildcard. Guidance is the point-your-DNS-here text |
| 4181 | // (the console Settings page's own wording), present only while DNSStatus |
| 4182 | // is not "pointing" — additive, so a console that predates it simply never |
| 4183 | // sends it. |
| 4184 | type apiDomainRecord struct { |
| 4185 | Host string `json:"host"` |
| 4186 | Route string `json:"route"` |
| 4187 | Status string `json:"status"` |
| 4188 | DNSStatus string `json:"dns_status,omitempty"` |
| 4189 | CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| 4190 | Wildcard bool `json:"wildcard"` |
| 4191 | Catchall bool `json:"catchall"` |
| 4192 | DelegationStatus string `json:"delegation_status,omitempty"` |
| 4193 | Guidance string `json:"guidance,omitempty"` |
| 4194 | // State is the door's single derived lifecycle word (#299): checking, |
| 4195 | // pending-dns, misdirected, issuing-cert, failed, live or unknown, with |
| 4196 | // Reason (a stable |
| 4197 | // machine slug) and Detail (a human sentence). Every first-party surface |
| 4198 | // reads THIS, not Status. |
| 4199 | // |
| 4200 | // Status above is legacy and frozen: it only ever meant "some non-stale |
| 4201 | // box reported ANY reading", so a row can carry status "live" while its |
| 4202 | // state is "misdirected". Empty from a console that predates the field — |
| 4203 | // callers must treat "" as "this console does not derive state" and fall |
| 4204 | // back, never as a state in its own right. |
| 4205 | State string `json:"state,omitempty"` |
| 4206 | Reason string `json:"reason,omitempty"` |
| 4207 | Detail string `json:"detail,omitempty"` |
| 4208 | Records []apiDomainDNSRecord `json:"records,omitempty"` |
| 4209 | // Instances marks an instance catch-all ("*.<parent>"); Via names the |
| 4210 | // catch-all a derived row came from. Both absent from an older console. |
| 4211 | Instances bool `json:"instances,omitempty"` |
| 4212 | Via string `json:"via,omitempty"` |
| 4213 | Reserves string `json:"reserves,omitempty"` |
| 4214 | // Verified is false while the claim waits for proof that this account |
| 4215 | // controls the hostname (#215); Records then carries the TXT record that |
| 4216 | // proves it. nil is a console that predates proof, where every claim |
| 4217 | // was live — read it as verified (see domainVerified). |
| 4218 | Verified *bool `json:"verified,omitempty"` |
| 4219 | } |
| 4220 | |
| 4221 | // domainVerified reads a list row's Verified, treating an absent field as |
| 4222 | // verified: a console that predates proof of control has no other kind of |
| 4223 | // claim. |
| 4224 | func domainVerified(d apiDomainRecord) bool { return d.Verified == nil || *d.Verified } |
| 4225 | |
| 4226 | // apiDomainDNSRecord is one record the member must create (#299 §2b) — the |
| 4227 | // structured form of Guidance. Records sharing a Set are ONE alternative |
| 4228 | // (with steering the door offers a CNAME or a set of A records); a client |
| 4229 | // picks one set per name and creates every record in it. Required marks a |
| 4230 | // record needed whichever alternative is chosen. |
| 4231 | type apiDomainDNSRecord struct { |
| 4232 | Type string `json:"type"` |
| 4233 | Name string `json:"name"` |
| 4234 | Value string `json:"value"` |
| 4235 | TTL int `json:"ttl"` |
| 4236 | Set string `json:"set"` |
| 4237 | Required bool `json:"required,omitempty"` |
| 4238 | } |
| 4239 | |
| 4240 | // domainListError wraps a domainList failure with the one extra fact its |
| 4241 | // own signature has no room for: the HTTP status (0 when the request never |
| 4242 | // got an HTTP response at all — a transport failure). deploy.go's callers |
| 4243 | // need to tell apart three meanings a bare error string cannot carry: "this |
| 4244 | // console predates the /domains door" (404), "this credential is not |
| 4245 | // allowed to see it" (401/403 — a real problem, must stay fatal), and "the |
| 4246 | // door exists but is unwell right now" (a transport failure, or any other |
| 4247 | // 5xx — worth a warning, not aborting an otherwise-successful deploy over). |
| 4248 | // Error()/Unwrap() make it a transparent drop-in wherever domainList's |
| 4249 | // error was already being printed or compared. |
| 4250 | type domainListError struct { |
| 4251 | status int |
| 4252 | err error |
| 4253 | } |
| 4254 | |
| 4255 | func (e *domainListError) Error() string { return e.err.Error() } |
| 4256 | func (e *domainListError) Unwrap() error { return e.err } |
| 4257 | |
| 4258 | // domainAttach asks the console to claim host for app, pointed at route (a |
| 4259 | // route hostname, or "" to let the console default it to the app's single |
| 4260 | // instance record). wildcard/catchall are the Task 5 capability flips — nil |
| 4261 | // means "leave unchanged" and is never sent on the wire (omitempty), so a |
| 4262 | // bare attach cannot clear a capability the caller never mentioned; the |
| 4263 | // caller (runDomainsAttach) is responsible for turning a bare, unset CLI |
| 4264 | // flag into nil rather than a pointer to false. created is the caller's own |
| 4265 | // answer — false on a re-attach that changed nothing or only repointed the |
| 4266 | // route — and resolvedRoute is always the route the claim actually carries, |
| 4267 | // whatever this call sent: a caller that omitted --route learns what the |
| 4268 | // server defaulted it to. A 409 (another account or app already holds the |
| 4269 | // hostname) surfaces as "hostname unavailable" verbatim, and a 422 (bad |
| 4270 | // wildcard/catchall request) surfaces its own message verbatim too — |
| 4271 | // apiErrorFromBody's json-first decode doing the work either way. |
| 4272 | // |
| 4273 | // kind and channel are `carlos deploy`'s first-route extras (#82): kind |
| 4274 | // "static" tells a console holding no ready route for this app to mint the |
| 4275 | // host AS the app's first route (a declared static record) instead of |
| 4276 | // refusing to default an alias target, and channel names the channel that |
| 4277 | // record follows. Both empty is the plain alias attach, and empty strings |
| 4278 | // are omitted from the body, so a console that predates the fields never |
| 4279 | // sees them. route and kind together are the door's 400 — an alias target |
| 4280 | // and a first-route mint are two different instructions. |
| 4281 | func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool, messages ...string) (created bool, resolvedRoute string, err error) { |
| 4282 | res, err := s.domainAttachFull(ctx, a, app, host, route, kind, channel, wildcard, catchall, messages...) |
| 4283 | return res.Created, res.Route, err |
| 4284 | } |
| 4285 | |
| 4286 | // domainAttachFull is domainAttach returning the whole answer, for the |
| 4287 | // callers that must tell a member their hostname still needs proving. |
| 4288 | func (s *apiSession) domainAttachFull(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool, messages ...string) (apiDomainAttachResult, error) { |
| 4289 | return s.postDomainAttachFull(ctx, a, app, domainAttachIn{ |
| 4290 | Host: host, Route: route, Kind: kind, Channel: channel, |
| 4291 | Wildcard: wildcard, Catchall: catchall, Message: firstMutationMessage(messages), |
| 4292 | }) |
| 4293 | } |
| 4294 | |
| 4295 | // domainAttachIn is the attach door's request body. Platform asks for the |
| 4296 | // operator's door (#209): a hostname in the deployment's own zones, which |
| 4297 | // the console grants only to an ops account owner or a system operator. |
| 4298 | type domainAttachIn struct { |
| 4299 | Host string `json:"host"` |
| 4300 | Route string `json:"route,omitempty"` |
| 4301 | Kind string `json:"kind,omitempty"` |
| 4302 | Channel string `json:"channel,omitempty"` |
| 4303 | Wildcard *bool `json:"wildcard,omitempty"` |
| 4304 | Catchall *bool `json:"catchall,omitempty"` |
| 4305 | Message string `json:"message,omitempty"` |
| 4306 | Platform bool `json:"platform,omitempty"` |
| 4307 | } |
| 4308 | |
| 4309 | // apiDomainAttachResult is the attach door's whole answer. Verified false |
| 4310 | // means the claim is stored but not served until the TXT record in Verify |
| 4311 | // is published and the attach repeated (#215). A console that predates |
| 4312 | // proof sends neither field and every attach it makes is live, so an absent |
| 4313 | // verified decodes as true. |
| 4314 | type apiDomainAttachResult struct { |
| 4315 | Created bool |
| 4316 | Route string |
| 4317 | Verified bool |
| 4318 | Verify *apiDomainDNSRecord |
| 4319 | } |
| 4320 | |
| 4321 | func (s *apiSession) postDomainAttach(ctx context.Context, a apiAcct, app string, in domainAttachIn) (created bool, resolvedRoute string, err error) { |
| 4322 | res, err := s.postDomainAttachFull(ctx, a, app, in) |
| 4323 | return res.Created, res.Route, err |
| 4324 | } |
| 4325 | |
| 4326 | func (s *apiSession) postDomainAttachFull(ctx context.Context, a apiAcct, app string, in domainAttachIn) (apiDomainAttachResult, error) { |
| 4327 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in) |
| 4328 | if err != nil { |
| 4329 | return apiDomainAttachResult{}, err |
| 4330 | } |
| 4331 | if status != http.StatusOK { |
| 4332 | return apiDomainAttachResult{}, apiErrorFromBody(status, body) |
| 4333 | } |
| 4334 | var out struct { |
| 4335 | Host string `json:"host"` |
| 4336 | Route string `json:"route"` |
| 4337 | Created bool `json:"created"` |
| 4338 | Verified *bool `json:"verified"` |
| 4339 | Verify *apiDomainDNSRecord `json:"verify"` |
| 4340 | } |
| 4341 | if err := json.Unmarshal(body, &out); err != nil { |
| 4342 | return apiDomainAttachResult{}, fmt.Errorf("domains attach: unreadable response: %w", err) |
| 4343 | } |
| 4344 | return apiDomainAttachResult{Created: out.Created, Route: out.Route, |
| 4345 | Verified: out.Verified == nil || *out.Verified, Verify: out.Verify}, nil |
| 4346 | } |
| 4347 | |
| 4348 | // domainDetach releases a hostname claim. Always 200 on a live console |
| 4349 | // (handleAPIDomainDetach's own contract): removed is false for a hostname |
| 4350 | // this app never held, one it no longer holds, or one another account |
| 4351 | // holds — the door's own quiet-in-both-directions neutrality (spec §5), not |
| 4352 | // distinguished here either. |
| 4353 | func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error) { |
| 4354 | status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host)), "") |
| 4355 | if err != nil { |
| 4356 | return false, err |
| 4357 | } |
| 4358 | if status != http.StatusOK { |
| 4359 | return false, apiErrorFromBody(status, body) |
| 4360 | } |
| 4361 | var out struct { |
| 4362 | Removed bool `json:"removed"` |
| 4363 | } |
| 4364 | if err := json.Unmarshal(body, &out); err != nil { |
| 4365 | return false, fmt.Errorf("domains detach: unreadable response: %w", err) |
| 4366 | } |
| 4367 | return out.Removed, nil |
| 4368 | } |
| 4369 | |
| 4370 | // domainList reads the app's hostname claims, each joined to the fleet's |
| 4371 | // readings sweep for status/dns_status/cert_expires_at. Failures come back |
| 4372 | // wrapped in *domainListError so deploy's degrade paths can read the HTTP |
| 4373 | // status; the wrapper is transparent (Error/Unwrap) everywhere else. |
| 4374 | func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error) { |
| 4375 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains")) |
| 4376 | if err != nil { |
| 4377 | return nil, &domainListError{err: err} |
| 4378 | } |
| 4379 | if status != http.StatusOK { |
| 4380 | return nil, &domainListError{status: status, err: apiErrorFromBody(status, body)} |
| 4381 | } |
| 4382 | var res struct { |
| 4383 | Domains []apiDomainRecord `json:"domains"` |
| 4384 | } |
| 4385 | if err := json.Unmarshal(body, &res); err != nil { |
| 4386 | return nil, fmt.Errorf("domains list: unreadable response: %w", err) |
| 4387 | } |
| 4388 | return res.Domains, nil |
| 4389 | } |
| 4390 | |
| 4391 | // domainPaths reads ONE hostname's published prefix map off the per-host |
| 4392 | // claim door. Published, not derived: this is the map the owning platform |
| 4393 | // PUT, which is a different fact from the route_paths rows `carlos routes` |
| 4394 | // prints. The reconcile pass drops an entry whose target is not a |
| 4395 | // same-account route on the box, so a prefix present here and absent there is |
| 4396 | // a refused flip — the one thing an operator debugging a migration needs to |
| 4397 | // see, and invisible everywhere else. |
| 4398 | // |
| 4399 | // A console that predates path routing sends no `paths` key, which decodes as |
| 4400 | // an empty map: "this hostname splits no traffic" is the same answer such a |
| 4401 | // deployment would give if asked, so there is nothing to distinguish. |
| 4402 | func (s *apiSession) domainPaths(ctx context.Context, a apiAcct, app, host string) (map[string]string, error) { |
| 4403 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host))) |
| 4404 | if err != nil { |
| 4405 | return nil, err |
| 4406 | } |
| 4407 | if status != http.StatusOK { |
| 4408 | return nil, apiErrorFromBody(status, body) |
| 4409 | } |
| 4410 | var res struct { |
| 4411 | Paths map[string]string `json:"paths"` |
| 4412 | } |
| 4413 | if err := json.Unmarshal(body, &res); err != nil { |
| 4414 | return nil, fmt.Errorf("domains paths: unreadable response: %w", err) |
| 4415 | } |
| 4416 | return res.Paths, nil |
| 4417 | } |
| 4418 | |
| 4419 | // shipTimeout bounds ship's own context — generous on purpose. The console |
| 4420 | // gives itself up to 10 minutes to read the request body and 2 minutes to |
| 4421 | // store it (internal/console/apicli.go), well past the 30s absolute Timeout |
| 4422 | // the JSON verbs' shared client carries; ship must never inherit that 30s |
| 4423 | // cap (doMultipart, auth.go, uses its own client for exactly this reason), |
| 4424 | // but it still needs SOME bound of its own rather than running forever. |
| 4425 | const shipTimeout = 15 * time.Minute |
| 4426 | |
| 4427 | // ship uploads one release's artifacts as a multipart POST: the meta part |
| 4428 | // ({"version","kind"[,"capabilities"]}) first, then one file part per entry |
| 4429 | // in files (partName -> local path), in map-key-sorted order — sorted so the |
| 4430 | // wire bytes are deterministic, since Go's map iteration order is not. |
| 4431 | // |
| 4432 | // capsSet rides in the meta part rather than a part of its own, so the |
| 4433 | // server reads the whole declaration before it reads a single artifact byte |
| 4434 | // and can refuse the upload at its first part. It is the CANONICAL |
| 4435 | // re-marshal of the decoded Set, not the operator's file verbatim: the same |
| 4436 | // bytes that will land in the manifest, bounded (a few hundred) and |
| 4437 | // deterministic, which matters because the server reads meta under an |
| 4438 | // io.LimitReader(first, 8<<10) that a whitespace-heavy file could otherwise |
| 4439 | // blow. Nothing is lost — the fields a re-marshal drops are exactly the |
| 4440 | // unknown-inside-a-known ones the strict decoder already reported and |
| 4441 | // ignored. nil (no --capabilities) omits the key entirely, so the meta part's |
| 4442 | // bytes stay byte-identical to what this verb sent yesterday. |
| 4443 | // |
| 4444 | // programChange rides in the meta part too (`program_change`, omitted when |
| 4445 | // false so the bytes match what this verb sent before the flag): it is the |
| 4446 | // operator's --program-change, and only `carlos ship` ever passes true. |
| 4447 | func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, label, notes string, files map[string]string, capsSet *caps.Set, programChange bool, messages ...string) (apiShipResult, error) { |
| 4448 | message := firstMutationMessage(messages) |
| 4449 | ctx, cancel := context.WithTimeout(ctx, shipTimeout) |
| 4450 | defer cancel() |
| 4451 | |
| 4452 | names := make([]string, 0, len(files)) |
| 4453 | for name := range files { |
| 4454 | names = append(names, name) |
| 4455 | } |
| 4456 | sort.Strings(names) |
| 4457 | |
| 4458 | status, body, err := s.c.doMultipart(ctx, apiPath(a, app, "/releases"), func(mw *multipart.Writer) error { |
| 4459 | var capsRaw json.RawMessage |
| 4460 | if capsSet != nil { |
| 4461 | b, err := json.Marshal(capsSet) |
| 4462 | if err != nil { |
| 4463 | return err |
| 4464 | } |
| 4465 | capsRaw = b |
| 4466 | } |
| 4467 | metaJSON, err := json.Marshal(struct { |
| 4468 | Version string `json:"version"` |
| 4469 | Kind string `json:"kind"` |
| 4470 | Label string `json:"label,omitempty"` |
| 4471 | Notes string `json:"notes,omitempty"` |
| 4472 | Message string `json:"message,omitempty"` |
| 4473 | Capabilities json.RawMessage `json:"capabilities,omitempty"` |
| 4474 | ProgramChange bool `json:"program_change,omitempty"` |
| 4475 | Swap string `json:"swap,omitempty"` |
| 4476 | }{version, kind, label, notes, message, capsRaw, programChange, s.shipSwap}) |
| 4477 | if err != nil { |
| 4478 | return err |
| 4479 | } |
| 4480 | metaPart, err := mw.CreateFormField("meta") |
| 4481 | if err != nil { |
| 4482 | return err |
| 4483 | } |
| 4484 | if _, err := metaPart.Write(metaJSON); err != nil { |
| 4485 | return err |
| 4486 | } |
| 4487 | for _, name := range names { |
| 4488 | if err := writeFilePart(mw, name, files[name]); err != nil { |
| 4489 | return err |
| 4490 | } |
| 4491 | } |
| 4492 | return nil |
| 4493 | }) |
| 4494 | if err != nil { |
| 4495 | return apiShipResult{}, err |
| 4496 | } |
| 4497 | // handleAPIShip (internal/console/apicli.go) sends "result": "shipped" |
| 4498 | // on BOTH paths — status is the only signal that distinguishes them: 201 |
| 4499 | // for bytes actually written, 200 when the request matched an identical |
| 4500 | // version already on record and nothing new was stored. |
| 4501 | switch status { |
| 4502 | case http.StatusCreated, http.StatusOK: |
| 4503 | if err := checkCapabilitiesEcho(capsSet, body); err != nil { |
| 4504 | return apiShipResult{}, err |
| 4505 | } |
| 4506 | if err := checkSwapEcho(s.shipSwap, body); err != nil { |
| 4507 | return apiShipResult{}, err |
| 4508 | } |
| 4509 | // Decoded from the response, not echoed back from the version this |
| 4510 | // call sent: a minted ship (empty version in) has no other way to |
| 4511 | // learn what the console actually shipped it as. |
| 4512 | var out struct { |
| 4513 | Version string `json:"version"` |
| 4514 | BuildProbe string `json:"build_probe"` |
| 4515 | } |
| 4516 | if err := json.Unmarshal(body, &out); err != nil { |
| 4517 | return apiShipResult{}, fmt.Errorf("ship: malformed response: %w", err) |
| 4518 | } |
| 4519 | return apiShipResult{NoOp: status == http.StatusOK, Version: out.Version, BuildProbe: out.BuildProbe}, nil |
| 4520 | default: |
| 4521 | return apiShipResult{}, apiErrorFromBody(status, body) |
| 4522 | } |
| 4523 | } |
| 4524 | |
| 4525 | // checkCapabilitiesEcho is the client half of the removal signal (2026-08-03 |
| 4526 | // review, I3), and it is checked on a SUCCESSFUL response because that is the |
| 4527 | // dangerous case. |
| 4528 | // |
| 4529 | // `capabilities` is an unknown JSON key to any console built before the field |
| 4530 | // existed, and Go's decoder ignores unknown keys — so shipping a |
| 4531 | // capability-bearing release through an un-upgraded console succeeds, stores a |
| 4532 | // manifest that declares NOTHING, and says 201. That manifest is not inert on |
| 4533 | // a box: a manifest that resolves and declares nothing is the REMOVAL |
| 4534 | // instruction, so the next converge pass deletes the drop-ins and the app |
| 4535 | // loses its port or its host role. Deploy order (spec §3b: platform binaries — |
| 4536 | // the CONSOLE binary among them — before capability-bearing releases) is what |
| 4537 | // prevents it; this is what makes breaking that order fail loudly at the |
| 4538 | // terminal, before a promote, instead of quietly on a box an hour later. |
| 4539 | // |
| 4540 | // Only when this client SENT a block. A ship with no --capabilities expects no |
| 4541 | // echo, and demanding one would refuse every ordinary ship against a console |
| 4542 | // that has nothing to echo — the exact opposite of failing in the direction |
| 4543 | // that changes nothing. |
| 4544 | // |
| 4545 | // The comparison is between CANONICAL re-marshals, not raw bytes: what matters |
| 4546 | // is whether the console understood the declaration and stored the same one, |
| 4547 | // not how it spaced its JSON. |
| 4548 | func checkCapabilitiesEcho(sent *caps.Set, body []byte) error { |
| 4549 | if sent == nil { |
| 4550 | return nil |
| 4551 | } |
| 4552 | const updateFirst = "this console does not understand capability declarations — update the console binary first, or ship without --capabilities" |
| 4553 | var echo struct { |
| 4554 | Capabilities json.RawMessage `json:"capabilities"` |
| 4555 | } |
| 4556 | if err := json.Unmarshal(body, &echo); err != nil || len(echo.Capabilities) == 0 { |
| 4557 | // The version is already in the bucket WITHOUT its declaration |
| 4558 | // (immutable, so a same-version re-ship after the update meets the |
| 4559 | // 409) — name the whole fix here, not just the console half. |
| 4560 | return fmt.Errorf("ship: the console accepted this release but its response did not echo the capability block back — "+ |
| 4561 | "the stored manifest carries no capabilities and must not be promoted: %s, then ship again under a new version", updateFirst) |
| 4562 | } |
| 4563 | var got *caps.Set |
| 4564 | if err := json.Unmarshal(echo.Capabilities, &got); err != nil || got == nil { |
| 4565 | return fmt.Errorf("ship: the console echoed a capability block this binary cannot read (%s): %s", strings.TrimSpace(string(echo.Capabilities)), updateFirst) |
| 4566 | } |
| 4567 | want, werr := json.Marshal(sent) |
| 4568 | have, herr := json.Marshal(got) |
| 4569 | if werr != nil || herr != nil || !bytes.Equal(want, have) { |
| 4570 | return fmt.Errorf("ship: the console stored a capability block that differs from the one this command sent — sent %s, stored %s. "+ |
| 4571 | "The stored block is what boxes converge onto, so the release must not be promoted; update the console binary and ship again under a new version", |
| 4572 | want, have) |
| 4573 | } |
| 4574 | return nil |
| 4575 | } |
| 4576 | |
| 4577 | const shipSwapNoEcho = "ship: this console predates --swap and stored the release without it, so it would swap in overlap mode. Nothing was promoted. Upgrade the console, then ship under a new version" |
| 4578 | |
| 4579 | // checkSwapEcho is checkCapabilitiesEcho's twin for the swap demand: a |
| 4580 | // console from before the field accepts the ship and drops the demand, and a |
| 4581 | // release stored without it swaps in overlap. Checked only when one was sent. |
| 4582 | func checkSwapEcho(sent string, body []byte) error { |
| 4583 | if sent == "" { |
| 4584 | return nil |
| 4585 | } |
| 4586 | var echo struct { |
| 4587 | Swap string `json:"swap"` |
| 4588 | } |
| 4589 | if err := json.Unmarshal(body, &echo); err != nil || echo.Swap != sent { |
| 4590 | return errors.New(shipSwapNoEcho) |
| 4591 | } |
| 4592 | return nil |
| 4593 | } |
| 4594 | |
| 4595 | // writeFilePart streams one local file into a multipart part named name, so |
| 4596 | // a 30MiB artifact is read a chunk at a time rather than buffered whole |
| 4597 | // before it starts crossing the wire. |
| 4598 | func writeFilePart(mw *multipart.Writer, name, path string) error { |
| 4599 | f, err := os.Open(path) |
| 4600 | if err != nil { |
| 4601 | return err |
| 4602 | } |
| 4603 | defer f.Close() |
| 4604 | part, err := mw.CreateFormFile(name, filepath.Base(path)) |
| 4605 | if err != nil { |
| 4606 | return err |
| 4607 | } |
| 4608 | _, err = io.Copy(part, f) |
| 4609 | return err |
| 4610 | } |
| 4611 | |
| 4612 | // ---- customer fleets (fleets.go's `carlos fleets` verbs + apps place) ----- |
| 4613 | |
| 4614 | // fleetPath builds one /api/cli/fleets/{account}... path — the fleets |
| 4615 | // family's account-scoped door (internal/console/fleets.go), sibling to |
| 4616 | // apiPath's /api/cli/apps/{account}/{app} shape but with no app segment: a |
| 4617 | // fleet's identity is account + name, never account + app + anything. tail |
| 4618 | // is a bare path (any fleet/label segments the caller needs already |
| 4619 | // url.PathEscape'd into it) with no query string of its own — exactly |
| 4620 | // apiPath's own contract, for the same reason: --account-id's exact=sqid |
| 4621 | // switch is appended by plain concatenation below. |
| 4622 | func fleetPath(a apiAcct, tail string) string { |
| 4623 | p := "/api/cli/fleets/" + url.PathEscape(a.Seg) + tail |
| 4624 | if a.Exact { |
| 4625 | p += "?exact=sqid" |
| 4626 | } |
| 4627 | return p |
| 4628 | } |
| 4629 | |
| 4630 | // apiFleetBox and apiFleetView are the wire shapes handleAPIFleetList and |
| 4631 | // handleAPIFleetNew send (internal/console/fleets.go's fleetBoxView / |
| 4632 | // fleetView) — deliberately distinct types from anything in internal/registry: |
| 4633 | // this client never sees a token hash, only ever a label and a status. |
| 4634 | type apiFleetBox struct { |
| 4635 | Label string `json:"label"` |
| 4636 | Status string `json:"status"` |
| 4637 | Tags string `json:"tags,omitempty"` |
| 4638 | } |
| 4639 | |
| 4640 | type apiFleetView struct { |
| 4641 | Name string `json:"name"` |
| 4642 | Bucket string `json:"bucket"` |
| 4643 | Boxes []apiFleetBox `json:"boxes"` |
| 4644 | } |
| 4645 | |
| 4646 | // createFleet mints one account-owned fleet record — `carlos fleets create`. |
| 4647 | func (s *apiSession) createFleet(ctx context.Context, a apiAcct, name, bucket string) (apiFleetView, error) { |
| 4648 | in := struct { |
| 4649 | Name string `json:"name"` |
| 4650 | Bucket string `json:"bucket"` |
| 4651 | }{name, bucket} |
| 4652 | status, body, err := s.postJSON(ctx, fleetPath(a, "/new"), in) |
| 4653 | if err != nil { |
| 4654 | return apiFleetView{}, err |
| 4655 | } |
| 4656 | if status != http.StatusOK { |
| 4657 | return apiFleetView{}, apiErrorFromBody(status, body) |
| 4658 | } |
| 4659 | var res struct { |
| 4660 | Fleet apiFleetView `json:"fleet"` |
| 4661 | } |
| 4662 | if err := json.Unmarshal(body, &res); err != nil { |
| 4663 | return apiFleetView{}, fmt.Errorf("fleets create: unreadable response: %w", err) |
| 4664 | } |
| 4665 | return res.Fleet, nil |
| 4666 | } |
| 4667 | |
| 4668 | // listFleets asks the console for every fleet the caller's account owns — |
| 4669 | // `carlos fleets ls`. |
| 4670 | func (s *apiSession) listFleets(ctx context.Context, a apiAcct) ([]apiFleetView, error) { |
| 4671 | status, body, err := s.getJSON(ctx, fleetPath(a, "")) |
| 4672 | if err != nil { |
| 4673 | return nil, err |
| 4674 | } |
| 4675 | if status != http.StatusOK { |
| 4676 | return nil, apiErrorFromBody(status, body) |
| 4677 | } |
| 4678 | var res struct { |
| 4679 | Fleets []apiFleetView `json:"fleets"` |
| 4680 | } |
| 4681 | if err := json.Unmarshal(body, &res); err != nil { |
| 4682 | return nil, fmt.Errorf("fleets ls: unreadable response: %w", err) |
| 4683 | } |
| 4684 | return res.Fleets, nil |
| 4685 | } |
| 4686 | |
| 4687 | // addFleetBox mints a box and its bearer token — `carlos fleets add-box`. |
| 4688 | // The plaintext is in the return value exactly once; RunFleets prints it |
| 4689 | // exactly once and it must never be logged or retried into a second print. |
| 4690 | func (s *apiSession) addFleetBox(ctx context.Context, a apiAcct, fleet, label string) (token string, err error) { |
| 4691 | in := struct { |
| 4692 | Label string `json:"label"` |
| 4693 | }{label} |
| 4694 | status, body, err := s.postJSON(ctx, fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes"), in) |
| 4695 | if err != nil { |
| 4696 | return "", err |
| 4697 | } |
| 4698 | if status != http.StatusOK { |
| 4699 | return "", apiErrorFromBody(status, body) |
| 4700 | } |
| 4701 | var res struct { |
| 4702 | Token string `json:"token"` |
| 4703 | } |
| 4704 | if err := json.Unmarshal(body, &res); err != nil { |
| 4705 | return "", fmt.Errorf("fleets add-box: unreadable response: %w", err) |
| 4706 | } |
| 4707 | return res.Token, nil |
| 4708 | } |
| 4709 | |
| 4710 | // rotateFleetBoxToken mints a fresh token for a box that already exists — |
| 4711 | // `carlos fleets rotate-token`. Per handleAPIFleetRotateBox's doc comment |
| 4712 | // (internal/console/fleets.go): this refuses the box's NEXT dial-in attempt |
| 4713 | // with the OLD token, but does NOT evict a channel that is already |
| 4714 | // connected — a box whose credential may be compromised needs detach-box, |
| 4715 | // not this. RunFleets repeats that warning in this verb's own output. |
| 4716 | func (s *apiSession) rotateFleetBoxToken(ctx context.Context, a apiAcct, fleet, label string) (token string, err error) { |
| 4717 | status, body, err := s.postJSON(ctx, |
| 4718 | fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/rotate"), struct{}{}) |
| 4719 | if err != nil { |
| 4720 | return "", err |
| 4721 | } |
| 4722 | if status != http.StatusOK { |
| 4723 | return "", apiErrorFromBody(status, body) |
| 4724 | } |
| 4725 | var res struct { |
| 4726 | Token string `json:"token"` |
| 4727 | } |
| 4728 | if err := json.Unmarshal(body, &res); err != nil { |
| 4729 | return "", fmt.Errorf("fleets rotate-token: unreadable response: %w", err) |
| 4730 | } |
| 4731 | return res.Token, nil |
| 4732 | } |
| 4733 | |
| 4734 | // tagFleetBox replaces a box's tag list IN FULL — `carlos fleets tag-box`. |
| 4735 | // tags "" clears every tag; there is no merge form. |
| 4736 | func (s *apiSession) tagFleetBox(ctx context.Context, a apiAcct, fleet, label, tags string) error { |
| 4737 | in := struct { |
| 4738 | Tags string `json:"tags"` |
| 4739 | }{tags} |
| 4740 | status, body, err := s.postJSON(ctx, |
| 4741 | fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/tags"), in) |
| 4742 | if err != nil { |
| 4743 | return err |
| 4744 | } |
| 4745 | if status != http.StatusOK { |
| 4746 | return apiErrorFromBody(status, body) |
| 4747 | } |
| 4748 | return nil |
| 4749 | } |
| 4750 | |
| 4751 | // detachFleetBox flips a box to "detached" — the hard revocation rotation is |
| 4752 | // not: the edge closes an already-connected channel too |
| 4753 | // (handleAPIFleetDetachBox). `carlos fleets detach-box`. |
| 4754 | func (s *apiSession) detachFleetBox(ctx context.Context, a apiAcct, fleet, label string) error { |
| 4755 | status, body, err := s.postJSON(ctx, |
| 4756 | fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/detach"), struct{}{}) |
| 4757 | if err != nil { |
| 4758 | return err |
| 4759 | } |
| 4760 | if status != http.StatusOK { |
| 4761 | return apiErrorFromBody(status, body) |
| 4762 | } |
| 4763 | return nil |
| 4764 | } |
| 4765 | |
| 4766 | // place asks the console to repoint where app's routes — and its |
| 4767 | // already-provisioned instances — should run: `carlos apps place`. target "" |
| 4768 | // clears placement back to platform-served/unconstrained; any other value |
| 4769 | // must be "fleet/<name>" naming a fleet the caller's own account owns |
| 4770 | // (handleAPIPlace, internal/console/fleets.go). |
| 4771 | func (s *apiSession) place(ctx context.Context, a apiAcct, app, target string) error { |
| 4772 | in := struct { |
| 4773 | Target string `json:"target"` |
| 4774 | }{target} |
| 4775 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/place"), in) |
| 4776 | if err != nil { |
| 4777 | return err |
| 4778 | } |
| 4779 | if status != http.StatusOK { |
| 4780 | return apiErrorFromBody(status, body) |
| 4781 | } |
| 4782 | return nil |
| 4783 | } |
| 4784 | |
| 4785 | // setBuildProbe stamps (or, with "", clears) an existing app's build-probe |
| 4786 | // path — the path `carlos deploy` polls INSTEAD of the X-Carlos-Version |
| 4787 | // header, which reports the adopted release rather than the running process. |
| 4788 | // |
| 4789 | // It returns what the console echoes back, which is what the store actually |
| 4790 | // holds: this is the field the deploy watch trusts, so a write that silently |
| 4791 | // failed has to be a loud error rather than a quiet lie. Same discipline the |
| 4792 | // claim door's own echo has. |
| 4793 | func (s *apiSession) setBuildProbe(ctx context.Context, a apiAcct, app, probe string) (string, error) { |
| 4794 | in := struct { |
| 4795 | BuildProbe string `json:"build_probe"` |
| 4796 | }{probe} |
| 4797 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/build-probe"), in) |
| 4798 | if err != nil { |
| 4799 | return "", err |
| 4800 | } |
| 4801 | if status != http.StatusOK { |
| 4802 | if status == http.StatusNotFound { |
| 4803 | return "", fmt.Errorf("not found — either %s is not an app in this account, "+ |
| 4804 | "or this console predates `carlos apps build-probe` (deploy a console built from current main)", app) |
| 4805 | } |
| 4806 | return "", apiErrorFromBody(status, body) |
| 4807 | } |
| 4808 | var out struct { |
| 4809 | BuildProbe string `json:"build_probe"` |
| 4810 | } |
| 4811 | if err := json.Unmarshal(body, &out); err != nil { |
| 4812 | return "", fmt.Errorf("apps build-probe: unreadable response: %w", err) |
| 4813 | } |
| 4814 | return out.BuildProbe, nil |
| 4815 | } |
| 4816 | |
| 4817 | // ---- service credentials (services.go's `carlos services` verbs) --------- |
| 4818 | |
| 4819 | // servicePath builds one /api/cli/services/{account}... path — the service |
| 4820 | // credential family's account-scoped door (internal/console/apiservices.go), |
| 4821 | // the same sibling-of-apiPath shape fleetPath already established: identity |
| 4822 | // here is account + (name or listing id), never account + app. tail is a |
| 4823 | // bare path (any name/id segment the caller needs already |
| 4824 | // url.PathEscape'd into it) with no query string of its own — exactly |
| 4825 | // fleetPath's own contract, for the same reason: --account-id's exact=sqid |
| 4826 | // switch is appended by plain concatenation below. |
| 4827 | func servicePath(a apiAcct, tail string) string { |
| 4828 | p := "/api/cli/services/" + url.PathEscape(a.Seg) + tail |
| 4829 | if a.Exact { |
| 4830 | p += "?exact=sqid" |
| 4831 | } |
| 4832 | return p |
| 4833 | } |
| 4834 | |
| 4835 | // apiServiceView is one listed credential's wire shape — deliberately the |
| 4836 | // same field set as serviceView (internal/console/apiservices.go): the |
| 4837 | // listing id (a sha256 hex, never a secret), name, role, app/label scope, |
| 4838 | // the created/rotated/last-used timestamps, and who minted it. There is |
| 4839 | // deliberately no "revoked" field — the door only ever lists live rows. |
| 4840 | type apiServiceView struct { |
| 4841 | ID string `json:"id"` |
| 4842 | Name string `json:"name"` |
| 4843 | Role string `json:"role"` |
| 4844 | Label string `json:"label"` |
| 4845 | App string `json:"app"` |
| 4846 | Created string `json:"created"` |
| 4847 | Rotated string `json:"rotated"` |
| 4848 | LastUsed string `json:"last_used"` |
| 4849 | MintedBy string `json:"minted_by"` |
| 4850 | // Rungs is a deploy credential's rungs, canonical order; empty for every |
| 4851 | // other role (the console never sends null). |
| 4852 | Rungs []string `json:"rungs"` |
| 4853 | // Commands is a run credential's granted command prefixes, each a list |
| 4854 | // of words; empty for every other role. |
| 4855 | Commands [][]string `json:"commands"` |
| 4856 | // CommandsUnreadable is a run row whose stored list the console cannot |
| 4857 | // read; it is refused at sign-in. |
| 4858 | CommandsUnreadable bool `json:"commands_unreadable"` |
| 4859 | } |
| 4860 | |
| 4861 | // createService mints one account-owned service credential, optionally |
| 4862 | // scoped to a single app — `carlos services create`. The plaintext secret is |
| 4863 | // in the return value exactly once; RunServices prints it exactly once and |
| 4864 | // it must never be logged or retried into a second print (only its hash is |
| 4865 | // stored server-side, so there is no second copy to hand out later). doors |
| 4866 | // is the sorted list of /api/cli/ patterns the granted role reaches |
| 4867 | // (handleAPIServiceCreate's doorsFor) — read at print time, not |
| 4868 | // hand-maintained here, so this client cannot drift from what the |
| 4869 | // credential will actually be admitted to. |
| 4870 | func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string, rungs []string, commands [][]string) (token string, doors []string, gotRungs []string, gotCommands [][]string, err error) { |
| 4871 | in := struct { |
| 4872 | Name string `json:"name"` |
| 4873 | Role string `json:"role"` |
| 4874 | App string `json:"app"` |
| 4875 | Rungs []string `json:"rungs,omitempty"` |
| 4876 | Commands [][]string `json:"commands,omitempty"` |
| 4877 | }{name, role, app, rungs, commands} |
| 4878 | status, body, err := s.postJSON(ctx, servicePath(a, ""), in) |
| 4879 | if err != nil { |
| 4880 | return "", nil, nil, nil, err |
| 4881 | } |
| 4882 | if status != http.StatusCreated { |
| 4883 | return "", nil, nil, nil, apiErrorFromBody(status, body) |
| 4884 | } |
| 4885 | var res struct { |
| 4886 | Token string `json:"token"` |
| 4887 | Doors []string `json:"doors"` |
| 4888 | Rungs []string `json:"rungs"` |
| 4889 | Commands [][]string `json:"commands"` |
| 4890 | } |
| 4891 | if err := json.Unmarshal(body, &res); err != nil { |
| 4892 | // The 201 already happened: the credential exists server-side and |
| 4893 | // its ONLY plaintext copy — never stored anywhere, only its hash is |
| 4894 | // — was in this unreadable body. Silence here would leave the |
| 4895 | // operator not knowing the mint even landed. Point at the one way |
| 4896 | // to recover a working secret for it. |
| 4897 | return "", nil, nil, nil, fmt.Errorf("services create %s: minted, but the response was unreadable (%v) — "+ |
| 4898 | "the credential exists and its only plaintext copy is lost; check `carlos services ls` "+ |
| 4899 | "and rotate it", name, err) |
| 4900 | } |
| 4901 | return res.Token, res.Doors, res.Rungs, res.Commands, nil |
| 4902 | } |
| 4903 | |
| 4904 | // listServices asks the console for every LIVE credential the caller's |
| 4905 | // account owns — `carlos services ls`. Never a secret and never a hash; |
| 4906 | // apiServiceView carries only what serviceView puts on the wire. |
| 4907 | func (s *apiSession) listServices(ctx context.Context, a apiAcct) ([]apiServiceView, error) { |
| 4908 | status, body, err := s.getJSON(ctx, servicePath(a, "")) |
| 4909 | if err != nil { |
| 4910 | return nil, err |
| 4911 | } |
| 4912 | if status != http.StatusOK { |
| 4913 | return nil, apiErrorFromBody(status, body) |
| 4914 | } |
| 4915 | var res struct { |
| 4916 | Services []apiServiceView `json:"services"` |
| 4917 | } |
| 4918 | if err := json.Unmarshal(body, &res); err != nil { |
| 4919 | return nil, fmt.Errorf("services ls: unreadable response: %w", err) |
| 4920 | } |
| 4921 | return res.Services, nil |
| 4922 | } |
| 4923 | |
| 4924 | // rotateService mints a fresh secret for a credential that already exists, |
| 4925 | // addressed by NAME only (handleAPIServiceRotate takes no listing-id form) — |
| 4926 | // `carlos services rotate`. This refuses the credential's NEXT call with the |
| 4927 | // OLD secret but does not stop anything already in flight; a credential that |
| 4928 | // may be compromised needs revokeService, not this. RunServices repeats that |
| 4929 | // distinction in its own output, the way printFleetToken does for fleets. |
| 4930 | func (s *apiSession) rotateService(ctx context.Context, a apiAcct, name string) (token string, err error) { |
| 4931 | status, body, err := s.postJSON(ctx, servicePath(a, "/"+url.PathEscape(name)+"/rotate"), struct{}{}) |
| 4932 | if err != nil { |
| 4933 | return "", err |
| 4934 | } |
| 4935 | if status != http.StatusOK { |
| 4936 | return "", apiErrorFromBody(status, body) |
| 4937 | } |
| 4938 | var res struct { |
| 4939 | Token string `json:"token"` |
| 4940 | } |
| 4941 | if err := json.Unmarshal(body, &res); err != nil { |
| 4942 | return "", fmt.Errorf("services rotate: unreadable response: %w", err) |
| 4943 | } |
| 4944 | return res.Token, nil |
| 4945 | } |
| 4946 | |
| 4947 | // revokeService is the hard stop rotation is not: the credential's next call |
| 4948 | // fails, whatever secret it holds — `carlos services revoke`, addressed by |
| 4949 | // name or by listing id (serviceHandle, internal/console/apiservices.go). |
| 4950 | // |
| 4951 | // Idempotent server-side: an unknown name, an unknown id, an |
| 4952 | // already-revoked credential and a live one all answer 200. matched is the |
| 4953 | // field that says which it was (C41) — false means nothing was actually |
| 4954 | // revoked, most often a typo'd name, and RunServices must never render that |
| 4955 | // as plain success. This method's own signature deliberately deviates from |
| 4956 | // the task brief's guessed `error`-only shape: the brief predates knowing |
| 4957 | // the wire carries "matched" at all, and dropping that bit here would leave |
| 4958 | // no way for the verb above to tell a real revoke from a no-op one. |
| 4959 | func (s *apiSession) revokeService(ctx context.Context, a apiAcct, name string) (matched bool, err error) { |
| 4960 | status, body, err := s.postJSON(ctx, servicePath(a, "/"+url.PathEscape(name)+"/revoke"), struct{}{}) |
| 4961 | if err != nil { |
| 4962 | return false, err |
| 4963 | } |
| 4964 | if status != http.StatusOK { |
| 4965 | return false, apiErrorFromBody(status, body) |
| 4966 | } |
| 4967 | var res struct { |
| 4968 | Matched bool `json:"matched"` |
| 4969 | } |
| 4970 | if err := json.Unmarshal(body, &res); err != nil { |
| 4971 | return false, fmt.Errorf("services revoke: unreadable response: %w", err) |
| 4972 | } |
| 4973 | return res.Matched, nil |
| 4974 | } |
| 4975 | |
| 4976 | type apiAuthorizePlan struct { |
| 4977 | Version string `json:"version"` |
| 4978 | ManifestDigest string `json:"manifest_digest"` |
| 4979 | Artifacts map[string]release.Artifact `json:"artifacts"` |
| 4980 | } |
| 4981 | |
| 4982 | func (s *apiSession) authorizePlan(ctx context.Context, a apiAcct, app, version string) (apiAuthorizePlan, error) { |
| 4983 | status, body, err := s.getJSON(ctx, apiPath(a, app, "/releases/"+version+"/authorize")) |
| 4984 | if err != nil { |
| 4985 | return apiAuthorizePlan{}, err |
| 4986 | } |
| 4987 | if status != http.StatusOK { |
| 4988 | return apiAuthorizePlan{}, apiErrorFromBody(status, body) |
| 4989 | } |
| 4990 | var p apiAuthorizePlan |
| 4991 | if err := json.Unmarshal(body, &p); err != nil || p.ManifestDigest == "" { |
| 4992 | return apiAuthorizePlan{}, fmt.Errorf("authorize: unreadable response") |
| 4993 | } |
| 4994 | return p, nil |
| 4995 | } |
| 4996 | |
| 4997 | func (s *apiSession) authorizeSign(ctx context.Context, a apiAcct, app, version, digest string) (already bool, err error) { |
| 4998 | status, body, err := s.postJSON(ctx, apiPath(a, app, "/releases/"+version+"/authorize"), map[string]string{"manifest_digest": digest}) |
| 4999 | if err != nil { |
| 5000 | return false, err |
| 5001 | } |
| 5002 | if status == http.StatusConflict { |
| 5003 | return false, errors.New(fmt.Sprintf(updatewords.Text("settings.authorize.changed"), version)) |
| 5004 | } |
| 5005 | if status != http.StatusOK { |
| 5006 | return false, apiErrorFromBody(status, body) |
| 5007 | } |
| 5008 | var res struct { |
| 5009 | Result string `json:"result"` |
| 5010 | } |
| 5011 | _ = json.Unmarshal(body, &res) |
| 5012 | return res.Result == "already", nil |
| 5013 | } |
| 5014 | |