isolated exec: consumers follow the layout; the agent wires the control client, the gate and the startup sweep (plan 3, task 6)
Config delivery (syncexec.go): ConfigEnvPath resolves a nested row's rendered bundle inside its own instance directory (isolated-exec design §7) instead of the flat <dataDir>/<host>.config.env; the writer uses instancefs.WriteAtomic for a nested path (tenant-owned ground, §3.2) and the plain temp+rename it always used for a flat one. reapConfigEnv now also walks <dataDir>/instances one level down to reap stale nested files. Schedule token (runner.go, schedule_wiring.go): TokenFromEnvPaths takes the env file's location as a func, and TokenFromEnvFile is now that with the flat resolver baked in. scheduleToken gains a registry so envPathFor can classify a host through activator.Classify and point the runner's Token and the EnsureAdminToken prepare step at the nested layout's own <host>.env when the row is nested. Moves pass (movespass.go): moveCfg gains Route, wired from reg.Get; a cutting move now fences on provision.LayoutJournalPath (the same fence Classify applies to a wake) and checkpoints the row's own DBPath rather than guessing the legacy flat path. Litestream glob (litestream.go): FleetDBsWithSkipped folds in a second glob under <dataDir>/instances/*/*.db so a nested row's database is discovered for replication. Agent (agent.go): wires eb.Control (instancectl.NewClient over controlsock.Path), eb.Layout (journal check + reg.List as Peers), eb.NestedOK (nestedGate: the three nosymfollow mounts then /ready), and svc.Routes = reg; runs the startup instance sweep before svc.Start(). Provision (provision.go): dropped its internal/appconfig import (appconfig now imports provision for the nested layout paths, which would otherwise cycle) by duplicating ValidEnvironmentName's small regex check locally as validEnvironmentName — same shape, same behaviour, no shared import. Tests: TestSyncExecWritesANestedRouteIntoItsInstanceDir, TestReapConfigEnvWalksInstanceDirs, TestFleetDBsDiscoversNestedDatabases, TestMovesPassCutFencedByLayoutJournal, TestMovesPassCutUsesRouteDBPath, TestNestedGateRefusesOnAMissingMount, TestNestedGateRefusesOnAMountCheckError, TestNestedGatePassesMountsThenChecksReady.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 files changed,
+518
−26
internal/appconfig/syncexec.go+97 −11internal/appconfig/syncexec_test.go+91 −0internal/host/agent.go+55 −0internal/host/agent_isolated_test.go+61 −0internal/host/movespass.go+26 −0internal/host/movespass_test.go+70 −0internal/host/schedule_wake_test.go+2 −2internal/host/schedule_wiring.go+31 −4internal/host/schedule_wiring_test.go+3 −3internal/ops/litestream.go+11 −0internal/ops/ops_test.go+36 −0internal/provision/provision.go+20 −2internal/schedule/runner.go+15 −4
CI cancelled — superseded by a newer push — run details
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 0b9ae9d48..4d38d8bfc 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -44,6 +44,8 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -52,6 +54,26 @@ import ( |
| // with this suffix belongs to sync; nothing else in dataDir does. |
| const configEnvSuffix = ".config.env" |
| +// nestedRoute reports whether rt's DB and socket are exactly the nested |
| +// layout's defaults (isolated-exec design §8.1) — the same two-column test |
| +// activator.Classify makes, spelled out here rather than imported so this |
| +// package need not take a dependency on internal/activator for one |
| +// comparison. |
| +func nestedRoute(dataDir string, rt registry.Route) bool { |
| + return rt.DBPath == provision.NestedDBPath(dataDir, rt.Host) && rt.Socket == provision.NestedSocket(rt.Host) |
| +} |
| + |
| +// ConfigEnvPath is where a route's rendered bundle lands: inside the |
| +// instance directory for a nested row (isolated-exec design §7), flat for |
| +// everything else, including opted-in addr routes whose supervisor reads |
| +// the flat path. |
| +func ConfigEnvPath(dataDir string, rt registry.Route) string { |
| + if nestedRoute(dataDir, rt) { |
| + return filepath.Join(provision.InstanceDir(dataDir, rt.Host), rt.Host+configEnvSuffix) |
| + } |
| + return filepath.Join(dataDir, rt.Host+configEnvSuffix) |
| +} |
| + |
| // SyncExec resolves config once per distinct (Account, App, Environment) |
| // among the box's exec-syncable routes and converges every such route's |
| // <dataDir>/<host>.config.env, calling bounce(host) whenever a file's |
| @@ -462,7 +484,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv |
| if c.v || !c.known { |
| continue |
| } |
| - path := filepath.Join(dataDir, rt.Host+configEnvSuffix) |
| + path := ConfigEnvPath(dataDir, rt) |
| if _, err := os.Stat(path); err != nil { |
| continue // nothing was ever staged for this route: nothing to warn about |
| } |
| @@ -869,7 +891,7 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| fmt.Printf(" %q: not a bare hostname, refusing to derive a config file path from it\n", rt.Host) |
| return |
| } |
| - path := filepath.Join(dataDir, rt.Host+configEnvSuffix) |
| + path := ConfigEnvPath(dataDir, rt) |
| if m == nil { |
| if err := os.Remove(path); err != nil { |
| if !os.IsNotExist(err) { |
| @@ -918,15 +940,29 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| if existed && bytes.Equal(cur, rendered) { |
| return // up to date — stay quiet on the periodic tick |
| } |
| - tmp := path + ".tmp" |
| - if err := os.WriteFile(tmp, rendered, 0o600); err != nil { |
| - fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) |
| - return |
| - } |
| - if err := os.Rename(tmp, path); err != nil { |
| - os.Remove(tmp) // don't leave rendered secrets behind |
| - fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err) |
| - return |
| + // A nested row's config file lives inside its own instance directory — |
| + // tenant-owned ground (isolated-exec design §3.2) — so it is written |
| + // through instancefs.WriteAtomic's O_EXCL temp name and openat2 |
| + // resolution rather than a predictable path.tmp: the same reasoning |
| + // backend_exec.go's own writes into that directory follow. The flat |
| + // directory is agent-owned throughout, so it keeps the plain |
| + // temp-then-rename it always used. |
| + if nestedRoute(dataDir, rt) { |
| + if err := instancefs.WriteAtomic(filepath.Dir(path), filepath.Base(path), rendered, 0o600); err != nil { |
| + fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + } else { |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, rendered, 0o600); err != nil { |
| + fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + os.Remove(tmp) // don't leave rendered secrets behind |
| + fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| } |
| fmt.Printf(" %s: config updated\n", rt.Host) |
| bounceErr := bounceLogged(rt.Host, bounce) |
| @@ -1021,4 +1057,54 @@ func reapConfigEnv(routes []registry.Route, dataDir string) { |
| } |
| fmt.Printf(" %s: no route on this box, removed its config file\n", host) |
| } |
| + reapNestedConfigEnv(live, dataDir) |
| +} |
| + |
| +// reapNestedConfigEnv is reapConfigEnv's nested half (isolated-exec design |
| +// §7): a nested row's rendered bundle lives inside its own instance |
| +// directory, one level under <dataDir>/instances, so a stale file there is |
| +// found by walking that tree rather than dataDir's own listing. The flat |
| +// directory's <host>.config / <host>.config.env name collision (reapConfigEnv's |
| +// own ambiguity guard, above) cannot happen inside an instance directory — |
| +// nothing in there is named after some OTHER host — so no such guard is |
| +// needed here. |
| +func reapNestedConfigEnv(live map[string]bool, dataDir string) { |
| + root := filepath.Join(dataDir, "instances") |
| + dirs, err := os.ReadDir(root) |
| + if err != nil { |
| + return // no nested layout on this box (or nothing has migrated yet) |
| + } |
| + for _, d := range dirs { |
| + if !d.IsDir() { |
| + continue |
| + } |
| + instDir := filepath.Join(root, d.Name()) |
| + ents, err := os.ReadDir(instDir) |
| + if err != nil { |
| + continue |
| + } |
| + for _, e := range ents { |
| + if e.IsDir() { |
| + continue |
| + } |
| + if strings.HasSuffix(e.Name(), configEnvSuffix+".tmp") { |
| + if err := os.Remove(filepath.Join(instDir, e.Name())); err != nil { |
| + fmt.Printf(" %s: remove stale config temp file: %v\n", e.Name(), err) |
| + } |
| + continue |
| + } |
| + if !strings.HasSuffix(e.Name(), configEnvSuffix) { |
| + continue |
| + } |
| + host := strings.TrimSuffix(e.Name(), configEnvSuffix) |
| + if live[host] { |
| + continue |
| + } |
| + if err := os.Remove(filepath.Join(instDir, e.Name())); err != nil { |
| + fmt.Printf(" %s: remove orphaned config file: %v\n", host, err) |
| + continue |
| + } |
| + fmt.Printf(" %s: no route on this box, removed its config file\n", host) |
| + } |
| + } |
| } |
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go| index b580080f9..ee6c48fbb 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -12,6 +12,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/relay" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -121,6 +122,96 @@ func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) { |
| } |
| } |
| +// TestSyncExecWritesANestedRouteIntoItsInstanceDir pins the isolated-exec |
| +// half of config delivery (design §7): a route whose DB and socket are the |
| +// nested layout's defaults gets its rendered bundle inside its OWN instance |
| +// directory, never at the flat <dataDir>/<host>.config.env path a foreign or |
| +// flat row would use. |
| +func TestSyncExecWritesANestedRouteIntoItsInstanceDir(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + host := "inst.example.com" |
| + // The nested layout's directory pre-exists here exactly as it would on |
| + // a real box: the migration pass that flips a row's DBPath/Socket to |
| + // the nested defaults is what creates it, before any route can |
| + // classify as nested at all. |
| + if err := os.MkdirAll(provision.InstanceDir(dd, host), 0o755); err != nil { |
| + t.Fatalf("mkdir instance dir: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, Kind: "instance", App: "eleven", Account: "bab", |
| + Status: "ready", DBPath: provision.NestedDBPath(dd, host), Socket: provision.NestedSocket(host), |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + var bounced []string |
| + bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + nestedPath := filepath.Join(provision.InstanceDir(dd, host), host+".config.env") |
| + got, err := os.ReadFile(nestedPath) |
| + if err != nil { |
| + t.Fatalf("read nested config env: %v", err) |
| + } |
| + if string(got) != "LOG_LEVEL=debug\n" { |
| + t.Fatalf("config env = %q, want LOG_LEVEL=debug", got) |
| + } |
| + if fi, _ := os.Stat(nestedPath); fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("nested config env mode = %v, want 0600", fi.Mode().Perm()) |
| + } |
| + if _, err := os.Stat(filepath.Join(dd, host+".config.env")); !os.IsNotExist(err) { |
| + t.Fatalf("a nested route's config also landed at the flat path, stat err = %v", err) |
| + } |
| + if len(bounced) != 1 || bounced[0] != host { |
| + t.Fatalf("bounced = %v, want exactly [%s]", bounced, host) |
| + } |
| +} |
| + |
| +// TestReapConfigEnvWalksInstanceDirs pins reapConfigEnv's nested half: a |
| +// stale config file inside a gone host's instance directory is removed, and |
| +// a live nested route's own file survives. |
| +func TestReapConfigEnvWalksInstanceDirs(t *testing.T) { |
| + dd := t.TempDir() |
| + reg := testRegistry(t) |
| + live, gone := "live.example.com", "gone.example.com" |
| + for _, host := range []string{live, gone} { |
| + if err := os.MkdirAll(provision.InstanceDir(dd, host), 0o755); err != nil { |
| + t.Fatalf("mkdir instance dir for %s: %v", host, err) |
| + } |
| + p := filepath.Join(provision.InstanceDir(dd, host), host+configEnvSuffix) |
| + if err := os.WriteFile(p, []byte("A=1\n"), 0o600); err != nil { |
| + t.Fatalf("stage %s: %v", host, err) |
| + } |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: live, Kind: "instance", App: "eleven", Account: "bab", |
| + Status: "ready", DBPath: provision.NestedDBPath(dd, live), Socket: provision.NestedSocket(live), |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + reapConfigEnv(routes, dd) |
| + |
| + livePath := filepath.Join(provision.InstanceDir(dd, live), live+configEnvSuffix) |
| + if _, err := os.Stat(livePath); err != nil { |
| + t.Fatalf("live nested route's config was reaped: %v", err) |
| + } |
| + gonePath := filepath.Join(provision.InstanceDir(dd, gone), gone+configEnvSuffix) |
| + if _, err := os.Stat(gonePath); !os.IsNotExist(err) { |
| + t.Fatalf("stale nested config survived reap, stat err = %v", err) |
| + } |
| +} |
| + |
| func TestSyncExecRemovesFileWhenManifestDeleted(t *testing.T) { |
| ctx, s, reg, dd, priv, _ := execKit(t) |
| pk, _ := ParsePrivateKey(priv, "test") |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index f7ecf08d5..1dbc325f7 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -171,10 +171,13 @@ import ( |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/controlsock" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/fleetapi" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -304,10 +307,26 @@ func RunAgent(args []string) { |
| if logSink != nil { // a typed-nil in the interface would NOT be nil |
| eb.Logs = logSink |
| } |
| + // The root instance-control channel (design §5.3) and the box facts |
| + // a nested wake needs beyond the row (design §8.1, Task 6): the |
| + // journal check and the peer list both read this SAME registry |
| + // handle, so a nested row's classification here always agrees with |
| + // what the rest of this process already knows about the box. |
| + eb.Control = instancectl.NewClient(controlsock.Path) |
| + eb.Layout = activator.LayoutInputs{ |
| + DataDir: dd, |
| + Journal: func(host string) bool { _, err := os.Stat(provision.LayoutJournalPath(dd, host)); return err == nil }, |
| + Peers: reg.List, |
| + } |
| + eb.NestedOK = nestedGate(instancefs.MountNoSymfollow, eb.Control.Ready) |
| svc = activator.New(*cfg, &activator.SplitBackend{ |
| Exec: eb, |
| Unit: &activator.UnitBackend{OptDir: release.OptDir()}, |
| }, reg) // the registry satisfies activator.DueStore directly |
| + // doWake's own re-read of the committed row before it spawns |
| + // (design §6.8) — the registry satisfies RouteLookup with its own |
| + // Get, exactly as DueStore above. |
| + svc.Routes = reg |
| if fleetStore != nil { |
| // MUST be assigned before Start() below spawns the sweeper |
| // goroutine, which reads svc.OnWake on every successful wake from |
| @@ -329,6 +348,18 @@ func RunAgent(args []string) { |
| eb.Mirror = m |
| } |
| armMovesPass(*cfg, dd, reg, svc) |
| + // The startup sweep (design §6.7): a leftover instance unit from a |
| + // previous agent process — one this box's registry no longer holds |
| + // a live wake for — is stopped before this process starts spawning |
| + // its own. Best-effort, like every other startup duty here: a |
| + // channel that never answers logs and moves on, and the box is no |
| + // worse off than before this existed (a leftover unit that keeps |
| + // running is exactly what an agent restart left before this pass). |
| + if sw, err := eb.Control.Sweep(context.Background()); err != nil { |
| + log.Printf("agent: instance sweep: %v (nested wakes refuse until the channel answers)", err) |
| + } else if len(sw.Units) > 0 { |
| + log.Printf("agent: swept %d instance unit(s) left from a previous agent: %v", len(sw.Units), sw.Units) |
| + } |
| svc.Start() |
| act = svc |
| log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle) |
| @@ -494,6 +525,30 @@ func RunAgent(args []string) { |
| stopHousekeeper() |
| } |
| +// nestedGate is what an isolated wake requires of the box (design §8.5, |
| +// §8.6): the three nosymfollow mounts a nested instance's tree depends on, |
| +// and a control service that reports ready. The answer is recomputed on |
| +// every wake (the mounts are cheap to read; /ready is one short request), |
| +// so a box that becomes ready needs no agent restart and a box that stops |
| +// being ready refuses the very next wake rather than the stale verdict a |
| +// once-at-startup check would give. |
| +func nestedGate(mounts func(string) (bool, error), ready func(context.Context) error) func() error { |
| + return func() error { |
| + for _, m := range []string{"/data/instances", "/run/carlos/instances", "/run/carlos/logs"} { |
| + ok, err := mounts(m) |
| + if err != nil { |
| + return fmt.Errorf("%s: %v", m, err) |
| + } |
| + if !ok { |
| + return fmt.Errorf("%s is not a nosymfollow mount", m) |
| + } |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineReady) |
| + defer cancel() |
| + return ready(ctx) |
| + } |
| +} |
| + |
| // peekRegistryPath pre-parses the agent args for -registry (default |
| // <dataDir>/registry.db) without disturbing them — edge.RunWithActivator does |
| // the authoritative parse. The set mirrors edge.Run's flags so a full command |
diff --git a/internal/host/agent_isolated_test.go b/internal/host/agent_isolated_test.go| new file mode 100644 |
| index 000000000..f54d110c7 |
| --- /dev/null |
| +++ b/internal/host/agent_isolated_test.go |
| @@ -0,0 +1,61 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestNestedGateRefusesOnAMissingMount pins the first half of the isolated |
| +// wake gate (design §8.5): the three nosymfollow mounts are checked in |
| +// order, and the first one that is missing (or errors) names itself in the |
| +// refusal rather than the gate silently moving on to /ready. |
| +func TestNestedGateRefusesOnAMissingMount(t *testing.T) { |
| + mounts := func(mountpoint string) (bool, error) { |
| + if mountpoint == "/run/carlos/instances" { |
| + return false, nil |
| + } |
| + return true, nil |
| + } |
| + readyCalled := false |
| + ready := func(context.Context) error { readyCalled = true; return nil } |
| + err := nestedGate(mounts, ready)() |
| + if err == nil || !strings.Contains(err.Error(), "/run/carlos/instances") { |
| + t.Fatalf("nestedGate() = %v, want an error naming /run/carlos/instances", err) |
| + } |
| + if readyCalled { |
| + t.Fatal("nestedGate called /ready after a mount already refused the wake") |
| + } |
| +} |
| + |
| +// TestNestedGateRefusesOnAMountCheckError pins the same refusal for a |
| +// mounts func that itself errors (an unreadable /proc/self/mountinfo, in |
| +// production), rather than nestedGate treating an error as "not mounted" |
| +// silently or panicking on it. |
| +func TestNestedGateRefusesOnAMountCheckError(t *testing.T) { |
| + wantErr := errors.New("read mountinfo: permission denied") |
| + mounts := func(string) (bool, error) { return false, wantErr } |
| + err := nestedGate(mounts, func(context.Context) error { return nil })() |
| + if err == nil || !strings.Contains(err.Error(), wantErr.Error()) { |
| + t.Fatalf("nestedGate() = %v, want it to carry %v", err, wantErr) |
| + } |
| +} |
| + |
| +// TestNestedGatePassesMountsThenChecksReady pins the second half: once |
| +// every mount checks out, the gate's answer is exactly whatever /ready |
| +// says — refused when the control service is not ready, allowed when it is. |
| +func TestNestedGatePassesMountsThenChecksReady(t *testing.T) { |
| + mounts := func(string) (bool, error) { return true, nil } |
| + |
| + wantErr := errors.New("instance control: box not ready") |
| + gate := nestedGate(mounts, func(context.Context) error { return wantErr }) |
| + if err := gate(); err != wantErr { |
| + t.Fatalf("nestedGate() = %v, want the /ready error %v", err, wantErr) |
| + } |
| + |
| + gate = nestedGate(mounts, func(context.Context) error { return nil }) |
| + if err := gate(); err != nil { |
| + t.Fatalf("nestedGate() = %v, want nil once every mount and /ready both pass", err) |
| + } |
| +} |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index c3d195093..b4fda635f 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -97,6 +97,7 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, |
| rt, err := reg.Get(host) |
| return err == nil && rt != nil && rt.Provisioned |
| }, |
| + Route: reg.Get, |
| Checkpoint: func(ctx context.Context, dbPath string) error { |
| host := strings.TrimSuffix(filepath.Base(dbPath), ".db") |
| rep, err := lease.ReplicateWithLease(ctx, dbPath, hib.Bucket, activator.HibernatePrefix(hib.Prefix, host), hib.Region) |
| @@ -139,6 +140,12 @@ type moveCfg struct { |
| // each box has its OWN registry, so the sibling's Quiesced is |
| // trivially, wrongly true. The box that owns the route owns the move. |
| OwnsRoute func(host string) bool |
| + // Route reads the row itself, so a cutting move can checkpoint the |
| + // row's OWN DBPath (isolated-exec design §9) instead of guessing the |
| + // legacy <DataDir>/<host>.db — the guess is wrong for a nested row, |
| + // whose DB lives under provision.InstanceDir. Nil (a test with no |
| + // registry in hand) keeps the guess, unchanged. |
| + Route func(host string) (*registry.Route, error) |
| // Checkpoint re-replicates the LOCAL DB to the own bucket and waits |
| // (review round 1): the activator's stop logs — and swallows — a failed |
| // final checkpoint, because pre-move the local warm cache made that |
| @@ -276,13 +283,32 @@ func runMoveRound(ctx context.Context, s release.Store, w *movesWorker, rec prov |
| } |
| return nil // the retire is in flight; the nudged pass re-enters here |
| } |
| + // A host whose layout migration is in flight (isolated-exec design |
| + // §8.2) is fenced here exactly as Classify fences it for a wake: its |
| + // DB may be mid-copy between the flat and nested paths, and neither |
| + // location is safe to checkpoint or purge until the journal clears. |
| + // Retried next pass, like every other not-yet-ready state above. |
| + if _, err := os.Stat(provision.LayoutJournalPath(cfg.DataDir, rec.Host)); err == nil { |
| + log.Printf("moves: %s: layout migration in flight; leaving the move for the next pass", rec.Host) |
| + return nil |
| + } |
| // Step 3: prove the own replica carries the tail. The activator's |
| // stop SWALLOWS a failed final checkpoint (safe pre-move: the warm |
| // cache stood behind it; fatal here: step 5 deletes that cache). |
| // Re-checkpointing an already-synced DB is cheap and idempotent; a |
| // held lease (the child not fully released) or an S3 blip returns |
| // an error and the pass retries. |
| + // |
| + // dbPath defaults to the legacy flat guess and is overridden by the |
| + // row's own DBPath when Route is wired (isolated-exec design §9): a |
| + // nested row's DB never lived at the flat guess at all, so |
| + // checkpointing and purging (step 5, below) must follow the row. |
| dbPath := filepath.Join(cfg.DataDir, rec.Host+".db") |
| + if cfg.Route != nil { |
| + if rt, err := cfg.Route(rec.Host); err == nil && rt != nil && rt.DBPath != "" { |
| + dbPath = rt.DBPath |
| + } |
| + } |
| if cfg.Checkpoint != nil { |
| if _, statErr := os.Stat(dbPath); statErr == nil { |
| if err := cfg.Checkpoint(ctx, dbPath); err != nil { |
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go| index 4cd60ecd7..2426c692e 100644 |
| --- a/internal/host/movespass_test.go |
| +++ b/internal/host/movespass_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -242,6 +243,75 @@ func TestMovesPassSkipsWithoutTargetCoords(t *testing.T) { |
| } |
| } |
| +// A cutting move for a host whose layout migration is in flight (the |
| +// journal at provision.LayoutJournalPath) does nothing this round: its DB |
| +// may be mid-copy between the flat and nested paths, so neither checkpoint |
| +// nor the warm-cache purge is safe until the journal clears — the same fence |
| +// Classify applies to a wake, applied here to a cut. |
| +func TestMovesPassCutFencedByLayoutJournal(t *testing.T) { |
| + h := newMoveHarness(t) |
| + host := "a.go.example.com" |
| + h.record(t, host, provision.MoveRequest("uk", time.Now()), provision.MovePromoteReady(time.Now()), provision.MoveMarkCutting()) |
| + h.f.put("data-ie", "instances/"+host+"/generations/g/snapshot", "e1") |
| + journal := provision.LayoutJournalPath(h.cfg.DataDir, host) |
| + if err := os.MkdirAll(filepath.Dir(journal), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeFileForTest(journal, "{}"); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.quiet = true |
| + if err := movesPass(context.Background(), h.s, h.worker()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(h.f.ops) != 0 { |
| + t.Fatalf("cut proceeded past the layout fence: %v", h.f.ops) |
| + } |
| + if got := h.read(t, host); got.Region != "ie" || got.Move.State != provision.MoveCutting { |
| + t.Fatalf("cut state advanced past the layout fence: %+v", got) |
| + } |
| +} |
| + |
| +// A cutting move for a nested row checkpoints the row's OWN DBPath, not the |
| +// legacy <DataDir>/<host>.db guess: a nested row's DB lives under |
| +// provision.InstanceDir and the flat guess names a file that was never |
| +// written for it. |
| +func TestMovesPassCutUsesRouteDBPath(t *testing.T) { |
| + h := newMoveHarness(t) |
| + host := "a.go.example.com" |
| + h.record(t, host, provision.MoveRequest("uk", time.Now()), provision.MovePromoteReady(time.Now()), provision.MoveMarkCutting()) |
| + h.f.put("data-ie", "instances/"+host+"/generations/g/snapshot", "e1") |
| + nested := provision.NestedDBPath(h.cfg.DataDir, host) |
| + if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeFileForTest(nested, "stale"); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.cfg.Route = func(string) (*registry.Route, error) { |
| + return ®istry.Route{Host: host, DBPath: nested}, nil |
| + } |
| + h.quiet = true |
| + if err := movesPass(context.Background(), h.s, h.worker()); err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, op := range h.f.ops { |
| + if op == "checkpoint "+filepath.Base(nested) { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("checkpoint did not use the route's own DBPath: %v", h.f.ops) |
| + } |
| + if fileExistsForTest(nested) { |
| + t.Fatal("the route's own DB survived the cut — the warm-cache trap did not follow DBPath") |
| + } |
| + if got := h.read(t, host); got.Region != "uk" || got.Move != nil { |
| + t.Fatalf("after cut: region=%q move=%+v", got.Region, got.Move) |
| + } |
| +} |
| + |
| func writeFileForTest(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) } |
| func fileExistsForTest(path string) bool { _, err := os.Stat(path); return err == nil } |
diff --git a/internal/host/schedule_wake_test.go b/internal/host/schedule_wake_test.go| index 691c80e62..fb6e48293 100644 |
| --- a/internal/host/schedule_wake_test.go |
| +++ b/internal/host/schedule_wake_test.go |
| @@ -238,7 +238,7 @@ func TestScheduledTickWakesASleepingInstanceAndItHibernatesAgain(t *testing.T) { |
| now := time.Now().UTC() |
| occurrence := now.Unix() - 5 |
| r := &schedule.Runner{ |
| - Reg: reg, Deliver: &activatorDeliverer{svc: svc}, Token: scheduleToken(dd, svc), |
| + Reg: reg, Deliver: &activatorDeliverer{svc: svc}, Token: scheduleToken(dd, svc, reg), |
| Now: func() time.Time { return now }, Timeout: 30 * time.Second, |
| Box: "box1", Build: "test", |
| } |
| @@ -257,7 +257,7 @@ func TestScheduledTickWakesASleepingInstanceAndItHibernatesAgain(t *testing.T) { |
| case <-time.After(30 * time.Second): |
| t.Fatal("the handler never ran — the tick did not wake the sleeping instance") |
| } |
| - tok, err := scheduleToken(dd, svc)(host) |
| + tok, err := scheduleToken(dd, svc, reg)(host) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/host/schedule_wiring.go b/internal/host/schedule_wiring.go| index 6774ca4c1..da721303e 100644 |
| --- a/internal/host/schedule_wiring.go |
| +++ b/internal/host/schedule_wiring.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/schedule" |
| @@ -84,7 +85,7 @@ func newScheduleDeps(reg *registry.Registry, store release.Store, svc *activator |
| runner: &schedule.Runner{ |
| Reg: reg, |
| Deliver: &activatorDeliverer{svc: svc}, |
| - Token: scheduleToken(dd, svc), |
| + Token: scheduleToken(dd, svc, reg), |
| Event: event, |
| Box: box, |
| Build: version.Build, |
| @@ -157,10 +158,11 @@ func armSchedule(reg *registry.Registry, store release.Store, storeErr error, sv |
| // The error is redacted: it reaches the row's last_status and from there the |
| // bucket status object and `carlos schedule ls`, and a member must not learn |
| // the box's filesystem layout from it. |
| -func scheduleToken(dd string, svc *activator.Service) func(host string) (string, error) { |
| - read := schedule.TokenFromEnvFile(dd) |
| +func scheduleToken(dd string, svc *activator.Service, reg *registry.Registry) func(host string) (string, error) { |
| + paths := envPathFor(dd, reg) |
| + read := schedule.TokenFromEnvPaths(paths) |
| prepare := func(host string) (string, error) { |
| - if err := activator.EnsureAdminToken(filepath.Join(dd, host+".env")); err != nil { |
| + if err := activator.EnsureAdminToken(paths(host)); err != nil { |
| return "", errors.New(activator.RedactPaths(err.Error())) |
| } |
| tok, err := read(host) |
| @@ -179,6 +181,31 @@ func scheduleToken(dd string, svc *activator.Service) func(host string) (string, |
| } |
| } |
| +// envPathFor is scheduleToken's route to an instance's own env file: flat by |
| +// default, and the nested layout's own <host>.env (design §7) when this |
| +// box's registry classifies the host as a nested row — the SAME |
| +// classification the exec backend used to decide where it spawned the |
| +// instance in the first place (activator.Classify), so the runner and the |
| +// token-ensure step ask the file the backend actually wrote rather than |
| +// guessing the flat path for every host. reg nil (a test with no registry |
| +// in hand) is every host flat, byte-identical to before this existed. |
| +func envPathFor(dd string, reg *registry.Registry) func(host string) string { |
| + flat := func(host string) string { return filepath.Join(dd, host+".env") } |
| + if reg == nil { |
| + return flat |
| + } |
| + return func(host string) string { |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil { |
| + return flat(host) |
| + } |
| + if layout, _ := activator.Classify(*rt, activator.LayoutInputs{DataDir: dd}); layout == activator.LayoutNested { |
| + return filepath.Join(provision.InstanceDir(dd, host), host+".env") |
| + } |
| + return flat(host) |
| + } |
| +} |
| + |
| // convergePassTimeout bounds ONE converge pass. The agent's three call sites |
| // (the seed and the serial leg in fastConvergeLoop, the hourly floor in |
| // adoptLoop) all hand this context.Background() — an unbounded context — and |
diff --git a/internal/host/schedule_wiring_test.go b/internal/host/schedule_wiring_test.go| index ea89a293e..0b2c134ef 100644 |
| --- a/internal/host/schedule_wiring_test.go |
| +++ b/internal/host/schedule_wiring_test.go |
| @@ -121,7 +121,7 @@ func TestScheduleTokenMintsForASleepingInstance(t *testing.T) { |
| if _, err := os.Stat(envPath); !os.IsNotExist(err) { |
| t.Fatalf("fixture: env file already exists (%v)", err) |
| } |
| - tok, err := scheduleToken(dd, nil)(host) |
| + tok, err := scheduleToken(dd, nil, nil)(host) |
| if err != nil { |
| t.Fatalf("Token on a host with no env file: %v", err) |
| } |
| @@ -135,7 +135,7 @@ func TestScheduleTokenMintsForASleepingInstance(t *testing.T) { |
| if err := activator.EnsureAdminToken(envPath); err != nil { |
| t.Fatal(err) |
| } |
| - again, err := scheduleToken(dd, nil)(host) |
| + again, err := scheduleToken(dd, nil, nil)(host) |
| if err != nil || again != tok { |
| t.Fatalf("second read = %q (%v), want the first token %q", again, err, tok) |
| } |
| @@ -150,7 +150,7 @@ func TestScheduleTokenMintsForASleepingInstance(t *testing.T) { |
| // filesystem layout. |
| func TestScheduleTokenErrorCarriesNoAbsolutePath(t *testing.T) { |
| dd := filepath.Join(t.TempDir(), "missing-data-dir") |
| - _, err := scheduleToken(dd, nil)("x.bab.oncarlos.com") |
| + _, err := scheduleToken(dd, nil, nil)("x.bab.oncarlos.com") |
| if err == nil { |
| t.Fatal("Token succeeded with no data dir") |
| } |
diff --git a/internal/ops/litestream.go b/internal/ops/litestream.go| index 6319d3a53..6b14b482f 100644 |
| --- a/internal/ops/litestream.go |
| +++ b/internal/ops/litestream.go |
| @@ -355,6 +355,17 @@ func FleetDBsWithSkipped(reg *registry.Registry, dataDir string) (dbs []string, |
| if err != nil { |
| return nil, nil, err |
| } |
| + // A nested row's DB lives one level down, under |
| + // <dataDir>/instances/<id>/<host>.db (isolated-exec design §7): the flat |
| + // glob above never walks into that directory, so its matches are folded |
| + // in here before the claimed-files gate below, which already knows how |
| + // to accept or skip each one — a nested row's DBPath was added to |
| + // claimed by the very same loop that adds a flat row's. |
| + nested, err := filepath.Glob(filepath.Join(dataDir, "instances", "*", "*.db")) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + matches = append(matches, nested...) |
| // THE GLOB PROPOSES, THE REGISTRY DISPOSES. A *.db under dataDir is |
| // replicated when a route claims it, or when it is the box's own registry |
| // or console. Anything else is somebody's leftover, and streaming leftovers |
diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go| index 1cb67afc4..9bd1f4780 100644 |
| --- a/internal/ops/ops_test.go |
| +++ b/internal/ops/ops_test.go |
| @@ -9,6 +9,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| _ "modernc.org/sqlite" |
| @@ -84,6 +85,41 @@ func TestConfigGenSkipsHibernatingRoutes(t *testing.T) { |
| } |
| } |
| +// TestFleetDBsDiscoversNestedDatabases pins the isolated-exec companion to |
| +// the flat glob above (design §7): a route's DBPath being "claimed" only |
| +// FILTERS what the glob already found — for a non-unit route it never adds |
| +// a path by itself — so a nested row's database, under |
| +// <dataDir>/instances/<id>/<host>.db, would stay invisible to replication |
| +// forever without the nested glob to find it in the first place. |
| +func TestFleetDBsDiscoversNestedDatabases(t *testing.T) { |
| + dir := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + host := "nested.example.com" |
| + nested := provision.NestedDBPath(dir, host) |
| + if err := os.MkdirAll(provision.InstanceDir(dir, host), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(nested, []byte("x"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Not hibernating: a resident, activator-managed nested row is exactly |
| + // as replicable by this box-wide pass as a flat one. |
| + if err := reg.Upsert(registry.Route{Host: host, DBPath: nested, Socket: provision.NestedSocket(host)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + dbs, err := FleetDBs(reg, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !slices.Contains(dbs, nested) { |
| + t.Fatalf("nested database missing from FleetDBs: %v, want %s", dbs, nested) |
| + } |
| +} |
| + |
| // TestFleetDBsUnitTenantWithoutDBPathIsInvisible pins the honest half of the |
| // I1 fix: a BackingUnit route added without -db has no discoverable database |
| // path (the platform enforces no filename convention inside a unit tenant's |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 525201223..9f5117c8f 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -25,7 +25,6 @@ import ( |
| "golang.org/x/crypto/bcrypt" |
| - "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/controlsock" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -420,7 +419,7 @@ func ValidateShape(rec Record) error { |
| if rec.Unit != "" && !registry.ValidUnitName(rec.Unit) { |
| return fmt.Errorf("unit %q: want a systemd service unit name (e.g. console.service)", rec.Unit) |
| } |
| - if rec.Environment != "" && !appconfig.ValidEnvironmentName(rec.Environment) { |
| + if rec.Environment != "" && !validEnvironmentName(rec.Environment) { |
| return fmt.Errorf("environment %q: want [a-z0-9-]{1,32}", rec.Environment) |
| } |
| if rec.KindOrDefault() == "static" { |
| @@ -552,6 +551,25 @@ var regionPoolRe = regexp.MustCompile(`^[a-z0-9-]{0,16}$`) |
| func ValidRegion(region string) bool { return regionPoolRe.MatchString(region) } |
| func ValidPool(pool string) bool { return regionPoolRe.MatchString(pool) } |
| +// envNameRe is appconfig.ValidEnvironmentName's own shape (pipelines design |
| +// §4a), spelled out again here rather than imported: appconfig already |
| +// imports this package (LoadMerged reads a route's environment column), |
| +// so the reverse import would be a cycle. A record's Environment column |
| +// and a bundle's environment name are the SAME vocabulary; if the shape |
| +// in appconfig/environments.go ever changes, this copy must change with |
| +// it — it exists only because of the import direction, not because the |
| +// rule is provision's to define. |
| +var envNameRe = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) |
| + |
| +// validEnvironmentName is appconfig.ValidEnvironmentName's check, duplicated |
| +// for the reason envNameRe's comment gives: "default" names the implicit |
| +// base bundle, never a named overlay, and "" is not a record's problem to |
| +// validate here (the field is optional; the caller above already gates on |
| +// rec.Environment != ""). |
| +func validEnvironmentName(name string) bool { |
| + return name != "default" && envNameRe.MatchString(name) |
| +} |
| + |
| // GetManifest reads app's instances manifest. Not-found stays |
| // os.ErrNotExist-wrapped (absence IS the "instances disabled" answer, not |
| // an error condition the caller must special-case beyond errors.Is). A |
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go| index e4b5c1e7b..b13b08001 100644 |
| --- a/internal/schedule/runner.go |
| +++ b/internal/schedule/runner.go |
| @@ -454,11 +454,16 @@ func BuildRequest(rt registry.Route, t registry.Timer, token, build string) (*ht |
| return req, nil |
| } |
| -// TokenFromEnvFile is the production Token: CARLOS_ADMIN_TOKEN from |
| -// <dataDir>/<host>.env, the file the activator owns (spec §6.4). |
| -func TokenFromEnvFile(dataDir string) func(host string) (string, error) { |
| +// TokenFromEnvPaths is the production Token with the env file's location |
| +// taken from envPath rather than assumed flat: isolated-exec's nested rows |
| +// keep <host>.env inside their own instance directory (design §7), and the |
| +// host wiring (schedule_wiring.go) resolves that through the same |
| +// classification the exec backend used to decide where it spawned the |
| +// instance. TokenFromEnvFile below is this with the flat resolver baked in, |
| +// which is every caller that predates the nested layout. |
| +func TokenFromEnvPaths(envPath func(host string) string) func(host string) (string, error) { |
| return func(host string) (string, error) { |
| - env, err := readEnvFile(filepath.Join(dataDir, host+".env")) |
| + env, err := readEnvFile(envPath(host)) |
| if err != nil { |
| return "", err |
| } |
| @@ -468,3 +473,9 @@ func TokenFromEnvFile(dataDir string) func(host string) (string, error) { |
| return env["CARLOS_ADMIN_TOKEN"], nil |
| } |
| } |
| + |
| +// TokenFromEnvFile is the production Token: CARLOS_ADMIN_TOKEN from |
| +// <dataDir>/<host>.env, the file the activator owns (spec §6.4). |
| +func TokenFromEnvFile(dataDir string) func(host string) (string, error) { |
| + return TokenFromEnvPaths(func(host string) string { return filepath.Join(dataDir, host+".env") }) |
| +} |