mirror of
https://github.com/coder/coder.git
synced 2026-06-06 06:28:20 +00:00
49b34a716a
Upgrades to slog v3 which includes a small, but backward incompatible API change to the acceptible call arguments when logging. This change allows us to verify via compile time type checking that arguments are correct and won't cause a panic, as was possible in slog v1, which this replaces (v2 was tagged but never used in coder/coder). It also updates dependencies that also use slog and were updated. I've left the `aibridge` dependency as a commit SHA, under the assumption that the team there (cc @pawbana @dannykopping ) will tag and update the dependency soon and on their own schedule. Other dependencies, I pushed new tags.
212 lines
7.3 KiB
Go
212 lines
7.3 KiB
Go
package dbtestutil
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/sloghuman"
|
|
|
|
"github.com/coder/retry"
|
|
)
|
|
|
|
const (
|
|
cleanerRespOK = "OK"
|
|
envCleanerParentUUID = "DB_CLEANER_PARENT_UUID"
|
|
envCleanerDSN = "DB_CLEANER_DSN"
|
|
envCleanerMagic = "DB_CLEANER_MAGIC"
|
|
envCleanerMagicValue = "XEHdJqWehWek8AaWwopy" // 20 random characters to make this collision resistant
|
|
)
|
|
|
|
func init() {
|
|
// We are hijacking the init() function here to do something very non-standard.
|
|
//
|
|
// We want to be able to run the cleaner as a subprocess of the test process so that it can outlive the test binary
|
|
// and still clean up, even if the test process times out or is killed. So, what we do is in startCleaner() below,
|
|
// which is called in the parent process, we exec our own binary and set a collision-resistant environment variable.
|
|
// Then here in the init(), which will run before main() and therefore before executing tests, we check for the
|
|
// environment variable, and if present we know this is the child process and we exec the cleaner. Instead of
|
|
// returning normally from init() we call os.Exit(). This prevents tests from being re-run in the child process (and
|
|
// recursion).
|
|
//
|
|
// If the magic value is not present, we know we are the parent process and init() returns normally.
|
|
magicValue := os.Getenv(envCleanerMagic)
|
|
if magicValue == envCleanerMagicValue {
|
|
RunCleaner()
|
|
os.Exit(0)
|
|
}
|
|
}
|
|
|
|
// startCleaner starts the cleaner in a subprocess. holdThis is an opaque reference that needs to be kept from being
|
|
// garbage collected until we are done with all test databases (e.g. the end of the process).
|
|
func startCleaner(ctx context.Context, _ TBSubset, parentUUID uuid.UUID, dsn string) (holdThis any, err error) {
|
|
bin, err := os.Executable()
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("could not get executable path: %w", err)
|
|
}
|
|
cmd := exec.Command(bin)
|
|
cmd.Env = append(os.Environ(),
|
|
fmt.Sprintf("%s=%s", envCleanerParentUUID, parentUUID.String()),
|
|
fmt.Sprintf("%s=%s", envCleanerDSN, dsn),
|
|
fmt.Sprintf("%s=%s", envCleanerMagic, envCleanerMagicValue),
|
|
)
|
|
|
|
// Here we don't actually use the reference to the stdin pipe, because we never write anything to it. When this
|
|
// process exits, the pipe is closed by the OS and this triggers the cleaner to do its cleaning work. But, we do
|
|
// need to hang on to a reference to it so that it doesn't get garbage collected and trigger cleanup early.
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to open stdin pipe: %w", err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to open stdout pipe: %w", err)
|
|
}
|
|
// uncomment this to see log output from the cleaner
|
|
// cmd.Stderr = os.Stderr
|
|
err = cmd.Start()
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to start broker: %w", err)
|
|
}
|
|
outCh := make(chan []byte, 1)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
buf := make([]byte, 1024)
|
|
n, readErr := stdout.Read(buf)
|
|
if readErr != nil {
|
|
errCh <- readErr
|
|
return
|
|
}
|
|
outCh <- buf[:n]
|
|
}()
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = cmd.Process.Kill()
|
|
return nil, ctx.Err()
|
|
case err := <-errCh:
|
|
return nil, xerrors.Errorf("failed to read db test cleaner output: %w", err)
|
|
case out := <-outCh:
|
|
if string(out) != cleanerRespOK {
|
|
return nil, xerrors.Errorf("db test cleaner error: %s", string(out))
|
|
}
|
|
return stdin, nil
|
|
}
|
|
}
|
|
|
|
type cleaner struct {
|
|
parentUUID uuid.UUID
|
|
logger slog.Logger
|
|
db *sql.DB
|
|
}
|
|
|
|
func (c *cleaner) init(ctx context.Context) error {
|
|
var err error
|
|
dsn := os.Getenv(envCleanerDSN)
|
|
if dsn == "" {
|
|
return xerrors.Errorf("DSN not set via env %s: %w", envCleanerDSN, err)
|
|
}
|
|
parentUUIDStr := os.Getenv(envCleanerParentUUID)
|
|
c.parentUUID, err = uuid.Parse(parentUUIDStr)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to parse parent UUID '%s': %w", parentUUIDStr, err)
|
|
}
|
|
c.logger = slog.Make(sloghuman.Sink(os.Stderr)).
|
|
Named("dbtestcleaner").
|
|
Leveled(slog.LevelDebug).
|
|
With(slog.F("parent_uuid", parentUUIDStr))
|
|
|
|
c.db, err = sql.Open("postgres", dsn)
|
|
if err != nil {
|
|
return xerrors.Errorf("couldn't open DB: %w", err)
|
|
}
|
|
for r := retry.New(10*time.Millisecond, 500*time.Millisecond); r.Wait(ctx); {
|
|
err = c.db.PingContext(ctx)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
c.logger.Error(ctx, "failed to ping DB", slog.Error(err))
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
// waitAndClean waits for stdin to close then attempts to clean up any test databases with our parent's UUID. This
|
|
// is best-effort. If we hit an error we exit.
|
|
//
|
|
// We log to stderr for debugging, but we don't expect this output to normally be available since the parent has
|
|
// exited. Uncomment the line `cmd.Stderr = os.Stderr` in startCleaner() to see this output.
|
|
func (c *cleaner) waitAndClean() {
|
|
c.logger.Debug(context.Background(), "waiting for stdin to close")
|
|
_, _ = io.ReadAll(os.Stdin) // here we're just waiting for stdin to close
|
|
c.logger.Debug(context.Background(), "stdin closed")
|
|
rows, err := c.db.Query(
|
|
"SELECT name FROM test_databases WHERE process_uuid = $1 AND dropped_at IS NULL",
|
|
c.parentUUID,
|
|
)
|
|
if err != nil {
|
|
c.logger.Error(context.Background(), "error querying test databases", slog.Error(err))
|
|
return
|
|
}
|
|
defer func() {
|
|
_ = rows.Close()
|
|
}()
|
|
names := make([]string, 0)
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
if closeErr := rows.Close(); closeErr != nil {
|
|
c.logger.Error(context.Background(), "error closing rows", slog.Error(closeErr))
|
|
}
|
|
c.logger.Debug(context.Background(), "queried names", slog.F("names", names))
|
|
for _, name := range names {
|
|
_, err := c.db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", name))
|
|
if err != nil {
|
|
c.logger.Error(context.Background(), "error dropping database", slog.Error(err), slog.F("name", name))
|
|
return
|
|
}
|
|
_, err = c.db.Exec("UPDATE test_databases SET dropped_at = CURRENT_TIMESTAMP WHERE name = $1", name)
|
|
if err != nil {
|
|
c.logger.Error(context.Background(), "error dropping database", slog.Error(err), slog.F("name", name))
|
|
return
|
|
}
|
|
}
|
|
c.logger.Debug(context.Background(), "finished cleaning")
|
|
}
|
|
|
|
// RunCleaner runs the test database cleaning process. It takes no arguments but uses stdio and environment variables
|
|
// for its operation.
|
|
//
|
|
// The cleaner is designed to run in a separate process from the main test suite, connected over stdio. If the main test
|
|
// process ends (panics, times out, or is killed) without explicitly discarding the databases it clones, the cleaner
|
|
// removes them so they don't leak beyond the test session. c.f. https://github.com/coder/internal/issues/927
|
|
func RunCleaner() {
|
|
c := cleaner{}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
// canceling a test via the IDE sends us an interrupt signal. We only want to process that signal during init. After
|
|
// we want to ignore the signal and do our cleaning.
|
|
signalCtx, signalCancel := signal.NotifyContext(ctx, os.Interrupt)
|
|
defer signalCancel()
|
|
err := c.init(signalCtx)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(os.Stdout, "failed to init: %s", err.Error())
|
|
_ = os.Stdout.Close()
|
|
return
|
|
}
|
|
_, _ = fmt.Fprint(os.Stdout, cleanerRespOK)
|
|
_ = os.Stdout.Close()
|
|
c.waitAndClean()
|
|
}
|