| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io/fs" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "syscall" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | // An already-staged release still needs a privileged pass when the agent's |
| 16 | // sandbox refuses the live symlink swap. The old build must remain recorded |
| 17 | // and running until that pass can complete the adoption. |
| 18 | func TestPassRelaysBlockedLiveSwapForStagedRelease(t *testing.T) { |
| 19 | if os.Geteuid() == 0 || runtime.GOOS == "windows" { |
| 20 | t.Skip("requires Unix directory permissions enforced for a non-root user") |
| 21 | } |
| 22 | ctx := context.Background() |
| 23 | s := NewDirStore(t.TempDir()) |
| 24 | priv, pub := testKeys(t) |
| 25 | opt := t.TempDir() |
| 26 | reg := testRegistry(t) |
| 27 | t.Cleanup(func() { reg.Close() }) |
| 28 | const host = "blocked.example.com" |
| 29 | for _, ver := range []string{"v1", "v2"} { |
| 30 | shipRelease(t, s, "bab", "myapp", ver, adoptPlatform, []byte(ver)) |
| 31 | } |
| 32 | if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | addRoute(t, reg, host, "myapp", "canary/x", false) |
| 36 | if err := Pass(ctx, reg, s, pub, opt, nil); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | if _, err := ensureStaged(ctx, s, opt, "bab", "myapp", "v2", host); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | live := filepath.Dir(LiveLink(opt, host)) |
| 46 | t.Cleanup(func() { os.Chmod(live, 0o755) }) |
| 47 | if err := os.Chmod(live, 0o555); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | var requests, restarts int |
| 51 | restart := func(string) error { restarts++; return nil } |
| 52 | request := func(gotHost, ver string, err error) { |
| 53 | requests++ |
| 54 | if gotHost != host || ver != "v2" || !errors.Is(err, os.ErrPermission) { |
| 55 | t.Errorf("relay = (%q, %q, %v), want blocked v2 live swap", gotHost, ver, err) |
| 56 | } |
| 57 | } |
| 58 | if err := Pass(ctx, reg, s, pub, opt, restart, OnStageBlocked(request)); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if requests != 1 || restarts != 0 { |
| 62 | t.Fatalf("requests=%d restarts=%d, want 1 and 0", requests, restarts) |
| 63 | } |
| 64 | if ver, err := LiveVersion(opt, host); err != nil || ver != "v1" { |
| 65 | t.Fatalf("live version = %q, %v; want v1", ver, err) |
| 66 | } |
| 67 | if rt, err := reg.Get(host); err != nil || rt == nil || rt.Version != "v1" { |
| 68 | t.Fatalf("registry route = %+v, %v; want v1", rt, err) |
| 69 | } |
| 70 | |
| 71 | // Model the requested privileged pass: once the live directory is |
| 72 | // writable, the same staged release adopts normally. |
| 73 | if err := os.Chmod(live, 0o755); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if err := Pass(ctx, reg, s, pub, opt, restart, OnStageBlocked(request)); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | if ver, err := LiveVersion(opt, host); err != nil || ver != "v2" || requests != 1 || restarts != 1 { |
| 80 | t.Fatalf("after retry: version=%q err=%v requests=%d restarts=%d", ver, err, requests, restarts) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // stageBlocked is deliberately narrow: it must match "this uid cannot write |
| 85 | // the version store" and nothing else. A callback that fired on any staging |
| 86 | // error would relay a bucket outage into a root pass on every tick — and none |
| 87 | // of those failures is fixed by re-running as root. |
| 88 | func TestStageBlockedMatchesOnlyWriteRefusals(t *testing.T) { |
| 89 | for _, tc := range []struct { |
| 90 | name string |
| 91 | err error |
| 92 | want bool |
| 93 | }{ |
| 94 | // The production shape: mkdir under a ProtectSystem=strict sandbox. |
| 95 | {"EROFS via PathError", &os.PathError{Op: "mkdir", Path: "/opt/carlos/versions/abc", Err: syscall.EROFS}, true}, |
| 96 | {"EACCES via PathError", &os.PathError{Op: "openat", Path: "/opt/carlos/versions/abc", Err: syscall.EACCES}, true}, |
| 97 | {"EPERM via PathError", &os.PathError{Op: "mkdir", Path: "/opt/carlos/versions/abc", Err: syscall.EPERM}, true}, |
| 98 | // Wrapped, because the pass wraps before it reports. |
| 99 | {"wrapped EROFS", fmt.Errorf("stage abc: %w", &os.PathError{Op: "mkdir", Err: syscall.EROFS}), true}, |
| 100 | |
| 101 | // Everything a root pass would fail at identically. |
| 102 | {"not exist", fs.ErrNotExist, false}, |
| 103 | {"bad signature", errors.New("manifest signature does not verify"), false}, |
| 104 | {"sha mismatch", errors.New("artifact sha256 mismatch"), false}, |
| 105 | {"bucket 403", errors.New("operation error S3: GetObject, api error AccessDenied"), false}, |
| 106 | {"nil", nil, false}, |
| 107 | } { |
| 108 | t.Run(tc.name, func(t *testing.T) { |
| 109 | if got := stageBlocked(tc.err); got != tc.want { |
| 110 | t.Errorf("stageBlocked(%v) = %v, want %v", tc.err, got, tc.want) |
| 111 | } |
| 112 | }) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // The option must be opt-in: every existing caller and test passes no |
| 117 | // OnStageBlocked, and those calls must behave exactly as they always have. |
| 118 | func TestOnStageBlockedUnsetByDefault(t *testing.T) { |
| 119 | var c passConfig |
| 120 | if c.stageBlocked != nil { |
| 121 | t.Fatal("passConfig.stageBlocked must default to nil") |
| 122 | } |
| 123 | called := false |
| 124 | OnStageBlocked(func(string, string, error) { called = true })(&c) |
| 125 | if c.stageBlocked == nil { |
| 126 | t.Fatal("OnStageBlocked did not set the hook") |
| 127 | } |
| 128 | c.stageBlocked("h", "v", syscall.EROFS) |
| 129 | if !called { |
| 130 | t.Error("hook not invoked") |
| 131 | } |
| 132 | } |
| 133 | |