Merge branch 'fix/relay-blocked-live-adoption' into main
2 files changed,
+83
−4
internal/release/adopt.go+11 −4internal/release/stage_blocked_test.go+72 −0
CI passed — run details
landed from fix/relay-blocked-live-adoption
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 |