Skip to content

Commit 1df7589

Browse files
authored
feat(coderd/database/dbtestutil): add ability to dump database on failure (#9704)
Adds dbtestutil.DumpOnFailure() to allow dumping the entire test database contents upon test failure. This does nothing for dbfake currently.
1 parent e6865e0 commit 1df7589

File tree

6 files changed

+114
-51
lines changed

6 files changed

+114
-51
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,6 @@ scaletest/terraform/secrets.tfvars
6464

6565
# Nix
6666
result
67+
68+
# Data dumps from unit tests
69+
**/*.test.sql

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ scaletest/terraform/secrets.tfvars
6767

6868
# Nix
6969
result
70+
71+
# Data dumps from unit tests
72+
**/*.test.sql
7073
# .prettierignore.include:
7174
# Helm templates contain variables that are invalid YAML and can't be formatted
7275
# by Prettier.

coderd/database/dbtestutil/db.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
package dbtestutil
22

33
import (
4+
"bytes"
45
"context"
56
"database/sql"
67
"fmt"
78
"net/url"
89
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"regexp"
913
"strings"
1014
"testing"
15+
"time"
1116

1217
"github.com/stretchr/testify/require"
18+
"golang.org/x/xerrors"
1319

1420
"github.com/coder/coder/v2/coderd/database"
1521
"github.com/coder/coder/v2/coderd/database/dbfake"
@@ -24,6 +30,7 @@ func WillUsePostgres() bool {
2430

2531
type options struct {
2632
fixedTimezone string
33+
dumpOnFailure bool
2734
}
2835

2936
type Option func(*options)
@@ -35,6 +42,13 @@ func WithTimezone(tz string) Option {
3542
}
3643
}
3744

45+
// WithDumpOnFailure will dump the entire database on test failure.
46+
func WithDumpOnFailure() Option {
47+
return func(o *options) {
48+
o.dumpOnFailure = true
49+
}
50+
}
51+
3852
func NewDB(t testing.TB, opts ...Option) (database.Store, pubsub.Pubsub) {
3953
t.Helper()
4054

@@ -74,6 +88,9 @@ func NewDB(t testing.TB, opts ...Option) (database.Store, pubsub.Pubsub) {
7488
t.Cleanup(func() {
7589
_ = sqlDB.Close()
7690
})
91+
if o.dumpOnFailure {
92+
t.Cleanup(func() { DumpOnFailure(t, connectionURL) })
93+
}
7794
db = database.New(sqlDB)
7895

7996
ps, err = pubsub.New(context.Background(), sqlDB, connectionURL)
@@ -110,3 +127,87 @@ func dbNameFromConnectionURL(t testing.TB, connectionURL string) string {
110127
require.NoError(t, err)
111128
return strings.TrimPrefix(u.Path, "/")
112129
}
130+
131+
// DumpOnFailure exports the database referenced by connectionURL to a file
132+
// corresponding to the current test, with a suffix indicating the time the
133+
// test was run.
134+
// To import this into a new database (assuming you have already run make test-postgres-docker):
135+
// - Create a new test database:
136+
// go run ./scripts/migrate-ci/main.go and note the database name it outputs
137+
// - Import the file into the above database:
138+
// psql 'postgres://postgres:postgres@127.0.0.1:5432/<dbname>?sslmode=disable' -f <path to file.test.sql>
139+
// - Run a dev server against that database:
140+
// ./scripts/coder-dev.sh server --postgres-url='postgres://postgres:postgres@127.0.0.1:5432/<dbname>?sslmode=disable'
141+
func DumpOnFailure(t testing.TB, connectionURL string) {
142+
if !t.Failed() {
143+
return
144+
}
145+
cwd, err := filepath.Abs(".")
146+
if err != nil {
147+
t.Errorf("dump on failure: cannot determine current working directory")
148+
return
149+
}
150+
snakeCaseName := regexp.MustCompile("[^a-zA-Z0-9-_]+").ReplaceAllString(t.Name(), "_")
151+
now := time.Now()
152+
timeSuffix := fmt.Sprintf("%d%d%d%d%d%d", now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())
153+
outPath := filepath.Join(cwd, snakeCaseName+"."+timeSuffix+".test.sql")
154+
dump, err := pgDump(connectionURL)
155+
if err != nil {
156+
t.Errorf("dump on failure: failed to run pg_dump")
157+
return
158+
}
159+
if err := os.WriteFile(outPath, filterDump(dump), 0o600); err != nil {
160+
t.Errorf("dump on failure: failed to write: %s", err.Error())
161+
return
162+
}
163+
t.Logf("Dumped database to %q due to failed test. I hope you find what you're looking for!", outPath)
164+
}
165+
166+
// pgDump runs pg_dump against dbURL and returns the output.
167+
func pgDump(dbURL string) ([]byte, error) {
168+
if _, err := exec.LookPath("pg_dump"); err != nil {
169+
return nil, xerrors.Errorf("could not find pg_dump in path: %w", err)
170+
}
171+
cmdArgs := []string{
172+
"pg_dump",
173+
dbURL,
174+
"--data-only",
175+
"--column-inserts",
176+
"--no-comments",
177+
"--no-privileges",
178+
"--no-publication",
179+
"--no-security-labels",
180+
"--no-subscriptions",
181+
"--no-tablespaces",
182+
// "--no-unlogged-table-data", // some tables are unlogged and may contain data of interest
183+
"--no-owner",
184+
"--exclude-table=schema_migrations",
185+
}
186+
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) // nolint:gosec
187+
cmd.Env = []string{
188+
// "PGTZ=UTC", // This is probably not going to be useful if tz has been changed.
189+
"PGCLIENTENCODINDG=UTF8",
190+
"PGDATABASE=", // we should always specify the database name in the connection string
191+
}
192+
var stdout bytes.Buffer
193+
cmd.Stdout = &stdout
194+
if err := cmd.Run(); err != nil {
195+
return nil, xerrors.Errorf("exec pg_dump: %w", err)
196+
}
197+
return stdout.Bytes(), nil
198+
}
199+
200+
func filterDump(dump []byte) []byte {
201+
lines := bytes.Split(dump, []byte{'\n'})
202+
var buf bytes.Buffer
203+
for _, line := range lines {
204+
// We dump in column-insert format, so these are the only lines
205+
// we care about
206+
if !bytes.HasPrefix(line, []byte("INSERT")) {
207+
continue
208+
}
209+
_, _ = buf.Write(line)
210+
_, _ = buf.WriteRune('\n')
211+
}
212+
return buf.Bytes()
213+
}

enterprise/cli/server_dbcrypt_test.go

Lines changed: 1 addition & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func TestServerDBCrypt(t *testing.T) {
3636
connectionURL, closePg, err := postgres.Open()
3737
require.NoError(t, err)
3838
t.Cleanup(closePg)
39+
t.Cleanup(func() { dbtestutil.DumpOnFailure(t, connectionURL) })
3940

4041
sqlDB, err := sql.Open("postgres", connectionURL)
4142
require.NoError(t, err)
@@ -44,13 +45,6 @@ func TestServerDBCrypt(t *testing.T) {
4445
})
4546
db := database.New(sqlDB)
4647

47-
t.Cleanup(func() {
48-
if t.Failed() {
49-
t.Logf("Dumping data due to failed test. I hope you find what you're looking for!")
50-
dumpUsers(t, sqlDB)
51-
}
52-
})
53-
5448
// Populate the database with some unencrypted data.
5549
t.Logf("Generating unencrypted data")
5650
users := genData(t, db)
@@ -250,50 +244,6 @@ func genData(t *testing.T, db database.Store) []database.User {
250244
return users
251245
}
252246

253-
func dumpUsers(t *testing.T, db *sql.DB) {
254-
t.Helper()
255-
rows, err := db.QueryContext(context.Background(), `SELECT
256-
u.id,
257-
u.login_type,
258-
u.status,
259-
u.deleted,
260-
ul.oauth_access_token_key_id AS uloatkid,
261-
ul.oauth_refresh_token_key_id AS ulortkid,
262-
gal.oauth_access_token_key_id AS galoatkid,
263-
gal.oauth_refresh_token_key_id AS galortkid
264-
FROM users u
265-
LEFT OUTER JOIN user_links ul ON u.id = ul.user_id
266-
LEFT OUTER JOIN git_auth_links gal ON u.id = gal.user_id
267-
ORDER BY u.created_at ASC;`)
268-
require.NoError(t, err)
269-
defer rows.Close()
270-
for rows.Next() {
271-
var (
272-
id string
273-
loginType string
274-
status string
275-
deleted bool
276-
UlOatKid sql.NullString
277-
UlOrtKid sql.NullString
278-
GalOatKid sql.NullString
279-
GalOrtKid sql.NullString
280-
)
281-
require.NoError(t, rows.Scan(
282-
&id,
283-
&loginType,
284-
&status,
285-
&deleted,
286-
&UlOatKid,
287-
&UlOrtKid,
288-
&GalOatKid,
289-
&GalOrtKid,
290-
))
291-
t.Logf("user: id:%s login_type:%-8s status:%-9s deleted:%-5t ul_kids{at:%-7s rt:%-7s} gal_kids{at:%-7s rt:%-7s}",
292-
id, loginType, status, deleted, UlOatKid.String, UlOrtKid.String, GalOatKid.String, GalOrtKid.String,
293-
)
294-
}
295-
}
296-
297247
func mustString(t *testing.T, n int) string {
298248
t.Helper()
299249
s, err := cryptorand.String(n)

site/.eslintignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ stats/
6767

6868
# Nix
6969
result
70+
71+
# Data dumps from unit tests
72+
**/*.test.sql
7073
# .prettierignore.include:
7174
# Helm templates contain variables that are invalid YAML and can't be formatted
7275
# by Prettier.

site/.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ stats/
6767

6868
# Nix
6969
result
70+
71+
# Data dumps from unit tests
72+
**/*.test.sql
7073
# .prettierignore.include:
7174
# Helm templates contain variables that are invalid YAML and can't be formatted
7275
# by Prettier.

0 commit comments

Comments
 (0)