Add optional descriptions to CLI mutations
13 files changed,
+512
−50
.gitignore+2 −0apimode.go+43 −21appconfig_cli.go+18 −3cli.go+21 −5config.go+8 −0deploy.go+18 −8deploy_test.go+47 −6docs/site/cli.md+20 −0features_cli.go+6 −1instances_cli.go+12 −2mutation_message.go+70 −0mutation_message_test.go+234 −0restart.go+13 −4
CI passed — run details
diff --git a/.gitignore b/.gitignore| index 42d71f76e..183f87420 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -9,3 +9,5 @@ platform |
| # directory in the checkout is that step's cache — see the step for why. |
| .amadan-cache/ |
| infra/modules/**/.terraform.lock.hcl |
| + |
| +writing/ |
diff --git a/apimode.go b/apimode.go| index bb2eb6675..985c4881a 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -887,12 +887,14 @@ func proposedFromBody(body []byte) error { |
| // console's promote door reads it strictly after its own rules decision |
| // (handleAPIPromote's doc comment), so sending it is never itself a bypass |
| // of anything this call site doesn't already ask for. |
| -func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string, hotfix bool) (apiPromoteResult, error) { |
| +func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, channel string, hotfix bool, messages ...string) (apiPromoteResult, error) { |
| + message := firstMutationMessage(messages) |
| in := struct { |
| Version string `json:"version"` |
| Channel string `json:"channel"` |
| Hotfix bool `json:"hotfix,omitempty"` |
| - }{version, channel, hotfix} |
| + Message string `json:"message,omitempty"` |
| + }{version, channel, hotfix, message} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/promote"), in) |
| if err != nil { |
| return apiPromoteResult{}, err |
| @@ -999,10 +1001,12 @@ func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string |
| // longer stay error-or-nil — *apiProposedError is a real, expected return |
| // alongside a genuine failure, and runRollback tells them apart with |
| // errors.As. |
| -func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel string) error { |
| +func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel string, messages ...string) error { |
| + message := firstMutationMessage(messages) |
| in := struct { |
| Channel string `json:"channel"` |
| - }{channel} |
| + Message string `json:"message,omitempty"` |
| + }{channel, message} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/rollback"), in) |
| if err != nil { |
| return err |
| @@ -1039,8 +1043,12 @@ type apiRestartResult struct { |
| // app that isn't there — there is nothing in the response to tell them apart. |
| // So the message names BOTH, shortest fix first, the same way restoreApp |
| // widens its own 404 rather than inventing certainty it doesn't have. |
| -func (s *apiSession) restart(ctx context.Context, a apiAcct, app string) (apiRestartResult, error) { |
| - status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart"), struct{}{}) |
| +func (s *apiSession) restart(ctx context.Context, a apiAcct, app string, messages ...string) (apiRestartResult, error) { |
| + message := firstMutationMessage(messages) |
| + in := struct { |
| + Message string `json:"message,omitempty"` |
| + }{message} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart"), in) |
| if err != nil { |
| return apiRestartResult{}, err |
| } |
| @@ -1095,12 +1103,14 @@ type apiRestartScopeResult struct { |
| // console does not serve, gets net/http's byte-identical "404 page not found", |
| // and says all three things it could mean — shortest fix first — the way |
| // restart() widens its own. |
| -func (s *apiSession) restartScoped(ctx context.Context, a apiAcct, app, host, environment string, dryRun bool) (apiRestartScopeResult, error) { |
| +func (s *apiSession) restartScoped(ctx context.Context, a apiAcct, app, host, environment string, dryRun bool, messages ...string) (apiRestartScopeResult, error) { |
| + message := firstMutationMessage(messages) |
| in := struct { |
| Host string `json:"host"` |
| Environment string `json:"environment"` |
| DryRun bool `json:"dry_run"` |
| - }{host, environment, dryRun} |
| + Message string `json:"message,omitempty"` |
| + }{host, environment, dryRun, message} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart/scope"), in) |
| if err != nil { |
| return apiRestartScopeResult{}, err |
| @@ -1272,11 +1282,13 @@ func (s *apiSession) features(ctx context.Context, a apiAcct, app string) (apiFe |
| return decodeFeatures(status, body, err) |
| } |
| -func (s *apiSession) setFeatures(ctx context.Context, a apiAcct, app string, set map[string]string, unset []string) (apiFeaturesResult, error) { |
| +func (s *apiSession) setFeatures(ctx context.Context, a apiAcct, app string, set map[string]string, unset []string, messages ...string) (apiFeaturesResult, error) { |
| + message := firstMutationMessage(messages) |
| in := struct { |
| - Set map[string]string `json:"set,omitempty"` |
| - Unset []string `json:"unset,omitempty"` |
| - }{set, unset} |
| + Set map[string]string `json:"set,omitempty"` |
| + Unset []string `json:"unset,omitempty"` |
| + Message string `json:"message,omitempty"` |
| + }{set, unset, message} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/features"), in) |
| return decodeFeatures(status, body, err) |
| } |
| @@ -2319,6 +2331,7 @@ type apiConfigApply struct { |
| Set map[string]appconfig.Var `json:"set,omitempty"` |
| Unset []string `json:"unset,omitempty"` |
| ExecDelivery *string `json:"exec_delivery,omitempty"` |
| + Message string `json:"message,omitempty"` |
| } |
| // configApply sends one PUT to the console's config door — a set, an unset, |
| @@ -2562,7 +2575,8 @@ func (s *apiSession) pendingList(ctx context.Context, a apiAcct, app string) ([] |
| // interpreting a non-200 as an error itself — getJSON's plumbing with the |
| // method swapped, for the instance-record retire door (issue #66), the one |
| // DELETE this client speaks. |
| -func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte, error) { |
| +func (s *apiSession) deleteJSON(ctx context.Context, path string, messages ...string) (int, []byte, error) { |
| + message := firstMutationMessage(messages) |
| req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.c.base+path, nil) |
| if err != nil { |
| return 0, nil, err |
| @@ -2570,6 +2584,9 @@ func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte, |
| if s.c.token != "" { |
| req.Header.Set("Authorization", "Bearer "+s.c.token) |
| } |
| + if message != "" { |
| + req.Header.Set("X-Carlos-Message", message) |
| + } |
| res, err := s.c.httpc.Do(req) |
| if err != nil { |
| return 0, nil, err |
| @@ -2757,7 +2774,8 @@ type instanceProbeReq struct { |
| // upstream (kind defaults to "instance", which needs no addr at all), and |
| // probe nil means unprobed — the same "absent, not zero" contract the wire |
| // carries for every optional field on this door. |
| -func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel, kind, environment string, addrs []string, probe *instanceProbeReq) (apiInstanceRecord, error) { |
| +func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel, kind, environment string, addrs []string, probe *instanceProbeReq, messages ...string) (apiInstanceRecord, error) { |
| + message := firstMutationMessage(messages) |
| in := struct { |
| Host string `json:"host"` |
| Region string `json:"region,omitempty"` |
| @@ -2766,7 +2784,8 @@ func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, r |
| Environment string `json:"environment,omitempty"` |
| Addrs []string `json:"addrs,omitempty"` |
| Probe *instanceProbeReq `json:"probe,omitempty"` |
| - }{host, region, channel, kind, environment, addrs, probe} |
| + Message string `json:"message,omitempty"` |
| + }{host, region, channel, kind, environment, addrs, probe, message} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in) |
| if err != nil { |
| return apiInstanceRecord{}, err |
| @@ -3157,8 +3176,8 @@ func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, bo |
| // instanceDelete retires one instance record. Idempotent server-side (the |
| // caller's goal is "not there"), so a 200 is the only success shape. |
| -func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host string) error { |
| - status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host))) |
| +func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host string, messages ...string) error { |
| + status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), messages...) |
| if err != nil { |
| return err |
| } |
| @@ -3262,7 +3281,7 @@ func (e *domainListError) Unwrap() error { return e.err } |
| // are omitted from the body, so a console that predates the fields never |
| // sees them. route and kind together are the door's 400 — an alias target |
| // and a first-route mint are two different instructions. |
| -func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool) (created bool, resolvedRoute string, err error) { |
| +func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool, messages ...string) (created bool, resolvedRoute string, err error) { |
| in := struct { |
| Host string `json:"host"` |
| Route string `json:"route,omitempty"` |
| @@ -3270,7 +3289,8 @@ func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, rou |
| Channel string `json:"channel,omitempty"` |
| Wildcard *bool `json:"wildcard,omitempty"` |
| Catchall *bool `json:"catchall,omitempty"` |
| - }{host, route, kind, channel, wildcard, catchall} |
| + Message string `json:"message,omitempty"` |
| + }{host, route, kind, channel, wildcard, catchall, firstMutationMessage(messages)} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in) |
| if err != nil { |
| return false, "", err |
| @@ -3384,7 +3404,8 @@ const shipTimeout = 15 * time.Minute |
| // unknown-inside-a-known ones the strict decoder already reported and |
| // ignored. nil (no --capabilities) omits the key entirely, so the meta part's |
| // bytes stay byte-identical to what this verb sent yesterday. |
| -func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, label, notes string, files map[string]string, capsSet *caps.Set) (apiShipResult, error) { |
| +func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, label, notes string, files map[string]string, capsSet *caps.Set, messages ...string) (apiShipResult, error) { |
| + message := firstMutationMessage(messages) |
| ctx, cancel := context.WithTimeout(ctx, shipTimeout) |
| defer cancel() |
| @@ -3408,8 +3429,9 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, la |
| Kind string `json:"kind"` |
| Label string `json:"label,omitempty"` |
| Notes string `json:"notes,omitempty"` |
| + Message string `json:"message,omitempty"` |
| Capabilities json.RawMessage `json:"capabilities,omitempty"` |
| - }{version, kind, label, notes, capsRaw}) |
| + }{version, kind, label, notes, message, capsRaw}) |
| if err != nil { |
| return err |
| } |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index a23bf26c1..f331a08b8 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -340,6 +340,7 @@ func parseKV(args []string) (map[string]string, error) { |
| func runEnvSet(args []string) { |
| fs := flag.NewFlagSet("env set", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| @@ -365,8 +366,12 @@ func runEnvSet(args []string) { |
| for k, v := range kv { |
| set[k] = appconfig.Var{Kind: "env", Value: v} |
| } |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("env set: %v", merr) |
| + } |
| res, err := sess.configApply(context.Background(), acct, app2, |
| - apiConfigApply{Environment: *environment, Set: set}) |
| + apiConfigApply{Environment: *environment, Set: set, Message: message}) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -400,6 +405,7 @@ func mapKeys(kv map[string]string) []string { |
| func runEnvUnset(args []string) { |
| fs := flag.NewFlagSet("env unset", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)") |
| acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs) |
| @@ -420,8 +426,12 @@ func runEnvUnset(args []string) { |
| printUsing(os.Stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, usage) |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("env unset: %v", merr) |
| + } |
| res, err := sess.configApply(context.Background(), acct, app2, |
| - apiConfigApply{Environment: *environment, Unset: fs.Args()}) |
| + apiConfigApply{Environment: *environment, Unset: fs.Args(), Message: message}) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -552,6 +562,7 @@ func printEnvList(app, environment, execDelivery string, vars map[string]string) |
| // is refused there, by name, rather than accepted as a synonym. |
| func runEnvExecDelivery(args []string) { |
| fs := flag.NewFlagSet("env exec-delivery", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| environment := environmentFlag(fs, |
| "named environment bundle to set the flag in (omit for the app's default bundle)") |
| @@ -615,8 +626,12 @@ func runEnvExecDelivery(args []string) { |
| if value != "" { |
| wire = value |
| } |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("env exec-delivery: %v", merr) |
| + } |
| res, err := sess.configApply(context.Background(), acct, appName, |
| - apiConfigApply{Environment: *environment, ExecDelivery: &wire}) |
| + apiConfigApply{Environment: *environment, ExecDelivery: &wire, Message: message}) |
| if err != nil { |
| log.Fatal(err) |
| } |
diff --git a/cli.go b/cli.go| index fe12cda75..ab12b9cd3 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -1907,6 +1907,7 @@ func publicKey() ed25519.PublicKey { |
| func runShip(args []string) { |
| fs := flag.NewFlagSet("ship", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)") |
| @@ -1977,6 +1978,10 @@ func runShip(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatal(merr) |
| + } |
| // The banner prints BEFORE the account resolves: resolution may put |
| // a muted "account: …" line — or the whole interactive chooser — on |
| // the terminal, and a prompt with no console named yet is a question |
| @@ -1998,7 +2003,7 @@ func runShip(args []string) { |
| log.Fatal(err) |
| } |
| } |
| - if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil { |
| + if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false, message); err != nil { |
| log.Fatal(err) |
| } |
| return |
| @@ -2160,7 +2165,8 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| // 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, label, notes string, capsSet *caps.Set, printBanner bool) (apiShipResult, error) { |
| +func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set, printBanner bool, messages ...string) (apiShipResult, error) { |
| + message := firstMutationMessage(messages) |
| files := map[string]string{} |
| if kind == "static" { |
| if fs.NArg() != 1 { |
| @@ -2218,7 +2224,7 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind |
| if printBanner { |
| printUsing(stdout, sess) |
| } |
| - res, err := sess.ship(context.Background(), acct, app, ver, kind, label, notes, files, capsSet) |
| + res, err := sess.ship(context.Background(), acct, app, ver, kind, label, notes, files, capsSet, message) |
| if err != nil { |
| return apiShipResult{}, err |
| } |
| @@ -2245,6 +2251,7 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind |
| // bypass — explicit, so it can be seen. |
| func runPromote(args []string) { |
| fs := flag.NewFlagSet("promote", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| @@ -2283,7 +2290,11 @@ func runPromote(args []string) { |
| } |
| channel = resolved |
| } |
| - res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix) |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatal(merr) |
| + } |
| + res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix, message) |
| if err != nil { |
| var prop *apiProposedError |
| if errors.As(err, &prop) { |
| @@ -2718,6 +2729,7 @@ func runReleases(args []string) { |
| func runRollback(args []string) { |
| fs := flag.NewFlagSet("rollback", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)") |
| @@ -2736,7 +2748,11 @@ func runRollback(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, usage) |
| - if err := sess.rollback(context.Background(), acct, app2, fs.Arg(0)); err != nil { |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatal(merr) |
| + } |
| + if err := sess.rollback(context.Background(), acct, app2, fs.Arg(0), message); err != nil { |
| var prop *apiProposedError |
| if errors.As(err, &prop) { |
| printProposed(stdout, prop) |
diff --git a/config.go b/config.go| index b63847ab5..2718325b9 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -53,6 +53,14 @@ type cliConfig struct { |
| // UpdateCheck: "off" disables the once-a-day newer-release hint |
| // (update.go). Any other value — including absent — leaves it on. |
| UpdateCheck string `json:"update_check"` |
| + // MessagePrompt: "off" suppresses the optional mutation-description |
| + // question. Any other value, including absent, leaves it enabled. |
| + MessagePrompt string `json:"message_prompt"` |
| +} |
| + |
| +func configMessagePrompt() (string, error) { |
| + cfg, _, err := walkConfig(func(c cliConfig) bool { return c.MessagePrompt != "" }) |
| + return cfg.MessagePrompt, err |
| } |
| // walkConfig finds the nearest .carlos/config (cwd upward, then global) |
diff --git a/deploy.go b/deploy.go| index ea045fb40..fd6e090b4 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -35,6 +35,7 @@ import ( |
| // never a box command, per the operate-via-API rule. |
| func runDeploy(args []string) { |
| fs := flag.NewFlagSet("deploy", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := fs.String("console", "", "console origin to deploy through (API mode only; default: the only logged-in one)") |
| @@ -207,6 +208,15 @@ func runDeploy(args []string) { |
| func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) }) |
| } |
| } |
| + if err != nil && !errors.Is(err, errNoStaticHost) { |
| + log.Fatalf("deploy: %v", err) |
| + } |
| + // Resolve once, before any route, release or channel write. Both halves |
| + // of deploy carry the same context and neither asks a second question. |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("deploy: %v", merr) |
| + } |
| // A static app with nothing serving it yet: mint its platform host |
| // rather than refusing (#186's static half). Typed, never text-matched, |
| // and BEFORE the ship — attachUnclaimedHost's ordering, so a failed mint |
| @@ -219,7 +229,7 @@ func runDeploy(args []string) { |
| // channel there is nothing to mint onto, so the refusal stands and names |
| // --host, which is the step that makes --channel askable next. |
| if errors.Is(err, errNoStaticHost) && channel != "" { |
| - host, mintErr := mintPlatformHost(ctx, sess, acct, *app, channel) |
| + host, mintErr := mintPlatformHost(ctx, sess, acct, *app, channel, message) |
| switch { |
| case mintErr == nil: |
| hosts, err = []string{host}, nil |
| @@ -240,18 +250,18 @@ func runDeploy(args []string) { |
| fmt.Fprintln(stdout, line) |
| } |
| if staticDeploy && *hostFlag != "" { |
| - attachUnclaimedHost(ctx, sess, acct, *app, *hostFlag, *kind, channel, recs) |
| + attachUnclaimedHost(ctx, sess, acct, *app, *hostFlag, *kind, channel, recs, message) |
| } |
| // printBanner=false: the "using ..." line already printed above, before |
| // the instance list — so a listing failure still names the console — |
| // and runShipAPI would otherwise print it a second time. |
| - shipRes, err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, *notes, capsSet, false) |
| + shipRes, err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, *notes, capsSet, false, message) |
| if err != nil { |
| log.Fatalf("deploy: ship: %v", err) |
| } |
| // `carlos deploy` carries no --hotfix flag of its own — it always ships in |
| // order, so this is a plain false, never a refusal. |
| - res, err := sess.promote(ctx, acct, *app, *ver, channel, false) |
| + res, err := sess.promote(ctx, acct, *app, *ver, channel, false, message) |
| if err != nil { |
| // A guarded channel turns promote into a 202 proposal (release |
| // pipelines v2 §4) rather than a landed write — *apiProposedError, |
| @@ -833,7 +843,7 @@ var errNoStaticHost = errors.New("no static host") |
| // stays right regardless: this caller has already resolved the channel it is |
| // about to promote to, and a record minted on a different one would serve |
| // something else. |
| -func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string) (string, error) { |
| +func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string, messages ...string) (string, error) { |
| m, err := sess.instancesEnable(ctx, acct, app, nil, nil) |
| if err != nil { |
| return "", err |
| @@ -841,7 +851,7 @@ func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, |
| // No environment: the app's apex serves its default config bundle, which |
| // is exactly right — this is the app's primary host, the one every other |
| // bundle's origin values are written to name. |
| - rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", "", nil, nil) |
| + rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", "", nil, nil, firstMutationMessage(messages)) |
| if err != nil { |
| return "", err |
| } |
| @@ -872,7 +882,7 @@ func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, |
| // passed, so a token that got this far can list domains too, and a 401/403 |
| // here means the credential genuinely lost authority mid-deploy — worth |
| // stopping for, not degrading past. |
| -func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, app, host, kind, channel string, recs []apiInstanceRecord) { |
| +func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, app, host, kind, channel string, recs []apiInstanceRecord, messages ...string) { |
| domains, err := sess.domainList(ctx, acct, app) |
| if err != nil { |
| switch { |
| @@ -931,7 +941,7 @@ func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, ap |
| // aliased, so the two cases get two different lines, and the alias one |
| // never names a channel — an alias serves the primary's channel, not |
| // one of its own. |
| - _, resolvedRoute, err := sess.domainAttach(ctx, acct, app, host, "", attachKind, channel, nil, nil) |
| + _, resolvedRoute, err := sess.domainAttach(ctx, acct, app, host, "", attachKind, channel, nil, nil, firstMutationMessage(messages)) |
| if err != nil { |
| log.Fatalf("deploy: attach %s: %v", host, err) |
| } |
diff --git a/deploy_test.go b/deploy_test.go| index a2f63d08a..fd4b50b6f 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -585,7 +585,7 @@ func TestAPISessionDomainListAndAttachWireShape(t *testing.T) { |
| } |
| created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely", |
| - "fresh.example.test", "", "static", "canary/live", nil, nil) |
| + "fresh.example.test", "", "static", "canary/live", nil, nil, "publish the new site") |
| if err != nil { |
| t.Fatalf("domainAttach: %v", err) |
| } |
| @@ -596,8 +596,9 @@ func TestAPISessionDomainListAndAttachWireShape(t *testing.T) { |
| t.Fatalf("path = %s", gotAttachPath) |
| } |
| if !strings.Contains(gotAttachBody, `"host":"fresh.example.test"`) || |
| - !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) { |
| - t.Fatalf("body = %s, want host+kind+channel", gotAttachBody) |
| + !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) || |
| + !strings.Contains(gotAttachBody, `"message":"publish the new site"`) { |
| + t.Fatalf("body = %s, want host+kind+channel+message", gotAttachBody) |
| } |
| if strings.Contains(gotAttachBody, `"route"`) { |
| t.Fatalf("body = %s, empty route must be omitted", gotAttachBody) |
| @@ -662,7 +663,8 @@ func TestDomainNeedsAttach(t *testing.T) { |
| // so a test can assert attach happened before ship rather than merely that |
| // it happened at all. |
| type deployFakeConsole struct { |
| - ts *httptest.Server |
| + shipMessage, promoteMessage string |
| + ts *httptest.Server |
| mu sync.Mutex |
| calls []string // "METHOD /path", in hit order |
| @@ -743,6 +745,26 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole { |
| }) |
| mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) { |
| fc.record(r) |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + part, err := mr.NextPart() |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + var meta struct { |
| + Message string `json:"message"` |
| + } |
| + if err := json.NewDecoder(part).Decode(&meta); err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + fc.mu.Lock() |
| + fc.shipMessage = meta.Message |
| + fc.mu.Unlock() |
| fc.mu.Lock() |
| ver, probe := fc.shipVersion, fc.shipProbe |
| fc.mu.Unlock() |
| @@ -755,6 +777,16 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole { |
| }) |
| mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) { |
| fc.record(r) |
| + var input struct { |
| + Message string `json:"message"` |
| + } |
| + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + fc.mu.Lock() |
| + fc.promoteMessage = input.Message |
| + fc.mu.Unlock() |
| fc.mu.Lock() |
| proposed := fc.promoteProposed |
| fc.mu.Unlock() |
| @@ -1582,7 +1614,7 @@ func TestDeployPromoteProposedExitsCleanly(t *testing.T) { |
| buf := captureStdout(t) |
| runDeploy([]string{"-app", app, "-account", "ops", "-kind", "binary", "-version", "sha0009", |
| - "-channel", "canary/x", "-timeout", "5s", binPath}) |
| + "-channel", "canary/x", "--message", "Fix invoice rounding", "-timeout", "5s", binPath}) |
| out := buf.String() |
| if promoteIdx := fc.callIndex("POST /api/cli/apps/ops/widgetco/promote"); promoteIdx < 0 { |
| @@ -1607,6 +1639,12 @@ func TestDeployPromoteProposedExitsCleanly(t *testing.T) { |
| if strings.Contains(out, "live https://") { |
| t.Fatalf("stdout = %q, must never reach the watch loop (nothing converges until the proposal is confirmed)", out) |
| } |
| + fc.mu.Lock() |
| + shipMessage, promoteMessage := fc.shipMessage, fc.promoteMessage |
| + fc.mu.Unlock() |
| + if shipMessage != "Fix invoice rounding" || promoteMessage != shipMessage { |
| + t.Fatalf("deploy messages: ship=%q promote=%q", shipMessage, promoteMessage) |
| + } |
| checkNoRegistry() |
| } |
| @@ -1700,7 +1738,7 @@ func TestMintPlatformHostSendsKindAndChannel(t *testing.T) { |
| defer ts.Close() |
| out := captureStdout(t) |
| - host, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge") |
| + host, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge", "publish the new site") |
| if err != nil { |
| t.Fatalf("mintPlatformHost: %v", err) |
| } |
| @@ -1716,6 +1754,9 @@ func TestMintPlatformHostSendsKindAndChannel(t *testing.T) { |
| if create["channel"] != "edge" { |
| t.Fatalf("create channel = %#v, want an EXPLICIT edge", create["channel"]) |
| } |
| + if create["message"] != "publish the new site" { |
| + t.Fatalf("create message = %#v", create["message"]) |
| + } |
| if got := out.String(); !strings.Contains(got, "minted") || !strings.Contains(got, host) { |
| t.Fatalf("stdout = %q, want it to name what was minted", got) |
| } |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index a092d5712..41aa3d78f 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -23,6 +23,26 @@ credentials gets their work done. With `CARLOS_DEPLOYMENT_BUCKET` or |
| `CARLOS_DEPLOYMENT_DIR` set, they write to the bucket directly. That is the |
| operator's path. Where a command only works one way, its section says so. |
| +## Describing changes |
| + |
| +Add a short description to a mutation with `--message`. Commands sent through the console record it alongside the exact Activity operation and target. |
| + |
| +```sh |
| +carlos deploy --message "Fix invoice total rounding" ./app-linux-arm64 |
| +``` |
| + |
| +This applies to `ship`, `deploy`, `promote`, `rollback`, `restart`, `instances create`, `instances delete`, `features set`, `env set`, `env unset`, and `env exec-delivery`. A `deploy` command uses one description for both `ship` and `promote`. |
| + |
| +In an interactive terminal, these commands ask for a description by default. Automation never receives a prompt. `restart --dry-run` also never prompts. Use `--no-prompt` to skip the question. To disable the default prompt, add this to `.carlos/config`: |
| + |
| +```json |
| +{"message_prompt":"off"} |
| +``` |
| + |
| +An explicit `--message` still works with either setting. An empty description is valid. Messages must be valid UTF-8, contain no control characters such as line breaks or tabs, and be no longer than 240 Unicode characters. Never include secrets. |
| + |
| +Release `--label` and release notes describe the release separately from a mutation message. |
| + |
| ## Your apps |
| Everything from claiming a name to watching a deploy land. None of it needs |
diff --git a/features_cli.go b/features_cli.go| index 52f6f6b07..5b9d9e133 100644 |
| --- a/features_cli.go |
| +++ b/features_cli.go |
| @@ -93,6 +93,7 @@ func parseFeatureArgs(words []string) (map[string]string, []string, error) { |
| func runFeaturesSet(args []string) { |
| fs := flag.NewFlagSet("features set", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := fs.String("app", "", "app whose feature flags to set") |
| consoleFlag := fs.String("console", "", "console to set through (default: the only logged-in one)") |
| acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| @@ -109,7 +110,11 @@ func runFeaturesSet(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, featuresSetUsage) |
| - res, err := sess.setFeatures(context.Background(), acct, app2, set, unset) |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("features set: %v", merr) |
| + } |
| + res, err := sess.setFeatures(context.Background(), acct, app2, set, unset, message) |
| if err != nil { |
| log.Fatalf("features set: %v", err) |
| } |
diff --git a/instances_cli.go b/instances_cli.go| index b5341dace..80707979c 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -261,6 +261,7 @@ func requireAPISession(verb, consoleFlag string) *apiSession { |
| func runInstancesCreate(args []string) { |
| fs := flag.NewFlagSet("instances create", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain") |
| region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)") |
| @@ -286,9 +287,13 @@ func runInstancesCreate(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("instances create: %v", merr) |
| + } |
| rec, err := sess.instanceCreate(context.Background(), acct, app2, |
| strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), |
| - strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe) |
| + strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe, message) |
| if err != nil { |
| log.Fatalf("instances create: %v", err) |
| } |
| @@ -390,6 +395,7 @@ func runInstancesList(args []string) { |
| func runInstancesDelete(args []string) { |
| fs := flag.NewFlagSet("instances delete", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host whose record to retire") |
| consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)") |
| @@ -404,7 +410,11 @@ func runInstancesDelete(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| - if err := sess.instanceDelete(context.Background(), acct, app2, strings.TrimSpace(*host)); err != nil { |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("instances delete: %v", merr) |
| + } |
| + if err := sess.instanceDelete(context.Background(), acct, app2, strings.TrimSpace(*host), message); err != nil { |
| log.Fatalf("instances delete: %v", err) |
| } |
| fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n", |
diff --git a/mutation_message.go b/mutation_message.go| new file mode 100644 |
| index 000000000..0d85396c5 |
| --- /dev/null |
| +++ b/mutation_message.go |
| @@ -0,0 +1,70 @@ |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "flag" |
| + "strings" |
| + "unicode" |
| + "unicode/utf8" |
| +) |
| + |
| +const mutationMessageMaxRunes = 240 |
| + |
| +type mutationMessageCopy struct { |
| + MessageHelp, NoPromptHelp, Prompt, Invalid string |
| +} |
| + |
| +var mutationMessageText = mutationMessageCopy{ |
| + MessageHelp: "Add a short note describing this change (shown alongside the recorded action).", |
| + NoPromptHelp: "Skip the optional change description prompt.", |
| + Prompt: "Describe this change (optional):", |
| + Invalid: "Message must be one line and 240 characters or fewer.", |
| +} |
| + |
| +func firstMutationMessage(messages []string) string { |
| + if len(messages) == 0 { |
| + return "" |
| + } |
| + return messages[0] |
| +} |
| + |
| +type mutationMessageFlags struct { |
| + message *string |
| + noPrompt *bool |
| +} |
| + |
| +func addMutationMessageFlags(fs *flag.FlagSet) mutationMessageFlags { |
| + return mutationMessageFlags{ |
| + message: fs.String("message", "", mutationMessageText.MessageHelp), |
| + noPrompt: fs.Bool("no-prompt", false, mutationMessageText.NoPromptHelp), |
| + } |
| +} |
| + |
| +func resolveMutationMessage(fs *flag.FlagSet, flags mutationMessageFlags) (string, error) { |
| + if flagWasSet(fs, "message") { |
| + return validateMutationMessage(*flags.message) |
| + } |
| + if *flags.noPrompt || !interactiveTerminal() { |
| + return "", nil |
| + } |
| + pref, err := configMessagePrompt() |
| + if err != nil { |
| + return "", err |
| + } |
| + if pref == "off" { |
| + return "", nil |
| + } |
| + return validateMutationMessage(promptLine(stdout, mutationMessageText.Prompt)) |
| +} |
| + |
| +func validateMutationMessage(message string) (string, error) { |
| + if !utf8.ValidString(message) || utf8.RuneCountInString(message) > mutationMessageMaxRunes { |
| + return "", errors.New(mutationMessageText.Invalid) |
| + } |
| + for _, r := range message { |
| + if unicode.IsControl(r) { |
| + return "", errors.New(mutationMessageText.Invalid) |
| + } |
| + } |
| + return strings.TrimSpace(message), nil |
| +} |
diff --git a/mutation_message_test.go b/mutation_message_test.go| new file mode 100644 |
| index 000000000..9198da66b |
| --- /dev/null |
| +++ b/mutation_message_test.go |
| @@ -0,0 +1,234 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "flag" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "unicode/utf8" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +func messageFlagSet(t *testing.T, args ...string) (*flag.FlagSet, mutationMessageFlags) { |
| + t.Helper() |
| + fs := flag.NewFlagSet("test", flag.ContinueOnError) |
| + flags := addMutationMessageFlags(fs) |
| + if err := fs.Parse(args); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fs, flags |
| +} |
| + |
| +func TestMutationMessageExplicitWinsWithoutPrompt(t *testing.T) { |
| + oldTTY, oldIn := interactiveTerminal, chooserIn |
| + t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn }) |
| + interactiveTerminal = func() bool { return true } |
| + chooserIn = strings.NewReader("should not be read\n") |
| + fs, flags := messageFlagSet(t, "--message", " explain the release ", "--no-prompt") |
| + got, err := resolveMutationMessage(fs, flags) |
| + if err != nil || got != "explain the release" { |
| + t.Fatalf("resolve = %q, %v", got, err) |
| + } |
| +} |
| + |
| +func TestMutationMessageExplicitEmptyAndNoPromptSkipTTYPrompt(t *testing.T) { |
| + oldTTY, oldIn := interactiveTerminal, chooserIn |
| + t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn }) |
| + interactiveTerminal = func() bool { return true } |
| + for _, args := range [][]string{{"--message", ""}, {"--no-prompt"}} { |
| + chooserIn = strings.NewReader("must not be read\n") |
| + fs, flags := messageFlagSet(t, args...) |
| + got, err := resolveMutationMessage(fs, flags) |
| + if err != nil || got != "" { |
| + t.Fatalf("resolve %v = %q, %v", args, got, err) |
| + } |
| + } |
| +} |
| + |
| +func TestMutationMessagePromptsOnlyAtTerminal(t *testing.T) { |
| + oldTTY, oldIn, oldOut := interactiveTerminal, chooserIn, stdout |
| + t.Cleanup(func() { interactiveTerminal, chooserIn, stdout = oldTTY, oldIn, oldOut }) |
| + var out bytes.Buffer |
| + stdout = &out |
| + interactiveTerminal = func() bool { return true } |
| + chooserIn = strings.NewReader("describe this change\n") |
| + t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "missing-config")) |
| + fs, flags := messageFlagSet(t) |
| + got, err := resolveMutationMessage(fs, flags) |
| + if err != nil || got != "describe this change" { |
| + t.Fatalf("terminal resolve = %q, %v", got, err) |
| + } |
| + |
| + interactiveTerminal = func() bool { return false } |
| + chooserIn = strings.NewReader("must not be read\n") |
| + fs, flags = messageFlagSet(t) |
| + got, err = resolveMutationMessage(fs, flags) |
| + if err != nil || got != "" { |
| + t.Fatalf("non-terminal resolve = %q, %v", got, err) |
| + } |
| +} |
| + |
| +func TestMutationMessageValidatesPromptAnswer(t *testing.T) { |
| + oldTTY, oldIn, oldOut := interactiveTerminal, chooserIn, stdout |
| + t.Cleanup(func() { interactiveTerminal, chooserIn, stdout = oldTTY, oldIn, oldOut }) |
| + interactiveTerminal = func() bool { return true } |
| + chooserIn = bytes.NewReader([]byte{0xff, '\n'}) |
| + stdout = &bytes.Buffer{} |
| + t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "missing-config")) |
| + fs, flags := messageFlagSet(t) |
| + if _, err := resolveMutationMessage(fs, flags); err == nil { |
| + t.Fatal("invalid UTF-8 prompt answer succeeded") |
| + } |
| +} |
| + |
| +func TestMutationMessagesTravelOnJSONWrites(t *testing.T) { |
| + got := map[string]string{} |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var body map[string]any |
| + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| + t.Errorf("%s: decode body: %v", r.URL.Path, err) |
| + } |
| + got[r.URL.Path] = body["message"].(string) |
| + switch { |
| + case strings.HasSuffix(r.URL.Path, "/restart"): |
| + json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "titogo"}) |
| + case strings.HasSuffix(r.URL.Path, "/promote"): |
| + json.NewEncoder(w).Encode(map[string]any{"result": "promoted"}) |
| + case strings.HasSuffix(r.URL.Path, "/features"): |
| + json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "titogo", "features": map[string]string{}, "changed": true}) |
| + case strings.HasSuffix(r.URL.Path, "/config"): |
| + json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0}) |
| + case strings.HasSuffix(r.URL.Path, "/instances"): |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"host": "web.example.test"}) |
| + default: |
| + json.NewEncoder(w).Encode(map[string]any{"result": "ok"}) |
| + } |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + a := apiAcct{Seg: "bab"} |
| + ctx := context.Background() |
| + if _, err := s.restart(ctx, a, "titogo", "restart after the config change"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.promote(ctx, a, "titogo", "abc1234", "edge", false, "release the new checkout"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.rollback(ctx, a, "titogo", "edge", "restore the previous checkout"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.setFeatures(ctx, a, "titogo", map[string]string{"beta": "on"}, nil, "open beta access"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.configApply(ctx, a, "titogo", apiConfigApply{Set: map[string]appconfig.Var{"MODE": {Kind: "env", Value: "live"}}, Message: "switch to live mode"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.instanceCreate(ctx, a, "titogo", "web.example.test", "", "edge", "", "", nil, nil, "add the web copy"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + for path, want := range map[string]string{ |
| + "/api/cli/apps/bab/titogo/restart": "restart after the config change", |
| + "/api/cli/apps/bab/titogo/promote": "release the new checkout", |
| + "/api/cli/apps/bab/titogo/rollback": "restore the previous checkout", |
| + "/api/cli/apps/bab/titogo/features": "open beta access", |
| + "/api/cli/apps/bab/titogo/config": "switch to live mode", |
| + "/api/cli/apps/bab/titogo/instances": "add the web copy", |
| + } { |
| + if got[path] != want { |
| + t.Errorf("%s message = %q, want %q", path, got[path], want) |
| + } |
| + } |
| +} |
| + |
| +func TestMutationMessageTravelsInShipMetadata(t *testing.T) { |
| + var got string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + part, err := mr.NextPart() |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + var meta map[string]any |
| + if err := json.NewDecoder(part).Decode(&meta); err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + got, _ = meta["message"].(string) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"version": "abc1234", "artifacts": map[string]any{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + artifact := filepath.Join(t.TempDir(), "linux-arm64") |
| + if err := os.WriteFile(artifact, []byte("binary"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err := newTestSession(ts.URL).ship(context.Background(), apiAcct{Seg: "bab"}, "titogo", |
| + "abc1234", "binary", "", "", map[string]string{"linux-arm64": artifact}, nil, |
| + "ship the checkout fix") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != "ship the checkout fix" { |
| + t.Fatalf("ship metadata message = %q", got) |
| + } |
| +} |
| + |
| +func TestMutationMessageTravelsOnDeleteHeader(t *testing.T) { |
| + var got string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + got = r.Header.Get("X-Carlos-Message") |
| + w.WriteHeader(http.StatusOK) |
| + })) |
| + defer ts.Close() |
| + if err := newTestSession(ts.URL).instanceDelete(context.Background(), apiAcct{Seg: "bab"}, |
| + "titogo", "old.example.test", "retire the old café copy"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != "retire the old café copy" { |
| + t.Fatalf("delete message header = %q", got) |
| + } |
| +} |
| + |
| +func TestMutationMessageConfigSuppressesPrompt(t *testing.T) { |
| + oldTTY, oldIn := interactiveTerminal, chooserIn |
| + t.Cleanup(func() { interactiveTerminal, chooserIn = oldTTY, oldIn }) |
| + interactiveTerminal = func() bool { return true } |
| + chooserIn = strings.NewReader("must not be read\n") |
| + path := filepath.Join(t.TempDir(), "config") |
| + if err := os.WriteFile(path, []byte(`{"message_prompt":"off"}`), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_CONFIG", path) |
| + fs, flags := messageFlagSet(t) |
| + got, err := resolveMutationMessage(fs, flags) |
| + if err != nil || got != "" { |
| + t.Fatalf("resolve = %q, %v", got, err) |
| + } |
| +} |
| + |
| +func TestMutationMessageValidation(t *testing.T) { |
| + if got, err := validateMutationMessage(strings.Repeat("界", mutationMessageMaxRunes)); err != nil || utf8.RuneCountInString(got) != mutationMessageMaxRunes { |
| + t.Fatalf("240-rune multibyte message = %d runes, %v", utf8.RuneCountInString(got), err) |
| + } |
| + for _, bad := range []string{"two\nlines", "trailing newline\n", "tab\tseparated", strings.Repeat("x", mutationMessageMaxRunes+1), string([]byte{0xff})} { |
| + if _, err := validateMutationMessage(bad); err == nil { |
| + t.Errorf("validateMutationMessage(%q) succeeded", bad) |
| + } |
| + } |
| +} |
diff --git a/restart.go b/restart.go| index 3edad0b3d..bb5f439a9 100644 |
| --- a/restart.go |
| +++ b/restart.go |
| @@ -49,6 +49,7 @@ import ( |
| // write, and the object it touches means nothing without a fleet reading it. |
| func runRestart(args []string) { |
| fs := flag.NewFlagSet("restart", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "app whose instances to cycle") |
| host := fs.String("host", "", "cycle exactly this route, instead of every instance of the app") |
| environment := environmentFlag(fs, "cycle every route reading this named config bundle, instead of every instance of the app") |
| @@ -93,16 +94,24 @@ func runRestart(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| name = resolvedApp(sess, acct, name, usage) |
| + message := "" |
| + if !*dryRun { |
| + var merr error |
| + message, merr = resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("restart: %v", merr) |
| + } |
| + } |
| // Anything narrowed, and any dry run, goes to the door that can resolve a |
| // selector and name what it resolved to. A plain --app takes the original |
| // door, unchanged: it is the one form that works against every console in |
| // the fleet, including one that predates all of this, and an existing |
| // script must not start depending on a door its console may not serve. |
| if hostSel != "" || envSel != "" || *dryRun { |
| - runRestartScoped(sess, acct, name, hostSel, envSel, *dryRun) |
| + runRestartScoped(sess, acct, name, hostSel, envSel, *dryRun, message) |
| return |
| } |
| - res, err := sess.restart(context.Background(), acct, name) |
| + res, err := sess.restart(context.Background(), acct, name, message) |
| if err != nil { |
| log.Fatalf("restart: %v", err) |
| } |
| @@ -150,8 +159,8 @@ Signed-in only (carlos auth login).` |
| // them built was that an operator could not tell what the blast radius would |
| // be, and a narrowed restart that reported no more than the old one would have |
| // fixed the radius and left the blindness. |
| -func runRestartScoped(sess *apiSession, acct apiAcct, app, host, environment string, dryRun bool) { |
| - res, err := sess.restartScoped(context.Background(), acct, app, host, environment, dryRun) |
| +func runRestartScoped(sess *apiSession, acct apiAcct, app, host, environment string, dryRun bool, message string) { |
| + res, err := sess.restartScoped(context.Background(), acct, app, host, environment, dryRun, message) |
| if err != nil { |
| log.Fatalf("restart: %v", err) |
| } |