// deploy.go composes ship + promote + watch into one member-facing verb: // "make this app's URL serve this version" (spec §6). It is deliberately // thin — every piece it calls (runShipAPI, apiSession.promote, // apiSession.instanceList, the X-Carlos-Version header) already exists for // the step-by-step verbs; this file only sequences them and watches for the // one fact that proves the job is actually done. package main import ( "bytes" "context" "errors" "flag" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "sync/atomic" "time" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/release" ) // deployRegionPreflight is spec §11: --region on an EXISTING app is // compared with the app's STORED region before anything ships. A failed // read STOPS the deploy (C15b), and so does a successful answer whose // stored region the console could not read (C15c, codex plan-R2 3): // either way the region is unknown, and shipping would go to whatever the // app already is. Only a successful response without app_default, an // older console, skips the check. The console states a home app's region // as the home's own code, so naming the home matches it. func deployRegionPreflight(res apiRegionsResult, readErr error, app, asked string) error { if asked == "" { return nil } if readErr != nil { return errors.New(deployRegionUnreadable(app, readErr)) } if res.AppDefault == nil { return nil } if !res.AppDefault.RegionKnown { return errors.New(deployRegionUnknown(app)) } if res.AppDefault.Region == asked { return nil } return errors.New(deployRegionMismatch(app, res.AppDefault.Name)) } // runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket // operators keep ship + promote as separate acts; deploy is the member path // that ships, promotes to the channel the app's instances already follow, // and then proves it by watching the edge's version header — never AWS, // never a box command, per the operate-via-API rule. func runDeploy(args []string) { fs := flag.NewFlagSet("deploy", flag.ExitOnError) messageFlags := addMutationMessageFlags(fs) app := appFlag(fs, "") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin to deploy through (API mode only; default: the only logged-in one)") kind := fs.String("kind", "binary", "binary | static") ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)") target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest") 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)") 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)") timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the URL before failing") // A deploy IS a ship plus a promote, and the ship half has carried // --label and --notes since it existed — they are the line a person // doing a rollback at 2am reads. Deploy passed "" for both, so // `carlos deploy --label "..."` failed with "flag provided but not // defined" and dumped usage, and the only way to label a release was to // stop using the one-command form (discussion #13, filed alongside the // watch problem). Nothing had to change below: runShipAPI already takes // both. label := fs.String("label", "", "one line describing this release, for carlos releases and the console") notes := fs.String("notes", "", "longer release notes") 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 ttlFlag := fs.String("ttl", "", "how long a temporary app lives, 1h to 30d (default 7d); implies --temporary") // C11.22 regionFlag := fs.String("region", "", flagRegionDeploy) swap := fs.String("swap", "", shipSwapFlagHelp) fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos deploy --app [--version ] [--kind binary|static] [--target linux-arm64] " + "[--account ] [--account-id ] [--console | -d ] [--capabilities ] " + "[--channel ] [--host ] [--region ] [--label ] [--notes ] [--swap hold] [--temporary] [--ttl 7d] [--timeout 120s] \n" + // C11.23 "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d ]" temp, ttl, terr := temporaryDeployFlags(*temporary, flagWasSet(fs, "ttl"), *ttlFlag, *hostFlag, flagWasSet(fs, "kind"), *kind, fs.NArg()) if terr != nil { log.Fatalf("deploy: %v", terr) } if temp { *kind = "static" } if *ver == "" { *ver = gitShortHead() } // The dirty-tree guard asks whether the OPERATOR TYPED --version (or its // -v alias), not whether *ver holds a value — by the line above it always // does. flagWasSet reads fs.Visit, so it is alias-aware and indifferent // to where it is called relative to the default-fill. if err := deployPreflight(".", flagWasSet(fs, "version")); err != nil { log.Fatal(err) } if err := validSwapFlag(*swap); err != nil { log.Fatal(err) } // A bad capability file is still refused before anything ships — but the // app name may be left to resolution now, so with an omitted --app the // read happens again once the name is known (ship's rule). var capsSet *caps.Set var err error if *app != "" { if capsSet, err = readCapabilities(*capsFile, *app); err != nil { log.Fatal(err) } } sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb sess.deferSaves = true // deploy makes ONE combined save offer, below printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below sess.shipSwap = *swap acct := resolvedAccount(sess, acctRaw, acctIDRaw) ctx := context.Background() // A temporary app is created here, through /new-temporary, before // anything ships (spec §3, §7). An older console answers 404 and nothing // is created; a console that does not echo an expiry stops the deploy. if temp { res, err := createTemporary(ctx, sess, acct, *app, ttl, strings.TrimSpace(*regionFlag), detectTimezone(), randomTemporaryLabel) if err != nil { log.Fatalf("deploy: %v", err) } *app = res.App fmt.Fprintf(stdout, "%s %s, a temporary app. It moves to the Trash %s, at %s\n", // C11.24 colorFor(stdout).ok("created"), res.App, inDuration(time.Until(res.ExpiresAt)), res.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")) printAppRegion(stdout, res.Region, strings.TrimSpace(*regionFlag), false) mutedf(stdout, "carlos apps keep --app %s makes it permanent", res.App) // C11.25 } // What was resolved interactively THIS run — the one combined save // offer at the end covers exactly these, never values that came from // flags or from config (project-defaults spec, 2026-08-13). newly := map[string]any{} // A temporary app was created just above, with --region in its create: // there is no stored region to check --region against. createdNow := temp if *app == "" { r, aerr := sess.defaultApp(ctx, acct, false) var na *noAppsError switch { case aerr == nil && r != "": *app = r if sess.appPrompted { // Only a CHOSEN app belongs in the save offer — the muted // single-app auto-resolve must not nag on every run. newly["app"] = r } case errors.As(aerr, &na) && interactiveTerminal(): // The account owns nothing yet: zero-arg deploy is allowed to // finish the onboarding — offer the claim, named after this // directory, through the same door as `carlos apps create` // (rate limiter and server validation unchanged). *app = offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag)) newly["app"] = *app createdNow = true case aerr != nil: log.Fatal(aerr) default: log.Fatal(usage) } } // --region on an app that already exists is checked against the app's // stored region before anything is read or shipped (spec §11). if r := strings.TrimSpace(*regionFlag); r != "" && !createdNow { res, err := sess.appRegions(ctx, acct, *app) if err := deployRegionPreflight(res, err, *app, r); err != nil { log.Fatalf("deploy: %v", err) } } if capsSet == nil { if capsSet, err = readCapabilities(*capsFile, *app); err != nil { log.Fatal(err) } } if !temp && release.IsTemporaryName(*app) { printTemporaryExpiry(ctx, sess, acct, *app) } if fs.NArg() == 0 { // Zero-arg: the saved deploy block answers, else the first-run // wizard names the artifact once — never a silent guess. artifact := "" dcfg, dsrc, derr := configDeploy() if derr != nil { log.Fatal(derr) } if dsrc != "" && dcfg.Console == sess.console { // The config's artifact is COMMITTED REPO INPUT and this run is // deliberately zero-question — so it is confined to the config's // own project tree before anything is packed. Without this, a // hostile checkout's {"artifact": "../../.carlos"} would tar the // operator's credentials and publish them to the edge on the very // flow this feature exists to sell (review 2026-08-13, finding 1). artifact, err = projectArtifact(dsrc, dcfg.Artifact) if err != nil { log.Fatalf("deploy: %v", err) } if !flagWasSet(fs, "kind") && dcfg.Kind != "" { *kind = dcfg.Kind } if !flagWasSet(fs, "target") && dcfg.Target != "" { *target = dcfg.Target } from := dsrc if dcfg.Destination != "" { from = "the " + dcfg.Destination + " destination in " + dsrc } mutedf(stdout, "deploying %s (%s — the defaults from %s)", artifact, *kind, from) } else if interactiveTerminal() { var k, tgt string artifact, k, tgt, err = deployArtifactWizard() if err != nil { log.Fatal(err) } if !flagWasSet(fs, "kind") { *kind = k } if !flagWasSet(fs, "target") && tgt != "" { *target = tgt } newly["artifact"] = artifact newly["kind"] = *kind if *kind == "binary" { newly["target"] = *target } } else { log.Fatal("deploy: nothing to ship — pass --app and a binary or site directory, " + "or run carlos deploy once at a terminal to save this project's defaults") } fi, err := os.Stat(artifact) if err != nil { log.Fatalf("deploy: artifact %s: %v (the saved default may be stale — edit .carlos/config)", artifact, err) } // A config that names an artifact but no kind must not ship a // directory as a "binary" (the flag's default): what the artifact IS // decides, exactly as the wizard infers it. if !flagWasSet(fs, "kind") && dcfg.Kind == "" && fi.IsDir() { *kind = "static" } // "--" so an artifact path starting with "-" is a PATH, never a // flag: without it a config value like "--kind=static" would mutate // the deploy's shape instead of being shipped (or refused). fs.Parse([]string{"--", artifact}) } if len(newly) > 0 && interactiveTerminal() { offerSaveProjectDefaults(sess.console, sess.accountName(acct), newly, "save these as this project's deploy defaults") } recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "") if err != nil { log.Fatalf("deploy: list instances: %v", err) } channel, hosts, staticDeploy, err := deployTargets(recs, *kind, *channelFlag, *hostFlag, func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) }) if errors.Is(err, errAmbiguousChannel) && *channelFlag == "" && interactiveTerminal() { // The refusal's own advice is "name one with --channel" — at a // terminal, ask instead of quoting the flag back. Typed, never // text-matched: only a genuine several-channels ambiguity may be // answered this way (a no-instances refusal must stay a refusal). if picked, ok := chooseDeployChannel(ctx, sess, acct, *app); ok { channel, hosts, staticDeploy, err = deployTargets(recs, *kind, picked, *hostFlag, func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) }) } } if err != nil && !errors.Is(err, errNoStaticHost) { log.Fatalf("deploy: %v", err) } // Resolve once, before any route, release or channel write. Both halves // of deploy carry the same context and neither asks a second question. message, merr := resolveMutationMessage(fs, messageFlags) if merr != nil { log.Fatalf("deploy: %v", merr) } // A static app with nothing serving it yet: mint its platform host // rather than refusing (#186's static half). Typed, never text-matched, // and BEFORE the ship — attachUnclaimedHost's ordering, so a failed mint // never leaves a published version with nothing to serve it. // // The channel guard is load-bearing, not defensive: deployTargets // returns this refusal with an empty channel when the app has no // resolvable channel EITHER, and minting a record with no channel is // exactly the stable-defaulted route this path exists to avoid. With no // channel there is nothing to mint onto, so the refusal stands and names // --host, which is the step that makes --channel askable next. if errors.Is(err, errNoStaticHost) && channel != "" { host, mintErr := mintPlatformHost(ctx, sess, acct, *app, channel, message) switch { case mintErr == nil: hosts, err = []string{host}, nil case enableUnavailable(mintErr): // This deployment has no platform host to give — no apps domain, // or an operator-pinned instance domain. The original refusal is // the actionable one; the door's own reason rides along so the // member is not left wondering why nothing was offered. mutedf(stdout, "%v\n", mintErr) default: log.Fatalf("deploy: mint the platform host: %v", mintErr) } } if err != nil { log.Fatalf("deploy: %v", err) } if line := deployingLine(hosts, channel); line != "" { fmt.Fprintln(stdout, line) } if staticDeploy && *hostFlag != "" { attachUnclaimedHost(ctx, sess, acct, *app, *hostFlag, *kind, channel, recs, message) } // printBanner=false: the "using ..." line already printed above, before // the instance list — so a listing failure still names the console — // and runShipAPI would otherwise print it a second time. shipRes, err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, *notes, capsSet, false, false, message) if err != nil { log.Fatalf("deploy: ship: %v", err) } // `carlos deploy` carries no --hotfix flag of its own — it always ships in // order, so this is a plain false, never a refusal. res, err := sess.promote(ctx, acct, *app, *ver, channel, false, false, message) if err != nil { // A guarded channel turns promote into a 202 proposal (release // pipelines v2 §4) rather than a landed write — *apiProposedError, // not a refusal (Task 10 controller ruling). The ship above already // succeeded (and printed its own "shipped" confirmation) and its // bytes are real; only the promote is pending, so this exits // CLEANLY (return, not log.Fatal) with its own message naming what // happened and what's still needed — never runPromote's bare // "proposed" line, which on its own would not say the ship had // already landed, and never a second "shipped" line repeating what // was just printed. The watch loop below must NOT run: nothing // converges anywhere until the proposal is confirmed, so there is // nothing yet to watch for. var prop *apiProposedError if errors.As(err, &prop) { fmt.Fprintf(stdout, "the ship succeeded; promoting %s to %s is proposed and needs %d confirmation(s) at %s\n", *ver, channel, prop.Needed, prop.ConsoleURL) return } log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err } fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, *ver, channel) if res.Held { mutedf(stdout, "%s\n", deployHeldLine(*app, channel, res.HeldUntil, time.Now())) return // exit 0: the writes succeeded, the hold is policy (spec §6.6) } if len(hosts) == 0 { mutedf(stdout, "no instance follows %s — promoted, nothing to watch (carlos instances create to add one)\n", channel) return } if staticDeploy { // The watch rule is unchanged — version match AND 2xx — but the wait // needs explaining: a static route trusts a resolved channel pointer // for a whole recheck window, and the probe's cache-bust does NOT // shorten that (it defeats the edge's HTTP response cache, not the // handler's own trust window). The default timeout already outlasts // one window (defaultDeployTimeout, pinned by test), so nothing here // adjusts the clock — the operator just gets told why "promoted" can // sit in front of the old page for a minute. mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n", int(staticRecheckWindow.Seconds())) } // The probe comes from the ship response the deploy just made (spec §3b): // an app that declares one is watched by asking its OWN process which // build it is running, because X-Carlos-Version is stamped by the edge // from the route row and goes green the moment adoption moves a symlink — // before, and whether or not, the process running the old binary // restarts. Empty (no probe declared, or a console too old to echo the // field) leaves the header watch exactly as it was. // // Re-checked HERE against the same grammar the create door enforces // (validBuildProbe, apps.go — the byte-identical mirror of the console's // own). Both existing gates are at the WRITE door; this value arrives // from a READ, and the watch CONCATENATES it onto an origin. A probe not // starting with "/" is the shape that matters: "https://" + host + // "@evil.example/x" parses `host` as userinfo and evil.example as the // HOST, so the watch would poll a machine of someone else's choosing and // report ITS build as this deploy's — precisely the failure // validBuildProbe exists to prevent. Only a pre-existing row, a future // migration that forgets, or a hostile console can produce it; the cost // of refusing to depend on that is four lines. Degrade, never fatal: the // ship and promote already landed, so the honest outcome is the header // watch plus a line saying why. probe := shipRes.BuildProbe if probe != "" { if err := validBuildProbe(probe); err != nil { mutedf(stdout, "ignoring the console's build probe: %v — watching the version header instead\n", err) probe = "" } } if staticDeploy { // A static route has no box rollout to follow (no process, no // scheduler), so it keeps the URL watch and its DNS guidance // exactly as before. printWatchNote(probe, true, *app) targets := make([]watchTarget, 0, len(hosts)) for _, h := range hosts { targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h), Probe: probe}) } if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second, deployDiagnostics(ctx, sess, acct, *app)); err != nil { if *hostFlag != "" && isConnectionClassFailure(err) { failDeployDNSGuidance(ctx, sess, acct, *app, *hostFlag) } log.Fatalf("deploy: %v", err) } return } // An instance deploy follows each box's rollout of the build (spec §9.1, // deploywatch.go), and is today's URL watch when no box reports one. if err := watchDeployed(ctx, sess, acct, *app, *ver, channel, hosts, probe, *timeout); err != nil { log.Fatalf("deploy: %v", err) } } const 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" // deployHeldLine is what `carlos deploy` says when the promote it made is // held by the channel's bake window (spec §6.6). The console's held_until, // when present and still ahead of now, is named in the operator's own zone // (R9); otherwise the line is today's, byte for byte. func deployHeldLine(app, channel, heldUntil string, now time.Time) string { if t, err := time.Parse(time.RFC3339, heldUntil); err == nil && t.After(now) { return fmt.Sprintf(deployHeldUntil, app, channel, t.Local().Format("Mon 2 Jan 15:04 MST")) } 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) } // defaultDeployTimeout is how long --timeout watches a URL before failing, // unless the operator says otherwise. It has to outlast one static recheck // window with room to spare, or a static deploy that was always going to land // times out first — TestDefaultDeployTimeoutOutlastsAStaticRecheck pins that, // so lowering this without raising the watch is a test failure, not a // mysterious timeout in production. const defaultDeployTimeout = 120 * time.Second // staticRecheckWindow mirrors internal/edge/static.go's staticRecheck: how // long a static route trusts a resolved channel pointer before re-reading it. // It is stated here rather than imported because it is a property of the EDGE // being watched — possibly an older build than this CLI — so sharing the // constant would promise a match this process cannot know it has. const staticRecheckWindow = 60 * time.Second // deployingLine names the destination a deploy is about to write to, // printed BEFORE ship so every later path — the bake hold, the watch, // even a ship failure — has already said where this deploy was going. // One instance gets its URL; a fleet-sized channel gets a count (the // per-host "live" lines and timeout diagnostics name hosts as they // resolve), and the no-instance path gets nothing: there is no URL to // name, and its own "promoted, nothing to watch" message stays the // honest report. func deployingLine(hosts []string, channel string) string { switch len(hosts) { case 0: return "" case 1: return "deploying https://" + hosts[0] } return fmt.Sprintf("deploying %d instances on %s", len(hosts), channel) } // projectArtifact confines a config-sourced artifact to the config's own // project tree: dsrc is /.carlos/config, and the artifact must // resolve — symlinks included — to somewhere under . Absolute paths // and ..-escapes are refused outright; the wizard path never comes here // (a human typed that answer at this terminal, and it is not committed // input). Returns the root-resolved path the deploy should actually use, // so a saved "./public" works from any subdirectory of the project. func projectArtifact(dsrc, artifact string) (string, error) { if artifact == "" { return "", fmt.Errorf("%s names no artifact", dsrc) } if filepath.IsAbs(artifact) { return "", fmt.Errorf("%s names an absolute artifact (%s) — refusing; a project default must be a path inside the checkout", dsrc, artifact) } root := filepath.Dir(filepath.Dir(dsrc)) resolved := filepath.Join(root, artifact) escapeErr := fmt.Errorf("%s names an artifact outside the project (%s) — refusing; a project default must be a path inside the checkout", dsrc, artifact) rel, err := filepath.Rel(root, resolved) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", escapeErr } // Symlinks are part of the same escape surface: a committed "site" // symlink pointing at $HOME resolves inside the tree lexically and // outside it physically. realRoot, err := filepath.EvalSymlinks(root) if err != nil { return "", err } realArtifact, err := filepath.EvalSymlinks(resolved) if err != nil { return "", fmt.Errorf("artifact %s: %w (the saved default may be stale — edit %s)", artifact, err, dsrc) } rel, err = filepath.Rel(realRoot, realArtifact) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", escapeErr } return resolved, nil } // deployArtifactWizard is first-run zero-arg deploy's one real question: // WHAT ships. A conventional site directory (public/, dist/, _site/) only // ever pre-fills the prompt — the human confirms; detection never decides. // A directory answers static; a regular file answers binary, with the // target prompted (linux-arm64 pre-filled, the fleet's shape). func deployArtifactWizard() (artifact, kind, target string, err error) { suggestion := "" for _, d := range []string{"public", "dist", "_site"} { if fi, err := os.Stat(d); err == nil && fi.IsDir() { if suggestion != "" { suggestion = "" // two candidates: suggest neither break } suggestion = "./" + d } } prompt := "artifact to deploy (binary or site directory): " if suggestion != "" { prompt = fmt.Sprintf("artifact to deploy [%s]: ", suggestion) } for attempt := 0; attempt < 2; attempt++ { ans := promptLine(stdout, prompt) if ans == "" { ans = suggestion } if ans == "" { // An accidental Enter with nothing pre-filled is a miss, not // the end — the retry below asks once more. continue } fi, serr := os.Stat(ans) if serr != nil { fmt.Fprintf(stdout, "%s does not exist\n", ans) continue } if fi.IsDir() { return ans, "static", "", nil } target = promptLine(stdout, "built for (GOOS-GOARCH) [linux-arm64]: ") if target == "" { target = "linux-arm64" } return ans, "binary", target, nil } return "", "", "", errors.New("deploy: nothing to ship — build the project first, then name its binary or site directory") } // offerCreateApp turns defaultApp's no-apps refusal into zero-arg deploy's // onboarding step: claim a name — this directory's, when it is a valid app // name — through the createApp door, or die with the refusal the other // verbs print. Declining is the refusal too: deploy cannot proceed without // an app, and inventing one silently is not on the table. func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noAppsError, region string) string { name := "" if cwd, err := os.Getwd(); err == nil { base := filepath.Base(cwd) if validAppName(base) == nil { name = base } } fmt.Fprintf(stdout, "account %s has no apps yet.\n", na.Account) if name != "" { if !promptYes(stdout, fmt.Sprintf("create %q (this directory's name)? [y/N] ", name)) { log.Fatal(na) } } else { name = promptLine(stdout, "app name to create (lowercase letters, digits, dashes): ") if name == "" { log.Fatal(na) } if verr := validAppName(name); verr != nil { // The typo is the story, not the empty account. log.Fatalf("deploy: %v", verr) } } res, err := sess.createApp(ctx, acct, name, "", false, "", region, detectTimezone()) if err != nil { log.Fatalf("deploy: create app: %v", err) } fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account) printAppRegion(stdout, res, region, false) return res.App } // chooseDeployChannel is the interactive form of "say which with --channel": // the app's verified channels, numbered. ok=false (no terminal answer, or // fewer than two clean channels to choose between) sends the caller back // to the original refusal — the chooser must never invent an option the // flag could not have named. func chooseDeployChannel(ctx context.Context, sess *apiSession, acct apiAcct, app string) (string, bool) { res, err := sess.channels(ctx, acct, app) if err != nil { return "", false } names := make([]string, 0, len(res.Channels)) for _, ch := range res.Channels { if ch.Err == "" { names = append(names, ch.Channel) } } if len(names) < 2 { return "", false } picked, err := chooseOne(chooserIn, stdout, "this app releases onto several channels:", "channel", names, errors.New("no channel chosen")) if err != nil { return "", false } return picked, true } // accountName answers what to CALL acct in a saved config: the memoized // memberships entry's name when the fetch has happened, else the segment // as given — a name resolves on any future run, and a sqid still works. func (s *apiSession) accountName(acct apiAcct) string { for i := range s.accts { if s.accts[i].Sqid == acct.Seg || s.accts[i].Name == acct.Seg { return s.accts[i].Name } } return acct.Seg } // deployTargets decides what this deploy promotes to and what it then watches, // and reports whether it took the static path. It is a function, not a stretch // of runDeploy, so the decision can be tested without a console: `channels` is // called only where a resolution actually needs it (a lazy closure over // apiSession.channels in the verb; a stub in tests) — the static path's own // channel listing, as before, plus (release pipelines v2, Task 11) the // no-instances case on EITHER path, to ask whether the app has declared a // pipeline whose entry channel can stand in for a resolution that would // otherwise fail. // // Zero instance records mean different things per kind, and the split matters: // - static: there are no records BY DESIGN — the edge serves the route off // the channel pointer and nothing is ever adopted — so host and channel // have to come from --host and --channel/the app's channels (issue #112). // - binary: the records simply aren't there yet, which is the pre-existing // "no instance follows this channel — promoted, nothing to watch" shape. // It keeps its old behaviour verbatim: this branch must not demand --host // from a binary app, nor tell it about static recheck windows. // // "No records" is judged KIND-AWARE, not by len(recs) == 0: a first-route // domain attach (#82) writes a Kind=="static" provision record, so the very // first `deploy --kind static --host ` leaves a record behind and every // redeploy after it would otherwise fall off the static path — losing the // recheck note and the DNS-guidance exit, and refusing a SECOND static // host with "no instance with host". Static records ARE static-deploy // targets; only a real instance/service record pulls a --kind static deploy // onto the instance path. func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string, channels func() (apiChannelsResult, error)) (channel string, hosts []string, static bool, err error) { if kind == "static" && staticOnlyRecords(recs) { // Hosts and channel are resolved INDEPENDENTLY, because the mint // path needs the channel precisely when the host is missing: the // record this deploy is about to create has to name a channel, and // an omitted one is defaulted to stable by the door // (internal/console/apiinstances.go) — a route that serves nothing, // with no door to change it afterwards. // // So errNoStaticHost travels WITH a usable channel: the one place // this function returns both a value and an error, and the reason // the two resolutions are no longer nested. hosts, herr := staticDeployHosts(hostFlag, recs) channel, cerr := staticChannel(channelFlag, hostFlag, recs, channels) if cerr != nil { // Both unresolved: the host refusal is the one that names // something the member can actually supply, so it wins. if herr != nil { return "", nil, true, herr } return "", nil, true, cerr } return channel, hosts, true, herr } if channelFlag != "" { // Explicit channel: no ambiguity to resolve. Watch the instances that // follow it (none is fine — promote still lands; the watch is skipped // with a note, since there is nothing that will serve this channel). // --host is still validated against the instance list even here: a // typo'd host must be its own error, not silently absorbed into "no // instance follows this channel" once ship and promote have already // run (finding 3, task-9 review). hosts, err := filterChannelHosts(recs, channelFlag, hostFlag) if err != nil { return "", nil, false, err } return channelFlag, hosts, false, nil } // The no-instances case is the only one a pipeline can rescue: every // other refusal resolveDeployChannel can return (several channels among // existing instances, an unknown --host) is a genuine ambiguity or typo a // pipeline's entry channel has no business papering over. So the // channels round trip only happens here, once, and only when there is // nothing else to resolve from. pipelineEntry := "" if len(recs) == 0 { if res, cerr := channels(); cerr == nil { pipelineEntry = res.PipelineEntry } } channel, hosts, err = resolveDeployChannel(recs, hostFlag, pipelineEntry) return channel, hosts, false, err } // staticOnlyRecords reports whether recs holds nothing but Kind=="static" // records — the condition under which a --kind static deploy stays on the // static path. Zero records is trivially true (the original no-records // static shape, issue #112); any instance/service record pulls the deploy // onto the instance path exactly as before this predicate existed. func staticOnlyRecords(recs []apiInstanceRecord) bool { for _, r := range recs { if r.Kind != "static" { return false } } return true } // staticRecordChannel resolves a static-path deploy's channel from the // records themselves — the record a first-route attach wrote already knows // what its domain follows, so a redeploy needs no --channel and no // channels-listing round trip. With --host, it is that host's record's // channel; without, it is the single distinct non-empty channel the records // agree on — the no-host redeploy is watching THOSE hosts, so their own // channel is the only one that keeps the promote and the watch pointed at // the same thing (a channels listing that happened to hold one OTHER // channel would promote onto it while watching hosts following another). // "" — host unrecorded, records disagreeing, or nothing recorded — sends // the caller to the channels listing (and its refusal names --channel). func staticRecordChannel(recs []apiInstanceRecord, host string) string { if host != "" { for _, r := range recs { if r.Host == host { return r.Channel } } return "" } channel := "" for _, r := range recs { if r.Channel == "" { continue } if channel == "" { channel = r.Channel } else if r.Channel != channel { return "" // disagreeing records: a human picks, via --channel } } return channel } // staticDeployHosts resolves the hosts a static-path deploy watches. --host // names one explicitly (an unrecorded host is fine — it is the SECOND // static domain, about to be attached on the way through). Without the // flag, the app's own static records are the next best source: each one // names a host this app serves, written by an earlier deploy's attach. Only // an app with neither — no flag, no records — has nothing to resolve a host // from, and that absence is refused before ship and promote run rather than // after, when the only remaining honest report would be "promoted, nothing // to watch". func staticDeployHosts(hostFlag string, recs []apiInstanceRecord) ([]string, error) { if hostFlag != "" { return []string{hostFlag}, nil } var hosts []string for _, r := range recs { // Static records only, so the helper holds its own invariant // rather than trusting the caller's gate: an instance record's // host is adopted and watched via the instance path, never here. if r.Kind != "static" { continue } hosts = append(hosts, r.Host) } if len(hosts) > 0 { return hosts, nil } return nil, fmt.Errorf("%w: this app has no instance records — a static site is served by the edge straight from its\n"+ "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n"+ "Name the hostname the site answers on, so the deploy can watch it:\n"+ " carlos deploy --app --kind static --host carlosframework.com ", errNoStaticHost) } // staticChannel resolves the channel a static deploy promotes onto, in the // order the flag, the record and the app's channels earn it. Split out of // deployTargets' static branch so the MINT path can resolve a channel on the // very path where the host resolution fails — the two answers are // independent, and only their nesting ever made them look otherwise. func staticChannel(channelFlag, hostFlag string, recs []apiInstanceRecord, channels func() (apiChannelsResult, error)) (string, error) { if channelFlag != "" { return channelFlag, nil } // A static record that names the deploy's own --host already knows its // channel — the record the first deploy's attach wrote. Same convenience // the instance path has always had (resolveDeployChannel resolves from // the --host record); a host with no record yet — the SECOND static // domain — falls through to the channels listing. if ch := staticRecordChannel(recs, hostFlag); ch != "" { return ch, nil } res, err := channels() if err != nil { return "", fmt.Errorf("list channels: %w", err) } // No channels at all AND a declared pipeline: this is the pipelines // spec's entry channel doing its job — a brand-new one-channel app's // FIRST deploy just lands on it, rather than being refused for a channel // that (being pipelined) was never going to be created any other way. A // pipeline whose channels have ALREADY been promoted onto is not this // case — staticDeployChannel resolves it exactly as before, unambiguous // or not. if len(res.Channels) == 0 && res.PipelineEntry != "" { return res.PipelineEntry, nil } return staticDeployChannel(res.Channels) } // staticDeployChannel picks the channel a NO-INSTANCE deploy promotes to, // from the app's own channels — the only record of what this app releases // onto once instance records are off the table. One channel is unambiguous; // zero and several are both for a human to settle with --channel, in // resolveDeployChannel's style: name what was found, name the flag that // breaks the tie, guess at nothing. // // A channel row can also carry an Err: the console's channels endpoint emits // {"channel": name, "error": ...} for a pointer it could not verify. Reading // only the name would resolve to — and then promote onto — a channel whose // pointer is unreadable or unsigned, so the one-channel case refuses on it. func staticDeployChannel(chans []apiChannel) (string, error) { names := make([]string, 0, len(chans)) for _, ch := range chans { names = append(names, ch.Channel) } if len(names) == 1 { if chans[0].Err != "" { return "", fmt.Errorf("this app's only channel %s is unreadable (%s) — resolving to it would promote onto "+ "a pointer the console cannot verify; fix it, or name a channel with --channel", chans[0].Channel, chans[0].Err) } return names[0], nil } if len(names) == 0 { return "", errors.New("this app has no channels yet, and no instances to resolve one from — " + "pass --channel to name the channel to promote onto") } sort.Strings(names) return "", fmt.Errorf("%w: this app has several channels and no instances to resolve one from — "+ "pass --channel to pick one: %s", errAmbiguousChannel, strings.Join(names, ", ")) } // errAmbiguousChannel marks the two refusals that mean "several channels, // pick one" — the ONLY refusals the interactive chooser may answer for. // Matching on error text once turned the no-instances refusal (which merely // MENTIONS --channel) into a ship+promote after an unrelated question // (review 2026-08-13, finding 2). var errAmbiguousChannel = errors.New("ambiguous channel") // errNoStaticHost marks the refusal "this static app has nothing serving it // yet". Typed for errAmbiguousChannel's reason, and the stakes are the same // shape: runDeploy answers THIS refusal by minting the app's platform host // (static-platform-host design §3.2), and a refusal matched by text would // eventually mint a hostname in answer to a different sentence that merely // mentioned one. var errNoStaticHost = errors.New("no static host") // mintPlatformHost gives an app its platform hostname — // .. — and the static route record that serves it. // This is the whole of what #186's static half was missing: DNS already // resolves every name under the apps domain to the edge, and the edge // derives its own per-account wildcard cert from the live route table, so a // record is the only thing standing between a fresh static app and a URL. // // Two existing doors, in order, and no new authority anywhere. The manifest // PUT is an idempotent upsert — it CREATES when absent and otherwise // preserves every field, since nil channel and nil seed both mean "leave it" // — and it answers with the domain the console pins to the account, so this // client never has to know the deployment's apps domain to build the host. // // Then the record, carrying the kind and — still explicit, though the door // no longer needs it to be — the channel this deploy resolved. The door used // to default an omitted channel to the manifest's and then to "stable" while // a fresh app's pipeline entry was "edge", so the record followed a channel // nothing ever promotes to; repairing one took a raw bucket write (hello.bdn, // 2026-08-22) and hitting it cost a first deploy a passkey (amadan #5). The // door now resolves the entry channel itself (instanceChannel). Passing it // stays right regardless: this caller has already resolved the channel it is // about to promote to, and a record minted on a different one would serve // something else. func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string, messages ...string) (string, error) { m, err := sess.instancesEnable(ctx, acct, app, nil, nil, nil) if err != nil { return "", err } // No environment: the app's apex serves its default config bundle, which // is exactly right — this is the app's primary host, the one every other // bundle's origin values are written to name. rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", "", nil, nil, nil, firstMutationMessage(messages)) if err != nil { return "", err } fmt.Fprintf(stdout, "%s %s (static, channel %s)\n", colorFor(stdout).ok("minted"), rec.Host, rec.Channel) return rec.Host, nil } // attachUnclaimedHost auto-attaches --host before shipping, when this deploy // is targeting a fresh static host with no route of its own yet (self-serve // custom domains, #82/#151): the app's first deploy to a brand-new hostname // should not need a separate `carlos domains attach` first. recs is the // SAME instance list runDeploy already fetched for deployTargets — checked // again here rather than assumed empty, so this stays correct even if a // future caller ever reaches it with records present. // // domainList is read BEFORE any attach POST, and its answer is decisive: // re-attaching a host this app already holds is a harmless idempotent 200 // on the merged door (same-owner converge — #151's claim-writer contract), // but it is still a write request this deploy has no business sending: the // list already proves there is nothing to do, and only a host held by a // DIFFERENT owner answers 409 "hostname unavailable". // // Since the kind-aware static gate, this runs on REDEPLOYS too (records // present), not just the first deploy — so a 401/403 on the GET aborting // the whole deploy is a question worth answering: the domains door sits // behind the SAME instanceGate as the instanceList runDeploy has already // passed, so a token that got this far can list domains too, and a 401/403 // here means the credential genuinely lost authority mid-deploy — worth // stopping for, not degrading past. func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, app, host, kind, channel string, recs []apiInstanceRecord, messages ...string) { domains, err := sess.domainList(ctx, acct, app) if err != nil { switch { case domainsDoorMissing(err): // A console that predates the /domains door (Task 8 wires the // real one; not every box has adopted it yet). Silence is only // honest when nothing was going to be attached anyway — an // instance already answering this host means so, regardless of // the door. A genuinely unclaimed host (no instance, and now no // way to check domainList either) gets a note instead of a // silent skip: without the door this deploy cannot attach it, // and a member watching "deploying https://" scroll by // with no explanation would have no idea DNS was never even // checked. if !hostHasInstance(host, recs) { mutedf(stdout, "this console has no domains door yet — attach %s to %s in the console before "+ "DNS can be checked; continuing without auto-attach\n", host, app) } return case domainListDegradable(err): // A transport failure or 5xx: the door exists but is unwell // right now, not a reason to abort a deploy that would // otherwise succeed. 401/403 do NOT take this branch — those // mean the credential itself lacks authority, which is worth // stopping for. mutedf(stdout, "could not check custom domains for %s (%v) — continuing without auto-attach\n", host, err) return default: log.Fatalf("deploy: list domains: %v", err) } } if !domainNeedsAttach(host, recs, domains) { return } // kind travels only when it is "static" — the one shape a first-route // attach accepts (attachDomain, internal/console/domainattach.go); empty // leaves the field out of the wire body entirely rather than sending a // value the server would refuse. attachUnclaimedHost is only ever // reached with kind == "static" (runDeploy gates the call on // staticDeploy, which deployTargets only sets true for that kind), so // this collapses to "static" in practice — kept as one variable rather // than a second hardcoded literal in the printed line below, so the two // can never drift apart and say different things. attachKind := "" if kind == "static" { attachKind = kind } // The door, not this call's own request, decides alias-vs-first-route // (spec §4's "one deliberate asymmetry"): resolvedRoute is the host // itself on a genuine first-route attach, or a DIFFERENT route when the // app already had one ready and the door aliased onto it instead — // which can happen even though attachKind asked for "static", since a // ready route always wins over minting a second one (§1's yes/no // split lives door-side). Printing attachKind/channel unconditionally // here would claim a first route was minted when the door actually // aliased, so the two cases get two different lines, and the alias one // never names a channel — an alias serves the primary's channel, not // one of its own. res, err := sess.domainAttachFull(ctx, acct, app, host, "", attachKind, channel, nil, nil, firstMutationMessage(messages)) if err != nil { log.Fatalf("deploy: attach %s: %v", host, err) } resolvedRoute := res.Route if !res.Verified { // Stored, not served (#215): the deploy carries on (the release // still ships to the app's own hosts), and says plainly that this // hostname waits on a record only the member can create. Re-running // the deploy repeats the attach, which is what verifies it. fmt.Fprintf(stdout, "%s %s, waiting for you to prove you control it\n", colorFor(stdout).warn("reserved"), host) if res.Verify != nil && res.Verify.Name != "" { fmt.Fprintf(stdout, "Add: TXT %s -> %s\n", res.Verify.Name, res.Verify.Value) } fmt.Fprintln(stdout, "Then: re-run this deploy, or carlos domains verify --app "+app+" "+host) return } if resolvedRoute == host { fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel) } else { fmt.Fprintf(stdout, "%s %s (aliases %s)\n", colorFor(stdout).ok("attached"), host, resolvedRoute) } } // hostHasInstance reports whether host is already answered by one of the // app's instance records — the one fact attachUnclaimedHost can still trust // even when domainList itself cannot be read (the door is missing, or // unwell): an instance already serving this host never needed an attach in // the first place, regardless of what domainList would have said. func hostHasInstance(host string, recs []apiInstanceRecord) bool { for _, r := range recs { if r.Host == host { return true } } return false } // domainNeedsAttach reports whether host is a fresh target this deploy must // attach before shipping: present in NEITHER the app's instance records (an // existing instance already answers it) NOR its already-claimed domains (an // earlier deploy's attach, or a manual `carlos domains attach`). Either // match means false. The merged door would answer a same-owner re-attach // with an idempotent 200 (not the 409 an EARLIER contract draft promised), // so skipping here is POST-avoidance, not failure-avoidance: the list // already said there is nothing to do, and a repeated `carlos deploy` should // send zero attach writes, not one no-op per run. func domainNeedsAttach(host string, recs []apiInstanceRecord, domains []apiDomainRecord) bool { if hostHasInstance(host, recs) { return false } for _, d := range domains { if d.Host == host { // A claim still waiting for proof of control (#215) is attached // again: repeating the attach is what verifies it once the TXT // record exists, so a re-run deploy finishes the job. return !domainVerified(d) } } return true } // domainsDoorMissing reports whether err is a domainList 404 — the shape // apiErrorFromBody gives ANY 404 whose body carries no JSON "error" field, // which is exactly what an unregistered route (a console that predates the // /domains door, Task 8) answers. By the time attachUnclaimedHost calls // domainList, runDeploy has already listed this exact account+app's // instances successfully — so "unknown account/app" is already ruled out, // and a domainList 404 here can only mean the door itself does not exist // yet. func domainsDoorMissing(err error) bool { var dle *domainListError return errors.As(err, &dle) && dle.status == http.StatusNotFound } // domainListDegradable reports whether a domainList failure should stand // auto-attach down with a warning rather than aborting the whole deploy: a // transport-level failure (status 0 — the request never got an HTTP // response at all) or any 5xx. 401/403 are deliberately excluded — those // mean the credential itself lacks authority to see this app's domains, // which is worth stopping the deploy for, not silently continuing past. An // error that isn't even a *domainListError (never expected from // domainList, but defensive) is treated as NOT degradable, so an unknown // shape stays fatal rather than silently swallowed. func domainListDegradable(err error) bool { var dle *domainListError if !errors.As(err, &dle) { return false } return dle.status == 0 || (dle.status >= 500 && dle.status < 600) } // isConnectionClassFailure reports whether a watchVersion timeout's error // text names a target that never even answered — the "unreachable: ..." // diag watchVersion emits when http.Client.Get itself failed (DNS // resolution, connection refused, TLS handshake: every shape a domain whose // DNS is not yet pointing here produces), as opposed to a reachable origin // that simply isn't serving the right version yet. watchVersion reports one // combined string, not a structured per-host reason, so a substring check is // what there is to go on here — precise enough because deploy only ever // reaches this with --host naming the single target that timed out. func isConnectionClassFailure(err error) bool { return err != nil && strings.Contains(err.Error(), "unreachable:") } // dnsGuidanceMessage decides the honest, spec'd exit text for a // connection-class watch timeout, from the app's domainList entry for // host — or reports ok=false when neither arm applies, telling the caller // to fall back to the plain watchVersion timeout report instead of // guessing. Two arms, keyed off domainList's own DNS verdict for the host: // // - DNSStatus == "pointing": DNS is right, so a connection-class failure // means something else is missing — the certificate, almost always, // since first-route attach mints the record before ACME has had a chance // to run. Says so, and to re-run SHORTLY (this should resolve itself). // - DNSStatus != "pointing" (elsewhere, or never checked) WITH guidance: // the DNS-not-pointing report, the console's own Create: text verbatim. // // When the door sends a derived state (#299) it is used instead: it names // "the certificate is coming" directly, where the DNSStatus arms below could // only infer it. The arms below remain for consoles that predate state. // // A host absent from domains, present with DNSStatus != "pointing" and no // guidance yet (checked, but the console has nothing to say), or stamped // "unresolved" (OUR lookup failed — not an answer about the member's // record, so "Create: ..." would blame them for our outage), is // deliberately ok=false — a guessed-at guidance line this call cannot back // up would be worse than the generic timeout report already in hand. func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcct) (msg string, ok bool) { for _, d := range domains { if d.Host != host { continue } // The door's derived state, when it sends one (#299). It says // directly what the DNS columns could only be reverse-engineered // into: "issuing-cert" is DNS-is-right-cert-is-coming, which the // old DNSStatus check could not distinguish from "pointing but // something else is broken". Older consoles send no state and fall // through to the DNSStatus arms below, unchanged. switch { case d.State == stateUnverified: // Not served until proven (#215), whatever DNS says. The door's // records carry the TXT that proves it beside the pointing ones. msg := fmt.Sprintf("%s is not served yet: prove you control it first.", host) if recs := formatDomainRecords(d.Records); recs != "" { msg += " " + recs } 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 case d.State == stateIssuingCert: return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.", host, app, acct.Seg), true case d.State == stateFailed: // DNS is right and the certificate is not — and that is ours to // fix, not theirs. The door already derived the honest sentence, // so it is repeated verbatim rather than paraphrased into a // second story about the same hostname. No "Create:" line: their // records are correct, and telling them otherwise would send // them to change the one thing that is working. msg := fmt.Sprintf("DNS for %s is pointing here, but we have not been able to obtain a certificate for it.", host) if d.Detail != "" { msg = fmt.Sprintf("DNS for %s is pointing here. %s", host, d.Detail) } return msg + fmt.Sprintf(" Watch with 'carlos domains list --app %s --account %s' — the shipped release is already promoted.", app, acct.Seg), true case d.State == stateLive: // DNS and certificate are both fine, so a connection failure is // something else entirely and this function has nothing honest // to add. return "", false case d.Reason == "lookup-failed": // OUR resolver failed. Not an answer about the member's record, // so "Create: ..." would blame them for our outage. return "", false case d.State == statePendingDNS || d.State == stateMisdirected || d.State == stateChecking: // checking belongs HERE, not with the silent cases: a domain // attached seconds ago has genuinely not been looked at by any // box yet, and the records to create are exactly what its member // needs. Silence would be a regression against the pre-state // behaviour, which offered guidance whenever DNS was not // confirmed pointing. if d.Guidance == "" && len(d.Records) == 0 { return "", false } msg := fmt.Sprintf("DNS for %s is not pointing at this deployment yet.", host) if d.Guidance != "" { msg += " Create: " + d.Guidance } else if recs := formatDomainRecords(d.Records); recs != "" { msg += " " + recs } return msg + fmt.Sprintf(" Watch with 'carlos domains list --app %s --account %s'; "+ "re-run this deploy any time — the shipped release is already promoted.", app, acct.Seg), true case d.State != "": // A state this build does not interpret (a newer console). return "", false } switch { case d.DNSStatus == "pointing": return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.", host, app, acct.Seg), true case d.DNSStatus == "unresolved": return "", false case d.DNSStatus != "pointing" && d.Guidance != "": return fmt.Sprintf("DNS for %s is not pointing at this deployment yet. Create: %s. "+ "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy any time — the shipped release is already promoted.", host, d.Guidance, app, acct.Seg), true default: return "", false } } return "", false } // failDeployDNSGuidance turns a connection-class watch timeout into the // honest report the spec asks for: the deploy itself already succeeded // (shipped and promoted), so the exit text says what's still missing and // how to fix it, never "deploy failed". It still exits nonzero (log.Fatal, // same as every other refusal in this file): the URL does not yet serve the // release, and this must never read as success. Falls through silently // (letting the caller's own generic watch-error message stand) when // domainList itself cannot be read, or dnsGuidanceMessage finds neither arm // applies. func failDeployDNSGuidance(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) { domains, err := sess.domainList(ctx, acct, app) if err != nil { return } if msg, ok := dnsGuidanceMessage(domains, host, app, acct); ok { log.Fatal(msg) } } // requireDeployAPISession resolves signed-in mode for deploy, refusing // direct-bucket mode with deploy's OWN message — not instances_cli.go's // requireAPISession, whose hardcoded text ("carlos instances %s works // through a console... use carlos add instead") names a sub-verb of a // different command and advice about routes, neither of which applies to // deploy (task-9 review, finding 2). Unlike ship/promote, deploy has no // direct-bucket body at all — it is pure console composition — so the // refusal also has nowhere to fall back to but naming the two verbs that DO // still work in that mode. func requireDeployAPISession(consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("carlos deploy works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). deploy is a member verb with no\n" + "direct-bucket equivalent; unset the store env and run: carlos auth login --console ,\n" + "or use carlos ship + carlos promote, which both still work in this mode.") } return sess } // deployPreflight refuses the one combination that produces a release whose // name is a lie: a version DERIVED from git (the --version default) taken // from a tree with uncommitted changes. The sha then names a commit the // artifact was not built from, and every later "which build is this" answer — // including the honest ones — inherits the lie. // // An explicit --version is untouched: that is the operator naming the release // themselves, and second-guessing it would break every workflow that versions // by something other than a sha. versionWasSet is runDeploy's flagWasSet // answer (flags.go) — "the operator typed the flag", alias-aware — rather // than the value itself, which the default-fill has always populated by the // time anything can ask. func deployPreflight(dir string, versionWasSet bool) error { if versionWasSet { return nil } // Named in the refusal below so an operator shipping a prebuilt artifact // from an unrelated dirty checkout knows which tree tripped it, rather // than guessing from a bare "-C .". named := dir if abs, err := filepath.Abs(dir); err == nil { named = abs } out, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output() if err != nil { return nil // not a git checkout, or no git: nothing to be dirty } if len(bytes.TrimSpace(out)) == 0 { return nil } return fmt.Errorf("%s has uncommitted changes and --version defaults to the git short sha, "+ "which would name a commit this build does not match — commit, or pass --version explicitly", named) } // gitShortHead resolves --version's default: the working directory's short // git SHA. Fatal here (rather than a returned error) matches every other // "this environment is wrong" refusal in this binary — there is no sane // fallback value, only the operator's own --version. func gitShortHead() string { out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output() if err != nil { log.Fatal("not in a git repository — pass --version") } return strings.TrimSpace(string(out)) } // resolveDeployChannel picks the channel `carlos deploy` promotes to, and the // hosts it then watches, from the app's own instance records (spec §6 step // 2). --host (hostFlag) narrows to one instance's own channel with nothing // left to resolve. With no host, every instance must already agree on one // channel — several distinct channels is treated as ambiguity for a human to // break with --channel or --host, not something this verb should guess at. // // pipelineEntry is deployTargets' pipeline-entry fallback (release pipelines // v2, Task 11): the zero-records case used to be an unconditional refusal — // it still is for a legacy app (pipelineEntry == "") — but a pipelined app's // entry channel is exactly where spec §2 says a fresh deploy lands, so a // brand-new one-channel app's first `carlos deploy` just works, no // --channel, no `carlos instances create` first. hosts stays nil either way: // there is nothing to watch until something is adopted onto the entry // channel. func resolveDeployChannel(recs []apiInstanceRecord, hostFlag, pipelineEntry string) (channel string, hosts []string, err error) { if len(recs) == 0 { if pipelineEntry != "" { return pipelineEntry, nil, nil } return "", nil, errors.New("no instances — create one: carlos instances create --app --host --channel ") } if hostFlag != "" { for _, r := range recs { if r.Host == hostFlag { return r.Channel, []string{r.Host}, nil } } return "", nil, fmt.Errorf("no instance with host %q", hostFlag) } byChannel := map[string][]string{} var order []string for _, r := range recs { if _, ok := byChannel[r.Channel]; !ok { order = append(order, r.Channel) } byChannel[r.Channel] = append(byChannel[r.Channel], r.Host) hosts = append(hosts, r.Host) } if len(byChannel) == 1 { return order[0], hosts, nil } sort.Strings(order) var b strings.Builder b.WriteString("instances follow different channels — pass --channel or --host to pick one: ") for i, ch := range order { if i > 0 { b.WriteString("; ") } fmt.Fprintf(&b, "%s: %s", ch, strings.Join(byChannel[ch], ", ")) } return "", nil, fmt.Errorf("%w: %s", errAmbiguousChannel, b.String()) } // filterChannelHosts computes the hosts an EXPLICIT --channel deploy should // watch: instance records whose Channel matches, further scoped to hostFlag // if given. hostFlag is validated against the instance list up front — // independent of channel — so a typo'd --host is its own error before ship // and promote ever run, rather than silently producing zero hosts and // surfacing as "no instance follows " once the writes have already // landed (task-9 review, finding 3). This mirrors resolveDeployChannel's own // host-existence check on the non-explicit-channel branch: an existing host // that simply doesn't follow the requested channel is not an error here // (the caller may be deploying a channel that host has never served before) // — only a host absent from the instance list entirely is. func filterChannelHosts(recs []apiInstanceRecord, channel, hostFlag string) ([]string, error) { if hostFlag != "" { found := false for _, r := range recs { if r.Host == hostFlag { found = true break } } if !found { return nil, fmt.Errorf("no instance with host %q", hostFlag) } } var hosts []string for _, r := range recs { if r.Channel == channel && (hostFlag == "" || r.Host == hostFlag) { hosts = append(hosts, r.Host) } } return hosts, nil } // watchTarget is one instance to watch: Host is what gets printed (the // public hostname the operator will actually visit) and Origin is what gets // dialed (https:// in production; a test's httptest.Server URL in // unit tests, which is never the same string as Host). // // Probe is the app's own build-report path (`/healthz`, say), empty for every // app that declares none. It exists because X-Carlos-Version is stamped by // the EDGE from the route row, so it reports what adoption INTENDED from the // moment the symlink moves — whether or not any process restarted. For a // long-running unit (the console above all) that header is not an exit // criterion at all: it goes green while the old binary is still serving. A // probe path is answered by the app's OWN process, so its body is the only // honest answer to "which build is running here". type watchTarget struct { Host, Origin string Probe string } // watchOrigin maps an instance host to the origin the watch dials. In // production this is always https:// — the public URL the operator // will visit, with no scheme or port to configure, because that is the only // thing an instance record's host ever means. // // It is a var solely so the deploy e2e (deploy_e2e_test.go) can point the // watch leg at a loopback httptest stand-in for the edge: a test server // lives at 127.0.0.1:, which is never a routable instance hostname, // so without this seam the composed verb could only be tested up to the // promote. Nothing in the binary reassigns it, and nothing should: an // operator-configurable scheme here would be a way to deploy while watching // something other than the URL people actually visit. var watchOrigin = func(host string) string { return "https://" + host } // watchVersion polls each target's origin until it reports ver — the deploy // verb's exit criterion (spec §6 step 5, §7). A target with no Probe is // watched exactly as it always was: GET / and match X-Carlos-Version. A // target that declares one is watched by GETting that path and matching ver // as a whole whitespace-delimited token in the BODY, because the app's own // process answers there and the edge's header does not (see watchTarget). // Every poll appends a query parameter that changes from the previous poll // (?carlos-deploy-probe=) to the URL it dials. This is not // decoration: the edge caches a proxied response, header included, for up to // 5 minutes (internal/edge/edge.go, versionHeader) — longer than this // function's own 120s default timeout — so an un-cache-busted watch could // poll one stale cached hit for the whole timeout and report a healthy // deploy as failed. The attempt counter is sufficient (no randomness // needed): each request just needs to differ from the ones before it. // watchDiagnostics enriches watchVersion's timeout report (issue #131's // tail): wakeReason answers "why is this host down" from the platform log // stream — the fact that used to be journal-only — and logsHint names the // command that shows the member the whole timeline. Nil (store-mode deploys, // tests that aren't about the report) changes nothing. type watchDiagnostics struct { wakeReason func(host string) string // last platform-stream error for host; "" = none found logsHint string // the follow-up command, in the member's own flags } func watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, interval time.Duration, diag *watchDiagnostics) error { // pending is a slice, not a map, so target order stays stable across // sweeps — the goroutines below write into an indexed result slice keyed // off this same order, and the "live" lines print in that order once the // whole sweep has finished, never interleaved mid-flight. A map would // have de-duped repeated hosts for free, so do that explicitly here, // keeping each host's first occurrence — otherwise a duplicate host gets // polled (and printed "live") twice per sweep. seen := make(map[string]bool, len(targets)) pending := make([]watchTarget, 0, len(targets)) for _, t := range targets { if seen[t.Host] { continue } seen[t.Host] = true pending = append(pending, t) } last := make(map[string]string, len(targets)) client := &http.Client{Timeout: 10 * time.Second} deadline := time.Now().Add(timeout) var attempt atomic.Int64 // per-sweep-and-target: every goroutine gets a distinct probe value for { type result struct { live bool diag string } results := make([]result, len(pending)) var wg sync.WaitGroup for i, t := range pending { wg.Add(1) go func(i int, t watchTarget) { defer wg.Done() live, d := checkTarget(client, t, ver, attempt.Add(1)) results[i] = result{live: live, diag: d} }(i, t) } wg.Wait() var stillPending []watchTarget for i, r := range results { host := pending[i].Host if r.live { fmt.Fprintf(w, "%s https://%s\n", colorFor(w).ok("live"), host) continue } last[host] = r.diag stillPending = append(stillPending, pending[i]) } pending = stillPending if len(pending) == 0 { return nil } if time.Now().After(deadline) { hosts := make([]string, 0, len(pending)) for _, t := range pending { hosts = append(hosts, t.Host) } sort.Strings(hosts) var b strings.Builder fmt.Fprintf(&b, "timed out waiting for %s to serve %s:", strings.Join(hosts, ", "), ver) for _, h := range hosts { fmt.Fprintf(&b, "\n %s: %s", h, last[h]) // The reason a wake failed used to be journal-only (#131); // now the platform log stream carries it member-safe, so a // timeout report can SAY it instead of guessing at causes. if diag != nil && diag.wakeReason != nil { if r := diag.wakeReason(h); r != "" { fmt.Fprintf(&b, "\n %s", r) } } } if diag != nil && diag.logsHint != "" { fmt.Fprintf(&b, "\nfull timeline: %s", diag.logsHint) } return errors.New(b.String()) } time.Sleep(interval) } } // checkTarget is one poll of one target: watchVersion's per-target body, // lifted out so the rollout watch (deploywatch.go) polls a host its box does // not report on exactly as this watch always has. n is the poll's // cache-buster, distinct for every poll of the watch. func checkTarget(client *http.Client, t watchTarget, ver string, n int64) (live bool, diag string) { // The cache-buster rides BOTH legs. The edge caches a proxied // 2xx — body included — for up to 5 minutes, longer than this // function's own default timeout, so an un-busted probe poll // could sit on one stale hit for the whole watch and report a // healthy deploy as failed. path := "/" if t.Probe != "" { path = t.Probe } url := fmt.Sprintf("%s%s?carlos-deploy-probe=%d", t.Origin, path, n) resp, err := client.Get(url) if err != nil { return false, "unreachable: " + err.Error() } if t.Probe != "" { // The app's own process answers here, so the body IS the // truth — no header/route-row disagreement to reconcile // the way the header path has to. Read a bounded amount: // this is a health endpoint, and a target that answers // with a stream is misconfigured, not something to buffer. b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() if rerr != nil { return false, "probe unreadable: " + rerr.Error() } got := strings.TrimSpace(string(b)) if resp.StatusCode/100 != 2 { // Same rule as the header leg's 502 case: the right // build in a crash loop is not a live deploy. return false, fmt.Sprintf("%s answered HTTP %d: %q", path, resp.StatusCode, got) } // Whole-token, never substring: abc123 must not satisfy a // watch for abc1234, and short git shas collide like that // in practice. for _, f := range strings.Fields(got) { if f == ver { return true, "" } } // Naming the body is what makes the ldflags trap // self-diagnosing: a binary linked without // -X …/internal/version.Build reports "dev" forever, so a // perfectly healthy roll fails its watch — and "timed out" // would send the operator hunting the wrong thing, while // `reports "ok dev", waiting for abc1234` says what to fix. return false, fmt.Sprintf("%s reports %q, waiting for %s", path, got, ver) } io.CopyN(io.Discard, resp.Body, 4096) resp.Body.Close() got := resp.Header.Get("X-Carlos-Version") switch { case got == ver && resp.StatusCode/100 == 2: return true, "" case got == ver: // The edge stamps X-Carlos-Version before proxying to the // origin, so a tenant that dies on boot can still answer // with the new version header attached to a non-2xx (a 502, // most commonly). That is the new build in a crash loop, not // a live deploy, so the success arm has to gate on status // too — matching the header alone let a dead tenant exit 0. return false, fmt.Sprintf("serving %s but HTTP %d — the app is failing", ver, resp.StatusCode) case got == "" && resp.StatusCode/100 != 2: // A non-2xx with no header is not an old edge — it is an edge // that answered and had nothing good to serve: an activator // 503 because the wake failed or timed out, or a 404 because // the route has not been minted yet. (A 502 from a tenant // that died on boot carries the version header — the edge // stamps it before proxying — so that shape lands in the // case above, not here.) Reporting those as "edge predates // this feature — the hourly pass will adopt it" sends the // operator off to wait for a pass that will not help (review // finding 2): the status code is the diagnostic, so print it. return false, fmt.Sprintf("not serving (HTTP %d) — instance failing to wake, or route not live yet", resp.StatusCode) case got == "": // 2xx and no header: something IS being served here, it just // isn't stamped — the only shape that really is an edge from // before X-Carlos-Version existed. return false, "no X-Carlos-Version header (edge predates this feature — the hourly pass will adopt it)" default: return false, "still serving " + got } } // The domain lifecycle states the door derives (#299 §2a). Mirrored here as // constants rather than compared as string literals so a typo is a build // error; the door is the authority on the vocabulary and publishes it in the // list answer's capability block. const ( statePendingDNS = "pending-dns" stateMisdirected = "misdirected" stateIssuingCert = "issuing-cert" stateFailed = "failed" stateLive = "live" stateChecking = "checking" stateUnknown = "unknown" stateUnverified = "unverified" ) // formatDomainRecords renders the door's structured records as one short // line for a deploy exit. Alternatives are joined with "or" — a member who // created both a CNAME and A records for one name would publish an illegal // pair, which is exactly what the set marker exists to prevent. func formatDomainRecords(recs []apiDomainDNSRecord) string { if len(recs) == 0 { return "" } var order []string groups := map[string][]string{} // Required records are needed whichever alternative is picked (the // ACME delegation, the #215 ownership TXT), so they are listed after // the alternatives rather than offered as one of them. var required []string for _, r := range recs { line := fmt.Sprintf("%s %s -> %s", r.Type, r.Name, r.Value) if r.Required { required = append(required, line) continue } if _, seen := groups[r.Set]; !seen { order = append(order, r.Set) } groups[r.Set] = append(groups[r.Set], line) } var alts []string for _, set := range order { alts = append(alts, strings.Join(groups[set], ", ")) } out := "" if len(alts) > 0 { out = "Create: " + strings.Join(alts, " — or — ") + "." } if len(required) > 0 { lead := "Also create: " if out == "" { lead = "Create: " } out = strings.TrimSpace(out + " " + lead + strings.Join(required, ", ") + ".") } return out }