appconfig: additive exec_delivery manifest field, issue #18's opt-in
Manifest.ExecDelivery ("" | "file") is the app-level opt-in a TCP-addr
exec route's app uses to ask SyncExec for the same /data/<host>.config.env
delivery a socket-exec route already gets. Schema stays 1 — the field is
additive, so an older binary must still parse a manifest a newer one
wrote; ValidExecDelivery is the single source of truth for "known value"
that both the set-time refusal and (in a following commit) SyncExec's
sync-time ignore-with-a-log share.
SetExecDelivery writes/clears it via the same CAS read-modify-write path
as SetEnv/SetSecret/Unset, but — unlike apply's applyAt generalization —
has no environment-scoped variant at all: it always targets Key(acct,
app), never EnvironmentKey(...). A named environment's manifest is never
a valid place for this field (the reader is the app's own supervisor, one
program per app, not per-environment — the "config-that-lies" failure
mode the issue's design comment calls out).
3 files changed,
+177
−0
internal/appconfig/appconfig.go+39 −0internal/appconfig/store.go+49 −0internal/appconfig/store_test.go+89 −0
diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go| index ca4b4e729..846396278 100644 |
| --- a/internal/appconfig/appconfig.go |
| +++ b/internal/appconfig/appconfig.go |
| @@ -22,6 +22,45 @@ type Manifest struct { |
| Schema int `json:"schema"` // 1 |
| App string `json:"app"` |
| Vars map[string]Var `json:"vars"` |
| + // ExecDelivery is the app's opt-in for issue #18's third SyncExec |
| + // eligibility case: a TCP-addr exec route (Backing == "", Socket == "", |
| + // Addr != "") whose process carlos does not spawn or own — an |
| + // app-supervised instance family (titogo's router fleet is the |
| + // motivating case). "" (the zero value, omitted from JSON) means not |
| + // opted in; ExecDeliveryFile ("file") means SyncExec writes this app's |
| + // routes' merged config to <dataDir>/<host>.config.env exactly as it |
| + // does for socket-exec routes — but NEVER bounces them, since carlos |
| + // does not own the process (see syncexec.go). |
| + // |
| + // Additive to schema 1 (schema is not bumped for this field), so any |
| + // other value is refused at SET time (SetExecDelivery, ValidExecDelivery) |
| + // and, since an older binary must still be able to parse a manifest a |
| + // newer one wrote, ignored — loudly, once per pass — at SYNC time |
| + // (see SyncExec's checkOptedIn) rather than causing a hard failure. |
| + // |
| + // APP-LEVEL ONLY: read from the default bundle (config.json) alone. |
| + // SetExecDelivery has no environment-scoped variant, unlike apply's |
| + // applyAt generalization — a named environment's manifest is never a |
| + // valid place for this field, and LoadMerged's merged result never |
| + // carries it forward. The reader is the app's own supervisor, one |
| + // program per app, not per-route or per-environment: per-route or |
| + // per-environment opt-in would let half a fleet read config the other |
| + // half silently ignores, the "config-that-lies" failure mode (issue |
| + // #18 design comment, 2026-08-03). |
| + ExecDelivery string `json:"exec_delivery,omitempty"` |
| +} |
| + |
| +// ExecDeliveryFile is the only non-empty value ValidExecDelivery accepts |
| +// today for Manifest.ExecDelivery — see its doc comment. |
| +const ExecDeliveryFile = "file" |
| + |
| +// ValidExecDelivery reports whether v is a value this binary understands |
| +// for Manifest.ExecDelivery: "" (not opted in) or ExecDeliveryFile. Shared |
| +// by the set-time refusal (SetExecDelivery, appconfig_cli.go's |
| +// runEnvExecDelivery) and SyncExec's sync-time ignore-with-a-log, so the |
| +// two can never define "valid" differently. |
| +func ValidExecDelivery(v string) bool { |
| + return v == "" || v == ExecDeliveryFile |
| } |
| // Var is one config var or secret. Kind selects which of Value/Envelope is |
diff --git a/internal/appconfig/store.go b/internal/appconfig/store.go| index 33883ec37..19d23a570 100644 |
| --- a/internal/appconfig/store.go |
| +++ b/internal/appconfig/store.go |
| @@ -214,3 +214,52 @@ func SetSecret(ctx context.Context, s release.Store, acct, app string, sealed ma |
| func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) (int, error) { |
| return apply(ctx, s, acct, app, DefaultBundle, nil, keys) |
| } |
| + |
| +// SetExecDelivery writes (`file`) or clears (`""`) the default bundle's |
| +// exec_delivery field — issue #18's TCP-addr exec route opt-in (see |
| +// Manifest.ExecDelivery) — via the same CAS read-modify-write path |
| +// SetEnv/SetSecret/Unset use. value must be ValidExecDelivery; anything |
| +// else is refused here, before it ever reaches the bucket, mirroring |
| +// applyAt's var-name/var-value validation. |
| +// |
| +// APP-LEVEL ONLY, structurally: unlike apply's applyAt generalization, |
| +// there is no environment-scoped variant of this function — it always |
| +// writes Key(acct, app), never EnvironmentKey(acct, app, name). A named |
| +// environment's manifest is never a valid place for this field (Manifest. |
| +// ExecDelivery's doc comment has the "config-that-lies" rationale); the |
| +// CLI's -environment flag is refused before it can reach here at all |
| +// (appconfig_cli.go's runEnvExecDelivery). |
| +// |
| +// Turning the field off (value == "") against an app with no default |
| +// bundle at all is refused, not silently turned into a create — the same |
| +// "unset-only against a nonexistent object" refusal applyAt documents, for |
| +// the same reason: minting an empty bundle just to hold an absent flag |
| +// would satisfy `carlos route -environment`'s existence rail and Sync's |
| +// "configured, then fully unset" state for nothing the operator asked for. |
| +func SetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) error { |
| + if !release.ValidAccount(acct) { |
| + return fmt.Errorf("config: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return fmt.Errorf("config: invalid app %q", app) |
| + } |
| + if !ValidExecDelivery(value) { |
| + return fmt.Errorf("config: invalid exec_delivery %q — want %q or \"\" (off)", value, ExecDeliveryFile) |
| + } |
| + key := Key(acct, app) |
| + m, etag, found, err := getForWriteAt(ctx, s, key, app) |
| + if err != nil { |
| + return err |
| + } |
| + if !found && value == "" { |
| + return fmt.Errorf("config: %s has no default bundle — nothing to turn off", app) |
| + } |
| + m.ExecDelivery = value |
| + m.Schema, m.App = 1, app |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, raw, etag) |
| + return err |
| +} |
diff --git a/internal/appconfig/store_test.go b/internal/appconfig/store_test.go| index 186af2492..629911dc1 100644 |
| --- a/internal/appconfig/store_test.go |
| +++ b/internal/appconfig/store_test.go |
| @@ -195,6 +195,92 @@ func TestSetEnvRejectsUnrepresentableValue(t *testing.T) { |
| } |
| } |
| +func TestSetExecDeliveryRoundTrips(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"K": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery(file): %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if m.ExecDelivery != ExecDeliveryFile { |
| + t.Fatalf("ExecDelivery = %q, want %q", m.ExecDelivery, ExecDeliveryFile) |
| + } |
| + // The var set earlier survives — SetExecDelivery is a field write, not |
| + // a bundle replace. |
| + if m.Vars["K"].Value != "1" { |
| + t.Fatalf("Vars[K] = %+v, want value 1 (SetExecDelivery must not clobber existing vars)", m.Vars["K"]) |
| + } |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ""); err != nil { |
| + t.Fatalf("SetExecDelivery(off): %v", err) |
| + } |
| + m, err = Get(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if m.ExecDelivery != "" { |
| + t.Fatalf("ExecDelivery after off = %q, want empty", m.ExecDelivery) |
| + } |
| +} |
| + |
| +func TestSetExecDeliveryRejectsUnknownValue(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"K": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + for _, bad := range []string{"grpc", "FILE", "true", "yes"} { |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", bad); err == nil { |
| + t.Fatalf("SetExecDelivery(%q) succeeded, want a refusal", bad) |
| + } |
| + } |
| + // The refusal must not have touched the bucket object at all. |
| + m, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if m.ExecDelivery != "" { |
| + t.Fatalf("ExecDelivery = %q after a refused set, want untouched (empty)", m.ExecDelivery) |
| + } |
| +} |
| + |
| +func TestSetExecDeliveryOffOnNeverConfiguredAppIsRefused(t *testing.T) { |
| + // Mirrors TestUnsetOnNeverConfiguredAppErrorsAndWritesNothing: turning |
| + // the field off must not mint an empty default bundle just to hold an |
| + // absent flag. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ""); err == nil { |
| + t.Fatal("SetExecDelivery(off) on a never-configured app succeeded, want a refusal") |
| + } |
| + if _, found, gerr := getIfExists(ctx, s, "bab", "titogo"); gerr != nil || found { |
| + t.Fatalf("SetExecDelivery(off) created a manifest object (found = %v, err = %v)", found, gerr) |
| + } |
| +} |
| + |
| +func TestSetExecDeliveryOnNeverConfiguredAppCreatesTheBundle(t *testing.T) { |
| + // The "file" direction DOES create a bundle on first use — same as |
| + // SetEnv/SetSecret already do — since "file" is genuine content to |
| + // store, not an unset-only call against nothing. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery(file): %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if m.ExecDelivery != ExecDeliveryFile { |
| + t.Fatalf("ExecDelivery = %q, want %q", m.ExecDelivery, ExecDeliveryFile) |
| + } |
| +} |
| + |
| func TestNewerSchemaIsRefusedNotDowngraded(t *testing.T) { |
| // json.Unmarshal drops fields this binary doesn't know; acting on a |
| // newer manifest would silently discard them — and a write-back would |
| @@ -211,6 +297,9 @@ func TestNewerSchemaIsRefusedNotDowngraded(t *testing.T) { |
| if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "1"}); err == nil { |
| t.Fatal("SetEnv wrote over a schema-2 manifest, want a refusal") |
| } |
| + if err := SetExecDelivery(ctx, s, "bab", "keymail", ExecDeliveryFile); err == nil { |
| + t.Fatal("SetExecDelivery wrote over a schema-2 manifest, want a refusal") |
| + } |
| got, _, err := s.Get(ctx, Key("bab", "keymail")) |
| if err != nil { |
| t.Fatalf("Get raw: %v", err) |