carlos / platform Public

Merge branch 'console-ux-08-cli' into main

paul@keymail.dev 08f278912022513e88914bad7767978286d7e938 parent 0bc32c4parent c1bedfb
12 files changed, +507 −50
  • apimode.go +40 −21
  • appconfig_cli.go +18 −3
  • cli.go +21 −5
  • config.go +8 −0
  • deploy.go +18 −8
  • deploy_test.go +47 −6
  • docs/site/cli.md +20 −0
  • features_cli.go +6 −1
  • instances_cli.go +12 −2
  • mutation_message.go +70 −0
  • mutation_message_test.go +234 −0
  • restart.go +13 −4

CI cancelled — superseded by a newer push — run details

diff --git a/apimode.go b/apimode.go
index abe737731..eeffedb27 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, message 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
@@ -2760,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"`
@@ -2769,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
@@ -3160,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, message string) error {
- status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), message)
+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
}
@@ -3265,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"`
@@ -3273,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
@@ -3387,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()
@@ -3411,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 12dc48b93..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)
}