carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// deploy.go composes ship + promote + watch into one member-facing verb:
2// "make this app's URL serve this version" (spec §6). It is deliberately
3// thin — every piece it calls (runShipAPI, apiSession.promote,
4// apiSession.instanceList, the X-Carlos-Version header) already exists for
5// the step-by-step verbs; this file only sequences them and watches for the
6// one fact that proves the job is actually done.
7package main
8
9import (
10 "bytes"
11 "context"
12 "errors"
13 "flag"
14 "fmt"
15 "io"
16 "log"
17 "net/http"
18 "os"
19 "os/exec"
20 "path/filepath"
21 "sort"
22 "strings"
23 "sync"
24 "sync/atomic"
25 "time"
26
27 "github.com/carlosframework/platform/internal/caps"
28 "github.com/carlosframework/platform/internal/release"
29)
30
31// deployRegionPreflight is spec §11: --region on an EXISTING app is
32// compared with the app's STORED region before anything ships. A failed
33// read STOPS the deploy (C15b), and so does a successful answer whose
34// stored region the console could not read (C15c, codex plan-R2 3):
35// either way the region is unknown, and shipping would go to whatever the
36// app already is. Only a successful response without app_default, an
37// older console, skips the check. The console states a home app's region
38// as the home's own code, so naming the home matches it.
39func deployRegionPreflight(res apiRegionsResult, readErr error, app, asked string) error {
40 if asked == "" {
41 return nil
42 }
43 if readErr != nil {
44 return errors.New(deployRegionUnreadable(app, readErr))
45 }
46 if res.AppDefault == nil {
47 return nil
48 }
49 if !res.AppDefault.RegionKnown {
50 return errors.New(deployRegionUnknown(app))
51 }
52 if res.AppDefault.Region == asked {
53 return nil
54 }
55 return errors.New(deployRegionMismatch(app, res.AppDefault.Name))
56}
57
58// runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket
59// operators keep ship + promote as separate acts; deploy is the member path
60// that ships, promotes to the channel the app's instances already follow,
61// and then proves it by watching the edge's version header — never AWS,
62// never a box command, per the operate-via-API rule.
63func runDeploy(args []string) {
64 fs := flag.NewFlagSet("deploy", flag.ExitOnError)
65 messageFlags := addMutationMessageFlags(fs)
66 app := appFlag(fs, "")
67 acctRaw, acctIDRaw, _ := accountFlags(fs)
68 consoleFlag := consoleOption(fs, "console origin to deploy through (API mode only; default: the only logged-in one)")
69 kind := fs.String("kind", "binary", "binary | static")
70 ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)")
71 target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
72 capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest")
73 channelFlag := channelFlag(fs, "channel to promote to (default: resolved from the app's instances, or its one channel if it has no instances; error if ambiguous)")
74 hostFlag := fs.String("host", "", "serve on this hostname instead of the app's own platform host (also scopes channel resolution and the watch to it)")
75 timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the URL before failing")
76 // A deploy IS a ship plus a promote, and the ship half has carried
77 // --label and --notes since it existed — they are the line a person
78 // doing a rollback at 2am reads. Deploy passed "" for both, so
79 // `carlos deploy --label "..."` failed with "flag provided but not
80 // defined" and dumped usage, and the only way to label a release was to
81 // stop using the one-command form (discussion #13, filed alongside the
82 // watch problem). Nothing had to change below: runShipAPI already takes
83 // both.
84 label := fs.String("label", "", "one line describing this release, for carlos releases and the console")
85 notes := fs.String("notes", "", "longer release notes")
86 temporary := fs.Bool("temporary", false, "create a new temporary app for this static site: named tmp-..., it moves to the Trash after --ttl and is purged 3 days later") // C11.21
87 ttlFlag := fs.String("ttl", "", "how long a temporary app lives, 1h to 30d (default 7d); implies --temporary") // C11.22
88 regionFlag := fs.String("region", "", flagRegionDeploy)
89 swap := fs.String("swap", "", shipSwapFlagHelp)
90 fs.Usage = flagUsage(fs, "")
91 fs.Parse(args)
92 usage := "usage: carlos deploy --app <a> [--version <sha>] [--kind binary|static] [--target linux-arm64] " +
93 "[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin> | -d <destination>] [--capabilities <file.json>] " +
94 "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--swap hold] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23
95 "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]"
96 temp, ttl, terr := temporaryDeployFlags(*temporary, flagWasSet(fs, "ttl"), *ttlFlag, *hostFlag, flagWasSet(fs, "kind"), *kind, fs.NArg())
97 if terr != nil {
98 log.Fatalf("deploy: %v", terr)
99 }
100 if temp {
101 *kind = "static"
102 }
103 if *ver == "" {
104 *ver = gitShortHead()
105 }
106 // The dirty-tree guard asks whether the OPERATOR TYPED --version (or its
107 // -v alias), not whether *ver holds a value — by the line above it always
108 // does. flagWasSet reads fs.Visit, so it is alias-aware and indifferent
109 // to where it is called relative to the default-fill.
110 if err := deployPreflight(".", flagWasSet(fs, "version")); err != nil {
111 log.Fatal(err)
112 }
113 if err := validSwapFlag(*swap); err != nil {
114 log.Fatal(err)
115 }
116 // A bad capability file is still refused before anything ships — but the
117 // app name may be left to resolution now, so with an omitted --app the
118 // read happens again once the name is known (ship's rule).
119 var capsSet *caps.Set
120 var err error
121 if *app != "" {
122 if capsSet, err = readCapabilities(*capsFile, *app); err != nil {
123 log.Fatal(err)
124 }
125 }
126
127 sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb
128 sess.deferSaves = true // deploy makes ONE combined save offer, below
129 printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
130 sess.shipSwap = *swap
131 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
132 ctx := context.Background()
133
134 // A temporary app is created here, through /new-temporary, before
135 // anything ships (spec §3, §7). An older console answers 404 and nothing
136 // is created; a console that does not echo an expiry stops the deploy.
137 if temp {
138 res, err := createTemporary(ctx, sess, acct, *app, ttl, strings.TrimSpace(*regionFlag), detectTimezone(), randomTemporaryLabel)
139 if err != nil {
140 log.Fatalf("deploy: %v", err)
141 }
142 *app = res.App
143 fmt.Fprintf(stdout, "%s %s, a temporary app. It moves to the Trash %s, at %s\n", // C11.24
144 colorFor(stdout).ok("created"), res.App, inDuration(time.Until(res.ExpiresAt)), res.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC"))
145 printAppRegion(stdout, res.Region, strings.TrimSpace(*regionFlag), false)
146 mutedf(stdout, "carlos apps keep --app %s makes it permanent", res.App) // C11.25
147 }
148
149 // What was resolved interactively THIS run — the one combined save
150 // offer at the end covers exactly these, never values that came from
151 // flags or from config (project-defaults spec, 2026-08-13).
152 newly := map[string]any{}
153
154 // A temporary app was created just above, with --region in its create:
155 // there is no stored region to check --region against.
156 createdNow := temp
157 if *app == "" {
158 r, aerr := sess.defaultApp(ctx, acct, false)
159 var na *noAppsError
160 switch {
161 case aerr == nil && r != "":
162 *app = r
163 if sess.appPrompted {
164 // Only a CHOSEN app belongs in the save offer — the muted
165 // single-app auto-resolve must not nag on every run.
166 newly["app"] = r
167 }
168 case errors.As(aerr, &na) && interactiveTerminal():
169 // The account owns nothing yet: zero-arg deploy is allowed to
170 // finish the onboarding — offer the claim, named after this
171 // directory, through the same door as `carlos apps create`
172 // (rate limiter and server validation unchanged).
173 *app = offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))
174 newly["app"] = *app
175 createdNow = true
176 case aerr != nil:
177 log.Fatal(aerr)
178 default:
179 log.Fatal(usage)
180 }
181 }
182 // --region on an app that already exists is checked against the app's
183 // stored region before anything is read or shipped (spec §11).
184 if r := strings.TrimSpace(*regionFlag); r != "" && !createdNow {
185 res, err := sess.appRegions(ctx, acct, *app)
186 if err := deployRegionPreflight(res, err, *app, r); err != nil {
187 log.Fatalf("deploy: %v", err)
188 }
189 }
190 if capsSet == nil {
191 if capsSet, err = readCapabilities(*capsFile, *app); err != nil {
192 log.Fatal(err)
193 }
194 }
195 if !temp && release.IsTemporaryName(*app) {
196 printTemporaryExpiry(ctx, sess, acct, *app)
197 }
198
199 if fs.NArg() == 0 {
200 // Zero-arg: the saved deploy block answers, else the first-run
201 // wizard names the artifact once — never a silent guess.
202 artifact := ""
203 dcfg, dsrc, derr := configDeploy()
204 if derr != nil {
205 log.Fatal(derr)
206 }
207 if dsrc != "" && dcfg.Console == sess.console {
208 // The config's artifact is COMMITTED REPO INPUT and this run is
209 // deliberately zero-question — so it is confined to the config's
210 // own project tree before anything is packed. Without this, a
211 // hostile checkout's {"artifact": "../../.carlos"} would tar the
212 // operator's credentials and publish them to the edge on the very
213 // flow this feature exists to sell (review 2026-08-13, finding 1).
214 artifact, err = projectArtifact(dsrc, dcfg.Artifact)
215 if err != nil {
216 log.Fatalf("deploy: %v", err)
217 }
218 if !flagWasSet(fs, "kind") && dcfg.Kind != "" {
219 *kind = dcfg.Kind
220 }
221 if !flagWasSet(fs, "target") && dcfg.Target != "" {
222 *target = dcfg.Target
223 }
224 from := dsrc
225 if dcfg.Destination != "" {
226 from = "the " + dcfg.Destination + " destination in " + dsrc
227 }
228 mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, from)
229 } else if interactiveTerminal() {
230 var k, tgt string
231 artifact, k, tgt, err = deployArtifactWizard()
232 if err != nil {
233 log.Fatal(err)
234 }
235 if !flagWasSet(fs, "kind") {
236 *kind = k
237 }
238 if !flagWasSet(fs, "target") && tgt != "" {
239 *target = tgt
240 }
241 newly["artifact"] = artifact
242 newly["kind"] = *kind
243 if *kind == "binary" {
244 newly["target"] = *target
245 }
246 } else {
247 log.Fatal("deploy: nothing to ship — pass --app and a binary or site directory, " +
248 "or run carlos deploy once at a terminal to save this project's defaults")
249 }
250 fi, err := os.Stat(artifact)
251 if err != nil {
252 log.Fatalf("deploy: artifact %s: %v (the saved default may be stale — edit .carlos/config)", artifact, err)
253 }
254 // A config that names an artifact but no kind must not ship a
255 // directory as a "binary" (the flag's default): what the artifact IS
256 // decides, exactly as the wizard infers it.
257 if !flagWasSet(fs, "kind") && dcfg.Kind == "" && fi.IsDir() {
258 *kind = "static"
259 }
260 // "--" so an artifact path starting with "-" is a PATH, never a
261 // flag: without it a config value like "--kind=static" would mutate
262 // the deploy's shape instead of being shipped (or refused).
263 fs.Parse([]string{"--", artifact})
264 }
265
266 if len(newly) > 0 && interactiveTerminal() {
267 offerSaveProjectDefaults(sess.console, sess.accountName(acct), newly,
268 "save these as this project's deploy defaults")
269 }
270
271 recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "")
272 if err != nil {
273 log.Fatalf("deploy: list instances: %v", err)
274 }
275 channel, hosts, staticDeploy, err := deployTargets(recs, *kind, *channelFlag, *hostFlag,
276 func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) })
277 if errors.Is(err, errAmbiguousChannel) && *channelFlag == "" && interactiveTerminal() {
278 // The refusal's own advice is "name one with --channel" — at a
279 // terminal, ask instead of quoting the flag back. Typed, never
280 // text-matched: only a genuine several-channels ambiguity may be
281 // answered this way (a no-instances refusal must stay a refusal).
282 if picked, ok := chooseDeployChannel(ctx, sess, acct, *app); ok {
283 channel, hosts, staticDeploy, err = deployTargets(recs, *kind, picked, *hostFlag,
284 func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) })
285 }
286 }
287 if err != nil && !errors.Is(err, errNoStaticHost) {
288 log.Fatalf("deploy: %v", err)
289 }
290 // Resolve once, before any route, release or channel write. Both halves
291 // of deploy carry the same context and neither asks a second question.
292 message, merr := resolveMutationMessage(fs, messageFlags)
293 if merr != nil {
294 log.Fatalf("deploy: %v", merr)
295 }
296 // A static app with nothing serving it yet: mint its platform host
297 // rather than refusing (#186's static half). Typed, never text-matched,
298 // and BEFORE the ship — attachUnclaimedHost's ordering, so a failed mint
299 // never leaves a published version with nothing to serve it.
300 //
301 // The channel guard is load-bearing, not defensive: deployTargets
302 // returns this refusal with an empty channel when the app has no
303 // resolvable channel EITHER, and minting a record with no channel is
304 // exactly the stable-defaulted route this path exists to avoid. With no
305 // channel there is nothing to mint onto, so the refusal stands and names
306 // --host, which is the step that makes --channel askable next.
307 if errors.Is(err, errNoStaticHost) && channel != "" {
308 host, mintErr := mintPlatformHost(ctx, sess, acct, *app, channel, message)
309 switch {
310 case mintErr == nil:
311 hosts, err = []string{host}, nil
312 case enableUnavailable(mintErr):
313 // This deployment has no platform host to give — no apps domain,
314 // or an operator-pinned instance domain. The original refusal is
315 // the actionable one; the door's own reason rides along so the
316 // member is not left wondering why nothing was offered.
317 mutedf(stdout, "%v\n", mintErr)
318 default:
319 log.Fatalf("deploy: mint the platform host: %v", mintErr)
320 }
321 }
322 if err != nil {
323 log.Fatalf("deploy: %v", err)
324 }
325 if line := deployingLine(hosts, channel); line != "" {
326 fmt.Fprintln(stdout, line)
327 }
328 if staticDeploy && *hostFlag != "" {
329 attachUnclaimedHost(ctx, sess, acct, *app, *hostFlag, *kind, channel, recs, message)
330 }
331 // printBanner=false: the "using ..." line already printed above, before
332 // the instance list — so a listing failure still names the console —
333 // and runShipAPI would otherwise print it a second time.
334 shipRes, err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, *notes, capsSet, false, false, message)
335 if err != nil {
336 log.Fatalf("deploy: ship: %v", err)
337 }
338 // `carlos deploy` carries no --hotfix flag of its own — it always ships in
339 // order, so this is a plain false, never a refusal.
340 res, err := sess.promote(ctx, acct, *app, *ver, channel, false, false, message)
341 if err != nil {
342 // A guarded channel turns promote into a 202 proposal (release
343 // pipelines v2 §4) rather than a landed write — *apiProposedError,
344 // not a refusal (Task 10 controller ruling). The ship above already
345 // succeeded (and printed its own "shipped" confirmation) and its
346 // bytes are real; only the promote is pending, so this exits
347 // CLEANLY (return, not log.Fatal) with its own message naming what
348 // happened and what's still needed — never runPromote's bare
349 // "proposed" line, which on its own would not say the ship had
350 // already landed, and never a second "shipped" line repeating what
351 // was just printed. The watch loop below must NOT run: nothing
352 // converges anywhere until the proposal is confirmed, so there is
353 // nothing yet to watch for.
354 var prop *apiProposedError
355 if errors.As(err, &prop) {
356 fmt.Fprintf(stdout, "the ship succeeded; promoting %s to %s is proposed and needs %d confirmation(s) at %s\n",
357 *ver, channel, prop.Needed, prop.ConsoleURL)
358 return
359 }
360 log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err
361 }
362 fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, *ver, channel)
363 if res.Held {
364 mutedf(stdout, "%s\n", deployHeldLine(*app, channel, res.HeldUntil, time.Now()))
365 return // exit 0: the writes succeeded, the hold is policy (spec §6.6)
366 }
367 if len(hosts) == 0 {
368 mutedf(stdout, "no instance follows %s — promoted, nothing to watch (carlos instances create to add one)\n", channel)
369 return
370 }
371 if staticDeploy {
372 // The watch rule is unchanged — version match AND 2xx — but the wait
373 // needs explaining: a static route trusts a resolved channel pointer
374 // for a whole recheck window, and the probe's cache-bust does NOT
375 // shorten that (it defeats the edge's HTTP response cache, not the
376 // handler's own trust window). The default timeout already outlasts
377 // one window (defaultDeployTimeout, pinned by test), so nothing here
378 // adjusts the clock — the operator just gets told why "promoted" can
379 // sit in front of the old page for a minute.
380 mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n",
381 int(staticRecheckWindow.Seconds()))
382 }
383 // The probe comes from the ship response the deploy just made (spec §3b):
384 // an app that declares one is watched by asking its OWN process which
385 // build it is running, because X-Carlos-Version is stamped by the edge
386 // from the route row and goes green the moment adoption moves a symlink —
387 // before, and whether or not, the process running the old binary
388 // restarts. Empty (no probe declared, or a console too old to echo the
389 // field) leaves the header watch exactly as it was.
390 //
391 // Re-checked HERE against the same grammar the create door enforces
392 // (validBuildProbe, apps.go — the byte-identical mirror of the console's
393 // own). Both existing gates are at the WRITE door; this value arrives
394 // from a READ, and the watch CONCATENATES it onto an origin. A probe not
395 // starting with "/" is the shape that matters: "https://" + host +
396 // "@evil.example/x" parses `host` as userinfo and evil.example as the
397 // HOST, so the watch would poll a machine of someone else's choosing and
398 // report ITS build as this deploy's — precisely the failure
399 // validBuildProbe exists to prevent. Only a pre-existing row, a future
400 // migration that forgets, or a hostile console can produce it; the cost
401 // of refusing to depend on that is four lines. Degrade, never fatal: the
402 // ship and promote already landed, so the honest outcome is the header
403 // watch plus a line saying why.
404 probe := shipRes.BuildProbe
405 if probe != "" {
406 if err := validBuildProbe(probe); err != nil {
407 mutedf(stdout, "ignoring the console's build probe: %v — watching the version header instead\n", err)
408 probe = ""
409 }
410 }
411 if staticDeploy {
412 // A static route has no box rollout to follow (no process, no
413 // scheduler), so it keeps the URL watch and its DNS guidance
414 // exactly as before.
415 printWatchNote(probe, true, *app)
416 targets := make([]watchTarget, 0, len(hosts))
417 for _, h := range hosts {
418 targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h), Probe: probe})
419 }
420 if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second, deployDiagnostics(ctx, sess, acct, *app)); err != nil {
421 if *hostFlag != "" && isConnectionClassFailure(err) {
422 failDeployDNSGuidance(ctx, sess, acct, *app, *hostFlag)
423 }
424 log.Fatalf("deploy: %v", err)
425 }
426 return
427 }
428 // An instance deploy follows each box's rollout of the build (spec §9.1,
429 // deploywatch.go), and is today's URL watch when no box reports one.
430 if err := watchDeployed(ctx, sess, acct, *app, *ver, channel, hosts, probe, *timeout); err != nil {
431 log.Fatalf("deploy: %v", err)
432 }
433}
434
435const deployHeldUntil = "%s is held by the %s bake window until about %s, then the fleet installs it. Fast-track it in the console if this is an emergency"
436
437// deployHeldLine is what `carlos deploy` says when the promote it made is
438// held by the channel's bake window (spec §6.6). The console's held_until,
439// when present and still ahead of now, is named in the operator's own zone
440// (R9); otherwise the line is today's, byte for byte.
441func deployHeldLine(app, channel, heldUntil string, now time.Time) string {
442 if t, err := time.Parse(time.RFC3339, heldUntil); err == nil && t.After(now) {
443 return fmt.Sprintf(deployHeldUntil, app, channel, t.Local().Format("Mon 2 Jan 15:04 MST"))
444 }
445 return fmt.Sprintf("%s is a production app: the %s bake window applies — the fleet adopts when it expires; console fast-track if this is an emergency", app, channel)
446}
447
448// defaultDeployTimeout is how long --timeout watches a URL before failing,
449// unless the operator says otherwise. It has to outlast one static recheck
450// window with room to spare, or a static deploy that was always going to land
451// times out first — TestDefaultDeployTimeoutOutlastsAStaticRecheck pins that,
452// so lowering this without raising the watch is a test failure, not a
453// mysterious timeout in production.
454const defaultDeployTimeout = 120 * time.Second
455
456// staticRecheckWindow mirrors internal/edge/static.go's staticRecheck: how
457// long a static route trusts a resolved channel pointer before re-reading it.
458// It is stated here rather than imported because it is a property of the EDGE
459// being watched — possibly an older build than this CLI — so sharing the
460// constant would promise a match this process cannot know it has.
461const staticRecheckWindow = 60 * time.Second
462
463// deployingLine names the destination a deploy is about to write to,
464// printed BEFORE ship so every later path — the bake hold, the watch,
465// even a ship failure — has already said where this deploy was going.
466// One instance gets its URL; a fleet-sized channel gets a count (the
467// per-host "live" lines and timeout diagnostics name hosts as they
468// resolve), and the no-instance path gets nothing: there is no URL to
469// name, and its own "promoted, nothing to watch" message stays the
470// honest report.
471func deployingLine(hosts []string, channel string) string {
472 switch len(hosts) {
473 case 0:
474 return ""
475 case 1:
476 return "deploying https://" + hosts[0]
477 }
478 return fmt.Sprintf("deploying %d instances on %s", len(hosts), channel)
479}
480
481// projectArtifact confines a config-sourced artifact to the config's own
482// project tree: dsrc is <root>/.carlos/config, and the artifact must
483// resolve — symlinks included — to somewhere under <root>. Absolute paths
484// and ..-escapes are refused outright; the wizard path never comes here
485// (a human typed that answer at this terminal, and it is not committed
486// input). Returns the root-resolved path the deploy should actually use,
487// so a saved "./public" works from any subdirectory of the project.
488func projectArtifact(dsrc, artifact string) (string, error) {
489 if artifact == "" {
490 return "", fmt.Errorf("%s names no artifact", dsrc)
491 }
492 if filepath.IsAbs(artifact) {
493 return "", fmt.Errorf("%s names an absolute artifact (%s) — refusing; a project default must be a path inside the checkout", dsrc, artifact)
494 }
495 root := filepath.Dir(filepath.Dir(dsrc))
496 resolved := filepath.Join(root, artifact)
497 escapeErr := fmt.Errorf("%s names an artifact outside the project (%s) — refusing; a project default must be a path inside the checkout", dsrc, artifact)
498 rel, err := filepath.Rel(root, resolved)
499 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
500 return "", escapeErr
501 }
502 // Symlinks are part of the same escape surface: a committed "site"
503 // symlink pointing at $HOME resolves inside the tree lexically and
504 // outside it physically.
505 realRoot, err := filepath.EvalSymlinks(root)
506 if err != nil {
507 return "", err
508 }
509 realArtifact, err := filepath.EvalSymlinks(resolved)
510 if err != nil {
511 return "", fmt.Errorf("artifact %s: %w (the saved default may be stale — edit %s)", artifact, err, dsrc)
512 }
513 rel, err = filepath.Rel(realRoot, realArtifact)
514 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
515 return "", escapeErr
516 }
517 return resolved, nil
518}
519
520// deployArtifactWizard is first-run zero-arg deploy's one real question:
521// WHAT ships. A conventional site directory (public/, dist/, _site/) only
522// ever pre-fills the prompt — the human confirms; detection never decides.
523// A directory answers static; a regular file answers binary, with the
524// target prompted (linux-arm64 pre-filled, the fleet's shape).
525func deployArtifactWizard() (artifact, kind, target string, err error) {
526 suggestion := ""
527 for _, d := range []string{"public", "dist", "_site"} {
528 if fi, err := os.Stat(d); err == nil && fi.IsDir() {
529 if suggestion != "" {
530 suggestion = "" // two candidates: suggest neither
531 break
532 }
533 suggestion = "./" + d
534 }
535 }
536 prompt := "artifact to deploy (binary or site directory): "
537 if suggestion != "" {
538 prompt = fmt.Sprintf("artifact to deploy [%s]: ", suggestion)
539 }
540 for attempt := 0; attempt < 2; attempt++ {
541 ans := promptLine(stdout, prompt)
542 if ans == "" {
543 ans = suggestion
544 }
545 if ans == "" {
546 // An accidental Enter with nothing pre-filled is a miss, not
547 // the end — the retry below asks once more.
548 continue
549 }
550 fi, serr := os.Stat(ans)
551 if serr != nil {
552 fmt.Fprintf(stdout, "%s does not exist\n", ans)
553 continue
554 }
555 if fi.IsDir() {
556 return ans, "static", "", nil
557 }
558 target = promptLine(stdout, "built for (GOOS-GOARCH) [linux-arm64]: ")
559 if target == "" {
560 target = "linux-arm64"
561 }
562 return ans, "binary", target, nil
563 }
564 return "", "", "", errors.New("deploy: nothing to ship — build the project first, then name its binary or site directory")
565}
566
567// offerCreateApp turns defaultApp's no-apps refusal into zero-arg deploy's
568// onboarding step: claim a name — this directory's, when it is a valid app
569// name — through the createApp door, or die with the refusal the other
570// verbs print. Declining is the refusal too: deploy cannot proceed without
571// an app, and inventing one silently is not on the table.
572func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noAppsError, region string) string {
573 name := ""
574 if cwd, err := os.Getwd(); err == nil {
575 base := filepath.Base(cwd)
576 if validAppName(base) == nil {
577 name = base
578 }
579 }
580 fmt.Fprintf(stdout, "account %s has no apps yet.\n", na.Account)
581 if name != "" {
582 if !promptYes(stdout, fmt.Sprintf("create %q (this directory's name)? [y/N] ", name)) {
583 log.Fatal(na)
584 }
585 } else {
586 name = promptLine(stdout, "app name to create (lowercase letters, digits, dashes): ")
587 if name == "" {
588 log.Fatal(na)
589 }
590 if verr := validAppName(name); verr != nil {
591 // The typo is the story, not the empty account.
592 log.Fatalf("deploy: %v", verr)
593 }
594 }
595 res, err := sess.createApp(ctx, acct, name, "", false, "", region, detectTimezone())
596 if err != nil {
597 log.Fatalf("deploy: create app: %v", err)
598 }
599 fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account)
600 printAppRegion(stdout, res, region, false)
601 return res.App
602}
603
604// chooseDeployChannel is the interactive form of "say which with --channel":
605// the app's verified channels, numbered. ok=false (no terminal answer, or
606// fewer than two clean channels to choose between) sends the caller back
607// to the original refusal — the chooser must never invent an option the
608// flag could not have named.
609func chooseDeployChannel(ctx context.Context, sess *apiSession, acct apiAcct, app string) (string, bool) {
610 res, err := sess.channels(ctx, acct, app)
611 if err != nil {
612 return "", false
613 }
614 names := make([]string, 0, len(res.Channels))
615 for _, ch := range res.Channels {
616 if ch.Err == "" {
617 names = append(names, ch.Channel)
618 }
619 }
620 if len(names) < 2 {
621 return "", false
622 }
623 picked, err := chooseOne(chooserIn, stdout, "this app releases onto several channels:", "channel",
624 names, errors.New("no channel chosen"))
625 if err != nil {
626 return "", false
627 }
628 return picked, true
629}
630
631// accountName answers what to CALL acct in a saved config: the memoized
632// memberships entry's name when the fetch has happened, else the segment
633// as given — a name resolves on any future run, and a sqid still works.
634func (s *apiSession) accountName(acct apiAcct) string {
635 for i := range s.accts {
636 if s.accts[i].Sqid == acct.Seg || s.accts[i].Name == acct.Seg {
637 return s.accts[i].Name
638 }
639 }
640 return acct.Seg
641}
642
643// deployTargets decides what this deploy promotes to and what it then watches,
644// and reports whether it took the static path. It is a function, not a stretch
645// of runDeploy, so the decision can be tested without a console: `channels` is
646// called only where a resolution actually needs it (a lazy closure over
647// apiSession.channels in the verb; a stub in tests) — the static path's own
648// channel listing, as before, plus (release pipelines v2, Task 11) the
649// no-instances case on EITHER path, to ask whether the app has declared a
650// pipeline whose entry channel can stand in for a resolution that would
651// otherwise fail.
652//
653// Zero instance records mean different things per kind, and the split matters:
654// - static: there are no records BY DESIGN — the edge serves the route off
655// the channel pointer and nothing is ever adopted — so host and channel
656// have to come from --host and --channel/the app's channels (issue #112).
657// - binary: the records simply aren't there yet, which is the pre-existing
658// "no instance follows this channel — promoted, nothing to watch" shape.
659// It keeps its old behaviour verbatim: this branch must not demand --host
660// from a binary app, nor tell it about static recheck windows.
661//
662// "No records" is judged KIND-AWARE, not by len(recs) == 0: a first-route
663// domain attach (#82) writes a Kind=="static" provision record, so the very
664// first `deploy --kind static --host <h>` leaves a record behind and every
665// redeploy after it would otherwise fall off the static path — losing the
666// recheck note and the DNS-guidance exit, and refusing a SECOND static
667// host with "no instance with host". Static records ARE static-deploy
668// targets; only a real instance/service record pulls a --kind static deploy
669// onto the instance path.
670func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
671 channels func() (apiChannelsResult, error)) (channel string, hosts []string, static bool, err error) {
672 if kind == "static" && staticOnlyRecords(recs) {
673 // Hosts and channel are resolved INDEPENDENTLY, because the mint
674 // path needs the channel precisely when the host is missing: the
675 // record this deploy is about to create has to name a channel, and
676 // an omitted one is defaulted to stable by the door
677 // (internal/console/apiinstances.go) — a route that serves nothing,
678 // with no door to change it afterwards.
679 //
680 // So errNoStaticHost travels WITH a usable channel: the one place
681 // this function returns both a value and an error, and the reason
682 // the two resolutions are no longer nested.
683 hosts, herr := staticDeployHosts(hostFlag, recs)
684 channel, cerr := staticChannel(channelFlag, hostFlag, recs, channels)
685 if cerr != nil {
686 // Both unresolved: the host refusal is the one that names
687 // something the member can actually supply, so it wins.
688 if herr != nil {
689 return "", nil, true, herr
690 }
691 return "", nil, true, cerr
692 }
693 return channel, hosts, true, herr
694 }
695 if channelFlag != "" {
696 // Explicit channel: no ambiguity to resolve. Watch the instances that
697 // follow it (none is fine — promote still lands; the watch is skipped
698 // with a note, since there is nothing that will serve this channel).
699 // --host is still validated against the instance list even here: a
700 // typo'd host must be its own error, not silently absorbed into "no
701 // instance follows this channel" once ship and promote have already
702 // run (finding 3, task-9 review).
703 hosts, err := filterChannelHosts(recs, channelFlag, hostFlag)
704 if err != nil {
705 return "", nil, false, err
706 }
707 return channelFlag, hosts, false, nil
708 }
709 // The no-instances case is the only one a pipeline can rescue: every
710 // other refusal resolveDeployChannel can return (several channels among
711 // existing instances, an unknown --host) is a genuine ambiguity or typo a
712 // pipeline's entry channel has no business papering over. So the
713 // channels round trip only happens here, once, and only when there is
714 // nothing else to resolve from.
715 pipelineEntry := ""
716 if len(recs) == 0 {
717 if res, cerr := channels(); cerr == nil {
718 pipelineEntry = res.PipelineEntry
719 }
720 }
721 channel, hosts, err = resolveDeployChannel(recs, hostFlag, pipelineEntry)
722 return channel, hosts, false, err
723}
724
725// staticOnlyRecords reports whether recs holds nothing but Kind=="static"
726// records — the condition under which a --kind static deploy stays on the
727// static path. Zero records is trivially true (the original no-records
728// static shape, issue #112); any instance/service record pulls the deploy
729// onto the instance path exactly as before this predicate existed.
730func staticOnlyRecords(recs []apiInstanceRecord) bool {
731 for _, r := range recs {
732 if r.Kind != "static" {
733 return false
734 }
735 }
736 return true
737}
738
739// staticRecordChannel resolves a static-path deploy's channel from the
740// records themselves — the record a first-route attach wrote already knows
741// what its domain follows, so a redeploy needs no --channel and no
742// channels-listing round trip. With --host, it is that host's record's
743// channel; without, it is the single distinct non-empty channel the records
744// agree on — the no-host redeploy is watching THOSE hosts, so their own
745// channel is the only one that keeps the promote and the watch pointed at
746// the same thing (a channels listing that happened to hold one OTHER
747// channel would promote onto it while watching hosts following another).
748// "" — host unrecorded, records disagreeing, or nothing recorded — sends
749// the caller to the channels listing (and its refusal names --channel).
750func staticRecordChannel(recs []apiInstanceRecord, host string) string {
751 if host != "" {
752 for _, r := range recs {
753 if r.Host == host {
754 return r.Channel
755 }
756 }
757 return ""
758 }
759 channel := ""
760 for _, r := range recs {
761 if r.Channel == "" {
762 continue
763 }
764 if channel == "" {
765 channel = r.Channel
766 } else if r.Channel != channel {
767 return "" // disagreeing records: a human picks, via --channel
768 }
769 }
770 return channel
771}
772
773// staticDeployHosts resolves the hosts a static-path deploy watches. --host
774// names one explicitly (an unrecorded host is fine — it is the SECOND
775// static domain, about to be attached on the way through). Without the
776// flag, the app's own static records are the next best source: each one
777// names a host this app serves, written by an earlier deploy's attach. Only
778// an app with neither — no flag, no records — has nothing to resolve a host
779// from, and that absence is refused before ship and promote run rather than
780// after, when the only remaining honest report would be "promoted, nothing
781// to watch".
782func staticDeployHosts(hostFlag string, recs []apiInstanceRecord) ([]string, error) {
783 if hostFlag != "" {
784 return []string{hostFlag}, nil
785 }
786 var hosts []string
787 for _, r := range recs {
788 // Static records only, so the helper holds its own invariant
789 // rather than trusting the caller's gate: an instance record's
790 // host is adopted and watched via the instance path, never here.
791 if r.Kind != "static" {
792 continue
793 }
794 hosts = append(hosts, r.Host)
795 }
796 if len(hosts) > 0 {
797 return hosts, nil
798 }
799 return nil, fmt.Errorf("%w: this app has no instance records — a static site is served by the edge straight from its\n"+
800 "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n"+
801 "Name the hostname the site answers on, so the deploy can watch it:\n"+
802 " carlos deploy --app <a> --kind static --host carlosframework.com <site-dir>", errNoStaticHost)
803}
804
805// staticChannel resolves the channel a static deploy promotes onto, in the
806// order the flag, the record and the app's channels earn it. Split out of
807// deployTargets' static branch so the MINT path can resolve a channel on the
808// very path where the host resolution fails — the two answers are
809// independent, and only their nesting ever made them look otherwise.
810func staticChannel(channelFlag, hostFlag string, recs []apiInstanceRecord,
811 channels func() (apiChannelsResult, error)) (string, error) {
812 if channelFlag != "" {
813 return channelFlag, nil
814 }
815 // A static record that names the deploy's own --host already knows its
816 // channel — the record the first deploy's attach wrote. Same convenience
817 // the instance path has always had (resolveDeployChannel resolves from
818 // the --host record); a host with no record yet — the SECOND static
819 // domain — falls through to the channels listing.
820 if ch := staticRecordChannel(recs, hostFlag); ch != "" {
821 return ch, nil
822 }
823 res, err := channels()
824 if err != nil {
825 return "", fmt.Errorf("list channels: %w", err)
826 }
827 // No channels at all AND a declared pipeline: this is the pipelines
828 // spec's entry channel doing its job — a brand-new one-channel app's
829 // FIRST deploy just lands on it, rather than being refused for a channel
830 // that (being pipelined) was never going to be created any other way. A
831 // pipeline whose channels have ALREADY been promoted onto is not this
832 // case — staticDeployChannel resolves it exactly as before, unambiguous
833 // or not.
834 if len(res.Channels) == 0 && res.PipelineEntry != "" {
835 return res.PipelineEntry, nil
836 }
837 return staticDeployChannel(res.Channels)
838}
839
840// staticDeployChannel picks the channel a NO-INSTANCE deploy promotes to,
841// from the app's own channels — the only record of what this app releases
842// onto once instance records are off the table. One channel is unambiguous;
843// zero and several are both for a human to settle with --channel, in
844// resolveDeployChannel's style: name what was found, name the flag that
845// breaks the tie, guess at nothing.
846//
847// A channel row can also carry an Err: the console's channels endpoint emits
848// {"channel": name, "error": ...} for a pointer it could not verify. Reading
849// only the name would resolve to — and then promote onto — a channel whose
850// pointer is unreadable or unsigned, so the one-channel case refuses on it.
851func staticDeployChannel(chans []apiChannel) (string, error) {
852 names := make([]string, 0, len(chans))
853 for _, ch := range chans {
854 names = append(names, ch.Channel)
855 }
856 if len(names) == 1 {
857 if chans[0].Err != "" {
858 return "", fmt.Errorf("this app's only channel %s is unreadable (%s) — resolving to it would promote onto "+
859 "a pointer the console cannot verify; fix it, or name a channel with --channel", chans[0].Channel, chans[0].Err)
860 }
861 return names[0], nil
862 }
863 if len(names) == 0 {
864 return "", errors.New("this app has no channels yet, and no instances to resolve one from — " +
865 "pass --channel to name the channel to promote onto")
866 }
867 sort.Strings(names)
868 return "", fmt.Errorf("%w: this app has several channels and no instances to resolve one from — "+
869 "pass --channel to pick one: %s", errAmbiguousChannel, strings.Join(names, ", "))
870}
871
872// errAmbiguousChannel marks the two refusals that mean "several channels,
873// pick one" — the ONLY refusals the interactive chooser may answer for.
874// Matching on error text once turned the no-instances refusal (which merely
875// MENTIONS --channel) into a ship+promote after an unrelated question
876// (review 2026-08-13, finding 2).
877var errAmbiguousChannel = errors.New("ambiguous channel")
878
879// errNoStaticHost marks the refusal "this static app has nothing serving it
880// yet". Typed for errAmbiguousChannel's reason, and the stakes are the same
881// shape: runDeploy answers THIS refusal by minting the app's platform host
882// (static-platform-host design §3.2), and a refusal matched by text would
883// eventually mint a hostname in answer to a different sentence that merely
884// mentioned one.
885var errNoStaticHost = errors.New("no static host")
886
887// mintPlatformHost gives an app its platform hostname —
888// <app>.<sqid>.<apps-domain> — and the static route record that serves it.
889// This is the whole of what #186's static half was missing: DNS already
890// resolves every name under the apps domain to the edge, and the edge
891// derives its own per-account wildcard cert from the live route table, so a
892// record is the only thing standing between a fresh static app and a URL.
893//
894// Two existing doors, in order, and no new authority anywhere. The manifest
895// PUT is an idempotent upsert — it CREATES when absent and otherwise
896// preserves every field, since nil channel and nil seed both mean "leave it"
897// — and it answers with the domain the console pins to the account, so this
898// client never has to know the deployment's apps domain to build the host.
899//
900// Then the record, carrying the kind and — still explicit, though the door
901// no longer needs it to be — the channel this deploy resolved. The door used
902// to default an omitted channel to the manifest's and then to "stable" while
903// a fresh app's pipeline entry was "edge", so the record followed a channel
904// nothing ever promotes to; repairing one took a raw bucket write (hello.bdn,
905// 2026-08-22) and hitting it cost a first deploy a passkey (amadan #5). The
906// door now resolves the entry channel itself (instanceChannel). Passing it
907// stays right regardless: this caller has already resolved the channel it is
908// about to promote to, and a record minted on a different one would serve
909// something else.
910func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string, messages ...string) (string, error) {
911 m, err := sess.instancesEnable(ctx, acct, app, nil, nil, nil)
912 if err != nil {
913 return "", err
914 }
915 // No environment: the app's apex serves its default config bundle, which
916 // is exactly right — this is the app's primary host, the one every other
917 // bundle's origin values are written to name.
918 rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", "", nil, nil, nil, firstMutationMessage(messages))
919 if err != nil {
920 return "", err
921 }
922 fmt.Fprintf(stdout, "%s %s (static, channel %s)\n",
923 colorFor(stdout).ok("minted"), rec.Host, rec.Channel)
924 return rec.Host, nil
925}
926
927// attachUnclaimedHost auto-attaches --host before shipping, when this deploy
928// is targeting a fresh static host with no route of its own yet (self-serve
929// custom domains, #82/#151): the app's first deploy to a brand-new hostname
930// should not need a separate `carlos domains attach` first. recs is the
931// SAME instance list runDeploy already fetched for deployTargets — checked
932// again here rather than assumed empty, so this stays correct even if a
933// future caller ever reaches it with records present.
934//
935// domainList is read BEFORE any attach POST, and its answer is decisive:
936// re-attaching a host this app already holds is a harmless idempotent 200
937// on the merged door (same-owner converge — #151's claim-writer contract),
938// but it is still a write request this deploy has no business sending: the
939// list already proves there is nothing to do, and only a host held by a
940// DIFFERENT owner answers 409 "hostname unavailable".
941//
942// Since the kind-aware static gate, this runs on REDEPLOYS too (records
943// present), not just the first deploy — so a 401/403 on the GET aborting
944// the whole deploy is a question worth answering: the domains door sits
945// behind the SAME instanceGate as the instanceList runDeploy has already
946// passed, so a token that got this far can list domains too, and a 401/403
947// here means the credential genuinely lost authority mid-deploy — worth
948// stopping for, not degrading past.
949func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, app, host, kind, channel string, recs []apiInstanceRecord, messages ...string) {
950 domains, err := sess.domainList(ctx, acct, app)
951 if err != nil {
952 switch {
953 case domainsDoorMissing(err):
954 // A console that predates the /domains door (Task 8 wires the
955 // real one; not every box has adopted it yet). Silence is only
956 // honest when nothing was going to be attached anyway — an
957 // instance already answering this host means so, regardless of
958 // the door. A genuinely unclaimed host (no instance, and now no
959 // way to check domainList either) gets a note instead of a
960 // silent skip: without the door this deploy cannot attach it,
961 // and a member watching "deploying https://<host>" scroll by
962 // with no explanation would have no idea DNS was never even
963 // checked.
964 if !hostHasInstance(host, recs) {
965 mutedf(stdout, "this console has no domains door yet — attach %s to %s in the console before "+
966 "DNS can be checked; continuing without auto-attach\n", host, app)
967 }
968 return
969 case domainListDegradable(err):
970 // A transport failure or 5xx: the door exists but is unwell
971 // right now, not a reason to abort a deploy that would
972 // otherwise succeed. 401/403 do NOT take this branch — those
973 // mean the credential itself lacks authority, which is worth
974 // stopping for.
975 mutedf(stdout, "could not check custom domains for %s (%v) — continuing without auto-attach\n", host, err)
976 return
977 default:
978 log.Fatalf("deploy: list domains: %v", err)
979 }
980 }
981 if !domainNeedsAttach(host, recs, domains) {
982 return
983 }
984 // kind travels only when it is "static" — the one shape a first-route
985 // attach accepts (attachDomain, internal/console/domainattach.go); empty
986 // leaves the field out of the wire body entirely rather than sending a
987 // value the server would refuse. attachUnclaimedHost is only ever
988 // reached with kind == "static" (runDeploy gates the call on
989 // staticDeploy, which deployTargets only sets true for that kind), so
990 // this collapses to "static" in practice — kept as one variable rather
991 // than a second hardcoded literal in the printed line below, so the two
992 // can never drift apart and say different things.
993 attachKind := ""
994 if kind == "static" {
995 attachKind = kind
996 }
997 // The door, not this call's own request, decides alias-vs-first-route
998 // (spec §4's "one deliberate asymmetry"): resolvedRoute is the host
999 // itself on a genuine first-route attach, or a DIFFERENT route when the
1000 // app already had one ready and the door aliased onto it instead —
1001 // which can happen even though attachKind asked for "static", since a
1002 // ready route always wins over minting a second one (§1's yes/no
1003 // split lives door-side). Printing attachKind/channel unconditionally
1004 // here would claim a first route was minted when the door actually
1005 // aliased, so the two cases get two different lines, and the alias one
1006 // never names a channel — an alias serves the primary's channel, not
1007 // one of its own.
1008 res, err := sess.domainAttachFull(ctx, acct, app, host, "", attachKind, channel, nil, nil, firstMutationMessage(messages))
1009 if err != nil {
1010 log.Fatalf("deploy: attach %s: %v", host, err)
1011 }
1012 resolvedRoute := res.Route
1013 if !res.Verified {
1014 // Stored, not served (#215): the deploy carries on (the release
1015 // still ships to the app's own hosts), and says plainly that this
1016 // hostname waits on a record only the member can create. Re-running
1017 // the deploy repeats the attach, which is what verifies it.
1018 fmt.Fprintf(stdout, "%s %s, waiting for you to prove you control it\n", colorFor(stdout).warn("reserved"), host)
1019 if res.Verify != nil && res.Verify.Name != "" {
1020 fmt.Fprintf(stdout, "Add: TXT %s -> %s\n", res.Verify.Name, res.Verify.Value)
1021 }
1022 fmt.Fprintln(stdout, "Then: re-run this deploy, or carlos domains verify --app "+app+" "+host)
1023 return
1024 }
1025 if resolvedRoute == host {
1026 fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel)
1027 } else {
1028 fmt.Fprintf(stdout, "%s %s (aliases %s)\n", colorFor(stdout).ok("attached"), host, resolvedRoute)
1029 }
1030}
1031
1032// hostHasInstance reports whether host is already answered by one of the
1033// app's instance records — the one fact attachUnclaimedHost can still trust
1034// even when domainList itself cannot be read (the door is missing, or
1035// unwell): an instance already serving this host never needed an attach in
1036// the first place, regardless of what domainList would have said.
1037func hostHasInstance(host string, recs []apiInstanceRecord) bool {
1038 for _, r := range recs {
1039 if r.Host == host {
1040 return true
1041 }
1042 }
1043 return false
1044}
1045
1046// domainNeedsAttach reports whether host is a fresh target this deploy must
1047// attach before shipping: present in NEITHER the app's instance records (an
1048// existing instance already answers it) NOR its already-claimed domains (an
1049// earlier deploy's attach, or a manual `carlos domains attach`). Either
1050// match means false. The merged door would answer a same-owner re-attach
1051// with an idempotent 200 (not the 409 an EARLIER contract draft promised),
1052// so skipping here is POST-avoidance, not failure-avoidance: the list
1053// already said there is nothing to do, and a repeated `carlos deploy` should
1054// send zero attach writes, not one no-op per run.
1055func domainNeedsAttach(host string, recs []apiInstanceRecord, domains []apiDomainRecord) bool {
1056 if hostHasInstance(host, recs) {
1057 return false
1058 }
1059 for _, d := range domains {
1060 if d.Host == host {
1061 // A claim still waiting for proof of control (#215) is attached
1062 // again: repeating the attach is what verifies it once the TXT
1063 // record exists, so a re-run deploy finishes the job.
1064 return !domainVerified(d)
1065 }
1066 }
1067 return true
1068}
1069
1070// domainsDoorMissing reports whether err is a domainList 404 — the shape
1071// apiErrorFromBody gives ANY 404 whose body carries no JSON "error" field,
1072// which is exactly what an unregistered route (a console that predates the
1073// /domains door, Task 8) answers. By the time attachUnclaimedHost calls
1074// domainList, runDeploy has already listed this exact account+app's
1075// instances successfully — so "unknown account/app" is already ruled out,
1076// and a domainList 404 here can only mean the door itself does not exist
1077// yet.
1078func domainsDoorMissing(err error) bool {
1079 var dle *domainListError
1080 return errors.As(err, &dle) && dle.status == http.StatusNotFound
1081}
1082
1083// domainListDegradable reports whether a domainList failure should stand
1084// auto-attach down with a warning rather than aborting the whole deploy: a
1085// transport-level failure (status 0 — the request never got an HTTP
1086// response at all) or any 5xx. 401/403 are deliberately excluded — those
1087// mean the credential itself lacks authority to see this app's domains,
1088// which is worth stopping the deploy for, not silently continuing past. An
1089// error that isn't even a *domainListError (never expected from
1090// domainList, but defensive) is treated as NOT degradable, so an unknown
1091// shape stays fatal rather than silently swallowed.
1092func domainListDegradable(err error) bool {
1093 var dle *domainListError
1094 if !errors.As(err, &dle) {
1095 return false
1096 }
1097 return dle.status == 0 || (dle.status >= 500 && dle.status < 600)
1098}
1099
1100// isConnectionClassFailure reports whether a watchVersion timeout's error
1101// text names a target that never even answered — the "unreachable: ..."
1102// diag watchVersion emits when http.Client.Get itself failed (DNS
1103// resolution, connection refused, TLS handshake: every shape a domain whose
1104// DNS is not yet pointing here produces), as opposed to a reachable origin
1105// that simply isn't serving the right version yet. watchVersion reports one
1106// combined string, not a structured per-host reason, so a substring check is
1107// what there is to go on here — precise enough because deploy only ever
1108// reaches this with --host naming the single target that timed out.
1109func isConnectionClassFailure(err error) bool {
1110 return err != nil && strings.Contains(err.Error(), "unreachable:")
1111}
1112
1113// dnsGuidanceMessage decides the honest, spec'd exit text for a
1114// connection-class watch timeout, from the app's domainList entry for
1115// host — or reports ok=false when neither arm applies, telling the caller
1116// to fall back to the plain watchVersion timeout report instead of
1117// guessing. Two arms, keyed off domainList's own DNS verdict for the host:
1118//
1119// - DNSStatus == "pointing": DNS is right, so a connection-class failure
1120// means something else is missing — the certificate, almost always,
1121// since first-route attach mints the record before ACME has had a chance
1122// to run. Says so, and to re-run SHORTLY (this should resolve itself).
1123// - DNSStatus != "pointing" (elsewhere, or never checked) WITH guidance:
1124// the DNS-not-pointing report, the console's own Create: text verbatim.
1125//
1126// When the door sends a derived state (#299) it is used instead: it names
1127// "the certificate is coming" directly, where the DNSStatus arms below could
1128// only infer it. The arms below remain for consoles that predate state.
1129//
1130// A host absent from domains, present with DNSStatus != "pointing" and no
1131// guidance yet (checked, but the console has nothing to say), or stamped
1132// "unresolved" (OUR lookup failed — not an answer about the member's
1133// record, so "Create: ..." would blame them for our outage), is
1134// deliberately ok=false — a guessed-at guidance line this call cannot back
1135// up would be worse than the generic timeout report already in hand.
1136func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcct) (msg string, ok bool) {
1137 for _, d := range domains {
1138 if d.Host != host {
1139 continue
1140 }
1141 // The door's derived state, when it sends one (#299). It says
1142 // directly what the DNS columns could only be reverse-engineered
1143 // into: "issuing-cert" is DNS-is-right-cert-is-coming, which the
1144 // old DNSStatus check could not distinguish from "pointing but
1145 // something else is broken". Older consoles send no state and fall
1146 // through to the DNSStatus arms below, unchanged.
1147 switch {
1148 case d.State == stateUnverified:
1149 // Not served until proven (#215), whatever DNS says. The door's
1150 // records carry the TXT that proves it beside the pointing ones.
1151 msg := fmt.Sprintf("%s is not served yet: prove you control it first.", host)
1152 if recs := formatDomainRecords(d.Records); recs != "" {
1153 msg += " " + recs
1154 }
1155 return msg + fmt.Sprintf(" Then re-run this deploy, or 'carlos domains verify --app %s --account %s %s' — the shipped release is already promoted.", app, acct.Seg, host), true
1156 case d.State == stateIssuingCert:
1157 return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+
1158 "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.",
1159 host, app, acct.Seg), true
1160 case d.State == stateFailed:
1161 // DNS is right and the certificate is not — and that is ours to
1162 // fix, not theirs. The door already derived the honest sentence,
1163 // so it is repeated verbatim rather than paraphrased into a
1164 // second story about the same hostname. No "Create:" line: their
1165 // records are correct, and telling them otherwise would send
1166 // them to change the one thing that is working.
1167 msg := fmt.Sprintf("DNS for %s is pointing here, but we have not been able to obtain a certificate for it.", host)
1168 if d.Detail != "" {
1169 msg = fmt.Sprintf("DNS for %s is pointing here. %s", host, d.Detail)
1170 }
1171 return msg + fmt.Sprintf(" Watch with 'carlos domains list --app %s --account %s' — the shipped release is already promoted.", app, acct.Seg), true
1172 case d.State == stateLive:
1173 // DNS and certificate are both fine, so a connection failure is
1174 // something else entirely and this function has nothing honest
1175 // to add.
1176 return "", false
1177 case d.Reason == "lookup-failed":
1178 // OUR resolver failed. Not an answer about the member's record,
1179 // so "Create: ..." would blame them for our outage.
1180 return "", false
1181 case d.State == statePendingDNS || d.State == stateMisdirected || d.State == stateChecking:
1182 // checking belongs HERE, not with the silent cases: a domain
1183 // attached seconds ago has genuinely not been looked at by any
1184 // box yet, and the records to create are exactly what its member
1185 // needs. Silence would be a regression against the pre-state
1186 // behaviour, which offered guidance whenever DNS was not
1187 // confirmed pointing.
1188 if d.Guidance == "" && len(d.Records) == 0 {
1189 return "", false
1190 }
1191 msg := fmt.Sprintf("DNS for %s is not pointing at this deployment yet.", host)
1192 if d.Guidance != "" {
1193 msg += " Create: " + d.Guidance
1194 } else if recs := formatDomainRecords(d.Records); recs != "" {
1195 msg += " " + recs
1196 }
1197 return msg + fmt.Sprintf(" Watch with 'carlos domains list --app %s --account %s'; "+
1198 "re-run this deploy any time — the shipped release is already promoted.", app, acct.Seg), true
1199 case d.State != "":
1200 // A state this build does not interpret (a newer console).
1201 return "", false
1202 }
1203 switch {
1204 case d.DNSStatus == "pointing":
1205 return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+
1206 "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.",
1207 host, app, acct.Seg), true
1208 case d.DNSStatus == "unresolved":
1209 return "", false
1210 case d.DNSStatus != "pointing" && d.Guidance != "":
1211 return fmt.Sprintf("DNS for %s is not pointing at this deployment yet. Create: %s. "+
1212 "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy any time — the shipped release is already promoted.",
1213 host, d.Guidance, app, acct.Seg), true
1214 default:
1215 return "", false
1216 }
1217 }
1218 return "", false
1219}
1220
1221// failDeployDNSGuidance turns a connection-class watch timeout into the
1222// honest report the spec asks for: the deploy itself already succeeded
1223// (shipped and promoted), so the exit text says what's still missing and
1224// how to fix it, never "deploy failed". It still exits nonzero (log.Fatal,
1225// same as every other refusal in this file): the URL does not yet serve the
1226// release, and this must never read as success. Falls through silently
1227// (letting the caller's own generic watch-error message stand) when
1228// domainList itself cannot be read, or dnsGuidanceMessage finds neither arm
1229// applies.
1230func failDeployDNSGuidance(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) {
1231 domains, err := sess.domainList(ctx, acct, app)
1232 if err != nil {
1233 return
1234 }
1235 if msg, ok := dnsGuidanceMessage(domains, host, app, acct); ok {
1236 log.Fatal(msg)
1237 }
1238}
1239
1240// requireDeployAPISession resolves signed-in mode for deploy, refusing
1241// direct-bucket mode with deploy's OWN message — not instances_cli.go's
1242// requireAPISession, whose hardcoded text ("carlos instances %s works
1243// through a console... use carlos add instead") names a sub-verb of a
1244// different command and advice about routes, neither of which applies to
1245// deploy (task-9 review, finding 2). Unlike ship/promote, deploy has no
1246// direct-bucket body at all — it is pure console composition — so the
1247// refusal also has nowhere to fall back to but naming the two verbs that DO
1248// still work in that mode.
1249func requireDeployAPISession(consoleFlag string) *apiSession {
1250 sess, err := resolveMode(consoleFlag)
1251 if err != nil {
1252 log.Fatal(err)
1253 }
1254 if sess == nil {
1255 log.Fatal("carlos deploy works through a console, and this shell is in direct bucket mode\n" +
1256 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). deploy is a member verb with no\n" +
1257 "direct-bucket equivalent; unset the store env and run: carlos auth login --console <url>,\n" +
1258 "or use carlos ship + carlos promote, which both still work in this mode.")
1259 }
1260 return sess
1261}
1262
1263// deployPreflight refuses the one combination that produces a release whose
1264// name is a lie: a version DERIVED from git (the --version default) taken
1265// from a tree with uncommitted changes. The sha then names a commit the
1266// artifact was not built from, and every later "which build is this" answer —
1267// including the honest ones — inherits the lie.
1268//
1269// An explicit --version is untouched: that is the operator naming the release
1270// themselves, and second-guessing it would break every workflow that versions
1271// by something other than a sha. versionWasSet is runDeploy's flagWasSet
1272// answer (flags.go) — "the operator typed the flag", alias-aware — rather
1273// than the value itself, which the default-fill has always populated by the
1274// time anything can ask.
1275func deployPreflight(dir string, versionWasSet bool) error {
1276 if versionWasSet {
1277 return nil
1278 }
1279 // Named in the refusal below so an operator shipping a prebuilt artifact
1280 // from an unrelated dirty checkout knows which tree tripped it, rather
1281 // than guessing from a bare "-C .".
1282 named := dir
1283 if abs, err := filepath.Abs(dir); err == nil {
1284 named = abs
1285 }
1286 out, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
1287 if err != nil {
1288 return nil // not a git checkout, or no git: nothing to be dirty
1289 }
1290 if len(bytes.TrimSpace(out)) == 0 {
1291 return nil
1292 }
1293 return fmt.Errorf("%s has uncommitted changes and --version defaults to the git short sha, "+
1294 "which would name a commit this build does not match — commit, or pass --version explicitly", named)
1295}
1296
1297// gitShortHead resolves --version's default: the working directory's short
1298// git SHA. Fatal here (rather than a returned error) matches every other
1299// "this environment is wrong" refusal in this binary — there is no sane
1300// fallback value, only the operator's own --version.
1301func gitShortHead() string {
1302 out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
1303 if err != nil {
1304 log.Fatal("not in a git repository — pass --version")
1305 }
1306 return strings.TrimSpace(string(out))
1307}
1308
1309// resolveDeployChannel picks the channel `carlos deploy` promotes to, and the
1310// hosts it then watches, from the app's own instance records (spec §6 step
1311// 2). --host (hostFlag) narrows to one instance's own channel with nothing
1312// left to resolve. With no host, every instance must already agree on one
1313// channel — several distinct channels is treated as ambiguity for a human to
1314// break with --channel or --host, not something this verb should guess at.
1315//
1316// pipelineEntry is deployTargets' pipeline-entry fallback (release pipelines
1317// v2, Task 11): the zero-records case used to be an unconditional refusal —
1318// it still is for a legacy app (pipelineEntry == "") — but a pipelined app's
1319// entry channel is exactly where spec §2 says a fresh deploy lands, so a
1320// brand-new one-channel app's first `carlos deploy` just works, no
1321// --channel, no `carlos instances create` first. hosts stays nil either way:
1322// there is nothing to watch until something is adopted onto the entry
1323// channel.
1324func resolveDeployChannel(recs []apiInstanceRecord, hostFlag, pipelineEntry string) (channel string, hosts []string, err error) {
1325 if len(recs) == 0 {
1326 if pipelineEntry != "" {
1327 return pipelineEntry, nil, nil
1328 }
1329 return "", nil, errors.New("no instances — create one: carlos instances create --app <a> --host <h> --channel <ch>")
1330 }
1331 if hostFlag != "" {
1332 for _, r := range recs {
1333 if r.Host == hostFlag {
1334 return r.Channel, []string{r.Host}, nil
1335 }
1336 }
1337 return "", nil, fmt.Errorf("no instance with host %q", hostFlag)
1338 }
1339 byChannel := map[string][]string{}
1340 var order []string
1341 for _, r := range recs {
1342 if _, ok := byChannel[r.Channel]; !ok {
1343 order = append(order, r.Channel)
1344 }
1345 byChannel[r.Channel] = append(byChannel[r.Channel], r.Host)
1346 hosts = append(hosts, r.Host)
1347 }
1348 if len(byChannel) == 1 {
1349 return order[0], hosts, nil
1350 }
1351 sort.Strings(order)
1352 var b strings.Builder
1353 b.WriteString("instances follow different channels — pass --channel or --host to pick one: ")
1354 for i, ch := range order {
1355 if i > 0 {
1356 b.WriteString("; ")
1357 }
1358 fmt.Fprintf(&b, "%s: %s", ch, strings.Join(byChannel[ch], ", "))
1359 }
1360 return "", nil, fmt.Errorf("%w: %s", errAmbiguousChannel, b.String())
1361}
1362
1363// filterChannelHosts computes the hosts an EXPLICIT --channel deploy should
1364// watch: instance records whose Channel matches, further scoped to hostFlag
1365// if given. hostFlag is validated against the instance list up front —
1366// independent of channel — so a typo'd --host is its own error before ship
1367// and promote ever run, rather than silently producing zero hosts and
1368// surfacing as "no instance follows <channel>" once the writes have already
1369// landed (task-9 review, finding 3). This mirrors resolveDeployChannel's own
1370// host-existence check on the non-explicit-channel branch: an existing host
1371// that simply doesn't follow the requested channel is not an error here
1372// (the caller may be deploying a channel that host has never served before)
1373// — only a host absent from the instance list entirely is.
1374func filterChannelHosts(recs []apiInstanceRecord, channel, hostFlag string) ([]string, error) {
1375 if hostFlag != "" {
1376 found := false
1377 for _, r := range recs {
1378 if r.Host == hostFlag {
1379 found = true
1380 break
1381 }
1382 }
1383 if !found {
1384 return nil, fmt.Errorf("no instance with host %q", hostFlag)
1385 }
1386 }
1387 var hosts []string
1388 for _, r := range recs {
1389 if r.Channel == channel && (hostFlag == "" || r.Host == hostFlag) {
1390 hosts = append(hosts, r.Host)
1391 }
1392 }
1393 return hosts, nil
1394}
1395
1396// watchTarget is one instance to watch: Host is what gets printed (the
1397// public hostname the operator will actually visit) and Origin is what gets
1398// dialed (https://<host> in production; a test's httptest.Server URL in
1399// unit tests, which is never the same string as Host).
1400//
1401// Probe is the app's own build-report path (`/healthz`, say), empty for every
1402// app that declares none. It exists because X-Carlos-Version is stamped by
1403// the EDGE from the route row, so it reports what adoption INTENDED from the
1404// moment the symlink moves — whether or not any process restarted. For a
1405// long-running unit (the console above all) that header is not an exit
1406// criterion at all: it goes green while the old binary is still serving. A
1407// probe path is answered by the app's OWN process, so its body is the only
1408// honest answer to "which build is running here".
1409type watchTarget struct {
1410 Host, Origin string
1411 Probe string
1412}
1413
1414// watchOrigin maps an instance host to the origin the watch dials. In
1415// production this is always https://<host> — the public URL the operator
1416// will visit, with no scheme or port to configure, because that is the only
1417// thing an instance record's host ever means.
1418//
1419// It is a var solely so the deploy e2e (deploy_e2e_test.go) can point the
1420// watch leg at a loopback httptest stand-in for the edge: a test server
1421// lives at 127.0.0.1:<port>, which is never a routable instance hostname,
1422// so without this seam the composed verb could only be tested up to the
1423// promote. Nothing in the binary reassigns it, and nothing should: an
1424// operator-configurable scheme here would be a way to deploy while watching
1425// something other than the URL people actually visit.
1426var watchOrigin = func(host string) string { return "https://" + host }
1427
1428// watchVersion polls each target's origin until it reports ver — the deploy
1429// verb's exit criterion (spec §6 step 5, §7). A target with no Probe is
1430// watched exactly as it always was: GET / and match X-Carlos-Version. A
1431// target that declares one is watched by GETting that path and matching ver
1432// as a whole whitespace-delimited token in the BODY, because the app's own
1433// process answers there and the edge's header does not (see watchTarget).
1434// Every poll appends a query parameter that changes from the previous poll
1435// (?carlos-deploy-probe=<attempt>) to the URL it dials. This is not
1436// decoration: the edge caches a proxied response, header included, for up to
1437// 5 minutes (internal/edge/edge.go, versionHeader) — longer than this
1438// function's own 120s default timeout — so an un-cache-busted watch could
1439// poll one stale cached hit for the whole timeout and report a healthy
1440// deploy as failed. The attempt counter is sufficient (no randomness
1441// needed): each request just needs to differ from the ones before it.
1442// watchDiagnostics enriches watchVersion's timeout report (issue #131's
1443// tail): wakeReason answers "why is this host down" from the platform log
1444// stream — the fact that used to be journal-only — and logsHint names the
1445// command that shows the member the whole timeline. Nil (store-mode deploys,
1446// tests that aren't about the report) changes nothing.
1447type watchDiagnostics struct {
1448 wakeReason func(host string) string // last platform-stream error for host; "" = none found
1449 logsHint string // the follow-up command, in the member's own flags
1450}
1451
1452func watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, interval time.Duration, diag *watchDiagnostics) error {
1453 // pending is a slice, not a map, so target order stays stable across
1454 // sweeps — the goroutines below write into an indexed result slice keyed
1455 // off this same order, and the "live" lines print in that order once the
1456 // whole sweep has finished, never interleaved mid-flight. A map would
1457 // have de-duped repeated hosts for free, so do that explicitly here,
1458 // keeping each host's first occurrence — otherwise a duplicate host gets
1459 // polled (and printed "live") twice per sweep.
1460 seen := make(map[string]bool, len(targets))
1461 pending := make([]watchTarget, 0, len(targets))
1462 for _, t := range targets {
1463 if seen[t.Host] {
1464 continue
1465 }
1466 seen[t.Host] = true
1467 pending = append(pending, t)
1468 }
1469 last := make(map[string]string, len(targets))
1470 client := &http.Client{Timeout: 10 * time.Second}
1471 deadline := time.Now().Add(timeout)
1472 var attempt atomic.Int64 // per-sweep-and-target: every goroutine gets a distinct probe value
1473 for {
1474 type result struct {
1475 live bool
1476 diag string
1477 }
1478 results := make([]result, len(pending))
1479 var wg sync.WaitGroup
1480 for i, t := range pending {
1481 wg.Add(1)
1482 go func(i int, t watchTarget) {
1483 defer wg.Done()
1484 live, d := checkTarget(client, t, ver, attempt.Add(1))
1485 results[i] = result{live: live, diag: d}
1486 }(i, t)
1487 }
1488 wg.Wait()
1489
1490 var stillPending []watchTarget
1491 for i, r := range results {
1492 host := pending[i].Host
1493 if r.live {
1494 fmt.Fprintf(w, "%s https://%s\n", colorFor(w).ok("live"), host)
1495 continue
1496 }
1497 last[host] = r.diag
1498 stillPending = append(stillPending, pending[i])
1499 }
1500 pending = stillPending
1501 if len(pending) == 0 {
1502 return nil
1503 }
1504 if time.Now().After(deadline) {
1505 hosts := make([]string, 0, len(pending))
1506 for _, t := range pending {
1507 hosts = append(hosts, t.Host)
1508 }
1509 sort.Strings(hosts)
1510 var b strings.Builder
1511 fmt.Fprintf(&b, "timed out waiting for %s to serve %s:", strings.Join(hosts, ", "), ver)
1512 for _, h := range hosts {
1513 fmt.Fprintf(&b, "\n %s: %s", h, last[h])
1514 // The reason a wake failed used to be journal-only (#131);
1515 // now the platform log stream carries it member-safe, so a
1516 // timeout report can SAY it instead of guessing at causes.
1517 if diag != nil && diag.wakeReason != nil {
1518 if r := diag.wakeReason(h); r != "" {
1519 fmt.Fprintf(&b, "\n %s", r)
1520 }
1521 }
1522 }
1523 if diag != nil && diag.logsHint != "" {
1524 fmt.Fprintf(&b, "\nfull timeline: %s", diag.logsHint)
1525 }
1526 return errors.New(b.String())
1527 }
1528 time.Sleep(interval)
1529 }
1530}
1531
1532// checkTarget is one poll of one target: watchVersion's per-target body,
1533// lifted out so the rollout watch (deploywatch.go) polls a host its box does
1534// not report on exactly as this watch always has. n is the poll's
1535// cache-buster, distinct for every poll of the watch.
1536func checkTarget(client *http.Client, t watchTarget, ver string, n int64) (live bool, diag string) {
1537 // The cache-buster rides BOTH legs. The edge caches a proxied
1538 // 2xx — body included — for up to 5 minutes, longer than this
1539 // function's own default timeout, so an un-busted probe poll
1540 // could sit on one stale hit for the whole watch and report a
1541 // healthy deploy as failed.
1542 path := "/"
1543 if t.Probe != "" {
1544 path = t.Probe
1545 }
1546 url := fmt.Sprintf("%s%s?carlos-deploy-probe=%d", t.Origin, path, n)
1547 resp, err := client.Get(url)
1548 if err != nil {
1549 return false, "unreachable: " + err.Error()
1550 }
1551 if t.Probe != "" {
1552 // The app's own process answers here, so the body IS the
1553 // truth — no header/route-row disagreement to reconcile
1554 // the way the header path has to. Read a bounded amount:
1555 // this is a health endpoint, and a target that answers
1556 // with a stream is misconfigured, not something to buffer.
1557 b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4096))
1558 resp.Body.Close()
1559 if rerr != nil {
1560 return false, "probe unreadable: " + rerr.Error()
1561 }
1562 got := strings.TrimSpace(string(b))
1563 if resp.StatusCode/100 != 2 {
1564 // Same rule as the header leg's 502 case: the right
1565 // build in a crash loop is not a live deploy.
1566 return false, fmt.Sprintf("%s answered HTTP %d: %q", path, resp.StatusCode, got)
1567 }
1568 // Whole-token, never substring: abc123 must not satisfy a
1569 // watch for abc1234, and short git shas collide like that
1570 // in practice.
1571 for _, f := range strings.Fields(got) {
1572 if f == ver {
1573 return true, ""
1574 }
1575 }
1576 // Naming the body is what makes the ldflags trap
1577 // self-diagnosing: a binary linked without
1578 // -X …/internal/version.Build reports "dev" forever, so a
1579 // perfectly healthy roll fails its watch — and "timed out"
1580 // would send the operator hunting the wrong thing, while
1581 // `reports "ok dev", waiting for abc1234` says what to fix.
1582 return false, fmt.Sprintf("%s reports %q, waiting for %s", path, got, ver)
1583 }
1584 io.CopyN(io.Discard, resp.Body, 4096)
1585 resp.Body.Close()
1586 got := resp.Header.Get("X-Carlos-Version")
1587 switch {
1588 case got == ver && resp.StatusCode/100 == 2:
1589 return true, ""
1590 case got == ver:
1591 // The edge stamps X-Carlos-Version before proxying to the
1592 // origin, so a tenant that dies on boot can still answer
1593 // with the new version header attached to a non-2xx (a 502,
1594 // most commonly). That is the new build in a crash loop, not
1595 // a live deploy, so the success arm has to gate on status
1596 // too — matching the header alone let a dead tenant exit 0.
1597 return false, fmt.Sprintf("serving %s but HTTP %d — the app is failing", ver, resp.StatusCode)
1598 case got == "" && resp.StatusCode/100 != 2:
1599 // A non-2xx with no header is not an old edge — it is an edge
1600 // that answered and had nothing good to serve: an activator
1601 // 503 because the wake failed or timed out, or a 404 because
1602 // the route has not been minted yet. (A 502 from a tenant
1603 // that died on boot carries the version header — the edge
1604 // stamps it before proxying — so that shape lands in the
1605 // case above, not here.) Reporting those as "edge predates
1606 // this feature — the hourly pass will adopt it" sends the
1607 // operator off to wait for a pass that will not help (review
1608 // finding 2): the status code is the diagnostic, so print it.
1609 return false, fmt.Sprintf("not serving (HTTP %d) — instance failing to wake, or route not live yet", resp.StatusCode)
1610 case got == "":
1611 // 2xx and no header: something IS being served here, it just
1612 // isn't stamped — the only shape that really is an edge from
1613 // before X-Carlos-Version existed.
1614 return false, "no X-Carlos-Version header (edge predates this feature — the hourly pass will adopt it)"
1615 default:
1616 return false, "still serving " + got
1617 }
1618}
1619
1620// The domain lifecycle states the door derives (#299 §2a). Mirrored here as
1621// constants rather than compared as string literals so a typo is a build
1622// error; the door is the authority on the vocabulary and publishes it in the
1623// list answer's capability block.
1624const (
1625 statePendingDNS = "pending-dns"
1626 stateMisdirected = "misdirected"
1627 stateIssuingCert = "issuing-cert"
1628 stateFailed = "failed"
1629 stateLive = "live"
1630 stateChecking = "checking"
1631 stateUnknown = "unknown"
1632 stateUnverified = "unverified"
1633)
1634
1635// formatDomainRecords renders the door's structured records as one short
1636// line for a deploy exit. Alternatives are joined with "or" — a member who
1637// created both a CNAME and A records for one name would publish an illegal
1638// pair, which is exactly what the set marker exists to prevent.
1639func formatDomainRecords(recs []apiDomainDNSRecord) string {
1640 if len(recs) == 0 {
1641 return ""
1642 }
1643 var order []string
1644 groups := map[string][]string{}
1645 // Required records are needed whichever alternative is picked (the
1646 // ACME delegation, the #215 ownership TXT), so they are listed after
1647 // the alternatives rather than offered as one of them.
1648 var required []string
1649 for _, r := range recs {
1650 line := fmt.Sprintf("%s %s -> %s", r.Type, r.Name, r.Value)
1651 if r.Required {
1652 required = append(required, line)
1653 continue
1654 }
1655 if _, seen := groups[r.Set]; !seen {
1656 order = append(order, r.Set)
1657 }
1658 groups[r.Set] = append(groups[r.Set], line)
1659 }
1660 var alts []string
1661 for _, set := range order {
1662 alts = append(alts, strings.Join(groups[set], ", "))
1663 }
1664 out := ""
1665 if len(alts) > 0 {
1666 out = "Create: " + strings.Join(alts, " — or — ") + "."
1667 }
1668 if len(required) > 0 {
1669 lead := "Also create: "
1670 if out == "" {
1671 lead = "Create: "
1672 }
1673 out = strings.TrimSpace(out + " " + lead + strings.Join(required, ", ") + ".")
1674 }
1675 return out
1676}
1677