package hostbox import ( "context" "database/sql" "errors" "os" "path/filepath" "testing" "time" "github.com/carlosframework/platform/internal/hostguard" ) // plan9Mutation prepares a mutation exactly as the remote client does. func plan9Mutation(t *testing.T, q JournalRequest) JournalRequest { t.Helper() q, err := journalPrepare(q, time.Now()) if err != nil { t.Fatal(err) } return q } func journalFixture(t *testing.T) (*JournalService, string, LaunchInventory) { t.Helper() path := filepath.Join(t.TempDir(), "journal.db") j, err := OpenLaunchJournal(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { j.Close() }) s, err := NewJournalService(j) if err != nil { t.Fatal(err) } 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}}} return s, path, in } func TestPlan9JournalReplaySurvivesLostReplyAndRestart(t *testing.T) { s, path, in := journalFixture(t) q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in}) first, err := s.Execute(t.Context(), "peer", q) if err != nil { t.Fatal(err) } if err = s.Launches.Close(); err != nil { t.Fatal(err) } j, err := OpenLaunchJournal(path) if err != nil { t.Fatal(err) } defer j.Close() s, err = NewJournalService(j) if err != nil { t.Fatal(err) } replay, err := s.Execute(t.Context(), "peer", q) if err != nil || replay.ID != first.ID { t.Fatalf("replay: %+v %v", replay, err) } if _, err = s.Execute(t.Context(), "other-peer", q); !errors.Is(err, ErrChanged) { t.Fatalf("peer alias: %v", err) } q.Launch.ProcessStart = "other" if _, err = s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) { t.Fatalf("body alias: %v", err) } got, err := s.Launches.Read(t.Context(), in.Target) if err != nil || got.ProcessStart != in.ProcessStart { t.Fatalf("history changed: %+v %v", got, err) } } func TestPlan9JournalReceiptFailureRollsBackMutation(t *testing.T) { s, _, in := journalFixture(t) if _, err := s.Launches.db.Exec("CREATE TRIGGER refuse_receipt BEFORE INSERT ON journal_requests BEGIN SELECT RAISE(ABORT,'injected'); END"); err != nil { t.Fatal(err) } q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in}) if _, err := s.Execute(t.Context(), "peer", q); err == nil { t.Fatal("receipt failure accepted") } if _, err := s.Launches.Read(t.Context(), in.Target); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("mutation escaped rollback: %v", err) } } func TestPlan9JournalClientUsesTypedRootService(t *testing.T) { s, _, in := journalFixture(t) client, inventory := RemoteJournals(func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) }) if err := client.Create(t.Context(), in); err != nil { t.Fatal(err) } if err := client.RecordProducer(t.Context(), in.Target, "p1"); err != nil { t.Fatal(err) } done, err := client.CompleteProducer(t.Context(), in.Target, "p1", ProducerCompletion{}) if err != nil || done.Count != 1 { t.Fatalf("completion: %+v %v", done, err) } got, err := client.Read(t.Context(), in.Target) if err != nil || got.Completed != done { t.Fatalf("read: %+v %v", got, err) } if found, err := client.HasHost(t.Context(), in.Target.Host); err != nil || !found { t.Fatalf("has host: %v %v", found, err) } rows, err := client.SessionLaunches(t.Context(), in.Target.Host, in.Target.Incarnation, 1, 1, "box", "enrollment") if err != nil || len(rows) != 1 { t.Fatalf("session: %v %v", rows, err) } inv := StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"} if err := inventory.WriteStopInventory(t.Context(), inv); err != nil { t.Fatal(err) } if got, err := inventory.ReadExactStopInventory(t.Context(), in.Target); err != nil || got.Host != inv.Host { t.Fatalf("inventory: %+v %v", got, err) } } func TestPlan9JournalLateConnectionKeepsDurability(t *testing.T) { s, _, in := journalFixture(t) s.Launches.db.SetMaxIdleConns(0) for i := 0; i < 3; i++ { var level int if err := s.Launches.db.QueryRow("PRAGMA synchronous").Scan(&level); err != nil || level != 2 { t.Fatalf("replacement connection durability: %d %v", level, err) } } if err := s.Launches.Create(t.Context(), in); err != nil { t.Fatal(err) } if _, err := s.Launches.Read(t.Context(), in.Target); err != nil { t.Fatal(err) } } func TestPlan9RootNamespaceRejectsUntrustedAncestorsAndSymlinks(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "root") if err := os.Mkdir(path, 0700); err != nil { t.Fatal(err) } if err := PrepareRootJournalDirectory(path); err == nil { t.Fatal("untrusted ancestor accepted") } link := filepath.Join(dir, "link") if err := os.Symlink(path, link); err != nil { t.Fatal(err) } if err := PrepareRootJournalDirectory(link); err == nil { t.Fatal("symlink namespace accepted") } } // A lost reply leaves the exact request in the uncertain error. Replaying it, // even after root restarts, returns the committed result once; rebuilding a // fresh request from the same payload is a distinct call and never a second // effect. func TestPlan9JournalClientCrashReplaysRetainedRequest(t *testing.T) { s, path, in := journalFixture(t) lost := true call := func(ctx context.Context, q JournalRequest) (JournalResponse, error) { out, err := s.Execute(ctx, "box/enrollment", q) if err != nil { return out, err } if q.Op == "create" && lost { lost = false return JournalResponse{}, context.DeadlineExceeded } return out, nil } client, _ := RemoteJournals(call) err := client.Create(t.Context(), in) var uncertain *JournalUncertainError if !errors.As(err, &uncertain) { t.Fatalf("reply was not lost: %v", err) } if err := s.Launches.Close(); err != nil { t.Fatal(err) } j, err := OpenLaunchJournal(path) if err != nil { t.Fatal(err) } defer j.Close() s, err = NewJournalService(j) if err != nil { t.Fatal(err) } client, _ = RemoteJournals(call) if err = client.Create(t.Context(), in); err == nil { t.Fatal("a rebuilt request repeated the create") } out, err := client.ReplayUncertain(t.Context(), uncertain) if err != nil || out.ID != uncertain.Request.ID { t.Fatalf("retained request did not replay: %+v %v", out, err) } var n int if err = s.Launches.db.QueryRow("SELECT count(*) FROM launches").Scan(&n); err != nil || n != 1 { t.Fatalf("duplicate effect: %d %v", n, err) } } func TestPlan9InventoryCrashReplayAndABAUseDurableVersions(t *testing.T) { s, _, _ := journalFixture(t) var writes []string lose := false call := func(ctx context.Context, q JournalRequest) (JournalResponse, error) { out, err := s.Execute(ctx, "box/enrollment", q) if q.Op == "inventory-write" { writes = append(writes, q.ID) if lose && err == nil { lose = false return JournalResponse{}, context.DeadlineExceeded } } return out, err } _, client := RemoteJournals(call) a := StopInventory{Complete: true, Host: "app.example", Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"} b := a b.DirectoryRevision = "2" if err := client.WriteStopInventory(t.Context(), a); err != nil { t.Fatal(err) } lose = true err := client.WriteStopInventory(t.Context(), b) var uncertain *JournalUncertainError if !errors.As(err, &uncertain) { t.Fatalf("reply was not uncertain: %v", err) } replay, restarted := RemoteJournals(call) client = restarted if _, err := replay.ReplayUncertain(t.Context(), uncertain); err != nil { t.Fatal(err) } if writes[1] != writes[2] { t.Fatal("client restart did not recover inventory request") } if err := client.WriteStopInventory(t.Context(), a); err != nil { t.Fatal(err) } if err := client.WriteStopInventory(t.Context(), b); err != nil { t.Fatal(err) } if writes[1] == writes[4] { t.Fatal("ABA reused an older transition ID") } got, err := s.Inventory.ReadStopInventory(t.Context(), a.Host) if err != nil || got.DirectoryRevision != "2" { t.Fatalf("ABA result: %+v %v", got, err) } } func TestPlan9JournalReadsDoNotCreateReceipts(t *testing.T) { s, _, in := journalFixture(t) if err := s.Launches.Create(t.Context(), in); err != nil { t.Fatal(err) } client, inventory := RemoteJournals(func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) }) for i := 0; i < 30; i++ { if _, err := client.Read(t.Context(), in.Target); err != nil { t.Fatal(err) } if _, err := client.HasHost(t.Context(), in.Target.Host); err != nil { t.Fatal(err) } if _, err := client.SessionLaunches(t.Context(), in.Target.Host, in.Target.Incarnation, 1, 1, "box", "enrollment"); err != nil { t.Fatal(err) } if _, err := inventory.ReadStopInventory(t.Context(), in.Target.Host); !errors.Is(err, sql.ErrNoRows) { t.Fatal(err) } } 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 { t.Fatal(err) } var count int if err := s.Launches.db.QueryRow("SELECT count(*) FROM journal_requests").Scan(&count); err != nil || count != 0 { t.Fatalf("read receipt growth: %d %v", count, err) } // A denied write transaction does not prevent a read-only request. if _, err := s.Launches.db.Exec("PRAGMA query_only=ON"); err != nil { t.Fatal(err) } defer s.Launches.db.Exec("PRAGMA query_only=OFF") if _, err := client.Read(t.Context(), in.Target); err != nil { t.Fatal("read attempted a write", err) } } func TestPlan9JournalRootEnforcesFrozenMutationID(t *testing.T) { s, _, in := journalFixture(t) q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in}) old := q.ID q.ID = "00112233445566778899aabbccddeeff" if _, err := s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) { t.Fatalf("caller-selected mutation id: %v", err) } // A v1 (nonce-free) mutation is refused. v1 := JournalRequest{V: 1, Op: "create", Launch: in} v1.ID = journalRequestID(v1) if _, err := s.Execute(t.Context(), "peer", v1); !errors.Is(err, ErrTypedJournal) { t.Fatalf("nonce-free mutation accepted: %v", err) } q.ID = old if _, err := s.Execute(t.Context(), "peer", q); err != nil { t.Fatal(err) } q.ID = "" if got := journalRequestID(q); got != old { t.Fatal("v2 hash depends on the request ID field") } q.ID = old q.Launch.ProcessStart = "changed" if _, err := s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) { t.Fatal("changed payload retained old identity", err) } } func TestPlan9JournalV1HashGolden(t *testing.T) { q := JournalRequest{Op: "producer", Producer: "p"} if got := journalRequestID(q); got != "3a33e3aaa02b53d4915b743130c01b2a" { t.Fatalf("v1 identity format changed: %s", got) } q.ID = "ignored" q.Launch.ProducerIDs = []string{} q.Launch.Files = []LaunchFile{} if got := journalRequestID(q); got != "3a33e3aaa02b53d4915b743130c01b2a" { t.Fatalf("equivalent empty fields changed identity: %s", got) } }