activator: hand every instance and seed its hostname as CARLOS_HOST (#256 C)
bdn/ips learned its hostname from its socket's file name, /run/carlos/<host>.sock. Nested, the socket is /run/carlos/instances/<iid>/app.sock, which names nothing, and three instances crashed at start on the first roll until an operator set the origin by hand in each environment. agentOwnedVars now sets CARLOS_HOST from the row for every exec child, flat and nested, and seedVars does the same for both seed paths. It is agent-owned like CARLOS_CONTROL_SOCKET, CARLOS_PURGE and CARLOS_REGION: a tenant's env file cannot make an app another host. The socket contract is unchanged. docs/site/cli.md gains the launch contract an instance can rely on, with CARLOS_HOST as the way to learn the hostname.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+81
−6
docs/site/cli.md+19 −0internal/activator/activator_test.go+3 −0internal/activator/backend_exec.go+20 −5internal/activator/backend_exec_test.go+34 −1internal/activator/unitproc_test.go+5 −0
diff --git a/docs/site/cli.md b/docs/site/cli.md| index ca0d32ced..b106c9ffc 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -674,6 +674,25 @@ holds the account's authoritative data, and until a batch of orders drains |
| home it holds order data in its own region — so the placement set is an |
| explicit decision on the home's record, never a default. |
| +#### What an instance is started with |
| + |
| +A hibernating instance is started by the box when a request arrives for it, |
| +as `<your binary> --socket <path> --db <path>`: the unix socket to serve on, |
| +and the SQLite database that is yours. Its environment is your app's config |
| +bundle, with the instance's own `<host>.env` layered on top, plus a few |
| +variables the platform sets and that no bundle can override: |
| + |
| +| Variable | Value | |
| +|---|---| |
| +| `CARLOS_HOST` | the instance's hostname, exactly as in `carlos instances list`. Read this, never the socket's file name, which says nothing about the host on an isolated box | |
| +| `CARLOS_REGION` | the box's region code, such as `ie` | |
| +| `CARLOS_CONTROL_SOCKET` | the socket the platform listens on for your app's purge and scheduling calls | |
| +| `CARLOS_PURGE` | `header` when the edge honours a `Carlos-Purge` response header | |
| +| `CARLOS_ADMIN_TOKEN` | a secret shared with this one instance: the platform presents it when it calls your app, and your app presents it on `CARLOS_CONTROL_SOCKET` | |
| + |
| +A seed (the `--seed` command of `carlos instances enable`) runs with the same |
| +environment, `CARLOS_HOST` included, and no control socket. |
| + |
| #### Storage of its own, for every instance |
| `--private-storage` on `enable` is one declaration that changes what every |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index f6edb8d93..8edb0a379 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -5649,6 +5649,9 @@ func TestExecBackendSeedRunsDeclaredArgvWithDBPath(t *testing.T) { |
| if got := child["CARLOS_ADMIN_TOKEN"]; got != "real" { |
| t.Fatalf("seed CARLOS_ADMIN_TOKEN = %q, want %q — the seed gets the instance's own env file", got, "real") |
| } |
| + if got := child["CARLOS_HOST"]; got != host { |
| + t.Fatalf("seed CARLOS_HOST = %q, want %q (#256: the seed is told its host like the instance is)", got, host) |
| + } |
| if _, ok := child["CARLOS_HIBERNATE_BUCKET"]; ok { |
| t.Fatal("the agent's own CARLOS_* config leaked into the seed process") |
| } |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 4ae1ea1e7..638c1d616 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -810,7 +810,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error { |
| // serving cache to purge, nor any business setting timers, in any case. |
| // This is what makes the contract above true rather than merely |
| // intended. |
| - cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| + cmd.Env = childEnv(seedVars(fileEnv, host)) |
| // Like the instance child, the seed logs through the agent's own streams |
| // (journald under systemd) — and its stderr is ALSO teed into a bounded |
| // buffer, because the wake error this returns is the whole diagnosis an |
| @@ -949,7 +949,7 @@ func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error { |
| return nil // defensive: doWake only calls us for a non-empty seed |
| } |
| argv := append([]string{b.liveLink(host)}, seedArgs...) |
| - env := childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| + env := childEnv(seedVars(fileEnv, host)) |
| if err := b.writeLaunch(p, instancectl.RoleSeed, host, argv, env); err != nil { |
| return err |
| } |
| @@ -1342,7 +1342,22 @@ func (b *ExecBackend) agentOwnedVars(rt registry.Route, fileEnv map[string]strin |
| } else { |
| childVars = withoutVars(childVars, "CARLOS_REGION") |
| } |
| - return childVars |
| + // The hostname, always, from the row (discussion #256 item 2). An app |
| + // used to learn it from its socket's file name, /run/carlos/<host>.sock, |
| + // and the nested layout's app.sock names nothing; three instances |
| + // crashed at start over that on the first roll. Agent-owned like the |
| + // three above: a tenant's env file does not get to tell an app it is |
| + // some other host. |
| + return withVar(childVars, "CARLOS_HOST", rt.Host) |
| +} |
| + |
| +// seedVars is the seed's environment over fileEnv: the control-channel |
| +// variables stripped (a seed has no listener for either; see seedChild's |
| +// comment), and CARLOS_HOST set, since a seed initialising a host's |
| +// database is as entitled to know which host as the instance is. One |
| +// composer for both seed paths, as agentOwnedVars is for both run paths. |
| +func seedVars(fileEnv map[string]string, host string) map[string]string { |
| + return withVar(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE"), "CARLOS_HOST", host) |
| } |
| // normalizeSocketMode is the PLATFORM's 0660 on a spawned instance's socket |
| @@ -1522,8 +1537,8 @@ func (b *ExecBackend) instanceEnvParts(rt registry.Route) (cfgEnv, ownEnv map[st |
| // exact composition Start uses (agentOwnedVars(rt, fileEnv, haveControl)), |
| // decoded back to a map. agentOwnedVars REMOVES tenant-provided |
| // CARLOS_CONTROL_SOCKET, CARLOS_PURGE and CARLOS_REGION when the agent |
| -// does not provide them, so a report over mergeEnv alone would misstate |
| -// the environment. haveControl is true: startChild calls |
| +// does not provide them, and always sets CARLOS_HOST, so a report over |
| +// mergeEnv alone would misstate the environment. haveControl is true: startChild calls |
| // edge.ListenControl for EVERY exec child, and b.Control is the nested |
| // root-control client, which says nothing about the flat channel, so |
| // before the spawn the honest composition is "the agent will bind it"; a |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 8bd1e88b6..8d86084f4 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -114,6 +114,35 @@ func TestExecBackendStartChildRegion(t *testing.T) { |
| } |
| } |
| +// Every exec child is told which host it is, in its environment, from the |
| +// row (discussion #256): an app that used to read its hostname off its |
| +// socket's file name has nothing to read nested (app.sock). The variable |
| +// is agent-owned: a tenant's own env file cannot make an app another host. |
| +func TestExecBackendStartChildHandsTheHost(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "host-env.example.com" |
| + _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+".env"), []byte("CARLOS_HOST=forged.example.com\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "instance.sock")} |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, _ := b.Start(ctx, rt) // fake binary never binds the socket; health-wait error expected |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + child := map[string]string{} |
| + for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { |
| + if k, v, ok := strings.Cut(line, "="); ok { |
| + child[k] = v |
| + } |
| + } |
| + if got := child["CARLOS_HOST"]; got != host { |
| + t.Fatalf("child CARLOS_HOST = %q, want %q", got, host) |
| + } |
| +} |
| + |
| // The staging fallback (issue #331): a host whose ordinary prefix is empty |
| // restores from a move's staged copy — promoted server-side, dropped after |
| // a clean restore — and the ordinary prefix ALWAYS wins when it exists. |
| @@ -348,7 +377,11 @@ func TestLaunchEnvironmentComposesTheSpawnEnvironment(t *testing.T) { |
| env["MINE"] == "1", env["UNRELATED"] == "yes", env["CARLOS_SECRETS_KEY"] != "", env["CARLOS_ADMIN_TOKEN"] == "t") |
| } |
| // The agent-owned layer, exactly as agentOwnedVars composes it: the |
| - // tenant's region and purge values are overridden or removed. |
| + // tenant's region and purge values are overridden or removed, and the |
| + // host is the row's (#256). |
| + if env["CARLOS_HOST"] != rt.Host { |
| + t.Fatalf("CARLOS_HOST = %q, want the row's host %q", env["CARLOS_HOST"], rt.Host) |
| + } |
| if env["CARLOS_REGION"] != "ie" || env["CARLOS_CONTROL_SOCKET"] != rt.Socket+".control" { |
| t.Fatalf("agent-owned vars: CARLOS_REGION is the backend's: %v; CARLOS_CONTROL_SOCKET is the route's control path: %v", |
| env["CARLOS_REGION"] == "ie", env["CARLOS_CONTROL_SOCKET"] == rt.Socket+".control") |
diff --git a/internal/activator/unitproc_test.go b/internal/activator/unitproc_test.go| index 9c9d89297..7b867e7c5 100644 |
| --- a/internal/activator/unitproc_test.go |
| +++ b/internal/activator/unitproc_test.go |
| @@ -286,10 +286,15 @@ func TestSeedUnitCleansUpOnlyItsOwnDBOnFailure(t *testing.T) { |
| if len(l.Argv) != 5 || l.Argv[1] != "seed" || l.Argv[2] != "--fast" || l.Argv[3] != "--db" || l.Argv[4] != rt.DBPath { |
| t.Errorf("seed argv = %v", l.Argv) |
| } |
| + gotHost := false |
| for _, kv := range l.Env { |
| if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") || strings.HasPrefix(kv, "CARLOS_PURGE=") { |
| t.Errorf("seed env carries %s", kv) |
| } |
| + gotHost = gotHost || kv == "CARLOS_HOST="+rt.Host |
| + } |
| + if !gotHost { |
| + t.Errorf("seed env lacks CARLOS_HOST=%s (#256): %v", rt.Host, l.Env) |
| } |
| if _, err := os.Stat(rt.DBPath); !os.IsNotExist(err) { |
| t.Errorf("failed seed left its database: %v", err) |