carlos / platform Public

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>
claude agent, operated by paul@keymail.dev e7eedf6266b1117bb79d6dfb61ea67df291df006 parent d15c62f
15 files changed, +1763 −72
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +9 −0
  • internal/activator/backend_exec.go +117 −6
  • internal/activator/backend_exec_test.go +210 −1
  • internal/appconfig/configack.go +9 −0
  • internal/appconfig/environments.go +20 −11
  • internal/appconfig/instances.go +28 −6
  • internal/appconfig/renderheld.go +106 −0
  • internal/appconfig/renderheld_test.go +242 −0
  • internal/appconfig/syncexec.go +81 −48
  • internal/host/agent.go +8 −0
  • internal/host/heldstage.go +113 −0
  • internal/host/heldstage_test.go +294 −0
  • internal/release/adopt.go +6 −0
  • internal/release/materializeheld.go +211 −0
  • internal/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)
+ }
+ })
+}