| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostguard" |
| 13 | ) |
| 14 | |
| 15 | // plan9Mutation prepares a mutation exactly as the remote client does. |
| 16 | func plan9Mutation(t *testing.T, q JournalRequest) JournalRequest { |
| 17 | t.Helper() |
| 18 | q, err := journalPrepare(q, time.Now()) |
| 19 | if err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | return q |
| 23 | } |
| 24 | |
| 25 | func journalFixture(t *testing.T) (*JournalService, string, LaunchInventory) { |
| 26 | t.Helper() |
| 27 | path := filepath.Join(t.TempDir(), "journal.db") |
| 28 | j, err := OpenLaunchJournal(path) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | t.Cleanup(func() { j.Close() }) |
| 33 | s, err := NewJournalService(j) |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | in := LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller"}, ControllerPID: 1, ProcessStart: "1", NoState: true, Groups: []LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}} |
| 38 | return s, path, in |
| 39 | } |
| 40 | func TestPlan9JournalReplaySurvivesLostReplyAndRestart(t *testing.T) { |
| 41 | s, path, in := journalFixture(t) |
| 42 | q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in}) |
| 43 | first, err := s.Execute(t.Context(), "peer", q) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | if err = s.Launches.Close(); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | j, err := OpenLaunchJournal(path) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | defer j.Close() |
| 55 | s, err = NewJournalService(j) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | replay, err := s.Execute(t.Context(), "peer", q) |
| 60 | if err != nil || replay.ID != first.ID { |
| 61 | t.Fatalf("replay: %+v %v", replay, err) |
| 62 | } |
| 63 | if _, err = s.Execute(t.Context(), "other-peer", q); !errors.Is(err, ErrChanged) { |
| 64 | t.Fatalf("peer alias: %v", err) |
| 65 | } |
| 66 | q.Launch.ProcessStart = "other" |
| 67 | if _, err = s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) { |
| 68 | t.Fatalf("body alias: %v", err) |
| 69 | } |
| 70 | got, err := s.Launches.Read(t.Context(), in.Target) |
| 71 | if err != nil || got.ProcessStart != in.ProcessStart { |
| 72 | t.Fatalf("history changed: %+v %v", got, err) |
| 73 | } |
| 74 | } |
| 75 | func TestPlan9JournalReceiptFailureRollsBackMutation(t *testing.T) { |
| 76 | s, _, in := journalFixture(t) |
| 77 | if _, err := s.Launches.db.Exec("CREATE TRIGGER refuse_receipt BEFORE INSERT ON journal_requests BEGIN SELECT RAISE(ABORT,'injected'); END"); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in}) |
| 81 | if _, err := s.Execute(t.Context(), "peer", q); err == nil { |
| 82 | t.Fatal("receipt failure accepted") |
| 83 | } |
| 84 | if _, err := s.Launches.Read(t.Context(), in.Target); !errors.Is(err, sql.ErrNoRows) { |
| 85 | t.Fatalf("mutation escaped rollback: %v", err) |
| 86 | } |
| 87 | } |
| 88 | func TestPlan9JournalClientUsesTypedRootService(t *testing.T) { |
| 89 | s, _, in := journalFixture(t) |
| 90 | client, inventory := RemoteJournals(func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) }) |
| 91 | if err := client.Create(t.Context(), in); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | if err := client.RecordProducer(t.Context(), in.Target, "p1"); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | done, err := client.CompleteProducer(t.Context(), in.Target, "p1", ProducerCompletion{}) |
| 98 | if err != nil || done.Count != 1 { |
| 99 | t.Fatalf("completion: %+v %v", done, err) |
| 100 | } |
| 101 | got, err := client.Read(t.Context(), in.Target) |
| 102 | if err != nil || got.Completed != done { |
| 103 | t.Fatalf("read: %+v %v", got, err) |
| 104 | } |
| 105 | if found, err := client.HasHost(t.Context(), in.Target.Host); err != nil || !found { |
| 106 | t.Fatalf("has host: %v %v", found, err) |
| 107 | } |
| 108 | rows, err := client.SessionLaunches(t.Context(), in.Target.Host, in.Target.Incarnation, 1, 1, "box", "enrollment") |
| 109 | if err != nil || len(rows) != 1 { |
| 110 | t.Fatalf("session: %v %v", rows, err) |
| 111 | } |
| 112 | inv := StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"} |
| 113 | if err := inventory.WriteStopInventory(t.Context(), inv); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if got, err := inventory.ReadExactStopInventory(t.Context(), in.Target); err != nil || got.Host != inv.Host { |
| 117 | t.Fatalf("inventory: %+v %v", got, err) |
| 118 | } |
| 119 | } |
| 120 | func TestPlan9JournalLateConnectionKeepsDurability(t *testing.T) { |
| 121 | s, _, in := journalFixture(t) |
| 122 | s.Launches.db.SetMaxIdleConns(0) |
| 123 | for i := 0; i < 3; i++ { |
| 124 | var level int |
| 125 | if err := s.Launches.db.QueryRow("PRAGMA synchronous").Scan(&level); err != nil || level != 2 { |
| 126 | t.Fatalf("replacement connection durability: %d %v", level, err) |
| 127 | } |
| 128 | } |
| 129 | if err := s.Launches.Create(t.Context(), in); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if _, err := s.Launches.Read(t.Context(), in.Target); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | } |
| 136 | func TestPlan9RootNamespaceRejectsUntrustedAncestorsAndSymlinks(t *testing.T) { |
| 137 | dir := t.TempDir() |
| 138 | path := filepath.Join(dir, "root") |
| 139 | if err := os.Mkdir(path, 0700); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | if err := PrepareRootJournalDirectory(path); err == nil { |
| 143 | t.Fatal("untrusted ancestor accepted") |
| 144 | } |
| 145 | link := filepath.Join(dir, "link") |
| 146 | if err := os.Symlink(path, link); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | if err := PrepareRootJournalDirectory(link); err == nil { |
| 150 | t.Fatal("symlink namespace accepted") |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // A lost reply leaves the exact request in the uncertain error. Replaying it, |
| 155 | // even after root restarts, returns the committed result once; rebuilding a |
| 156 | // fresh request from the same payload is a distinct call and never a second |
| 157 | // effect. |
| 158 | func TestPlan9JournalClientCrashReplaysRetainedRequest(t *testing.T) { |
| 159 | s, path, in := journalFixture(t) |
| 160 | lost := true |
| 161 | call := func(ctx context.Context, q JournalRequest) (JournalResponse, error) { |
| 162 | out, err := s.Execute(ctx, "box/enrollment", q) |
| 163 | if err != nil { |
| 164 | return out, err |
| 165 | } |
| 166 | if q.Op == "create" && lost { |
| 167 | lost = false |
| 168 | return JournalResponse{}, context.DeadlineExceeded |
| 169 | } |
| 170 | return out, nil |
| 171 | } |
| 172 | client, _ := RemoteJournals(call) |
| 173 | err := client.Create(t.Context(), in) |
| 174 | var uncertain *JournalUncertainError |
| 175 | if !errors.As(err, &uncertain) { |
| 176 | t.Fatalf("reply was not lost: %v", err) |
| 177 | } |
| 178 | if err := s.Launches.Close(); err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | j, err := OpenLaunchJournal(path) |
| 182 | if err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | defer j.Close() |
| 186 | s, err = NewJournalService(j) |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | client, _ = RemoteJournals(call) |
| 191 | if err = client.Create(t.Context(), in); err == nil { |
| 192 | t.Fatal("a rebuilt request repeated the create") |
| 193 | } |
| 194 | out, err := client.ReplayUncertain(t.Context(), uncertain) |
| 195 | if err != nil || out.ID != uncertain.Request.ID { |
| 196 | t.Fatalf("retained request did not replay: %+v %v", out, err) |
| 197 | } |
| 198 | var n int |
| 199 | if err = s.Launches.db.QueryRow("SELECT count(*) FROM launches").Scan(&n); err != nil || n != 1 { |
| 200 | t.Fatalf("duplicate effect: %d %v", n, err) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestPlan9InventoryCrashReplayAndABAUseDurableVersions(t *testing.T) { |
| 205 | s, _, _ := journalFixture(t) |
| 206 | var writes []string |
| 207 | lose := false |
| 208 | call := func(ctx context.Context, q JournalRequest) (JournalResponse, error) { |
| 209 | out, err := s.Execute(ctx, "box/enrollment", q) |
| 210 | if q.Op == "inventory-write" { |
| 211 | writes = append(writes, q.ID) |
| 212 | if lose && err == nil { |
| 213 | lose = false |
| 214 | return JournalResponse{}, context.DeadlineExceeded |
| 215 | } |
| 216 | } |
| 217 | return out, err |
| 218 | } |
| 219 | _, client := RemoteJournals(call) |
| 220 | a := StopInventory{Complete: true, Host: "app.example", Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"} |
| 221 | b := a |
| 222 | b.DirectoryRevision = "2" |
| 223 | if err := client.WriteStopInventory(t.Context(), a); err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | lose = true |
| 227 | err := client.WriteStopInventory(t.Context(), b) |
| 228 | var uncertain *JournalUncertainError |
| 229 | if !errors.As(err, &uncertain) { |
| 230 | t.Fatalf("reply was not uncertain: %v", err) |
| 231 | } |
| 232 | replay, restarted := RemoteJournals(call) |
| 233 | client = restarted |
| 234 | if _, err := replay.ReplayUncertain(t.Context(), uncertain); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | if writes[1] != writes[2] { |
| 238 | t.Fatal("client restart did not recover inventory request") |
| 239 | } |
| 240 | if err := client.WriteStopInventory(t.Context(), a); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if err := client.WriteStopInventory(t.Context(), b); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | if writes[1] == writes[4] { |
| 247 | t.Fatal("ABA reused an older transition ID") |
| 248 | } |
| 249 | got, err := s.Inventory.ReadStopInventory(t.Context(), a.Host) |
| 250 | if err != nil || got.DirectoryRevision != "2" { |
| 251 | t.Fatalf("ABA result: %+v %v", got, err) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func TestPlan9JournalReadsDoNotCreateReceipts(t *testing.T) { |
| 256 | s, _, in := journalFixture(t) |
| 257 | if err := s.Launches.Create(t.Context(), in); err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | client, inventory := RemoteJournals(func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) }) |
| 261 | for i := 0; i < 30; i++ { |
| 262 | if _, err := client.Read(t.Context(), in.Target); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if _, err := client.HasHost(t.Context(), in.Target.Host); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | if _, err := client.SessionLaunches(t.Context(), in.Target.Host, in.Target.Incarnation, 1, 1, "box", "enrollment"); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if _, err := inventory.ReadStopInventory(t.Context(), in.Target.Host); !errors.Is(err, sql.ErrNoRows) { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | } |
| 275 | if _, err := journalCall(t.Context(), func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) }, JournalRequest{Op: "inventory-version", Target: in.Target}); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | var count int |
| 279 | if err := s.Launches.db.QueryRow("SELECT count(*) FROM journal_requests").Scan(&count); err != nil || count != 0 { |
| 280 | t.Fatalf("read receipt growth: %d %v", count, err) |
| 281 | } |
| 282 | // A denied write transaction does not prevent a read-only request. |
| 283 | if _, err := s.Launches.db.Exec("PRAGMA query_only=ON"); err != nil { |
| 284 | t.Fatal(err) |
| 285 | } |
| 286 | defer s.Launches.db.Exec("PRAGMA query_only=OFF") |
| 287 | if _, err := client.Read(t.Context(), in.Target); err != nil { |
| 288 | t.Fatal("read attempted a write", err) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestPlan9JournalRootEnforcesFrozenMutationID(t *testing.T) { |
| 293 | s, _, in := journalFixture(t) |
| 294 | q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in}) |
| 295 | old := q.ID |
| 296 | q.ID = "00112233445566778899aabbccddeeff" |
| 297 | if _, err := s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) { |
| 298 | t.Fatalf("caller-selected mutation id: %v", err) |
| 299 | } |
| 300 | // A v1 (nonce-free) mutation is refused. |
| 301 | v1 := JournalRequest{V: 1, Op: "create", Launch: in} |
| 302 | v1.ID = journalRequestID(v1) |
| 303 | if _, err := s.Execute(t.Context(), "peer", v1); !errors.Is(err, ErrTypedJournal) { |
| 304 | t.Fatalf("nonce-free mutation accepted: %v", err) |
| 305 | } |
| 306 | q.ID = old |
| 307 | if _, err := s.Execute(t.Context(), "peer", q); err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | q.ID = "" |
| 311 | if got := journalRequestID(q); got != old { |
| 312 | t.Fatal("v2 hash depends on the request ID field") |
| 313 | } |
| 314 | q.ID = old |
| 315 | q.Launch.ProcessStart = "changed" |
| 316 | if _, err := s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) { |
| 317 | t.Fatal("changed payload retained old identity", err) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | func TestPlan9JournalV1HashGolden(t *testing.T) { |
| 322 | q := JournalRequest{Op: "producer", Producer: "p"} |
| 323 | if got := journalRequestID(q); got != "3a33e3aaa02b53d4915b743130c01b2a" { |
| 324 | t.Fatalf("v1 identity format changed: %s", got) |
| 325 | } |
| 326 | q.ID = "ignored" |
| 327 | q.Launch.ProducerIDs = []string{} |
| 328 | q.Launch.Files = []LaunchFile{} |
| 329 | if got := journalRequestID(q); got != "3a33e3aaa02b53d4915b743130c01b2a" { |
| 330 | t.Fatalf("equivalent empty fields changed identity: %s", got) |
| 331 | } |
| 332 | } |
| 333 | |