Skip to content

Commit d06e9c1

Browse files
committed
feat(coderd/database/dbtestutil): add ability to dump database on failure
1 parent 72dff7f commit d06e9c1

File tree

3 files changed

+102
-51
lines changed

3 files changed

+102
-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

coderd/database/dbtestutil/db.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
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"
1115

1216
"github.com/stretchr/testify/require"
17+
"golang.org/x/xerrors"
1318

1419
"github.com/coder/coder/v2/coderd/database"
1520
"github.com/coder/coder/v2/coderd/database/dbfake"
@@ -24,6 +29,7 @@ func WillUsePostgres() bool {
2429

2530
type options struct {
2631
fixedTimezone string
32+
dumpOnFailure bool
2733
}
2834

2935
type Option func(*options)
@@ -35,6 +41,13 @@ func WithTimezone(tz string) Option {
3541
}
3642
}
3743

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

@@ -74,6 +87,9 @@ func NewDB(t testing.TB, opts ...Option) (database.Store, pubsub.Pubsub) {
7487
t.Cleanup(func() {
7588
_ = sqlDB.Close()
7689
})
90+
if o.dumpOnFailure {
91+
t.Cleanup(func() { DumpOnFailure(t, connectionURL) })
92+
}
7793
db = database.New(sqlDB)
7894

7995
ps, err = pubsub.New(context.Background(), sqlDB, connectionURL)
@@ -110,3 +126,85 @@ func dbNameFromConnectionURL(t testing.TB, connectionURL string) string {
110126
require.NoError(t, err)
111127
return strings.TrimPrefix(u.Path, "/")
112128
}
129+
130+
func DumpOnFailure(t testing.TB, connectionURL string) {
131+
if !t.Failed() {
132+
return
133+
}
134+
cwd, err := filepath.Abs(".")
135+
if err != nil {
136+
t.Errorf("dump on failure: cannot determine current working directory")
137+
return
138+
}
139+
snakeCaseName := regexp.MustCompile("[^a-zA-Z0-9-_]+").ReplaceAllString(t.Name(), "_")
140+
outPath := filepath.Join(cwd, snakeCaseName+".test.sql")
141+
dump, err := pgDump(connectionURL)
142+
if err != nil {
143+
t.Errorf("dump on failure: failed to run pg_dump")
144+
return
145+
}
146+
if err := os.WriteFile(outPath, filterDump(dump), 0o600); err != nil {
147+
t.Errorf("dump on failure: failed to write: %w", err)
148+
return
149+
}
150+
t.Logf("Dumped database to %q due to failed test. I hope you find what you're looking for!", outPath)
151+
}
152+
153+
// pgDump runs pg_dump against dbURL and returns the output.
154+
func pgDump(dbURL string) ([]byte, error) {
155+
if _, err := exec.LookPath("pg_dump"); err != nil {
156+
return nil, xerrors.Errorf("could not find pg_dump in path: %w", err)
157+
}
158+
cmdArgs := []string{
159+
"pg_dump",
160+
dbURL,
161+
"--data-only",
162+
"--no-comments",
163+
"--no-privileges",
164+
"--no-publication",
165+
"--no-security-labels",
166+
"--no-subscriptions",
167+
"--no-tablespaces",
168+
// "--no-unlogged-table-data", // some tables are unlogged and may contain data of interest
169+
"--no-owner",
170+
"--exclude-table=schema_migrations",
171+
}
172+
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) // nolint:gosec
173+
cmd.Env = []string{
174+
// "PGTZ=UTC", // This is probably not going to be useful if tz has been changed.
175+
"PGCLIENTENCODINDG=UTF8",
176+
"PGDATABASE=", // we should always specify the database name in the connection string
177+
}
178+
var stdout bytes.Buffer
179+
cmd.Stdout = &stdout
180+
if err := cmd.Run(); err != nil {
181+
return nil, xerrors.Errorf("exec pg_dump: %w", err)
182+
}
183+
return stdout.Bytes(), nil
184+
}
185+
186+
func filterDump(dump []byte) []byte {
187+
lines := bytes.Split(dump, []byte{'\n'})
188+
var buf bytes.Buffer
189+
for _, line := range lines {
190+
// Skip blank lines
191+
if len(line) == 0 {
192+
continue
193+
}
194+
// Skip comments
195+
if bytes.HasPrefix(line, []byte("--")) {
196+
continue
197+
}
198+
// Skip SELECT or SET statements
199+
if bytes.HasPrefix(line, []byte("SELECT")) {
200+
continue
201+
}
202+
if bytes.HasPrefix(line, []byte("SET")) {
203+
continue
204+
}
205+
206+
buf.Write(line)
207+
buf.WriteRune('\n')
208+
}
209+
return buf.Bytes()
210+
}

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)

0 commit comments

Comments
 (0)