carlos / platform Public

release: relay blocked live symlink adoption to root pass

Paul Campbell pushed by paul@keymail.dev 90b087820e928499239eb121553ffd4da0791890 parent ceecb3a
2 files changed, +83 −4
  • internal/release/adopt.go +11 −4
  • internal/release/stage_blocked_test.go +72 −0

CI passed — run details

diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 52f2f98ac..ed2b3485b 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -140,10 +140,11 @@ func BakeDir(dir string) PassOption {
return func(c *passConfig) { c.bakeDir = dir }
}
-// OnStageBlocked reports a version this pass could not stage because the
-// process cannot WRITE the version store — never because the bucket, the
-// signature or the manifest was bad. It is the release half of the relay
-// carlos-adopt.path already runs for config: the agent's in-process loops are
+// OnStageBlocked reports a version this pass could not stage or point live
+// because the process cannot WRITE the install directory — never because
+// the bucket, the signature or the manifest was bad. It is the release half
+// of the relay carlos-adopt.path already runs for config: the agent's
+// in-process loops are
// sandboxed (carlos-edge.service sets ProtectSystem=strict with no /opt write),
// so they can see a promotion within seconds and stage nothing, while the only
// process that CAN stage runs hourly off carlos-adopt.timer. Without a relay a
@@ -1057,6 +1058,12 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
}
}
fmt.Printf(" %s: repoint failed: %v%s\n", rt.Host, err, rolled)
+ // A release staged by an earlier root pass can still need root to
+ // swap its live symlink. Relay that write refusal too, or the fast
+ // loop never requests another pass once the artifact exists.
+ if cfg.stageBlocked != nil && stageBlocked(err) {
+ cfg.stageBlocked(rt.Host, ver, err)
+ }
return
}
diff --git a/internal/release/stage_blocked_test.go b/internal/release/stage_blocked_test.go
index 2ef086258..1446fd8aa 100644
--- a/internal/release/stage_blocked_test.go
+++ b/internal/release/stage_blocked_test.go
@@ -1,14 +1,86 @@
package release
import (
+ "context"
"errors"
"fmt"
"io/fs"
"os"
+ "path/filepath"
+ "runtime"
"syscall"
"testing"
)
+// An already-staged release still needs a privileged pass when the agent's
+// sandbox refuses the live symlink swap. The old build must remain recorded
+// and running until that pass can complete the adoption.
+func TestPassRelaysBlockedLiveSwapForStagedRelease(t *testing.T) {
+ if os.Geteuid() == 0 || runtime.GOOS == "windows" {
+ t.Skip("requires Unix directory permissions enforced for a non-root user")
+ }
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ t.Cleanup(func() { reg.Close() })
+ const host = "blocked.example.com"
+ for _, ver := range []string{"v1", "v2"} {
+ shipRelease(t, s, "bab", "myapp", ver, adoptPlatform, []byte(ver))
+ }
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, host, "myapp", "canary/x", false)
+ if err := Pass(ctx, reg, s, pub, opt, nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ensureStaged(ctx, s, opt, "bab", "myapp", "v2", host); err != nil {
+ t.Fatal(err)
+ }
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ live := filepath.Dir(LiveLink(opt, host))
+ t.Cleanup(func() { os.Chmod(live, 0o755) })
+ if err := os.Chmod(live, 0o555); err != nil {
+ t.Fatal(err)
+ }
+ var requests, restarts int
+ restart := func(string) error { restarts++; return nil }
+ request := func(gotHost, ver string, err error) {
+ requests++
+ if gotHost != host || ver != "v2" || !errors.Is(err, os.ErrPermission) {
+ t.Errorf("relay = (%q, %q, %v), want blocked v2 live swap", gotHost, ver, err)
+ }
+ }
+ if err := Pass(ctx, reg, s, pub, opt, restart, OnStageBlocked(request)); err != nil {
+ t.Fatal(err)
+ }
+ if requests != 1 || restarts != 0 {
+ t.Fatalf("requests=%d restarts=%d, want 1 and 0", requests, restarts)
+ }
+ if ver, err := LiveVersion(opt, host); err != nil || ver != "v1" {
+ t.Fatalf("live version = %q, %v; want v1", ver, err)
+ }
+ if rt, err := reg.Get(host); err != nil || rt == nil || rt.Version != "v1" {
+ t.Fatalf("registry route = %+v, %v; want v1", rt, err)
+ }
+
+ // Model the requested privileged pass: once the live directory is
+ // writable, the same staged release adopts normally.
+ if err := os.Chmod(live, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := Pass(ctx, reg, s, pub, opt, restart, OnStageBlocked(request)); err != nil {
+ t.Fatal(err)
+ }
+ if ver, err := LiveVersion(opt, host); err != nil || ver != "v2" || requests != 1 || restarts != 1 {
+ t.Fatalf("after retry: version=%q err=%v requests=%d restarts=%d", ver, err, requests, restarts)
+ }
+}
+
// stageBlocked is deliberately narrow: it must match "this uid cannot write
// the version store" and nothing else. A callback that fired on any staging
// error would relay a bucket outage into a root pass on every tick — and none