carlos / platform Public

appconfig, release: the held render and the held release under the claim selection (Plan 3 Task 5, H13)

RenderHeldUnder writes the held config through a work its caller selects
from the merged etags; MaterializeHeldUnder stages as adoption does and
swaps live/<host> inside the selection under the pinned release's
identity. The flag-off paths and their CARLOS_HOST_CLAIM refusals are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 210b7d9bf2c35ec84e80300540c9de29032c3ac3 parent 1051124
4 files changed, +249 −3
  • internal/appconfig/renderheld.go +28 −1
  • internal/appconfig/renderheld_claim_test.go +99 −0
  • internal/release/materializeheld.go +51 −2
  • internal/release/materializeheld_claim_test.go +71 −0
diff --git a/internal/appconfig/renderheld.go b/internal/appconfig/renderheld.go
index 836f8ce8c..1258805b0 100644
--- a/internal/appconfig/renderheld.go
+++ b/internal/appconfig/renderheld.go
@@ -52,6 +52,26 @@ func RenderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dat
if hostwrite.Enabled(os.Getenv) {
return HeldRender{}, ErrHeldClaimWired
}
+ return renderHeld(ctx, s, priv, dataDir, rt, op, box, func(HeldRender) (hostwrite.Work, error) { return legacyHostWork{}, nil })
+}
+
+// RenderHeldUnder is RenderHeld for a held row under the host claim (moves
+// Phase 1 Plan 3 Task 5, ruling H13): the same ownership check, merge,
+// renderer, writer and move-bound ack, with the file written through the
+// claim selection work returns instead of legacyHostWork. work is called
+// once the merged versions are known, with BundleETags and OverlayETag set
+// (the rest of the HeldRender empty), so its caller can select a held-config
+// work bound to exactly the etags this render writes; nothing is written
+// before it returns. A nil work, or a work that is nil, is refused. It does
+// not consult CARLOS_HOST_CLAIM: the selection is the authority.
+func RenderHeldUnder(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error)) (HeldRender, error) {
+ if work == nil {
+ return HeldRender{}, hostwrite.ErrUnavailable
+ }
+ return renderHeld(ctx, s, priv, dataDir, rt, op, box, work)
+}
+
+func renderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error)) (HeldRender, error) {
if err := heldRenderable(rt, op, box); err != nil {
return HeldRender{}, err
}
@@ -60,7 +80,14 @@ func RenderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dat
if err != nil {
return HeldRender{}, err
}
- r, err := renderExecConfig(ctx, legacyHostWork{}, rt, m, priv, dataDir, "", v.Overlay)
+ w, err := work(HeldRender{BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}, OverlayETag: v.Overlay})
+ if err != nil {
+ return HeldRender{}, err
+ }
+ if w == nil {
+ return HeldRender{}, hostwrite.ErrUnavailable
+ }
+ r, err := renderExecConfig(ctx, w, rt, m, priv, dataDir, "", v.Overlay)
if err != nil {
return HeldRender{}, err
}
diff --git a/internal/appconfig/renderheld_claim_test.go b/internal/appconfig/renderheld_claim_test.go
new file mode 100644
index 000000000..002944b63
--- /dev/null
+++ b/internal/appconfig/renderheld_claim_test.go
@@ -0,0 +1,99 @@
+package appconfig
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+)
+
+// recordingWork is a claim selection stand-in: it records each identity it
+// is asked to write under and runs the effect only while open.
+type recordingWork struct {
+ ids []hostwrite.Identity
+ shut bool
+}
+
+func (w *recordingWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error {
+ w.ids = append(w.ids, id)
+ if w.shut {
+ return hostwrite.ErrUnavailable
+ }
+ return fn()
+}
+
+// TestRenderHeldUnderWritesThroughTheClaimSelection (moves Plan 3 Task 5,
+// H13): with CARLOS_HOST_CLAIM on, the held render writes the config file
+// through the work its caller selects, after the merged versions are known
+// (the work is chosen from them), and acks the move as RenderHeld does.
+func TestRenderHeldUnderWritesThroughTheClaimSelection(t *testing.T) {
+ ctx, s, _, dd, privB64, _ := execKit(t)
+ priv, err := ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ def, env, overlay := heldFixture(t, ctx, s)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ rt := landingRow()
+ work := &recordingWork{}
+ var seen HeldRender
+ hr, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, func(pre HeldRender) (hostwrite.Work, error) {
+ seen = pre
+ return work, nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen.BundleETags["default"] != def || seen.BundleETags["environment"] != env || seen.OverlayETag != overlay {
+ t.Fatalf("the work was chosen from %+v", seen)
+ }
+ if hr.BundleETags["default"] != def || hr.OverlayETag != overlay || hr.SHA256 == "" || hr.Path != ConfigEnvPath(dd, rt) {
+ t.Fatalf("render: %+v", hr)
+ }
+ if len(work.ids) == 0 {
+ t.Fatal("the file was not written through the selection")
+ }
+ if _, err := os.Stat(hr.Path); err != nil {
+ t.Fatal(err)
+ }
+ if a := readAck(t, ctx, s); a.MoveOp != heldOp || a.FileSHA256 != hr.SHA256 || a.Box != heldBox {
+ t.Fatalf("ack: %+v", a)
+ }
+}
+
+// A refused selection writes nothing, and a missing work is refused before
+// any write; the ownership checks are RenderHeld's.
+func TestRenderHeldUnderRefusals(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()
+ shut := &recordingWork{shut: true}
+ if _, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, func(HeldRender) (hostwrite.Work, error) { return shut, nil }); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("a refused selection: %v", err)
+ }
+ selectErr := errors.New("no selection")
+ if _, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, func(HeldRender) (hostwrite.Work, error) { return nil, selectErr }); !errors.Is(err, selectErr) {
+ t.Fatalf("a failed selection: %v", err)
+ }
+ if _, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, nil); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("no selection: %v", err)
+ }
+ ready := rt
+ ready.Status = "ready"
+ if _, err := RenderHeldUnder(ctx, s, priv, dd, ready, heldOp, heldBox, func(HeldRender) (hostwrite.Work, error) { return &recordingWork{}, nil }); !errors.Is(err, ErrNotHeld) {
+ t.Fatalf("a row SyncExec owns: %v", 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)
+ }
+}
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go
index c36d618a0..e5f4a9ee7 100644
--- a/internal/release/materializeheld.go
+++ b/internal/release/materializeheld.go
@@ -136,6 +136,40 @@ func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Ro
if err := heldStageable(rt, version); err != nil {
return nil, err
}
+ return materializeHeld(ctx, s, optDir, rt, version, proof, guard)
+}
+
+// MaterializeHeldUnder is MaterializeHeld for a held row under the host
+// claim (moves Phase 1 Plan 3 Task 5, ruling H13). As adoption does under
+// the claim, the version is staged outside the selection (the version store
+// is shared, and a download must not hold the hostguard flock), and the
+// host-specific publication, the bind, the live/<host> swap and its
+// rollback, runs inside work under the identity naming exactly version (the
+// held-release action's identity). guard, when not nil, runs inside work
+// around the publication (the caller's held-stage lock). D3's admit and bind
+// are MaterializeHeld's. A nil work is refused before anything is staged; it
+// does not consult CARLOS_HOST_CLAIM, since the selection is the authority.
+// A stage this process cannot write is ErrStageBlocked: there is no root
+// relay under the claim.
+func MaterializeHeldUnder(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, work hostwrite.Work, guard func(publish func() error) error) (*Manifest, error) {
+ if err := heldShape(rt, version); err != nil {
+ return nil, err
+ }
+ if work == nil {
+ return nil, errHeldNoGuard
+ }
+ id := hostwrite.Identity{Release: version}
+ return materializeHeld(ctx, s, optDir, rt, version, proof, func(publish func() error) error {
+ return work.With(ctx, id, func() error {
+ if guard == nil {
+ return publish()
+ }
+ return guard(publish)
+ })
+ })
+}
+
+func materializeHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error) (*Manifest, error) {
if guard == nil {
return nil, errHeldNoGuard
}
@@ -147,7 +181,7 @@ func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Ro
if err != nil {
return nil, err
}
- m, err := StageHeld(ctx, s, optDir, rt, version)
+ m, err := stageHeld(ctx, s, optDir, rt, version)
if err != nil {
return nil, err
}
@@ -165,6 +199,13 @@ func StageHeld(ctx context.Context, s Store, optDir string, rt registry.Route, v
if err := heldStageable(rt, version); err != nil {
return nil, err
}
+ return stageHeld(ctx, s, optDir, rt, version)
+}
+
+func stageHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string) (*Manifest, error) {
+ if err := heldShape(rt, version); err != nil {
+ return nil, err
+ }
m, err := materializeHeldStage(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host)
if err != nil {
if StageBlocked(err) {
@@ -218,7 +259,9 @@ func PublishHeld(ctx context.Context, s Store, optDir string, rt registry.Route,
// MoveOp) in the held namespace (HeldRefusalBase), never in adoption's; a
// publication that succeeds retires its move's earlier refusal.
func publishHeld(optDir string, rt registry.Route, version string, proof *ProofGate, ptr *ChannelPointer, v proofVerdict, guard func(publish func() error) error) error {
- if err := heldStageable(rt, version); err != nil {
+ // The CARLOS_HOST_CLAIM refusal is the callers' (heldStageable): the
+ // claim path reaches here only inside its selection.
+ if err := heldShape(rt, version); err != nil {
return err
}
if guard == nil {
@@ -384,6 +427,12 @@ func heldStageable(rt registry.Route, version string) error {
if hostwrite.Enabled(os.Getenv) {
return ErrHeldClaimWired
}
+ return heldShape(rt, version)
+}
+
+// heldShape is heldStageable without the CARLOS_HOST_CLAIM refusal, for the
+// claim path, which writes only inside its selection.
+func heldShape(rt registry.Route, version string) error {
if !controlname.ValidHostShape(rt.Host) || !ValidApp(rt.App) || !ValidVersion(version) {
return fmt.Errorf("release: held stage of %q/%q at %q: malformed", rt.Host, rt.App, version)
}
diff --git a/internal/release/materializeheld_claim_test.go b/internal/release/materializeheld_claim_test.go
new file mode 100644
index 000000000..cf9dc5349
--- /dev/null
+++ b/internal/release/materializeheld_claim_test.go
@@ -0,0 +1,71 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+)
+
+type heldClaimWork struct {
+ ids []hostwrite.Identity
+ shut bool
+}
+
+func (w *heldClaimWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error {
+ w.ids = append(w.ids, id)
+ if w.shut {
+ return hostwrite.ErrUnavailable
+ }
+ return fn()
+}
+
+// TestMaterializeHeldUnderPublishesThroughTheClaimSelection (moves Plan 3
+// Task 5, H13): with CARLOS_HOST_CLAIM on, the held release is staged as
+// adoption stages and live/<host> is pointed at it inside the claim
+// selection, under the identity naming exactly the pinned version, with the
+// caller's lock inside it.
+func TestMaterializeHeldUnderPublishesThroughTheClaimSelection(t *testing.T) {
+ ctx, s, opt := heldFixture(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ work := &heldClaimWork{}
+ locked := 0
+ m, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), work, func(publish func() error) error {
+ locked++
+ return publish()
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m == nil || m.Version != "v1" {
+ t.Fatalf("manifest %+v", m)
+ }
+ if got, err := LiveVersion(opt, heldTestHost); err != nil || got != "v1" {
+ t.Fatalf("LiveVersion = %q, %v", got, err)
+ }
+ if len(work.ids) != 1 || work.ids[0] != (hostwrite.Identity{Release: "v1"}) || locked != 1 {
+ t.Fatalf("publication ran under %v with the lock taken %d times", work.ids, locked)
+ }
+}
+
+// A refused selection never points live/<host>; a missing selection is
+// refused before anything is staged.
+func TestMaterializeHeldUnderRefusals(t *testing.T) {
+ ctx, s, opt := heldFixture(t)
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ if _, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), nil, nil); !errors.Is(err, errHeldNoGuard) {
+ t.Fatalf("no selection: %v", err)
+ }
+ if _, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), &heldClaimWork{shut: true}, nil); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("a refused selection: %v", err)
+ }
+ if got, _ := LiveVersion(opt, heldTestHost); got != "" {
+ t.Fatalf("live/<host> names %q", got)
+ }
+ noOp := heldRoute()
+ noOp.HoldOp = ""
+ if _, err := MaterializeHeldUnder(ctx, s, opt, noOp, "v1", offGate(t), &heldClaimWork{}, nil); err == nil {
+ t.Fatal("a row no move holds was staged")
+ }
+}