release: a dangling live link is a materialize failure, not a success
ExecBackend.ensureLiveLink calls Materialize whenever os.Stat of the link fails, and os.Stat FOLLOWS symlinks — so "link present, staged version deleted" (an operator's /opt cleanup, a partially restored volume) reaches the hook exactly like "link absent". MaterializeHost judged on LiveVersion, a bare os.Readlink, which still answers the dead version; and the pass cannot repair the state while the channel head is unchanged, because adoptHost's cur == ver short-circuit reads that same dangling link and returns having staged nothing. Net effect: nil returned, the activator execs a path that does not resolve. Judge on a link that RESOLVES (Readlink to tell absent from dangling, then a following Stat) and name the missing target in the error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+76
−5
internal/release/materialize.go+23 −5internal/release/materialize_test.go+53 −0
diff --git a/internal/release/materialize.go b/internal/release/materialize.go| index 026146a43..c50a2d80d 100644 |
| --- a/internal/release/materialize.go |
| +++ b/internal/release/materialize.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "crypto/ed25519" |
| "fmt" |
| + "os" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -22,6 +23,17 @@ import ( |
| // 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. |
| // |
| +// And judged by a link that RESOLVES, not merely one that exists. The caller |
| +// is ExecBackend.ensureLiveLink, which reaches this hook whenever os.Stat of |
| +// the link fails — and os.Stat FOLLOWS symlinks, so "link present, staged |
| +// version gone" (an operator's /opt cleanup, a partially restored volume) |
| +// arrives here exactly like "link absent". The pass cannot repair that state |
| +// on its own: with the channel head unchanged, adoptHost's cur == ver |
| +// short-circuit sees the dangling link's version via os.Readlink and returns |
| +// having staged nothing. So a dangling link is reported as the error it is, |
| +// naming the missing target — never as success the caller would answer by |
| +// exec'ing a path that does not resolve. |
| +// |
| // 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 |
| @@ -31,12 +43,18 @@ func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub e |
| 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 == "" { |
| + link := LiveLink(optDir, host) |
| + target, err := os.Readlink(link) |
| + switch { |
| + case err != nil && os.IsNotExist(err): |
| return fmt.Errorf("materialize %s: no adoptable head (nothing promoted for its channel, or the head is bake-held)", host) |
| + case err != nil: |
| + return fmt.Errorf("materialize %s: read live link %s: %w", host, link, err) |
| + } |
| + // The link is there; stat FOLLOWS it, so this is the question the caller |
| + // actually has — is there a binary at the end of it? |
| + if _, err := os.Stat(link); err != nil { |
| + return fmt.Errorf("materialize %s: live link %s points at %s, which does not resolve (%v) — the staged version was removed; re-promote the channel or run `carlos adopt` on the box", host, link, target, err) |
| } |
| return nil |
| } |
diff --git a/internal/release/materialize_test.go b/internal/release/materialize_test.go| index 8f0b8b6bd..b60745944 100644 |
| --- a/internal/release/materialize_test.go |
| +++ b/internal/release/materialize_test.go |
| @@ -3,6 +3,7 @@ package release |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "os" |
| "strings" |
| "testing" |
| @@ -109,6 +110,58 @@ func TestMaterializeHostUnknownHost(t *testing.T) { |
| } |
| } |
| +// TestMaterializeHostDanglingLiveLink is the state this hook is ACTUALLY |
| +// called in as often as "no link at all": ExecBackend.ensureLiveLink invokes |
| +// Materialize when os.Stat of the link fails, and os.Stat FOLLOWS symlinks — |
| +// so "link present, staged version deleted" (an operator's /opt cleanup, a |
| +// partially restored volume) arrives here indistinguishably from "link |
| +// absent". |
| +// |
| +// The pass cannot repair it while the channel head is unchanged: adoptHost |
| +// reads the version off the dangling link with os.Readlink, sees cur == ver, |
| +// and returns having staged nothing. So the only honest answer is an error |
| +// naming the target — a nil here would be answered by the activator exec'ing |
| +// a path that does not resolve, which is the confusing failure this whole |
| +// function exists to remove. |
| +func TestMaterializeHostDanglingLiveLink(t *testing.T) { |
| + env := newMatFixture(t) |
| + ctx := context.Background() |
| + |
| + 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) |
| + |
| + // The box staged and pointed the head, then lost the version store under |
| + // it — the link survives and still reads "abc1234". |
| + if err := StageArtifact(env.optDir, "bab", "abc1234", "matapp", []byte("cold wake build")); err != nil { |
| + t.Fatalf("StageArtifact: %v", err) |
| + } |
| + if err := PointLive(env.optDir, "mat.test", "bab", "abc1234", "matapp"); err != nil { |
| + t.Fatalf("PointLive: %v", err) |
| + } |
| + if err := os.RemoveAll(VersionPath(env.optDir, "bab", "abc1234", "matapp")); err != nil { |
| + t.Fatalf("remove staged version: %v", err) |
| + } |
| + if v, _ := LiveVersion(env.optDir, "mat.test"); v != "abc1234" { |
| + t.Fatalf("fixture is wrong: LiveVersion = %q, want the dangling link to still read abc1234", v) |
| + } |
| + |
| + err := MaterializeHost(ctx, env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test") |
| + if err == nil { |
| + // If a future change makes the pass restage here, that is also a |
| + // correct outcome — but only if the binary is genuinely back. |
| + if _, statErr := os.Stat(LiveLink(env.optDir, "mat.test")); statErr != nil { |
| + t.Fatalf("materialize reported success with a dangling live link (%v) — the activator would exec a path that does not resolve", statErr) |
| + } |
| + return |
| + } |
| + if !strings.Contains(err.Error(), "abc1234") { |
| + t.Errorf("error %q does not name the missing target", err) |
| + } |
| +} |
| + |
| // 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). |