appconfig.RenderHeld, release.MaterializeHeld, strict spawns under a move (§9.1, §9.2, §5.3)
- appconfig: LoadMergedHostVersions returns the etag of each merged object from the read that returned its bytes; LoadMergedHost and LoadMerged are wrappers with unchanged behaviour. renderExecConfig is SyncExec's file writer, extracted with ctx and work; RenderHeld renders a held row through it under legacyHostWork and always writes a move-bound ack (move_op, file_sha256, bundle_etags). It refuses a row SyncExec owns, and refuses outright while CARLOS_HOST_CLAIM is on (ruling F10). - release: MaterializeHeld stages the pinned version and points live/<host> at it, no channel, no restart; a write refusal is ErrStageBlocked (StageBlocked, now exported). ServeHeldStageRequests is the root side of the relay: every field validated before any path, authorize, materialize, ack, remove, all through instancefs. - host: heldStageAuthority checks op shape, this box, the registry hold, the claim (op, kind, target, holder) and the source report's Release; carlos adopt serves held-stage requests after release adoption, a no-op with no request on disk. - activator: ExecBackend.Strict and the prepareStrict seam make every spawn of a host in a move strict at spawnInCgroup, drained then closed on exit, or refused with ErrStrictRequired; NextSpawnStrict is S2's input. Nothing sets Strict yet (Task 14). - copy rows MV-N96 to MV-N104, pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+1763
−72
docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+9 −0internal/activator/backend_exec.go+117 −6internal/activator/backend_exec_test.go+210 −1internal/appconfig/configack.go+9 −0internal/appconfig/environments.go+20 −11internal/appconfig/instances.go+28 −6internal/appconfig/renderheld.go+106 −0internal/appconfig/renderheld_test.go+242 −0internal/appconfig/syncexec.go+81 −48internal/host/agent.go+8 −0internal/host/heldstage.go+113 −0internal/host/heldstage_test.go+294 −0internal/release/adopt.go+6 −0internal/release/materializeheld.go+211 −0internal/release/materializeheld_test.go+309 −0
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 61104121e..d1d359459 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -135,6 +135,15 @@ Columns: id, file, text, status. |
| | MV-N93 | internal/host/systemapply.go | <refusal>; the root passes paused for the install could not be started again: <error>. the next apply or recovery starts them | pending | |
| | MV-N94 | internal/host/systemapply.go | <refusal>; the root passes paused for the install could not be started again: <error>, and the retry could not be recorded: <error>. the journal stays for the next recovery | pending | |
| | MV-N95 | internal/host/systemapply.go | <refusal>; the root passes paused for the restore could not be started again: <error> | pending | |
| +| MV-N96 | internal/activator/backend_exec.go | activator: a host in a move spawns only under strict containment | pending | |
| +| MV-N97 | internal/activator/backend_exec.go | S2: the next spawn of this host is not strict | pending | |
| +| MV-N98 | internal/activator/backend_exec.go | S2: this box has no delegated cgroup subtree, so it cannot spawn strictly | pending | |
| +| MV-N99 | internal/activator/backend_exec.go | S2: this build cannot spawn strictly | pending | |
| +| MV-N100 | internal/release/materializeheld.go | release: this process cannot write the version store, so the root stage pass stages this release | pending | |
| +| MV-N101 | internal/release/materializeheld.go | release: held stage request no longer matches its move | pending | |
| +| MV-N102 | internal/release/materializeheld.go | release: held rows are not staged while CARLOS_HOST_CLAIM is on | pending | |
| +| MV-N103 | internal/appconfig/renderheld.go | config: not a held row of this move | pending | |
| +| MV-N104 | internal/appconfig/renderheld.go | config: held rows are not rendered while CARLOS_HOST_CLAIM is on | pending | |
| ## Plan 2 |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index d1f8008b7..972e63751 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -14,6 +14,7 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "runtime" |
| "sort" |
| "strings" |
| "sync" |
| @@ -25,6 +26,7 @@ import ( |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/schedule" |
| @@ -160,8 +162,48 @@ type ExecBackend struct { |
| // nested run dir and log socket beneath it, so a test can exercise the |
| // nested layout without writing to /run/carlos. |
| RunDirOverride string |
| + // Strict, when non-nil and true for a host, makes every spawn of that |
| + // host strict (moves design §5.3): the child starts only inside a group |
| + // prepareStrict prepared, which is drained (KillAndEmpty) and closed |
| + // when the child exits, and a spawn that cannot be made strict is |
| + // refused with ErrStrictRequired. The host package sets it from the hold |
| + // view: a host whose record has a move in flight, with the switch on |
| + // (Task 14). Nil, or false for a host, is today's spawn exactly. |
| + Strict func(host string) bool |
| + // prepareStrict is the strict path's seam. Nil: Cgroups.PrepareStrict, |
| + // and a nil Cgroups under Strict is ErrStrictRequired. Tests replace |
| + // this seam, never the Cgroups field. |
| + prepareStrict func(host string, limit cgroup.Limit) (strictProc, error) |
| } |
| +// ErrStrictRequired is a spawn refused because its host is in a move and |
| +// the child could not be started under strict containment (moves design |
| +// §5.3): no delegated cgroup subtree, a populated or unverifiable group, or |
| +// a strict start that failed. Nothing was spawned. |
| +var ErrStrictRequired = errors.New("activator: a host in a move spawns only under strict containment") |
| + |
| +// strictProc is what a strict spawn holds for the life of its child. |
| +// *cgroup.Prepared satisfies it on every build (strict_linux.go, |
| +// strict_other.go). |
| +type strictProc interface { |
| + Start(*exec.Cmd) error |
| + KillAndEmpty(context.Context) error |
| + Close() error |
| +} |
| + |
| +var _ strictProc = (*cgroup.Prepared)(nil) |
| + |
| +// strictBuild reports whether this build can prepare a strict group at all: |
| +// cgroup.PrepareStrict is Linux-only and answers ErrStrictUnsupported |
| +// elsewhere. A variable so a test can take the other branch. |
| +var strictBuild = runtime.GOOS == "linux" |
| + |
| +// strictDrainTimeout bounds the KillAndEmpty a strict child's exit runs. |
| +// A group still populated after it keeps its reservation (Close refuses), |
| +// so the host's next strict spawn is refused rather than sharing a group |
| +// with an unaccounted process. |
| +var strictDrainTimeout = 30 * time.Second |
| + |
| // execLogLineMax caps one teed log line (spec §5): longer is truncated with |
| // a marker, never split into phantom lines. |
| const execLogLineMax = 8 << 10 |
| @@ -206,8 +248,71 @@ func (b *ExecBackend) tier(rt registry.Route) cgroup.Limit { |
| // site that bypassed it would compile and silently ship uncapped children; |
| // keeping the tier plumbing here makes that a diff a reviewer can see and |
| // TestSpawnInCgroupAppliesTheTier can pin. |
| -func (b *ExecBackend) spawnInCgroup(rt registry.Route, build func() *exec.Cmd) (*exec.Cmd, error) { |
| - return b.Cgroups.Start(rt.Host, b.tier(rt), build) |
| +// |
| +// Under a move (Strict) it is also the one place a spawn is made strict, so |
| +// no child shape can start a host's process outside the strict group. The |
| +// returned settle runs after the child has been reaped: for a strict spawn |
| +// it drains and closes the group, for a plain one it does nothing. |
| +func (b *ExecBackend) spawnInCgroup(rt registry.Route, build func() *exec.Cmd) (*exec.Cmd, func(), error) { |
| + if b.Strict == nil || !b.Strict(rt.Host) { |
| + cmd, err := b.Cgroups.Start(rt.Host, b.tier(rt), build) |
| + return cmd, func() {}, err |
| + } |
| + sp, err := b.prepareStrictProc(rt.Host, b.tier(rt)) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("%s: %w: %v", rt.Host, ErrStrictRequired, err) |
| + } |
| + cmd := build() |
| + if err := sp.Start(cmd); err != nil { |
| + settleStrict(rt.Host, sp) |
| + return nil, nil, fmt.Errorf("%s: %w: %v", rt.Host, ErrStrictRequired, err) |
| + } |
| + return cmd, func() { settleStrict(rt.Host, sp) }, nil |
| +} |
| + |
| +// prepareStrictProc is prepareStrict with its default applied. |
| +func (b *ExecBackend) prepareStrictProc(host string, limit cgroup.Limit) (strictProc, error) { |
| + if b.prepareStrict != nil { |
| + return b.prepareStrict(host, limit) |
| + } |
| + if b.Cgroups == nil { |
| + return nil, errors.New("no delegated cgroup subtree") |
| + } |
| + p, err := b.Cgroups.PrepareStrict(host, limit) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return p, nil |
| +} |
| + |
| +// settleStrict drains a strict child's group and closes the handle. A |
| +// failure is logged and leaves the handle's reservation in place, which |
| +// refuses the host's next strict spawn on this agent: the group may still |
| +// hold a process nothing accounts for. |
| +func settleStrict(host string, sp strictProc) { |
| + ctx, cancel := context.WithTimeout(context.Background(), strictDrainTimeout) |
| + defer cancel() |
| + if err := sp.KillAndEmpty(ctx); err != nil { |
| + log.Printf("activator: %s: strict group did not drain: %v", host, err) |
| + } |
| + if err := sp.Close(); err != nil { |
| + log.Printf("activator: %s: strict group not closed: %v", host, err) |
| + } |
| +} |
| + |
| +// NextSpawnStrict is the source's S2 input (moves design §7.3, §5.3): will |
| +// host's next spawn be strict? OK only when Strict names the host, the box |
| +// has a delegated cgroup subtree, and this build can prepare a strict group. |
| +func (b *ExecBackend) NextSpawnStrict(host string) moves.Check { |
| + switch { |
| + case b.Strict == nil || !b.Strict(host): |
| + return moves.Check{Reason: "S2: the next spawn of this host is not strict"} |
| + case b.Cgroups.Root() == "": |
| + return moves.Check{Reason: "S2: this box has no delegated cgroup subtree, so it cannot spawn strictly"} |
| + case !strictBuild: |
| + return moves.Check{Reason: "S2: this build cannot spawn strictly"} |
| + } |
| + return moves.Check{OK: true} |
| } |
| func (b *ExecBackend) liveDir() string { return filepath.Join(b.OptDir, "live") } |
| @@ -481,7 +586,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error { |
| // cost is that instance's work. No CleanupHost here — the instance spawn |
| // reuses the dir moments later, and its exit path (or the next agent |
| // start's sweep) removes it. |
| - cmd, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| + cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| cmd := exec.CommandContext(ctx, b.liveLink(host), argv...) |
| // The control-channel vars are agent-owned in BOTH directions here too |
| // (Start's discipline, see withoutVars), and here they are always |
| @@ -517,6 +622,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error { |
| }) |
| if err == nil { |
| err = cmd.Wait() |
| + settle() |
| } |
| if err != nil { |
| // WaitDelay reports on the SUCCESS path too: a seed that exits 0 after |
| @@ -938,7 +1044,7 @@ func (b *ExecBackend) startChild(ctx context.Context, rt registry.Route) (Proc, |
| // carlos-exec@<host> and the sampler can account it. build may run |
| // twice — the manager's clone3-refusal fallback — so everything about |
| // the Cmd is configured inside it. |
| - cmd, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| + cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| cmd.Env = childEnv(childVars) |
| // The child logs through the agent's own stderr/stdout (journald |
| @@ -980,6 +1086,9 @@ func (b *ExecBackend) startChild(ctx context.Context, rt registry.Route) (Proc, |
| } else { |
| emitEvent(b.Logs, rt, "info", "instance exited (exit 0)") |
| } |
| + // A strict child's group is drained and closed before anything |
| + // reads this exit as a stop (p.done closes below). |
| + settle() |
| // Last exiter wins: rmdir refuses while the sidecar (same host |
| // cgroup) is still alive, and that failure is tolerated. |
| b.Cgroups.CleanupHost(host) |
| @@ -1375,7 +1484,7 @@ func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (P |
| // Same host cgroup as the instance: a sidecar's load is that instance's |
| // work, and attributing it elsewhere would be a lie in the other |
| // direction. |
| - cmd, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| + cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| cmd.Env = childEnv(fileEnv) |
| // Like the instance child: logs through the agent's own streams |
| @@ -1390,6 +1499,7 @@ func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (P |
| p := &childProc{cmd: cmd, done: make(chan struct{})} |
| go func() { |
| p.err = cmd.Wait() |
| + settle() |
| b.Cgroups.CleanupHost(rt.Host) |
| close(p.done) |
| }() |
| @@ -1430,7 +1540,7 @@ func (b *ExecBackend) StartWorker(ctx context.Context, rt registry.Route, extra |
| // of a tenant's app like any other. Bypassing it compiles and silently |
| // ships an uncapped process — the exact failure spawnInCgroup's own |
| // comment names. |
| - cmd, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| + cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| cmd := exec.Command(b.liveLink(rt.Host), argv...) |
| cmd.Env = childEnv(fileEnv) |
| b.teeStd(cmd, rt) |
| @@ -1455,6 +1565,7 @@ func (b *ExecBackend) StartWorker(ctx context.Context, rt registry.Route, extra |
| } else { |
| emitEvent(b.Logs, rt, "info", "worker exited (exit 0)") |
| } |
| + settle() |
| b.Cgroups.CleanupHost(rt.Host) |
| close(p.done) |
| }() |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 7bba0776c..5ffbacf2a 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -3,10 +3,12 @@ package activator |
| import ( |
| "context" |
| "database/sql" |
| + "errors" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -51,7 +53,7 @@ func TestSpawnInCgroupAppliesTheTier(t *testing.T) { |
| } |
| b := ExecBackend{Cgroups: m, Tier: func(registry.Route) cgroup.Limit { return cgroup.TierChildren }} |
| rt := registry.Route{Host: "h.example.com"} |
| - cmd, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| + cmd, _, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -372,3 +374,210 @@ func TestLaunchEnvironmentNoFiles(t *testing.T) { |
| t.Fatalf("CARLOS_REGION is the backend's: %v; shadowed %v; err %v", env["CARLOS_REGION"] == "ie", shadowed, err) |
| } |
| } |
| + |
| +// recordingStrict is a strictProc that starts the command for real and |
| +// records each call in order, so a test can see the spawn went through the |
| +// strict handle and what happened to it on exit. |
| +type recordingStrict struct { |
| + mu sync.Mutex |
| + calls []string |
| + deadline bool |
| + started chan struct{} |
| + closed chan struct{} |
| +} |
| + |
| +func newRecordingStrict() *recordingStrict { |
| + return &recordingStrict{started: make(chan struct{}), closed: make(chan struct{})} |
| +} |
| + |
| +func (r *recordingStrict) note(s string) { r.mu.Lock(); r.calls = append(r.calls, s); r.mu.Unlock() } |
| +func (r *recordingStrict) Start(cmd *exec.Cmd) error { |
| + r.note("start") |
| + defer close(r.started) |
| + return cmd.Start() |
| +} |
| +func (r *recordingStrict) KillAndEmpty(ctx context.Context) error { |
| + _, ok := ctx.Deadline() |
| + r.mu.Lock() |
| + r.deadline = ok |
| + r.mu.Unlock() |
| + r.note("kill-and-empty") |
| + return nil |
| +} |
| +func (r *recordingStrict) Close() error { |
| + r.note("close") |
| + close(r.closed) |
| + return nil |
| +} |
| +func (r *recordingStrict) seen() []string { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return append([]string(nil), r.calls...) |
| +} |
| + |
| +// TestSpawnUnderAMoveIsStrictOrRefused (moves design §5.3): a host Strict |
| +// names spawns only through the strict handle. A handle that cannot be |
| +// prepared refuses the spawn with ErrStrictRequired and nothing runs; a |
| +// prepared one starts the child and, on exit, is drained (KillAndEmpty |
| +// under a deadline) and then closed. With Strict false or unset the plain |
| +// spawn runs exactly as before and the seam is never asked. |
| +func TestSpawnUnderAMoveIsStrictOrRefused(t *testing.T) { |
| + old := spawnHealthTimeout |
| + spawnHealthTimeout = 300 * time.Millisecond |
| + t.Cleanup(func() { spawnHealthTimeout = old }) |
| + host := "strict.example.com" |
| + start := func(t *testing.T, b *ExecBackend) error { |
| + t.Helper() |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, err := b.Start(ctx, rt) // the fake binary never binds: a health error is expected |
| + if proc != nil { |
| + _ = proc.Stop(context.Background()) |
| + } |
| + return err |
| + } |
| + noSpawn := func(t *testing.T, argvPath string) { |
| + t.Helper() |
| + time.Sleep(200 * time.Millisecond) |
| + if _, err := os.Stat(argvPath); !os.IsNotExist(err) { |
| + t.Fatalf("the child ran: %v", err) |
| + } |
| + } |
| + |
| + t.Run("a handle that cannot be prepared refuses the spawn", func(t *testing.T) { |
| + b := testExecBackend(t) |
| + argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| + b.Strict = func(h string) bool { return h == host } |
| + b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return nil, cgroup.ErrStrictRefused } |
| + if err := start(t, b); !errors.Is(err, ErrStrictRequired) { |
| + t.Fatalf("Start: %v, want ErrStrictRequired", err) |
| + } |
| + noSpawn(t, argvPath) |
| + }) |
| + |
| + t.Run("no delegation and no seam refuses the spawn", func(t *testing.T) { |
| + b := testExecBackend(t) // Cgroups nil |
| + argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| + b.Strict = func(string) bool { return true } |
| + if err := start(t, b); !errors.Is(err, ErrStrictRequired) { |
| + t.Fatalf("Start: %v, want ErrStrictRequired", err) |
| + } |
| + noSpawn(t, argvPath) |
| + }) |
| + |
| + t.Run("a handle whose Start fails refuses the spawn and is closed", func(t *testing.T) { |
| + b := testExecBackend(t) |
| + argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| + b.Strict = func(string) bool { return true } |
| + rec := newRecordingStrict() |
| + failing := &failingStrict{recordingStrict: rec} |
| + b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return failing, nil } |
| + if err := start(t, b); !errors.Is(err, ErrStrictRequired) { |
| + t.Fatalf("Start: %v, want ErrStrictRequired", err) |
| + } |
| + noSpawn(t, argvPath) |
| + if got := strings.Join(rec.seen(), ","); got != "start,kill-and-empty,close" { |
| + t.Fatalf("handle calls = %s", got) |
| + } |
| + }) |
| + |
| + t.Run("a prepared handle starts the child and is drained then closed on exit", func(t *testing.T) { |
| + b := testExecBackend(t) |
| + argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| + b.Strict = func(string) bool { return true } |
| + rec := newRecordingStrict() |
| + var gotHost string |
| + var gotLimit cgroup.Limit |
| + b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierChildren } |
| + b.prepareStrict = func(h string, l cgroup.Limit) (strictProc, error) { gotHost, gotLimit = h, l; return rec, nil } |
| + _ = start(t, b) |
| + waitForFile(t, argvPath, time.Second) |
| + select { |
| + case <-rec.closed: |
| + case <-time.After(5 * time.Second): |
| + t.Fatalf("the handle was never closed: %v", rec.seen()) |
| + } |
| + if got := strings.Join(rec.seen(), ","); got != "start,kill-and-empty,close" { |
| + t.Fatalf("handle calls = %s, want start, kill-and-empty, close", got) |
| + } |
| + if !rec.deadline { |
| + t.Fatal("KillAndEmpty was called without a deadline") |
| + } |
| + if gotHost != host || gotLimit != cgroup.TierChildren { |
| + t.Fatalf("prepared %q at %v, want %q at the route's tier", gotHost, gotLimit, host) |
| + } |
| + }) |
| + |
| + for name, strict := range map[string]func(string) bool{ |
| + "Strict false": func(string) bool { return false }, |
| + "Strict unset": nil, |
| + } { |
| + t.Run(name+" runs the plain spawn", func(t *testing.T) { |
| + b := testExecBackend(t) |
| + argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| + b.Strict = strict |
| + asked := false |
| + b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| + asked = true |
| + return nil, errors.New("must not be asked") |
| + } |
| + _ = start(t, b) |
| + waitForFile(t, argvPath, time.Second) |
| + if asked { |
| + t.Fatal("the strict seam was asked for a host not in a move") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// failingStrict is a recordingStrict whose Start fails without starting |
| +// anything, the way Prepared.Start refuses a populated group. |
| +type failingStrict struct{ *recordingStrict } |
| + |
| +func (f *failingStrict) Start(*exec.Cmd) error { |
| + f.note("start") |
| + return cgroup.ErrStrictRefused |
| +} |
| + |
| +// TestNextSpawnStrict is S2's input: OK only when the host is in a move |
| +// (Strict), the box has a delegated cgroup subtree, and this build can |
| +// prepare a strict group. |
| +func TestNextSpawnStrict(t *testing.T) { |
| + root := t.TempDir() |
| + for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| + if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + m, err := cgroup.SetupAt(root, t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + host := "s2.example.com" |
| + yes := func(string) bool { return true } |
| + for name, tc := range map[string]struct { |
| + strict func(string) bool |
| + cg *cgroup.Manager |
| + build bool |
| + ok bool |
| + inWords string |
| + }{ |
| + "in a move, delegated, supported": {yes, m, true, true, ""}, |
| + "not in a move": {func(string) bool { return false }, m, true, false, "not strict"}, |
| + "Strict unset": {nil, m, true, false, "not strict"}, |
| + "no delegation": {yes, nil, true, false, "delegated"}, |
| + "unsupported build": {yes, m, false, false, "build"}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + orig := strictBuild |
| + strictBuild = tc.build |
| + t.Cleanup(func() { strictBuild = orig }) |
| + b := ExecBackend{Strict: tc.strict, Cgroups: tc.cg} |
| + c := b.NextSpawnStrict(host) |
| + if c.OK != tc.ok || (!c.OK && !strings.Contains(c.Reason, tc.inWords)) || (c.OK && c.Reason != "") { |
| + t.Fatalf("NextSpawnStrict = %+v, want ok=%v naming %q", c, tc.ok, tc.inWords) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go| index 44c5d3ead..ff99dce75 100644 |
| --- a/internal/appconfig/configack.go |
| +++ b/internal/appconfig/configack.go |
| @@ -104,6 +104,15 @@ type ConfigAck struct { |
| // too old to read the layer and a new agent rendering an overlay whose |
| // vars the reader does not recognise produce the same map. |
| InstanceConfigETag string `json:"instance_config_etag,omitempty"` |
| + // MoveOp, FileSHA256 and BundleETags are written only by a move's held |
| + // render (RenderHeld, moves design §9.1), never by a sync pass. MoveOp is |
| + // the operation the render was made for, FileSHA256 the hex sha256 of the |
| + // file as it stands after the render, and BundleETags the etags of the |
| + // "default" and "environment" objects merged into it ("" for one that was |
| + // absent or not bound). T3 compares them with what it read itself. |
| + MoveOp string `json:"move_op,omitempty"` |
| + FileSHA256 string `json:"file_sha256,omitempty"` |
| + BundleETags map[string]string `json:"bundle_etags,omitempty"` |
| } |
| // ConfigAckPrefix is where every route's config-adoption ack lives, one |
diff --git a/internal/appconfig/environments.go b/internal/appconfig/environments.go| index b3b4a4719..9935683f3 100644 |
| --- a/internal/appconfig/environments.go |
| +++ b/internal/appconfig/environments.go |
| @@ -167,31 +167,40 @@ func ListEnvironments(ctx context.Context, s release.Store, acct, app string) ([ |
| // is rare in practice (it means the bundle was deleted after binding, not |
| // that the bind was unchecked). |
| func LoadMerged(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, error) { |
| + m, _, _, err := loadMergedETags(ctx, s, acct, app, envName) |
| + return m, err |
| +} |
| + |
| +// loadMergedETags is LoadMerged plus the etag of each object it actually |
| +// merged ("" for one it did not read or found absent), taken from the same |
| +// read that returned the bytes. LoadMergedHostVersions needs them: a later, |
| +// independent read could not say which versions were rendered. |
| +func loadMergedETags(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, string, string, error) { |
| if !release.ValidAccount(acct) { |
| - return nil, fmt.Errorf("config: invalid account %q", acct) |
| + return nil, "", "", fmt.Errorf("config: invalid account %q", acct) |
| } |
| if !release.ValidApp(app) { |
| - return nil, fmt.Errorf("config: invalid app %q", app) |
| + return nil, "", "", fmt.Errorf("config: invalid app %q", app) |
| } |
| - def, defFound, err := getIfExistsAt(ctx, s, Key(acct, app)) |
| + def, defETag, defFound, err := getIfExistsAtETag(ctx, s, Key(acct, app)) |
| if err != nil { |
| - return nil, err |
| + return nil, "", "", err |
| } |
| if envName == "" { |
| if !defFound { |
| - return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil |
| + return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", "", nil |
| } |
| - return def, nil |
| + return def, defETag, "", nil |
| } |
| if !ValidEnvironmentName(envName) { |
| - return nil, fmt.Errorf("config: invalid environment name %q", envName) |
| + return nil, "", "", fmt.Errorf("config: invalid environment name %q", envName) |
| } |
| - env, envFound, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, envName)) |
| + env, envETag, envFound, err := getIfExistsAtETag(ctx, s, EnvironmentKey(acct, app, envName)) |
| if err != nil { |
| - return nil, err |
| + return nil, "", "", err |
| } |
| if !envFound { |
| - return nil, fmt.Errorf("config: environment %q not found for %s/%s — bound but never created (`carlos env set --app %s --environment %s ...`)", envName, acct, app, app, envName) |
| + return nil, "", "", fmt.Errorf("config: environment %q not found for %s/%s — bound but never created (`carlos env set --app %s --environment %s ...`)", envName, acct, app, app, envName) |
| } |
| merged := &Manifest{Schema: 1, App: app, Vars: map[string]Var{}} |
| if defFound { |
| @@ -202,5 +211,5 @@ func LoadMerged(ctx context.Context, s release.Store, acct, app, envName string) |
| for k, v := range env.Vars { |
| merged.Vars[k] = v |
| } |
| - return merged, nil |
| + return merged, defETag, envETag, nil |
| } |
diff --git a/internal/appconfig/instances.go b/internal/appconfig/instances.go| index 686c4b29e..9960527c7 100644 |
| --- a/internal/appconfig/instances.go |
| +++ b/internal/appconfig/instances.go |
| @@ -200,21 +200,43 @@ func DeleteInstanceConfig(ctx context.Context, s release.Store, acct, app, host |
| // must not substitute this function for that reason: the two disagree here, |
| // on purpose, and only one of them is what a box will actually deliver. |
| func LoadMergedHost(ctx context.Context, s release.Store, acct, app, envName, host string) (*Manifest, string, error) { |
| + m, v, err := LoadMergedHostVersions(ctx, s, acct, app, envName, host) |
| + return m, v.Overlay, err |
| +} |
| + |
| +// MergedVersions names the version (etag) of each object a three-layer |
| +// merge actually read: the app's default bundle, the route's named |
| +// environment, and the host's instance overlay. "" means that layer was |
| +// absent or not part of the merge (an unbound route reads no environment). |
| +type MergedVersions struct { |
| + Default, Environment, Overlay string |
| +} |
| + |
| +// LoadMergedHostVersions is LoadMergedHost returning the etag of every |
| +// object it merged, each taken from the very read that returned its bytes |
| +// (getIfExistsAtETag). It is the one loader a move's held render and T3 |
| +// use (moves design §9.1): a later, independent read could not establish |
| +// which versions were rendered, because a bundle written in between would |
| +// answer with the new etag over the old bytes. LoadMergedHost is this |
| +// function with everything but the overlay's etag discarded. |
| +func LoadMergedHostVersions(ctx context.Context, s release.Store, acct, app, envName, host string) (*Manifest, MergedVersions, error) { |
| if !ValidInstanceHost(host) { |
| - return nil, "", fmt.Errorf("config: invalid instance host %q", host) |
| + return nil, MergedVersions{}, fmt.Errorf("config: invalid instance host %q", host) |
| } |
| - base, err := LoadMerged(ctx, s, acct, app, envName) |
| + base, defETag, envETag, err := loadMergedETags(ctx, s, acct, app, envName) |
| if err != nil { |
| - return nil, "", err |
| + return nil, MergedVersions{}, err |
| } |
| + v := MergedVersions{Default: defETag, Environment: envETag} |
| inst, etag, found, err := LoadInstanceConfig(ctx, s, acct, app, host) |
| if err != nil { |
| - return nil, "", err |
| + return nil, MergedVersions{}, err |
| } |
| if !found { |
| - return base, "", nil |
| + return base, v, nil |
| } |
| - return applyInstanceOverlay(app, base, inst), etag, nil |
| + v.Overlay = etag |
| + return applyInstanceOverlay(app, base, inst), v, nil |
| } |
| // applyInstanceOverlay lays inst over base, per key. base may be nil (the |
diff --git a/internal/appconfig/renderheld.go b/internal/appconfig/renderheld.go| new file mode 100644 |
| index 000000000..836f8ce8c |
| --- /dev/null |
| +++ b/internal/appconfig/renderheld.go |
| @@ -0,0 +1,106 @@ |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ErrNotHeld is RenderHeld refusing a route it does not own: a row SyncExec |
| +// converges (status "" or "ready"), a row held by another operation, a row |
| +// of a shape no move takes, or a call without the op and box the ack is |
| +// bound to. |
| +var ErrNotHeld = errors.New("config: not a held row of this move") |
| + |
| +// ErrHeldClaimWired is RenderHeld refusing to write while CARLOS_HOST_CLAIM |
| +// is on (ruling F10): a held row's write has no claim selection until Plan |
| +// 2 adds one under the landing op, and an unguarded write is not added. |
| +var ErrHeldClaimWired = errors.New("config: held rows are not rendered while CARLOS_HOST_CLAIM is on") |
| + |
| +// HeldRender is what RenderHeld rendered: the file, the sha256 of its |
| +// bytes, and the versions of the objects merged into it. BundleETags has |
| +// the keys "default" and "environment". |
| +type HeldRender struct { |
| + Path string |
| + SHA256 string |
| + BundleETags map[string]string |
| + OverlayETag string |
| +} |
| + |
| +// RenderHeld renders the config file of a held row: one that a move holds |
| +// on this box (status not "" or "ready", hold_op empty or op), which |
| +// SyncExec therefore skips (execRouteBase), so the two never write one file. |
| +// It merges through LoadMergedHostVersions, writes through the same writer |
| +// SyncExec uses (renderExecConfig) when the bytes differ, and writes a |
| +// move-bound ack through WriteConfigAck on every call, changed or not: the |
| +// ack names op, the file's sha256 and the merged objects' etags, which T3 |
| +// compares (moves design §9.1). |
| +// |
| +// It writes through legacyHostWork, so it refuses outright while |
| +// CARLOS_HOST_CLAIM is on (ErrHeldClaimWired) and writes nothing. |
| +func RenderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string) (HeldRender, error) { |
| + if hostwrite.Enabled(os.Getenv) { |
| + return HeldRender{}, ErrHeldClaimWired |
| + } |
| + if err := heldRenderable(rt, op, box); err != nil { |
| + return HeldRender{}, err |
| + } |
| + acct := release.RouteAccount(rt) |
| + m, v, err := LoadMergedHostVersions(ctx, s, acct, rt.App, rt.Environment, rt.Host) |
| + if err != nil { |
| + return HeldRender{}, err |
| + } |
| + r, err := renderExecConfig(ctx, legacyHostWork{}, rt, m, priv, dataDir, "", v.Overlay) |
| + if err != nil { |
| + return HeldRender{}, err |
| + } |
| + sum := sha256.Sum256(r.rendered) |
| + out := HeldRender{ |
| + Path: r.path, |
| + SHA256: hex.EncodeToString(sum[:]), |
| + BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}, |
| + OverlayETag: v.Overlay, |
| + } |
| + a := ConfigAck{Schema: ConfigAckSchema, Box: box, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), |
| + InstanceConfigETag: v.Overlay, MoveOp: op, FileSHA256: out.SHA256, |
| + BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}} |
| + if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil { |
| + fmt.Printf(" %s: held config rendered, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err) |
| + } else { |
| + a.RelaySuffixes = suffixes |
| + } |
| + if err := WriteConfigAck(ctx, s, a); err != nil { |
| + return out, fmt.Errorf("%s: held config ack: %w", rt.Host, err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// heldRenderable is RenderHeld's admission: the exec-instance shape a move |
| +// takes (execSyncable without its status clause), a status SyncExec skips, |
| +// no hold by another operation, a bare host name, and an op and box to bind |
| +// the ack to. |
| +func heldRenderable(rt registry.Route, op, box string) error { |
| + switch { |
| + case op == "" || box == "": |
| + return fmt.Errorf("%w: a held render needs a move op and a box", ErrNotHeld) |
| + case rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host): |
| + return fmt.Errorf("%w: %q is not a bare host name", ErrNotHeld, rt.Host) |
| + case rt.Status == "" || rt.Status == "ready": |
| + return fmt.Errorf("%w: %s is %q, which config sync owns", ErrNotHeld, rt.Host, rt.Status) |
| + case rt.HoldOp != "" && rt.HoldOp != op: |
| + return fmt.Errorf("%w: %s is held by %s", ErrNotHeld, rt.Host, rt.HoldOp) |
| + case rt.Kind != "instance" || rt.App == "" || rt.Platform || rt.Backing == registry.BackingUnit || rt.Socket == "": |
| + return fmt.Errorf("%w: %s is not an exec-backed instance", ErrNotHeld, rt.Host) |
| + } |
| + return nil |
| +} |
diff --git a/internal/appconfig/renderheld_test.go b/internal/appconfig/renderheld_test.go| new file mode 100644 |
| index 000000000..26e5bf5cf |
| --- /dev/null |
| +++ b/internal/appconfig/renderheld_test.go |
| @@ -0,0 +1,242 @@ |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + heldOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| + heldBox = "uk-box" |
| + heldHost = "held.example.com" |
| +) |
| + |
| +// heldFixture writes all three config layers for bab/eleven and returns the |
| +// etag the dir store reports for each object. |
| +func heldFixture(t *testing.T, ctx context.Context, s release.Store) (def, env, overlay string) { |
| + t.Helper() |
| + if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"FROM_DEFAULT": "d", "SHARED": "default"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if _, err := ApplyEnvironment(ctx, s, "bab", "eleven", "prod", map[string]Var{"SHARED": {Kind: "env", Value: "environment"}}, nil); err != nil { |
| + t.Fatalf("ApplyEnvironment: %v", err) |
| + } |
| + if _, _, err := ApplyInstance(ctx, s, "bab", "eleven", heldHost, map[string]Var{"FROM_INSTANCE": {Kind: "env", Value: "i"}}, nil); err != nil { |
| + t.Fatalf("ApplyInstance: %v", err) |
| + } |
| + etagOf := func(key string) string { |
| + _, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatalf("Get %s: %v", key, err) |
| + } |
| + return etag |
| + } |
| + return etagOf(Key("bab", "eleven")), etagOf(EnvironmentKey("bab", "eleven", "prod")), etagOf(InstanceConfigKey("bab", "eleven", heldHost)) |
| +} |
| + |
| +func landingRow() registry.Route { |
| + return registry.Route{Host: heldHost, Kind: "instance", App: "eleven", Account: "bab", Environment: "prod", |
| + Status: "landing", HoldOp: heldOp, Socket: "/run/carlos/held.sock"} |
| +} |
| + |
| +// TestLoadMergedHostVersionsReportsTheMergedObjects: the etag of every |
| +// object the merge actually read comes back with the manifest, and an |
| +// absent layer reports "". |
| +func TestLoadMergedHostVersionsReportsTheMergedObjects(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + def, env, overlay := heldFixture(t, ctx, s) |
| + |
| + m, v, err := LoadMergedHostVersions(ctx, s, "bab", "eleven", "prod", heldHost) |
| + if err != nil { |
| + t.Fatalf("LoadMergedHostVersions: %v", err) |
| + } |
| + if v != (MergedVersions{Default: def, Environment: env, Overlay: overlay}) { |
| + t.Fatalf("versions = %+v, want default %q environment %q overlay %q", v, def, env, overlay) |
| + } |
| + for k, want := range map[string]string{"FROM_DEFAULT": "d", "SHARED": "environment", "FROM_INSTANCE": "i"} { |
| + if got := m.Vars[k].Value; got != want { |
| + t.Errorf("%s = %q, want %q", k, got, want) |
| + } |
| + } |
| + legacy, etag, err := LoadMergedHost(ctx, s, "bab", "eleven", "prod", heldHost) |
| + if err != nil || etag != overlay || len(legacy.Vars) != len(m.Vars) { |
| + t.Fatalf("LoadMergedHost = %d vars, %q, %v; want the same merge and the overlay etag %q", len(legacy.Vars), etag, err, overlay) |
| + } |
| + |
| + // Unbound, no overlay: only the default is read, the other two are "". |
| + _, v2, err := LoadMergedHostVersions(ctx, s, "bab", "eleven", "", "other.example.com") |
| + if err != nil { |
| + t.Fatalf("LoadMergedHostVersions (unbound): %v", err) |
| + } |
| + if v2 != (MergedVersions{Default: def}) { |
| + t.Fatalf("unbound versions = %+v, want only the default %q", v2, def) |
| + } |
| +} |
| + |
| +func readAck(t *testing.T, ctx context.Context, s release.Store) ConfigAck { |
| + t.Helper() |
| + raw, _, err := s.Get(ctx, ConfigAckKey(heldBox, heldHost)) |
| + if err != nil { |
| + t.Fatalf("ack: %v", err) |
| + } |
| + var a ConfigAck |
| + if err := json.Unmarshal(raw, &a); err != nil { |
| + t.Fatal(err) |
| + } |
| + return a |
| +} |
| + |
| +// TestRenderHeldWritesALandingRowSyncExecSkips: SyncExec leaves a landing |
| +// row alone (execRouteBase), RenderHeld renders it through the same writer |
| +// and acks it bound to the move: the op, the file's sha256 and the etags of |
| +// the default and environment objects the dir store reports. |
| +func TestRenderHeldWritesALandingRowSyncExecSkips(t *testing.T) { |
| + ctx, s, reg, dd, privB64, _ := execKit(t) |
| + priv, err := ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + def, env, overlay := heldFixture(t, ctx, s) |
| + rt := landingRow() |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SyncExec(ctx, reg, s, priv, dd, nil, "", nil); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + path := ConfigEnvPath(dd, rt) |
| + if _, err := os.Stat(path); !os.IsNotExist(err) { |
| + t.Fatalf("SyncExec wrote a landing row's file: %v", err) |
| + } |
| + |
| + got, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox) |
| + if err != nil { |
| + t.Fatalf("RenderHeld: %v", err) |
| + } |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("RenderHeld wrote no file: %v", err) |
| + } |
| + sum := sha256.Sum256(body) |
| + wantSHA := hex.EncodeToString(sum[:]) |
| + wantTags := map[string]string{"default": def, "environment": env} |
| + if got.Path != path || got.SHA256 != wantSHA || got.OverlayETag != overlay || len(got.BundleETags) != 2 || got.BundleETags["default"] != def || got.BundleETags["environment"] != env { |
| + t.Fatalf("HeldRender = %+v, want path %s sha %s bundles %v overlay %q", got, path, wantSHA, wantTags, overlay) |
| + } |
| + m, _, err := LoadMergedHostVersions(ctx, s, "bab", "eleven", "prod", heldHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want, err := RenderEnv(m, priv, routeBinding(rt)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(body) != string(want) { |
| + t.Fatalf("file = %q, want the three-layer render %q", body, want) |
| + } |
| + |
| + a := readAck(t, ctx, s) |
| + if a.MoveOp != heldOp || a.FileSHA256 != wantSHA || a.Box != heldBox || a.Host != heldHost || a.InstanceConfigETag != overlay || a.Schema != ConfigAckSchema || a.Shape != "socket-exec" { |
| + t.Fatalf("ack = %+v", a) |
| + } |
| + if len(a.BundleETags) != 2 || a.BundleETags["default"] != def || a.BundleETags["environment"] != env { |
| + t.Fatalf("ack bundle etags = %v, want %v", a.BundleETags, wantTags) |
| + } |
| +} |
| + |
| +// TestRenderHeldAcksASecondCallWithNoChange: nothing changed, so the file |
| +// is not rewritten, but the ack is: T3 reads it for this round. |
| +func TestRenderHeldAcksASecondCallWithNoChange(t *testing.T) { |
| + ctx, s, _, dd, privB64, _ := execKit(t) |
| + priv, err := ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + heldFixture(t, ctx, s) |
| + rt := landingRow() |
| + first, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox) |
| + if err != nil { |
| + t.Fatalf("RenderHeld: %v", err) |
| + } |
| + if err := s.Delete(ctx, ConfigAckKey(heldBox, heldHost)); err != nil { |
| + t.Fatal(err) |
| + } |
| + second, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox) |
| + if err != nil { |
| + t.Fatalf("second RenderHeld: %v", err) |
| + } |
| + if second.SHA256 != first.SHA256 || second.Path != first.Path { |
| + t.Fatalf("second = %+v, first = %+v", second, first) |
| + } |
| + if a := readAck(t, ctx, s); a.MoveOp != heldOp || a.FileSHA256 != first.SHA256 { |
| + t.Fatalf("second call's ack = %+v", a) |
| + } |
| +} |
| + |
| +// TestRenderHeldRefusesWhileHostClaimIsWired: ruling F10. RenderHeld writes |
| +// through legacyHostWork, so with CARLOS_HOST_CLAIM on it refuses and |
| +// writes nothing, neither file nor ack. Plan 2 adds the held-row selection. |
| +func TestRenderHeldRefusesWhileHostClaimIsWired(t *testing.T) { |
| + ctx, s, _, dd, privB64, _ := execKit(t) |
| + priv, err := ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + heldFixture(t, ctx, s) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + rt := landingRow() |
| + if _, err := RenderHeld(ctx, s, priv, dd, rt, heldOp, heldBox); !errors.Is(err, ErrHeldClaimWired) { |
| + t.Fatalf("RenderHeld with the claim wired: %v, want ErrHeldClaimWired", err) |
| + } |
| + if _, err := os.Stat(ConfigEnvPath(dd, rt)); !os.IsNotExist(err) { |
| + t.Fatalf("a file was written: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, ConfigAckKey(heldBox, heldHost)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an ack was written: %v", err) |
| + } |
| +} |
| + |
| +// TestRenderHeldRefusesWhatItDoesNotOwn: a row SyncExec owns (ready, or |
| +// no status at all) would race SyncExec on one file, a row held by another |
| +// operation is not this move's, and a missing op or box cannot be acked. |
| +func TestRenderHeldRefusesWhatItDoesNotOwn(t *testing.T) { |
| + ctx, s, _, dd, privB64, _ := execKit(t) |
| + priv, err := ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + heldFixture(t, ctx, s) |
| + for name, tc := range map[string]struct { |
| + mutate func(*registry.Route) |
| + op, box string |
| + }{ |
| + "ready row": {func(r *registry.Route) { r.Status = "ready" }, heldOp, heldBox}, |
| + "statusless row": {func(r *registry.Route) { r.Status = "" }, heldOp, heldBox}, |
| + "held by another op": {func(r *registry.Route) { r.HoldOp = "mv_bcdefghijklmnopqrstuvwxyza" }, heldOp, heldBox}, |
| + "no op": {func(*registry.Route) {}, "", heldBox}, |
| + "no box": {func(*registry.Route) {}, heldOp, ""}, |
| + "unit-backed": {func(r *registry.Route) { r.Backing = registry.BackingUnit }, heldOp, heldBox}, |
| + "no socket": {func(r *registry.Route) { r.Socket = "" }, heldOp, heldBox}, |
| + "not a bare host name": {func(r *registry.Route) { r.Host = "../x" }, heldOp, heldBox}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + rt := landingRow() |
| + tc.mutate(&rt) |
| + if _, err := RenderHeld(ctx, s, priv, dd, rt, tc.op, tc.box); !errors.Is(err, ErrNotHeld) { |
| + t.Fatalf("RenderHeld: %v, want ErrNotHeld", err) |
| + } |
| + if _, err := os.Stat(ConfigEnvPath(dd, landingRow())); !os.IsNotExist(err) { |
| + t.Fatalf("a file was written: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 2fb1d33b3..d1304717c 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -1226,6 +1226,76 @@ func syncExecHostClaimed(ctx context.Context, work hostwrite.Work, rt registry.R |
| } |
| return nil |
| } |
| + r, err := renderExecConfig(ctx, work, rt, m, priv, dataDir, header, instETag) |
| + if err != nil { |
| + return err |
| + } |
| + if !r.changed { |
| + return nil // up to date — stay quiet on the periodic tick |
| + } |
| + id := r.id |
| + if err := guarded(ctx, work, id, func() error { return nil }); err != nil { |
| + return err |
| + } |
| + var bounceErr error |
| + if bounce != nil || callbacks.SelectedRestart != nil { |
| + if callbacks.enabled { |
| + bounceErr = callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}) |
| + if bounceErr != nil { |
| + return bounceErr |
| + } |
| + } else { |
| + bounceErr = bounceLogged(rt.Host, bounce) |
| + } |
| + } |
| + if ack != nil || callbacks.SelectedAck != nil { |
| + a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag} |
| + if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil { |
| + fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err) |
| + } else { |
| + a.RelaySuffixes = suffixes |
| + } |
| + // bounce == nil (the tcp-exec-file/addr shape) leaves Restarted |
| + // false and RestartError empty — see this function's own doc |
| + // comment for why. bounce != nil is the socket-exec shape, and its |
| + // bounce IS this pass's restart (spec §5 step 5: a socket-exec |
| + // route's cutover is the ack "since the platform restarted it"), |
| + // so the ack has to carry what bounceLogged actually returned |
| + // rather than assume success the way the removal branch above may. |
| + if bounce != nil { |
| + if bounceErr != nil { |
| + a.RestartError = bounceErr.Error() |
| + } else { |
| + a.Restarted = true |
| + } |
| + } |
| + if err := guarded(ctx, work, id, func() error { return nil }); err != nil { |
| + return err |
| + } |
| + return callbacks.acknowledge(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}, a) |
| + } |
| + return nil |
| +} |
| + |
| +// execRender is what renderExecConfig did: the file's path, the bytes it |
| +// holds now, whether this call wrote them, and the identity the writes were |
| +// guarded under (zero when nothing was written). |
| +type execRender struct { |
| + path string |
| + rendered []byte |
| + changed bool |
| + id hostwrite.Identity |
| +} |
| + |
| +// renderExecConfig is the file-writing half of syncExecHostClaimed for a |
| +// present manifest: render m for rt, compare with what is on disk, and |
| +// write through work only when the bytes differ. Extracted so the held-row |
| +// render (RenderHeld, moves design §9.1) writes through the very same |
| +// reader, renderer and writer SyncExec does, rather than a second copy of |
| +// them. The bare-hostname check stays with each caller, before any path is |
| +// derived. |
| +func renderExecConfig(ctx context.Context, work hostwrite.Work, rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, dataDir, header, instETag string) (execRender, error) { |
| + path := ConfigEnvPath(dataDir, rt) |
| // A nested row's current file lives on tenant-owned ground, so its read |
| // goes through instancefs the same way its write below does: a plain |
| // os.ReadFile would block forever on a FIFO a tenant planted at this |
| @@ -1239,14 +1309,14 @@ func syncExecHostClaimed(ctx context.Context, work hostwrite.Work, rt registry.R |
| existed = err == nil |
| if err != nil && !os.IsNotExist(err) { |
| fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| - return err |
| + return execRender{}, err |
| } |
| } else { |
| cur, err = os.ReadFile(path) |
| existed = err == nil |
| if err != nil && !os.IsNotExist(err) { |
| fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| - return err |
| + return execRender{}, err |
| } |
| } |
| // Where sync.go has its defaultMissing/lostKeys guard, this path has |
| @@ -1272,15 +1342,17 @@ func syncExecHostClaimed(ctx context.Context, work hostwrite.Work, rt registry.R |
| rendered, err := RenderEnv(m, priv, routeBinding(rt)) |
| if err != nil { |
| fmt.Printf(" %s: render config: %v\n", rt.Host, err) |
| - return err |
| + return execRender{}, err |
| } |
| if header != "" { |
| rendered = append([]byte(header), rendered...) |
| } |
| + out := execRender{path: path, rendered: rendered} |
| if existed && bytes.Equal(cur, rendered) { |
| - return nil // up to date — stay quiet on the periodic tick |
| + return out, nil // up to date — stay quiet on the periodic tick |
| } |
| id := renderedIdentity(rendered, true, rt.Environment != "", instETag != "") |
| + out.id = id |
| // 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 |
| @@ -1291,62 +1363,23 @@ func syncExecHostClaimed(ctx context.Context, work hostwrite.Work, rt registry.R |
| if nestedRoute(dataDir, rt) { |
| if err := guarded(ctx, work, id, func() error { return instancefs.WriteAtomic(filepath.Dir(path), filepath.Base(path), rendered, 0o600) }); err != nil { |
| fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) |
| - return err |
| + return execRender{}, err |
| } |
| } else { |
| tmp := path + ".tmp" |
| if err := guarded(ctx, work, id, func() error { return os.WriteFile(tmp, rendered, 0o600) }); err != nil { |
| fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) |
| - return err |
| + return execRender{}, err |
| } |
| if err := guarded(ctx, work, id, func() error { return os.Rename(tmp, path) }); err != nil { |
| _ = guarded(ctx, work, id, func() error { return os.Remove(tmp) }) |
| fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err) |
| - return err |
| + return execRender{}, err |
| } |
| } |
| fmt.Printf(" %s: config updated\n", rt.Host) |
| - if err := guarded(ctx, work, id, func() error { return nil }); err != nil { |
| - return err |
| - } |
| - var bounceErr error |
| - if bounce != nil || callbacks.SelectedRestart != nil { |
| - if callbacks.enabled { |
| - bounceErr = callbacks.restart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}) |
| - if bounceErr != nil { |
| - return bounceErr |
| - } |
| - } else { |
| - bounceErr = bounceLogged(rt.Host, bounce) |
| - } |
| - } |
| - if ack != nil || callbacks.SelectedAck != nil { |
| - a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag} |
| - if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil { |
| - fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err) |
| - } else { |
| - a.RelaySuffixes = suffixes |
| - } |
| - // bounce == nil (the tcp-exec-file/addr shape) leaves Restarted |
| - // false and RestartError empty — see this function's own doc |
| - // comment for why. bounce != nil is the socket-exec shape, and its |
| - // bounce IS this pass's restart (spec §5 step 5: a socket-exec |
| - // route's cutover is the ack "since the platform restarted it"), |
| - // so the ack has to carry what bounceLogged actually returned |
| - // rather than assume success the way the removal branch above may. |
| - if bounce != nil { |
| - if bounceErr != nil { |
| - a.RestartError = bounceErr.Error() |
| - } else { |
| - a.Restarted = true |
| - } |
| - } |
| - if err := guarded(ctx, work, id, func() error { return nil }); err != nil { |
| - return err |
| - } |
| - return callbacks.acknowledge(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id}, a) |
| - } |
| - return nil |
| + out.changed = true |
| + return out, nil |
| } |
| // bounceLogged calls bounce — best-effort in the sense that a failure is |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 3f8a1f34a..744364817 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -3821,6 +3821,14 @@ func runAdopt(args []string, buildClaims ClaimFactory) { |
| log.Fatalf("adopt: %v", err) |
| } |
| + // A move's held stage (moves design §7.3, T7): the sandboxed agent |
| + // cannot write /opt, so a move's target leaves a request under |
| + // /data/moves/held-stage and touches stage-requested. Served here, after |
| + // release adoption, on -stage-only and on the hourly pass (its floor). |
| + // Reads nothing and writes nothing unless a request exists, and none |
| + // exist while CARLOS_MOVES_PHASE1 is off. |
| + heldStagePass(ctx, dataDir(), release.OptDir(), reg, store, BoxLabel()) |
| + |
| // Step 4: the advisories. The two capability advisories that fall out of |
| // READING manifests (declared-but-ungranted, and capabilities on an |
| // exec-backed route) are printed by capabilityPass above, where the |
diff --git a/internal/host/heldstage.go b/internal/host/heldstage.go| new file mode 100644 |
| index 000000000..4c9696a3b |
| --- /dev/null |
| +++ b/internal/host/heldstage.go |
| @@ -0,0 +1,113 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// heldStageReadTimeout bounds the bucket reads one authorization makes. |
| +const heldStageReadTimeout = 30 * time.Second |
| + |
| +// heldStageAuthority is the authorize closure the root adopt pass hands |
| +// release.ServeHeldStageRequests (moves design §7.3, T7). A held-stage |
| +// request is honoured only for the move this box is the target of, right |
| +// now, and only for the release the source runs: |
| +// |
| +// - the op is a well-formed move or rollback id (it becomes part of a |
| +// bucket key below); |
| +// - the request names this box; |
| +// - the registry row for the host, when one exists, is held by the |
| +// request's op and names the request's account and app (during |
| +// preparing the target has no row yet, so an absent row is accepted, and |
| +// a row held by another op refuses); |
| +// - the host claim names the op, is moving or landing, targets this box, |
| +// and is held by the request's account and app; |
| +// - the source's report for the op publishes a Release equal to the |
| +// requested version. |
| +// |
| +// Any mismatch is release.ErrStaleHeldStage, so a request queued for an |
| +// operation that was aborted or replaced never repoints live/<host>. A |
| +// read that fails is returned as itself: the request is dropped either way, |
| +// and the agent queues it again on its next round. |
| +func heldStageAuthority(reg *registry.Registry, store release.Store, box string) func(release.HeldStageRequest) error { |
| + return func(req release.HeldStageRequest) error { |
| + stale := func(format string, args ...any) error { |
| + return fmt.Errorf("%w: "+format, append([]any{release.ErrStaleHeldStage}, args...)...) |
| + } |
| + if !moves.ValidOpID(req.Op) { |
| + return stale("malformed op id") |
| + } |
| + if box == "" || req.Box != box { |
| + return stale("it names box %q and this is %q", req.Box, box) |
| + } |
| + rt, err := reg.Get(req.Host) |
| + if err != nil { |
| + return fmt.Errorf("registry row for %s: %w", req.Host, err) |
| + } |
| + if rt != nil { |
| + if rt.HoldOp != req.Op { |
| + return stale("the registry row is held by %q", rt.HoldOp) |
| + } |
| + if release.RouteAccount(*rt) != req.Account || rt.App != req.App { |
| + return stale("the registry row is %s/%s", release.RouteAccount(*rt), rt.App) |
| + } |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), heldStageReadTimeout) |
| + defer cancel() |
| + obs, err := hostclaim.Read(ctx, store, req.Host) |
| + if err != nil { |
| + return fmt.Errorf("host claim for %s: %w", req.Host, err) |
| + } |
| + if obs.Absent || obs.Claim == nil { |
| + return stale("the host has no claim") |
| + } |
| + op := obs.Claim.Op |
| + if op.ID != req.Op { |
| + return stale("the claim names op %q", op.ID) |
| + } |
| + if op.Kind != hostclaim.KindMoving && op.Kind != hostclaim.KindLanding { |
| + return stale("the claim's op is %s", op.Kind) |
| + } |
| + if op.Intent["target"] != req.Box { |
| + return stale("the claim's target is %q", op.Intent["target"]) |
| + } |
| + if h := obs.Claim.Holder; h == nil || h.Account != req.Account || h.App != req.App { |
| + return stale("the claim is not held by %s/%s", req.Account, req.App) |
| + } |
| + raw, _, err := store.Get(ctx, moves.SourceReportKey(moves.ReportPrefix(req.Account, req.App, req.Host, req.Op))) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return stale("the source has not reported its release") |
| + } |
| + if err != nil { |
| + return fmt.Errorf("source report for %s: %w", req.Host, err) |
| + } |
| + var sr moves.SourceReport |
| + if err := json.Unmarshal(raw, &sr); err != nil { |
| + return fmt.Errorf("source report for %s: %w", req.Host, err) |
| + } |
| + if sr.Release == "" || sr.Release != req.Version { |
| + return stale("the source runs %q", sr.Release) |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// heldStagePass serves the held-stage requests the sandboxed agent left |
| +// for this root pass. A no-op that reads nothing unless a request exists, |
| +// and none exist while CARLOS_MOVES_PHASE1 is off: only a move's target |
| +// writes one. Never fatal. |
| +func heldStagePass(ctx context.Context, dataDir, optDir string, reg *registry.Registry, store release.Store, box string) { |
| + if err := release.ServeHeldStageRequests(ctx, store, optDir, dataDir, heldStageAuthority(reg, store, box)); err != nil { |
| + log.Printf("adopt: held stage requests: %v", err) |
| + } |
| +} |
diff --git a/internal/host/heldstage_test.go b/internal/host/heldstage_test.go| new file mode 100644 |
| index 000000000..f60f3d0e2 |
| --- /dev/null |
| +++ b/internal/host/heldstage_test.go |
| @@ -0,0 +1,294 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + hsHost = "held.example.com" |
| + hsOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| + hsOther = "mv_bcdefghijklmnopqrstuvwxyza" |
| + hsBox = "uk-box" |
| +) |
| + |
| +// hsClaim writes host's claim: held by bab/titogo, op as given. |
| +func hsClaim(t *testing.T, s release.Store, op hostclaim.Op) { |
| + t.Helper() |
| + created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| + inc, err := hostclaim.Incarnation("bab", "titogo", hsHost, created) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = hostclaim.Update(context.Background(), s, hostclaim.Observed{Host: hsHost, Absent: true}, func(c *hostclaim.Claim) error { |
| + c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "titogo", hsHost), Account: "bab", App: "titogo", |
| + Incarnation: inc, CreatedAt: created, Location: "instances/" + hsHost + "/" + inc, Stamp: 1} |
| + c.Op = op |
| + c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func movingOp() hostclaim.Op { |
| + return hostclaim.Op{Kind: hostclaim.KindMoving, ID: hsOp, Exec: "ie-box", Participants: []string{"ie-box", hsBox}, |
| + Intent: map[string]string{"to": "uk", "source": "ie-box", "target": hsBox}} |
| +} |
| + |
| +func hsReport(t *testing.T, s release.Store, op, rel string) { |
| + t.Helper() |
| + raw, err := json.Marshal(moves.SourceReport{V: 1, Box: "ie-box", MoveID: op, At: time.Now(), Release: rel}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "titogo", hsHost, op)), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// hsReportFor is hsReport under another app of the same account. |
| +func hsReportFor(t *testing.T, s release.Store, app, op, rel string) { |
| + t.Helper() |
| + raw, err := json.Marshal(moves.SourceReport{V: 1, Box: "ie-box", MoveID: op, At: time.Now(), Release: rel}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", app, hsHost, op)), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func hsRequest() release.HeldStageRequest { |
| + return release.HeldStageRequest{Account: "bab", App: "titogo", Host: hsHost, Version: "v7", Op: hsOp, Box: hsBox} |
| +} |
| + |
| +// TestHeldStageAuthority covers each refused field of heldStageAuthority: |
| +// the request's op, box and version, the registry row (absent is accepted, |
| +// held by another op or naming another app is not), the claim (absent, |
| +// another op, a kind that is not moving or landing, another target, |
| +// another holder), and the source's report (absent, empty, another |
| +// release). |
| +func TestHeldStageAuthority(t *testing.T) { |
| + type fixture struct { |
| + reg *registry.Registry |
| + s release.Store |
| + } |
| + good := func(t *testing.T) fixture { |
| + s := release.NewDirStore(t.TempDir()) |
| + hsClaim(t, s, movingOp()) |
| + hsReport(t, s, hsOp, "v7") |
| + return fixture{testRegistry(t), s} |
| + } |
| + t.Run("a malformed op is refused before any bucket read", func(t *testing.T) { |
| + f := good(t) |
| + cs := &hsCountingStore{Store: f.s} |
| + req := hsRequest() |
| + req.Op = "../" + hsOp |
| + if err := heldStageAuthority(f.reg, cs, hsBox)(req); !errors.Is(err, release.ErrStaleHeldStage) { |
| + t.Fatalf("authorize: %v, want ErrStaleHeldStage", err) |
| + } |
| + if cs.n != 0 { |
| + t.Fatalf("a malformed op reached the bucket (%d calls)", cs.n) |
| + } |
| + }) |
| + t.Run("absent row, moving claim, matching report", func(t *testing.T) { |
| + f := good(t) |
| + if err := heldStageAuthority(f.reg, f.s, hsBox)(hsRequest()); err != nil { |
| + t.Fatalf("authorize: %v", err) |
| + } |
| + }) |
| + t.Run("a row held by the op, and a landing claim", func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + op := movingOp() |
| + op.Kind = hostclaim.KindLanding |
| + hsClaim(t, s, op) |
| + hsReport(t, s, hsOp, "v7") |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "landing", HoldOp: hsOp}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := heldStageAuthority(reg, s, hsBox)(hsRequest()); err != nil { |
| + t.Fatalf("authorize: %v", err) |
| + } |
| + }) |
| + |
| + for name, tc := range map[string]struct { |
| + box string |
| + setup func(*testing.T, fixture, *release.HeldStageRequest) |
| + }{ |
| + "malformed op": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Op = "../x" }}, |
| + "another box": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Box = "ie-box" }}, |
| + "the claim's target, not this box": {hsBox, func(t *testing.T, f fixture, r *release.HeldStageRequest) { |
| + r.Box = "fr-box" |
| + rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.Intent["target"] = "fr-box" }) |
| + }}, |
| + "claim held by another app": {hsBox, func(t *testing.T, f fixture, r *release.HeldStageRequest) { |
| + r.App = "other" |
| + hsReportFor(t, f.s, "other", hsOp, "v7") |
| + }}, |
| + "this box is unnamed": {"", func(*testing.T, fixture, *release.HeldStageRequest) {}}, |
| + "another version": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Version = "v8" }}, |
| + "another account": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Account = "xyz" }}, |
| + "another app": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.App = "other" }}, |
| + "an op the claim drops": {hsBox, func(_ *testing.T, _ fixture, r *release.HeldStageRequest) { r.Op = hsOther }}, |
| + "row held by another op": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + if err := f.reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "landing", HoldOp: hsOther}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + "row not held": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + if err := f.reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "titogo", Account: "bab", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + "row of another app": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + if err := f.reg.Upsert(registry.Route{Host: hsHost, Kind: "instance", App: "other", Account: "bab", Status: "landing", HoldOp: hsOp}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + "no claim": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + if err := f.s.Delete(context.Background(), "control/hosts/"+hsHost+".json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + "claim names another op": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.ID = hsOther }) |
| + }}, |
| + "claim is deleting": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.Kind = hostclaim.KindDeleting }) |
| + }}, |
| + "claim targets another box": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + rewriteClaim(t, f.s, func(op *hostclaim.Op) { op.Intent["target"] = "fr-box" }) |
| + }}, |
| + "no source report": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + if err := f.s.Delete(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "titogo", hsHost, hsOp))); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + "report without a release": {hsBox, func(t *testing.T, f fixture, _ *release.HeldStageRequest) { |
| + if err := f.s.Delete(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "titogo", hsHost, hsOp))); err != nil { |
| + t.Fatal(err) |
| + } |
| + hsReport(t, f.s, hsOp, "") |
| + }}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := good(t) |
| + req := hsRequest() |
| + tc.setup(t, f, &req) |
| + if err := heldStageAuthority(f.reg, f.s, tc.box)(req); !errors.Is(err, release.ErrStaleHeldStage) { |
| + t.Fatalf("authorize: %v, want ErrStaleHeldStage", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// rewriteClaim CASes the host's claim with its op changed by edit. |
| +func rewriteClaim(t *testing.T, s release.Store, edit func(*hostclaim.Op)) { |
| + t.Helper() |
| + obs, err := hostclaim.Read(context.Background(), s, hsHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(context.Background(), s, obs, func(c *hostclaim.Claim) error { edit(&c.Op); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// hsCountingStore counts every call that reaches the store. |
| +type hsCountingStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + n int |
| +} |
| + |
| +func (c *hsCountingStore) bump() { c.mu.Lock(); c.n++; c.mu.Unlock() } |
| +func (c *hsCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.bump() |
| + return c.Store.Get(ctx, key) |
| +} |
| +func (c *hsCountingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + c.bump() |
| + return c.Store.List(ctx, prefix) |
| +} |
| +func (c *hsCountingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + c.bump() |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// TestHeldStagePassIsANoOpWithoutRequests is the switch-off guarantee for |
| +// `carlos adopt`: with no held-stage request on the box (none exist while |
| +// CARLOS_MOVES_PHASE1 is off, since only a move's target writes one) the |
| +// pass reads nothing from the bucket and creates nothing on disk. |
| +func TestHeldStagePassIsANoOpWithoutRequests(t *testing.T) { |
| + dd, opt := t.TempDir(), t.TempDir() |
| + s := &hsCountingStore{Store: release.NewDirStore(t.TempDir())} |
| + heldStagePass(context.Background(), dd, opt, testRegistry(t), s, hsBox) |
| + if s.n != 0 { |
| + t.Fatalf("the pass made %d store calls", s.n) |
| + } |
| + for _, dir := range []string{dd, opt} { |
| + if ents, _ := os.ReadDir(dir); len(ents) != 0 { |
| + t.Fatalf("%s gained %v", dir, ents) |
| + } |
| + } |
| +} |
| + |
| +// TestHeldStagePassServesAnAuthorizedRequest: end to end through the real |
| +// authority, a request for this box's move stages the source's release. |
| +func TestHeldStagePassServesAnAuthorizedRequest(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + hsClaim(t, s, movingOp()) |
| + hsReport(t, s, hsOp, "v7") |
| + m := release.Manifest{App: "titogo", Version: "v7", Kind: "binary", Artifacts: map[string]release.Artifact{runtime.GOOS + "-" + runtime.GOARCH: {}}} |
| + if err := release.Ship(ctx, s, "bab", m, map[string][]byte{runtime.GOOS + "-" + runtime.GOARCH: []byte("pinned")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + dd, opt := t.TempDir(), t.TempDir() |
| + reqDir := release.HeldStageDir(dd) |
| + if err := os.MkdirAll(reqDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(hsRequest()) |
| + if err := os.WriteFile(filepath.Join(reqDir, hsHost+".json"), raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A stale sibling: its op is not the claim's. |
| + stale := hsRequest() |
| + stale.Host, stale.Op = "other.example.com", hsOther |
| + raw, _ = json.Marshal(stale) |
| + if err := os.WriteFile(filepath.Join(reqDir, stale.Host+".json"), raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + heldStagePass(ctx, dd, opt, testRegistry(t), s, hsBox) |
| + if got, err := release.LiveVersion(opt, hsHost); err != nil || got != "v7" { |
| + t.Fatalf("LiveVersion = %q, %v", got, err) |
| + } |
| + if got, _ := release.LiveVersion(opt, stale.Host); got != "" { |
| + t.Fatalf("the stale request was served: %q", got) |
| + } |
| + if ents, _ := os.ReadDir(reqDir); len(ents) != 0 { |
| + names := []string{} |
| + for _, e := range ents { |
| + names = append(names, e.Name()) |
| + } |
| + t.Fatalf("requests left behind: %s", strings.Join(names, ", ")) |
| + } |
| +} |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index e1b3050b2..14fd258e8 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -259,6 +259,12 @@ func stageBlocked(err error) bool { |
| errors.Is(err, syscall.EACCES) |
| } |
| +// StageBlocked is stageBlocked for callers outside this package: the |
| +// relay-or-fail decision a move's held stage makes (MaterializeHeld). EROFS |
| +// is what ProtectSystem=strict answers a write with, and it is not |
| +// fs.ErrPermission, so a caller must not substitute that test for this one. |
| +func StageBlocked(err error) bool { return stageBlocked(err) } |
| + |
| // Pass is one full adoption sweep: group instance routes by (App, Channel), |
| // resolve each channel pointer once, verify signatures and artifact sha256s, |
| // download/stage missing versions, and repoint live/<host> symlinks. |
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go| new file mode 100644 |
| index 000000000..02b0f8ac7 |
| --- /dev/null |
| +++ b/internal/release/materializeheld.go |
| @@ -0,0 +1,211 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// ErrStageBlocked is MaterializeHeld reporting that this process cannot |
| +// write the version store (StageBlocked: EROFS, EPERM or EACCES). The |
| +// sandboxed agent gets it on every box, because carlos-edge.service writes |
| +// only /data and /run/carlos; its caller relays the stage to the root pass |
| +// (a held-stage request) instead of failing (moves design §7.3, T7). |
| +var ErrStageBlocked = errors.New("release: this process cannot write the version store, so the root stage pass stages this release") |
| + |
| +// ErrStaleHeldStage is a held-stage request its authority refused: the |
| +// operation it names is no longer the one the host claim, the registry row |
| +// or the source's report names, so the request is dropped and live/<host> |
| +// is never repointed for it. |
| +var ErrStaleHeldStage = errors.New("release: held stage request no longer matches its move") |
| + |
| +// ErrHeldClaimWired is MaterializeHeld refusing while CARLOS_HOST_CLAIM is |
| +// on: the live link would be written outside any claim selection. Plan 2 |
| +// adds the held-row selection under the landing op (ruling F10, applied |
| +// here as it is to appconfig.RenderHeld). |
| +var ErrHeldClaimWired = errors.New("release: held rows are not staged while CARLOS_HOST_CLAIM is on") |
| + |
| +// materializeHeldStage is MaterializeHeld's staging step, ensureStaged, as |
| +// a variable so a test can make it fail the way a read-only mount does. |
| +var materializeHeldStage = ensureStaged |
| + |
| +// MaterializeHeld stages version of rt's app and points live/<host> at it: |
| +// ensureStaged, then PointLive. A held row runs exactly the pinned release, |
| +// so there is no channel resolution, no bake-hold check and no restart. |
| +// When this process cannot write optDir it returns ErrStageBlocked wrapping |
| +// the cause and has written nothing to live/<host>. |
| +func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string) (*Manifest, error) { |
| + if hostwrite.Enabled(os.Getenv) { |
| + return nil, ErrHeldClaimWired |
| + } |
| + if !controlname.ValidHostShape(rt.Host) || !ValidApp(rt.App) || !ValidVersion(version) { |
| + return nil, fmt.Errorf("release: held stage of %q/%q at %q: malformed", rt.Host, rt.App, version) |
| + } |
| + acct := RouteAccount(rt) |
| + m, err := materializeHeldStage(ctx, s, optDir, acct, rt.App, version, rt.Host) |
| + if err != nil { |
| + if StageBlocked(err) { |
| + return nil, fmt.Errorf("%w: %w", ErrStageBlocked, err) |
| + } |
| + return nil, err |
| + } |
| + if err := PointLive(optDir, rt.Host, acct, version, rt.App); err != nil { |
| + if StageBlocked(err) { |
| + return nil, fmt.Errorf("%w: %w", ErrStageBlocked, err) |
| + } |
| + return nil, err |
| + } |
| + return m, nil |
| +} |
| + |
| +// HeldStageRequest is what the sandboxed agent leaves at |
| +// HeldStageDir(dataDir)/<Host>.json when MaterializeHeld returned |
| +// ErrStageBlocked: stage Version of Account/App for Host under move Op, |
| +// whose target is Box. |
| +type HeldStageRequest struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Host string `json:"host"` |
| + Version string `json:"version"` |
| + Op string `json:"op"` |
| + Box string `json:"box"` |
| +} |
| + |
| +// HeldStaged is what the root pass leaves at HeldStagedDir(dataDir)/<host>.json |
| +// after serving a request. It is a hint only: T7 trusts LiveVersion. |
| +type HeldStaged struct { |
| + Version string `json:"version"` |
| + Op string `json:"op"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +// HeldStageDir is where held-stage requests wait for the root pass. |
| +func HeldStageDir(dataDir string) string { return filepath.Join(dataDir, "moves", "held-stage") } |
| + |
| +// HeldStagedDir is where the root pass acknowledges the requests it served. |
| +func HeldStagedDir(dataDir string) string { return filepath.Join(dataDir, "moves", "held-staged") } |
| + |
| +// heldStageRequestMax bounds a request file: the six fields fit many times |
| +// over, and the directory is writable by every process that runs as the |
| +// agent's uid, flat tenants included. |
| +const heldStageRequestMax = 4096 |
| + |
| +// ServeHeldStageRequests is the root side of the held-stage relay, run by |
| +// `carlos adopt` after its release adoption. For each <name>.json in |
| +// HeldStageDir it validates every field before any filesystem effect (Host |
| +// equal to name and a valid host shape, valid account, app and version, a |
| +// non-empty op and box), asks authorize, then runs MaterializeHeld, writes |
| +// the HeldStaged ack and removes the request. A request that fails |
| +// validation or authorize is removed with a log line and never reaches a |
| +// path. A request whose materialization or ack fails is kept for the next |
| +// pass. With no request directory it reads nothing and creates nothing. |
| +// |
| +// The directory is agent-owned and this runs as root, so every read, write |
| +// and removal goes through instancefs: no symlink is followed at any |
| +// component, and nothing but a regular file is read. |
| +func ServeHeldStageRequests(ctx context.Context, s Store, optDir, dataDir string, authorize func(HeldStageRequest) error) error { |
| + reqDir := HeldStageDir(dataDir) |
| + ents, err := os.ReadDir(reqDir) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + for _, e := range ents { |
| + name := e.Name() |
| + if strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".json") { |
| + continue // a writer's temp file, or not a request |
| + } |
| + if err := serveHeldStageRequest(ctx, s, optDir, dataDir, reqDir, name, authorize); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// serveHeldStageRequest serves one request file. It returns an error only |
| +// when this build cannot do the work at all (instancefs unsupported). |
| +func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir, name string, authorize func(HeldStageRequest) error) error { |
| + drop := func(format string, args ...any) error { |
| + fmt.Printf(" held stage request %q: "+format+"; removed\n", append([]any{name}, args...)...) |
| + if err := instancefs.RemoveBeneath(reqDir, name); err != nil { |
| + if errors.Is(err, errors.ErrUnsupported) { |
| + return err |
| + } |
| + fmt.Printf(" held stage request %q: remove: %v\n", name, err) |
| + } |
| + return nil |
| + } |
| + body, err := instancefs.ReadBeneathLimit(reqDir, name, heldStageRequestMax) |
| + if err != nil { |
| + if errors.Is(err, errors.ErrUnsupported) { |
| + return err |
| + } |
| + return drop("unreadable: %v", err) |
| + } |
| + var req HeldStageRequest |
| + if err := json.Unmarshal(body, &req); err != nil { |
| + return drop("not a request") |
| + } |
| + if why := heldStageRequestInvalid(req, strings.TrimSuffix(name, ".json")); why != "" { |
| + return drop("refused: %s", why) |
| + } |
| + if err := authorize(req); err != nil { |
| + return drop("refused: %v", err) |
| + } |
| + rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App} |
| + if _, err := MaterializeHeld(ctx, s, optDir, rt, req.Version); err != nil { |
| + fmt.Printf(" %s: held stage of %s for %s: %v; kept for the next pass\n", req.Host, req.Version, req.Op, err) |
| + return nil |
| + } |
| + raw, err := json.Marshal(HeldStaged{Version: req.Version, Op: req.Op, At: time.Now().UTC()}) |
| + if err == nil { |
| + _, err = instancefs.EnsureDirBeneath(filepath.Dir(HeldStagedDir(dataDir)), filepath.Base(HeldStagedDir(dataDir)), 0o755) |
| + } |
| + if err == nil { |
| + err = instancefs.WriteAtomic(HeldStagedDir(dataDir), req.Host+".json", raw, 0o644) |
| + } |
| + if err != nil { |
| + fmt.Printf(" %s: held stage of %s for %s: live, but the ack was not written: %v; kept for the next pass\n", req.Host, req.Version, req.Op, err) |
| + return nil |
| + } |
| + fmt.Printf(" %s: held stage of %s for %s: live\n", req.Host, req.Version, req.Op) |
| + if err := instancefs.RemoveBeneath(reqDir, name); err != nil { |
| + fmt.Printf(" held stage request %q: served, but not removed: %v\n", name, err) |
| + } |
| + return nil |
| +} |
| + |
| +// heldStageRequestInvalid names the first field of req that cannot be |
| +// trusted as a path component or an identity, or returns "". host is the |
| +// request's own file name less ".json". |
| +func heldStageRequestInvalid(req HeldStageRequest, host string) string { |
| + switch { |
| + case req.Host != host: |
| + return "its host is not its file name" |
| + case !controlname.ValidHostShape(req.Host): |
| + return "malformed host" |
| + case !ValidAccount(req.Account): |
| + return "malformed account" |
| + case !ValidApp(req.App): |
| + return "malformed app" |
| + case !ValidVersion(req.Version): |
| + return "malformed version" |
| + case req.Op == "": |
| + return "no move op" |
| + case req.Box == "": |
| + return "no box" |
| + } |
| + return "" |
| +} |
diff --git a/internal/release/materializeheld_test.go b/internal/release/materializeheld_test.go| new file mode 100644 |
| index 000000000..3afbcc8ff |
| --- /dev/null |
| +++ b/internal/release/materializeheld_test.go |
| @@ -0,0 +1,309 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "syscall" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const ( |
| + heldTestHost = "held.example.com" |
| + heldTestOp = "mv_abcdefghijklmnopqrstuvwxyz" |
| +) |
| + |
| +// heldFixture ships two builds of bab/console and moves the stable channel |
| +// to the newer one: the pinned release (v1) is deliberately NOT the head. |
| +func heldFixture(t *testing.T) (context.Context, Store, string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + priv, _ := testKeys(t) |
| + shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("pinned build")) |
| + shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("channel head")) |
| + if err := Promote(ctx, s, priv, "bab", "console", "stable", "v2"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + return ctx, s, t.TempDir() |
| +} |
| + |
| +func heldRoute() registry.Route { |
| + return registry.Route{Host: heldTestHost, Kind: "instance", App: "console", Account: "bab", Channel: "stable", Status: "landing", HoldOp: heldTestOp} |
| +} |
| + |
| +// TestMaterializeHeldStagesThePinnedVersion: the held row runs exactly the |
| +// pinned release, never the channel head, and LiveVersion reads it back. |
| +func TestMaterializeHeldStagesThePinnedVersion(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + m, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1") |
| + if err != nil { |
| + t.Fatalf("MaterializeHeld: %v", err) |
| + } |
| + if m == nil || m.Version != "v1" { |
| + t.Fatalf("manifest = %+v, want v1's", m) |
| + } |
| + if got, err := LiveVersion(opt, heldTestHost); err != nil || got != "v1" { |
| + t.Fatalf("LiveVersion = %q, %v; want the pinned v1, not the channel head", got, err) |
| + } |
| + body, err := os.ReadFile(LiveLink(opt, heldTestHost)) |
| + if err != nil || string(body) != "pinned build" { |
| + t.Fatalf("live binary = %q, %v", body, err) |
| + } |
| +} |
| + |
| +// TestMaterializeHeldStageBlocked: a process that cannot write the version |
| +// store gets ErrStageBlocked wrapping the cause and writes nothing. EROFS is |
| +// what ProtectSystem=strict answers, and it is not fs.ErrPermission. |
| +func TestMaterializeHeldStageBlocked(t *testing.T) { |
| + for _, errno := range []syscall.Errno{syscall.EROFS, syscall.EACCES} { |
| + t.Run(errno.Error(), func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + orig := materializeHeldStage |
| + t.Cleanup(func() { materializeHeldStage = orig }) |
| + materializeHeldStage = func(context.Context, Store, string, string, string, string, string) (*Manifest, error) { |
| + return nil, &os.PathError{Op: "mkdir", Path: filepath.Join(opt, "versions"), Err: errno} |
| + } |
| + _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1") |
| + if !errors.Is(err, ErrStageBlocked) || !errors.Is(err, errno) { |
| + t.Fatalf("MaterializeHeld: %v, want ErrStageBlocked wrapping %v", err, errno) |
| + } |
| + if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| + t.Fatalf("optDir is not empty: %v", ents) |
| + } |
| + }) |
| + } |
| + other := &os.PathError{Op: "open", Path: "x", Err: syscall.ENOENT} |
| + if StageBlocked(other) || !StageBlocked(&os.PathError{Err: syscall.EPERM}) { |
| + t.Fatal("StageBlocked classifies EROFS, EPERM and EACCES only") |
| + } |
| +} |
| + |
| +// TestMaterializeHeldRefusesWhileHostClaimIsWired mirrors RenderHeld's |
| +// ruling F10: the live link is written outside any claim selection, so with |
| +// CARLOS_HOST_CLAIM on the call refuses and writes nothing. |
| +func TestMaterializeHeldRefusesWhileHostClaimIsWired(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + if _, err := MaterializeHeld(ctx, s, opt, heldRoute(), "v1"); !errors.Is(err, ErrHeldClaimWired) { |
| + t.Fatalf("MaterializeHeld: %v, want ErrHeldClaimWired", err) |
| + } |
| + if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| + t.Fatalf("optDir is not empty: %v", ents) |
| + } |
| +} |
| + |
| +// heldRecordingStore counts every call that reaches the store underneath. |
| +type heldRecordingStore struct { |
| + Store |
| + mu sync.Mutex |
| + calls []string |
| +} |
| + |
| +func (r *heldRecordingStore) note(s string) { r.mu.Lock(); r.calls = append(r.calls, s); r.mu.Unlock() } |
| +func (r *heldRecordingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + r.note("get " + key) |
| + return r.Store.Get(ctx, key) |
| +} |
| +func (r *heldRecordingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + r.note("list " + prefix) |
| + return r.Store.List(ctx, prefix) |
| +} |
| + |
| +func writeHeldRequest(t *testing.T, dataDir, name string, req HeldStageRequest) string { |
| + t.Helper() |
| + dir := HeldStageDir(dataDir) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := json.Marshal(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, name) |
| + if err := os.WriteFile(path, raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + return path |
| +} |
| + |
| +func goodHeldRequest() HeldStageRequest { |
| + return HeldStageRequest{Account: "bab", App: "console", Host: heldTestHost, Version: "v1", Op: heldTestOp, Box: "uk-box"} |
| +} |
| + |
| +func gone(t *testing.T, path string) { |
| + t.Helper() |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatalf("%s is still there: %v", path, err) |
| + } |
| +} |
| + |
| +// TestServeHeldStageRequestsRefusesStaleAndTraversal: the root side of the |
| +// held-stage relay validates every component before any filesystem effect, |
| +// asks authorize, and only then materializes. A request it refuses is |
| +// removed and never repoints live/<host>. |
| +func TestServeHeldStageRequestsRefusesStaleAndTraversal(t *testing.T) { |
| + t.Run("no request directory is a no-op", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + rec := &heldRecordingStore{Store: s} |
| + dd := t.TempDir() |
| + called := false |
| + if err := ServeHeldStageRequests(ctx, rec, opt, dd, func(HeldStageRequest) error { called = true; return nil }); err != nil { |
| + t.Fatalf("ServeHeldStageRequests: %v", err) |
| + } |
| + if called || len(rec.calls) != 0 { |
| + t.Fatalf("authorize called %v, store calls %v", called, rec.calls) |
| + } |
| + if ents, _ := os.ReadDir(dd); len(ents) != 0 { |
| + t.Fatalf("dataDir gained %v", ents) |
| + } |
| + if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| + t.Fatalf("optDir gained %v", ents) |
| + } |
| + }) |
| + |
| + t.Run("an op the claim no longer names", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + dd := t.TempDir() |
| + req := goodHeldRequest() |
| + req.Op = "mv_bcdefghijklmnopqrstuvwxyza" |
| + path := writeHeldRequest(t, dd, req.Host+".json", req) |
| + authorize := func(r HeldStageRequest) error { |
| + if r.Op != heldTestOp { |
| + return fmt.Errorf("%w: the claim names %s", ErrStaleHeldStage, heldTestOp) |
| + } |
| + return nil |
| + } |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, authorize); err != nil { |
| + t.Fatalf("ServeHeldStageRequests: %v", err) |
| + } |
| + gone(t, path) |
| + gone(t, LiveLink(opt, heldTestHost)) |
| + gone(t, filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| + }) |
| + |
| + for name, tc := range map[string]struct { |
| + file string |
| + mutate func(*HeldStageRequest) |
| + }{ |
| + "host traverses": {"evil.json", func(r *HeldStageRequest) { r.Host = "../x" }}, |
| + "host is not the file name": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Host = "other.example.com" }}, |
| + "host carries a NUL": {"nul.json", func(r *HeldStageRequest) { r.Host = "nul\x00" }}, |
| + "version traverses": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Version = ".." }}, |
| + "account is malformed": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Account = "../bab" }}, |
| + "app is malformed": {heldTestHost + ".json", func(r *HeldStageRequest) { r.App = "a/b" }}, |
| + "op is empty": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Op = "" }}, |
| + "box is empty": {heldTestHost + ".json", func(r *HeldStageRequest) { r.Box = "" }}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + rec := &heldRecordingStore{Store: s} |
| + dd := t.TempDir() |
| + req := goodHeldRequest() |
| + tc.mutate(&req) |
| + path := writeHeldRequest(t, dd, tc.file, req) |
| + called := false |
| + if err := ServeHeldStageRequests(ctx, rec, opt, dd, func(HeldStageRequest) error { called = true; return nil }); err != nil { |
| + t.Fatalf("ServeHeldStageRequests: %v", err) |
| + } |
| + gone(t, path) |
| + if called || len(rec.calls) != 0 { |
| + t.Fatalf("a refused request reached authorize (%v) or the store (%v)", called, rec.calls) |
| + } |
| + if ents, _ := os.ReadDir(opt); len(ents) != 0 { |
| + t.Fatalf("optDir gained %v", ents) |
| + } |
| + }) |
| + } |
| + |
| + t.Run("garbage is removed", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + dd := t.TempDir() |
| + path := filepath.Join(HeldStageDir(dd), heldTestHost+".json") |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, func(HeldStageRequest) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + gone(t, path) |
| + }) |
| + |
| + t.Run("a symlinked request is removed, never followed", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + dd := t.TempDir() |
| + target := filepath.Join(t.TempDir(), "target.json") |
| + raw, _ := json.Marshal(goodHeldRequest()) |
| + if err := os.WriteFile(target, raw, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(HeldStageDir(dd), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + link := filepath.Join(HeldStageDir(dd), heldTestHost+".json") |
| + if err := os.Symlink(target, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + called := false |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, func(HeldStageRequest) error { called = true; return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + gone(t, link) |
| + if called { |
| + t.Fatal("a symlinked request reached authorize") |
| + } |
| + if _, err := os.Stat(target); err != nil { |
| + t.Fatalf("the link's target was touched: %v", err) |
| + } |
| + }) |
| + |
| + t.Run("a failed materialization keeps the request", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + dd := t.TempDir() |
| + req := goodHeldRequest() |
| + req.Version = "v9" // never shipped |
| + path := writeHeldRequest(t, dd, req.Host+".json", req) |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, func(HeldStageRequest) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(path); err != nil { |
| + t.Fatalf("the request was dropped after a failed stage: %v", err) |
| + } |
| + gone(t, LiveLink(opt, heldTestHost)) |
| + }) |
| + |
| + t.Run("the happy path materializes, acks and removes", func(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + dd := t.TempDir() |
| + req := goodHeldRequest() |
| + path := writeHeldRequest(t, dd, req.Host+".json", req) |
| + var seen []HeldStageRequest |
| + if err := ServeHeldStageRequests(ctx, s, opt, dd, func(r HeldStageRequest) error { seen = append(seen, r); return nil }); err != nil { |
| + t.Fatalf("ServeHeldStageRequests: %v", err) |
| + } |
| + if len(seen) != 1 || seen[0] != req { |
| + t.Fatalf("authorize saw %+v, want exactly %+v", seen, req) |
| + } |
| + gone(t, path) |
| + if got, err := LiveVersion(opt, heldTestHost); err != nil || got != "v1" { |
| + t.Fatalf("LiveVersion = %q, %v; want v1", got, err) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(HeldStagedDir(dd), heldTestHost+".json")) |
| + if err != nil { |
| + t.Fatalf("no held-staged ack: %v", err) |
| + } |
| + var ack HeldStaged |
| + if err := json.Unmarshal(raw, &ack); err != nil || ack.Version != "v1" || ack.Op != heldTestOp || ack.At.IsZero() { |
| + t.Fatalf("held-staged ack = %s (%v)", raw, err) |
| + } |
| + }) |
| +} |