| 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. |
| 7 | package main |
| 8 | |
| 9 | import ( |
| 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. |
| 39 | func 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. |
| 63 | func 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 | |
| 435 | 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" |
| 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. |
| 441 | func 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. |
| 454 | const 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. |
| 461 | const 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. |
| 471 | func 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. |
| 488 | func 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). |
| 525 | func 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. |
| 572 | func 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. |
| 609 | func 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. |
| 634 | func (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. |
| 670 | func 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. |
| 730 | func 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). |
| 750 | func 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". |
| 782 | func 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. |
| 810 | func 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. |
| 851 | func 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). |
| 877 | var 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. |
| 885 | var 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. |
| 910 | func 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. |
| 949 | func 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. |
| 1037 | func 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. |
| 1055 | func 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. |
| 1078 | func 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. |
| 1092 | func 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. |
| 1109 | func 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. |
| 1136 | func 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. |
| 1230 | func 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. |
| 1249 | func 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. |
| 1275 | func 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. |
| 1301 | func 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. |
| 1324 | func 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. |
| 1374 | func 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". |
| 1409 | type 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. |
| 1426 | var 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. |
| 1447 | type 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 | |
| 1452 | func 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. |
| 1536 | func 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. |
| 1624 | const ( |
| 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. |
| 1639 | func 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 | |