mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: Add database fixtures for testing migrations (#4858)
This commit is contained in:
committed by
GitHub
parent
b97043850b
commit
e906d0dc54
30
coderd/database/migrations/create_fixture.sh
Executable file
30
coderd/database/migrations/create_fixture.sh
Executable file
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Naming the fixture is optional, if missing, the name of the latest
|
||||||
|
# migration will be used.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./create_fixture
|
||||||
|
# ./create_fixture name of fixture
|
||||||
|
# ./create_fixture "name of fixture"
|
||||||
|
# ./create_fixture name_of_fixture
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}")
|
||||||
|
(
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
latest_migration=$(basename "$(find . -maxdepth 1 -name "*.up.sql" | sort -n | tail -n 1)")
|
||||||
|
if [[ -n "${*}" ]]; then
|
||||||
|
name=$*
|
||||||
|
name=${name// /_}
|
||||||
|
num=${latest_migration%%_*}
|
||||||
|
latest_migration="${num}_${name}.up.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename="$(pwd)/testdata/fixtures/$latest_migration"
|
||||||
|
touch "$filename"
|
||||||
|
echo "$filename"
|
||||||
|
echo "Edit fixture and commit it."
|
||||||
|
)
|
@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
@ -160,3 +161,52 @@ func CheckLatestVersion(sourceDriver source.Driver, currentVersion uint) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stepper returns a function that runs SQL migrations one step at a time.
|
||||||
|
//
|
||||||
|
// Stepper cannot be closed pre-emptively, it must be run to completion
|
||||||
|
// (or until an error is encountered).
|
||||||
|
func Stepper(db *sql.DB) (next func() (version uint, more bool, err error), err error) {
|
||||||
|
_, m, err := setup(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("migrate setup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() (version uint, more bool, err error) {
|
||||||
|
defer func() {
|
||||||
|
if !more {
|
||||||
|
srcErr, dbErr := m.Close()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dbErr != nil {
|
||||||
|
err = dbErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = srcErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = m.Steps(1)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, migrate.ErrNoChange):
|
||||||
|
// It's OK if no changes happened!
|
||||||
|
return 0, false, nil
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
// This error is encountered at the of Steps when
|
||||||
|
// reading from embed.FS.
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false, xerrors.Errorf("Step: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, _, err := m.Version()
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, true, nil
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
@ -3,17 +3,27 @@
|
|||||||
package migrations_test
|
package migrations_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
migratepostgres "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
"github.com/golang-migrate/migrate/v4/source"
|
"github.com/golang-migrate/migrate/v4/source"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"github.com/golang-migrate/migrate/v4/source/stub"
|
"github.com/golang-migrate/migrate/v4/source/stub"
|
||||||
|
"github.com/lib/pq"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/goleak"
|
"go.uber.org/goleak"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
|
||||||
"github.com/coder/coder/coderd/database/migrations"
|
"github.com/coder/coder/coderd/database/migrations"
|
||||||
"github.com/coder/coder/coderd/database/postgres"
|
"github.com/coder/coder/coderd/database/postgres"
|
||||||
|
"github.com/coder/coder/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@ -129,3 +139,196 @@ func TestCheckLatestVersion(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupMigrate(t *testing.T, db *sql.DB, name, path string) (source.Driver, *migrate.Migrate) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dbDriver, err := migratepostgres.WithConnection(ctx, conn, &migratepostgres.Config{
|
||||||
|
MigrationsTable: "test_migrate_" + name,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dirFS := os.DirFS(path)
|
||||||
|
d, err := iofs.New(dirFS, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
d.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance(name, d, "", dbDriver)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
m.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return d, m
|
||||||
|
}
|
||||||
|
|
||||||
|
type tableStats struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
s map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tableStats) Add(table string, n int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.s[table] = s.s[table] + n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tableStats) Empty() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
var m []string
|
||||||
|
for table, n := range s.s {
|
||||||
|
if n == 0 {
|
||||||
|
m = append(m, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateUpWithFixtures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
|
||||||
|
// For determining if test case table stats
|
||||||
|
// are used to determine test coverage.
|
||||||
|
useStats bool
|
||||||
|
}
|
||||||
|
tests := []testCase{
|
||||||
|
{
|
||||||
|
name: "fixtures",
|
||||||
|
path: filepath.Join("testdata", "fixtures"),
|
||||||
|
useStats: true,
|
||||||
|
},
|
||||||
|
// More test cases added via glob below.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folders in testdata/full_dumps represent fixtures for a full
|
||||||
|
// deployment of Coder.
|
||||||
|
matches, err := filepath.Glob(filepath.Join("testdata", "full_dumps", "*"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, match := range matches {
|
||||||
|
tests = append(tests, testCase{
|
||||||
|
name: filepath.Base(match),
|
||||||
|
path: match,
|
||||||
|
useStats: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// These tables are allowed to have zero rows for now,
|
||||||
|
// but we should eventually add fixtures for them.
|
||||||
|
ignoredTablesForStats := []string{
|
||||||
|
"audit_logs",
|
||||||
|
"git_auth_links",
|
||||||
|
"group_members",
|
||||||
|
"licenses",
|
||||||
|
"replicas",
|
||||||
|
}
|
||||||
|
s := &tableStats{s: make(map[string]int)}
|
||||||
|
|
||||||
|
// This will run after all subtests have run and fail the test if
|
||||||
|
// new tables have been added without covering them with fixtures.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
emptyTables := s.Empty()
|
||||||
|
slices.Sort(emptyTables)
|
||||||
|
for _, table := range ignoredTablesForStats {
|
||||||
|
i := slices.Index(emptyTables, table)
|
||||||
|
if i >= 0 {
|
||||||
|
emptyTables = slices.Delete(emptyTables, i, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(emptyTables) > 0 {
|
||||||
|
t.Logf("The following tables have zero rows, consider adding fixtures for them or create a full database dump:")
|
||||||
|
t.Errorf("tables have zero rows: %v", emptyTables)
|
||||||
|
t.Logf("See https://github.com/coder/coder/blob/main/docs/CONTRIBUTING.md#database-fixtures-for-testing-migrations for more information")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testSQLDB(t)
|
||||||
|
|
||||||
|
ctx, _ := testutil.Context(t)
|
||||||
|
|
||||||
|
// Prepare database for stepping up.
|
||||||
|
err := migrations.Down(db)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Initialize migrations for fixtures.
|
||||||
|
fDriver, fMigrate := setupMigrate(t, db, tt.name, tt.path)
|
||||||
|
|
||||||
|
nextStep, err := migrations.Stepper(db)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var fixtureVer uint
|
||||||
|
nextFixtureVer, err := fDriver.First()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for {
|
||||||
|
version, more, err := nextStep()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if !more {
|
||||||
|
// We reached the end of the migrations.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextFixtureVer == version {
|
||||||
|
err = fMigrate.Steps(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
fixtureVer = version
|
||||||
|
|
||||||
|
nv, _ := fDriver.Next(nextFixtureVer)
|
||||||
|
if nv > 0 {
|
||||||
|
nextFixtureVer = nv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("migrated to version %d, fixture version %d", version, fixtureVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather number of rows for all existing tables
|
||||||
|
// at the end of the migrations and fixtures.
|
||||||
|
var tables pq.StringArray
|
||||||
|
err = db.QueryRowContext(ctx, `
|
||||||
|
SELECT array_agg(tablename)
|
||||||
|
FROM pg_catalog.pg_tables
|
||||||
|
WHERE
|
||||||
|
schemaname != 'information_schema'
|
||||||
|
AND schemaname != 'pg_catalog'
|
||||||
|
AND tablename NOT LIKE 'test_migrate_%'
|
||||||
|
`).Scan(&tables)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
var count int
|
||||||
|
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table).Scan(&count)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if tt.useStats {
|
||||||
|
s.Add(table, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
911
coderd/database/migrations/testdata/fixtures/000022_initial_v0.6.6.up.sql
vendored
Normal file
911
coderd/database/migrations/testdata/fixtures/000022_initial_v0.6.6.up.sql
vendored
Normal file
File diff suppressed because one or more lines are too long
6
coderd/database/migrations/testdata/fixtures/000024_example.up.sql
vendored
Normal file
6
coderd/database/migrations/testdata/fixtures/000024_example.up.sql
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- Example:
|
||||||
|
-- This fixture is applied after migrations/000024_site_config.up.sql
|
||||||
|
-- and inserts a value into site_configs that must not cause issues in
|
||||||
|
-- future migrations.
|
||||||
|
|
||||||
|
INSERT INTO site_configs (key, value) VALUES ('mytest', 'example');
|
922
coderd/database/migrations/testdata/full_dumps/v0.6.6/000022_dump_v0.6.6.up.sql
vendored
Normal file
922
coderd/database/migrations/testdata/full_dumps/v0.6.6/000022_dump_v0.6.6.up.sql
vendored
Normal file
File diff suppressed because one or more lines are too long
1991
coderd/database/migrations/testdata/full_dumps/v0.7.12/000028_dump_v0.7.12.up.sql
vendored
Normal file
1991
coderd/database/migrations/testdata/full_dumps/v0.7.12/000028_dump_v0.7.12.up.sql
vendored
Normal file
File diff suppressed because one or more lines are too long
2533
coderd/database/migrations/testdata/full_dumps/v0.8.15/000049_dump_v0.8.15.up.sql
vendored
Normal file
2533
coderd/database/migrations/testdata/full_dumps/v0.8.15/000049_dump_v0.8.15.up.sql
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -73,6 +73,77 @@ Use the following `make` commands and scripts in development:
|
|||||||
- `make install` installs binaries to `$GOPATH/bin`
|
- `make install` installs binaries to `$GOPATH/bin`
|
||||||
- `make test`
|
- `make test`
|
||||||
|
|
||||||
|
### Adding database migrations and fixtures
|
||||||
|
|
||||||
|
#### Database migrations
|
||||||
|
|
||||||
|
Database migrations are managed with [`migrate`](https://github.com/golang-migrate/migrate).
|
||||||
|
|
||||||
|
To add new migrations, use the following command:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./coderd/database/migrations/create_fixture.sh my name
|
||||||
|
/home/coder/src/coder/coderd/database/migrations/000070_my_name.up.sql
|
||||||
|
/home/coder/src/coder/coderd/database/migrations/000070_my_name.down.sql
|
||||||
|
Run "make gen" to generate models.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then write queries into the generated `.up.sql` and `.down.sql` files and commit
|
||||||
|
them into the repository. The down script should make a best-effort to retain as
|
||||||
|
much data as possible.
|
||||||
|
|
||||||
|
#### Database fixtures (for testing migrations)
|
||||||
|
|
||||||
|
There are two types of fixtures that are used to test that migrations don't
|
||||||
|
break existing Coder deployments:
|
||||||
|
|
||||||
|
- Partial fixtures [`migrations/testdata/fixtures`](../coderd/database/migrations/testdata/fixtures)
|
||||||
|
- Full database dumps [`migrations/testdata/full_dumps`](../coderd/database/migrations/testdata/full_dumps)
|
||||||
|
|
||||||
|
Both types behave like database migrations (they also [`migrate`](https://github.com/golang-migrate/migrate)). Their behavior mirrors Coder migrations such that when migration
|
||||||
|
number `000022` is applied, fixture `000022` is applied afterwards.
|
||||||
|
|
||||||
|
Partial fixtures are used to conveniently add data to newly created tables so
|
||||||
|
that we can ensure that this data is migrated without issue.
|
||||||
|
|
||||||
|
Full database dumps are for testing the migration of fully-fledged Coder
|
||||||
|
deployments. These are usually done for a specific version of Coder and are
|
||||||
|
often fixed in time. A full database dump may be necessary when testing the
|
||||||
|
migration of multiple features or complex configurations.
|
||||||
|
|
||||||
|
To add a new partial fixture, run the following command:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./coderd/database/migrations/create_fixture.sh my fixture
|
||||||
|
/home/coder/src/coder/coderd/database/migrations/testdata/fixtures/000070_my_fixture.up.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add some queries to insert data and commit the file to the repo. See
|
||||||
|
[`000024_example.up.sql`](../coderd/database/migrations/testdata/fixtures/000024_example.up.sql)
|
||||||
|
for an example.
|
||||||
|
|
||||||
|
To create a full dump, run a fully fledged Coder deployment and use it to
|
||||||
|
generate data in the database. Then shut down the deployment and take a snapshot
|
||||||
|
of the database.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ mkdir -p coderd/database/migrations/testdata/full_dumps/v0.12.2 && cd $_
|
||||||
|
$ pg_dump "postgres://coder@localhost:..." -a --inserts >000069_dump_v0.12.2.up.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure sensitive data in the dump is desensitized, for instance names,
|
||||||
|
emails, OAuth tokens and other secrets. Then commit the dump to the project.
|
||||||
|
|
||||||
|
To find out what the latest migration for a version of Coder is, use the
|
||||||
|
following command:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git ls-files v0.12.2 -- coderd/database/migrations/*.up.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
This helps in naming the dump (e.g. `000069` above).
|
||||||
|
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
Reference in New Issue
Block a user