//go:build linux package hostbox import ( "bytes" "context" "database/sql" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" ) // legacyBoundJournals writes one valid legacy launch and its inventory, then // lets the case rewrite the raw rows the way an agent could. func legacyBoundJournals(t *testing.T, in LaunchInventory, mutate func(launches, inventory *sql.DB)) string { t.Helper() legacy := t.TempDir() old, err := OpenLaunchJournal(filepath.Join(legacy, "launches.db")) if err != nil { t.Fatal(err) } if err = old.Create(t.Context(), in); err != nil { t.Fatal(err) } inventory, err := OpenStopInventoryDB(filepath.Join(legacy, "inventory.db")) if err != nil { t.Fatal(err) } if err = inventory.WriteStopInventory(t.Context(), StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"}); err != nil { t.Fatal(err) } if mutate != nil { mutate(old.db, inventory.db) } if err = old.Close(); err != nil { t.Fatal(err) } inventory.Close() return legacy } func padJSON(t *testing.T, v any, size int) []byte { t.Helper() raw, err := json.Marshal(v) if err != nil { t.Fatal(err) } return append(raw, bytes.Repeat([]byte(" "), size-len(raw))...) } func boundExec(t *testing.T, db *sql.DB, q string, args ...any) { t.Helper() if _, err := db.Exec(q, args...); err != nil { t.Fatal(err) } } // R2: a single legacy cell is bounded by SQL before it is scanned into // memory, the legacy files are capped near the import budget, and a schema // that computes columns on read refuses. Every refusal imports nothing, // publishes no marker and leaves no snapshot behind. func TestPlan9Fix1MigrationRefusesUnboundedCells(t *testing.T) { _, _, base := journalFixture(t) inventory := StopInventory{Complete: true, Host: base.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"} longHost := base longHost.Target.Host = strings.Repeat("a", 5000) cases := map[string]func(*testing.T) string{ "launch body over the row cap": func(t *testing.T) string { return legacyBoundJournals(t, base, func(launches, _ *sql.DB) { boundExec(t, launches, "UPDATE launches SET body=?", padJSON(t, base, 4<<20)) }) }, "launch identity over the key cap": func(t *testing.T) string { return legacyBoundJournals(t, base, func(launches, inv *sql.DB) { body, _ := json.Marshal(longHost) boundExec(t, launches, "UPDATE launches SET identity=?, body=?", launchKey(longHost.Target), body) long := inventory long.Host = longHost.Target.Host invBody, _ := json.Marshal(long) boundExec(t, inv, "UPDATE stop_inventory SET host=?, body=?", long.Host, invBody) }) }, "inventory body over the row cap": func(t *testing.T) string { return legacyBoundJournals(t, base, func(_, inv *sql.DB) { boundExec(t, inv, "UPDATE stop_inventory SET body=?", padJSON(t, inventory, 4<<20)) }) }, "inventory host over the key cap": func(t *testing.T) string { return legacyBoundJournals(t, base, func(_, inv *sql.DB) { long := inventory long.Host = longHost.Target.Host body, _ := json.Marshal(long) boundExec(t, inv, "INSERT INTO stop_inventory(host,incarnation,stamp,body) VALUES(?,?,?,?)", long.Host, long.Incarnation, long.InstallationStamp, body) }) }, "generated body column": func(t *testing.T) string { return legacyBoundJournals(t, base, func(launches, _ *sql.DB) { boundExec(t, launches, "ALTER TABLE launches RENAME TO l0") boundExec(t, launches, "CREATE TABLE launches(identity TEXT PRIMARY KEY, raw BLOB NOT NULL, body BLOB GENERATED ALWAYS AS (raw) VIRTUAL)") boundExec(t, launches, "INSERT INTO launches(identity,raw) SELECT identity,body FROM l0") boundExec(t, launches, "DROP TABLE l0") }) }, "legacy file over the size cap": func(t *testing.T) string { legacy := legacyBoundJournals(t, base, nil) if err := os.Truncate(filepath.Join(legacy, "launches.db"), 64<<20+1); err != nil { t.Fatal(err) } return legacy }, } for name, build := range cases { t.Run(name, func(t *testing.T) { s, _, _ := journalFixture(t) legacy := build(t) freeze := func(context.Context) (func(), error) { return func() {}, nil } err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", freeze, func(context.Context, LaunchInventory, StopInventory, StopKernelJournal) error { return nil }) if !errors.Is(err, ErrTypedJournal) { t.Fatalf("unbounded legacy journal: %v", err) } var count int if err = s.Launches.db.QueryRow("SELECT (SELECT count(*) FROM launches)+(SELECT count(*) FROM stop_inventory)+(SELECT count(*) FROM journal_migration)").Scan(&count); err != nil || count != 0 { t.Fatalf("refused migration imported or published: %d %v", count, err) } entries, err := os.ReadDir(filepath.Dir(s.Launches.path)) if err != nil { t.Fatal(err) } for _, entry := range entries { if strings.HasPrefix(entry.Name(), "migration-") { t.Fatalf("snapshot left behind: %s", entry.Name()) } } }) } } // The bounds leave room for every row root's own journal methods write. func TestPlan9Fix1MigrationBoundsAdmitOrdinaryRows(t *testing.T) { s, _, in := journalFixture(t) legacy := legacyBoundJournals(t, in, nil) freeze := func(context.Context) (func(), error) { return func() {}, nil } if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", freeze, func(context.Context, LaunchInventory, StopInventory, StopKernelJournal) error { return nil }); err != nil { t.Fatal(err) } if _, err := s.Launches.Read(t.Context(), in.Target); err != nil { t.Fatal(err) } }