diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 1f7ce48..8194e67 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -448,9 +448,11 @@ PG_LOCKOUT_LINE="# The felis database moved into k3s (felis-postgres); nothing e # The units that write to the database from the host; the move stops them and starts # again the ones that were running. PG_MOVE_TIMERS=(felis-db-backup felis-offsite felis-update-check felis-watchdog) -# Each public table with its exact row count, one per line, in a collation-independent -# order, so the two servers' answers compare byte for byte. -PG_TABLE_COUNTS="SELECT c.relname || ' ' || (xpath('/row/n/text()', query_to_xml(format('SELECT count(*) AS n FROM public.%I', c.relname), false, true, '')))[1]::text FROM pg_class c JOIN pg_namespace s ON s.oid = c.relnamespace WHERE s.nspname = 'public' AND c.relkind IN ('r', 'p') ORDER BY 1 COLLATE \"C\"" +# Each public table with its exact row count, one per line, in byte order of the table names +# whatever the servers' collations (relname is a name, which sorts in collation "C" since +# PostgreSQL 12), so the two servers' answers compare byte for byte. internal/pgint runs it +# on the oldest and newest server the installer meets; the shell tests stub psql. +PG_TABLE_COUNTS="SELECT c.relname || ' ' || (xpath('/row/n/text()', query_to_xml(format('SELECT count(*) AS n FROM public.%I', c.relname), false, true, '')))[1]::text FROM pg_class c JOIN pg_namespace s ON s.oid = c.relnamespace WHERE s.nspname = 'public' AND c.relkind IN ('r', 'p') ORDER BY c.relname" PG_MOVE_STAGE="" PG_MOVE_HBA="" PG_MOVE_UNITS=() diff --git a/internal/pgint/installer_test.go b/internal/pgint/installer_test.go new file mode 100644 index 0000000..aaa059c --- /dev/null +++ b/internal/pgint/installer_test.go @@ -0,0 +1,105 @@ +//go:build pgint + +package pgint + +import ( + "context" + "database/sql" + "fmt" + "os/exec" + "slices" + "sort" + "strings" + "testing" +) + +// The row-count query deploy/bootstrap.sh runs on the host PostgreSQL and on felis-postgres +// when it moves the database into k3s, comparing the two answers byte for byte: every public +// table with its exact row count, one per line, in byte order of the table names. The shell +// tests stub psql, so this is the only place the SQL itself runs before a real host does. +func TestInstallerTableCounts(t *testing.T) { + // bash reads the assignment the way the installer does, quoting and escapes included. + out, err := exec.Command("bash", "-c", `eval "$(grep '^PG_TABLE_COUNTS=' "$1")" && printf %s "$PG_TABLE_COUNTS"`, + "bash", "../../deploy/bootstrap.sh").Output() + if err != nil || len(out) == 0 { + t.Fatalf("reading PG_TABLE_COUNTS from deploy/bootstrap.sh: %v (%q)", err, out) + } + ctx := context.Background() + // Tables the schema does not have yet but a migration may add: a name needing quotes, + // which byte order puts before every lowercase one and a locale's order does not, and a + // partitioned table. + for _, stmt := range []string{ + `CREATE TABLE public."Pgint_Counts" (id int)`, + `INSERT INTO public."Pgint_Counts" VALUES (1), (2), (3)`, + `CREATE TABLE public.pgint_counts_parted (id int) PARTITION BY RANGE (id)`, + `CREATE TABLE public.pgint_counts_parted_all PARTITION OF public.pgint_counts_parted FOR VALUES FROM (0) TO (100)`, + `INSERT INTO public.pgint_counts_parted VALUES (1), (2)`, + } { + if _, err := db.ExecContext(ctx, stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + t.Cleanup(func() { + _, _ = db.ExecContext(context.Background(), `DROP TABLE IF EXISTS public."Pgint_Counts", public.pgint_counts_parted`) + }) + // One snapshot for the query and the counts it is checked against. + tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + + rows, err := tx.QueryContext(ctx, string(out)) + if err != nil { + t.Fatalf("PG_TABLE_COUNTS: %v", err) + } + var got []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + t.Fatal(err) + } + got = append(got, line) + } + if err := rows.Err(); err != nil { + t.Fatalf("PG_TABLE_COUNTS: %v", err) + } + + names, err := publicTables(ctx, tx) + if err != nil { + t.Fatal(err) + } + sort.Strings(names) // byte order, what COLLATE "C" sorts by + var want []string + for _, name := range names { + var n int64 + q := `SELECT count(*) FROM public."` + strings.ReplaceAll(name, `"`, `""`) + `"` + if err := tx.QueryRowContext(ctx, q).Scan(&n); err != nil { + t.Fatal(err) + } + want = append(want, fmt.Sprintf("%s %d", name, n)) + } + if !slices.Contains(want, "Pgint_Counts 3") || !slices.Contains(want, "pgint_counts_parted 2") { + t.Fatalf("the tables this test made are not what it counts: %q", want) + } + if !slices.Equal(got, want) { + t.Fatalf("PG_TABLE_COUNTS returned\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func publicTables(ctx context.Context, tx *sql.Tx) ([]string, error) { + rows, err := tx.QueryContext(ctx, "SELECT tablename FROM pg_tables WHERE schemaname = 'public'") + if err != nil { + return nil, err + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +}