Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commite2f5401

Browse files
authored
test: add test database cleaner in subprocess (#19844)
fixescoder/internal#927Adds a small subprocess that outlives the testing process to clean up any leaked test databases.
1 parent596fdcb commite2f5401

File tree

4 files changed

+249
-1
lines changed

4 files changed

+249
-1
lines changed

‎coderd/database/dbtestutil/broker.go‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dbtestutil
22

33
import (
4+
"context"
45
"database/sql"
56
_"embed"
67
"fmt"
@@ -25,6 +26,8 @@ type Broker struct {
2526
uuid uuid.UUID
2627
coderTestingDB*sql.DB
2728
refCountint
29+
// we keep a reference to the stdin of the cleaner so that Go doesn't garbage collect it.
30+
cleanerFDany
2831
}
2932

3033
func (b*Broker)Create(tTBSubset,opts...OpenOption) (ConnectionParams,error) {
@@ -142,7 +145,16 @@ func (b *Broker) init(t TBSubset) error {
142145
returnxerrors.Errorf("ping '%s' database: %w",CoderTestingDBName,err)
143146
}
144147
b.coderTestingDB=coderTestingDB
145-
b.uuid=uuid.New()
148+
149+
ifb.uuid==uuid.Nil {
150+
b.uuid=uuid.New()
151+
ctx,cancel:=context.WithTimeout(context.Background(),20*time.Second)
152+
defercancel()
153+
b.cleanerFD,err=startCleaner(ctx,b.uuid,coderTestingParams.DSN())
154+
iferr!=nil {
155+
returnxerrors.Errorf("start test db cleaner: %w",err)
156+
}
157+
}
146158
returnnil
147159
}
148160

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package dbtestutil
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"os/signal"
11+
"time"
12+
13+
"github.com/google/uuid"
14+
"golang.org/x/xerrors"
15+
16+
"cdr.dev/slog"
17+
"cdr.dev/slog/sloggers/sloghuman"
18+
"github.com/coder/retry"
19+
)
20+
21+
const (
22+
cleanerRespOK="OK"
23+
envCleanerParentUUID="DB_CLEANER_PARENT_UUID"
24+
envCleanerDSN="DB_CLEANER_DSN"
25+
)
26+
27+
var (
28+
originalWorkingDirstring
29+
errGettingWorkingDirerror
30+
)
31+
32+
funcinit() {
33+
// We expect our tests to run from somewhere in the project tree where `go run` below in `startCleaner` will
34+
// be able to resolve the command package. However, some of the tests modify the working directory during the run.
35+
// So, we grab the working directory during package init, before tests are run, and then set that work dir on the
36+
// subcommand process before it starts.
37+
originalWorkingDir,errGettingWorkingDir=os.Getwd()
38+
}
39+
40+
// startCleaner starts the cleaner in a subprocess. holdThis is an opaque reference that needs to be kept from being
41+
// garbage collected until we are done with all test databases (e.g. the end of the process).
42+
funcstartCleaner(ctx context.Context,parentUUID uuid.UUID,dsnstring) (holdThisany,errerror) {
43+
cmd:=exec.Command("go","run","github.com/coder/coder/v2/coderd/database/dbtestutil/cleanercmd")
44+
cmd.Env=append(os.Environ(),
45+
fmt.Sprintf("%s=%s",envCleanerParentUUID,parentUUID.String()),
46+
fmt.Sprintf("%s=%s",envCleanerDSN,dsn),
47+
)
48+
49+
// c.f. comment on `func init()` in this file.
50+
iferrGettingWorkingDir!=nil {
51+
returnnil,xerrors.Errorf("failed to get working directory during init: %w",errGettingWorkingDir)
52+
}
53+
cmd.Dir=originalWorkingDir
54+
55+
// Here we don't actually use the reference to the stdin pipe, because we never write anything to it. When this
56+
// process exits, the pipe is closed by the OS and this triggers the cleaner to do its cleaning work. But, we do
57+
// need to hang on to a reference to it so that it doesn't get garbage collected and trigger cleanup early.
58+
stdin,err:=cmd.StdinPipe()
59+
iferr!=nil {
60+
returnnil,xerrors.Errorf("failed to open stdin pipe: %w",err)
61+
}
62+
stdout,err:=cmd.StdoutPipe()
63+
iferr!=nil {
64+
returnnil,xerrors.Errorf("failed to open stdout pipe: %w",err)
65+
}
66+
// uncomment this to see log output from the cleaner
67+
// cmd.Stderr = os.Stderr
68+
err=cmd.Start()
69+
iferr!=nil {
70+
returnnil,xerrors.Errorf("failed to start broker: %w",err)
71+
}
72+
outCh:=make(chan []byte,1)
73+
errCh:=make(chanerror,1)
74+
gofunc() {
75+
buf:=make([]byte,1024)
76+
n,readErr:=stdout.Read(buf)
77+
ifreadErr!=nil {
78+
errCh<-readErr
79+
return
80+
}
81+
outCh<-buf[:n]
82+
}()
83+
select {
84+
case<-ctx.Done():
85+
_=cmd.Process.Kill()
86+
returnnil,ctx.Err()
87+
caseerr:=<-errCh:
88+
returnnil,xerrors.Errorf("failed to read db test cleaner output: %w",err)
89+
caseout:=<-outCh:
90+
ifstring(out)!=cleanerRespOK {
91+
returnnil,xerrors.Errorf("db test cleaner error: %s",string(out))
92+
}
93+
returnstdin,nil
94+
}
95+
}
96+
97+
typecleanerstruct {
98+
parentUUID uuid.UUID
99+
logger slog.Logger
100+
db*sql.DB
101+
}
102+
103+
func (c*cleaner)init(ctx context.Context)error {
104+
varerrerror
105+
dsn:=os.Getenv(envCleanerDSN)
106+
ifdsn=="" {
107+
returnxerrors.Errorf("DSN not set via env %s: %w",envCleanerDSN,err)
108+
}
109+
parentUUIDStr:=os.Getenv(envCleanerParentUUID)
110+
c.parentUUID,err=uuid.Parse(parentUUIDStr)
111+
iferr!=nil {
112+
returnxerrors.Errorf("failed to parse parent UUID '%s': %w",parentUUIDStr,err)
113+
}
114+
c.logger=slog.Make(sloghuman.Sink(os.Stderr)).
115+
Named("dbtestcleaner").
116+
Leveled(slog.LevelDebug).
117+
With(slog.F("parent_uuid",parentUUIDStr))
118+
119+
c.db,err=sql.Open("postgres",dsn)
120+
iferr!=nil {
121+
returnxerrors.Errorf("couldn't open DB: %w",err)
122+
}
123+
forr:=retry.New(10*time.Millisecond,500*time.Millisecond);r.Wait(ctx); {
124+
err=c.db.PingContext(ctx)
125+
iferr==nil {
126+
returnnil
127+
}
128+
c.logger.Error(ctx,"failed to ping DB",slog.Error(err))
129+
}
130+
returnctx.Err()
131+
}
132+
133+
// waitAndClean waits for stdin to close then attempts to clean up any test databases with our parent's UUID. This
134+
// is best-effort. If we hit an error we exit.
135+
//
136+
// We log to stderr for debugging, but we don't expect this output to normally be available since the parent has
137+
// exited. Uncomment the line `cmd.Stderr = os.Stderr` in startCleaner() to see this output.
138+
func (c*cleaner)waitAndClean() {
139+
c.logger.Debug(context.Background(),"waiting for stdin to close")
140+
_,_=io.ReadAll(os.Stdin)// here we're just waiting for stdin to close
141+
c.logger.Debug(context.Background(),"stdin closed")
142+
rows,err:=c.db.Query(
143+
"SELECT name FROM test_databases WHERE process_uuid = $1 AND dropped_at IS NULL",
144+
c.parentUUID,
145+
)
146+
iferr!=nil {
147+
c.logger.Error(context.Background(),"error querying test databases",slog.Error(err))
148+
return
149+
}
150+
deferfunc() {
151+
_=rows.Close()
152+
}()
153+
names:=make([]string,0)
154+
forrows.Next() {
155+
varnamestring
156+
iferr:=rows.Scan(&name);err!=nil {
157+
continue
158+
}
159+
names=append(names,name)
160+
}
161+
ifcloseErr:=rows.Close();closeErr!=nil {
162+
c.logger.Error(context.Background(),"error closing rows",slog.Error(closeErr))
163+
}
164+
c.logger.Debug(context.Background(),"queried names",slog.F("names",names))
165+
for_,name:=rangenames {
166+
_,err:=c.db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s",name))
167+
iferr!=nil {
168+
c.logger.Error(context.Background(),"error dropping database",slog.Error(err),slog.F("name",name))
169+
return
170+
}
171+
_,err=c.db.Exec("UPDATE test_databases SET dropped_at = CURRENT_TIMESTAMP WHERE name = $1",name)
172+
iferr!=nil {
173+
c.logger.Error(context.Background(),"error dropping database",slog.Error(err),slog.F("name",name))
174+
return
175+
}
176+
}
177+
c.logger.Debug(context.Background(),"finished cleaning")
178+
}
179+
180+
// RunCleaner runs the test database cleaning process. It takes no arguments but uses stdio and environment variables
181+
// for its operation. It is designed to be launched as the only task of a `main()` process, but is included in this
182+
// package to share constants with the parent code that launches it above.
183+
//
184+
// The cleaner is designed to run in a separate process from the main test suite, connected over stdio. If the main test
185+
// process ends (panics, times out, or is killed) without explicitly discarding the databases it clones, the cleaner
186+
// removes them so they don't leak beyond the test session. c.f. https://github.com/coder/internal/issues/927
187+
funcRunCleaner() {
188+
c:=cleaner{}
189+
ctx,cancel:=context.WithTimeout(context.Background(),10*time.Second)
190+
defercancel()
191+
// canceling a test via the IDE sends us an interrupt signal. We only want to process that signal during init. After
192+
// we want to ignore the signal and do our cleaning.
193+
signalCtx,signalCancel:=signal.NotifyContext(ctx,os.Interrupt)
194+
defersignalCancel()
195+
err:=c.init(signalCtx)
196+
iferr!=nil {
197+
_,_=fmt.Fprintf(os.Stdout,"failed to init: %s",err.Error())
198+
_=os.Stdout.Close()
199+
return
200+
}
201+
_,_=fmt.Fprint(os.Stdout,cleanerRespOK)
202+
_=os.Stdout.Close()
203+
c.waitAndClean()
204+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package main
2+
3+
import"github.com/coder/coder/v2/coderd/database/dbtestutil"
4+
5+
funcmain() {
6+
dbtestutil.RunCleaner()
7+
}

‎coderd/database/dbtestutil/postgres_test.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dbtestutil_test
33
import (
44
"database/sql"
55
"testing"
6+
"time"
67

78
_"github.com/lib/pq"
89
"github.com/stretchr/testify/require"
@@ -110,3 +111,27 @@ func TestOpen_ValidDBFrom(t *testing.T) {
110111
require.True(t,rows.Next())
111112
require.NoError(t,rows.Close())
112113
}
114+
115+
funcTestOpen_Panic(t*testing.T) {
116+
t.Skip("unskip this to manually test that we don't leak a database into postgres")
117+
t.Parallel()
118+
if!dbtestutil.WillUsePostgres() {
119+
t.Skip("this test requires postgres")
120+
}
121+
122+
_,err:=dbtestutil.Open(t)
123+
require.NoError(t,err)
124+
panic("now check SELECT datname FROM pg_database;")
125+
}
126+
127+
funcTestOpen_Timeout(t*testing.T) {
128+
t.Skip("unskip this and set a short timeout to manually test that we don't leak a database into postgres")
129+
t.Parallel()
130+
if!dbtestutil.WillUsePostgres() {
131+
t.Skip("this test requires postgres")
132+
}
133+
134+
_,err:=dbtestutil.Open(t)
135+
require.NoError(t,err)
136+
time.Sleep(11*time.Minute)
137+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp