| 1 | package main |
| 2 | |
| 3 | // `carlos apps build-probe` — set, on an app that already exists, the path |
| 4 | // `carlos deploy` polls to learn which build is ACTUALLY running. |
| 5 | // |
| 6 | // The probe already existed and could only be stamped at claim time: |
| 7 | // SetAppBuildProbe had exactly one caller, the create door. So every app made |
| 8 | // before the flag existed, and every app whose author did not think to pass |
| 9 | // it, was permanently stuck on header-watching — the X-Carlos-Version header |
| 10 | // the edge stamps from the route row the moment adoption moves a symlink, |
| 11 | // whether or not any process restarted. Which means the fix for "deploy said |
| 12 | // live over the old binary" was out of reach of exactly the apps that had the |
| 13 | // problem (discussion #13). |
| 14 | |
| 15 | import ( |
| 16 | "context" |
| 17 | "flag" |
| 18 | "fmt" |
| 19 | "log" |
| 20 | "strings" |
| 21 | ) |
| 22 | |
| 23 | const buildProbeUsage = `usage: carlos apps build-probe --app <name> </path|-> [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] |
| 24 | |
| 25 | </path> an absolute path, no query, whose response body names the running |
| 26 | build — "/healthz" serving "ok abc1234" is the shape. carlos deploy |
| 27 | polls it instead of the X-Carlos-Version header, which reports the |
| 28 | release adoption INTENDED rather than the build actually serving. |
| 29 | - clear the probe, back to watching the header.` |
| 30 | |
| 31 | // runAppsBuildProbe stamps the probe path, or clears it. |
| 32 | // |
| 33 | // "-" is the clear sentinel, the same one `carlos route --unit` and |
| 34 | // `--environment` use and for the same reason: "" is a LEGAL value of this |
| 35 | // field — it means header-watching — so an empty argument cannot be told from |
| 36 | // an argument nobody meant to pass. The doors that clear say so out loud. |
| 37 | // |
| 38 | // The stored value is echoed back and compared rather than assumed, the same |
| 39 | // discipline `apps create` applies to this exact field. It is what the deploy |
| 40 | // watch trusts; a write that half-landed would leave a deploy quietly |
| 41 | // believing a header again, which is the whole bug. |
| 42 | func runAppsBuildProbe(args []string) { |
| 43 | fs := flag.NewFlagSet("apps build-probe", flag.ExitOnError) |
| 44 | app := appFlag(fs, "app whose build probe to set") |
| 45 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 46 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 47 | consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| 48 | fs.Usage = flagUsage(fs, buildProbeUsage) |
| 49 | fs.Parse(args) |
| 50 | if strings.TrimSpace(*app) == "" || fs.NArg() != 1 { |
| 51 | log.Fatal(buildProbeUsage) |
| 52 | } |
| 53 | if err := validAppName(strings.TrimSpace(*app)); err != nil { |
| 54 | log.Fatalf("apps build-probe: %v", err) |
| 55 | } |
| 56 | probe := strings.TrimSpace(fs.Arg(0)) |
| 57 | clearProbe := probe == "-" |
| 58 | if clearProbe { |
| 59 | probe = "" |
| 60 | } else { |
| 61 | if probe == "" { |
| 62 | log.Fatal(`apps build-probe: an empty probe is refused — pass "-" to clear it back to header-watching, or name the path`) |
| 63 | } |
| 64 | // Checked here, before the call, the same client-side grammar gate |
| 65 | // `apps create` applies: a shape the server is certain to refuse |
| 66 | // should cost no round trip and reach nothing. |
| 67 | if err := validBuildProbe(probe); err != nil { |
| 68 | log.Fatalf("apps build-probe: %v", err) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | sess, err := resolveMode(*consoleFlag) |
| 73 | if err != nil { |
| 74 | log.Fatal(err) |
| 75 | } |
| 76 | if sess == nil { |
| 77 | log.Fatal("apps build-probe works through a console, and this shell is in direct bucket mode\n" + |
| 78 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). The probe is console-tracked\n" + |
| 79 | "state on the app row: log in instead — carlos auth login --console <url>") |
| 80 | } |
| 81 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 82 | got, err := sess.setBuildProbe(context.Background(), acct, strings.TrimSpace(*app), probe) |
| 83 | if err != nil { |
| 84 | log.Fatalf("apps build-probe: %v", err) |
| 85 | } |
| 86 | if got != probe { |
| 87 | log.Fatalf("apps build-probe: did not stick (console echoed %q, want %q) — the probe is what "+ |
| 88 | "carlos deploy trusts, so a write that half-landed is worse than none", got, probe) |
| 89 | } |
| 90 | if clearProbe { |
| 91 | fmt.Fprintf(stdout, "cleared the build probe for %s — carlos deploy watches the X-Carlos-Version header again\n", strings.TrimSpace(*app)) |
| 92 | fmt.Fprintf(stdout, " that header is stamped by the edge from the route row, so it reports the release adoption intended, not the build serving\n") |
| 93 | return |
| 94 | } |
| 95 | fmt.Fprintf(stdout, "build probe for %s: %s\n", strings.TrimSpace(*app), got) |
| 96 | fmt.Fprintf(stdout, " carlos deploy now asks the app's own process which build it is running, instead of trusting the router's header\n") |
| 97 | } |
| 98 | |