carlos / platform Public

instances: let a route name its own config bundle, so a canary can accept a POST (#385)

`carlos canary` mints a live host in seconds and then hands you one that
cannot accept a form submission. Reported by James against jam: the canary
serves GETs perfectly, and /signin answers 403 cross-origin form submission
refused.

The cause is not in the canary verb. Config in CARLOS is per-app, so a
second instance standing alongside a live one inherits the default bundle —
including whatever single origin the app's framework compares the request's
Origin header against. Jam keys the magic-link URLs, the WebAuthn RP ID and
the cookie Secure flag off that same scalar, so it is not only onboarding
that breaks: no POST works on that host at all.

Everything underneath already existed. Record.Environment is validated by
provision.ValidateShape, convergeOwned converges it onto the row, and the
console renders the binding. What was missing was a door: the create door
listed environment among the fields deliberately NOT accepted, and the only
way to set one was `carlos route --environment`, which convergeOwned reverts
within seconds — that function's own comment names it as the write it undoes.

Why this stopped being invisible now. The four channels in liveChannels are
PRIMARY routes following canary channels — go.tito.io follows
canary/platform-adoption — so it is one route, one origin, one bundle, and
the canary is a build choice rather than a second deployment. `carlos canary`
is what makes the canary an additional instance, and an additional instance
is what needs config of its own.

So:

  carlos instances create --environment <e>
  carlos instances set-environment --app <a> --host <h> --environment <e>
  carlos canary --environment <e>

Environment is not an operator column in the way pool and unit are. Those
govern host resources a member has no standing over; a config bundle is the
app's own, written by the app's own `carlos env --environment`, and binding
one of an app's routes to one of its own bundles sits squarely inside what a
member already controls. The PATCH door's existing control-plane guard and
CAS cover it unchanged.

An empty --environment is a real request (return this route to the default
bundle), so the field carries no omitempty on the PATCH and the CLI requires
the flag's PRESENCE rather than a non-empty value.

Two smaller things that follow from it: `canary ls` grows a CONFIG column,
because "default" on a canary is precisely the state in which every POST
fails closed and nothing about the host looks wrong from outside; and the
deploy verb leaves a canary with no --environment with a note saying so at
the moment it is actionable, rather than letting it be discovered by a 403.

Also fixes the env docs, which told people `carlos route --environment` binds
a route to a bundle. On a provisioned route it does not — it is reverted.


Claude-Session: https://claude.ai/code/session_015jcPXsYTT3ySVTrrv6gHJK
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f7ac3aaf5855e845ae6802b73a5b13ead60b14ce parent 537c4a1
10 files changed, +611 −51
  • apimode.go +51 −8
  • apimode_test.go +3 −3
  • canary.go +60 −6
  • cli.go +3 −0
  • deploy.go +4 −1
  • docs/site/cli.md +43 −7
  • instances_cli.go +103 −8
  • instances_environment_test.go +98 −0
  • internal/console/apiinstances.go +50 −18
  • internal/console/apiinstancesenvironment_test.go +196 −0

CI passed — run details

diff --git a/apimode.go b/apimode.go
index 59556c81b..5b0e1764e 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2236,6 +2236,12 @@ type apiInstanceRecord struct {
// on a pre-steering console's response too — the field predates it, not
// a proof steering is off.
Steering string `json:"steering"`
+ // Environment is the named config bundle this route binds to ("" = the
+ // app's default bundle). The list door has always carried it — the row
+ // embeds provision.Record — but nothing on this side decoded it until
+ // the binding became something a member could set (2026-09-04), and a
+ // column the CLI cannot read is a column the CLI cannot show is wrong.
+ Environment string `json:"environment"`
}
// apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status)
@@ -2272,15 +2278,16 @@ type instanceProbeReq struct {
// upstream (kind defaults to "instance", which needs no addr at all), and
// probe nil means unprobed — the same "absent, not zero" contract the wire
// carries for every optional field on this door.
-func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel, kind string, addrs []string, probe *instanceProbeReq) (apiInstanceRecord, error) {
+func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel, kind, environment string, addrs []string, probe *instanceProbeReq) (apiInstanceRecord, error) {
in := struct {
- Host string `json:"host"`
- Region string `json:"region,omitempty"`
- Channel string `json:"channel,omitempty"`
- Kind string `json:"kind,omitempty"`
- Addrs []string `json:"addrs,omitempty"`
- Probe *instanceProbeReq `json:"probe,omitempty"`
- }{host, region, channel, kind, addrs, probe}
+ Host string `json:"host"`
+ Region string `json:"region,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ Environment string `json:"environment,omitempty"`
+ Addrs []string `json:"addrs,omitempty"`
+ Probe *instanceProbeReq `json:"probe,omitempty"`
+ }{host, region, channel, kind, environment, addrs, probe}
status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in)
if err != nil {
return apiInstanceRecord{}, err
@@ -2362,6 +2369,42 @@ func (s *apiSession) instanceSetUpstreams(ctx context.Context, a apiAcct, app, h
// steering is always sent (never omitted): unlike addrs/probe there is no
// "leave it alone" spelling this verb needs — every invocation states the
// value the caller wants, "latency" or "" (off).
+// instanceSetEnvironment rebinds one route to a named config bundle —
+// `carlos instances set-environment`, over the same PATCH door
+// set-upstreams and set-steering use.
+//
+// environment is always sent, never omitted, for the same reason steering
+// is: every invocation states the value the caller wants. "" is a real
+// request — return this route to the app's default bundle — and not a
+// "leave it alone", which is why the field carries no omitempty and why
+// this signature takes a plain string rather than a pointer. The CLI's own
+// flag handling is what refuses an invocation that named nothing.
+//
+// This is the door that closes the gap `carlos canary` opened. Config is
+// per-app, so a canary route created without it inherits the default bundle
+// — including the single origin the app's framework compares against — and
+// every POST to the canary host fails closed while every GET looks fine.
+func (s *apiSession) instanceSetEnvironment(ctx context.Context, a apiAcct, app, host, environment string) (apiInstanceRecord, error) {
+ in := struct {
+ Environment string `json:"environment"`
+ }{environment}
+ status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in)
+ if err != nil {
+ return apiInstanceRecord{}, err
+ }
+ if status == http.StatusAccepted {
+ return apiInstanceRecord{}, proposedFromBody(body)
+ }
+ if status != http.StatusOK {
+ return apiInstanceRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, fmt.Errorf("instances set-environment: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
func (s *apiSession) instanceSetSteering(ctx context.Context, a apiAcct, app, host, steering string) (apiInstanceRecord, error) {
in := struct {
Steering string `json:"steering"`
diff --git a/apimode_test.go b/apimode_test.go
index 85187a936..6a79e8dfe 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -771,7 +771,7 @@ func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) {
s := newTestSession(ts.URL)
rec, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
- "a.example.com", "", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"},
+ "a.example.com", "", "", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"},
&instanceProbeReq{Path: "/healthz", Status: 200})
if err != nil {
t.Fatalf("instanceCreate: %v", err)
@@ -799,7 +799,7 @@ func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) {
// No addrs/probe given at all: neither key crosses the wire.
gotBody = ""
if _, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
- "b.example.com", "", "", "", nil, nil); err != nil {
+ "b.example.com", "", "", "", "", nil, nil); err != nil {
t.Fatalf("instanceCreate (no addrs/probe): %v", err)
}
if strings.Contains(gotBody, "addrs") || strings.Contains(gotBody, "probe") {
@@ -3760,7 +3760,7 @@ func TestAPISessionInstanceCreateSendsKind(t *testing.T) {
defer ts.Close()
rec, err := newTestSession(ts.URL).instanceCreate(context.Background(),
- apiAcct{Seg: "bab"}, "mysite", "mysite.bab.oncarlos.test", "", "edge", tc.kind, nil, nil)
+ apiAcct{Seg: "bab"}, "mysite", "mysite.bab.oncarlos.test", "", "edge", tc.kind, "", nil, nil)
if err != nil {
t.Fatalf("instanceCreate: %v", err)
}
diff --git a/canary.go b/canary.go
index 950d88c07..3e749c707 100644
--- a/canary.go
+++ b/canary.go
@@ -31,11 +31,13 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/release"
)
const canaryUsage = `usage: carlos canary [deploy] --app <a> [--name <n>] [--version <sha>] [--kind binary|static]
- [--target <goos-goarch>] [--label <l>] [--timeout 120s] <files-or-site-dir>
+ [--environment <e>] [--target <goos-goarch>] [--label <l>]
+ [--timeout 120s] <files-or-site-dir>
carlos canary ls --app <a>
carlos canary rm --app <a> --name <n>
@@ -47,6 +49,14 @@ is deploy.
<name>.<app>.<your account's platform domain>, and its channel is
canary/<name>.
+--environment binds the canary to one of the app's named config bundles.
+Reach for it whenever the app carries config: bundles are per-app, so without
+one the canary serves values naming the app's MAIN host, and anything keyed to
+a single origin — the Origin header the app compares against, magic-link URLs,
+a WebAuthn RP ID, the cookie Secure flag — refuses requests on the canary host
+while GETs look healthy. "carlos env --app <a> --environment <e> KEY=V" writes
+a bundle; this flag points the canary at it.
+
Promotion into a canary is never refused, and a canary costs no rung: a build
that has been on one still enters the pipeline as a fresh build, so canarying
and then promoting to edge is the ordinary path, not a bypass.
@@ -332,6 +342,7 @@ func runCanaryDeploy(args []string) {
acctRaw, acctIDRaw, _ := accountFlags(fs)
consoleFlag := fs.String("console", "", "console to deploy through (default: the only logged-in one)")
kind := fs.String("kind", "binary", "binary | static")
+ environment := fs.String("environment", "", "named config bundle the canary binds to (default: the app's default bundle)")
ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)")
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
label := fs.String("label", "", "one line for humans — shows up in carlos releases and in the console")
@@ -385,14 +396,19 @@ func runCanaryDeploy(args []string) {
recKind = "static"
}
+ env := strings.TrimSpace(*environment)
+ if env != "" && !appconfig.ValidEnvironmentName(env) {
+ log.Fatalf("canary: %q is not a config environment — want [a-z0-9-]{1,32}; carlos env environments --app %s lists this app's", env, app2)
+ }
+
pal := colorFor(stdout)
switch canaryRecordPlan(recs, host, channel) {
case canaryPlanCreate:
- rec, cerr := sess.instanceCreate(ctx, acct, app2, host, "", channel, recKind, nil, nil)
+ rec, cerr := sess.instanceCreate(ctx, acct, app2, host, "", channel, recKind, env, nil, nil)
if cerr != nil {
log.Fatalf("canary: create %s: %v", host, cerr)
}
- fmt.Fprintf(stdout, "%s %s (channel %s)\n", pal.ok("canary host"), rec.Host, rec.Channel)
+ fmt.Fprintf(stdout, "%s %s (channel %s%s)\n", pal.ok("canary host"), rec.Host, rec.Channel, environmentSuffix(rec.Environment))
case canaryPlanRechannel:
// set-channel, not `carlos route --channel`: the record is where a
// provisioned route's channel lives, and the reconciler reverts the
@@ -406,6 +422,37 @@ func runCanaryDeploy(args []string) {
mutedf(stdout, "canary host %s already follows %s\n", host, channel)
}
+ // --environment on an EXISTING record is its own write: create carried
+ // it above, but the two converge paths did not, and a re-canary that
+ // silently kept yesterday's binding is the failure this flag exists to
+ // prevent. Only ever sent when the flag was actually typed — an omitted
+ // flag must leave a deliberate binding alone rather than clear it.
+ if flagWasSet(fs, "environment") && canaryRecordPlan(recs, host, channel) != canaryPlanCreate {
+ rec, eerr := sess.instanceSetEnvironment(ctx, acct, app2, host, env)
+ if eerr != nil {
+ log.Fatalf("canary: bind %s to environment %q: %v", host, env, eerr)
+ }
+ if rec.Environment == "" {
+ fmt.Fprintf(stdout, "%s %s to the app's default bundle\n", pal.ok("bound"), rec.Host)
+ } else {
+ fmt.Fprintf(stdout, "%s %s to environment %s\n", pal.ok("bound"), rec.Host, rec.Environment)
+ }
+ }
+
+ // The one thing this verb cannot do for you, said once, at the moment it
+ // is actionable. Config is per-app: with no bundle of its own the canary
+ // serves the app's default one, and for anything that keys off a single
+ // origin scalar — the Origin header the framework compares against,
+ // magic-link URLs, a WebAuthn RP ID, the cookie Secure flag — those
+ // values name the app's OTHER host. GETs look perfectly healthy and
+ // every POST fails closed, which is the worst way for this to be
+ // discovered.
+ if env == "" && recKind != "static" {
+ mutedf(stdout, "no --environment: this canary serves %s's default config, so anything keyed to the app's\n"+
+ " main origin (CORS, magic links, WebAuthn, cookie flags) will refuse requests on this host.\n"+
+ " carlos env --app %s --environment <e> writes a bundle; --environment <e> binds it here.\n", app2, app2)
+ }
+
// printBanner false: printUsing above already said it once.
if _, err = runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, "", nil, false); err != nil {
log.Fatalf("canary: ship: %v", err)
@@ -470,14 +517,21 @@ func runCanaryList(args []string) {
now := time.Now()
w := newTable(stdout)
- fmt.Fprintln(w, "NAME\tHOST\tCHANNEL\tVERSION\tLEASE")
+ // CONFIG earns its column: "default" on a canary standing beside a live
+ // instance is the state in which every POST to that host fails closed,
+ // and it is invisible from the outside — the host serves GETs perfectly.
+ fmt.Fprintln(w, "NAME\tHOST\tCHANNEL\tVERSION\tCONFIG\tLEASE")
for _, r := range canaries {
version := r.StagedVersion
if version == "" {
version = "—"
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
- canaryNameOf(r.Channel), r.Host, r.Channel, version, canaryLeaseLabel(promoted[r.Channel], now))
+ env := r.Environment
+ if env == "" {
+ env = "default"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
+ canaryNameOf(r.Channel), r.Host, r.Channel, version, env, canaryLeaseLabel(promoted[r.Channel], now))
}
if err := w.Flush(); err != nil {
log.Fatalf("canary ls: %v", err)
diff --git a/cli.go b/cli.go
index 0e89a73ae..e20eac7df 100644
--- a/cli.go
+++ b/cli.go
@@ -292,6 +292,9 @@ func runInstances(args []string) {
case "set-channel":
runInstancesSetChannel(args[1:])
return
+ case "set-environment":
+ runInstancesSetEnvironment(args[1:])
+ return
}
}
fs := flag.NewFlagSet("instances", flag.ExitOnError)
diff --git a/deploy.go b/deploy.go
index 7172f33a9..0f664742e 100644
--- a/deploy.go
+++ b/deploy.go
@@ -809,7 +809,10 @@ func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app,
if err != nil {
return "", err
}
- rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", nil, nil)
+ // No environment: the app's apex serves its default config bundle, which
+ // is exactly right — this is the app's primary host, the one every other
+ // bundle's origin values are written to name.
+ rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", "", nil, nil)
if err != nil {
return "", err
}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index a48343ca3..aef13a492 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -182,13 +182,27 @@ does a canary cost you a rung: canary channels sit outside every pipeline, so
a build that has been on one still enters the pipeline as a fresh build.
Canary, then promote to your entry channel, and the ladder is untouched.
+**If your app carries config, reach for `--environment`.** A canary is a
+second instance standing alongside a live one, and config in CARLOS is
+per-app: with no bundle of its own the canary serves the app's default one,
+where every origin-shaped value names the app's *main* host. Anything keyed
+to that single scalar — the `Origin` header the app compares against,
+magic-link URLs, a WebAuthn RP ID, the cookie `Secure` flag — refuses
+requests on the canary host, so the canary serves GETs perfectly and fails
+every form submission. The verb says so when you leave the flag off.
+
+```sh
+carlos env --app jam --environment jam-canary JAM_ORIGIN=https://new-queue.jam.bab.oncarlos.com
+carlos canary --app jam --environment jam-canary ./jam
+```
+
```sh
carlos canary ls --app jam
carlos canary rm --app jam --name new-queue
```
-`ls` shows each canary's host, what it is serving, and what is left of its
-seven-day lease — refreshed every time you re-promote, so an active canary
+`ls` shows each canary's host, what it is serving, which config bundle it is
+bound to, and what is left of its seven-day lease — refreshed every time you re-promote, so an active canary
never expires and a forgotten one starts saying so. `rm` deletes the
instance record: the host stops being served and the process goes away. The
channel itself remains, followed by nothing, and lapses at the end of its
@@ -317,10 +331,18 @@ carlos env set --app hello LOG_LEVEL=debug
```
Values land in the app's default bundle. `--environment <name>` writes a named
-bundle instead, layered on top of the default when config is materialized,
-and `carlos route --environment` is what binds a route to one.
+bundle instead, layered on top of the default when config is materialized.
`carlos env environments --app hello` lists the bundle names an app has.
+Binding a route to a bundle is a different verb depending on the route.
+[carlos instances set-environment](/docs/cli#carlos-instances) is the one to
+reach for: on a provisioned route the binding lives in the instance record,
+and the reconciler converges the row from that record every pass — so
+`carlos route --environment`, the box-local operator form, is reverted within
+seconds and refuses such a row by name. `carlos instances create` takes
+`--environment` too, which is how a route is born on the right bundle rather
+than moved onto it.
+
### carlos secrets
The same shape as `carlos env`, sealed. `list` prints key names and never
@@ -338,9 +360,9 @@ pair, and it is always local to your terminal.
An app's instances are the places it actually runs. `carlos instances enable`
is the app's opt-in to provisioning, and until it is set the API refuses to
-create anything. After that, `create`, `list`, `delete` and `set-upstreams`
-work on records through the console, and the box's reconcile pass turns a
-record into a live route.
+create anything. After that, `create`, `list`, `delete`, `set-upstreams`,
+`set-channel` and `set-environment` work on records through the console, and
+the box's reconcile pass turns a record into a live route.
```sh
carlos instances enable --app hello
@@ -351,6 +373,20 @@ carlos instances list --app hello
`not-responding` and the rest. That is usually what you want when something
is wrong.
+`--environment <e>` on `create`, and `set-environment` on a route that
+already exists, bind one instance to one of the app's named config bundles.
+This matters most for a route standing **alongside** a live one. Config is
+per-app, so a second instance inherits the default bundle, and for anything
+the app keys off a single origin — the `Origin` header it compares against,
+magic-link URLs, a WebAuthn RP ID, the cookie `Secure` flag — that bundle
+names the *other* host. The result is a host that serves every GET perfectly
+and refuses every POST. Write the bundle with `carlos env --environment`,
+bind it here, then restart the instance to pick the values up.
+
+```sh
+carlos instances set-environment --app jam --host new-queue.jam.bab.oncarlos.com --environment jam-canary
+```
+
Typed on a box with no sub-verb, `carlos instances` does something different:
it lists that box's own registry routes, backing and owning unit included.
Off the box, [carlos routes](/docs/cli#carlos-routes) answers the same
diff --git a/instances_cli.go b/instances_cli.go
index 74df7630c..b5341dace 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -30,6 +30,7 @@ import (
"os"
"strings"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -179,13 +180,14 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p
}
const instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> [--region <r>] [--channel <c>]
- [--kind instance|service|static]
+ [--kind instance|service|static] [--environment <e>]
[--addr <a:p,...>] [--health-path <p>] [--health-status <n>]
- carlos instances list --app <a> [--match <substr>] [--health <slug>]
- carlos instances delete --app <a> --host <h>
- carlos instances set-upstreams --app <a> --host <h> [--addrs <a:p,...>]
- [--health-path <p>] [--health-status <n>]
- carlos instances set-channel --app <a> --host <h> --channel <c>
+ carlos instances list --app <a> [--match <substr>] [--health <slug>]
+ carlos instances delete --app <a> --host <h>
+ carlos instances set-upstreams --app <a> --host <h> [--addrs <a:p,...>]
+ [--health-path <p>] [--health-status <n>]
+ carlos instances set-channel --app <a> --host <h> --channel <c>
+ carlos instances set-environment --app <a> --host <h> --environment <e>
Signed-in only (carlos auth login): instance records are console-authorized
writes — the console checks the host against the app's manifest and the
@@ -204,6 +206,18 @@ pass — so this is the verb that changes it, not "carlos route --channel",
which edits a row the next reconcile overwrites. A move that the app's
pipeline gates is proposed and finished in the console, like a promote.
+--environment binds a route to one of the app's named config bundles
+("carlos env environments" lists them; "carlos env --environment <e>" writes
+them). Omitted, a route serves the app's default bundle — which matters most
+when a route stands ALONGSIDE a live one: config is per-app, so a second
+instance inherits values naming the first one's host, and anything the app
+keys off a single origin (the Origin header it checks, magic-link URLs, a
+WebAuthn RP ID, the cookie Secure flag) fails closed on the new host while
+GETs look healthy. set-environment rebinds an existing route the same way,
+and for the same reason set-channel exists rather than "carlos route
+--environment": the reconciler reverts the box-side edit within seconds.
+Restart the instance afterwards to pick the new values up.
+
set-upstreams-only flags:
--addrs <a:p,...> comma-separated TCP upstream set, host:port each,
@@ -252,6 +266,7 @@ func runInstancesCreate(args []string) {
region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)")
channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then this app's other instances, then its pipeline entry)")
kind := fs.String("kind", "", "route class: instance (default) | service | static — a static record serves a file tree off the channel pointer and has no process")
+ environment := fs.String("environment", "", "named config bundle this route binds to (default: the app's default bundle) — carlos env environments lists them")
addr := fs.String("addr", "", "comma-separated TCP upstream set, host:port each (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means the platform's own default upstream")
healthPath := fs.String("health-path", "", "states the whole probe alongside --health-status: request path (default \"/\"); honoured for a single --addr too")
healthStatus := fs.Int("health-status", 0, "probe's expected response status (default 200); fields neither flag names take the platform's own defaults")
@@ -273,11 +288,12 @@ func runInstancesCreate(args []string) {
app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
rec, err := sess.instanceCreate(context.Background(), acct, app2,
strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel),
- strings.TrimSpace(*kind), addrs, probe)
+ strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe)
if err != nil {
log.Fatalf("instances create: %v", err)
}
- fmt.Fprintf(stdout, "%s %s (channel %s)\n", colorFor(stdout).ok("created instance"), rec.Host, rec.Channel)
+ fmt.Fprintf(stdout, "%s %s (channel %s%s)\n", colorFor(stdout).ok("created instance"),
+ rec.Host, rec.Channel, environmentSuffix(rec.Environment))
mutedf(stdout, " the host comes live on the next reconcile pass on its box")
}
@@ -604,3 +620,82 @@ func runInstancesSetChannel(args []string) {
mutedf(stdout, " the change lands on its box within seconds of the next converge tick")
}
}
+
+// environmentSuffix renders a record's config binding for a one-line
+// confirmation, and renders NOTHING for the default bundle. The empty case
+// is the common one and naming it ("environment default") on every create
+// would train people to skim the parenthetical that matters.
+func environmentSuffix(env string) string {
+ if strings.TrimSpace(env) == "" {
+ return ""
+ }
+ return ", environment " + env
+}
+
+// runInstancesSetEnvironment is `carlos instances set-environment` — rebind
+// ONE route to a named config bundle.
+//
+// This is the door that did not exist until 2026-09-04, and its absence had
+// a specific shape. Config in CARLOS is per-app, so a second instance
+// standing alongside a live one inherits the default bundle. For any app
+// whose framework keys behaviour off a single origin scalar — the Origin
+// header it compares against, the magic-link base URL, the WebAuthn RP ID,
+// the cookie Secure flag — that bundle names the OTHER host, and every POST
+// to the new one fails closed while every GET looks perfectly healthy.
+//
+// It is an instances verb and not `carlos route --environment` for exactly
+// the reason set-channel is: the record is where a provisioned route's
+// Environment lives, and convergeOwned (internal/provision/reconcile.go)
+// reverts a box-side edit within seconds of the next serial bump — that
+// function's own doc comment names `carlos route -environment` as the write
+// it undoes.
+//
+// An explicit empty --environment is a real request (return this route to
+// the app's default bundle), so the flag's PRESENCE is what is required
+// here, never a non-empty value.
+func runInstancesSetEnvironment(args []string) {
+ fs := flag.NewFlagSet("instances set-environment", flag.ExitOnError)
+ app := appFlag(fs, "app the instance belongs to")
+ host := fs.String("host", "", "instance host whose config binding to change")
+ environment := fs.String("environment", "", "named config bundle to bind to; empty returns the route to the app's default bundle")
+ consoleFlag := fs.String("console", "", "console to act 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.Usage = flagUsage(fs, instancesRecordsUsage)
+ fs.Parse(args)
+ if *host == "" || !flagWasSet(fs, "environment") {
+ log.Fatal(instancesRecordsUsage)
+ }
+ env := strings.TrimSpace(*environment)
+ // Checked here as well as at the door, so a typo costs a refusal rather
+ // than a round trip. "default" is not a bundle name — it is the absence
+ // of one, spelled by an empty string (appconfig.ValidEnvironmentName
+ // refuses the literal for exactly this reason).
+ if env != "" && !appconfig.ValidEnvironmentName(env) {
+ log.Fatalf("carlos instances set-environment: %q is not a config environment — want [a-z0-9-]{1,32}; pass an empty --environment to return this route to the app's default bundle", env)
+ }
+ sess := requireAPISession("set-environment", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
+ rec, err := sess.instanceSetEnvironment(context.Background(), acct, app2, strings.TrimSpace(*host), env)
+ if err != nil {
+ var prop *apiProposedError
+ if errors.As(err, &prop) {
+ fmt.Fprintf(stdout, "rebinding %s is proposed and needs %d confirmation(s) at %s\n",
+ strings.TrimSpace(*host), prop.Needed, prop.ConsoleURL)
+ return
+ }
+ log.Fatalf("instances set-environment: %v", err)
+ }
+ if rec.Environment == "" {
+ fmt.Fprintf(stdout, "%s %s to the app's default bundle\n", colorFor(stdout).ok("bound"), rec.Host)
+ } else {
+ fmt.Fprintf(stdout, "%s %s to environment %s\n", colorFor(stdout).ok("bound"), rec.Host, rec.Environment)
+ }
+ // The config a process was started with is the config it keeps: this
+ // changes which bundle the box DELIVERS, and the running process picks
+ // it up when it is next started. Saying so is the difference between
+ // "it didn't work" and "it needs a restart".
+ mutedf(stdout, " the binding lands within seconds; restart the instance to pick the new values up: carlos restart --app %s\n", app2)
+}
diff --git a/instances_environment_test.go b/instances_environment_test.go
new file mode 100644
index 000000000..ef65b0daf
--- /dev/null
+++ b/instances_environment_test.go
@@ -0,0 +1,98 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// instanceCreate carries the config binding when one is asked for, and omits
+// the key entirely when it is not — so a create from a caller that never
+// heard of the flag is byte-identical to what it always sent.
+func TestInstanceCreateSendsEnvironmentOnlyWhenGiven(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": "canary.example.com", "channel": "canary/x", "environment": "jam-canary",
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ rec, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
+ "canary.example.com", "", "canary/x", "", "jam-canary", nil, nil)
+ if err != nil {
+ t.Fatalf("instanceCreate: %v", err)
+ }
+ if !strings.Contains(gotBody, `"environment":"jam-canary"`) {
+ t.Errorf("request body = %s, want it to carry the environment", gotBody)
+ }
+ // The decode matters as much as the send: `canary ls` prints this column,
+ // and the whole point of the column is that a wrong binding is otherwise
+ // invisible — the host serves GETs perfectly either way.
+ if rec.Environment != "jam-canary" {
+ t.Errorf("decoded environment = %q, want jam-canary", rec.Environment)
+ }
+
+ gotBody = ""
+ if _, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
+ "plain.example.com", "", "", "", "", nil, nil); err != nil {
+ t.Fatalf("instanceCreate (no environment): %v", err)
+ }
+ if strings.Contains(gotBody, "environment") {
+ t.Errorf("request body = %s, want no environment key when none was given", gotBody)
+ }
+}
+
+// instanceSetEnvironment sends the field ALWAYS, empty string included:
+// clearing a binding — returning a route to the app's default bundle — is a
+// real request, and an omitempty here would silently turn it into a no-op
+// PATCH the door then refuses as "nothing to update".
+func TestInstanceSetEnvironmentSendsAnEmptyValue(t *testing.T) {
+ var gotBody, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"host": "canary.example.com", "environment": ""})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ rec, err := s.instanceSetEnvironment(context.Background(), apiAcct{Seg: "bah"}, "app",
+ "canary.example.com", "")
+ if err != nil {
+ t.Fatalf("instanceSetEnvironment: %v", err)
+ }
+ if gotMethod != http.MethodPatch {
+ t.Errorf("method = %s, want PATCH — this rides the same door as set-upstreams", gotMethod)
+ }
+ if !strings.Contains(gotBody, `"environment":""`) {
+ t.Errorf("request body = %s, want an explicit empty environment", gotBody)
+ }
+ if rec.Environment != "" {
+ t.Errorf("decoded environment = %q, want empty", rec.Environment)
+ }
+}
+
+// environmentSuffix renders nothing for the default bundle. The empty case is
+// the common one, and printing "environment default" on every create would
+// train people to skim the parenthetical in the case that matters.
+func TestEnvironmentSuffix(t *testing.T) {
+ for _, tc := range []struct{ env, want string }{
+ {"", ""},
+ {" ", ""},
+ {"jam-canary", ", environment jam-canary"},
+ } {
+ if got := environmentSuffix(tc.env); got != tc.want {
+ t.Errorf("environmentSuffix(%q) = %q, want %q", tc.env, got, tc.want)
+ }
+ }
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index a041381cc..6ce77a0c8 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -525,14 +525,35 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
// below — the same door the reconcile pass re-checks them at, because
// neither side trusts the other about what is in the bucket.
//
- // What is deliberately NOT here: pool (manifest policy), hibernate,
- // unit and environment (operator columns with their own verbs), and
- // anything that could widen which HOST this app may claim — the
- // suffix check below is unchanged, so a "kind":"static" body cannot
- // reach for a hostname outside the app's own domain.
- Kind string `json:"kind"`
- Addrs []string `json:"addrs"`
- Health *provision.Health `json:"probe"`
+ // What is deliberately NOT here: pool (manifest policy), hibernate
+ // and unit (operator columns with their own verbs), and anything
+ // that could widen which HOST this app may claim — the suffix check
+ // below is unchanged, so a "kind":"static" body cannot reach for a
+ // hostname outside the app's own domain.
+ //
+ // Environment WAS on that list until 2026-09-04, and the omission
+ // had a cost worth recording. Config in CARLOS is per-app, so a
+ // second instance standing alongside a live one inherits the
+ // default bundle — including whatever single origin the app's
+ // framework compares the request's Origin header against. That
+ // makes every POST to the new host fail closed: sign-in, magic
+ // links, WebAuthn RP ID, the cookie Secure flag, all keyed off a
+ // scalar that names the OTHER host. `carlos canary` mints exactly
+ // such a route, so without this field the verb hands you a live
+ // hostname that cannot accept a form submission for any app that
+ // carries config, which is most of them.
+ //
+ // It is not an operator column in the way pool and unit are. Those
+ // govern host resources the member has no standing over; a config
+ // bundle is the app's own, written by the app's own `carlos env
+ // --environment`, and binding one of an app's routes to one of its
+ // own bundles is squarely inside what a member already controls.
+ // ValidateShape (provision.go) checks the name, and the same
+ // control-plane guard the PATCH door applies is applied above.
+ Kind string `json:"kind"`
+ Environment string `json:"environment"`
+ Addrs []string `json:"addrs"`
+ Health *provision.Health `json:"probe"`
}
if !readJSON(w, r, &req) {
return
@@ -656,11 +677,12 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
Channel: channel,
// UTC, like every other timestamp written to the bucket: these
// records are read on boxes in other regions.
- CreatedAt: time.Now().UTC(),
- CreatedBy: principal,
- Kind: strings.TrimSpace(req.Kind),
- Addrs: req.Addrs,
- Health: req.Health,
+ CreatedAt: time.Now().UTC(),
+ CreatedBy: principal,
+ Kind: strings.TrimSpace(req.Kind),
+ Environment: strings.TrimSpace(req.Environment),
+ Addrs: req.Addrs,
+ Health: req.Health,
}
// A static platform host is HA by default. Static is the one serving
// class every platform box materializes unconditionally
@@ -940,17 +962,24 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
// Absent (nil) leaves the record's steering alone; `"steering":""` turns
// it off; `"steering":"latency"` turns it on (subject to the pool rail
// below).
+ // Environment is a *string for the same reason Steering is: one scalar
+ // fact, so absent (nil) leaves the record's binding alone and
+ // `"environment":""` returns the route to the app's default bundle.
+ // Rebinding is exactly what the handler doc above describes as
+ // legitimate-when-deliberate — this door repoints the control plane, and
+ // refuseServiceControlPlane has already decided whether this caller may.
var req struct {
- Addrs *[]string `json:"addrs"`
- Probe json.RawMessage `json:"probe"`
- Steering *string `json:"steering"`
+ Addrs *[]string `json:"addrs"`
+ Probe json.RawMessage `json:"probe"`
+ Steering *string `json:"steering"`
+ Environment *string `json:"environment"`
}
if !readJSON(w, r, &req) {
return
}
- if req.Addrs == nil && req.Probe == nil && req.Steering == nil {
+ if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil {
jsonError(w, http.StatusBadRequest,
- "nothing to update — send addrs, probe, steering, or a combination")
+ "nothing to update — send addrs, probe, steering, environment, or a combination")
return
}
var probe *provision.Health
@@ -994,6 +1023,9 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if req.Steering != nil {
rec.Steering = *req.Steering
}
+ if req.Environment != nil {
+ rec.Environment = strings.TrimSpace(*req.Environment)
+ }
// The same door the create path and the box's reconcile pass check
// against, so a shape that could not have been created cannot be patched
// into existence either — and the caller gets ValidateShape's own
diff --git a/internal/console/apiinstancesenvironment_test.go b/internal/console/apiinstancesenvironment_test.go
new file mode 100644
index 000000000..208ebd0a0
--- /dev/null
+++ b/internal/console/apiinstancesenvironment_test.go
@@ -0,0 +1,196 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// The create door stamps a named config bundle onto the record (2026-09-04).
+//
+// Why it matters, and why it was worth changing a door that deliberately
+// omitted the field: config in CARLOS is per-app, so a second instance
+// standing alongside a live one inherits the default bundle — including
+// whatever single origin the app's framework compares the request's Origin
+// header against. Every POST to the new host then fails closed while every
+// GET looks perfectly healthy. `carlos canary` mints exactly such a route.
+func TestAPIInstanceCreateStampsEnvironment(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-env-create")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+
+ status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"canary.go.tito.io","region":"ie","environment":"jam-canary"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create with environment = %d %s, want 201", status, out)
+ }
+ var echoed provision.Record
+ if err := json.Unmarshal([]byte(out), &echoed); err != nil {
+ t.Fatalf("create body %s: %v", out, err)
+ }
+ if echoed.Environment != "jam-canary" {
+ t.Fatalf("echoed environment = %q, want jam-canary", echoed.Environment)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "jam-canary" {
+ t.Fatalf("stored environment = %q, want jam-canary — the record is what convergeOwned reads", stored.Environment)
+ }
+}
+
+// An omitted environment still means the app's default bundle, byte for byte
+// what this door did before the field existed. Every record in production was
+// written without it.
+func TestAPIInstanceCreateWithoutEnvironmentIsUnchanged(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-env-absent")
+ enableInstances(t, fx, "titogo", titoManifest())
+
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"plain.go.tito.io","region":"ie"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %s, want 201", status, out)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "plain.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "" {
+ t.Fatalf("stored environment = %q, want empty for an omitted field", stored.Environment)
+ }
+}
+
+// A malformed name is ValidateShape's own 400, at the same door the box's
+// reconcile pass re-checks against — neither side trusts the other about
+// what is in the bucket.
+func TestAPIInstanceCreateRefusesBadEnvironment(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-env-bad")
+ enableInstances(t, fx, "titogo", titoManifest())
+
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"bad.go.tito.io","region":"ie","environment":"Not A Bundle"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("create with a malformed environment = %d %s, want 400", status, out)
+ }
+ if !strings.Contains(out, "environment") {
+ t.Errorf("refusal %s does not name the offending field", out)
+ }
+}
+
+// The PATCH door's fourth field: bind an existing route to a bundle, and
+// clear it again. This is the door `carlos instances set-environment` and
+// `carlos canary --environment` both reach, and the one that closes the gap
+// convergeOwned opened by reverting `carlos route -environment`.
+func TestAPIInstanceUpdateEnvironmentRoundTrip(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-env-round")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"env.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ afterCreate := serial(t, fx)
+
+ pu := updateURL(fx, "bab", "titogo", "env.go.tito.io")
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"environment":"jam-canary"}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch environment = %d %s, want 200", status, out)
+ }
+ var echoed provision.Record
+ if err := json.Unmarshal([]byte(out), &echoed); err != nil {
+ t.Fatalf("patch body %s: %v", out, err)
+ }
+ if echoed.Environment != "jam-canary" {
+ t.Fatalf("echoed environment = %q, want jam-canary", echoed.Environment)
+ }
+ // An environment-only patch must leave the upstream set exactly alone —
+ // the same independence the addrs/probe/steering fields already have.
+ if !slices.Equal(echoed.Addrs, []string{"127.0.0.1:7001"}) {
+ t.Fatalf("environment-only patch moved addrs: %v", echoed.Addrs)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "env.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "jam-canary" {
+ t.Fatalf("stored environment = %q, want jam-canary", stored.Environment)
+ }
+ if got := serial(t, fx); got != afterCreate+1 {
+ t.Fatalf("serial after patch = %d, want exactly one bump past %d", got, afterCreate)
+ }
+ afterBind := serial(t, fx)
+
+ // "" is a real request — return this route to the app's default bundle —
+ // and not a "leave it alone". That is why the client sends the field
+ // without omitempty.
+ status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"environment":""}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch environment off = %d %s, want 200", status, out)
+ }
+ stored, err = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "env.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "" {
+ t.Fatalf("stored environment after clearing = %q, want empty", stored.Environment)
+ }
+ if !slices.Equal(stored.Addrs, []string{"127.0.0.1:7001"}) {
+ t.Fatalf("environment-only patch moved addrs: %v", stored.Addrs)
+ }
+ if got := serial(t, fx); got != afterBind+1 {
+ t.Fatalf("serial after clearing = %d, want exactly one bump past %d", got, afterBind)
+ }
+}
+
+// The fourth field joins the "nothing to update" rail rather than making it
+// optional — a body naming none of the four is still a 400 that costs no
+// bucket round trip and no fleet-wide serial bump.
+func TestAPIInstanceUpdateEmptyBodyNamesEnvironment(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-env-empty")
+ enableInstances(t, fx, "titogo", titoManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"none.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ before := serial(t, fx)
+ status, _, out := rawReq(t, http.MethodPatch,
+ updateURL(fx, "bab", "titogo", "none.go.tito.io"), token, `{}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("patch with no fields = %d %s, want 400", status, out)
+ }
+ if !strings.Contains(out, "environment") {
+ t.Errorf("empty-body refusal = %s, want it to name environment among the patchable fields", out)
+ }
+ if got := serial(t, fx); got != before {
+ t.Errorf("serial after a refused empty patch = %d, want it unmoved from %d", got, before)
+ }
+}
+
+// A malformed name is refused on the PATCH door too, and the stored record is
+// left exactly as it was — a refusal must not half-apply.
+func TestAPIInstanceUpdateRefusesBadEnvironment(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-env-patch-bad")
+ enableInstances(t, fx, "titogo", titoManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"keep.go.tito.io","region":"ie","environment":"good-one"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ status, _, out := rawReq(t, http.MethodPatch,
+ updateURL(fx, "bab", "titogo", "keep.go.tito.io"), token, `{"environment":"Not A Bundle"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("patch with a malformed environment = %d %s, want 400", status, out)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "keep.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "good-one" {
+ t.Fatalf("stored environment = %q after a refused patch, want the original good-one", stored.Environment)
+ }
+}