carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
16package main
17
18import (
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.
51const 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.
56type 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.
110func 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 = &notLoggedInError{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.
185type consoleMovedError struct{ From, To string }
186
187func (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.
202func 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.
225var 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.
230var 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.
238func 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.
268var 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.
279func 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".
332const 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.
341func 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.
358type 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).
383type 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.
402type 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
417type 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.
432type 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.
445func 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.
460func 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.
480func storeEnvTail(tail, env string) string {
481 if env == "" {
482 return tail
483 }
484 return tail + "?environment=" + url.QueryEscape(env)
485}
486
487func 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.
502func (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.
536func (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
547func (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.
626type noAppsError struct{ Account string }
627
628func (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.
640func (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.
705func 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.
734func 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.
754func 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.
782func 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.
789var 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.
796func 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.
810func 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.
827func (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).
834func (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.
866func (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.
896func (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.
910func 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).
933type apiProposedError struct {
934 PendingID string
935 Needed int
936 Confirmed int
937 ConsoleURL string
938}
939
940func (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.
951func 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.
973func (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.
1019type 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.
1063func (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 "".
1095type 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
1107type apiRegionEntry struct {
1108 Code string `json:"code"`
1109 Name string `json:"name"`
1110}
1111
1112type 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.
1119func (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.
1130func (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.
1153func (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.
1178type 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.
1188func (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.
1206func (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.
1233type 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.
1243type 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.
1266func (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.
1299type 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.
1311func 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.
1322func 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.
1330func (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.
1353func (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).
1372func (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).
1397type 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.
1410func (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.
1431type 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
1440func (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
1445func (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
1456func 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.
1479type 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
1490func (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
1495func (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
1505func 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.
1525func (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
1574type 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.
1585func (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.
1605func (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
1617type apiDigestAccount struct {
1618 Account string `json:"account"`
1619 On bool `json:"on"`
1620 Since string `json:"since"`
1621 Error string `json:"error"`
1622}
1623type 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}
1630type 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.
1638func (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.
1672type apiVersionTarget struct {
1673 Target string `json:"target"`
1674 SetBy string `json:"set_by"`
1675 SetAt string `json:"set_at"`
1676}
1677
1678func (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.
1696func (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.
1719type 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
1726func (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.
1746type 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
1754func (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.
1776type 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.
1784type 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.
1794func (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".
1821func (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.
1847type 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.
1859type 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
1866var (
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.
1880type sentinelError struct {
1881 msg string
1882 sentinel error
1883}
1884
1885func (e *sentinelError) Error() string { return e.msg }
1886func (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.
1900func (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, &reg) // 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.
1952func (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.
1973func (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.
1985func (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.
2021type apiStoreDeclared struct {
2022 EnvPrefix string `json:"env_prefix"`
2023 At time.Time `json:"at"`
2024 By string `json:"by"`
2025}
2026
2027type 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
2035type 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
2046type 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).
2075type 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
2082const storeProvisionHTTPTimeout = 75 * time.Second
2083
2084type apiStoreAccountReply struct {
2085 Account string `json:"account"`
2086 Entitlement *objstore.Entitlement `json:"entitlement"`
2087}
2088
2089type apiStoreApprovalsReply struct {
2090 Applications []*objstore.Entitlement `json:"applications"`
2091}
2092
2093func 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.
2113type 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
2130func (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
2145func 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
2158func (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.
2178func (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
2196func (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
2201func (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
2208func (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
2223type apiWebhookRotateReply struct {
2224 Rotated int `json:"rotated"`
2225 Remaining int `json:"remaining"`
2226}
2227
2228func (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
2250type apiScanRequested struct {
2251 At time.Time `json:"at"`
2252 By string `json:"by"`
2253 Encrypted bool `json:"encrypted,omitempty"`
2254}
2255
2256type apiScanEnabled struct {
2257 Engine string `json:"engine"`
2258 At time.Time `json:"at"`
2259 By string `json:"by"`
2260}
2261
2262type apiScanEnforce struct {
2263 Key string `json:"key"`
2264 Value string `json:"value"`
2265}
2266
2267type 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.
2276type 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
2297type apiScanCoverage struct {
2298 NotApplicable bool `json:"not_applicable,omitempty"`
2299 Current *apiSweep `json:"current,omitempty"`
2300 LastComplete *apiSweep `json:"last_complete,omitempty"`
2301}
2302
2303type 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.
2317func (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.
2340func (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".
2347func (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
2354func (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
2358func (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
2362func (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
2366func (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
2373func (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.
2386func (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.
2408func (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.
2436func (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 `{}`.
2461func (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).
2487func (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
2520type 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
2539type 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
2556type 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.
2571type 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.
2581func (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.
2610func (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.
2620func (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
2624func (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.
2630func (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.
2643func (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.
2679func (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.
2697func (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.
2718func (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.
2737func (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.
2759func (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.
2795func (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).
2803func (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.
2813type 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).
2824type 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 "".
2842func (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.
2902type 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).
2917func (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.
2940type 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.
2959type 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.
2968type 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.
2978func (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.
3003type apiPipelineChannel struct {
3004 Name string
3005 Bake string
3006 Passkey bool
3007 PromoteApprovals int
3008 ChangeApprovals int
3009}
3010
3011func (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
3029func (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.
3051type 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.
3059func (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).
3090func (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).
3119type 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.
3130func (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.
3151func (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.
3182type 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.
3204type enableError struct {
3205 status int
3206 msg string
3207}
3208
3209func (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.
3225func 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.
3237func (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.
3260type 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).
3359type 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).
3371type 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.
3398type 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.
3409type 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.
3419type instanceReplicaReq struct {
3420 Role string
3421 Set []string
3422}
3423
3424func (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.
3432func (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.
3451func (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.
3484func (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.
3521func (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.
3561func (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
3582func (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.
3604func (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.
3633func (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.
3668type 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.
3693type 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.
3704func (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.
3724type 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.
3734func (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.
3760type 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.
3772func (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.
3778func (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
3782func (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.
3807type 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.
3832func (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.
3858func (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.
3871func (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).
3886func (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).
3910func (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.
3950const 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.
3956func 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".
3969func (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.
3987func (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.
4002type 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).
4020func (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).
4052type 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).
4074func (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).
4096func (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.
4117func (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.
4135func (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.
4150type 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.
4157func (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.
4184type 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.
4224func 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.
4231type 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.
4250type domainListError struct {
4251 status int
4252 err error
4253}
4254
4255func (e *domainListError) Error() string { return e.err.Error() }
4256func (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.
4281func (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.
4288func (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.
4298type 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.
4314type apiDomainAttachResult struct {
4315 Created bool
4316 Route string
4317 Verified bool
4318 Verify *apiDomainDNSRecord
4319}
4320
4321func (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
4326func (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.
4353func (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.
4374func (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.
4402func (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.
4425const 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.
4447func (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.
4548func 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
4577const 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.
4582func 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.
4598func 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.
4622func 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.
4634type apiFleetBox struct {
4635 Label string `json:"label"`
4636 Status string `json:"status"`
4637 Tags string `json:"tags,omitempty"`
4638}
4639
4640type 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`.
4647func (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`.
4670func (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.
4690func (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.
4716func (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.
4736func (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`.
4754func (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).
4771func (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.
4793func (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.
4827func 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.
4840type 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.
4870func (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.
4907func (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.
4930func (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.
4959func (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
4976type apiAuthorizePlan struct {
4977 Version string `json:"version"`
4978 ManifestDigest string `json:"manifest_digest"`
4979 Artifacts map[string]release.Artifact `json:"artifacts"`
4980}
4981
4982func (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
4997func (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