| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostguard" |
| 11 | ) |
| 12 | |
| 13 | func roleLaunchFixture(t *testing.T) (*LaunchJournal, LaunchReservation) { |
| 14 | t.Helper() |
| 15 | j, err := OpenLaunchJournal(filepath.Join(t.TempDir(), "launch.db")) |
| 16 | if err != nil { |
| 17 | t.Fatal(err) |
| 18 | } |
| 19 | t.Cleanup(func() { j.Close() }) |
| 20 | target := hostguard.FenceTarget{Region: "eu", Host: "app.example.test", Incarnation: "inc", Stamp: 3, Session: 4, Op: "", Phase: "", Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller", ClaimETag: "etag", DirectoryRevision: "7"} |
| 21 | if err = j.Prelaunch(context.Background(), LaunchInventory{ControllerPID: 41, ProcessStart: "start-41", Target: target, Groups: []LaunchGroup{{RelativePath: "carlos/claim-parent", Device: 1, Inode: 9}}, NoState: true}); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | r := LaunchReservation{ID: "attempt-1:main", Role: "main", InvocationID: "invocation-1", Inputs: "inputs-1", Target: target, Group: LaunchGroup{RelativePath: "carlos.slice/carlos-instances.slice/carlos-instances-app.slice/carlos-instance@app.service"}, Executable: LaunchFile{Device: 5, Inode: 6}} |
| 25 | return j, r |
| 26 | } |
| 27 | |
| 28 | func TestRoleLaunchRequestDecodeIsStrict(t *testing.T) { |
| 29 | _, r := roleLaunchFixture(t) |
| 30 | good, err := EncodeRoleLaunchRequest(RoleLaunchRequest{V: RoleLaunchV, Op: RoleLaunchStart, Reservation: &r, Executable: "/opt/carlos/versions/v1/app", MemoryMax: 1 << 29}) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if q, err := DecodeRoleLaunchRequest(strings.NewReader(string(good))); err != nil || q.Reservation == nil || *q.Reservation != r { |
| 35 | t.Fatalf("round trip=%+v %v", q, err) |
| 36 | } |
| 37 | for name, body := range map[string]string{ |
| 38 | "unknown field": strings.Replace(string(good), `"v":1`, `"v":1,"argv":["/bin/sh"]`, 1), |
| 39 | "trailing value": string(good) + `{}`, |
| 40 | "wrong version": strings.Replace(string(good), `"v":1`, `"v":2`, 1), |
| 41 | "unknown op": strings.Replace(string(good), `"op":"start"`, `"op":"exec"`, 1), |
| 42 | "oversize": string(good) + strings.Repeat(" ", RoleLaunchLimit), |
| 43 | "relative build": strings.Replace(string(good), `/opt/carlos/versions/v1/app`, `opt/app`, 1), |
| 44 | "capability carries": `{"v":1,"op":"capability","executable":"/x"}`, |
| 45 | "stop without id": `{"v":1,"op":"stop"}`, |
| 46 | } { |
| 47 | if _, err := DecodeRoleLaunchRequest(strings.NewReader(body)); !errors.Is(err, ErrRoleLaunchShape) { |
| 48 | t.Fatalf("%s accepted: %v", name, err) |
| 49 | } |
| 50 | } |
| 51 | for name, mutate := range map[string]func(*LaunchReservation){ |
| 52 | "sidecar role": func(r *LaunchReservation) { r.Role = "sidecar" }, |
| 53 | "agent group": func(r *LaunchReservation) { r.Group.Device = 1 }, |
| 54 | "agent state": func(r *LaunchReservation) { r.State = UnitStarted }, |
| 55 | "agent pid": func(r *LaunchReservation) { r.PID = 7 }, |
| 56 | "agent parent": func(r *LaunchReservation) { r.Parent = LaunchGroup{RelativePath: "x", Device: 1, Inode: 1} }, |
| 57 | "missing executable": func(r *LaunchReservation) { r.Executable = LaunchFile{} }, |
| 58 | "missing session": func(r *LaunchReservation) { r.Target.Session = 0 }, |
| 59 | "missing controller": func(r *LaunchReservation) { r.Target.ControllerID = "" }, |
| 60 | "missing inputs": func(r *LaunchReservation) { r.Inputs = "" }, |
| 61 | "unit invocation set": func(r *LaunchReservation) { r.Group.InvocationID = "forged" }, |
| 62 | } { |
| 63 | bad := r |
| 64 | mutate(&bad) |
| 65 | body, err := EncodeRoleLaunchRequest(RoleLaunchRequest{V: RoleLaunchV, Op: RoleLaunchStart, Reservation: &bad, Executable: "/opt/carlos/versions/v1/app", MemoryMax: 1 << 29}) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if _, err := DecodeRoleLaunchRequest(strings.NewReader(string(body))); !errors.Is(err, ErrRoleLaunchShape) { |
| 70 | t.Fatalf("%s accepted: %v", name, err) |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestRoleLaunchGrantMustNameTheReservation(t *testing.T) { |
| 76 | _, r := roleLaunchFixture(t) |
| 77 | g := RoleLaunchGrant{ReservationID: r.ID, Unit: "carlos-instance@app.service", UnitInvocationID: "unit-1", PID: 99, ProcessStart: "123", Group: LaunchGroup{RelativePath: r.Group.RelativePath, Device: 3, Inode: 4, InvocationID: "unit-1"}} |
| 78 | if err := g.Check(r); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | for name, mutate := range map[string]func(*RoleLaunchGrant){ |
| 82 | "other reservation": func(g *RoleLaunchGrant) { g.ReservationID = "attempt-2:main" }, |
| 83 | "other group": func(g *RoleLaunchGrant) { g.Group.RelativePath = "carlos.slice/x.service" }, |
| 84 | "no identity": func(g *RoleLaunchGrant) { g.Group.Inode = 0 }, |
| 85 | "no pid": func(g *RoleLaunchGrant) { g.PID = 0 }, |
| 86 | "invocation split": func(g *RoleLaunchGrant) { g.Group.InvocationID = "unit-2" }, |
| 87 | "no start": func(g *RoleLaunchGrant) { g.ProcessStart = "" }, |
| 88 | } { |
| 89 | bad := g |
| 90 | mutate(&bad) |
| 91 | if err := bad.Check(r); err == nil { |
| 92 | t.Fatalf("%s accepted", name) |
| 93 | } |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestUnitReservationLifecycleIsRootOnlyAndSingleUse(t *testing.T) { |
| 98 | ctx := context.Background() |
| 99 | j, r := roleLaunchFixture(t) |
| 100 | if err := j.IntendUnit(ctx, r); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | // Replays: the same reservation, the same attempt invocation under another |
| 104 | // ID, and a second live unit for the record all refuse. |
| 105 | if err := j.IntendUnit(ctx, r); !errors.Is(err, ErrChanged) { |
| 106 | t.Fatalf("replayed intention: %v", err) |
| 107 | } |
| 108 | other := r |
| 109 | other.ID = "attempt-1b:main" |
| 110 | if err := j.IntendUnit(ctx, other); !errors.Is(err, ErrChanged) { |
| 111 | t.Fatalf("same invocation accepted: %v", err) |
| 112 | } |
| 113 | other.InvocationID = "invocation-2" |
| 114 | if err := j.IntendUnit(ctx, other); !errors.Is(err, ErrChanged) { |
| 115 | t.Fatalf("second live unit accepted: %v", err) |
| 116 | } |
| 117 | // The stop journal never reports a pending unit as settled. |
| 118 | sj, err := j.ReadStopKernelJournal(ctx, r.Target) |
| 119 | if err != nil || sj.Settled { |
| 120 | t.Fatalf("pending unit settled=%v %v", sj.Settled, err) |
| 121 | } |
| 122 | // The agent's journal operations cannot move a unit reservation. |
| 123 | agent := r |
| 124 | agent.Group = LaunchGroup{RelativePath: "carlos/claim-parent/role-x", Device: 1, Inode: 2, InvocationID: r.InvocationID} |
| 125 | if err := j.Reserve(ctx, agent); err == nil { |
| 126 | t.Fatal("agent reserve rebound a unit reservation") |
| 127 | } |
| 128 | if err := j.Started(ctx, r, 99, "123"); err == nil { |
| 129 | t.Fatal("agent started a unit reservation") |
| 130 | } |
| 131 | if err := j.Settled(ctx, r); err == nil { |
| 132 | t.Fatal("agent settled a unit reservation") |
| 133 | } |
| 134 | group := LaunchGroup{RelativePath: r.Group.RelativePath, Device: 3, Inode: 4, InvocationID: "unit-1"} |
| 135 | wrong := r |
| 136 | wrong.Executable.Inode++ |
| 137 | if err := j.StartedUnit(ctx, wrong, group, 99, "123"); !errors.Is(err, ErrChanged) { |
| 138 | t.Fatalf("different executable started: %v", err) |
| 139 | } |
| 140 | if err := j.StartedUnit(ctx, r, LaunchGroup{RelativePath: "carlos.slice/other.service", Device: 3, Inode: 4, InvocationID: "unit-1"}, 99, "123"); err == nil { |
| 141 | t.Fatal("other group started") |
| 142 | } |
| 143 | if err := j.StartedUnit(ctx, r, group, 99, "123"); err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | if err := j.StartedUnit(ctx, r, group, 99, "123"); !errors.Is(err, ErrChanged) { |
| 147 | t.Fatalf("restarted: %v", err) |
| 148 | } |
| 149 | got, err := j.UnitReservation(ctx, r.Target, r.ID) |
| 150 | if err != nil || got.State != UnitStarted || got.Group != group || got.PID != 99 || got.ProcessStart != "123" { |
| 151 | t.Fatalf("started=%+v %v", got, err) |
| 152 | } |
| 153 | if sj, err = j.ReadStopKernelJournal(ctx, r.Target); err != nil || sj.Settled || len(sj.Cgroups) != 1 { |
| 154 | t.Fatalf("started unit leaked into the agent cgroup scan or settled: %+v %v", sj, err) |
| 155 | } |
| 156 | if err := j.SettleUnit(ctx, r.Target, r.ID); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if err := j.SettleUnit(ctx, r.Target, r.ID); err != nil { |
| 160 | t.Fatalf("settle not idempotent: %v", err) |
| 161 | } |
| 162 | if sj, err = j.ReadStopKernelJournal(ctx, r.Target); err != nil || !sj.Settled { |
| 163 | t.Fatalf("settled unit still pending: %+v %v", sj, err) |
| 164 | } |
| 165 | // A settled ID is spent for good. |
| 166 | if err := j.IntendUnit(ctx, r); !errors.Is(err, ErrChanged) { |
| 167 | t.Fatalf("spent reservation reused: %v", err) |
| 168 | } |
| 169 | if err := j.IntendUnit(ctx, other); err != nil { |
| 170 | t.Fatalf("next attempt after settlement: %v", err) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestUnitTombstoneBurnsAnUnseenReservation(t *testing.T) { |
| 175 | ctx := context.Background() |
| 176 | j, r := roleLaunchFixture(t) |
| 177 | if err := j.TombstoneUnit(ctx, r); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | if err := j.IntendUnit(ctx, r); !errors.Is(err, ErrChanged) { |
| 181 | t.Fatalf("start after its stop accepted: %v", err) |
| 182 | } |
| 183 | got, err := j.UnitReservation(ctx, r.Target, r.ID) |
| 184 | if err != nil || got.State != UnitSettled || got.PID != 0 || !got.NeverIntended { |
| 185 | t.Fatalf("tombstone=%+v %v", got, err) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestUnitJournalRefusesRemoteAndMissingRecords(t *testing.T) { |
| 190 | ctx := context.Background() |
| 191 | _, r := roleLaunchFixture(t) |
| 192 | remote, _ := RemoteJournals(func(context.Context, JournalRequest) (JournalResponse, error) { |
| 193 | t.Fatal("unit operation reached the agent journal transport") |
| 194 | return JournalResponse{}, nil |
| 195 | }) |
| 196 | if err := remote.IntendUnit(ctx, r); !errors.Is(err, ErrUnsupported) { |
| 197 | t.Fatalf("remote intention: %v", err) |
| 198 | } |
| 199 | if err := remote.TombstoneUnit(ctx, r); !errors.Is(err, ErrUnsupported) { |
| 200 | t.Fatalf("remote tombstone: %v", err) |
| 201 | } |
| 202 | j, err := OpenLaunchJournal(filepath.Join(t.TempDir(), "launch.db")) |
| 203 | if err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | defer j.Close() |
| 207 | if err := j.IntendUnit(ctx, r); err == nil { |
| 208 | t.Fatal("intention without the prelaunch record") |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestPlan14NeverIntendedIsRootOnly(t *testing.T) { |
| 213 | j, r := roleLaunchFixture(t) |
| 214 | r.NeverIntended = true |
| 215 | if unitReservationShape(r) { |
| 216 | t.Fatal("agent set root cancellation proof") |
| 217 | } |
| 218 | if err := j.TombstoneUnit(t.Context(), r); err == nil { |
| 219 | t.Fatal("forged cancellation proof accepted") |
| 220 | } |
| 221 | if err := j.IntendRole(t.Context(), r); err == nil { |
| 222 | t.Fatal("forged legacy intention accepted") |
| 223 | } |
| 224 | if err := j.Reserve(t.Context(), r); err == nil { |
| 225 | t.Fatal("forged legacy reservation accepted") |
| 226 | } |
| 227 | } |
| 228 | |