carlos / platform Public

release+host: Materialize stages the channel head at cold wake (closes #77)

A cold wake of an exec-backed route with nothing staged 503s ("no live
binary") until the hourly adopt pass comes round. release.MaterializeHost
closes that window: it IS the adopt pass, filtered to one host with
restart=nil (the host is cold — the activator execing it is the restart),
so signature verification, the bake hold, staging and the Version stamp are
all the pass's own. A second staging path is exactly the drift this refuses
to create.

Success is judged by the live symlink, not the pass error: PassFilter logs
per-route trouble rather than returning it, and "bake-held", "nothing
promoted" and "no such host" all correctly leave the link absent — so an
absent link is MaterializeHost's error. A cold wake must never become a
bake bypass; the 503 is then correct and says why.

The agent wires it through materializeHook, which degrades to nil with no
release store or no CARLOS_RELEASE_PUBKEY — nil is today's behavior exactly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 82d71f5a819032b9f51113b27774f07fbac635ba parent f7cdb64
4 files changed, +299 −4
  • internal/host/agent.go +36 −4
  • internal/host/agent_test.go +93 −0
  • internal/release/materialize.go +42 −0
  • internal/release/materialize_test.go +128 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 53743493c..433fba9f9 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -144,10 +144,11 @@ func RunAgent(args []string) {
// never touch the bucket — one opt-in switch for the whole activator.
svc = activator.New(*cfg, &activator.SplitBackend{
Exec: &activator.ExecBackend{
- Cfg: *cfg,
- OptDir: release.OptDir(),
- DataDir: dd,
- Cache: cache,
+ Cfg: *cfg,
+ OptDir: release.OptDir(),
+ DataDir: dd,
+ Cache: cache,
+ Materialize: materializeHook(reg, dd),
},
Unit: &activator.UnitBackend{OptDir: release.OptDir()},
}, reg) // the registry satisfies activator.DueStore directly
@@ -203,6 +204,37 @@ func peekRegistryPath(args []string, dd string) string {
return *regPath
}
+// materializeHook builds the ExecBackend's cold-wake staging hook, or nil
+// when this box has no release store/pubkey — nil keeps today's behavior
+// ("no live binary for <host>" wake failure), the safe degrade for a box
+// whose routes are versioned by hand or not at all.
+//
+// The hook is the adopt pass scoped to one host (release.MaterializeHost),
+// so a cold wake with nothing staged pays one slow request instead of 503ing
+// until the hourly pass comes round (issue #77). Everything the pass refuses
+// it still refuses here: a bake-held head is NOT materialized, and the wake
+// fails with that reason rather than the head being installed early.
+//
+// Store and key are resolved ONCE, at agent start, exactly like adoptLoop's:
+// a wake is on a request's critical path, and re-reading env plus re-opening
+// an S3 client per cold wake would put that work there for nothing.
+func materializeHook(reg *registry.Registry, dd string) func(context.Context, string) error {
+ store, err := openDeployStore(context.Background())
+ if err != nil {
+ log.Printf("agent: cold-wake materialize disabled: %v", err)
+ return nil
+ }
+ pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ log.Printf("agent: cold-wake materialize disabled: %v", err)
+ return nil
+ }
+ optDir := release.OptDir()
+ return func(ctx context.Context, host string) error {
+ return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host)
+ }
+}
+
// adoptLoop runs a release-adoption pass roughly hourly (plus jitter, so a
// fleet never hits the store in the same second), forever. Missing deployment
// config disables the loop with one log line — an agent with no release store
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go
index 3bddab9a9..66e320903 100644
--- a/internal/host/agent_test.go
+++ b/internal/host/agent_test.go
@@ -2,7 +2,11 @@ package host
import (
"context"
+ "crypto/ed25519"
+ "encoding/base64"
"errors"
+ "path/filepath"
+ "runtime"
"testing"
"github.com/carlosframework/platform/internal/appconfig"
@@ -386,3 +390,92 @@ func TestGenPollPassPrunesUnitAppsToo(t *testing.T) {
t.Fatalf("unexpected read errors: %v", errs)
}
}
+
+// ---- cold-wake materialize hook (carlos-deploy spec §5) ---------------------
+
+// TestMaterializeHookDegradesToNil pins the safe degrade: a box with no
+// release store, or no (or a broken) release pubkey, gets a NIL hook — which
+// is today's behavior exactly, the ExecBackend's "no live binary" wake
+// failure. A non-nil hook built from a half-configured box would turn every
+// cold wake into a staging error that names the wrong problem.
+func TestMaterializeHookDegradesToNil(t *testing.T) {
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ pub, _, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ pubB64 := base64.StdEncoding.EncodeToString(pub)
+
+ for _, c := range []struct{ name, dir, pub string }{
+ {"no store, no key", "", ""},
+ {"store but no key", t.TempDir(), ""},
+ {"store but unparseable key", t.TempDir(), "not-base64!!"},
+ {"key but no store", "", pubB64},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", c.dir)
+ t.Setenv("CARLOS_RELEASE_PUBKEY", c.pub)
+ if hook := materializeHook(reg, t.TempDir()); hook != nil {
+ t.Fatal("hook must be nil on a box that cannot adopt — nil is today's behavior")
+ }
+ })
+ }
+}
+
+// TestMaterializeHookStagesAColdRoute exercises the wired path end to end
+// through the very function the ExecBackend calls: a fully configured box, a
+// route with nothing staged, and after one hook call the live symlink exists
+// and names the channel head. This is what retires "no live binary" as a
+// wake-failure class (issue #77).
+func TestMaterializeHookStagesAColdRoute(t *testing.T) {
+ dir := t.TempDir()
+ optDir := t.TempDir()
+ s := release.NewDirStore(dir)
+ ctx := context.Background()
+ plat := runtime.GOOS + "-" + runtime.GOARCH
+
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "cold.example.com", Kind: "instance", App: "coldapp",
+ Channel: "canary/dev", Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ m := release.Manifest{
+ App: "coldapp", Version: "abc1234", Kind: "binary",
+ Artifacts: map[string]release.Artifact{plat: {}},
+ }
+ if err := release.Ship(ctx, s, "bab", m, map[string][]byte{plat: []byte("cold build")}); err != nil {
+ t.Fatalf("Ship: %v", err)
+ }
+ if err := release.Promote(ctx, s, priv, "bab", "coldapp", "canary/dev", "abc1234"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
+ t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub))
+ t.Setenv("CARLOS_OPT_DIR", optDir)
+
+ hook := materializeHook(reg, t.TempDir())
+ if hook == nil {
+ t.Fatal("a fully configured box must get a hook")
+ }
+ if err := hook(ctx, "cold.example.com"); err != nil {
+ t.Fatalf("hook: %v", err)
+ }
+ if v, err := release.LiveVersion(optDir, "cold.example.com"); err != nil || v != "abc1234" {
+ t.Fatalf("live = %q, %v; want abc1234", v, err)
+ }
+}
diff --git a/internal/release/materialize.go b/internal/release/materialize.go
new file mode 100644
index 000000000..026146a43
--- /dev/null
+++ b/internal/release/materialize.go
@@ -0,0 +1,42 @@
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// MaterializeHost stages host's channel head and points its live symlink —
+// the cold-wake, on-demand form of the adopt pass (carlos-deploy spec §5).
+// It IS the adopt pass, filtered to one host with restart=nil (the host is
+// cold; the activator execing it is the "restart"): bake holds, signature
+// verification, staging, and the Version stamp are all the pass's own —
+// a second staging path is exactly the drift this refuses to create.
+//
+// Success is judged by the symlink, not the pass error: PassFilter logs
+// per-route trouble rather than returning it (only foundation failures — an
+// unreadable registry, an unwritable bake clock — come back as errors), and
+// "held by a bake window", "nothing promoted for this channel" and "no such
+// host" all correctly leave the link absent. A caller about to exec the
+// binary needs the link, so an absent link is this function's error.
+//
+// dataDir is where the bake clock lives (BakeDir; /data in production), so
+// the window a cold wake observes is the same one the hourly root pass
+// measures — a cold wake must never become a bake bypass by keeping its own
+// clock.
+func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir, dataDir, host string) error {
+ only := func(rt registry.Route) bool { return rt.Host == host }
+ if err := PassFilter(ctx, reg, s, pub, optDir, nil, only, BakeDir(dataDir)); err != nil {
+ return fmt.Errorf("materialize %s: %w", host, err)
+ }
+ v, err := LiveVersion(optDir, host)
+ if err != nil {
+ return fmt.Errorf("materialize %s: %w", host, err)
+ }
+ if v == "" {
+ return fmt.Errorf("materialize %s: no adoptable head (nothing promoted for its channel, or the head is bake-held)", host)
+ }
+ return nil
+}
diff --git a/internal/release/materialize_test.go b/internal/release/materialize_test.go
new file mode 100644
index 000000000..8f0b8b6bd
--- /dev/null
+++ b/internal/release/materialize_test.go
@@ -0,0 +1,128 @@
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// matFixture is the shape every test below needs: a dir store with a shipped
+// release, a signed channel pointer, a registry holding the route(s), a fresh
+// optDir with NOTHING staged, and a dataDir for the bake clock. Built inline
+// the way adopt_test.go builds its fixtures (testKeys/shipRelease/addRoute/
+// testRegistry) — this package has no shared fixture builder and inventing a
+// parallel harness for three tests would be the drift, not the reuse.
+type matFixture struct {
+ store Store
+ priv ed25519.PrivateKey
+ pub ed25519.PublicKey
+ reg *registry.Registry
+ optDir string
+ dataDir string
+}
+
+func newMatFixture(t *testing.T) *matFixture {
+ t.Helper()
+ priv, pub := testKeys(t)
+ return &matFixture{
+ store: NewDirStore(t.TempDir()),
+ priv: priv,
+ pub: pub,
+ reg: testRegistry(t),
+ optDir: t.TempDir(),
+ dataDir: t.TempDir(),
+ }
+}
+
+// TestMaterializeHostStagesChannelHead is the cold-wake case the activator
+// hits: an exec-backed route with nothing staged at all. MaterializeHost must
+// leave the live symlink pointing at the channel head — and must touch ONLY
+// the named host, since the wake it serves is that host's alone.
+func TestMaterializeHostStagesChannelHead(t *testing.T) {
+ env := newMatFixture(t)
+ ctx := context.Background()
+
+ // canary/* has no bake window, so the head is immediately adoptable.
+ shipRelease(t, env.store, "bab", "matapp", "abc1234", adoptPlatform, []byte("cold wake build"))
+ if err := Promote(ctx, env.store, env.priv, "bab", "matapp", "canary/dev", "abc1234"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, env.reg, "mat.test", "matapp", "canary/dev", false)
+ // A sibling on the same channel, equally unstaged: waking one host must not
+ // stage the fleet.
+ addRoute(t, env.reg, "other.test", "matapp", "canary/dev", false)
+
+ if err := MaterializeHost(ctx, env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test"); err != nil {
+ t.Fatalf("MaterializeHost: %v", err)
+ }
+
+ v, err := LiveVersion(env.optDir, "mat.test")
+ if err != nil || v != "abc1234" {
+ t.Fatalf("live = %q, %v; want abc1234", v, err)
+ }
+ if v, _ := LiveVersion(env.optDir, "other.test"); v != "" {
+ t.Fatalf("sibling host was materialized too (%q) — a wake stages one host", v)
+ }
+}
+
+// TestMaterializeHostRefusesHeldHead: a cold wake must NOT become a bake
+// bypass (spec §5). The app is declared production and the route follows beta
+// (24h hold) with a head promoted moments ago, so the pass correctly stages
+// nothing — and MaterializeHost must report that as an error, because the
+// caller's next move is exec'ing a binary that isn't there.
+func TestMaterializeHostRefusesHeldHead(t *testing.T) {
+ env := newMatFixture(t)
+ ctx := context.Background()
+
+ shipRelease(t, env.store, "bab", "matapp", "abc1234", adoptPlatform, []byte("held build"))
+ if err := Promote(ctx, env.store, env.priv, "bab", "matapp", "beta", "abc1234"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ if err := SetProduction(ctx, env.store, env.priv, "bab", "matapp", true, "test"); err != nil {
+ t.Fatalf("SetProduction: %v", err)
+ }
+ addRoute(t, env.reg, "mat.test", "matapp", "beta", false)
+
+ err := MaterializeHost(ctx, env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test")
+ if err == nil {
+ t.Fatal("materialize of a bake-held head must refuse")
+ }
+ if !strings.Contains(err.Error(), "mat.test") {
+ t.Errorf("error %q does not name the host", err)
+ }
+ if v, _ := LiveVersion(env.optDir, "mat.test"); v != "" {
+ t.Fatalf("held head was staged anyway: %q", v)
+ }
+}
+
+// TestMaterializeHostUnknownHost: a host the registry has never heard of
+// matches no route, so the pass is a no-op and the symlink stays absent. That
+// must surface as an error rather than a silent success the activator would
+// read as "go ahead and exec".
+func TestMaterializeHostUnknownHost(t *testing.T) {
+ env := newMatFixture(t)
+ if err := MaterializeHost(context.Background(), env.reg, env.store, env.pub, env.optDir, env.dataDir, "nope.test"); err == nil {
+ t.Fatal("unknown host must error")
+ }
+}
+
+// TestMaterializeHostNothingPromoted: the route exists and the app has
+// shipped, but its channel has no pointer — nothing to stage, and the same
+// error contract as a held head (the link is what is judged, not the pass).
+func TestMaterializeHostNothingPromoted(t *testing.T) {
+ env := newMatFixture(t)
+ ctx := context.Background()
+
+ shipRelease(t, env.store, "bab", "matapp", "abc1234", adoptPlatform, []byte("never promoted"))
+ addRoute(t, env.reg, "mat.test", "matapp", "canary/dev", false)
+
+ if err := MaterializeHost(ctx, env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test"); err == nil {
+ t.Fatal("a channel with nothing promoted must error, not report success")
+ }
+ if v, _ := LiveVersion(env.optDir, "mat.test"); v != "" {
+ t.Fatalf("live = %q, want absent", v)
+ }
+}