cli: deploy — no duplicate banner/shipped line, deploy's own refusal, validate -host under -channel
Three review fixes on carlos deploy: - runShipAPI gains a printBanner bool so deploy can suppress its own "using ..." line (already printed once, before the instance list) and no longer prints a second, contradictory "shipped" line of its own — runShipAPI's shipped/already-shipped wording is the one true account. - deploy no longer borrows instances_cli.go's requireAPISession, whose hardcoded refusal text names a nonexistent "carlos deploy" sub-verb and unrelated route advice in direct-bucket mode; requireDeployAPISession gives deploy its own message pointing at ship + promote. - -channel no longer bypasses host validation: filterChannelHosts checks a given -host against the instance list up front, so a typo'd host errors before ship/promote run instead of surfacing afterwards as "no instance follows <channel>" with the host never named.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+121
−16
apimode_test.go+5 −5cli.go+13 −3deploy.go+69 −8deploy_test.go+34 −0
diff --git a/apimode_test.go b/apimode_test.go| index 7fbff641b..0f0360882 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -1084,7 +1084,7 @@ func TestShipAPIStaticNamesSiteTarGz(t *testing.T) { |
| sess := newTestSession(ts.URL) |
| fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| - if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", nil); err != nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", nil, true); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1131,7 +1131,7 @@ func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) { |
| sess := newTestSession(ts.URL) |
| fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| - if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", nil); err == nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", nil, true); err == nil { |
| t.Fatal("runShipAPI against a failing server must error") |
| } |
| @@ -1197,7 +1197,7 @@ func TestShipAPIMultiFileGoodBasenames(t *testing.T) { |
| sess := newTestSession(ts.URL) |
| fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd) |
| - if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil); err != nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil, true); err != nil { |
| t.Fatalf("runShipAPI: %v", err) |
| } |
| @@ -1239,7 +1239,7 @@ func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) { |
| sess := newTestSession(ts.URL) |
| fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad) |
| - err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil) |
| + err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil, true) |
| if err == nil { |
| t.Fatal("runShipAPI with a bad basename must error") |
| } |
| @@ -1274,7 +1274,7 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) { |
| sess := newTestSession(ts.URL) |
| fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, missing) |
| - err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil) |
| + err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", nil, true) |
| if err == nil { |
| t.Fatal("runShipAPI with a missing artifact path must error") |
| } |
diff --git a/cli.go b/cli.go| index 78682b327..ae236a9cd 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -1470,7 +1470,7 @@ func runShip(args []string) { |
| // branch defers the cleanup of a scratch tarball up to 256MiB). |
| // Evaluated as an argument, the refusal lands before runShipAPI's |
| // body has registered a defer at all. |
| - if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, capsSet); err != nil { |
| + if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, capsSet, true); err != nil { |
| log.Fatal(err) |
| } |
| return |
| @@ -1589,7 +1589,15 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| // function — including the static kind's scratch temp file cleanup below, |
| // up to 256MiB per invocation. Returning lets that defer actually run on |
| // every exit path, fatal or not. |
| -func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target string, capsSet *caps.Set) error { |
| +// |
| +// printBanner controls only the "using <console> as <you>" line: true for |
| +// runShip's own call (unchanged), false for a caller — `carlos deploy` — |
| +// that already printed its own banner earlier in the same run (before its |
| +// instance-list call, so a listing failure still names the console) and |
| +// would otherwise print it twice. The "shipped"/"already shipped" line |
| +// below is never suppressed; it is the one true account of what ship did, |
| +// and deploy relies on that rather than printing its own. |
| +func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target string, capsSet *caps.Set, printBanner bool) error { |
| files := map[string]string{} |
| if kind == "static" { |
| if fs.NArg() != 1 { |
| @@ -1644,7 +1652,9 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind |
| } |
| } |
| - printUsing(stdout, sess) |
| + if printBanner { |
| + printUsing(stdout, sess) |
| + } |
| res, err := sess.ship(context.Background(), acct, app, ver, kind, files, capsSet) |
| if err != nil { |
| return err |
diff --git a/deploy.go b/deploy.go| index 0be8674fd..1aa54df07 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -55,9 +55,9 @@ func runDeploy(args []string) { |
| log.Fatal(err) |
| } |
| - sess := requireAPISession("deploy", *consoleFlag) // API-mode only: member verb |
| + sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - printUsing(stdout, sess) |
| + printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below |
| ctx := context.Background() |
| recs, _, err := sess.instanceList(ctx, acct, *app) |
| if err != nil { |
| @@ -69,11 +69,14 @@ func runDeploy(args []string) { |
| // Explicit channel: no ambiguity to resolve. Watch the instances that |
| // follow it (none is fine — promote still lands; the watch is skipped |
| // with a note, since there is nothing that will serve this channel). |
| + // -host is still validated against the instance list even here: a |
| + // typo'd host must be its own error, not silently absorbed into "no |
| + // instance follows this channel" once ship and promote have already |
| + // run (finding 3, task-9 review). |
| channel = *channelFlag |
| - for _, r := range recs { |
| - if r.Channel == channel && (*hostFlag == "" || r.Host == *hostFlag) { |
| - hosts = append(hosts, r.Host) |
| - } |
| + hosts, err = filterChannelHosts(recs, channel, *hostFlag) |
| + if err != nil { |
| + log.Fatalf("deploy: %v", err) |
| } |
| } else { |
| channel, hosts, err = resolveDeployChannel(recs, *hostFlag) |
| @@ -81,10 +84,12 @@ func runDeploy(args []string) { |
| log.Fatalf("deploy: %v", err) |
| } |
| } |
| - if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, capsSet); err != nil { |
| + // printBanner=false: the "using ..." line already printed above, before |
| + // the instance list — so a listing failure still names the console — |
| + // and runShipAPI would otherwise print it a second time. |
| + if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, capsSet, false); 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 |
| @@ -107,6 +112,29 @@ func runDeploy(args []string) { |
| } |
| } |
| +// requireDeployAPISession resolves signed-in mode for deploy, refusing |
| +// direct-bucket mode with deploy's OWN message — not instances_cli.go's |
| +// requireAPISession, whose hardcoded text ("carlos instances %s works |
| +// through a console... use carlos add instead") names a sub-verb of a |
| +// different command and advice about routes, neither of which applies to |
| +// deploy (task-9 review, finding 2). Unlike ship/promote, deploy has no |
| +// direct-bucket body at all — it is pure console composition — so the |
| +// refusal also has nowhere to fall back to but naming the two verbs that DO |
| +// still work in that mode. |
| +func requireDeployAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos deploy works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). deploy is a member verb with no\n" + |
| + "direct-bucket equivalent; unset the store env and run: carlos auth login -console <url>,\n" + |
| + "or use carlos ship + carlos promote, which both still work in this mode.") |
| + } |
| + return sess |
| +} |
| + |
| // 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 |
| @@ -161,6 +189,39 @@ func resolveDeployChannel(recs []apiInstanceRecord, hostFlag string) (channel st |
| return "", nil, errors.New(b.String()) |
| } |
| +// filterChannelHosts computes the hosts an EXPLICIT -channel deploy should |
| +// watch: instance records whose Channel matches, further scoped to hostFlag |
| +// if given. hostFlag is validated against the instance list up front — |
| +// independent of channel — so a typo'd -host is its own error before ship |
| +// and promote ever run, rather than silently producing zero hosts and |
| +// surfacing as "no instance follows <channel>" once the writes have already |
| +// landed (task-9 review, finding 3). This mirrors resolveDeployChannel's own |
| +// host-existence check on the non-explicit-channel branch: an existing host |
| +// that simply doesn't follow the requested channel is not an error here |
| +// (the caller may be deploying a channel that host has never served before) |
| +// — only a host absent from the instance list entirely is. |
| +func filterChannelHosts(recs []apiInstanceRecord, channel, hostFlag string) ([]string, error) { |
| + if hostFlag != "" { |
| + found := false |
| + for _, r := range recs { |
| + if r.Host == hostFlag { |
| + found = true |
| + break |
| + } |
| + } |
| + if !found { |
| + return nil, fmt.Errorf("no instance with host %q", hostFlag) |
| + } |
| + } |
| + var hosts []string |
| + for _, r := range recs { |
| + if r.Channel == channel && (hostFlag == "" || r.Host == hostFlag) { |
| + hosts = append(hosts, r.Host) |
| + } |
| + } |
| + return hosts, nil |
| +} |
| + |
| // watchTarget is one instance to watch: Host is what gets printed (the |
| // public hostname the operator will actually visit) and Origin is what gets |
| // dialed (https://<host> in production; a test's httptest.Server URL in |
diff --git a/deploy_test.go b/deploy_test.go| index 201fa27ae..12d291e45 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -32,6 +32,40 @@ func TestResolveDeployChannel(t *testing.T) { |
| } |
| } |
| +// TestFilterChannelHosts pins the -channel+-host validation fix (task-9 |
| +// review, finding 3): a -host that names no instance at all must error — |
| +// not silently produce zero hosts, which would let deploy ship and promote |
| +// against a typo'd host and only then shrug "no instance follows <channel>", |
| +// blaming the channel and never naming the host. A host that DOES exist but |
| +// simply isn't on the requested channel is not an error (the caller may be |
| +// pointing a channel at a host for the first time). |
| +func TestFilterChannelHosts(t *testing.T) { |
| + recs := []apiInstanceRecord{ |
| + {Host: "a.test", Channel: "edge"}, |
| + {Host: "b.test", Channel: "beta"}, |
| + } |
| + hosts, err := filterChannelHosts(recs, "edge", "") |
| + if err != nil || len(hosts) != 1 || hosts[0] != "a.test" { |
| + t.Fatalf("unscoped: %v %v", hosts, err) |
| + } |
| + hosts, err = filterChannelHosts(recs, "edge", "a.test") |
| + if err != nil || len(hosts) != 1 || hosts[0] != "a.test" { |
| + t.Fatalf("host-scoped, matching channel: %v %v", hosts, err) |
| + } |
| + hosts, err = filterChannelHosts(recs, "edge", "b.test") |
| + if err != nil { |
| + t.Fatalf("an existing host on a different channel must not error: %v", err) |
| + } |
| + if len(hosts) != 0 { |
| + t.Fatalf("b.test follows beta, not edge — must not appear: %v", hosts) |
| + } |
| + if _, err := filterChannelHosts(recs, "edge", "typo.test"); err == nil { |
| + t.Fatal("a -host naming no instance at all must error") |
| + } else if !strings.Contains(err.Error(), "typo.test") { |
| + t.Fatalf("error must name the offending host: %v", err) |
| + } |
| +} |
| + |
| func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) { |
| var mu sync.Mutex |
| ver := "old1234" |