| 1 | package activator |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | // stagedFake is fakeBackend with spec §7.9's capability. It records which |
| 18 | // build each worker spawn asked for (nil = "what the live link names") and |
| 19 | // reports a synthetic identity, so a test can tell builds apart without real |
| 20 | // files. |
| 21 | type stagedFake struct { |
| 22 | *fakeBackend |
| 23 | smu sync.Mutex |
| 24 | onto []*release.StagedIdentity |
| 25 | // failNextStaged, when set, is what the next spawn onto a named build |
| 26 | // (onto != nil) returns instead of a child, once: the backend refusing a |
| 27 | // recycle. |
| 28 | failNextStaged error |
| 29 | } |
| 30 | |
| 31 | func fakeIdentity(ver string) release.StagedIdentity { |
| 32 | return release.StagedIdentity{Version: ver, StagedPath: "/opt/carlos/versions/" + ver + "/app", Digest: "sha-of-" + ver} |
| 33 | } |
| 34 | |
| 35 | func (b *stagedFake) StartWorkerStaged(ctx context.Context, rt registry.Route, env map[string]string, onto *release.StagedIdentity) (Proc, release.StagedIdentity, error) { |
| 36 | b.smu.Lock() |
| 37 | if onto != nil { |
| 38 | c := *onto |
| 39 | b.onto = append(b.onto, &c) |
| 40 | } else { |
| 41 | b.onto = append(b.onto, nil) |
| 42 | } |
| 43 | fail := b.failNextStaged |
| 44 | if onto != nil { |
| 45 | b.failNextStaged = nil |
| 46 | } |
| 47 | b.smu.Unlock() |
| 48 | if onto != nil && fail != nil { |
| 49 | return nil, release.StagedIdentity{}, fail |
| 50 | } |
| 51 | // ExecBackend's contract with the activator's before-exec hook (ruling |
| 52 | // R19 item 3): the hook runs with the identity about to be "exec'd", and |
| 53 | // a hook error means no child. |
| 54 | id := fakeIdentity(b.fakeBackend.liveVerNow()) |
| 55 | if onto != nil { |
| 56 | id = *onto |
| 57 | } |
| 58 | if err := runBeforeExec(ctx, id); err != nil { |
| 59 | return nil, release.StagedIdentity{}, err |
| 60 | } |
| 61 | // And the caller's exec gate at the start, after the publication (Astra |
| 62 | // R3 §5 item 4): ExecBackend asks it at the cgroup manager's start. |
| 63 | if err := runExecGate(ctx); err != nil { |
| 64 | return nil, release.StagedIdentity{}, err |
| 65 | } |
| 66 | p, ver, err := b.fakeBackend.StartWorker(ctx, rt, env) |
| 67 | if err != nil { |
| 68 | return p, release.StagedIdentity{}, err |
| 69 | } |
| 70 | if onto != nil { |
| 71 | return p, *onto, nil |
| 72 | } |
| 73 | return p, fakeIdentity(ver), nil |
| 74 | } |
| 75 | |
| 76 | // liveVerNow is the version the fake's next exec from the live link would |
| 77 | // report, as StartWorker computes it. |
| 78 | func (b *fakeBackend) liveVerNow() string { |
| 79 | b.mu.Lock() |
| 80 | defer b.mu.Unlock() |
| 81 | if b.execVerFn != nil { |
| 82 | return b.execVerFn() |
| 83 | } |
| 84 | return b.workerSpawnVer |
| 85 | } |
| 86 | |
| 87 | func (b *stagedFake) calls() []*release.StagedIdentity { |
| 88 | b.smu.Lock() |
| 89 | defer b.smu.Unlock() |
| 90 | return append([]*release.StagedIdentity(nil), b.onto...) |
| 91 | } |
| 92 | |
| 93 | // stageArgv0Worker stages a worker build at <OptDir>/versions/<ver>/app that |
| 94 | // writes its own $0 to out and then sleeps, and points live/<host> at it. The |
| 95 | // version is in the script, so each build hashes differently. |
| 96 | func stageArgv0Worker(t *testing.T, b *ExecBackend, host, ver, out string) string { |
| 97 | t.Helper() |
| 98 | vdir := filepath.Join(b.OptDir, "versions", ver) |
| 99 | if err := os.MkdirAll(vdir, 0o755); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | bin := filepath.Join(vdir, "app") |
| 103 | script := "#!/bin/sh\n# build " + ver + "\nprintf '%s\\n' \"$0\" > '" + out + ".tmp' && mv '" + out + ".tmp' '" + out + "'\nexec sleep 30\n" |
| 104 | if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | os.Remove(b.liveLink(host)) |
| 111 | if err := os.Symlink(bin, b.liveLink(host)); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | return bin |
| 115 | } |
| 116 | |
| 117 | func workerRoute(host string) registry.Route { |
| 118 | return registry.Route{Host: host, Kind: "sidecar", Args: `["sidecar","run"]`} |
| 119 | } |
| 120 | |
| 121 | // Spec §7.9: the serving identity is the build captured at exec, so the exec |
| 122 | // must be of the file the identity names, never of live/<host>. |
| 123 | func TestExecBackendWorkerExecsTheStagedPathItResolved(t *testing.T) { |
| 124 | b := testExecBackend(t) |
| 125 | host := "bot.x.internal" |
| 126 | out := filepath.Join(t.TempDir(), "argv0") |
| 127 | bin := stageArgv0Worker(t, b, host, "v1", out) |
| 128 | p, id, err := b.StartWorkerStaged(context.Background(), workerRoute(host), nil, nil) |
| 129 | if err != nil { |
| 130 | t.Fatalf("StartWorkerStaged: %v", err) |
| 131 | } |
| 132 | defer p.Stop(context.Background()) |
| 133 | if got := strings.TrimSpace(waitForFile(t, out, 5*time.Second)); got != bin { |
| 134 | t.Fatalf("$0 = %q; want the staged path %q, never live/%s", got, bin, host) |
| 135 | } |
| 136 | sum, err := release.FileSHA256(bin) |
| 137 | if err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if want := (release.StagedIdentity{Version: "v1", StagedPath: bin, Digest: sum}); id != want { |
| 141 | t.Fatalf("identity = %+v, want %+v", id, want) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // Spec §7.9: a recycle execs the captured build even after root has |
| 146 | // repointed the link. |
| 147 | func TestExecBackendWorkerRecycleExecsTheCapturedBuildAfterARepoint(t *testing.T) { |
| 148 | t.Parallel() |
| 149 | b := testExecBackend(t) |
| 150 | host := "bot.x.internal" |
| 151 | out1 := filepath.Join(t.TempDir(), "argv0-v1") |
| 152 | bin1 := stageArgv0Worker(t, b, host, "v1", out1) |
| 153 | p1, id1, err := b.StartWorkerStaged(context.Background(), workerRoute(host), nil, nil) |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | waitForFile(t, out1, 5*time.Second) |
| 158 | p1.Stop(context.Background()) |
| 159 | os.Remove(out1) |
| 160 | |
| 161 | stageArgv0Worker(t, b, host, "v2", filepath.Join(t.TempDir(), "argv0-v2")) // root installs v2 |
| 162 | |
| 163 | p2, id2, err := b.StartWorkerStaged(context.Background(), workerRoute(host), nil, &id1) |
| 164 | if err != nil { |
| 165 | t.Fatalf("recycle onto v1: %v", err) |
| 166 | } |
| 167 | defer p2.Stop(context.Background()) |
| 168 | if got := strings.TrimSpace(waitForFile(t, out1, 5*time.Second)); got != bin1 { |
| 169 | t.Fatalf("recycle exec'd %q, want the captured %q", got, bin1) |
| 170 | } |
| 171 | if id2 != id1 { |
| 172 | t.Fatalf("recycle reported %+v, want the captured %+v", id2, id1) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // Spec §4.2: a recovery whose staged copy has changed is refused, and nothing |
| 177 | // runs. |
| 178 | func TestExecBackendWorkerRecycleRefusesAChangedStagedCopy(t *testing.T) { |
| 179 | b := testExecBackend(t) |
| 180 | host := "bot.x.internal" |
| 181 | out := filepath.Join(t.TempDir(), "argv0") |
| 182 | bin := stageArgv0Worker(t, b, host, "v1", out) |
| 183 | p1, id1, err := b.StartWorkerStaged(context.Background(), workerRoute(host), nil, nil) |
| 184 | if err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | waitForFile(t, out, 5*time.Second) |
| 188 | p1.Stop(context.Background()) |
| 189 | os.Remove(out) |
| 190 | if err := os.WriteFile(bin, []byte("#!/bin/sh\n# tampered, and longer than the original\nexec sleep 30\n"), 0o755); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | p2, _, err := b.StartWorkerStaged(context.Background(), workerRoute(host), nil, &id1) |
| 194 | if !errors.Is(err, release.ErrStagedCopyChanged) { |
| 195 | t.Fatalf("recycle onto changed bytes = %v, want ErrStagedCopyChanged", err) |
| 196 | } |
| 197 | if p2 != nil { |
| 198 | p2.Stop(context.Background()) |
| 199 | t.Fatal("a refused recycle still spawned a child") |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestSplitBackendForwardsStagedWorkerStarts(t *testing.T) { |
| 204 | exec := &stagedFake{fakeBackend: &fakeBackend{workerSpawnVer: "v1"}} |
| 205 | sb := &SplitBackend{Exec: exec, Unit: &fakeBackend{workerSpawnVer: "u1"}} |
| 206 | rt := testWorkerRoute("bot.x.internal") |
| 207 | onto := fakeIdentity("v0") |
| 208 | if _, id, err := sb.StartWorkerStaged(context.Background(), rt, nil, &onto); err != nil || id != onto { |
| 209 | t.Fatalf("exec route: identity %+v, %v; want %+v forwarded", id, err, onto) |
| 210 | } |
| 211 | if calls := exec.calls(); len(calls) != 1 || calls[0] == nil || *calls[0] != onto { |
| 212 | t.Fatalf("exec backend saw %v, want one call onto v0", calls) |
| 213 | } |
| 214 | unit := rt |
| 215 | unit.Backing = registry.BackingUnit |
| 216 | if _, id, err := sb.StartWorkerStaged(context.Background(), unit, nil, &onto); err != nil || id != (release.StagedIdentity{Version: "u1"}) { |
| 217 | t.Fatalf("a backend without the capability: %+v, %v; want today's StartWorker answer", id, err) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // Ruling R19 item 3: ExecBackend runs the activator's before-exec hook with |
| 222 | // the identity it resolved, before the child exists, and returns that same |
| 223 | // identity. |
| 224 | func TestExecBackendWorkerRunsTheBeforeExecHookFirst(t *testing.T) { |
| 225 | b := testExecBackend(t) |
| 226 | host := "bot.x.internal" |
| 227 | out := filepath.Join(t.TempDir(), "argv0") |
| 228 | stageArgv0Worker(t, b, host, "v1", out) |
| 229 | var seen []release.StagedIdentity |
| 230 | ctx := withBeforeExec(context.Background(), func(id release.StagedIdentity) error { |
| 231 | if _, err := os.Stat(out); err == nil { |
| 232 | t.Error("the child ran before the before-exec hook") |
| 233 | } |
| 234 | seen = append(seen, id) |
| 235 | return nil |
| 236 | }) |
| 237 | p, id, err := b.StartWorkerStaged(ctx, workerRoute(host), nil, nil) |
| 238 | if err != nil { |
| 239 | t.Fatalf("StartWorkerStaged: %v", err) |
| 240 | } |
| 241 | t.Cleanup(func() { p.Stop(context.Background()) }) |
| 242 | if len(seen) != 1 || seen[0] != id { |
| 243 | t.Fatalf("hook saw %+v; want exactly the identity exec'd, %+v", seen, id) |
| 244 | } |
| 245 | waitForFile(t, out, 5*time.Second) |
| 246 | } |
| 247 | |
| 248 | // Ruling R19 item 3: a before-exec hook that fails means no child, for a |
| 249 | // wake from the live link and for a recycle onto a captured build. |
| 250 | func TestExecBackendWorkerDoesNotExecWhenTheHookFails(t *testing.T) { |
| 251 | t.Parallel() |
| 252 | b := testExecBackend(t) |
| 253 | host := "bot.x.internal" |
| 254 | out := filepath.Join(t.TempDir(), "argv0") |
| 255 | stageArgv0Worker(t, b, host, "v1", out) |
| 256 | id, err := release.ResolveLive(b.OptDir, host) |
| 257 | if err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | refuse := errors.New("publication failed") |
| 261 | ctx := withBeforeExec(context.Background(), func(release.StagedIdentity) error { return refuse }) |
| 262 | for name, onto := range map[string]*release.StagedIdentity{"wake": nil, "recycle": &id} { |
| 263 | p, _, err := b.StartWorkerStaged(ctx, workerRoute(host), nil, onto) |
| 264 | if !errors.Is(err, refuse) || p != nil { |
| 265 | t.Fatalf("%s: StartWorkerStaged = %v, %v; want no child and the hook's error", name, p, err) |
| 266 | } |
| 267 | } |
| 268 | time.Sleep(200 * time.Millisecond) |
| 269 | if _, err := os.Stat(out); err == nil { |
| 270 | t.Fatal("a child ran although the before-exec hook failed") |
| 271 | } |
| 272 | } |
| 273 | |