carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 774acd651816fd8bcde8a61abd52aecacfd3605f parent 8a8bf4b
5 files changed, +81 −6
  • docs/site/cli.md +19 −0
  • internal/activator/activator_test.go +3 −0
  • internal/activator/backend_exec.go +20 −5
  • internal/activator/backend_exec_test.go +34 −1
  • internal/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)