carlos / platform Public

Merge branch 'worktree-config-write-restart-notice' into main

claude agent, operated by paul@keymail.dev b8fd86c171bc5215a5a958b2f4536782d140d049 parent 6a59497parent 3b86c98
12 files changed, +1381 −84
  • apimode.go +46 −8
  • apimode_test.go +9 −3
  • appconfig_cli.go +237 −23
  • appconfig_restart_test.go +142 −0
  • cli.go +17 −2
  • docs/site/cli.md +11 −0
  • internal/appconfig/awaitingrestart_test.go +263 −0
  • internal/appconfig/crypto.go +31 −0
  • internal/appconfig/crypto_test.go +58 −5
  • internal/appconfig/syncexec.go +274 −42
  • internal/console/apiconfig.go +85 −1
  • internal/console/apiconfigrestart_test.go +208 −0

CI passed — run details

diff --git a/apimode.go b/apimode.go
index 91d190316..0267d6b43 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1897,22 +1897,60 @@ type apiConfigApply struct {
// the server's count of unsets ACTUALLY present, never the argument count —
// the same rule unsetVars already follows for direct mode (a count of
// arguments reads as success for a typo'd key).
-func (s *apiSession) configApply(ctx context.Context, a apiAcct, app string, req apiConfigApply) (int, error) {
+func (s *apiSession) configApply(ctx context.Context, a apiAcct, app string, req apiConfigApply) (apiConfigApplyResult, error) {
status, body, err := s.putJSON(ctx, apiPath(a, app, "/config"), req)
if err != nil {
- return 0, err
+ return apiConfigApplyResult{}, err
}
if status != http.StatusOK {
- return 0, apiErrorFromBody(status, body)
+ return apiConfigApplyResult{}, apiErrorFromBody(status, body)
}
var res struct {
- Result string `json:"result"`
- Removed int `json:"removed"`
+ Result string `json:"result"`
+ Removed int `json:"removed"`
+ Restart *apiConfigRestart `json:"restart"`
}
if err := json.Unmarshal(body, &res); err != nil {
- return 0, fmt.Errorf("config: unreadable response: %w", err)
- }
- return res.Removed, nil
+ return apiConfigApplyResult{}, fmt.Errorf("config: unreadable response: %w", err)
+ }
+ return apiConfigApplyResult{Removed: res.Removed, Restart: res.Restart}, nil
+}
+
+// apiConfigApplyResult is one config write's answer — a struct rather than
+// a bare count for the reason apiPromoteResult is one: a second thing worth
+// returning arrived, and growing a third return value would have meant
+// touching every call site to ignore it.
+type apiConfigApplyResult struct {
+ // Removed is how many vars the unset half ACTUALLY removed, which is
+ // not necessarily how many were asked for.
+ Removed int
+ // Restart is what the write landed on that will not read it, and it
+ // has three states, all meaningful. NIL means the console never
+ // mentioned it — a console older than this field, whose silence must
+ // not be rendered as "nothing to restart", because it is not a claim
+ // at all (the same precedent apiRetentionResult.Eligible sets for a
+ // console that could not compute a number). Present with Known false
+ // means the console looked and could not tell. Present with Known true
+ // means these are the routes, and an empty Hosts there really does mean
+ // none.
+ Restart *apiConfigRestart
+}
+
+// apiConfigRestart mirrors the console's own restart notice across the wire
+// (internal/console/apiconfig.go). Additive: an older console omits it
+// entirely and a newer one may add fields this binary ignores.
+type apiConfigRestart struct {
+ Known bool `json:"known"`
+ Hosts []apiConfigRestartHost `json:"hosts"`
+ Unresolved []string `json:"unresolved"`
+}
+
+// apiConfigRestartHost is one delivered-to route. Unit decides which remedy
+// the renderer offers, so it is carried rather than derived: a plain
+// TCP-addr route is one `carlos restart` cannot cycle at all.
+type apiConfigRestartHost struct {
+ Host string `json:"host"`
+ Unit string `json:"unit"`
}
// configSync asks the console to touch app's generation object — the remote
diff --git a/apimode_test.go b/apimode_test.go
index 6a79e8dfe..c459e9004 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -2873,7 +2873,7 @@ func TestAPISessionConfigApplyUnsetBody(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- removed, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{
+ res, err := s.configApply(context.Background(), apiAcct{Seg: "acct"}, "app", apiConfigApply{
Environment: "",
Unset: []string{"OLD_KEY", "ALSO_MISSING"},
})
@@ -2885,8 +2885,14 @@ func TestAPISessionConfigApplyUnsetBody(t *testing.T) {
}
// removed is the server's ACTUAL count, never the argument count — two
// keys were asked for, only one was really removed.
- if removed != 1 {
- t.Fatalf("removed = %d, want 1 (the server's count, not len(Unset)==2)", removed)
+ if res.Removed != 1 {
+ t.Fatalf("removed = %d, want 1 (the server's count, not len(Unset)==2)", res.Removed)
+ }
+ // A response with no "restart" key at all leaves Restart nil — the
+ // "console said nothing" state, which the CLI renders as silence rather
+ // than as "nothing needs restarting".
+ if res.Restart != nil {
+ t.Fatalf("Restart = %+v, want nil for a response that omits the field", res.Restart)
}
}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 1722a6980..a23bf26c1 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -8,15 +8,154 @@ import (
"crypto/ecdh"
"flag"
"fmt"
+ "io"
"log"
"os"
"sort"
"strings"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
+// restartAnswer is what one config write can be said to have left behind
+// on the box, and it exists to keep THREE outcomes apart where a bool or an
+// empty slice would keep two.
+//
+// A nil *restartAnswer means "no claim was made" — an older console that
+// does not send the field, or a direct-mode run with no registry anywhere
+// near it. Nothing is printed: silence about a question nobody was in a
+// position to ask is honest, where a "could not check" line printed on
+// every laptop-side `env set` would be noise that teaches people to skip
+// the line that matters.
+//
+// known == false means the opposite: someone WAS in a position to look and
+// could not. That gets said out loud. Conflating it with "all clear" is
+// discussion #16's bug, and reproducing it inside the fix would be a poor
+// joke.
+type restartAnswer struct {
+ known bool
+ pending appconfig.RestartPending
+}
+
+// restartFromAPI converts the console's answer, preserving the nil case.
+func restartFromAPI(r *apiConfigRestart) *restartAnswer {
+ if r == nil {
+ return nil
+ }
+ out := &restartAnswer{known: r.Known}
+ out.pending.Unresolved = r.Unresolved
+ for _, h := range r.Hosts {
+ out.pending.Hosts = append(out.pending.Hosts, appconfig.RestartHost{Host: h.Host, Unit: h.Unit})
+ }
+ return out
+}
+
+// localRestartAnswer is the direct-mode (on-box, CARLOS_DEPLOYMENT_BUCKET)
+// half: the same question asked of this box's own registry file.
+//
+// A registry that is not THERE returns nil rather than known:false. Direct
+// mode is equally the mode an operator runs from a laptop against the
+// bucket, where no registry exists, none is expected, and there is no box
+// in the picture to have routes on. A registry that exists but will not
+// open or list is the other thing entirely — a box that should be able to
+// answer and cannot — and that one says so.
+//
+// Deliberately os.Stat before registry.Open: Open CREATES an empty database
+// at a path that has none (see SyncExec's own note on this), so opening
+// optimistically would answer "no routes, nothing to restart" with complete
+// confidence, having just minted the empty file it read that from.
+func localRestartAnswer(ctx context.Context, s release.Store, regPath, acct, app, env string, keys []string) *restartAnswer {
+ path := regPath
+ if _, err := os.Stat(path); err != nil {
+ return nil
+ }
+ reg, err := registry.Open(path)
+ if err != nil {
+ return &restartAnswer{}
+ }
+ defer reg.Close()
+ routes, err := reg.List()
+ if err != nil {
+ return &restartAnswer{}
+ }
+ return &restartAnswer{known: true, pending: appconfig.AwaitingRestart(ctx, s, routes, acct, app, env, keys)}
+}
+
+// printRestartNotice writes the line discussion #16 was filed for: the
+// config write landed, and here is what is still running without it.
+//
+// One renderer for all four writing verbs (env set/unset, secrets
+// set/unset) and both modes, because the thing being reported is a property
+// of the write rather than of the verb — and because four hand-written
+// variants of one sentence is how they end up disagreeing about which
+// routes count.
+func printRestartNotice(w io.Writer, app string, a *restartAnswer) {
+ if a == nil {
+ return
+ }
+ c := colorFor(w)
+ if !a.known {
+ fmt.Fprintf(w, "%s %s\n", c.warn("note:"), restartCopyUnknown)
+ return
+ }
+ if a.pending.Empty() {
+ return
+ }
+ for _, h := range a.pending.Hosts {
+ line := fmt.Sprintf(restartCopyPlain, h.Host)
+ if h.Unit != "" {
+ line = fmt.Sprintf(restartCopyUnit, h.Host, app, h.Unit)
+ }
+ fmt.Fprintf(w, "%s %s\n", c.warn("note:"), line)
+ }
+ for _, h := range a.pending.Unresolved {
+ fmt.Fprintf(w, "%s %s\n", c.warn("note:"), fmt.Sprintf(restartCopyUnresolved, h))
+ }
+ // Once, under the list, rather than on every line: the caveat is about
+ // the LIST — what this box could see — so repeating it per host would be
+ // three copies of one sentence saying nothing more the third time.
+ fmt.Fprintf(w, "%s\n", c.muted(restartCopyScope))
+}
+
+// checkNoEmptyValues refuses a `secrets set` carrying any empty value, and
+// is a returned error rather than an inline log.Fatalf so that it can be
+// tested for what it says as well as for whether it fires.
+//
+// Keys are named, values never — parseKV's rule, for the same reason: a
+// mistyped `secrets set` argument IS a secret, and this text lands in stderr
+// and in shell scrollback. All of them at once, sorted, because a caller who
+// mistyped one field name has usually mistyped several and Go's map order
+// would otherwise pick a different one to complain about each run.
+func checkNoEmptyValues(kv map[string]string) error {
+ var empty []string
+ for k, v := range kv {
+ if v == "" {
+ empty = append(empty, k)
+ }
+ }
+ if len(empty) == 0 {
+ return nil
+ }
+ sort.Strings(empty)
+ return fmt.Errorf(secretsCopyEmpty, strings.Join(empty, ", "))
+}
+
+// The notice's words, in one block because they are one message in four
+// shapes and were reviewed as one. The verb sites format these; they do not
+// write their own.
+const (
+ // Carries its own "secrets set:" prefix, so its one call site prints it
+ // bare rather than prefixing it again.
+ secretsCopyEmpty = "secrets set: %s: empty value: nothing stored. To remove a secret use `carlos secrets unset`; for a deliberately empty value use `carlos env set`. (A command substitution that produced nothing is the usual cause.)"
+ restartCopyPlain = "%s has the file but is still running the old values. carlos routes to this process without owning it. Restart to fix."
+ restartCopyUnit = "%s has the file but is still running the old values. carlos restart --app %s cycles %s."
+ restartCopyUnresolved = "could not tell whether %s picked this up. Its config bundle would not load."
+ restartCopyUnknown = "could not check which routes are still running the old values. This console cannot see the route registry."
+ restartCopyScope = "(this machine's routes only)"
+)
+
const envUsage = `usage: carlos env <command>
commands:
@@ -203,7 +342,7 @@ func runEnvSet(args []string) {
fs := flag.NewFlagSet("env set", flag.ExitOnError)
app := appFlag(fs, "")
environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)")
- acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
@@ -226,28 +365,44 @@ func runEnvSet(args []string) {
for k, v := range kv {
set[k] = appconfig.Var{Kind: "env", Value: v}
}
- if _, err := sess.configApply(context.Background(), acct, app2,
- apiConfigApply{Environment: *environment, Set: set}); err != nil {
+ res, err := sess.configApply(context.Background(), acct, app2,
+ apiConfigApply{Environment: *environment, Set: set})
+ if err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment))
+ printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
return
}
if *app == "" {
log.Fatal(usage)
}
- if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil {
+ store := openStore()
+ if err := saveEnvVars(context.Background(), store, acctOf(), *app, *environment, kv); err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
+ printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, mapKeys(kv)))
+}
+
+// mapKeys is the key set of a KEY=value map, for the writtenKeys argument
+// AwaitingRestart narrows on. Sorted, so a notice is deterministic across
+// runs where Go's map order is not.
+func mapKeys(kv map[string]string) []string {
+ out := make([]string, 0, len(kv))
+ for k := range kv {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
}
func runEnvUnset(args []string) {
fs := flag.NewFlagSet("env unset", flag.ExitOnError)
app := appFlag(fs, "")
environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)")
- acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
@@ -265,23 +420,31 @@ func runEnvUnset(args []string) {
printUsing(os.Stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, usage)
- removed, err := sess.configApply(context.Background(), acct, app2,
+ res, err := sess.configApply(context.Background(), acct, app2,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
}
- fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), app2, bundleLabel(*environment))
+ fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), res.Removed, fs.NArg(), app2, bundleLabel(*environment))
+ printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
return
}
if *app == "" {
log.Fatal(usage)
}
- removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args())
+ store := openStore()
+ removed, err := unsetVars(context.Background(), store, acctOf(), *app, *environment, fs.Args())
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
+ // Gated on removed: an unset that removed nothing moved no file, so
+ // there is nothing for anyone to restart over — the same test the
+ // console door applies before it computes its own notice.
+ if removed > 0 {
+ printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, fs.Args()))
+ }
}
func runEnvList(args []string) {
@@ -392,7 +555,7 @@ func runEnvExecDelivery(args []string) {
app := appFlag(fs, "")
environment := environmentFlag(fs,
"named environment bundle to set the flag in (omit for the app's default bundle)")
- acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
@@ -432,6 +595,12 @@ func runEnvExecDelivery(args []string) {
log.Fatal(err)
}
appName := *app
+ // Turning delivery ON is the write that creates a route's file for the
+ // first time, with the same never-bounced process on the other end of
+ // it, so it carries the same notice as a var write. No keys are passed:
+ // there is nothing to shadow here, and the file appears (or does not)
+ // regardless of which vars the bundle happens to hold.
+ var restart *restartAnswer
if sess != nil {
printUsing(os.Stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
@@ -446,23 +615,28 @@ func runEnvExecDelivery(args []string) {
if value != "" {
wire = value
}
- if _, err := sess.configApply(context.Background(), acct, appName,
- apiConfigApply{Environment: *environment, ExecDelivery: &wire}); err != nil {
+ res, err := sess.configApply(context.Background(), acct, appName,
+ apiConfigApply{Environment: *environment, ExecDelivery: &wire})
+ if err != nil {
log.Fatal(err)
}
+ restart = restartFromAPI(res.Restart)
} else {
if *app == "" {
log.Fatal(usage)
}
- if err := appconfig.SetExecDeliveryAt(context.Background(), openStore(), acctOf(), *app, *environment, value); err != nil {
+ store := openStore()
+ if err := appconfig.SetExecDeliveryAt(context.Background(), store, acctOf(), *app, *environment, value); err != nil {
log.Fatal(err)
}
+ restart = localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, nil)
}
word := value
if value == "" {
word = cleared
}
fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), word, appName, bundleLabel(*environment))
+ printRestartNotice(os.Stdout, appName, restart)
}
// bundleAppName renders --app for a refusal printed BEFORE the app has been
@@ -559,9 +733,15 @@ func runEnvSync(args []string) {
log.Fatal(err)
}
}
- fmt.Printf("%s for %s — exec-backed instances converge within seconds; "+
- "unit-backed routes within seconds where the box's carlos-adopt.path trigger is armed, "+
- "else on the next hourly root pass\n", colorFor(os.Stdout).ok("config sync requested"), appName)
+ // The files, not the processes. The old wording promised that
+ // "exec-backed instances converge within seconds", which is true of the
+ // file and false of the program reading it for exactly the route shape
+ // discussion #16 was filed about — and this is the command people run
+ // while waiting for a change to take effect, so overstating it sends
+ // them off to debug the wrong thing.
+ fmt.Printf("%s for %s the files land within seconds; programs carlos owns restart themselves, "+
+ "and anything else keeps its old values until you restart it\n",
+ colorFor(os.Stdout).ok("config sync requested"), appName)
}
const secretsUsage = `usage: carlos secrets <command>
@@ -656,7 +836,7 @@ func runSecretsSet(args []string) {
fs := flag.NewFlagSet("secrets set", flag.ExitOnError)
app := appFlag(fs, "")
environment := environmentFlag(fs, "named environment bundle to set in (omit for the default bundle)")
- acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
@@ -666,6 +846,30 @@ func runSecretsSet(args []string) {
if err != nil {
log.Fatalf("secrets set: %v", err)
}
+ // Empty values first, and as a group: they are the likeliest thing to
+ // be wrong about a `secrets set` invocation and the cheapest to catch,
+ // and a caller who mistyped one field name has usually mistyped
+ // several. Discussion #16 — values arrive as "$(op read ...)", `op
+ // read` on a field that does not exist prints nothing and exits 0, so
+ // the shell hands us an empty string, we seal it, store it, and report
+ // success. The symptom lands hours later somewhere else entirely: with
+ // both halves of a credential pair empty the app reads "not
+ // configured" and silently offers no OAuth provider; with one empty it
+ // refuses to boot.
+ //
+ // Keys only, never values — parseKV's rule above, for the same reason:
+ // a mistyped `secrets set` argument IS a secret, and this text lands in
+ // stderr and shell scrollback.
+ //
+ // The console door refuses these too (appconfig.CheckEnvelope, which
+ // reads emptiness off the envelope's length). This check exists in
+ // front of it because it is the ONLY one in direct mode, because it
+ // fires before the network round-trip that fetches the sealing key,
+ // and because it can name every offending key at once where the door,
+ // which validates var by var, names the first.
+ if err := checkNoEmptyValues(kv); err != nil {
+ log.Fatal(err)
+ }
// Validate every value while the plaintext is still in hand, before
// either branch below seals anything — after Seal the value is opaque
// forever (`secrets list` never shows values), so a bad one would only
@@ -699,11 +903,13 @@ func runSecretsSet(args []string) {
}
set[k] = appconfig.Var{Kind: "secret", Envelope: env}
}
- if _, err := sess.configApply(context.Background(), acct, app2,
- apiConfigApply{Environment: *environment, Set: set}); err != nil {
+ res, err := sess.configApply(context.Background(), acct, app2,
+ apiConfigApply{Environment: *environment, Set: set})
+ if err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), app2, bundleLabel(*environment))
+ printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
return
}
@@ -719,17 +925,19 @@ func runSecretsSet(args []string) {
}
sealed[k] = env
}
- if err := saveSecrets(context.Background(), openStore(), acctOf(), *app, *environment, sealed); err != nil {
+ store := openStore()
+ if err := saveSecrets(context.Background(), store, acctOf(), *app, *environment, sealed); err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
+ printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, mapKeys(kv)))
}
func runSecretsUnset(args []string) {
fs := flag.NewFlagSet("secrets unset", flag.ExitOnError)
app := appFlag(fs, "")
environment := environmentFlag(fs, "named environment bundle to remove from (omit for the default bundle)")
- acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ acctRaw, acctIDRaw, regPath, acctOf := accountFlagsWithRegistry(fs)
consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
@@ -747,23 +955,29 @@ func runSecretsUnset(args []string) {
printUsing(os.Stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, usage)
- removed, err := sess.configApply(context.Background(), acct, app2,
+ res, err := sess.configApply(context.Background(), acct, app2,
apiConfigApply{Environment: *environment, Unset: fs.Args()})
if err != nil {
log.Fatal(err)
}
- fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), app2, bundleLabel(*environment))
+ fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), res.Removed, fs.NArg(), app2, bundleLabel(*environment))
+ printRestartNotice(os.Stdout, app2, restartFromAPI(res.Restart))
return
}
if *app == "" {
log.Fatal(usage)
}
- removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args())
+ store := openStore()
+ removed, err := unsetVars(context.Background(), store, acctOf(), *app, *environment, fs.Args())
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment))
+ // Gated on removed, exactly as `env unset`'s direct branch is.
+ if removed > 0 {
+ printRestartNotice(os.Stdout, *app, localRestartAnswer(context.Background(), store, *regPath, acctOf(), *app, *environment, fs.Args()))
+ }
}
// printSecretsList renders `secrets list`'s output — header line, then
diff --git a/appconfig_restart_test.go b/appconfig_restart_test.go
new file mode 100644
index 000000000..7ebdff591
--- /dev/null
+++ b/appconfig_restart_test.go
@@ -0,0 +1,142 @@
+package main
+
+// printRestartNotice's three states (discussion #16). The assertions are on
+// STRUCTURE — which hosts are named, which remedy is offered, whether
+// anything is said at all — never on the exact sentences, which are reviewed
+// copy and change without changing what the notice means.
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+)
+
+func TestPrintRestartNoticeSaysNothingWhenNoClaimWasMade(t *testing.T) {
+ var b bytes.Buffer
+ printRestartNotice(&b, "relay", nil)
+ if b.Len() != 0 {
+ t.Fatalf("printed %q for a nil answer, want silence — an older console said nothing, which is not a claim that nothing needs restarting", b.String())
+ }
+}
+
+func TestPrintRestartNoticeSaysNothingWhenNothingPending(t *testing.T) {
+ var b bytes.Buffer
+ printRestartNotice(&b, "relay", &restartAnswer{known: true})
+ if b.Len() != 0 {
+ t.Fatalf("printed %q when the answer is a genuine none", b.String())
+ }
+}
+
+// The one that matters: a console that LOOKED and could not tell must say
+// so, because staying quiet here is the failure being fixed.
+func TestPrintRestartNoticeSpeaksUpWhenItCouldNotTell(t *testing.T) {
+ var b bytes.Buffer
+ printRestartNotice(&b, "relay", &restartAnswer{known: false})
+ if b.Len() == 0 {
+ t.Fatal("printed nothing for known:false — 'could not check' must not render as 'all clear'")
+ }
+}
+
+func TestPrintRestartNoticeNamesEachHost(t *testing.T) {
+ var b bytes.Buffer
+ printRestartNotice(&b, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{
+ Hosts: []appconfig.RestartHost{
+ {Host: "one.example.com"},
+ {Host: "two.example.com"},
+ },
+ }})
+ got := b.String()
+ for _, want := range []string{"one.example.com", "two.example.com"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("output %q does not name %q", got, want)
+ }
+ }
+ // One line per host, plus the single trailing scope caveat.
+ if n := strings.Count(got, "\n"); n != 3 {
+ t.Fatalf("output has %d lines, want one per host plus one caveat:\n%s", n, got)
+ }
+ if n := strings.Count(got, restartCopyScope); n != 1 {
+ t.Fatalf("scope caveat appears %d times, want exactly once:\n%s", n, got)
+ }
+}
+
+// The remedy split. A route whose unit carlos knows gets pointed at a
+// command; a route without one must NOT be, because `carlos restart` is a
+// documented no-op for that shape and sending someone to run it would be a
+// second silent failure answering the first.
+func TestPrintRestartNoticeOffersTheRightRemedy(t *testing.T) {
+ var withUnit bytes.Buffer
+ printRestartNotice(&withUnit, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{
+ Hosts: []appconfig.RestartHost{{Host: "console.example.com", Unit: "console.service"}},
+ }})
+ if !strings.Contains(withUnit.String(), "console.service") {
+ t.Fatalf("output %q does not name the unit that can be cycled", withUnit.String())
+ }
+
+ var plain bytes.Buffer
+ printRestartNotice(&plain, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{
+ Hosts: []appconfig.RestartHost{{Host: "connect.example.com"}},
+ }})
+ if strings.Contains(plain.String(), "carlos restart") {
+ t.Fatalf("output %q offers `carlos restart` for a route it cannot cycle", plain.String())
+ }
+}
+
+func TestPrintRestartNoticeNamesUnresolvedHosts(t *testing.T) {
+ var b bytes.Buffer
+ printRestartNotice(&b, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{
+ Unresolved: []string{"ghost.example.com"},
+ }})
+ if !strings.Contains(b.String(), "ghost.example.com") {
+ t.Fatalf("output %q does not name the host it could not resolve", b.String())
+ }
+}
+
+// checkNoEmptyValues: the report's aside, on the CLI side of the seal. This
+// is the only gate in direct mode, and it fires before the network
+// round-trip that fetches the sealing key.
+func TestCheckNoEmptyValues(t *testing.T) {
+ if err := checkNoEmptyValues(map[string]string{"A": "1", "B": "2"}); err != nil {
+ t.Fatalf("refused a pair of real values: %v", err)
+ }
+ err := checkNoEmptyValues(map[string]string{"CLIENT_ID": "", "CLIENT_SECRET": "", "FINE": "v"})
+ if err == nil {
+ t.Fatal("accepted empty values")
+ }
+ // Every offending key, not just the first — a mistyped `op read` field
+ // usually spoils several at once, and Go's map order would otherwise
+ // name a different one each run.
+ for _, want := range []string{"CLIENT_ID", "CLIENT_SECRET"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("error %q does not name %q", err, want)
+ }
+ }
+ if strings.Contains(err.Error(), "FINE") {
+ t.Fatalf("error %q names a key that was fine", err)
+ }
+ // Sorted, so the message is the same on every run.
+ if i, j := strings.Index(err.Error(), "CLIENT_ID"), strings.Index(err.Error(), "CLIENT_SECRET"); i > j {
+ t.Fatalf("keys are not sorted in %q", err)
+ }
+}
+
+// restartFromAPI's nil-preservation is the wire half of the same three
+// states: a console that omits the field must not be turned into one that
+// claims there is nothing to do.
+func TestRestartFromAPIPreservesNil(t *testing.T) {
+ if got := restartFromAPI(nil); got != nil {
+ t.Fatalf("restartFromAPI(nil) = %+v, want nil", got)
+ }
+ got := restartFromAPI(&apiConfigRestart{
+ Known: true,
+ Hosts: []apiConfigRestartHost{{Host: "h.example.com", Unit: "u.service"}},
+ })
+ if got == nil || !got.known {
+ t.Fatalf("restartFromAPI dropped a present answer: %+v", got)
+ }
+ if len(got.pending.Hosts) != 1 || got.pending.Hosts[0].Unit != "u.service" {
+ t.Fatalf("hosts = %+v, want the unit carried through", got.pending.Hosts)
+ }
+}
diff --git a/cli.go b/cli.go
index e20eac7df..0652d5ac7 100644
--- a/cli.go
+++ b/cli.go
@@ -1643,14 +1643,29 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub
// string-coupled to this function's internal flag name the way
// fs.Lookup("account") would be.
func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) {
+ raw, rawID, _, resolve = accountFlagsWithRegistry(fs)
+ return raw, rawID, resolve
+}
+
+// accountFlagsWithRegistry is accountFlags for the handful of verbs that
+// need the registry PATH as well as the account it resolves — not the flag's
+// default, but whatever --registry actually says.
+//
+// It exists so that those verbs cannot end up reading a different database
+// than the one their own --account was resolved against. A verb that called
+// defaultRegistry() directly would do exactly that the moment anyone passed
+// --registry, and would do it silently, which for `env set`'s restart notice
+// (appconfig_cli.go) would mean answering "which routes did this reach" from
+// the wrong box's registry.
+func accountFlagsWithRegistry(fs *flag.FlagSet) (raw, rawID, regPath *string, resolve func() string) {
// Empty default, two meanings by mode: API mode resolves it to the
// caller's own account (resolvedAccount, apimode.go); direct mode's
// resolveAccount reads "" as ops exactly as it always has, so box and
// script behavior is unchanged.
acct := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one; ops in direct mode)")
acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account; this is the flag an ambiguity error tells you to use")
- regPath := fs.String("registry", defaultRegistry(), "registry database (resolves --account)")
- return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) }
+ reg := fs.String("registry", defaultRegistry(), "registry database (resolves --account)")
+ return acct, acctID, reg, func() string { return resolveAccount(*reg, *acct, *acctID) }
}
func openStore() release.Store {
diff --git a/docs/site/cli.md b/docs/site/cli.md
index aef13a492..22561c166 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -343,6 +343,12 @@ seconds and refuses such a row by name. `carlos instances create` takes
`--environment` too, which is how a route is born on the right bundle rather
than moved onto it.
+Some routes are ones carlos proxies to without owning the process behind them.
+Setting config for one of those writes the file and stops there. The program
+carries on with the old values until its own supervisor restarts it. `set` and
+`unset` say so, naming the routes it applies to, and say when they could not
+work it out rather than staying quiet about it.
+
### carlos secrets
The same shape as `carlos env`, sealed. `list` prints key names and never
@@ -356,6 +362,11 @@ Sealing uses a public key; the private half lives on the box that decrypts
them and never leaves it, in either mode. `carlos secrets genkey` mints the
pair, and it is always local to your terminal.
+An empty value is refused rather than stored. A command substitution that
+produced nothing looks exactly like a real value at the point you type it, and
+a stored empty secret usually surfaces much later as a feature quietly not
+working. Use `carlos env set` for a value that is meant to be empty.
+
### carlos instances
An app's instances are the places it actually runs. `carlos instances enable`
diff --git a/internal/appconfig/awaitingrestart_test.go b/internal/appconfig/awaitingrestart_test.go
new file mode 100644
index 000000000..501ed1d92
--- /dev/null
+++ b/internal/appconfig/awaitingrestart_test.go
@@ -0,0 +1,263 @@
+package appconfig
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// addrRoute is the shape this whole feature is about: an instance carlos
+// proxies to over TCP and neither spawns nor owns — the one SyncExec writes
+// a file for and deliberately never bounces.
+func addrRoute(host, app, acct, env string) registry.Route {
+ return registry.Route{
+ Host: host, Kind: "instance", App: app, Account: acct,
+ Status: "ready", Addr: "127.0.0.1:8080", Environment: env,
+ }
+}
+
+// TestAwaitingRestartNamesOptedInAddrRoute is the base case, and the one
+// the notice exists for: config written, file delivered, process none the
+// wiser.
+func TestAwaitingRestartNamesOptedInAddrRoute(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"CLIENT_ID"})
+ if len(got.Hosts) != 1 || got.Hosts[0].Host != "connect.example.com" {
+ t.Fatalf("Hosts = %+v, want just connect.example.com", got.Hosts)
+ }
+ if got.Hosts[0].Unit != "" {
+ t.Fatalf("Unit = %q, want empty for a route carlos cannot restart", got.Hosts[0].Unit)
+ }
+ if len(got.Unresolved) != 0 {
+ t.Fatalf("Unresolved = %v, want none", got.Unresolved)
+ }
+}
+
+// TestAwaitingRestartSkipsUnoptedAndSocketRoutes pins the two shapes that
+// must never appear in the notice, for opposite reasons: an un-opted addr
+// route never gets a file at all, and a socket route gets one AND gets
+// bounced, so telling someone to restart it by hand would be noise.
+func TestAwaitingRestartSkipsUnoptedAndSocketRoutes(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ socket := registry.Route{
+ Host: "sock.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/sock.sock",
+ }
+ unit := registry.Route{
+ Host: "unit.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Addr: "127.0.0.1:9090", Backing: registry.BackingUnit,
+ }
+ got := AwaitingRestart(ctx, s, []registry.Route{socket, unit}, "bab", "relay", "", []string{"K"})
+ if !got.Empty() {
+ t.Fatalf("got %+v, want nothing: a socket route is bounced and a unit route is the root pass's", got)
+ }
+
+ // The same addr route, with the app never opted in: no file was ever
+ // written for it, so there is nothing to restart over.
+ ctx2, s2, _, _, _, _ := execKit(t)
+ got = AwaitingRestart(ctx2, s2, []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")},
+ "bab", "relay", "", []string{"K"})
+ if !got.Empty() {
+ t.Fatalf("got %+v, want nothing for an app that never opted in", got)
+ }
+}
+
+// TestAwaitingRestartCarriesUnit pins the field that decides which remedy
+// the CLI offers. An addr route naming its own unit (issue #110's consoles)
+// is the one addr shape `carlos restart` can actually cycle.
+func TestAwaitingRestartCarriesUnit(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ rt := addrRoute("console.example.com", "relay", "bab", "")
+ rt.Unit = "console.service"
+ got := AwaitingRestart(ctx, s, []registry.Route{rt}, "bab", "relay", "", nil)
+ if len(got.Hosts) != 1 || got.Hosts[0].Unit != "console.service" {
+ t.Fatalf("Hosts = %+v, want console.service carried through", got.Hosts)
+ }
+}
+
+// TestAwaitingRestartDefaultWriteReachesBoundRoute: a named bundle is an
+// overlay ON the default, not a replacement for it, so a default-bundle
+// write does reach a route bound to an environment — for any key that
+// environment does not itself state.
+func TestAwaitingRestartDefaultWriteReachesBoundRoute(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ if _, err := ApplyEnvironment(ctx, s, "bab", "relay", "prod",
+ map[string]Var{"OWN_KEY": {Kind: "env", Value: "v"}}, nil); err != nil {
+ t.Fatalf("ApplyEnvironment: %v", err)
+ }
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "prod")}
+
+ // A key the bound bundle does NOT state: the merge changes, the file
+ // changes, the process is behind.
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OTHER_KEY"})
+ if len(got.Hosts) != 1 {
+ t.Fatalf("Hosts = %+v, want the bound route for an unshadowed key", got.Hosts)
+ }
+
+ // A key it DOES state: the overlay wins, the merged manifest is
+ // byte-identical, syncExecHost writes nothing. Naming the route here
+ // would send someone to restart a process for a value it was never
+ // going to see.
+ got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY"})
+ if !got.Empty() {
+ t.Fatalf("got %+v, want nothing: OWN_KEY is shadowed by the bound bundle", got)
+ }
+
+ // Mixed: one shadowed, one not — still affected.
+ got = AwaitingRestart(ctx, s, routes, "bab", "relay", "", []string{"OWN_KEY", "OTHER_KEY"})
+ if len(got.Hosts) != 1 {
+ t.Fatalf("Hosts = %+v, want the route when any key is unshadowed", got.Hosts)
+ }
+}
+
+// TestAwaitingRestartNamedWriteOnlyReachesItsOwnRoutes: the other
+// direction. A write to one environment must not name routes bound to
+// another, or to none.
+func TestAwaitingRestartNamedWriteOnlyReachesItsOwnRoutes(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ for _, env := range []string{"prod", "staging"} {
+ if _, err := ApplyEnvironment(ctx, s, "bab", "relay", env,
+ map[string]Var{"K": {Kind: "env", Value: env}}, nil); err != nil {
+ t.Fatalf("ApplyEnvironment(%s): %v", env, err)
+ }
+ }
+ routes := []registry.Route{
+ addrRoute("prod.example.com", "relay", "bab", "prod"),
+ addrRoute("staging.example.com", "relay", "bab", "staging"),
+ addrRoute("plain.example.com", "relay", "bab", ""),
+ }
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "prod", []string{"K"})
+ if len(got.Hosts) != 1 || got.Hosts[0].Host != "prod.example.com" {
+ t.Fatalf("Hosts = %+v, want only prod.example.com", got.Hosts)
+ }
+}
+
+// TestAwaitingRestartOtherAppsAndAccounts is the tenant boundary: routes
+// belong to one app of one account, and a notice that leaked a neighbour's
+// hostname would be a disclosure, not a nicety.
+func TestAwaitingRestartOtherAppsAndAccounts(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ for _, app := range []string{"relay", "other"} {
+ if err := SetExecDelivery(ctx, s, "bab", app, ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery(%s): %v", app, err)
+ }
+ }
+ if err := SetExecDelivery(ctx, s, "zzz", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery(zzz): %v", err)
+ }
+ routes := []registry.Route{
+ addrRoute("mine.example.com", "relay", "bab", ""),
+ addrRoute("otherapp.example.com", "other", "bab", ""),
+ addrRoute("otheracct.example.com", "relay", "zzz", ""),
+ }
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil)
+ if len(got.Hosts) != 1 || got.Hosts[0].Host != "mine.example.com" {
+ t.Fatalf("Hosts = %+v, want only this account's route for this app", got.Hosts)
+ }
+}
+
+// TestAwaitingRestartUnresolvedOnMissingBoundBundle: a route bound to a
+// bundle that does not exist resolves to an ERROR, not to the app default —
+// LoadMerged refuses it and SyncExec leaves the file exactly as it was.
+// Nothing was delivered, so "delivered" would be a lie; but the route is
+// not fine either, so it is not a skip.
+func TestAwaitingRestartUnresolvedOnMissingBoundBundle(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "ghost")}
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil)
+ if len(got.Hosts) != 0 {
+ t.Fatalf("Hosts = %+v, want none: nothing was delivered", got.Hosts)
+ }
+ if len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" {
+ t.Fatalf("Unresolved = %v, want connect.example.com", got.Unresolved)
+ }
+}
+
+// TestAwaitingRestartUnresolvedOnFetchFailure is the same distinction for a
+// transient blip. A read that failed is not a determination either way, and
+// reporting it as "nothing to restart" would be discussion #16's own bug
+// reappearing inside its fix.
+func TestAwaitingRestartUnresolvedOnFetchFailure(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ blip := &failingKeyStore{Store: s, failKey: Key("bab", "relay"), err: errors.New("503 slow down")}
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}
+ got := AwaitingRestart(ctx, blip, routes, "bab", "relay", "", nil)
+ if len(got.Hosts) != 0 {
+ t.Fatalf("Hosts = %+v, want none when the bundle would not read", got.Hosts)
+ }
+ if len(got.Unresolved) != 1 || got.Unresolved[0] != "connect.example.com" {
+ t.Fatalf("Unresolved = %v, want connect.example.com", got.Unresolved)
+ }
+}
+
+// TestAwaitingRestartUnknownExecDeliveryIsOff pins the additive-discipline
+// case: a value a NEWER carlos wrote decides "not opted in" here, exactly as
+// it does in SyncExec, so the notice and the sweep agree about who gets a
+// file even across versions.
+func TestAwaitingRestartUnknownExecDeliveryIsOff(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ // Written straight to the bucket rather than through a setter: the
+ // setters refuse this value by design, and the case being pinned is a
+ // manifest a FUTURE carlos wrote that this binary has to read without
+ // choking on.
+ raw, err := json.Marshal(&Manifest{Schema: 1, ExecDelivery: "socket-someday"})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if _, err := s.Put(ctx, Key("bab", "relay"), raw, ""); err != nil {
+ t.Fatalf("Put: %v", err)
+ }
+ routes := []registry.Route{addrRoute("connect.example.com", "relay", "bab", "")}
+ if got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil); !got.Empty() {
+ t.Fatalf("got %+v, want nothing for an exec_delivery value this binary does not understand", got)
+ }
+}
+
+// TestAwaitingRestartSorted pins determinism: registry order is not stable,
+// and a notice whose lines reshuffle between identical runs is one people
+// stop reading.
+func TestAwaitingRestartSorted(t *testing.T) {
+ ctx, s, _, _, _, _ := execKit(t)
+ if err := SetExecDelivery(ctx, s, "bab", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ routes := []registry.Route{
+ addrRoute("zebra.example.com", "relay", "bab", ""),
+ addrRoute("alpha.example.com", "relay", "bab", ""),
+ addrRoute("middle.example.com", "relay", "bab", ""),
+ }
+ got := AwaitingRestart(ctx, s, routes, "bab", "relay", "", nil)
+ want := []string{"alpha.example.com", "middle.example.com", "zebra.example.com"}
+ if len(got.Hosts) != len(want) {
+ t.Fatalf("Hosts = %+v, want %d", got.Hosts, len(want))
+ }
+ for i, w := range want {
+ if got.Hosts[i].Host != w {
+ t.Fatalf("Hosts[%d] = %q, want %q", i, got.Hosts[i].Host, w)
+ }
+ }
+}
diff --git a/internal/appconfig/crypto.go b/internal/appconfig/crypto.go
index aa2630762..922ec2c3d 100644
--- a/internal/appconfig/crypto.go
+++ b/internal/appconfig/crypto.go
@@ -172,6 +172,37 @@ func CheckEnvelope(envelopeB64 string) error {
if _, err := ecdh.P256().NewPublicKey(raw[:ephLen]); err != nil {
return errors.New("envelope has no valid ephemeral key")
}
+ // LAST, and deliberately after the structural checks: this one is about
+ // the VALUE inside a well-formed envelope, where every check above is
+ // about whether there is a well-formed envelope at all. Run earlier it
+ // would answer "seals an empty value" for a 93-byte run of garbage that
+ // seals nothing — naming a defect the caller does not have, which is
+ // the one thing every refusal here must not do.
+ //
+ // EXACTLY the floor means the plaintext was empty: AES-GCM is
+ // length-preserving, so an envelope carries len(plaintext) bytes beyond
+ // the ephemeral point, the nonce and the tag. That makes emptiness the
+ // one property of a secret's VALUE this side of the seal can read
+ // without a key — and reading it is worth doing, because an empty
+ // secret is almost always a substitution that produced nothing rather
+ // than a value someone meant. Discussion #16: values arriving as
+ // "$(op read ...)" where a mistyped field name returns empty, sealed
+ // and stored and reported as set, surfacing hours later as an OAuth
+ // provider silently not offered.
+ //
+ // Enforced HERE as well as in `carlos secrets set` for the reason the
+ // environment-name check above it is: a door must not depend on its
+ // client's manners. The CLI's check is on the plaintext, before Seal
+ // and before any network; this one is the same rule expressed on the
+ // far side of the seal, and it also covers an older CLI that has not
+ // got the first one.
+ //
+ // A caller who genuinely wants an empty value wants an env var, not a
+ // secret: `carlos env set KEY=` stores one, and it is not sealed
+ // because there is nothing in it to protect.
+ if len(raw) == minLen {
+ return errors.New("envelope seals an empty value. To remove a secret use `carlos secrets unset`. For a deliberately empty value use `carlos env set`")
+ }
return nil
}
diff --git a/internal/appconfig/crypto_test.go b/internal/appconfig/crypto_test.go
index ad8461397..c18237c79 100644
--- a/internal/appconfig/crypto_test.go
+++ b/internal/appconfig/crypto_test.go
@@ -115,10 +115,17 @@ func TestOpenRejectsTamperedCiphertext(t *testing.T) {
}
// TestCheckEnvelopeAcceptsSealed pins the property the console door relies
-// on: anything Seal produces passes the structural check, including the
-// shortest legal envelope (empty plaintext, which is still a full GCM tag).
-// If these two ever diverge, the door starts refusing envelopes the box
-// could have opened.
+// on: anything Seal produces passes the structural check. If these two ever
+// diverge, the door starts refusing envelopes the box could have opened.
+//
+// Empty plaintext is the ONE deliberate exception, and it is the next test
+// rather than a case here. It is not a structural refusal — the envelope is
+// perfectly well formed and the box could open it and find "" — it is a
+// refusal of the VALUE, added because an empty secret is nearly always a
+// substitution that produced nothing (discussion #16). Keeping the two
+// tests apart keeps that distinction where the next person will see it: the
+// invariant this test protects is still "Seal's output is structurally
+// valid", not "the door takes everything".
func TestCheckEnvelopeAcceptsSealed(t *testing.T) {
_, pub, err := GenerateKey()
if err != nil {
@@ -128,7 +135,7 @@ func TestCheckEnvelopeAcceptsSealed(t *testing.T) {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- for _, plaintext := range [][]byte{[]byte("s3kr1t"), {}} {
+ for _, plaintext := range [][]byte{[]byte("s3kr1t"), []byte("x")} {
envelope, err := Seal(pubKey, plaintext)
if err != nil {
t.Fatalf("Seal(%q): %v", plaintext, err)
@@ -139,6 +146,52 @@ func TestCheckEnvelopeAcceptsSealed(t *testing.T) {
}
}
+// TestCheckEnvelopeRefusesEmptyValue pins the length-read the refusal rests
+// on, because it is the whole mechanism and it is not obvious: AES-GCM is
+// length-preserving, so an envelope of EXACTLY ephLen+nonceLen+16 sealed an
+// empty plaintext and one byte longer did not. That is the only fact about a
+// secret's value this side of the seal can know without a key, and if it
+// ever stopped being true this refusal would start rejecting real
+// one-character secrets — hence the boundary is asserted from both sides,
+// against real Seal output rather than against a hand-built buffer.
+func TestCheckEnvelopeRefusesEmptyValue(t *testing.T) {
+ _, pub, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ pubKey, err := ParsePublicKey(pub, "test")
+ if err != nil {
+ t.Fatalf("ParsePublicKey: %v", err)
+ }
+ empty, err := Seal(pubKey, []byte{})
+ if err != nil {
+ t.Fatalf("Seal(empty): %v", err)
+ }
+ err = CheckEnvelope(empty)
+ if err == nil {
+ t.Fatal("CheckEnvelope accepted an envelope sealing an empty value")
+ }
+ if !strings.Contains(err.Error(), "empty value") {
+ t.Fatalf("error %q does not name the defect (want it to mention %q)", err, "empty value")
+ }
+ // The refusal points somewhere useful rather than just saying no: the
+ // two things an operator might actually have meant.
+ for _, want := range []string{"secrets unset", "env set"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("error %q does not offer %q", err, want)
+ }
+ }
+ // One byte over the floor is a real secret and must survive — the
+ // shortest value this check must never eat.
+ oneByte, err := Seal(pubKey, []byte("x"))
+ if err != nil {
+ t.Fatalf("Seal(x): %v", err)
+ }
+ if err := CheckEnvelope(oneByte); err != nil {
+ t.Fatalf("CheckEnvelope rejected a one-byte secret: %v", err)
+ }
+}
+
// TestCheckEnvelopeRefusals walks the structural defects the door has to
// catch at SET time, and asserts on the one thing every refusal must have in
// common besides being an error: it names the DEFECT and never echoes the
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 2987f1f5e..0f0f8e5b6 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -161,46 +161,38 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
if c, done := optedIn[k]; done {
return c
}
- var c optedInRes
// The route's OWN bundle first, and only when it STATES something:
// a named bundle silent on the field inherits the app default,
// exactly as a key it does not set inherits the default bundle's
- // value. Absence is inheritance, never "off" — an operator who has
- // never mentioned exec_delivery in a bundle has not asked for
- // delivery to stop.
- decided := false
+ // value. That PRECEDENCE now lives in resolveExecDelivery, which
+ // AwaitingRestart resolves through too, so the notice an operator
+ // reads and the file this sweep writes can never disagree about
+ // who is opted in. What stays here is what THIS caller owes the
+ // two non-verdict outcomes: a loud line in the sweep's log, once
+ // per bundle per pass, and every file left exactly as it was.
+ var eb envRes
if env != "" {
- eb := loadEnvBundle(acct, app, env)
- switch {
- case eb.err != nil:
+ eb = loadEnvBundle(acct, app, env)
+ if eb.err != nil {
// NOT a determination — the same contract the default
// bundle's fetch-failure branch below gives: say so loudly,
// leave every file alone, retry next pass.
fmt.Printf(" %s/%s: exec_delivery unresolved for environment %q (config fetch failed), leaving this bundle's TCP-addr route file(s) as-is: %v\n", acct, app, env, eb.err)
- c, decided = optedInRes{v: false, known: false}, true
- case eb.found && eb.m.ExecDelivery == ExecDeliveryFile:
- c, decided = optedInRes{v: true, known: true}, true
- case eb.found && eb.m.ExecDelivery != "":
- // A value THIS binary doesn't understand, in a bundle that
- // does state one: a statement, so it decides — treated as
- // not opted in, loudly, once per bundle per pass, never a
- // hard failure (additive discipline, AGENTS.md).
- fmt.Printf(" %s/%s: config: exec_delivery %q in environment %q is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, eb.m.ExecDelivery, env)
- c, decided = optedInRes{v: false, known: true}, true
+ c := optedInRes{v: false, known: false}
+ optedIn[k] = c
+ return c
}
// found-but-silent, and absent, both fall through to the app
- // default. A bundle bound but MISSING is a defect the route's
- // own resolution reports (LoadMerged refuses it) and its file is
- // left as-is there — the verdict reached here never gets to a
- // write in that case.
- }
- if decided {
- optedIn[k] = c
- return c
- }
- def := loadDefault(acct, app)
- switch {
- case def.err != nil:
+ // default inside resolveExecDelivery. A bundle bound but
+ // MISSING is a defect the route's own resolution reports
+ // (LoadMerged refuses it) and its file is left as-is there —
+ // the verdict reached here never gets to a write in that case.
+ }
+ v, err := resolveExecDelivery(eb.m, eb.found, func() (*Manifest, bool, error) {
+ d := loadDefault(acct, app)
+ return d.m, d.found, d.err
+ })
+ if err != nil {
// NOT a determination: this app may well be opted in and this
// box just failed to see it this pass — indistinguishable here
// from any other route's ordinary fetch-failure blip. Silence
@@ -222,22 +214,25 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
// the materialization loop, or reap; its file is untouched by
// construction, not because this function decided to spare
// it. Next pass retries from scratch.
- fmt.Printf(" %s/%s: exec_delivery unresolved (config fetch failed), leaving this app's TCP-addr route file(s) as-is: %v\n", acct, app, def.err)
- c = optedInRes{v: false, known: false}
- case !def.found:
- c = optedInRes{v: false, known: true}
- case def.m.ExecDelivery == ExecDeliveryFile:
- c = optedInRes{v: true, known: true}
- case def.m.ExecDelivery == "":
- c = optedInRes{v: false, known: true}
- default:
+ fmt.Printf(" %s/%s: exec_delivery unresolved (config fetch failed), leaving this app's TCP-addr route file(s) as-is: %v\n", acct, app, err)
+ c := optedInRes{v: false, known: false}
+ optedIn[k] = c
+ return c
+ }
+ if v.Unknown != "" {
// A manifest a NEWER carlos wrote with a value THIS binary
// doesn't understand must not brick this box's sync (additive
// discipline, AGENTS.md) — ignore it, loudly, once, and treat
- // the app as not opted in rather than refusing the whole pass.
- fmt.Printf(" %s/%s: config: exec_delivery %q in the default bundle is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, def.m.ExecDelivery)
- c = optedInRes{v: false, known: true}
+ // the bundle as not opted in rather than refusing the whole
+ // pass. Which bundle said it is named, because "upgrade
+ // carlos" is useless advice without knowing where to look.
+ where := "the default bundle"
+ if v.FromEnvironment {
+ where = fmt.Sprintf("environment %q", env)
+ }
+ fmt.Printf(" %s/%s: config: exec_delivery %q in %s is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, v.Unknown, where)
}
+ c := optedInRes{v: v.On, known: true}
optedIn[k] = c
return c
}
@@ -557,6 +552,243 @@ func ExecConfigApps(routes []registry.Route) [][2]string {
return out
}
+// execDeliveryVerdict is the DECIDED half of exec_delivery resolution
+// (issue #195): given a route's bound bundle and its app's default one,
+// what does the flag say? Pure and log-free, so that the two callers who
+// need the answer — SyncExec's checkOptedIn, which writes the files, and
+// AwaitingRestart, which tells an operator a file was written that nothing
+// has read — reach it through one function rather than through two
+// readings of one rule. A notice that disagreed with the sweep about who
+// gets a file would be worse than no notice: it would name the wrong
+// route, in the exact place an operator went looking for certainty.
+//
+// Fetch FAILURES are deliberately not modelled here. They are not
+// determinations (see checkOptedIn's def.err branch), and the two callers
+// owe them different things — a loud log line and a left-alone file on one
+// side, a named "could not tell" host on the other. A caller that could
+// not read a bundle must not call this.
+type execDeliveryVerdict struct {
+ // On is the only question SyncExec asks: does this bundle get files?
+ On bool
+ // Unknown is non-empty when the DECIDING bundle stated a value this
+ // binary does not understand — decided "off", but for a reason worth
+ // saying out loud (additive discipline, AGENTS.md: a manifest a newer
+ // carlos wrote must not brick an older box, and must not pass silently
+ // either). Carried out rather than logged in place, because the two
+ // callers word it differently and print it to different places.
+ Unknown string
+ // FromEnvironment records WHICH bundle decided, so a caller's message
+ // can name it. False means the app default decided — including the
+ // common case where a named bundle was silent and inherited.
+ FromEnvironment bool
+}
+
+// resolveExecDelivery applies the #195 precedence: the route's OWN bundle
+// first, and only when it STATES something; the app default otherwise.
+// Absence is inheritance, never "off" — an operator who has never
+// mentioned exec_delivery in a bundle has not asked for delivery to stop.
+//
+// loadDefault is a thunk rather than a value because the default bundle
+// must not be FETCHED when the named one already decided: eager fetching
+// would let an unrelated blip on the default's read turn a decided verdict
+// into an unresolved one, and would cost a bucket read per call on the
+// path where the answer was already in hand.
+func resolveExecDelivery(envM *Manifest, envFound bool, loadDefault func() (*Manifest, bool, error)) (execDeliveryVerdict, error) {
+ if envFound && envM.ExecDelivery == ExecDeliveryFile {
+ return execDeliveryVerdict{On: true, FromEnvironment: true}, nil
+ }
+ if envFound && envM.ExecDelivery != "" {
+ return execDeliveryVerdict{Unknown: envM.ExecDelivery, FromEnvironment: true}, nil
+ }
+ defM, defFound, err := loadDefault()
+ if err != nil {
+ return execDeliveryVerdict{}, err
+ }
+ switch {
+ case !defFound:
+ return execDeliveryVerdict{}, nil
+ case defM.ExecDelivery == ExecDeliveryFile:
+ return execDeliveryVerdict{On: true}, nil
+ case defM.ExecDelivery == "":
+ return execDeliveryVerdict{}, nil
+ default:
+ return execDeliveryVerdict{Unknown: defM.ExecDelivery}, nil
+ }
+}
+
+// RestartHost names one route a config write delivered a file to and that
+// nothing bounced. Unit is carried because it decides the REMEDY, and the
+// two remedies are not interchangeable: an addr route naming its own unit
+// (issue #110's consoles) is the one addr shape `carlos restart` can
+// actually cycle, where a plain one reaches nothing at all — the agent's
+// restart leg skips it (internal/host/restart.go, the rt.Unit != "" test)
+// and the activator it would otherwise ask has no child for a process
+// carlos never spawned. Telling an operator to run a command that is a
+// documented no-op for their route would be a second silent failure
+// answering the first.
+type RestartHost struct {
+ Host string
+ Unit string
+}
+
+// RestartPending is what a config write left behind on ONE box: files that
+// landed for processes carlos does not own, and therefore does not restart.
+//
+// Unresolved is the honest half and the reason this is not a plain
+// []string. A route whose exec_delivery could not be resolved this call —
+// its bundle unreadable, or the bundle it is bound to missing outright —
+// is a route nothing can say "delivered" or "not delivered" about. Dropping
+// it silently would commit the very conflation this notice exists to end:
+// "nothing needs a restart" and "I could not tell" rendered identically is
+// how discussion #16 got filed in the first place.
+type RestartPending struct {
+ Hosts []RestartHost
+ Unresolved []string
+}
+
+// Empty reports whether there is nothing to say — no host delivered to and
+// none unresolved. The renderers' one test, so "say nothing" is one
+// judgement rather than four.
+func (r RestartPending) Empty() bool { return len(r.Hosts) == 0 && len(r.Unresolved) == 0 }
+
+// AwaitingRestart answers, for a config write to app's envName bundle
+// ("" = the default), which of this box's routes were handed a file that
+// nothing will make their process read. It is the reporting half of the
+// ownership line SyncExec draws: carlos writes these files and
+// deliberately never bounces the processes behind them, so the only thing
+// that can close the loop is telling the person who just ran the command.
+// See discussion #16 — `secrets set` reported success, `secrets list`
+// confirmed the key, and the value reached nothing for want of one line.
+//
+// keys are the var names the write touched (set and unset together), used
+// ONLY to skip routes whose merged config the write cannot have changed —
+// see the shadowing test below. An empty slice disables that narrowing,
+// which is what a write with no vars in it (turning exec_delivery on)
+// needs: there is nothing to shadow, and the file appears regardless.
+//
+// Eligibility is execAddrCandidate plus the opt-in, which is exactly
+// SyncExec's inScope for the addr shape — reached through the same
+// predicate and the same resolveExecDelivery, from the same file, for the
+// same reason ExecConfigApps is exported from here.
+//
+// SCOPE, and it is a real limit: routes is whatever the caller could see,
+// which is one box's registry (the #145 blind spot internal/console/
+// apiroutes.go documents). This function cannot know what it was not
+// shown, so its callers say what they looked at rather than letting a
+// short list read as a complete one.
+func AwaitingRestart(ctx context.Context, s release.Store, routes []registry.Route, acct, app, envName string, keys []string) RestartPending {
+ var out RestartPending
+ // Both caches exist for the same reason SyncExec's do: a box with many
+ // TCP-addr routes on one bundle must not cost more than one read for
+ // it. Unlike SyncExec's, they live for one call.
+ var (
+ defM *Manifest
+ defFound bool
+ defErr error
+ defDone bool
+ )
+ loadDefault := func() (*Manifest, bool, error) {
+ if !defDone {
+ defM, defFound, defErr = getIfExists(ctx, s, acct, app)
+ defDone = true
+ }
+ return defM, defFound, defErr
+ }
+ type envRes struct {
+ m *Manifest
+ found bool
+ err error
+ }
+ envBundles := map[string]envRes{}
+ loadEnv := func(env string) envRes {
+ if e, done := envBundles[env]; done {
+ return e
+ }
+ m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, env))
+ e := envRes{m: m, found: found, err: err}
+ envBundles[env] = e
+ return e
+ }
+ for _, rt := range routes {
+ if !execAddrCandidate(rt) || rt.App != app || release.RouteAccount(rt) != acct {
+ continue
+ }
+ // Which routes a write REACHES. A named bundle is read by the
+ // routes bound to it and by nothing else. The default bundle is
+ // read by every route of the app, bound or not, because a named
+ // bundle is an overlay ON it (environments.go, LoadMerged) rather
+ // than a replacement for it.
+ if envName != "" && rt.Environment != envName {
+ continue
+ }
+ eb := envRes{}
+ if rt.Environment != "" {
+ eb = loadEnv(rt.Environment)
+ if eb.err != nil {
+ out.Unresolved = append(out.Unresolved, rt.Host)
+ continue
+ }
+ // A route bound to a bundle that does not exist resolves to an
+ // ERROR, not to the app default: LoadMerged refuses it, and
+ // SyncExec leaves the route's file exactly as it was. Nothing
+ // was delivered, so "delivered" would be a lie — but neither
+ // is this route fine, so it is not a skip either.
+ if !eb.found {
+ out.Unresolved = append(out.Unresolved, rt.Host)
+ continue
+ }
+ }
+ v, err := resolveExecDelivery(eb.m, eb.found, loadDefault)
+ if err != nil {
+ out.Unresolved = append(out.Unresolved, rt.Host)
+ continue
+ }
+ // Not opted in — including the Unknown case, which decides "off"
+ // — means no file was ever written for this route, by this write
+ // or any other. Nothing to say.
+ if !v.On {
+ continue
+ }
+ // The shadowing test, and the reason keys is a parameter: a write
+ // to the DEFAULT bundle changes what a bound route resolves to
+ // only for keys that route's own bundle does not state. Where every
+ // touched key is shadowed, the merged manifest is byte-identical,
+ // syncExecHost's bytes.Equal writes nothing, and naming the route
+ // would send someone to restart a process for a value it was never
+ // going to see.
+ //
+ // Only for a default-bundle write reaching a BOUND route: a write
+ // to the route's own bundle is never shadowed, and an unbound
+ // route has nothing above it.
+ if envName == "" && rt.Environment != "" && len(keys) > 0 && allShadowed(keys, eb.m) {
+ continue
+ }
+ out.Hosts = append(out.Hosts, RestartHost{Host: rt.Host, Unit: rt.Unit})
+ }
+ // Sorted so a caller's output — and any test pinning it — is stable
+ // across passes, where registry order is not. Same discipline as
+ // ExecConfigApps'.
+ sort.Slice(out.Hosts, func(i, j int) bool { return out.Hosts[i].Host < out.Hosts[j].Host })
+ sort.Strings(out.Unresolved)
+ return out
+}
+
+// allShadowed reports whether every one of keys is STATED by m — the test
+// that decides whether a default-bundle write can have changed what a route
+// bound to m actually resolves to. An unset key counts the same as a set
+// one: removing a key the overlay shadows changes the merge by nothing.
+func allShadowed(keys []string, m *Manifest) bool {
+ if m == nil {
+ return false
+ }
+ for _, k := range keys {
+ if _, ok := m.Vars[k]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
// execAddrConfigHeader is prepended to a TCP-addr (issue #18) route's
// <host>.config.env — and ONLY that shape. RenderEnv itself stays
// header-free, deliberately: it is shared by sync.go's unit path AND this
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go
index 722b97bb3..24d7911d4 100644
--- a/internal/console/apiconfig.go
+++ b/internal/console/apiconfig.go
@@ -469,7 +469,91 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+*req.ExecDelivery+" ("+bundle+") (cli)")
s.bumpSerial(ctx)
}
- writeJSON(w, http.StatusOK, map[string]any{"result": "ok", "removed": removed})
+ out := map[string]any{"result": "ok", "removed": removed}
+ // Whether this write landed on a process that will never read it
+ // (discussion #16). Computed AFTER both writes, because it describes
+ // what they left behind, and only when something actually changed: an
+ // unset of keys that were not there removed nothing and moved no file,
+ // so there is nothing for anyone to restart over.
+ if len(sets) > 0 || removed > 0 || req.ExecDelivery != nil {
+ out["restart"] = s.configRestartNotice(ctx, acct.Sqid, app, envName, writtenKeys(sets, req.Unset))
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// apiConfigRestart is the config door's answer to "did that reach
+// anything?" — the wire half of appconfig.AwaitingRestart.
+//
+// Known is the field that carries the whole point. A console with no
+// registry, or one whose registry will not open, has not looked and must
+// not answer with an empty list: "nothing needs restarting" and "I could
+// not tell" rendering identically is the failure discussion #16 reported,
+// and answering it with a second instance of itself would be worse than
+// saying nothing. The CLI renders the two differently, and a client that
+// predates the field sees no `restart` key at all — see apimode.go, where
+// the field is a pointer for exactly that third state.
+type apiConfigRestart struct {
+ Known bool `json:"known"`
+ // Hosts got a file that nothing will bounce. Unit, when set, is the
+ // systemd unit that owns the process — the difference between an
+ // operator who can be pointed at a command and one who has to reach
+ // for their own supervisor.
+ Hosts []apiConfigRestartHost `json:"hosts,omitempty"`
+ // Unresolved got neither a yes nor a no: their bundle would not read,
+ // or the bundle they are bound to is missing.
+ Unresolved []string `json:"unresolved,omitempty"`
+}
+
+type apiConfigRestartHost struct {
+ Host string `json:"host"`
+ Unit string `json:"unit,omitempty"`
+}
+
+// configRestartNotice resolves the notice for one config write, or reports
+// that it could not.
+//
+// SCOPE: the console reads its OWN box's registry, so in a multi-box fleet
+// the other boxes' routes are not represented here — the same blind spot
+// handleAPIRoutes documents at the top of apiroutes.go, and internal/
+// fleetapi (#145) is the eventual fix. The CLI says what it looked at
+// rather than letting a short list read as a complete one.
+func (s *Server) configRestartNotice(ctx context.Context, acct, app, envName string, keys []string) apiConfigRestart {
+ if s.cfg.Registry == "" {
+ // Opening "" would hand sqlite a throwaway temp DB and answer
+ // "no routes" having created a file to say it — handleAPIRoutes'
+ // twin guard, and the same reasoning.
+ return apiConfigRestart{}
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: api config: registry %s: %v", s.cfg.Registry, err)
+ return apiConfigRestart{}
+ }
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("console: api config: registry list: %v", err)
+ return apiConfigRestart{}
+ }
+ p := appconfig.AwaitingRestart(ctx, s.cfg.Releases, routes, acct, app, envName, keys)
+ out := apiConfigRestart{Known: true, Unresolved: p.Unresolved}
+ for _, h := range p.Hosts {
+ out.Hosts = append(out.Hosts, apiConfigRestartHost{Host: h.Host, Unit: h.Unit})
+ }
+ return out
+}
+
+// writtenKeys is the var names one config write touched, set and unset
+// together — what AwaitingRestart needs to tell whether a default-bundle
+// write can have changed anything a BOUND route resolves to. Sets and
+// unsets are one list because the shadowing question does not care which
+// it was: a key the route's own bundle states is a key this write did not
+// move for that route, whichever way it moved elsewhere.
+func writtenKeys(sets map[string]appconfig.Var, unset []string) []string {
+ out := make([]string, 0, len(sets)+len(unset))
+ for k := range sets {
+ out = append(out, k)
+ }
+ return append(out, unset...)
}
// handleAPIConfigSync answers `carlos env sync`: touch the app's generation
diff --git a/internal/console/apiconfigrestart_test.go b/internal/console/apiconfigrestart_test.go
new file mode 100644
index 000000000..4cd98f43b
--- /dev/null
+++ b/internal/console/apiconfigrestart_test.go
@@ -0,0 +1,208 @@
+package console
+
+// The config door's restart notice (discussion #16): a write that lands on
+// a process carlos does not own comes back naming it, so the CLI can say so
+// instead of reporting a bare success. And the empty-secret refusal from the
+// same report, which this door can enforce without a key because AES-GCM is
+// length-preserving.
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// restartOf pulls the `restart` object out of a config PUT's response.
+// found==false means the door said nothing about it at all, which is a
+// distinct answer from an empty one and is asserted as such below.
+func restartOf(t *testing.T, out map[string]any) (map[string]any, bool) {
+ t.Helper()
+ r, ok := out["restart"].(map[string]any)
+ return r, ok
+}
+
+// restartHosts lists the hostnames in a restart object, in order.
+func restartHosts(t *testing.T, r map[string]any) []string {
+ t.Helper()
+ raw, _ := r["hosts"].([]any)
+ var out []string
+ for _, h := range raw {
+ m, _ := h.(map[string]any)
+ out = append(out, str(t, m, "host"))
+ }
+ return out
+}
+
+// optIn turns exec_delivery on for an app's default bundle — the opt-in that
+// makes a TCP-addr route eligible for a delivered file in the first place.
+func optIn(t *testing.T, fx *consoleFixture, app string) {
+ t.Helper()
+ if err := appconfig.SetExecDelivery(context.Background(), fx.rel, release.OpsAccount, app, appconfig.ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+}
+
+// TestAPIConfigPutNamesRoutesAwaitingRestart is the door's half of the
+// report. routesFixture's connect.go.tito.io is exactly the shape that was
+// filed: a hand-added TCP-addr relay bound to the "relay" environment, with
+// no instance record anywhere and nothing that will ever bounce it.
+func TestAPIConfigPutNamesRoutesAwaitingRestart(t *testing.T) {
+ fx, token := routesFixture(t, "code-restart-named")
+ optIn(t, fx, "titogo")
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "relay",
+ "set": map[string]any{"LOG_LEVEL": map[string]any{"kind": "env", "value": "debug"}},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT = %d %v", status, out)
+ }
+ r, ok := restartOf(t, out)
+ if !ok {
+ t.Fatalf("response carries no restart object: %v", out)
+ }
+ if r["known"] != true {
+ t.Fatalf("known = %v, want true — this console has a registry and read it", r["known"])
+ }
+ got := restartHosts(t, r)
+ if len(got) != 1 || got[0] != "connect.go.tito.io" {
+ t.Fatalf("hosts = %v, want just connect.go.tito.io", got)
+ }
+}
+
+// TestAPIConfigPutRestartUnknownWithoutRegistry is the field that carries
+// the whole point. A console that cannot see a registry has not looked, and
+// must not answer with an empty list: "nothing needs restarting" and "I
+// could not tell" rendering identically IS the bug being fixed.
+func TestAPIConfigPutRestartUnknownWithoutRegistry(t *testing.T) {
+ fx, token := routesFixture(t, "code-restart-noreg")
+ optIn(t, fx, "titogo")
+ fx.srv.cfg.Registry = ""
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "relay",
+ "set": map[string]any{"LOG_LEVEL": map[string]any{"kind": "env", "value": "debug"}},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT = %d %v", status, out)
+ }
+ r, ok := restartOf(t, out)
+ if !ok {
+ t.Fatalf("response carries no restart object: %v", out)
+ }
+ if r["known"] != false {
+ t.Fatalf("known = %v, want false for a console with no registry", r["known"])
+ }
+ if hosts := restartHosts(t, r); len(hosts) != 0 {
+ t.Fatalf("hosts = %v, want none alongside known:false", hosts)
+ }
+}
+
+// TestAPIConfigPutRestartSilentForUnoptedApp: the notice appears only where
+// a file is actually delivered. An app that never opted into exec delivery
+// has routes, and they get nothing, so there is nothing to restart over —
+// and this is a genuine "none", not a "could not tell".
+func TestAPIConfigPutRestartSilentForUnoptedApp(t *testing.T) {
+ fx, token := routesFixture(t, "code-restart-unopted")
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "relay",
+ "set": map[string]any{"LOG_LEVEL": map[string]any{"kind": "env", "value": "debug"}},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT = %d %v", status, out)
+ }
+ r, ok := restartOf(t, out)
+ if !ok {
+ t.Fatalf("response carries no restart object: %v", out)
+ }
+ if r["known"] != true {
+ t.Fatalf("known = %v, want true — the registry read fine, the answer is genuinely none", r["known"])
+ }
+ if hosts := restartHosts(t, r); len(hosts) != 0 {
+ t.Fatalf("hosts = %v, want none for an app that never opted in", hosts)
+ }
+}
+
+// TestAPIConfigPutRestartAbsentWhenNothingChanged: an unset that removed
+// nothing moved no file, so the door does not speculate about restarts.
+func TestAPIConfigPutRestartAbsentWhenNothingChanged(t *testing.T) {
+ fx, token := routesFixture(t, "code-restart-noop")
+ optIn(t, fx, "titogo")
+ // The bundle has to exist: unsetting from one that does not is a 409
+ // refusal, which is a different answer than "removed nothing".
+ if status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "relay",
+ "set": map[string]any{"REAL_KEY": map[string]any{"kind": "env", "value": "v"}},
+ }, token); status != http.StatusOK {
+ t.Fatalf("seed PUT = %d %v", status, out)
+ }
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "environment": "relay",
+ "unset": []string{"NEVER_SET"},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT = %d %v", status, out)
+ }
+ if r, ok := restartOf(t, out); ok {
+ t.Fatalf("restart = %v, want the field absent when the write removed nothing", r)
+ }
+}
+
+// TestAPIConfigPutRefusesEmptySecret pins the door's half of the empty-value
+// refusal (the report's aside). The console holds no private key, so it
+// reads emptiness the only way it can: AES-GCM is length-preserving, so an
+// envelope at exactly the floor sealed nothing.
+//
+// The refusal must also leave the bucket untouched — a rejected write that
+// still stored something would be worse than the silence it replaces.
+func TestAPIConfigPutRefusesEmptySecret(t *testing.T) {
+ fx, token := configFixture(t, "code-config-empty-secret")
+ _, _, envelope := sealedFor(t, "")
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"CLIENT_SECRET": map[string]any{"kind": "secret", "envelope": envelope}},
+ }, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("PUT = %d %v, want 400", status, out)
+ }
+ msg := str(t, out, "error")
+ for _, want := range []string{"empty value", "secrets unset", "env set"} {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("refusal %q does not mention %q", msg, want)
+ }
+ }
+ if strings.Contains(msg, envelope) {
+ t.Fatalf("refusal echoes the envelope back: %q", msg)
+ }
+ if _, found := storedManifest(t, fx, "titogo"); found {
+ t.Fatal("a refused empty secret still wrote a manifest")
+ }
+}
+
+// TestAPIConfigPutAcceptsOneByteSecret is the boundary from the other side:
+// the shortest real secret is one byte longer than an empty one, and the
+// length read must never eat it.
+func TestAPIConfigPutAcceptsOneByteSecret(t *testing.T) {
+ fx, token := configFixture(t, "code-config-one-byte")
+ _, _, envelope := sealedFor(t, "x")
+
+ status, out := putJSON(t, configURL(fx, "titogo"), map[string]any{
+ "set": map[string]any{"CLIENT_SECRET": map[string]any{"kind": "secret", "envelope": envelope}},
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("PUT = %d %v, want 200 for a one-byte secret", status, out)
+ }
+ m, found := storedManifest(t, fx, "titogo")
+ if !found {
+ t.Fatal("a one-byte secret was accepted but stored nothing")
+ }
+ if m.Vars["CLIENT_SECRET"].Envelope != envelope {
+ t.Fatal("stored envelope is not the one that was sent")
+ }
+}