sidecars: bind a config environment at create (#340)
A sidecar read only its app's default config bundle — the same bundle the
app's instances read — so a secret a sidecar needed but the app's server
must never hold had nowhere to live. Eleven's bot is the case: it holds an
ANTHROPIC_API_KEY and its one-time invites, and the chat server process must
never see either.
`carlos sidecars create -environment <e>` (and the console's create body)
now names a config environment for the record. sidecarRouteFor already
carried Record.Environment onto the route and config delivery already keys
on it, so a named bundle overlays the default for the sidecar and for no
instance. Shape-checked by ValidateSidecarShape (the instance record's
[a-z0-9-]{1,32}); the same binding authority an instance create has.
Census: the record-write digest for handleAPISidecarCreate is re-pinned
(the record now carries Environment), and a PROPOSED envelope-only Plan 14
overlay re-pins the root, internal/console and internal/provision envelopes
— awaiting the Plan 14 owner's ruling. No root-read unit changed; the seal
stands.
Follow-up, not here: set-environment for an existing sidecar (records are
create-only, and #199's leftover-replica rail refuses reusing a name).
9 files changed,
+108
−19
apimode.go+11 −7internal/console/apisidecars.go+19 −8internal/console/apisidecars_test.go+35 −0internal/provision/plan14_root_reads_test.go+7 −0internal/provision/plan9_b8_census_test.go+2 −1internal/provision/sidecar.go+7 −0internal/provision/sidecar_test.go+13 −0internal/provision/testdata/plan14-root-reads-sidecar-environment.tsv+4 −0sidecars_cli.go+10 −3
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (18 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (166 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.812s
ok github.com/carlosframework/platform/cmd/moveprobe 12.146s
ok github.com/carlosframework/platform/cmd/zddprobe 0.804s
ok github.com/carlosframework/platform/internal/activator 60.580s
ok github.com/carlosframework/platform/internal/agentsock 0.029s
ok github.com/carlosframework/platform/internal/alerting 0.009s
ok github.com/carlosframework/platform/internal/analytics 0.039s
ok github.com/carlosframework/platform/internal/appconfig 3.089s
ok github.com/carlosframework/platform/internal/apperr 0.153s
ok github.com/carlosframework/platform/internal/applog 0.253s
ok github.com/carlosframework/platform/internal/awsinv 0.014s
ok github.com/carlosframework/platform/internal/boxchoice 0.012s
ok github.com/carlosframework/platform/internal/boxid 1.016s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.021s
ok github.com/carlosframework/platform/internal/caps 0.024s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.880s
ok github.com/carlosframework/platform/internal/changelog 0.537s
ok github.com/carlosframework/platform/internal/claimactivation 1.390s
ok github.com/carlosframework/platform/internal/clamd 0.081s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.920s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.017s
ok github.com/carlosframework/platform/internal/dbverify 0.011s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.017s
ok github.com/carlosframework/platform/internal/domains 3.076s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 45.562s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.135s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.016s
ok github.com/carlosframework/platform/internal/geo 0.016s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/historyreadback 0.158s
ok github.com/carlosframework/platform/internal/host 136.684s
ok github.com/carlosframework/platform/internal/hostactors 5.986s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.010s
ok github.com/carlosframework/platform/internal/hostbox 3.050s
ok github.com/carlosframework/platform/internal/hostclaim 0.822s
ok github.com/carlosframework/platform/internal/hostenv 0.008s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 5.344s
ok github.com/carlosframework/platform/internal/hostfence 0.060s
ok github.com/carlosframework/platform/internal/hostfiles 10.444s
ok github.com/carlosframework/platform/internal/hostfloor 0.020s
ok github.com/carlosframework/platform/internal/hostguard 1.086s
ok github.com/carlosframework/platform/internal/hostops 1.932s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.004s
ok github.com/carlosframework/platform/internal/hostquarantine 0.007s
ok github.com/carlosframework/platform/internal/hostrelocate 0.678s
ok github.com/carlosframework/platform/internal/hostresolve 0.226s
ok github.com/carlosframework/platform/internal/hostwrite 0.009s
ok github.com/carlosframework/platform/internal/infraview 0.004s
ok github.com/carlosframework/platform/internal/instancectl 5.226s
ok github.com/carlosframework/platform/internal/instanceexec 2.801s
ok github.com/carlosframework/platform/internal/instancefs 0.014s
ok github.com/carlosframework/platform/internal/instancekey 0.006s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.767s
ok github.com/carlosframework/platform/internal/lbhealth 0.005s
ok github.com/carlosframework/platform/internal/lease 10.252s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.561s
ok github.com/carlosframework/platform/internal/ledger 0.024s
ok github.com/carlosframework/platform/internal/mail 0.237s
ok github.com/carlosframework/platform/internal/maintenance 0.107s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.581s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.130s
ok github.com/carlosframework/platform/internal/metrics 0.008s
ok github.com/carlosframework/platform/internal/moves 22.668s
ok github.com/carlosframework/platform/internal/moves/archive 0.991s
ok github.com/carlosframework/platform/internal/objstore 0.351s
ok github.com/carlosframework/platform/internal/ops 0.209s
ok github.com/carlosframework/platform/internal/park 0.270s
ok github.com/carlosframework/platform/internal/plans 0.005s
ok github.com/carlosframework/platform/internal/privatefile 0.010s
ok github.com/carlosframework/platform/internal/provision 23.278s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.627s
ok github.com/carlosframework/platform/internal/relay 7.231s
ok github.com/carlosframework/platform/internal/release 26.580s
ok github.com/carlosframework/platform/internal/rollout 3.820s
ok github.com/carlosframework/platform/internal/rolloutwords 0.006s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.059s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.587s
ok github.com/carlosframework/platform/internal/s3compat 0.009s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.720s
ok github.com/carlosframework/platform/internal/seeds 0.013s
ok github.com/carlosframework/platform/internal/sim/hostlife 12.177s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.779s
ok github.com/carlosframework/platform/internal/status 0.090s
ok github.com/carlosframework/platform/internal/steering 0.008s
ok github.com/carlosframework/platform/internal/stopevidence 0.290s
ok github.com/carlosframework/platform/internal/svcrole 0.003s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.004s
ok github.com/carlosframework/platform/internal/updatewords 0.016s
ok github.com/carlosframework/platform/internal/usage 0.006s
ok github.com/carlosframework/platform/internal/version 0.004s
ok github.com/carlosframework/platform/internal/vet 1.702s
=== internal_console@0 (78 s)
ok github.com/carlosframework/platform/internal/console 76.833s
=== internal_console@1 (37 s)
ok github.com/carlosframework/platform/internal/console 35.981s
=== internal_console@2 (38 s)
ok github.com/carlosframework/platform/internal/console 36.436s
=== internal_console@3 (36 s)
ok github.com/carlosframework/platform/internal/console 35.175s
=== internal_console@4 (43 s)
ok github.com/carlosframework/platform/internal/console 41.839s
=== internal_console@5 (39 s)
ok github.com/carlosframework/platform/internal/console 38.043s
=== internal_console@6 (40 s)
ok github.com/carlosframework/platform/internal/console 38.826s
=== internal_console@7 (36 s)
ok github.com/carlosframework/platform/internal/console 35.034s
=== root@0 (34 s)
ok github.com/carlosframework/platform 32.464s
=== root@1 (45 s)
ok github.com/carlosframework/platform 43.268s
=== root@2 (42 s)
ok github.com/carlosframework/platform 40.993s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.026s
=== RUN TestT4StorageProbeMinio
2026/10/21 21:41:27 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0A96173F3E49E, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/apimode.go b/apimode.go| index 42621bc70..e97926f2f 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -4058,8 +4058,11 @@ type apiSidecarRecord struct { |
| Region string `json:"region"` |
| Channel string `json:"channel"` |
| Placement string `json:"placement"` |
| - CreatedAt string `json:"created_at"` |
| - CreatedBy string `json:"created_by"` |
| + // Environment is the named config bundle the sidecar is bound to; empty |
| + // is the app's default bundle. |
| + Environment string `json:"environment"` |
| + CreatedAt string `json:"created_at"` |
| + CreatedBy string `json:"created_by"` |
| // RunningVersion is the console's best-effort join from the fleet's own |
| // box snapshots (sidecarListRecord.RunningVersion) — see the doc above. |
| RunningVersion string `json:"running_version"` |
| @@ -4071,12 +4074,13 @@ type apiSidecarRecord struct { |
| // region and channel are the caller's requests, both subject to the |
| // manifest's policy and a fleet-placed app's own placement, which |
| // overrides either (handleAPISidecarCreate, apisidecars.go). |
| -func (s *apiSession) sidecarCreate(ctx context.Context, a apiAcct, app, name, region, channel string) (apiSidecarRecord, error) { |
| +func (s *apiSession) sidecarCreate(ctx context.Context, a apiAcct, app, name, region, channel, environment string) (apiSidecarRecord, error) { |
| in := struct { |
| - Name string `json:"name"` |
| - Region string `json:"region,omitempty"` |
| - Channel string `json:"channel,omitempty"` |
| - }{name, region, channel} |
| + Name string `json:"name"` |
| + Region string `json:"region,omitempty"` |
| + Channel string `json:"channel,omitempty"` |
| + Environment string `json:"environment,omitempty"` |
| + }{name, region, channel, environment} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/sidecars"), in) |
| if err != nil { |
| return apiSidecarRecord{}, err |
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go| index b16346bc7..e9c2fe7b4 100644 |
| --- a/internal/console/apisidecars.go |
| +++ b/internal/console/apisidecars.go |
| @@ -231,6 +231,16 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request) |
| Name string `json:"name"` |
| Region string `json:"region"` |
| Channel string `json:"channel"` |
| + // Environment binds the sidecar to one of the app's named config |
| + // bundles instead of the default — the same binding, and the same |
| + // authority, an instance create carries. It is what keeps a |
| + // sidecar's secrets out of the app's own instances: a named bundle |
| + // overlays the default for the routes bound to it, and only for them |
| + // (appconfig.LoadMerged), so a secret set only in the sidecar's |
| + // bundle never reaches an instance on the default one. The motivating |
| + // case is a bot that holds an API key its app's server must never |
| + // see (amadan carlos/platform#340). Empty = the default bundle. |
| + Environment string `json:"environment"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| @@ -291,14 +301,15 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request) |
| return |
| } |
| rec := provision.SidecarRecord{ |
| - Schema: 1, |
| - Name: name, |
| - Host: provision.SidecarHost(name, app, acct.Sqid), |
| - App: app, |
| - Account: acct.Sqid, |
| - Pool: m.Pool, |
| - Region: region, |
| - Channel: channel, |
| + Schema: 1, |
| + Name: name, |
| + Host: provision.SidecarHost(name, app, acct.Sqid), |
| + App: app, |
| + Account: acct.Sqid, |
| + Pool: m.Pool, |
| + Region: region, |
| + Channel: channel, |
| + Environment: strings.TrimSpace(req.Environment), |
| // UTC, like every other timestamp written to the bucket: these |
| // records are read on boxes in other regions. |
| CreatedAt: time.Now().UTC(), |
diff --git a/internal/console/apisidecars_test.go b/internal/console/apisidecars_test.go| index 62ff24636..d1f84a0a8 100644 |
| --- a/internal/console/apisidecars_test.go |
| +++ b/internal/console/apisidecars_test.go |
| @@ -1024,3 +1024,38 @@ func TestAPISidecarRateLimitNamesTheVerbFamily(t *testing.T) { |
| t.Fatalf("429 body = %s, want it to name the verb family the caller was using", out) |
| } |
| } |
| + |
| +// TestAPISidecarCreateBindsAnEnvironment: a sidecar can read a named config |
| +// bundle instead of the app's default — what keeps its secrets out of the |
| +// app's instances (amadan carlos/platform#340). The binding lands on the |
| +// stored record (the reconcile pass reads those bytes), a blank one is the |
| +// default bundle, and a name that could not be a bucket key segment is |
| +// refused at the door. |
| +func TestAPISidecarCreateBindsAnEnvironment(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-side-env") |
| + u := sidecarsURL(fx, "bab", "titogo") |
| + m := titoSidecarManifest() |
| + m.Regions = nil |
| + enableSidecars(t, fx, "titogo", m) |
| + |
| + for _, c := range []struct{ name, body, want string }{ |
| + {"bots", `{"name":"bots","environment":"bots"}`, "bots"}, |
| + {"plain", `{"name":"plain","environment":" "}`, ""}, // blank: the default bundle |
| + {"bare", `{"name":"bare"}`, ""}, // omitted: the default bundle |
| + } { |
| + status, _, out := rawReq(t, http.MethodPost, u, token, c.body) |
| + if status != http.StatusCreated { |
| + t.Fatalf("%s: create = %d %s, want 201", c.name, status, out) |
| + } |
| + rec, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", c.name) |
| + if err != nil { |
| + t.Fatalf("%s: read back: %v", c.name, err) |
| + } |
| + if rec.Environment != c.want { |
| + t.Errorf("%s: stored environment = %q, want %q", c.name, rec.Environment, c.want) |
| + } |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, `{"name":"evil","environment":"../default"}`); status != http.StatusBadRequest { |
| + t.Fatalf("traversal environment: create = %d %s, want 400", status, out) |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index e0524ad72..25b5c79a5 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1255,6 +1255,13 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-merge-main-4.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay sidecar-environment (amadan #340, PROPOSED — awaiting |
| + // the Plan 14 owner's ruling), loaded last: a sidecar record names a |
| + // config environment at create. Envelope only; the root package, |
| + // internal/console and internal/provision re-pinned. The seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-sidecar-environment.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 094df27bd..04aab4837 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -686,7 +686,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/console/apiinstances.go", "Server.handleAPIInstanceUpdate", "record-writer", "key:UpdateRecord", "43a8bead675a4aa33777c6c68431882d55d79a9c1d27e1a54c8f103dda087de0"}, |
| {"internal/console/apiscan.go", "Server.writeScanRecord", "nonrecord", "sel:Put", "c3c092f4b7be9bf6bf09d0fe1e2c9fe52892ee97e57f6c7ab964b85fe36fb5c6"}, |
| {"internal/console/apisidecars.go", "Server.handleAPISidecarList", "key-site", "lit:sidecars", "6a5e461256c985ea4924bf7ba7c0793ae601a59efe8ef570913138ef8b47441a"}, |
| - {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "f3b049692e3289d9bb3c77e9e6a55dbdfac78bd9ff90af893b6cc753e76a0164"}, |
| + // carlos/platform#340: the record now also carries a request-named Environment, shape-checked by ValidateSidecarShape before the create-only write — the same binding authority instance create has. |
| + {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "dc83b93c9e195d013628ee842a579267de28982aeb878c8108bedde1d3ce5ca0"}, |
| {"internal/console/apistatusinstances.go", "Server.handleAPIStatusInstances", "key-site", "lit:instances", "2b4b9a2bea5f2fc5727869a5dcb8ae96c020e72571621f19898b195ca3b61aaa"}, |
| {"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"}, |
| // Settings plan 5 Task 5: the shared core behind the JSON doors (instanceGate, svcrole.Set{}, readJSON) and the instance page's forms (session, CSRF, app membership, write budget). The doors' two UpdateRecord writes moved here from handleAPIUpdatesPut and handleAPIUpdatesInstall. applyUpdates refuses a named-unit or non-instance record (shapeRefusal), validates, gates enabling on the fleet (enableRefusal), and writes only through provision.UpdateRecord under the record's etag (a lost race is a 409). |
diff --git a/internal/provision/sidecar.go b/internal/provision/sidecar.go| index aa77c8a2c..e2287bdf0 100644 |
| --- a/internal/provision/sidecar.go |
| +++ b/internal/provision/sidecar.go |
| @@ -245,6 +245,13 @@ func ValidateSidecarShape(rec SidecarRecord) error { |
| if rec.Pool != "" && !ValidPool(rec.Pool) { |
| return fmt.Errorf("pool %q: want [a-z0-9-]{0,16}", rec.Pool) |
| } |
| + // An environment is a bucket key segment on both sides (apps/<acct>/ |
| + // <app>/environments/<name>.json), so it gets the instance record's own |
| + // shape check (ValidateShape) — the reconcile pass carries it straight |
| + // onto the route, and config delivery reads the bundle it names. |
| + if rec.Environment != "" && !validEnvironmentName(rec.Environment) { |
| + return fmt.Errorf("environment %q: want [a-z0-9-]{1,32}", rec.Environment) |
| + } |
| return nil |
| } |
diff --git a/internal/provision/sidecar_test.go b/internal/provision/sidecar_test.go| index 42232d83d..93f1341c0 100644 |
| --- a/internal/provision/sidecar_test.go |
| +++ b/internal/provision/sidecar_test.go |
| @@ -161,4 +161,17 @@ func TestValidateSidecarShape(t *testing.T) { |
| if err := ValidateSidecarShape(bad); err == nil { |
| t.Fatal("placement vocabulary must be closed") |
| } |
| + // The environment is a bucket key segment: the instance record's shape. |
| + withEnv := ok |
| + withEnv.Environment = "bots" |
| + if err := ValidateSidecarShape(withEnv); err != nil { |
| + t.Fatalf("named environment refused: %v", err) |
| + } |
| + for _, env := range []string{"../default", "Bots", "a/b"} { |
| + bad := ok |
| + bad.Environment = env |
| + if err := ValidateSidecarShape(bad); err == nil { |
| + t.Errorf("environment %q accepted", env) |
| + } |
| + } |
| } |
diff --git a/internal/provision/testdata/plan14-root-reads-sidecar-environment.tsv b/internal/provision/testdata/plan14-root-reads-sidecar-environment.tsv| new file mode 100644 |
| index 000000000..818b88f91 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-sidecar-environment.tsv |
| @@ -0,0 +1,4 @@ |
| +# Plan 14 census overlay sidecar-environment (amadan #340, PROPOSED — awaiting the Plan 14 owner's ruling): a sidecar record can name a config environment at create (carlos sidecars create -environment; the console's create body), shape-checked by provision.ValidateSidecarShape before the create-only write. Envelope-only: no root-read unit changed or was added; the root package (sidecars_cli.go, apimode.go), internal/console (apisidecars.go) and internal/provision (sidecar.go) envelopes are re-pinned. No accepted-legacy row, so the seal stands. |
| +@coverage . fe2aa9d20757983168741663f7ed37f6756020915d5afddf843e77daf0fd8205 |
| +@coverage internal/console 6f9623cc61b40eff32fcb7ea2305dcc70cf7be4581ff1679d166af7134421267 |
| +@coverage internal/provision bb802135e3ad5e9e36f397578df488c008aa4946bff7b2540db79b9f7cd65029 |
diff --git a/sidecars_cli.go b/sidecars_cli.go| index 652528eb3..166c97032 100644 |
| --- a/sidecars_cli.go |
| +++ b/sidecars_cli.go |
| @@ -282,7 +282,7 @@ func sidecarsEnable(ctx context.Context, s release.Store, acct, app string, m pr |
| return provision.PutSidecarManifest(ctx, s, acct, app, m) |
| } |
| -const sidecarsRecordsUsage = `usage: carlos sidecars create -app <a> [-region <r>] [-channel <c>] <name> |
| +const sidecarsRecordsUsage = `usage: carlos sidecars create -app <a> [-region <r>] [-channel <c>] [-environment <e>] <name> |
| carlos sidecars ls -app <a> [-long] |
| carlos sidecars wake -app <a> <name> |
| carlos sidecars rm -app <a> <name> |
| @@ -305,6 +305,12 @@ create-only flags: |
| placement, when it has one (carlos apps place) |
| -channel <c> release channel the sidecar follows (default: the |
| manifest's, then "stable") |
| + -environment <e> |
| + named config bundle the sidecar reads instead of the |
| + app's default. A named bundle overlays the default for |
| + the routes bound to it and only them, so a secret set |
| + with "carlos secrets set -environment <e>" reaches the |
| + sidecar and never the app's instances |
| ls-only flags: |
| @@ -341,6 +347,7 @@ func runSidecarsCreate(args []string) { |
| app := fs.String("app", "", "app the sidecar belongs to") |
| region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list, or the app's own placement)") |
| channel := fs.String("channel", "", "release channel the sidecar follows (default: the manifest's, then stable)") |
| + environment := fs.String("environment", "", "named config bundle the sidecar reads instead of the app's default — keeps its secrets out of the app's instances (carlos env set --environment <e>)") |
| consoleFlag := consoleOption(fs, "console to create 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") |
| @@ -353,11 +360,11 @@ func runSidecarsCreate(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, sidecarsRecordsUsage) |
| - rec, err := sess.sidecarCreate(context.Background(), acct, app2, name, strings.TrimSpace(*region), strings.TrimSpace(*channel)) |
| + rec, err := sess.sidecarCreate(context.Background(), acct, app2, name, strings.TrimSpace(*region), strings.TrimSpace(*channel), strings.TrimSpace(*environment)) |
| if err != nil { |
| log.Fatalf("sidecars create: %v", err) |
| } |
| - fmt.Fprintf(stdout, "%s %s (channel %s)\n", colorFor(stdout).ok("created sidecar"), rec.Name, rec.Channel) |
| + fmt.Fprintf(stdout, "%s %s (channel %s%s)\n", colorFor(stdout).ok("created sidecar"), rec.Name, rec.Channel, environmentSuffix(rec.Environment)) |
| // Deliberately NOT "it comes live": the default sidecar hibernates, so the |
| // reconcile pass mints its row and then nothing runs. Saying otherwise |
| // sends a member off to read logs for a process that was never started. |