| new file mode 100644 |
| index 000000000..0be8674fd |
| --- /dev/null |
| +++ b/deploy.go |
| @@ -0,0 +1,229 @@ |
| +// deploy.go composes ship + promote + watch into one member-facing verb: |
| +// "make this app's URL serve this version" (spec §6). It is deliberately |
| +// thin — every piece it calls (runShipAPI, apiSession.promote, |
| +// apiSession.instanceList, the X-Carlos-Version header) already exists for |
| +// the step-by-step verbs; this file only sequences them and watches for the |
| +// one fact that proves the job is actually done. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "os/exec" |
| + "sort" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket |
| +// operators keep ship + promote as separate acts; deploy is the member path |
| +// that ships, promotes to the channel the app's instances already follow, |
| +// and then proves it by watching the edge's version header — never AWS, |
| +// never a box command, per the operate-via-API rule. |
| +func runDeploy(args []string) { |
| + fs := flag.NewFlagSet("deploy", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to deploy through (API mode only; default: the only logged-in one)") |
| + kind := fs.String("kind", "binary", "binary | static") |
| + ver := fs.String("version", "", "version (default: git rev-parse --short HEAD)") |
| + target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| + capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest") |
| + channelFlag := fs.String("channel", "", "channel to promote to (default: resolved from the app's instances; error if they disagree)") |
| + hostFlag := fs.String("host", "", "scope channel resolution and the watch to one instance host") |
| + timeout := fs.Duration("timeout", 120*time.Second, "how long to watch the URL before failing") |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() == 0 { |
| + log.Fatal("usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " + |
| + "[-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] " + |
| + "[-channel <ch>] [-host <host>] [-timeout 120s] <files-or-site-dir>") |
| + } |
| + if *ver == "" { |
| + *ver = gitShortHead() |
| + } |
| + |
| + // Read and decode the capability declaration before ever dialing the |
| + // console, exactly like runShip does — a bad file is refused at the |
| + // operator's terminal, not after the ship half of this verb already ran. |
| + capsSet, err := readCapabilities(*capsFile, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + |
| + sess := requireAPISession("deploy", *consoleFlag) // API-mode only: member verb |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + printUsing(stdout, sess) |
| + ctx := context.Background() |
| + recs, _, err := sess.instanceList(ctx, acct, *app) |
| + if err != nil { |
| + log.Fatalf("deploy: list instances: %v", err) |
| + } |
| + var channel string |
| + var hosts []string |
| + if *channelFlag != "" { |
| + // Explicit channel: no ambiguity to resolve. Watch the instances that |
| + // follow it (none is fine — promote still lands; the watch is skipped |
| + // with a note, since there is nothing that will serve this channel). |
| + channel = *channelFlag |
| + for _, r := range recs { |
| + if r.Channel == channel && (*hostFlag == "" || r.Host == *hostFlag) { |
| + hosts = append(hosts, r.Host) |
| + } |
| + } |
| + } else { |
| + channel, hosts, err = resolveDeployChannel(recs, *hostFlag) |
| + if err != nil { |
| + log.Fatalf("deploy: %v", err) |
| + } |
| + } |
| + if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, capsSet); err != nil { |
| + log.Fatalf("deploy: ship: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), *app, *ver) |
| + res, err := sess.promote(ctx, acct, *app, *ver, channel) |
| + if err != nil { |
| + log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err |
| + } |
| + fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, *ver, channel) |
| + if res.Held { |
| + mutedf(stdout, "%s is a production app: the %s bake window applies — the fleet adopts when it expires; console fast-track if this is an emergency\n", *app, channel) |
| + return // exit 0: the writes succeeded, the hold is policy (spec §6.6) |
| + } |
| + if len(hosts) == 0 { |
| + mutedf(stdout, "no instance follows %s — promoted, nothing to watch (carlos instances create to add one)\n", channel) |
| + return |
| + } |
| + targets := make([]watchTarget, 0, len(hosts)) |
| + for _, h := range hosts { |
| + targets = append(targets, watchTarget{Host: h, Origin: "https://" + h}) |
| + } |
| + if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second); err != nil { |
| + log.Fatalf("deploy: %v", err) |
| + } |
| +} |
| + |
| +// gitShortHead resolves -version's default: the working directory's short |
| +// git SHA. Fatal here (rather than a returned error) matches every other |
| +// "this environment is wrong" refusal in this binary — there is no sane |
| +// fallback value, only the operator's own -version. |
| +func gitShortHead() string { |
| + out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output() |
| + if err != nil { |
| + log.Fatal("not in a git repository — pass -version") |
| + } |
| + return strings.TrimSpace(string(out)) |
| +} |
| + |
| +// resolveDeployChannel picks the channel `carlos deploy` promotes to, and the |
| +// hosts it then watches, from the app's own instance records (spec §6 step |
| +// 2). -host (hostFlag) narrows to one instance's own channel with nothing |
| +// left to resolve. With no host, every instance must already agree on one |
| +// channel — several distinct channels is treated as ambiguity for a human to |
| +// break with -channel or -host, not something this verb should guess at. |
| +func resolveDeployChannel(recs []apiInstanceRecord, hostFlag string) (channel string, hosts []string, err error) { |
| + if len(recs) == 0 { |
| + return "", nil, errors.New("no instances — create one: carlos instances create -app <a> -host <h> -channel <ch>") |
| + } |
| + if hostFlag != "" { |
| + for _, r := range recs { |
| + if r.Host == hostFlag { |
| + return r.Channel, []string{r.Host}, nil |
| + } |
| + } |
| + return "", nil, fmt.Errorf("no instance with host %q", hostFlag) |
| + } |
| + byChannel := map[string][]string{} |
| + var order []string |
| + for _, r := range recs { |
| + if _, ok := byChannel[r.Channel]; !ok { |
| + order = append(order, r.Channel) |
| + } |
| + byChannel[r.Channel] = append(byChannel[r.Channel], r.Host) |
| + hosts = append(hosts, r.Host) |
| + } |
| + if len(byChannel) == 1 { |
| + return order[0], hosts, nil |
| + } |
| + sort.Strings(order) |
| + var b strings.Builder |
| + b.WriteString("instances follow different channels — pass -channel or -host to pick one: ") |
| + for i, ch := range order { |
| + if i > 0 { |
| + b.WriteString("; ") |
| + } |
| + fmt.Fprintf(&b, "%s: %s", ch, strings.Join(byChannel[ch], ", ")) |
| + } |
| + return "", nil, errors.New(b.String()) |
| +} |
| + |
| +// watchTarget is one instance to watch: Host is what gets printed (the |
| +// public hostname the operator will actually visit) and Origin is what gets |
| +// dialed (https://<host> in production; a test's httptest.Server URL in |
| +// unit tests, which is never the same string as Host). |
| +type watchTarget struct{ Host, Origin string } |
| + |
| +// watchVersion polls each target's origin until it serves ver on |
| +// X-Carlos-Version — the deploy verb's exit criterion (spec §6 step 5, §7). |
| +// Every poll appends a query parameter that changes from the previous poll |
| +// (?carlos-deploy-probe=<attempt>) to the URL it dials. This is not |
| +// decoration: the edge caches a proxied response, header included, for up to |
| +// 5 minutes (internal/edge/edge.go, versionHeader) — longer than this |
| +// function's own 120s default timeout — so an un-cache-busted watch could |
| +// poll one stale cached hit for the whole timeout and report a healthy |
| +// deploy as failed. The attempt counter is sufficient (no randomness |
| +// needed): each request just needs to differ from the ones before it. |
| +func watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, interval time.Duration) error { |
| + pending := make(map[string]watchTarget, len(targets)) |
| + for _, t := range targets { |
| + pending[t.Host] = t |
| + } |
| + last := make(map[string]string, len(targets)) |
| + client := &http.Client{Timeout: 10 * time.Second} |
| + deadline := time.Now().Add(timeout) |
| + attempt := 0 |
| + for { |
| + for host, t := range pending { |
| + attempt++ |
| + url := fmt.Sprintf("%s/?carlos-deploy-probe=%d", t.Origin, attempt) |
| + resp, err := client.Get(url) |
| + if err != nil { |
| + last[host] = "unreachable: " + err.Error() |
| + continue |
| + } |
| + io.CopyN(io.Discard, resp.Body, 4096) |
| + resp.Body.Close() |
| + got := resp.Header.Get("X-Carlos-Version") |
| + switch { |
| + case got == ver: |
| + fmt.Fprintf(w, "%s https://%s\n", colorFor(w).ok("live"), host) |
| + delete(pending, host) |
| + case got == "": |
| + last[host] = "no X-Carlos-Version header (edge predates this feature — the hourly pass will adopt it)" |
| + default: |
| + last[host] = "still serving " + got |
| + } |
| + } |
| + if len(pending) == 0 { |
| + return nil |
| + } |
| + if time.Now().After(deadline) { |
| + hosts := make([]string, 0, len(pending)) |
| + for h := range pending { |
| + hosts = append(hosts, h) |
| + } |
| + sort.Strings(hosts) |
| + var b strings.Builder |
| + fmt.Fprintf(&b, "timed out waiting for %s to serve %s (route not minted yet or a wake failing? try `carlos instances -app <a>`):", strings.Join(hosts, ", "), ver) |
| + for _, h := range hosts { |
| + fmt.Fprintf(&b, "\n %s: %s", h, last[h]) |
| + } |
| + return errors.New(b.String()) |
| + } |
| + time.Sleep(interval) |
| + } |
| +} |