| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/hostguard" |
| 9 | ) |
| 10 | |
| 11 | func TestLaunchJournalDurableExactReservations(t *testing.T) { |
| 12 | ctx := context.Background() |
| 13 | file := filepath.Join(t.TempDir(), "launch.db") |
| 14 | j, err := OpenLaunchJournal(file) |
| 15 | if err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | target := hostguard.FenceTarget{Host: "app.example.test", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"} |
| 19 | if err = j.Create(ctx, LaunchInventory{ControllerPID: 123, ProcessStart: "456", Target: target, Groups: []LaunchGroup{{RelativePath: "host", Device: 1, Inode: 1}}, NoState: true}); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | reservation := LaunchReservation{ID: "launch", Role: "main", InvocationID: "invocation", Inputs: "inputs", Target: target, Group: LaunchGroup{RelativePath: "host/main", Device: 1, Inode: 2, InvocationID: "invocation"}, Executable: LaunchFile{Device: 1, Inode: 3}} |
| 23 | if err = j.Reserve(ctx, reservation); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if err = j.Close(); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | j, err = OpenLaunchJournal(file) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | defer j.Close() |
| 34 | got, err := j.Read(ctx, target) |
| 35 | if err != nil || len(got.Reservations) != 1 || got.Reservations[0].State != "reserved" { |
| 36 | t.Fatalf("recovery=%+v %v", got, err) |
| 37 | } |
| 38 | wrong := reservation |
| 39 | wrong.Executable.Inode++ |
| 40 | if err = j.Started(ctx, wrong, 123, "12345"); err == nil { |
| 41 | t.Fatal("different executable accepted") |
| 42 | } |
| 43 | if err = j.Reserve(ctx, reservation); err == nil { |
| 44 | t.Fatal("launch ID reused") |
| 45 | } |
| 46 | if err = j.Started(ctx, reservation, 123, "12345"); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if err = j.Settled(ctx, reservation); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | target.Session++ |
| 53 | if _, err = j.Read(ctx, target); err == nil { |
| 54 | t.Fatal("successor read predecessor inventory") |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func TestLaunchJournalPrelaunchRecordIsRecoverableWithoutEffects(t *testing.T) { |
| 59 | ctx := context.Background() |
| 60 | file := filepath.Join(t.TempDir(), "launch.db") |
| 61 | j, err := OpenLaunchJournal(file) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | former := hostguard.FenceTarget{Host: "app.example.test", Incarnation: "inc", Stamp: 7, Session: 7, Box: "box", Enrollment: "enroll", BootID: "boot-1", ControllerID: "controller-1"} |
| 66 | parent := LaunchGroup{RelativePath: "carlos/claim-parent", Device: 1, Inode: 9} |
| 67 | for _, bad := range []LaunchInventory{ |
| 68 | {ControllerPID: 41, ProcessStart: "start-1", Target: former, Groups: []LaunchGroup{parent}, Files: []LaunchFile{{Device: 1, Inode: 2}}}, |
| 69 | {ControllerPID: 41, ProcessStart: "start-1", Target: former, Groups: []LaunchGroup{parent}, NoState: true, ProducerIDs: []string{"replicator"}}, |
| 70 | {ControllerPID: 41, ProcessStart: "start-1", Target: former, Groups: []LaunchGroup{parent, {RelativePath: "carlos/claim-parent/role", Device: 1, Inode: 10}}, NoState: true}, |
| 71 | {ControllerPID: 41, ProcessStart: "start-1", Target: former, NoState: true}, |
| 72 | } { |
| 73 | if err = j.Prelaunch(ctx, bad); err == nil { |
| 74 | t.Fatalf("pre-launch record accepted effects or missing containment: %+v", bad) |
| 75 | } |
| 76 | } |
| 77 | if err = j.Prelaunch(ctx, LaunchInventory{ControllerPID: 41, ProcessStart: "start-1", Target: former, Groups: []LaunchGroup{parent}, NoState: true}); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | successor := former |
| 81 | successor.Session, successor.Stamp, successor.BootID, successor.ControllerID = 8, 8, "boot-2", "controller-2" |
| 82 | if err = j.Prelaunch(ctx, LaunchInventory{ControllerPID: 42, ProcessStart: "start-2", Target: successor, Groups: []LaunchGroup{{RelativePath: "carlos/claim-successor", Device: 1, Inode: 11}}, NoState: true}); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if err = j.Close(); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | // A restarted controller reopens the journal and finds the former tuple. |
| 89 | j, err = OpenLaunchJournal(file) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | defer j.Close() |
| 94 | found, err := j.SessionLaunches(ctx, former.Host, former.Incarnation, former.Stamp, former.Session, former.Box, former.Enrollment) |
| 95 | if err != nil || len(found) != 1 || !SameLaunchOwner(found[0].Target, former) || found[0].ControllerPID != 41 || found[0].ProcessStart != "start-1" { |
| 96 | t.Fatalf("session records = %+v, %v", found, err) |
| 97 | } |
| 98 | stop, err := j.ReadStopKernelJournal(ctx, former) |
| 99 | if err != nil || !stop.Settled || len(stop.Cgroups) != 1 || len(stop.Files) != 0 || len(stop.ProducerIDs) != 0 { |
| 100 | t.Fatalf("pre-launch stop journal = %+v, %v", stop, err) |
| 101 | } |
| 102 | // The record is not an installation effect, so cold handover still accepts it. |
| 103 | if effects, err := j.HasInstallationEffects(ctx, former.Host, former.Incarnation, former.Stamp); err != nil || effects { |
| 104 | t.Fatalf("pre-launch record counted as installation effect: %v %v", effects, err) |
| 105 | } |
| 106 | if err = j.SettleRecovered(ctx, former); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | other, err := j.Read(ctx, successor) |
| 110 | if err != nil || len(other.Reservations) != 0 || !other.NoState { |
| 111 | t.Fatalf("recovering the former tuple changed its successor: %+v %v", other, err) |
| 112 | } |
| 113 | if found, err = j.SessionLaunches(ctx, former.Host, former.Incarnation, former.Stamp, 99, former.Box, former.Enrollment); err != nil || len(found) != 0 { |
| 114 | t.Fatalf("unrelated session matched: %+v %v", found, err) |
| 115 | } |
| 116 | if err = j.RecordFile(ctx, former, LaunchFile{Device: 1, Inode: 12}); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if got, err := j.Read(ctx, former); err != nil || got.NoState || len(got.Files) != 1 { |
| 120 | t.Fatalf("launch files after pre-launch = %+v %v", got, err) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestClaimReviewCompletedProducersAreInstallationEffects(t *testing.T) { |
| 125 | j, err := OpenLaunchJournal(filepath.Join(t.TempDir(), "launch.db")) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | defer j.Close() |
| 130 | target := hostguard.FenceTarget{Host: "app.test", Incarnation: "inc", Stamp: 2, Session: 2, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"} |
| 131 | if err := j.Prelaunch(t.Context(), LaunchInventory{ControllerPID: 123, ProcessStart: "456", Target: target, Groups: []LaunchGroup{{RelativePath: "host", Device: 1, Inode: 1}}, NoState: true}); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if err := j.RecordProducer(t.Context(), target, "delivery"); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if _, err := j.CompleteProducer(t.Context(), target, "delivery", ProducerCompletion{}); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if effects, err := j.HasInstallationEffects(t.Context(), target.Host, target.Incarnation, target.Stamp); err != nil || !effects { |
| 141 | t.Fatalf("completed producer lost from handover check: effects=%v err=%v", effects, err) |
| 142 | } |
| 143 | if _, err := j.db.Exec(`UPDATE launches SET body=json_set(body,'$.Completed.Digest','broken')`); err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | if _, err := j.HasInstallationEffects(t.Context(), target.Host, target.Incarnation, target.Stamp); err == nil { |
| 147 | t.Fatal("invalid completion summary accepted") |
| 148 | } |
| 149 | } |
| 150 | |