| new file mode 100644 |
| index 000000000..b0a1cef75 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-16-route-scoped-config-delivery.md |
| @@ -0,0 +1,2028 @@ |
| +# Route-scoped config delivery — implementation plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Close #195, #196 and #64 — the three defects in titogo's |
| +"deliver one sealed credential to one service" runbook: scope |
| +`exec_delivery` to the route's bound environment, make a route's |
| +environment binding converge in seconds instead of an hour, and let a |
| +member read a route's binding back without box access. |
| + |
| +**Architecture:** Three independent changes on one branch. (1) |
| +`SyncExec`'s opt-in check reads the route's bound environment bundle, |
| +falling back to the app default — an overlay, exactly like vars. (2) The |
| +agent's fast poll loop fingerprints the registry-side facts that decide |
| +what config a route gets, so a binding change is a change it can see. (3) |
| +A new account-scoped `GET /api/cli/apps/{account}/{app}/routes` served |
| +from the console's own registry, fronted by `carlos routes`. |
| + |
| +**Tech Stack:** Go 1.x, stdlib only. `internal/appconfig` (config plane), |
| +`internal/host` (agent loops), `internal/console` (HTTP API), root package |
| +(CLI). |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md` |
| + |
| +## Global Constraints |
| + |
| +- **Gate before every commit:** `go build ./...`, `go vet ./...`, |
| + `go test ./... -count=1` all clean, every package; `gofmt -l` clean for |
| + files you touched. Sandbox note: use |
| + `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go |
| + commands may need the sandbox disabled ("read-only file system" from the |
| + toolchain is the tell). |
| +- **Wire-shape changes are additive.** Live manifests exist in production |
| + buckets; anything written before this change must still parse, and an |
| + older carlos parsing a bundle this writes must not break. |
| +- **TDD.** Write the failing test, run it, watch it fail for the right |
| + reason, then implement. |
| +- **Nothing merges to `main` without a PR.** Never offer or take a local |
| + merge to `main`. |
| +- **A test that shells out to the carlos binary must build its env through |
| + `carlosTestEnv`** and set `cmd.Dir` to a scratch dir. |
| +- **Do not change `LoadMerged`.** The merged manifest stays vars-only and |
| + never carries `ExecDelivery`. The opt-in is resolved by reading the two |
| + bundles' fields directly. |
| +- Vocabulary, fixed: at app scope the CLI words are `file` / `off`; at |
| + environment scope they are `file` / `inherit`, and `off` is refused |
| + there by name. |
| + |
| +--- |
| + |
| +### Task 1: Environment-scoped `exec_delivery` write path |
| + |
| +**Files:** |
| +- Modify: `internal/appconfig/store.go:229-283` (`SetExecDelivery`) |
| +- Modify: `internal/appconfig/appconfig.go:22-56` (`Manifest.ExecDelivery` |
| + doc comment, `ValidExecDelivery`) |
| +- Test: `internal/appconfig/environments_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `getForWriteAt(ctx, s, key, app) (Manifest, string, bool, error)`, |
| + `Key(acct, app) string`, `EnvironmentKey(acct, app, name) string`, |
| + `touchLogged(ctx, s, acct, app)`, `DefaultBundle = "default"`. |
| +- Produces: |
| + `func SetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) error` |
| + — `envName == ""` writes the default bundle (identical to today), |
| + otherwise the named bundle. `SetExecDelivery` stays as a wrapper with |
| + its existing signature; every current caller keeps working unchanged. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/appconfig/environments_test.go`. Use whatever |
| +store/helper constructor the neighbouring tests in that file already use |
| +(read the top of the file first — do not invent one). |
| + |
| +```go |
| +func TestSetExecDeliveryAtEnvironmentRoundTrips(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) // match this file's existing helper |
| + // A bundle must exist before a flag can be written into it, exactly as |
| + // the default bundle must. |
| + if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay", |
| + map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil { |
| + t.Fatalf("set: %v", err) |
| + } |
| + m, err := LoadEnvironment(ctx, s, "bab", "titogo", "relay") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.ExecDelivery != ExecDeliveryFile { |
| + t.Fatalf("environment bundle exec_delivery = %q, want %q", m.ExecDelivery, ExecDeliveryFile) |
| + } |
| + // The vars already in the bundle survive the flag write — this is a |
| + // read-modify-write, not a replace. |
| + if m.Vars["A"].Value != "1" { |
| + t.Fatalf("A = %q, want it preserved at \"1\"", m.Vars["A"].Value) |
| + } |
| + // And the DEFAULT bundle is untouched: an environment-scoped write that |
| + // leaked into config.json would opt the whole fleet in, which is the |
| + // bug this closes. |
| + def, found, err := getIfExists(ctx, s, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if found && def.ExecDelivery != "" { |
| + t.Fatalf("default bundle exec_delivery = %q, want it untouched", def.ExecDelivery) |
| + } |
| +} |
| + |
| +func TestSetExecDeliveryAtEnvironmentClearsToInherit(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay", |
| + map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ""); err != nil { |
| + t.Fatalf("clear: %v", err) |
| + } |
| + m, err := LoadEnvironment(ctx, s, "bab", "titogo", "relay") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.ExecDelivery != "" { |
| + t.Fatalf("exec_delivery = %q, want %q (inherit)", m.ExecDelivery, "") |
| + } |
| +} |
| + |
| +func TestSetExecDeliveryAtRefusesAbsentBundle(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + // Nothing has ever been written for this app. |
| + err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", "") |
| + if err == nil { |
| + t.Fatal("clearing the flag on a bundle that does not exist must be refused, not silently create one") |
| + } |
| + if !strings.Contains(err.Error(), "relay") { |
| + t.Fatalf("refusal %q must name the bundle it could not find", err) |
| + } |
| +} |
| + |
| +func TestSetExecDeliveryAtRefusesInvalidValue(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay", |
| + map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", "socket"); err == nil { |
| + t.Fatal("an unknown exec_delivery value must be refused before it reaches the bucket") |
| + } |
| +} |
| + |
| +func TestSetExecDeliveryAtBumpsGeneration(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay", |
| + map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, before, err := s.Get(ctx, GenKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, after, err := s.Get(ctx, GenKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == after { |
| + t.Fatal("an environment-scoped exec_delivery write must bump the generation object — the fast loop watches nothing else for it") |
| + } |
| +} |
| +``` |
| + |
| +Add `"strings"` to that file's imports if it is not already there. |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/appconfig/ -run TestSetExecDeliveryAt -count=1` |
| +Expected: FAIL — `undefined: SetExecDeliveryAt`. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `internal/appconfig/store.go`, replace `SetExecDelivery`'s body with a |
| +delegation and add the generalized form beneath it: |
| + |
| +```go |
| +// SetExecDelivery writes (`file`) or clears (`""`) the DEFAULT bundle's |
| +// exec_delivery field — issue #18's TCP-addr exec route opt-in (see |
| +// Manifest.ExecDelivery). The app-level door, unchanged in behaviour and |
| +// signature; SetExecDeliveryAt is the general one. |
| +func SetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) error { |
| + return SetExecDeliveryAt(ctx, s, acct, app, "", value) |
| +} |
| + |
| +// SetExecDeliveryAt writes or clears exec_delivery in ONE bundle: the |
| +// default bundle (envName == "") or a named environment's, 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. |
| +// |
| +// Environment scope is issue #195 (ruled 2026-08-16): the app-level flag |
| +// answered "does this APP get files", so opting one relay route in wrote |
| +// a file for every router route of the same app too. The flag now lives |
| +// wherever the route's config comes from, and clearing it in a named |
| +// bundle means INHERIT the app default — not "off". Route scope is still |
| +// refused: it would need a registry column, a record field and reconcile |
| +// convergence, three doors onto one bit, where the environment binding is |
| +// a scope the operator has already chosen. |
| +// |
| +// Turning the field off against a bundle that does not exist 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 SetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, 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/inherit)", value, ExecDeliveryFile) |
| + } |
| + key, label := Key(acct, app), DefaultBundle |
| + if envName != "" { |
| + if !ValidEnvironmentName(envName) { |
| + return fmt.Errorf("config: invalid environment name %q", envName) |
| + } |
| + key, label = EnvironmentKey(acct, app, envName), envName |
| + } |
| + m, etag, found, err := getForWriteAt(ctx, s, key, app) |
| + if err != nil { |
| + return err |
| + } |
| + if !found && value == "" { |
| + return fmt.Errorf("config: %s has no %s bundle — nothing to turn off", app, label) |
| + } |
| + m.ExecDelivery = value |
| + m.Schema, m.App = 1, app |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| + return err |
| + } |
| + // The opt-in flag changes what SyncExec delivers for this app's routes, |
| + // so it is a config change like any other and earns the same fast |
| + // convergence. The generation object is per-APP, not per-bundle |
| + // (generation.go), so an environment-scoped write signals through the |
| + // same key an app-level one does — which is why the fast loop needs no |
| + // new bundle awareness for this half. |
| + touchLogged(ctx, s, acct, app) |
| + return nil |
| +} |
| +``` |
| + |
| +Then rewrite the `APP-LEVEL ONLY` paragraph of `Manifest.ExecDelivery`'s |
| +doc comment in `internal/appconfig/appconfig.go` (the block currently |
| +beginning "APP-LEVEL ONLY: read from the default bundle (config.json) |
| +alone."): |
| + |
| +```go |
| + // BUNDLE-LEVEL, resolved as an overlay (issue #195, ruled 2026-08-16). |
| + // SyncExec reads this field from the bundle a route's config actually |
| + // comes from: a route bound to environment e takes e's value when e |
| + // STATES one, and inherits the default bundle's when it does not. An |
| + // unbound route reads the default bundle alone, exactly as before. |
| + // LoadMerged still never carries this field into its merged result — |
| + // the merged manifest is what gets rendered into a file, and a delivery |
| + // flag has no business appearing there; SyncExec's checkOptedIn reads |
| + // the two bundles' fields directly instead. |
| + // |
| + // The 2026-08-03 ruling this narrows said a named environment's |
| + // manifest is never a valid place for the field, because "the reader is |
| + // the app's own supervisor, one program per app". That premise is false |
| + // in the deployment the feature was built for: the titogo app |
| + // supervises its relay and its router fleet through different units, so |
| + // an app-level flag thrown to deliver one sealed credential to the |
| + // relay wrote a file for every router too. |
| + // |
| + // STILL NOT PER-ROUTE. A per-route flag would need a registry column |
| + // and — since a Provisioned route's Channel and Environment belong to |
| + // its instance record — a record field and reconcile convergence too: |
| + // three doors onto one bit, and the "config-that-lies" hazard the |
| + // original ruling named is real at that granularity, where routes |
| + // sharing one bundle would disagree about whether it is delivered. |
| + // The environment binding is one door onto a scope that already exists. |
| + // |
| + // Additive to schema 1 (schema is not bumped for this field), so any |
| + // other value is refused at SET time (SetExecDeliveryAt, |
| + // 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. An older binary reading a bundle this one wrote ignores the |
| + // field entirely (it consults the default bundle alone), which is the |
| + // pre-change behaviour, not a fault. |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/appconfig/ -count=1` |
| +Expected: PASS, including every pre-existing test — `SetExecDelivery`'s |
| +behaviour must be byte-identical. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/store.go internal/appconfig/appconfig.go internal/appconfig/environments_test.go |
| +git commit -m "appconfig: exec_delivery can be set on a named environment bundle (#195)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: `SyncExec` resolves the opt-in per environment |
| + |
| +**Files:** |
| +- Modify: `internal/appconfig/syncexec.go:116-192` (the `optedInRes` / |
| + `optedIn` / `checkOptedIn` / `inScope` block) and `:374-387` (the |
| + stale-secrets notice loop) |
| +- Test: `internal/appconfig/syncexec_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `SetExecDeliveryAt` (Task 1) in tests only; |
| + `getIfExistsAt(ctx, s, key) (*Manifest, bool, error)`, |
| + `EnvironmentKey(acct, app, name) string`. |
| +- Produces: no exported change. `SyncExec`'s signature is untouched. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/appconfig/syncexec_test.go`. Read the top of that file |
| +first and reuse its existing fixture helpers (store constructor, registry |
| +constructor, route builder, key generation) rather than inventing new |
| +ones — the names below are the shape, not necessarily the spelling. |
| + |
| +```go |
| +// TestSyncExecOptInResolvesPerEnvironment is issue #195's matrix: which |
| +// hosts get a <host>.config.env, for each combination of the app-level |
| +// flag and the bound bundle's own. Two TCP-addr exec routes of ONE app, |
| +// one bound to "relay" and one unbound — titogo's exact shape. |
| +func TestSyncExecOptInResolvesPerEnvironment(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + appLevel string // "" | ExecDeliveryFile |
| + envLevel string // "absent" | "silent" | "file" | "inherit" | "bogus" |
| + wantRelayFile bool |
| + wantUnboundFile bool |
| + }{ |
| + {"both off", "", "silent", false, false}, |
| + {"app on, env silent — inherit on", ExecDeliveryFile, "silent", true, true}, |
| + {"app off, env on — the narrow case", "", "file", true, false}, |
| + {"app on, env on", ExecDeliveryFile, "file", true, true}, |
| + {"app off, env bundle absent", "", "absent", false, false}, |
| + {"app on, env bundle absent — inherit on", ExecDeliveryFile, "absent", true, true}, |
| + {"app on, env states an unknown value — that statement decides", ExecDeliveryFile, "bogus", false, true}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + dir := t.TempDir() |
| + // The default bundle always exists and always has a var, so the |
| + // only thing under test is WHETHER a file is written. |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"SHARED": "1"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc.appLevel != "" { |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", tc.appLevel); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if tc.envLevel != "absent" { |
| + if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay", |
| + map[string]Var{"SEALED": {Kind: "env", Value: "2"}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch tc.envLevel { |
| + case "file": |
| + if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "bogus": |
| + // Written past the setter's validation on purpose: this |
| + // is what an OLDER binary sees when a NEWER one invents |
| + // a value, and it must not brick the pass. |
| + writeRawManifest(t, ctx, s, EnvironmentKey("bab", "titogo", "relay"), |
| + &Manifest{Schema: 1, App: "titogo", ExecDelivery: "socket", |
| + Vars: map[string]Var{"SEALED": {Kind: "env", Value: "2"}}}) |
| + } |
| + } |
| + reg := newTestRegistry(t) |
| + mustAddRoute(t, reg, Route{Host: "relay.example", Kind: "instance", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9001", Environment: "relay"}) |
| + mustAddRoute(t, reg, Route{Host: "router.example", Kind: "instance", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9002"}) |
| + |
| + if err := SyncExec(ctx, reg, s, nil, dir, nil); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + assertConfigFile(t, dir, "relay.example", tc.wantRelayFile) |
| + assertConfigFile(t, dir, "router.example", tc.wantUnboundFile) |
| + }) |
| + } |
| +} |
| + |
| +// assertConfigFile asserts a route's <host>.config.env exists (or not). |
| +func assertConfigFile(t *testing.T, dir, host string, want bool) { |
| + t.Helper() |
| + _, err := os.Stat(filepath.Join(dir, host+configEnvSuffix)) |
| + switch { |
| + case want && err != nil: |
| + t.Fatalf("%s: want a config file, got %v", host, err) |
| + case !want && err == nil: |
| + t.Fatalf("%s: want NO config file, one was written", host) |
| + } |
| +} |
| + |
| +// TestSyncExecEnvBundleFetchErrorLeavesFileAlone: an unreadable |
| +// environment bundle is not a determination that the route is un-opted — |
| +// an existing file must survive it byte-identical. |
| +func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) { |
| + // Build the opted-in, converged state first, snapshot the file's bytes, |
| + // then make the environment bundle's key fail on Get and run one more |
| + // pass. Use whatever failing-store wrapper this file already has for |
| + // the default-bundle fetch-failure test; extend it to the environment |
| + // key rather than writing a second wrapper. |
| +} |
| +``` |
| + |
| +If the file has no raw-manifest writer, add one beside the test: |
| + |
| +```go |
| +// writeRawManifest puts a manifest at key without going through a setter — |
| +// the only way to stage a value a current binary would refuse to write. |
| +func writeRawManifest(t *testing.T, ctx context.Context, s release.Store, key string, m *Manifest) { |
| + t.Helper() |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +(Check the store's `Put` etag convention for an unconditional write in the |
| +existing tests and match it — `""` here is a placeholder for whatever this |
| +repo's "no precondition" spelling is.) |
| + |
| +- [ ] **Step 2: Run the test to verify it fails** |
| + |
| +Run: `go test ./internal/appconfig/ -run TestSyncExecOptInResolvesPerEnvironment -count=1` |
| +Expected: FAIL on the "app off, env on" row — no file is written for |
| +`relay.example`, because the opt-in is still read from the default bundle. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `internal/appconfig/syncexec.go`, replace the `optedIn` map, |
| +`checkOptedIn` closure and `inScope` closure with: |
| + |
| +```go |
| + // optKey keys the opt-in decision on the bundle a route's config |
| + // actually resolves through, not on the app alone (issue #195). Two |
| + // routes of one app bound to different environments can now differ, so |
| + // the cache — and the once-per-pass logs below — must too. |
| + type optKey struct{ acct, app, env string } |
| + optedIn := map[optKey]optedInRes{} |
| + // envBundles caches the named-bundle read the same way loadDefault |
| + // caches the default's: a box with many routes on one (app, |
| + // environment) pair costs one fetch, and the logs below fire at most |
| + // once per pair per pass rather than once per route. |
| + type envRes struct { |
| + m *Manifest |
| + found bool |
| + err error |
| + } |
| + envBundles := map[optKey]envRes{} |
| + loadEnvBundle := func(acct, app, env string) envRes { |
| + k := optKey{acct, app, env} |
| + if e, done := envBundles[k]; done { |
| + return e |
| + } |
| + m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, env)) |
| + if err != nil { |
| + err = fmt.Errorf("%s/%s (environment %q): config fetch failed: %w", acct, app, env, err) |
| + } |
| + e := envRes{m: m, found: found, err: err} |
| + envBundles[k] = e |
| + return e |
| + } |
| + checkOptedIn := func(acct, app, env string) optedInRes { |
| + k := optKey{acct, app, env} |
| + 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 that is 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 |
| + if env != "" { |
| + eb := loadEnvBundle(acct, app, env) |
| + switch { |
| + case eb.err != nil: |
| + // NOT a determination — same contract as the default |
| + // bundle's fetch-failure branch below: 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, CLAUDE.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 |
| + } |
| + // 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 opt-in verdict never reaches a |
| + // write in that case. |
| + } |
| + if !decided { |
| + def := loadDefault(acct, app) |
| + switch { |
| + case def.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 |
| + // would mean an opted-in app's delivery can stall with zero |
| + // trace in the log, on the exact path this feature exists to |
| + // light up, so say so loudly — the same "unresolved, left |
| + // as-is" contract the resolution loop below already gives a |
| + // bound-group fetch failure. |
| + // |
| + // It is safe to leave any existing file exactly as it is |
| + // regardless of how long this persists, because reapConfigEnv |
| + // keys on ROUTE EXISTENCE, never on scope (its live map is |
| + // built from every route's Host, full stop — see its own doc |
| + // comment). A route that drops out of scope for one pass, for |
| + // ANY reason, is simply not visited by the resolution loop, |
| + // the materialization loop, or reap; its file is untouched by |
| + // construction. 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: 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} |
| + } |
| + } |
| + optedIn[k] = c |
| + return c |
| + } |
| + // inScope is the single door both loops below use to decide whether a |
| + // route is in scope this pass, and — for the addr shape — that it must |
| + // never be bounced. One function so the two loops structurally cannot |
| + // diverge on either question. |
| + inScope := func(rt registry.Route) (ok, addrShape bool) { |
| + if execSyncable(rt) { |
| + return true, false |
| + } |
| + if execAddrCandidate(rt) && checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment).v { |
| + return true, true |
| + } |
| + return false, false |
| + } |
| +``` |
| + |
| +Update the `optedInRes` doc comment above it to say the verdict is per |
| +`(account, app, environment)`, not per app. |
| + |
| +Then, in the stale-secrets notice loop near the end of `SyncExec`, change |
| +the one call site: |
| + |
| +```go |
| + c := checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment) |
| +``` |
| + |
| +Finally update the package doc at the top of the file: the sentence |
| +beginning "A third eligibility case (issue #18): a TCP-addr exec route |
| +[...] whose app has opted in via its default bundle's additive |
| +`"exec_delivery": "file"` manifest field" becomes "whose BOUND BUNDLE has |
| +opted in — the route's named environment when it states a value, the |
| +app's default bundle otherwise (issue #195)". |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/appconfig/ -count=1` |
| +Expected: PASS, all rows of the matrix and every pre-existing SyncExec |
| +test — the unbound-route behaviour must be unchanged. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/syncexec.go internal/appconfig/syncexec_test.go |
| +git commit -m "appconfig: SyncExec resolves exec_delivery from the route's bound bundle (#195)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: `ConfigRouteFingerprints` |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/routefingerprint.go` |
| +- Test: `internal/appconfig/routefingerprint_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `execSyncable(rt)`, `execAddrCandidate(rt)` (syncexec.go), |
| + `syncable(rt)` (sync.go), `release.RouteAccount(rt)`, |
| + `registry.BackingUnit`. |
| +- Produces: |
| + `func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string)` |
| + — two hex digests, one per delivery half. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Create `internal/appconfig/routefingerprint_test.go`: |
| + |
| +```go |
| +package appconfig |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func execRoute(host, env string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9001", Environment: env} |
| +} |
| + |
| +func unitRoute(host, env string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "keymail", |
| + Account: "bae", Socket: "/run/x.sock", Backing: registry.BackingUnit, |
| + Environment: env} |
| +} |
| + |
| +func TestConfigRouteFingerprintsMoveOnRebinding(t *testing.T) { |
| + before := []registry.Route{execRoute("relay.example", "")} |
| + after := []registry.Route{execRoute("relay.example", "relay")} |
| + be, bu := ConfigRouteFingerprints(before) |
| + ae, au := ConfigRouteFingerprints(after) |
| + if be == ae { |
| + t.Fatal("binding an exec route to an environment must move the exec digest — this is issue #196") |
| + } |
| + if bu != au { |
| + t.Fatal("an exec route's rebinding must not move the UNIT digest — that would ask for a root pass for nothing") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsUnitHalfIsIndependent(t *testing.T) { |
| + before := []registry.Route{unitRoute("keymail.dev", "")} |
| + after := []registry.Route{unitRoute("keymail.dev", "staging")} |
| + be, bu := ConfigRouteFingerprints(before) |
| + ae, au := ConfigRouteFingerprints(after) |
| + if bu == au { |
| + t.Fatal("binding a unit-backed route to an environment must move the unit digest") |
| + } |
| + if be != ae { |
| + t.Fatal("a unit route's rebinding must not move the exec digest") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsIgnoreOrderAndIrrelevantColumns(t *testing.T) { |
| + a := execRoute("a.example", "") |
| + b := execRoute("b.example", "relay") |
| + e1, _ := ConfigRouteFingerprints([]registry.Route{a, b}) |
| + e2, _ := ConfigRouteFingerprints([]registry.Route{b, a}) |
| + if e1 != e2 { |
| + t.Fatal("registry order must not move the digest — it is not stable across passes") |
| + } |
| + c := execRoute("a.example", "") |
| + c.Version = "deadbeef" |
| + c.LastError = "whatever" |
| + e3, _ := ConfigRouteFingerprints([]registry.Route{c, b}) |
| + if e1 != e3 { |
| + t.Fatal("a column that cannot change what config a route gets must not move the digest — every version stamp would trigger a pass") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsMoveWhenARouteLeavesScope(t *testing.T) { |
| + both := []registry.Route{execRoute("a.example", ""), execRoute("b.example", "")} |
| + one := []registry.Route{execRoute("a.example", "")} |
| + e1, _ := ConfigRouteFingerprints(both) |
| + e2, _ := ConfigRouteFingerprints(one) |
| + if e1 == e2 { |
| + t.Fatal("a route leaving the registry must move the digest — its file needs reaping") |
| + } |
| + // Leaving scope by status, not by deletion, counts the same way. |
| + failed := []registry.Route{execRoute("a.example", ""), execRoute("b.example", "")} |
| + failed[1].Status = "failed" |
| + e3, _ := ConfigRouteFingerprints(failed) |
| + if e1 == e3 { |
| + t.Fatal("a route falling out of the eligibility predicate must move the digest") |
| + } |
| +} |
| + |
| +func TestConfigRouteFingerprintsIgnoreOutOfScopeRoutes(t *testing.T) { |
| + base := []registry.Route{execRoute("a.example", "")} |
| + withStatic := append([]registry.Route{}, base...) |
| + withStatic = append(withStatic, registry.Route{Host: "site.example", Kind: "static", App: "site", Account: "bab"}) |
| + e1, u1 := ConfigRouteFingerprints(base) |
| + e2, u2 := ConfigRouteFingerprints(withStatic) |
| + if e1 != e2 || u1 != u2 { |
| + t.Fatal("a route neither half can deliver config to must not move either digest") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the test to verify it fails** |
| + |
| +Run: `go test ./internal/appconfig/ -run TestConfigRouteFingerprints -count=1` |
| +Expected: FAIL — `undefined: ConfigRouteFingerprints`. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +Create `internal/appconfig/routefingerprint.go`: |
| + |
| +```go |
| +// Route-shape fingerprints: the registry-side half of "has this box's |
| +// config delivery got anything to do". |
| +// |
| +// The agent's fast loop (host package's configGenLoop) decides whether to |
| +// converge by reading each config-carrying app's generation object out of |
| +// the bucket. That catches every BUCKET-side change — a var set, a secret |
| +// rotated, an exec_delivery flag flipped — and nothing at all on the |
| +// REGISTRY side. But a route's Environment is a registry column, and it |
| +// decides which bundle the route's config comes from, so binding a route |
| +// to an environment changes what that route should be served while moving |
| +// no etag anywhere. Delivery then waits for the hourly configSyncLoop: |
| +// issue #196, an hour of a live route serving the wrong merge, whose only |
| +// symptom is the app saying a credential is missing. |
| +// |
| +// These digests give that loop something to watch. They live here, beside |
| +// ExecConfigApps and UnitConfigApps and composed from the SAME eligibility |
| +// predicates those two use, for the reason both of those give for living |
| +// where they do: a fingerprint that disagreed with the sweep about which |
| +// routes matter would either burn passes forever or miss changes silently. |
| +package appconfig |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// ConfigRouteFingerprints returns one digest per delivery half over the |
| +// registry-side facts that decide what config a route gets: which routes |
| +// are in scope at all, and — for each — the (account, app, environment) |
| +// triple its manifest resolves from. |
| +// |
| +// Set MEMBERSHIP is what carries scope changes: a route that gains |
| +// Status: "failed", loses its app stamp, or is deleted outright simply |
| +// stops contributing a row, which moves the digest exactly as a rebinding |
| +// does. That is deliberate — those transitions all change what should be |
| +// on disk (a file to remove, a file to stop updating), and the pass they |
| +// trigger resolves each case properly. |
| +// |
| +// The OPT-IN is deliberately not here, for the reason ExecConfigApps gives |
| +// for omitting it too: reading it costs a bucket fetch per app per tick, |
| +// which is exactly what the fast loop exists to avoid. It does not need to |
| +// be: exec_delivery is a bundle write, and every bundle write bumps the |
| +// app's generation object (SetExecDeliveryAt), which the loop already |
| +// watches. |
| +// |
| +// Two digests, not one, because the two halves are delivered by different |
| +// processes: the exec half by the agent itself (SyncExec), the unit half |
| +// by a root pass the agent can only REQUEST. A shared digest would ask for |
| +// a root config run every time an exec route was rebound. |
| +func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string) { |
| + var execRows, unitRows []string |
| + for _, rt := range routes { |
| + row := rt.Host + "|" + release.RouteAccount(rt) + "|" + rt.App + "|" + rt.Environment |
| + if execSyncable(rt) || execAddrCandidate(rt) { |
| + execRows = append(execRows, row) |
| + } |
| + if syncable(rt) && rt.Backing == registry.BackingUnit { |
| + unitRows = append(unitRows, row) |
| + } |
| + } |
| + return digestRows(execRows), digestRows(unitRows) |
| +} |
| + |
| +// digestRows sorts and hashes. Sorted because registry order is not stable |
| +// across passes and an order-sensitive digest would fire constantly; |
| +// NUL-separated because a hostname cannot contain one, so no two distinct |
| +// row sets can concatenate to the same bytes. |
| +func digestRows(rows []string) string { |
| + sort.Strings(rows) |
| + h := sha256.New() |
| + for _, r := range rows { |
| + h.Write([]byte(r)) |
| + h.Write([]byte{0}) |
| + } |
| + return hex.EncodeToString(h.Sum(nil)) |
| +} |
| +``` |
| + |
| +Add `"github.com/carlosframework/platform/internal/release"` to the |
| +imports (the row uses `release.RouteAccount`). |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/appconfig/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/routefingerprint.go internal/appconfig/routefingerprint_test.go |
| +git commit -m "appconfig: fingerprint the registry-side facts that decide a route's config (#196)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: The fast loop watches the fingerprints |
| + |
| +**Files:** |
| +- Modify: `internal/host/agent.go:571-670` (`genCache`, `genPollPass`) and |
| + `:792-850` (`configGenLoop`) |
| +- Test: `internal/host/agent_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `appconfig.ConfigRouteFingerprints(routes) (exec, unit string)` |
| + (Task 3). |
| +- Produces: `type genCache struct` with `etags map[[2]string]string` and |
| + unexported digest fields; `func newGenCache() *genCache`; |
| + `genPollPass(ctx, routes, store, c *genCache) (execChanged, unitChanged bool, readErrs []error)`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/host/agent_test.go`: |
| + |
| +```go |
| +// TestGenPollPassSyncsOnRouteRebinding is issue #196: a route bound to an |
| +// environment must converge from the FAST loop, not an hour later. The |
| +// bucket does not move at all here — only the registry column does. |
| +func TestGenPollPassSyncsOnRouteRebinding(t *testing.T) { |
| + ctx := context.Background() |
| + s := newGenStore(t) // match this file's existing helper |
| + routes := []registry.Route{execRouteFixture("relay.example", "")} |
| + cache := newGenCache() |
| + genPollPass(ctx, routes, s, cache) // first sight: silent |
| + |
| + rebound := []registry.Route{execRouteFixture("relay.example", "relay")} |
| + execChanged, unitChanged, errs := genPollPass(ctx, rebound, s, cache) |
| + if len(errs) > 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if !execChanged { |
| + t.Fatal("binding a route to an environment must make the exec half sync — this is the hour titogo lost") |
| + } |
| + if unitChanged { |
| + t.Fatal("an exec route's rebinding must not request a root config pass") |
| + } |
| + // And the pass AFTER it is quiet again: a digest that re-fired every |
| + // tick would run SyncExec forever. |
| + if execChanged, _, _ := genPollPass(ctx, rebound, s, cache); execChanged { |
| + t.Fatal("the pass after a rebinding must be silent again") |
| + } |
| +} |
| + |
| +// TestGenPollPassFirstSightOfRouteShapeIsSilent: a box coming up has never |
| +// seen the digest before, and must not treat that as a change — every |
| +// instance on the box would sync at once on boot. |
| +func TestGenPollPassFirstSightOfRouteShapeIsSilent(t *testing.T) { |
| + ctx := context.Background() |
| + s := newGenStore(t) |
| + routes := []registry.Route{execRouteFixture("relay.example", "relay")} |
| + execChanged, unitChanged, errs := genPollPass(ctx, routes, s, newGenCache()) |
| + if len(errs) > 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if execChanged || unitChanged { |
| + t.Fatal("the first pass must record the shape silently — configSyncLoop's first pass owns boot convergence") |
| + } |
| +} |
| +``` |
| + |
| +Add an `execRouteFixture` helper beside them if the file has no equivalent: |
| + |
| +```go |
| +func execRouteFixture(host, env string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: "titogo", |
| + Account: "bab", Addr: "127.0.0.1:9001", Environment: env} |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the test to verify it fails** |
| + |
| +Run: `go test ./internal/host/ -run TestGenPollPass -count=1` |
| +Expected: FAIL — `undefined: newGenCache` (and, once that compiles, a |
| +`execChanged == false` failure on the rebinding). |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `internal/host/agent.go`, add above `genPollPass`: |
| + |
| +```go |
| +// genCache is everything configGenLoop remembers between ticks: one |
| +// generation etag per config-carrying app, and the previous tick's |
| +// route-shape digests. One object rather than two parameters because the |
| +// two are remembered for exactly one reason — deciding what "changed" |
| +// means — and a caller holding one without the other would silently lose |
| +// half the loop's sensitivity. |
| +type genCache struct { |
| + etags map[[2]string]string |
| + // exec/unit are appconfig.ConfigRouteFingerprints' two digests as of |
| + // the last pass that computed them; shapeSeen is false until the first |
| + // such pass. The bool is not redundant with an empty string: a box with |
| + // no config-carrying routes at all has a real, stable digest of the |
| + // empty set, and treating that as "never seen" would make its first |
| + // route look like a change on the tick after boot rather than on the |
| + // tick it appeared. |
| + exec, unit string |
| + shapeSeen bool |
| +} |
| + |
| +func newGenCache() *genCache { return &genCache{etags: map[[2]string]string{}} } |
| +``` |
| + |
| +Change `genPollPass`'s signature and body. The signature becomes: |
| + |
| +```go |
| +func genPollPass(ctx context.Context, routes []registry.Route, store release.Store, c *genCache) (execChanged, unitChanged bool, readErrs []error) { |
| +``` |
| + |
| +Inside, replace every `cache[...]` with `c.etags[...]`, and add the shape |
| +comparison at the top of the function, before the etag loop: |
| + |
| +```go |
| + // The registry-side half (issue #196). Cheap — no bucket read at all — |
| + // and checked first so a rebinding converges on the same tick a var |
| + // change would, rather than waiting for one. |
| + execFP, unitFP := appconfig.ConfigRouteFingerprints(routes) |
| + if c.shapeSeen { |
| + execChanged = execChanged || execFP != c.exec |
| + unitChanged = unitChanged || unitFP != c.unit |
| + } |
| + c.exec, c.unit, c.shapeSeen = execFP, unitFP, true |
| +``` |
| + |
| +Extend `genPollPass`'s doc comment with a bullet in the "Semantics" list: |
| + |
| +```go |
| +// - The ROUTE SHAPE is watched too, not just the bucket: a route's |
| +// Environment binding lives in the box's own registry and moves no |
| +// etag anywhere, so without this the fast loop cannot see a rebinding |
| +// at all and delivery waits out configSyncLoop's hourly floor (issue |
| +// #196 — ten minutes of a live route serving the wrong merge, whose |
| +// only symptom is the app reporting a missing credential). First sight |
| +// of the shape is silent for the same reason first sight of an app is. |
| +// Covers BOTH doors onto the column: `carlos route -environment` on |
| +// the box, and the provisioning reconciler converging Environment from |
| +// an instance record — which is the sanctioned door for a Provisioned |
| +// row, and which a fix in `carlos route` would have missed entirely. |
| +``` |
| + |
| +In `configGenLoop`, change the cache construction and the call: |
| + |
| +```go |
| + // One cache for the life of the process: what "changed" means is |
| + // exactly "differs from the last tick that could read it". |
| + cache := newGenCache() |
| +``` |
| + |
| +(the `genPollPass(ctx, routes, store, cache)` call site needs no edit). |
| + |
| +Then fix the existing tests mechanically — these three substitutions over |
| +`internal/host/agent_test.go`: |
| + |
| +- `cache := map[[2]string]string{}` → `cache := newGenCache()` |
| +- `cache[[2]string{` → `cache.etags[[2]string{` |
| +- `_, ok := cache[` → `_, ok := cache.etags[` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/host/ -count=1` |
| +Expected: PASS, including all 26 pre-existing `genPollPass` assertions. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/host/agent.go internal/host/agent_test.go |
| +git commit -m "host: the fast config loop notices a route's environment rebinding (#196)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: `carlos env exec-delivery -environment` |
| + |
| +**Files:** |
| +- Modify: `appconfig_cli.go:358-435` (`runEnvExecDelivery`), `:331-356` |
| + (`printEnvList`), `:20-58` (`envUsage`) |
| +- Test: `appconfig_cli_test.go` (or the file in the root package that |
| + already tests `env` subcommands — check `e2e_test.go` for the |
| + established pattern first) |
| + |
| +**Interfaces:** |
| +- Consumes: `appconfig.SetExecDeliveryAt(ctx, store, acct, app, envName, value)` |
| + (Task 1); `apiConfigApply{Environment string, ExecDelivery *string}` |
| + (existing wire struct in `apimode.go`). |
| +- Produces: no exported change. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +The refusals are the behaviour worth pinning. Follow the file's existing |
| +convention for driving a CLI verb — if these tests shell out to the built |
| +binary, they MUST use `carlosTestEnv` and set `cmd.Dir` to a scratch dir |
| +(CLAUDE.md; the fallback is the operator's real login and console). |
| + |
| +```go |
| +func TestEnvExecDeliveryRefusesOffAtEnvironmentScope(t *testing.T) { |
| + out, err := runCarlos(t, "env", "exec-delivery", "-app", "titogo", "-environment", "relay", "off") |
| + if err == nil { |
| + t.Fatal(`"off" must be refused for a named environment — clearing the field there means inherit`) |
| + } |
| + if !strings.Contains(out, "inherit") { |
| + t.Fatalf("the refusal must name the spelling that IS accepted; got %q", out) |
| + } |
| +} |
| + |
| +func TestEnvExecDeliveryRefusesUnknownValueAtEnvironmentScope(t *testing.T) { |
| + out, err := runCarlos(t, "env", "exec-delivery", "-app", "titogo", "-environment", "relay", "socket") |
| + if err == nil { |
| + t.Fatal("an unknown value must be refused") |
| + } |
| + if !strings.Contains(out, "file") || !strings.Contains(out, "inherit") { |
| + t.Fatalf("the refusal must name both accepted spellings; got %q", out) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test . -run TestEnvExecDelivery -count=1` |
| +Expected: FAIL — `-environment` is currently refused outright, so the |
| +first test fails on the message and the second passes for the wrong |
| +reason. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +Rewrite `runEnvExecDelivery`: |
| + |
| +```go |
| +// runEnvExecDelivery is `carlos env exec-delivery` — issue #18's opt-in |
| +// verb, deliberately NOT `env set -app <a> EXEC_DELIVERY=...`: the field |
| +// is not a config var (it's never rendered into an EnvironmentFile, never |
| +// listed alongside KEY/VALUE pairs), it's a manifest-level flag with its |
| +// own validation and its own bundle, so it gets its own verb surface |
| +// instead of overloading env set's KEY=value parsing with a special-cased |
| +// key name. |
| +// |
| +// -environment (issue #195) writes the flag into that bundle instead of |
| +// the app default. The vocabulary changes with the scope, and that is the |
| +// point: at app scope the second word is "off", because clearing the |
| +// default bundle's field really does stop delivery; in a named bundle it |
| +// is "inherit", because clearing it there hands the question back to the |
| +// app default. Spelling the second case "off" would read as "stop |
| +// delivering" while doing the opposite whenever the app default is on, |
| +// which is precisely the kind of quiet lie this whole feature exists to |
| +// avoid — so "off" is refused there, by name, rather than accepted as a |
| +// synonym. |
| +func runEnvExecDelivery(args []string) { |
| + fs := flag.NewFlagSet("env exec-delivery", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + environment := fs.String("environment", "", |
| + "named environment bundle to set the flag in (omit for the app's default bundle)") |
| + acctRaw, acctIDRaw, acctOf := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)") |
| + fs.Parse(args) |
| + usage := "usage: carlos env exec-delivery -app <a> [-console <origin>] file|off\n" + |
| + " or: carlos env exec-delivery -app <a> -environment <name> [-console <origin>] file|inherit" |
| + if fs.NArg() != 1 { |
| + log.Fatal(usage) |
| + } |
| + checkEnvironmentFlag(*environment) |
| + // The wire and store vocabulary is one value either way — "" clears the |
| + // field. What differs is which SECOND WORD spells the clear, and which |
| + // bundle it clears in. |
| + var value, cleared string |
| + switch { |
| + case fs.Arg(0) == appconfig.ExecDeliveryFile: |
| + value = appconfig.ExecDeliveryFile |
| + case *environment == "" && fs.Arg(0) == "off": |
| + value, cleared = "", "off" |
| + case *environment != "" && fs.Arg(0) == "inherit": |
| + value, cleared = "", "inherit" |
| + case *environment != "" && fs.Arg(0) == "off": |
| + log.Fatalf(`carlos env exec-delivery: %q is not a value for a named environment — say "inherit" instead. `+ |
| + `Clearing exec_delivery in a bundle does not turn delivery off, it hands the question back to the app's `+ |
| + `default bundle (config.json), so a route bound to %q keeps getting files whenever the app-level flag is on. `+ |
| + `To stop delivery for the whole app: carlos env exec-delivery -app %s off`, |
| + "off", *environment, *app) |
| + default: |
| + want := `"off"` |
| + if *environment != "" { |
| + want = `"inherit"` |
| + } |
| + log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or %s", fs.Arg(0), appconfig.ExecDeliveryFile, want) |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + appName := *app |
| + if sess != nil { |
| + printUsing(os.Stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + appName = resolvedApp(sess, acct, *app, usage) |
| + // The console's PUT decodes ExecDelivery as a pointer where nil |
| + // means "no change", so a clear must still be spelled as a word. |
| + // The word is the scope's own: the server refuses "off" for a named |
| + // bundle exactly as this function does, so relaying the operator's |
| + // own vocabulary keeps the two refusals identical. |
| + wire := cleared |
| + if value != "" { |
| + wire = value |
| + } |
| + if _, err := sess.configApply(context.Background(), acct, appName, |
| + apiConfigApply{Environment: *environment, ExecDelivery: &wire}); err != nil { |
| + log.Fatal(err) |
| + } |
| + } else { |
| + if *app == "" { |
| + log.Fatal(usage) |
| + } |
| + if err := appconfig.SetExecDeliveryAt(context.Background(), openStore(), acctOf(), *app, *environment, value); err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + if value == "" { |
| + fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), cleared, appName, bundleLabel(*environment)) |
| + } else { |
| + fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), value, appName, bundleLabel(*environment)) |
| + } |
| +} |
| +``` |
| + |
| +In `printEnvList`, drop the environment suppression: |
| + |
| +```go |
| + // exec_delivery is printed for whichever bundle is being listed — it can |
| + // now be set in a named one (issue #195). A bundle that is SILENT on it |
| + // prints nothing, rather than printing the value it inherits: the |
| + // listing shows what this bundle holds, and rendering an inherited |
| + // value would make the bundle look like it holds a statement it does |
| + // not, which is exactly the confusion that makes the app-level flag |
| + // hard to reason about in the first place. |
| + if execDelivery != "" { |
| + fmt.Printf("exec-delivery: %s\n", execDelivery) |
| + } |
| +``` |
| + |
| +Update `envUsage`: the command line becomes |
| + |
| +``` |
| + exec-delivery -app <a> [-environment <name>] file|off|inherit opt an app's (or one bundle's) TCP-addr exec routes into config delivery |
| +``` |
| + |
| +and replace the "exec-delivery is APP-LEVEL ONLY" paragraph with: |
| + |
| +``` |
| +exec-delivery sets "exec_delivery" in one bundle. "file" makes SyncExec |
| +write /data/<host>.config.env for the TCP-addr exec routes that resolve |
| +through that bundle (routes carlos proxies to but does not spawn — e.g. |
| +titogo's router fleet) exactly as it already does for socket-exec routes, |
| +but those routes are NEVER bounced: carlos does not own the process, so |
| +the app's own supervisor owns the restart. |
| + |
| +Without -environment it is the app default, and "off" clears it. With |
| +-environment it is that bundle alone, and "inherit" clears it — a bundle |
| +that says nothing takes the app default's answer, so clearing there is not |
| +the same as turning delivery off. To deliver to ONE service: turn the app |
| +default off, and set "file" on the bundle its route is bound to. |
| + |
| +Turning the app default off does NOT delete files already written for |
| +routes that lose the opt-in — they hold decrypted secrets and carlos |
| +cannot know when the app's own supervisor has stopped reading them. It |
| +names each one in its log every pass until you delete it by hand. |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test . -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add appconfig_cli.go appconfig_cli_test.go |
| +git commit -m "cli: carlos env exec-delivery -environment (#195)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: The console's config door accepts environment scope |
| + |
| +**Files:** |
| +- Modify: `internal/console/apiconfig.go:230-241` (GET's exec_delivery |
| + emission), `:369-388` (POST's exec_delivery validation), `:427-441` |
| + (POST's second CAS write) |
| +- Test: `internal/console/apiconfig_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `appconfig.SetExecDeliveryAt` (Task 1). |
| +- Produces: no Go-level export change. Wire: `exec_delivery` accepts |
| + `"file"` and `"inherit"` when `environment` is non-empty, and `"file"` / |
| + `"off"` when it is empty; GET emits the named bundle's own value. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/console/apiconfig_test.go`, following that file's |
| +existing request helpers: |
| + |
| +```go |
| +func TestAPIConfigPutExecDeliveryAtEnvironmentScope(t *testing.T) { |
| + // PUT {"environment":"relay","exec_delivery":"file"} must land in the |
| + // NAMED bundle and leave the default bundle's field empty. |
| +} |
| + |
| +func TestAPIConfigPutRefusesOffAtEnvironmentScope(t *testing.T) { |
| + // PUT {"environment":"relay","exec_delivery":"off"} → 400, and the |
| + // message must name "inherit". |
| +} |
| + |
| +func TestAPIConfigPutAcceptsInheritAtEnvironmentScope(t *testing.T) { |
| + // PUT {"environment":"relay","exec_delivery":"inherit"} clears the |
| + // named bundle's field; a following GET omits exec_delivery. |
| +} |
| + |
| +func TestAPIConfigPutRefusesInheritAtAppScope(t *testing.T) { |
| + // PUT {"exec_delivery":"inherit"} with no environment → 400: there is |
| + // nothing above the default bundle to inherit FROM, so accepting the |
| + // word would silently mean "off". |
| +} |
| + |
| +func TestAPIConfigGetEmitsNamedBundleExecDelivery(t *testing.T) { |
| + // After the environment-scoped set, GET ?environment=relay carries |
| + // exec_delivery; GET with no environment does not. |
| +} |
| +``` |
| + |
| +Write each body out against the file's own helpers — the comments state |
| +the assertion, the code must make it. |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run TestAPIConfig -count=1` |
| +Expected: FAIL — the environment-scoped PUT is currently a 400 ("app-level |
| +only") and GET suppresses the field for a named bundle. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `handleAPIConfigGet`, replace the emission condition: |
| + |
| +```go |
| + // exec_delivery is emitted for whichever bundle was asked for: since |
| + // issue #195 it can be set in a named one, where it means "routes bound |
| + // to this environment get files". Omitted when the bundle is silent on |
| + // it rather than reported as the inherited value — the answer describes |
| + // this bundle, and an inherited value would read as a statement the |
| + // bundle does not make. |
| + if m.ExecDelivery != "" { |
| + out["exec_delivery"] = m.ExecDelivery |
| + } |
| +``` |
| + |
| +In `handleAPIConfigPut`, replace the `req.ExecDelivery` validation block: |
| + |
| +```go |
| + execDelivery := "" |
| + if req.ExecDelivery != nil { |
| + // The wire's clearing word is the SCOPE's own, and the two are not |
| + // interchangeable: clearing the default bundle's flag stops |
| + // delivery, while clearing a named bundle's hands the question back |
| + // to the default. Accepting "off" for a named bundle would let a |
| + // caller ask to stop delivery and get the opposite whenever the app |
| + // default is on. Both refusals are worded here, once, so the CLI |
| + // and any other client read the same sentence. |
| + switch *req.ExecDelivery { |
| + case appconfig.ExecDeliveryFile: |
| + execDelivery = appconfig.ExecDeliveryFile |
| + case "off": |
| + if envName != "" { |
| + jsonError(w, http.StatusBadRequest, |
| + "exec_delivery \"off\" is app-level — in a named environment, clearing the flag means "+ |
| + "\"inherit\" (routes bound to it fall back to the app's default bundle), which is not the "+ |
| + "same as turning delivery off. Send \"inherit\" for this bundle, or send \"off\" with no "+ |
| + "environment to turn the app's default off.") |
| + return |
| + } |
| + execDelivery = "" |
| + case "inherit": |
| + if envName == "" { |
| + jsonError(w, http.StatusBadRequest, |
| + "exec_delivery \"inherit\" needs an environment — the default bundle has nothing above it to "+ |
| + "inherit from. Send \"off\" to clear it.") |
| + return |
| + } |
| + execDelivery = "" |
| + default: |
| + want := `"off"` |
| + if envName != "" { |
| + want = `"inherit"` |
| + } |
| + jsonError(w, http.StatusBadRequest, |
| + fmt.Sprintf("invalid exec_delivery %q — want %q or %s", *req.ExecDelivery, appconfig.ExecDeliveryFile, want)) |
| + return |
| + } |
| + } |
| +``` |
| + |
| +And the second CAS write: |
| + |
| +```go |
| + if req.ExecDelivery != nil { |
| + // A SECOND CAS write, and deliberately not folded into the one |
| + // above: it targets one field of one bundle through a different |
| + // setter, and the two cannot be one transaction. The audit row for |
| + // the set/unset half is written above, before this runs — if this |
| + // fails, the trail still says truthfully what landed. |
| + if err := appconfig.SetExecDeliveryAt(ctx, s.cfg.Releases, acct.Sqid, app, envName, execDelivery); err != nil { |
| + s.configStoreError(w, fmt.Sprintf("api config exec-delivery %s/%s (%s)", acct.Sqid, app, bundle), err) |
| + return |
| + } |
| + label := *req.ExecDelivery |
| + s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+label+" ("+bundle+") (cli)") |
| + s.bumpSerial(ctx) |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/apiconfig.go internal/console/apiconfig_test.go |
| +git commit -m "console: config API accepts environment-scoped exec_delivery (#195)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: `GET /api/cli/apps/{account}/{app}/routes` |
| + |
| +**Files:** |
| +- Create: `internal/console/apiroutes.go` |
| +- Modify: `internal/console/authcli.go` (registration, beside the config |
| + family at :294-296), `internal/console/apiinstances.go:214-296` |
| + (extract `healthByHost` from `instanceHealth`) |
| +- Test: `internal/console/apiroutes_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `s.apiAccountApp(w, r) (string, *Account, string, bool)`, |
| + `openRegistry(path) (*registry.Registry, error)`, |
| + `s.OwnerAppMetrics(ctx, hosts []string, unitByHost map[string]string) ([]InstanceMetrics, error)`, |
| + `healthWord(kind, status string, hibernate, reporting, expectReadings bool) string`, |
| + `expectsReadings(provisioned bool, unit, backing string) bool`, |
| + `release.RouteAccount(rt) string`. |
| +- Produces: `func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request)`; |
| + `func (s *Server) healthByHost(ctx context.Context, routeFor map[string]registry.Route) map[string]string`. |
| + Wire: `{"routes":[{"host","kind","upstream","app","channel","environment","status","health","hibernate","unit","backing","provisioned"}]}`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Create `internal/console/apiroutes_test.go`. Copy the gate assertions from |
| +`apiinstances_test.go` verbatim in shape — the 404 discipline is the |
| +security boundary and must be pinned identically here. |
| + |
| +```go |
| +func TestAPIRoutesRequiresMembership(t *testing.T) { |
| + // A signed-in caller who is not a member of the account gets the |
| + // family's byte-identical 404 — not a 403, not an empty list. |
| +} |
| + |
| +func TestAPIRoutesRefusesAnAppTokenOutright(t *testing.T) { |
| + // This door is member-only: an app token that CAN reach |
| + // /instances must not reach /routes. Assert the 404 (or whatever |
| + // apiAccountApp answers a non-person bearer) rather than a 200. |
| +} |
| + |
| +func TestAPIRoutesListsOnlyThisAppsRoutes(t *testing.T) { |
| + // A registry holding: this app's route, another app's route in the |
| + // SAME account, and a route stamped to another account entirely. |
| + // Only the first appears. |
| +} |
| + |
| +func TestAPIRoutesReportsTheEnvironmentBinding(t *testing.T) { |
| + // The whole point of issue #64: a route with Environment "relay" |
| + // reports "relay", and an unbound one reports "". |
| +} |
| + |
| +func TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry(t *testing.T) { |
| + // cfg.Registry == "": a 503 naming the situation, never an empty |
| + // list — "you have no routes" would be a lie. |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run TestAPIRoutes -count=1` |
| +Expected: FAIL — `undefined: handleAPIRoutes`, and the endpoint 404s |
| +because nothing is registered. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +First, extract the health computation from `instanceHealth` so one |
| +function computes the word. In `internal/console/apiinstances.go`, replace |
| +`instanceHealth`'s final block (from `am, err := s.OwnerAppMetrics(...)` to |
| +its `return out`) with a call, and add the extracted function: |
| + |
| +```go |
| + for h, word := range s.healthByHost(ctx, routeFor) { |
| + out[h] = word |
| + } |
| + return out |
| +} |
| + |
| +// healthByHost is the metrics half of a health answer, shared by |
| +// instanceHealth (records joined to routes) and handleAPIRoutes (routes |
| +// directly), so the two — and the Running tab, which uses the same |
| +// healthWord — can never disagree about an instance's state. |
| +// |
| +// routeFor arrives ALREADY SCOPED to the caller's own account and app. |
| +// This function performs no tenant check of its own and must not be |
| +// handed an unfiltered registry listing: OwnerAppMetrics' allowlist is |
| +// exactly the hosts and stamped units it is given, so a foreign unit name |
| +// reaching unitByHost below would widen the metrics view past the |
| +// caller's own app. |
| +func (s *Server) healthByHost(ctx context.Context, routeFor map[string]registry.Route) map[string]string { |
| + out := make(map[string]string, len(routeFor)) |
| + hosts := make([]string, 0, len(routeFor)) |
| + unitByHost := map[string]string{} |
| + for h, rt := range routeFor { |
| + hosts = append(hosts, h) |
| + if rt.Unit != "" { |
| + unitByHost[h] = rt.Unit |
| + } |
| + } |
| + am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost) |
| + if err != nil { |
| + // An unreadable sweep is not knowledge that these instances are |
| + // down: every host reads as not-reporting below, which healthWord |
| + // turns into "No readings" or "Not responding" per expectsReadings — |
| + // the same answer the page gives when the sweep is unavailable. |
| + log.Printf("console: health by host: metrics: %v", err) |
| + am = nil |
| + } |
| + rep := make(map[string]bool, len(am)) |
| + for _, m := range am { |
| + if m.Reporting() { |
| + rep[m.Host] = true |
| + } |
| + } |
| + for h, rt := range routeFor { |
| + status := rt.Status |
| + if status == "" { |
| + status = "ready" // registry contract: empty status means ready |
| + } |
| + out[h] = healthWord(rt.Kind, status, rt.Hibernate, rep[h], |
| + expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)) |
| + } |
| + return out |
| +} |
| +``` |
| + |
| +Keep `instanceHealth`'s tenant-boundary doc comment and its `routeFor` |
| +construction exactly where they are — only the metrics half moves. |
| + |
| +Create `internal/console/apiroutes.go`: |
| + |
| +```go |
| +// The routes read (`carlos routes`, issue #64): what routes this app |
| +// actually has on this deployment, and — the reason the issue was |
| +// reopened — what named config environment each one is bound to. |
| +// |
| +// Before this, a route's environment binding could be read exactly one |
| +// way: sqlite3 against /data/registry.db on the box. `carlos route` is a |
| +// setter, `carlos apps` is console-side, and the instance-record API |
| +// answers for RECORDS, so a hand-added route (titogo's relay, the one that |
| +// needed the binding) had nothing to read at all. That made verifying the |
| +// scope of a sealed-credential delivery the one step of the whole |
| +// procedure needing box access, in a flow whose selling point is that a |
| +// developer needs no AWS anything — and it is why issue #196 took an hour |
| +// to see rather than a minute. |
| +// |
| +// GATE: apiAccountApp, the member-proving lookup, NOT the instance family's |
| +// instanceGate. An app token is a machine credential minted for one |
| +// instance to register itself; this answer describes every route of the |
| +// app — upstream addresses, owning units, environment bindings — which is |
| +// an operator's view, not a workload's. The narrower gate is the whole |
| +// difference, so do not "unify" it with the instance family's. |
| +// |
| +// SCOPE: the console reads its OWN box's registry, so in a multi-box fleet |
| +// the other boxes' routes do not appear here. That is the same blind spot |
| +// instanceHealth has today; internal/fleetapi is the eventual fix (#145) |
| +// and is unmerged. The CLI says so in its own help rather than letting a |
| +// short list read as a complete one. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// routeListTimeout bounds the metrics join, the only part of this handler |
| +// that can be slow: the registry is a local file. |
| +const routeListTimeout = 15 * time.Second |
| + |
| +// apiRoute is one row of the answer. Every field is drawn from the |
| +// registry row EXCEPT health, which is the same word instanceHealth and |
| +// the Running tab compute (healthByHost). |
| +type apiRoute struct { |
| + Host string `json:"host"` |
| + Kind string `json:"kind"` |
| + Upstream string `json:"upstream"` |
| + App string `json:"app"` |
| + Channel string `json:"channel"` |
| + Environment string `json:"environment"` |
| + Status string `json:"status"` |
| + Health string `json:"health"` |
| + Hibernate bool `json:"hibernate"` |
| + Unit string `json:"unit,omitempty"` |
| + Backing string `json:"backing,omitempty"` |
| + Provisioned bool `json:"provisioned"` |
| +} |
| + |
| +// routeUpstream renders the one cell that answers "where does this route |
| +// send traffic": a socket wins over addresses (registry.Route's own |
| +// precedence), a multi-upstream set is shown whole rather than reduced to |
| +// its first element, and a tunnel-backed route says so instead of |
| +// rendering an empty cell that would read as a broken row. |
| +func routeUpstream(rt registry.Route) string { |
| + switch { |
| + case rt.Socket != "": |
| + return rt.Socket |
| + case rt.Addrs != "": |
| + return rt.Addrs |
| + case rt.Addr != "": |
| + return rt.Addr |
| + case rt.Tunnel != "": |
| + return "tunnel:" + rt.Tunnel |
| + } |
| + return "" |
| +} |
| + |
| +func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if s.cfg.Registry == "" { |
| + // Never an empty list: a console with no registry cannot know any |
| + // route's state, and "[]" would read as "this app has no routes". |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "this console serves no route registry, so it cannot say what routes exist") |
| + return |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: api routes: registry %s: %v", s.cfg.Registry, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") |
| + return |
| + } |
| + all, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: api routes: registry list: %v", err) |
| + jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") |
| + return |
| + } |
| + // The tenant boundary, and the only one: the registry read above |
| + // returned every route on this box, other accounts' included. A row |
| + // enters routeFor only if it is stamped to THIS app and resolves to |
| + // THIS account — release.RouteAccount, so an unstamped account column |
| + // resolves the same way every other read of it resolves rather than |
| + // being silently excluded or silently included. |
| + routeFor := map[string]registry.Route{} |
| + for _, rt := range all { |
| + if rt.App != app || release.RouteAccount(rt) != acct.Sqid { |
| + continue |
| + } |
| + routeFor[rt.Host] = rt |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), routeListTimeout) |
| + defer cancel() |
| + health := s.healthByHost(ctx, routeFor) |
| + rows := make([]apiRoute, 0, len(routeFor)) |
| + for _, rt := range routeFor { |
| + rows = append(rows, apiRoute{ |
| + Host: rt.Host, Kind: rt.Kind, Upstream: routeUpstream(rt), App: rt.App, |
| + Channel: rt.Channel, Environment: rt.Environment, Status: rt.Status, |
| + Health: health[rt.Host], Hibernate: rt.Hibernate, Unit: rt.Unit, |
| + Backing: rt.Backing, Provisioned: rt.Provisioned, |
| + }) |
| + } |
| + // Sorted by host: registry order is not stable across passes, and a |
| + // listing that reshuffles between calls is hard to read a diff out of. |
| + sortRoutesByHost(rows) |
| + writeJSON(w, http.StatusOK, map[string]any{"routes": rows}) |
| +} |
| + |
| +func sortRoutesByHost(rows []apiRoute) { |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| +} |
| +``` |
| + |
| +Add `"sort"` to the import block. |
| + |
| +Register it in `internal/console/authcli.go`, immediately after the config |
| +family's three lines: |
| + |
| +```go |
| + // The routes read (apiroutes.go, issue #64) — the member-only door onto |
| + // this box's registry rows for one app, so a route's environment |
| + // binding can be read back without a box shell. NOT instanceGate: see |
| + // that file's package comment for why an app token must not reach it. |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/routes", s.handleAPIRoutes) |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -count=1` |
| +Expected: PASS, including every pre-existing `instanceHealth` test — the |
| +extraction must not change a single health word. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/apiroutes.go internal/console/apiroutes_test.go internal/console/authcli.go internal/console/apiinstances.go |
| +git commit -m "console: GET /api/cli/apps/{account}/{app}/routes (#64)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8: `carlos routes` |
| + |
| +**Files:** |
| +- Create: `routes_cli.go` |
| +- Modify: `apimode.go` (add the client method beside `instanceList` at |
| + :1838), `cli.go` (verb dispatch — find the switch that routes |
| + `"instances"` to `runInstances` and add `"routes"` beside it), and the |
| + top-level usage/help text that lists verbs |
| +- Test: `routes_cli_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `apiPath(a apiAcct, app, tail string) string`, |
| + `s.getJSON(ctx, path) (int, []byte, error)`, |
| + `apiErrorFromBody(status, body) error`, `requireAPISession(verb, |
| + consoleFlag) *apiSession`, `resolvedAccount`, `resolvedApp`, |
| + `newTable(stdout)`, `printUsing`, `mutedf`. |
| +- Produces: |
| + `type apiRouteRow struct` (client mirror of the wire shape); |
| + `func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, error)`; |
| + `func runRoutes(args []string)`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Create `routes_cli_test.go`. Follow the root package's existing |
| +CLI-against-a-fake-console pattern (read `instances_cli_test.go` first and |
| +reuse its fake-console harness rather than building a second one). |
| + |
| +```go |
| +func TestRoutesListsTheEnvironmentColumn(t *testing.T) { |
| + // A fake console answering {"routes":[{"host":"connect.go.tito.io", |
| + // "environment":"relay",...}]} must produce a table whose row carries |
| + // both the host and "relay" — this is the read issue #64 exists for. |
| +} |
| + |
| +func TestRoutesRefusesDirectStoreMode(t *testing.T) { |
| + // With CARLOS_DEPLOYMENT_BUCKET set and no session, the verb refuses by |
| + // name: the registry lives on a box, and the box-side listing is |
| + // `carlos instances`. Assert the refusal names that command. |
| +} |
| + |
| +func TestRoutesSaysWhenTheAppHasNoRoutes(t *testing.T) { |
| + // An empty list prints a line saying so, not an empty table — and the |
| + // line mentions that only this deployment's own box is covered. |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test . -run TestRoutes -count=1` |
| +Expected: FAIL — `undefined: runRoutes`. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +Add to `apimode.go`, beneath `instanceList`: |
| + |
| +```go |
| +// apiRouteRow is one row of the console's routes answer |
| +// (internal/console/apiroutes.go). Every field is additive on the wire: a |
| +// console that predates one sends nothing and it decodes as the zero |
| +// value, which is why the table below prints "—" for an empty ENVIRONMENT |
| +// rather than treating absence as a binding. |
| +type apiRouteRow struct { |
| + Host string `json:"host"` |
| + Kind string `json:"kind"` |
| + Upstream string `json:"upstream"` |
| + App string `json:"app"` |
| + Channel string `json:"channel"` |
| + Environment string `json:"environment"` |
| + Status string `json:"status"` |
| + Health string `json:"health"` |
| + Hibernate bool `json:"hibernate"` |
| + Unit string `json:"unit"` |
| + Backing string `json:"backing"` |
| + Provisioned bool `json:"provisioned"` |
| +} |
| + |
| +// routeList reads one app's routes on the console's own box (issue #64). |
| +func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/routes")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Routes []apiRouteRow `json:"routes"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("routes: unreadable response: %w", err) |
| + } |
| + return res.Routes, nil |
| +} |
| +``` |
| + |
| +Create `routes_cli.go`: |
| + |
| +```go |
| +// `carlos routes` — what routes this app has, and what each one is bound |
| +// to (issue #64). |
| +// |
| +// API MODE ONLY, and that is the point of the verb existing at all. The |
| +// facts it prints live in a box's registry, and reading them used to mean |
| +// sqlite3 on the box: `carlos route` is a setter, `carlos apps` answers |
| +// from the console's app list, and `carlos instances list` answers for |
| +// instance RECORDS, so a hand-added route had no reader anywhere. Since a |
| +// route's ENVIRONMENT binding is what decides which sealed secrets it |
| +// gets, "which routes is this credential delivered to" was a question only |
| +// answerable with box access — the one step of titogo's whole secrets |
| +// procedure that needed it. |
| +// |
| +// The box-side listing is `carlos instances` (cli.go), unchanged: an |
| +// operator standing on the box already has the registry in front of them, |
| +// and a second box-local verb answering the same question would be two |
| +// commands to keep in step for no gain. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "strings" |
| +) |
| + |
| +const routesUsage = `usage: carlos routes -app <a> [-account <sqid|name>] [-match <substr>] [-console <origin>] |
| + |
| +Lists the routes this app has on the console's deployment: where each one |
| +sends traffic, which release channel it follows, and which named config |
| +environment it is bound to (the binding that decides which secrets it |
| +gets — see carlos env, carlos secrets). |
| + |
| +Signed-in only (carlos auth login). On a box, the local listing is |
| +"carlos instances".` |
| + |
| +func runRoutes(args []string) { |
| + fs := flag.NewFlagSet("routes", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose routes to list") |
| + match := fs.String("match", "", "only hosts containing this substring") |
| + consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + sess := requireAPISession("routes", *consoleFlag) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, routesUsage) |
| + rows, err := sess.routeList(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatalf("routes: %v", err) |
| + } |
| + shown := 0 |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "HOST\tKIND\tUPSTREAM\tCHANNEL\tENVIRONMENT\tSTATUS\tHEALTH") |
| + for _, r := range rows { |
| + if *match != "" && !strings.Contains(r.Host, *match) { |
| + continue |
| + } |
| + shown++ |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| + r.Host, dashIfEmpty(r.Kind), dashIfEmpty(r.Upstream), dashIfEmpty(r.Channel), |
| + // An empty ENVIRONMENT is a real answer — the route takes the |
| + // app's default bundle — so it prints as a dash rather than a |
| + // blank cell an operator would read as missing data. |
| + dashIfEmpty(r.Environment), dashIfEmpty(r.Status), dashIfEmpty(r.Health)) |
| + } |
| + w.Flush() |
| + if shown == 0 { |
| + mutedf(stdout, "no routes for %s on this deployment's box", app2) |
| + } |
| + // Said every time, not only when the list is empty: this console reads |
| + // its OWN box's registry, so on a multi-box fleet a short answer is not |
| + // a complete one, and an operator must not read it as one (#145). |
| + mutedf(stdout, "(routes on this deployment's own box; other boxes in a fleet are not listed)") |
| +} |
| + |
| +// dashIfEmpty renders an empty cell as "—" so a column that is legitimately |
| +// blank reads as an answer rather than as missing data. |
| +func dashIfEmpty(s string) string { |
| + if s == "" { |
| + return "—" |
| + } |
| + return s |
| +} |
| +``` |
| + |
| +If `dashIfEmpty` (or an equivalent) already exists in the root package, |
| +use that one and delete this copy — check before adding. |
| + |
| +Register the verb in `cli.go`'s dispatch switch beside `"instances"`, and |
| +add it to the top-level usage text: |
| + |
| +``` |
| + routes list an app's routes and what each is bound to |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test . -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add routes_cli.go routes_cli_test.go apimode.go cli.go |
| +git commit -m "cli: carlos routes (#64)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 9: The box-side listing shows the binding, and the docs catch up |
| + |
| +**Files:** |
| +- Modify: `cli.go:284-310` (the bare `carlos instances` table) |
| +- Modify: `README.md` (the command list, if it enumerates verbs — check |
| + first) |
| +- Test: `cli_test.go` (or wherever the bare listing is already tested) |
| + |
| +**Interfaces:** |
| +- Consumes: nothing new. |
| +- Produces: nothing new. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +func TestInstancesListingShowsTheEnvironmentBinding(t *testing.T) { |
| + // A registry with one route bound to "relay": the bare `carlos |
| + // instances` table must carry an ENV column and the value, so an |
| + // operator standing on the box can read the binding without sqlite3. |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the test to verify it fails** |
| + |
| +Run: `go test . -run TestInstancesListing -count=1` |
| +Expected: FAIL — no ENV column. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `cli.go`'s `runInstances` bare-listing branch: |
| + |
| +```go |
| + // BACKING and UNIT are the two halves of one answer — who owns this |
| + // route's process — so they print side by side: a route with a UNIT is |
| + // cycled by that unit on adoption, whatever BACKING says (and BACKING is |
| + // empty for every such route, which is the shape that made issue #110 |
| + // invisible from this listing). |
| + // |
| + // ENV is the named config bundle the route is bound to (issue #64): it |
| + // decides which secrets this route's process is delivered, and reading |
| + // it used to mean sqlite3 against the registry. Empty means the app's |
| + // default bundle, which is a real answer, so it prints blank rather |
| + // than being omitted. |
| + fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tENV\tSTATUS\tVERSION\tHIB\tBACKING\tUNIT") |
| + for _, t := range routes { |
| + hib := "" |
| + if t.Hibernate { |
| + hib = "yes" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| + t.Host, t.App, t.Kind, t.Channel, t.Environment, t.Status, t.Version, hib, t.Backing, t.Unit) |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./... -count=1` |
| +Expected: PASS, every package. |
| + |
| +- [ ] **Step 5: Full gate, then commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +gofmt -l $(git diff --name-only main... | grep '\.go$') |
| +git add -A |
| +git commit -m "cli: carlos instances shows a route's environment binding (#64)" |
| +``` |
| + |
| +--- |
| + |
| +### Task 10: Branch close-out |
| + |
| +**Files:** |
| +- Modify: the spec (`docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md`) |
| + only if implementation diverged from it — the spec must describe what |
| + was built, not what was planned. |
| + |
| +- [ ] **Step 1: Run the full gate one more time from a clean tree** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +GOOS=darwin go build ./... |
| +``` |
| + |
| +The `GOOS=darwin` compile is not optional: `internal/host` links into the |
| +CLI, which people run on Macs, and CI runs this. |
| + |
| +- [ ] **Step 2: Reconcile the spec with what was built** |
| + |
| +Read the spec section by section against the diff. Fix any place the |
| +implementation went a different way. Commit any edits. |
| + |
| +- [ ] **Step 3: Push the branch and open the PR** |
| + |
| +```bash |
| +git push -u origin titogo-bugs |
| +gh pr create --title "Route-scoped config delivery: exec_delivery per environment, fast rebinding convergence, carlos routes" --body "$(cat <<'EOF' |
| +Closes #195, #196, #64 — the three defects in titogo's "deliver one sealed |
| +credential to one service" runbook. |
| + |
| +**#195** `exec_delivery` is read from the bundle a route's config actually |
| +resolves through: the route's named environment when it states a value, |
| +the app's default bundle otherwise. Additive — every deployment on an |
| +app-level `file` today delivers to exactly the same routes. Reverses half |
| +of the 2026-08-03 issue #18 ruling (ruled 2026-08-16); route scope is |
| +still refused, and the comment says why. |
| + |
| +**#196** The agent's fast config loop fingerprints the registry-side facts |
| +that decide what config a route gets, so binding a route to an environment |
| +converges in ~3s instead of waiting out the hourly floor. Covers both |
| +doors onto the column — `carlos route -environment` and the reconciler |
| +converging `Environment` from an instance record — where the fix suggested |
| +in the issue would have missed the second. |
| + |
| +**#64** `carlos routes` over a new member-only |
| +`GET /api/cli/apps/{account}/{app}/routes`, so a route's environment |
| +binding can be read back without a box shell. Works for hand-added routes |
| +with no instance record, which is the case that made this urgent. The bare |
| +box-side `carlos instances` listing gains an ENV column too. |
| + |
| +Known limitation, stated in the code and the CLI's own help: the console |
| +reads its own box's registry, so other boxes in a fleet are not listed |
| +(#145). |
| + |
| +Spec: `docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md` |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
| + |
| +https://claude.ai/code/session_015gyhxeoXGBW4WCFRnMqBue |
| +EOF |
| +)" |
| +``` |
| + |
| +- [ ] **Step 4: Post the migration note on #195** |
| + |
| +titogo's migration has a manual step the issue does not mention. Comment |
| +on #195 with it: turning the app-level flag off leaves the router routes' |
| +`*.config.env` files in place (they hold decrypted secrets and carlos |
| +cannot know when the app's supervisor stopped reading them), naming them |
| +in the log every pass until deleted by hand. |
| + |
| +--- |
| + |
| +## Deploy (after the PR merges — a human's action, not part of this plan) |
| + |
| +Ship the platform binary to both boxes, promote, adopt, restart the |
| +consoles for the API half. The agent half takes effect on the agent's next |
| +start; the `exec_delivery` half on the next pass after that. Verify with |
| +`carlos routes -app titogo` from off-box, which is the whole point of #64. |