carlos / platform Public

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>
Paul Campbell a8530048970ed33fe6a195a537fda03bd5ae3230 parent 82d71f5
2 files changed, +76 −5
  • internal/release/materialize.go +23 −5
  • internal/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).