| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "syscall" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostbox" |
| 17 | "github.com/carlosframework/platform/internal/instancectl" |
| 18 | ) |
| 19 | |
| 20 | func TestPlan11Rev3ObservesRealInitTenant(t *testing.T) { |
| 21 | raw, err := os.ReadFile("/proc/self/cgroup") |
| 22 | if err != nil || !strings.HasPrefix(string(raw), "0::/") { |
| 23 | t.Skip("no unified cgroup membership") |
| 24 | } |
| 25 | rel := strings.TrimSpace(strings.TrimPrefix(strings.SplitN(string(raw), "\n", 2)[0], "0::/")) |
| 26 | if _, err := os.Stat(filepath.Join("/sys/fs/cgroup", rel)); err != nil { |
| 27 | t.Skip("cgroup tree not visible:", err) |
| 28 | } |
| 29 | binary := filepath.Join(t.TempDir(), "instanceexec.test") |
| 30 | build := exec.Command("go", "test", "-c", "-o", binary, "../instanceexec") |
| 31 | if out, err := build.CombinedOutput(); err != nil { |
| 32 | t.Fatalf("build init helper: %v\n%s", err, out) |
| 33 | } |
| 34 | cmd := exec.Command(binary, "-test.run=^TestPlan11Rev3InitObserveHelper$") |
| 35 | cmd.Env = append(os.Environ(), "CARLOS_REV3_OBSERVE_HELPER=1") |
| 36 | if err := cmd.Start(); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | t.Cleanup(func() { _ = cmd.Process.Signal(syscall.SIGTERM); _ = cmd.Wait() }) |
| 40 | deadline := time.Now().Add(5 * time.Second) |
| 41 | for { |
| 42 | tasks, err := filepath.Glob(filepath.Join("/proc", strconv.Itoa(cmd.Process.Pid), "task", "*", "children")) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | found := false |
| 47 | for _, path := range tasks { |
| 48 | raw, _ := os.ReadFile(path) |
| 49 | for _, child := range strings.Fields(string(raw)) { |
| 50 | if st, err := os.Stat(filepath.Join("/proc", child, "exe")); err == nil { |
| 51 | want, err := os.Stat("/bin/sleep") |
| 52 | if err == nil && os.SameFile(st, want) { |
| 53 | found = true |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | if found { |
| 59 | break |
| 60 | } |
| 61 | if time.Now().After(deadline) { |
| 62 | t.Fatal("real init did not fork tenant") |
| 63 | } |
| 64 | time.Sleep(time.Millisecond) |
| 65 | } |
| 66 | k := linuxClaimLaunchKernel{CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc", BoundApp: "bin/sleep"} |
| 67 | started := instancectl.ClaimUnitStarted{Unit: "u", InvocationID: "i", ControlGroup: rel, MainPID: cmd.Process.Pid} |
| 68 | got, err := k.Observe(started) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | want := plan11LaunchFileID(t, "/bin/sleep") |
| 73 | if got.Running != want || got.Executable != want { |
| 74 | t.Fatalf("observed trampoline instead of tenant: got=%+v tenant=%+v", got, want) |
| 75 | } |
| 76 | mainStart, err := claimLaunchProcessStart("/proc", cmd.Process.Pid) |
| 77 | if err != nil || got.ProcessStart != mainStart { |
| 78 | t.Fatalf("main PID lifetime lost: got=%+v main=%s err=%v", got, mainStart, err) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func rev3ChildFixture(t *testing.T) (linuxClaimLaunchKernel, instancectl.ClaimUnitStarted, func(string, string)) { |
| 83 | t.Helper() |
| 84 | root := t.TempDir() |
| 85 | write := func(path, value string) { |
| 86 | t.Helper() |
| 87 | path = filepath.Join(root, path) |
| 88 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | if err := os.WriteFile(path, []byte(value), 0644); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | } |
| 95 | stat := func(pid, ppid, start string) string { |
| 96 | return pid + " (app) S " + ppid + strings.Repeat(" 0", 17) + " " + start |
| 97 | } |
| 98 | write("proc/100/stat", stat("100", "1", "main-start")) |
| 99 | write("proc/200/stat", stat("200", "100", "child-start")) |
| 100 | write("proc/100/cgroup", "0::/unit\n") |
| 101 | write("proc/200/cgroup", "0::/unit\n") |
| 102 | write("proc/100/task/100/children", "") |
| 103 | write("proc/100/task/101/children", "200") |
| 104 | write("proc/100/root/opt/carlos/app", "tenant") |
| 105 | if err := os.MkdirAll(filepath.Join(root, "proc/200/root/opt/carlos"), 0755); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | if err := os.Link(filepath.Join(root, "proc/100/root/opt/carlos/app"), filepath.Join(root, "proc/200/root/opt/carlos/app")); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if err := os.Link(filepath.Join(root, "proc/100/root/opt/carlos/app"), filepath.Join(root, "proc/200/exe")); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | write("proc/100/exe", "trampoline") |
| 115 | if err := os.MkdirAll(filepath.Join(root, "cgroup/unit"), 0755); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | return linuxClaimLaunchKernel{ProcRoot: filepath.Join(root, "proc"), CgroupRoot: filepath.Join(root, "cgroup")}, instancectl.ClaimUnitStarted{MainPID: 100, ControlGroup: "unit"}, write |
| 119 | } |
| 120 | |
| 121 | func TestPlan11Rev3ChildScope(t *testing.T) { |
| 122 | for _, name := range []string{"valid", "other parent", "other cgroup", "ambiguous", "no tenant", "dead tenant", "other namespace bind"} { |
| 123 | t.Run(name, func(t *testing.T) { |
| 124 | k, s, write := rev3ChildFixture(t) |
| 125 | switch name { |
| 126 | case "other parent": |
| 127 | write("proc/200/stat", "200 (app) S 99"+strings.Repeat(" 0", 17)+" child-start") |
| 128 | case "other cgroup": |
| 129 | write("proc/200/cgroup", "0::/other\n") |
| 130 | case "ambiguous": |
| 131 | write("proc/100/task/100/children", "300") |
| 132 | case "no tenant": |
| 133 | write("proc/100/task/101/children", "") |
| 134 | case "dead tenant": |
| 135 | write("proc/200/stat", "200 (app) Z 100"+strings.Repeat(" 0", 17)+" child-start") |
| 136 | case "other namespace bind": |
| 137 | if err := os.Remove(filepath.Join(k.ProcRoot, "200/root/opt/carlos/app")); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | write("proc/200/root/opt/carlos/app", "other") |
| 141 | } |
| 142 | got, err := k.Observe(s) |
| 143 | if name == "valid" { |
| 144 | if err != nil || got.TenantPID != 200 || got.TenantStart != "child-start" || got.ProcessStart != "main-start" || got.Running != got.Executable { |
| 145 | t.Fatalf("observation=%+v err=%v", got, err) |
| 146 | } |
| 147 | } else if name == "no tenant" { |
| 148 | if !errors.Is(err, errClaimLaunchPending) || got.ProcessStart != "main-start" || got.TenantPID != 0 { |
| 149 | t.Fatalf("pending=%+v err=%v", got, err) |
| 150 | } |
| 151 | } else if err == nil { |
| 152 | t.Fatalf("unsafe child accepted: %+v", got) |
| 153 | } |
| 154 | }) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestPlan11Rev3PinsTenantReadiness(t *testing.T) { |
| 159 | for _, name := range []string{"stable", "changed pid", "reused pid", "disappeared"} { |
| 160 | t.Run(name, func(t *testing.T) { |
| 161 | f := newLaunchFixture(t, servingOp()) |
| 162 | calls := 0 |
| 163 | f.kernel.observe = func(instancectl.ClaimUnitStarted) (ClaimLaunchObserved, error) { |
| 164 | calls++ |
| 165 | got := f.kernel.observed |
| 166 | got.TenantPID, got.TenantStart = 4243, "child-start" |
| 167 | if calls == 1 { |
| 168 | return got, errClaimLaunchPending |
| 169 | } |
| 170 | switch name { |
| 171 | case "changed pid": |
| 172 | got.TenantPID++ |
| 173 | case "reused pid": |
| 174 | got.TenantStart = "other-start" |
| 175 | case "disappeared": |
| 176 | got.TenantPID, got.TenantStart = 0, "" |
| 177 | } |
| 178 | return got, nil |
| 179 | } |
| 180 | got, err := f.root.Serve(t.Context(), f.peer, f.req) |
| 181 | if name == "stable" { |
| 182 | if err != nil || got.Grant == nil || got.Grant.PID != 4242 || got.Grant.ProcessStart != "ps-4242" { |
| 183 | t.Fatalf("main supervision identity changed: %+v %v", got, err) |
| 184 | } |
| 185 | } else if !errors.Is(err, hostbox.ErrChanged) { |
| 186 | t.Fatalf("replacement child granted: %+v %v", got, err) |
| 187 | } |
| 188 | }) |
| 189 | } |
| 190 | } |
| 191 | |