package activator import ( "context" "crypto/sha256" "encoding/hex" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/registry" ) // The attempt's chosen bytes are the bytes that run (spec §7.5 step 3, // §4.2): verifyStaged admits a regular file whose sha256 matches, and // refuses everything it cannot compare. func TestVerifyStaged(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, "app") if err := os.WriteFile(bin, []byte("build v2"), 0o755); err != nil { t.Fatal(err) } sum := sha256.Sum256([]byte("build v2")) good := hex.EncodeToString(sum[:]) if err := verifyStaged(bin, good); err != nil { t.Fatalf("matching digest refused: %v", err) } if err := verifyStaged(bin, strings.ToUpper(good)); err != nil { t.Fatalf("upper-case hex refused: %v", err) } link := filepath.Join(dir, "live") if err := os.Symlink(bin, link); err != nil { t.Fatal(err) } for name, tc := range map[string]struct{ path, digest string }{ "mismatch": {bin, strings.Repeat("0", 64)}, "empty digest": {bin, ""}, "short digest": {bin, "abc"}, "relative path": {"app", good}, "through a symlink": {link, good}, "missing": {filepath.Join(dir, "gone"), good}, "directory": {dir, good}, } { if err := verifyStaged(tc.path, tc.digest); err == nil { t.Errorf("%s: accepted", name) } } } // recordingGenExec is an exec backend that can run staged generations. type recordingGenExec struct { *fakeBackend started int } func (r *recordingGenExec) StartStaged(ctx context.Context, _ registry.Route, g Gen) (Gen, Proc, error) { if err := runExecGate(ctx); err != nil { return g, nil, err } r.started++ return g, &fakeProc{}, nil } func (r *recordingGenExec) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault } func (r *recordingGenExec) GenReady(registry.Route, Gen, Proc) {} func (r *recordingGenExec) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error { return nil } func (r *recordingGenExec) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error { return nil } func (r *recordingGenExec) GenSwitched(registry.Route, Gen, Proc) error { return nil } // Swaps are for exec rows only (spec §2 decision 9): a unit-backed row is // refused, and an exec half that cannot run staged generations refuses // rather than falling back to anything. func TestSplitBackendSwapsAreExecOnly(t *testing.T) { ctx := context.Background() exec := &recordingGenExec{fakeBackend: &fakeBackend{}} s := &SplitBackend{Exec: exec, Unit: &UnitBackend{}} unitRt := registry.Route{Host: "u.example.com", Backing: registry.BackingUnit} if _, _, err := s.StartStaged(ctx, unitRt, Gen{N: 1}); !errors.Is(err, errUnitNoGens) { t.Fatalf("unit row StartStaged: %v, want errUnitNoGens", err) } if err := s.RaiseGenParent(ctx, unitRt, cgroup.TierDefault, cgroup.TierDefault); !errors.Is(err, errUnitNoGens) { t.Fatalf("unit row RaiseGenParent: %v", err) } execRt := registry.Route{Host: "e.example.com"} g, _, err := s.StartStaged(ctx, execRt, Gen{N: 7, StagedPath: "/opt/carlos/versions/v2/app"}) if err != nil || g.N != 7 || exec.started != 1 { t.Fatalf("exec row StartStaged = %+v, %v (started %d)", g, err, exec.started) } plain := &SplitBackend{Exec: &fakeBackend{}, Unit: &UnitBackend{}} if _, _, err := plain.StartStaged(ctx, execRt, Gen{N: 1}); !errors.Is(err, errSwapNoGens) { t.Fatalf("exec half without staged generations: %v, want errSwapNoGens", err) } } // A flat staged generation execs the attempt's staged path, not the live // link, on its own generation socket (spec §7.5 step 3). The fake binary // never binds. What it recorded as argv[0] is the proof. func TestExecBackendStartStagedExecsTheStagedPath(t *testing.T) { b := testExecBackend(t) b.RunDirOverride = t.TempDir() host := "staged.example.com" stageLive(t, b, host, "v1") // the live link names v1 throughout marker := filepath.Join(t.TempDir(), "argv0") v2 := filepath.Join(b.OptDir, "versions", "v2", "app") if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(v2, []byte("#!/bin/sh\nprintf '%s' \"$0\" > '"+marker+"'\n"), 0o755); err != nil { t.Fatal(err) } rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"), Socket: filepath.Join(b.RunDirOverride, host+".sock")} // flat under the override _, proc, err := b.StartStaged(context.Background(), rt, Gen{N: 5, Build: "v2", StagedPath: v2}) if err != nil { t.Fatalf("StartStaged: %v", err) } defer proc.Stop(context.Background()) //nolint:errcheck if got := waitForFile(t, marker, 2*time.Second); got != v2 { t.Fatalf("the generation execd %q, want the staged %q", got, v2) } } // StartStaged is a spawn, not a health check (spec §8.2): it hands back the // running process at once, so the caller's own deadline governs and a build // that never answers is the caller's to record and kill. This runs the real // flat spawn with the real 30 s spawnHealthTimeout. A StartStaged that still // waits for health inside blocks for those 30 s and then returns a nil // handle, which a planned wake reads as "nothing executed". func TestExecBackendStartStagedReturnsTheProcessAtOnce(t *testing.T) { b := testExecBackend(t) b.RunDirOverride = t.TempDir() host := "silent.example.com" stageLive(t, b, host, "v1") v2 := filepath.Join(b.OptDir, "versions", "v2", "app") if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(v2, []byte("#!/bin/sh\nexec sleep 30\n"), 0o755); err != nil { // never binds t.Fatal(err) } rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"), Socket: filepath.Join(b.RunDirOverride, host+".sock")} type out struct { g Gen proc Proc err error } done := make(chan out, 1) go func() { g, p, err := b.StartStaged(context.Background(), rt, Gen{N: 6, Build: "v2", StagedPath: v2}) done <- out{g, p, err} }() var o out select { case o = <-done: case <-time.After(5 * time.Second): t.Fatal("StartStaged waited for health: it must return the running process at once") } if o.err != nil || o.proc == nil { t.Fatalf("StartStaged = %v, %v: want the running process", o.proc, o.err) } defer o.proc.Stop(context.Background()) //nolint:errcheck if o.proc.Exited() { t.Fatal("the returned process has already exited") } if o.g.Socket != b.flatGenSocket(host, 6) { t.Fatalf("Socket = %q, want generation 6's", o.g.Socket) } } // A foreign row keeps its canonical socket (plan 1): a cold, pinned or hold // start execs the staged build there, one generation at a time. Refusing it // would break every planned wake of a foreign row once the flag is on. func TestExecBackendStartStagedRunsAForeignRowOnItsCanonicalSocket(t *testing.T) { b := testExecBackend(t) host := "f.example.com" marker := filepath.Join(t.TempDir(), "argv0") v2 := filepath.Join(b.OptDir, "versions", "v2", "app") if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(v2, []byte("#!/bin/sh\nprintf '%s' \"$0\" > '"+marker+"'\n"), 0o755); err != nil { t.Fatal(err) } rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"), Socket: filepath.Join(t.TempDir(), "odd.sock")} // hand-set: LayoutForeign if l, _ := b.Layout(rt); l != LayoutForeign { t.Fatalf("fixture layout = %v, want LayoutForeign", l) } g, proc, err := b.StartStaged(context.Background(), rt, Gen{N: 1, Build: "v2", StagedPath: v2}) if err != nil { t.Fatalf("foreign row: %v", err) } defer proc.Stop(context.Background()) //nolint:errcheck if g.Socket != rt.Socket || g.Control != rt.Socket+".control" { t.Fatalf("foreign generation sockets %q %q, want the canonical %q", g.Socket, g.Control, rt.Socket) } if got := waitForFile(t, marker, 2*time.Second); got != v2 { t.Fatalf("the generation execd %q, want the staged %q", got, v2) } } // A link left at a foreign row's canonical path by an agent that ran with // generations on would fail the app's bind, so a staged start there clears // it first, as StartGen does. func TestExecBackendStartStagedClearsALeftoverCanonicalLink(t *testing.T) { b := testExecBackend(t) host := "l.example.com" v2 := filepath.Join(b.OptDir, "versions", "v2", "app") if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(v2, []byte("#!/bin/sh\nexec sleep 30\n"), 0o755); err != nil { t.Fatal(err) } rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"), Socket: filepath.Join(t.TempDir(), "odd.sock")} if err := os.Symlink("/nonexistent/g/old.sock", rt.Socket); err != nil { t.Fatal(err) } _, proc, err := b.StartStaged(context.Background(), rt, Gen{N: 2, Build: "v2", StagedPath: v2}) if err != nil { t.Fatalf("foreign row: %v", err) } defer proc.Stop(context.Background()) //nolint:errcheck if _, err := os.Lstat(rt.Socket); !errors.Is(err, os.ErrNotExist) { t.Fatalf("the leftover link at the canonical path survived: %v", err) } } // Each artifact's own tier (spec §7.3): a staged child gets g.Tier, not the // live link's tier, and the overlap parent is the incumbent's tier plus the // successor's. Here the live link (b.Tier) says 512 MiB throughout, while // the incumbent declares children (1 GiB) and the successor does not. Using // b.tier(rt) for both terms gives a 1 GiB parent, and an incumbent that // fills its own 1 GiB is OOM-killed by the successor's first allocation. func TestExecBackendUsesEachArtifactsTier(t *testing.T) { const host = "t.example.com" b, parent := delegatedBackend(t, host) cgroup.FakePlacementForTest(t) // a temp dir is no cgroupfs, and a generation is never moved by PID b.GenContainment = true b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierDefault } b.TierOf = func(_ registry.Route, v string) cgroup.Limit { if v == "v1" { return cgroup.TierChildren } return cgroup.TierDefault } rt := registry.Route{Host: host} if got := b.GenTier(rt, "v1"); got != cgroup.TierChildren { t.Fatalf("GenTier(v1) = %v, want the children tier", got) } if err := b.RaiseGenParent(context.Background(), rt, b.GenTier(rt, "v1"), b.GenTier(rt, "v2")); err != nil { t.Fatal(err) } if got, err := os.ReadFile(filepath.Join(parent, "memory.max")); err != nil || string(got) != "1610612736" { t.Fatalf("overlap parent memory.max = %q, %v; want 1 GiB + 512 MiB", got, err) } cmd, settle, err := b.spawnInCgroupAtTier(rt, "g9", true, cgroup.TierChildren, func() *exec.Cmd { return exec.Command("/bin/true") }, nil) if err != nil { t.Fatal(err) } _ = cmd.Wait() settle() if got, err := os.ReadFile(filepath.Join(parent, "g9", "memory.max")); err != nil || string(got) != "1073741824" { t.Fatalf("g9 memory.max = %q, %v; want its own artifact's 1 GiB", got, err) } if err := b.RestoreGenParent(context.Background(), rt, cgroup.TierChildren); err != nil { t.Fatal(err) } if got, _ := os.ReadFile(filepath.Join(parent, "memory.max")); string(got) != "1073741824" { t.Fatalf("restored parent memory.max = %q, want the current generation's 1 GiB", got) } } // The switch points the canonical path at the new generation's socket // (spec §7.1, rev 15), for dialers outside the agent (#103). func TestExecBackendGenSwitchedRepointsTheCanonicalLink(t *testing.T) { b := testExecBackend(t) dir := t.TempDir() rt := registry.Route{Host: "c.example.com", Socket: filepath.Join(dir, "c.example.com.sock")} for _, n := range []uint64{1, 2} { g := Gen{N: n, Socket: filepath.Join(dir, "g", "c."+strings.Repeat("x", int(n))+".sock")} if err := b.GenSwitched(rt, g, &fakeProc{}); err != nil { t.Fatal(err) } if got, err := os.Readlink(rt.Socket); err != nil || got != g.Socket { t.Fatalf("after switch %d the canonical path names %q (%v), want %q", n, got, err, g.Socket) } } } // Without plan 2's per-generation containment there is no parent to raise, // and the swap is refused before anything spawns. func TestExecBackendRaiseNeedsContainment(t *testing.T) { b := testExecBackend(t) if err := b.RaiseGenParent(context.Background(), registry.Route{Host: "h.example.com"}, cgroup.TierDefault, cgroup.TierDefault); !errors.Is(err, errNoGenContainment) { t.Fatalf("RaiseGenParent without GenContainment: %v", err) } } var ( _ SwapBackend = (*ExecBackend)(nil) _ SwapBackend = (*SplitBackend)(nil) )