refactor(schema): Add support for migration editions

This refactors the schema package and subpackages to support editions.
An Edition is a collection of sql migrations. A given boundary binary
can have multiple migration editions. The `schema.Manager` is updated to
apply the necessary migrations in the correct order, leveraging an
Edition's priority to determine the order in which the separate Editions
are applied.

The sql migration files are now nested under a directory for the
edition.

This also switches from the generated code to using embed, which
eliminates the need for the `make migrations` target and the generated
code. As such this commit removes the target and generated code.

See:
    https://pkg.go.dev/embed
This commit is contained in:
Timothy Messier committed 2021-10-08 06:50:26 -04:00
1 parent 217adfc4d2
commit 8caaf3e076
144 files changed
+3469 -11098

No files matched your search

@@ -0,0 +1,3 @@
// Package postgres provides an implementation of the schema.driver interface
// for a PostgreSQL database.
package postgres
@@ -0,0 +1,111 @@
package postgres_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEnsureVersion(t *testing.T) {
ctx := context.Background()
p, _, _ := setup(ctx, t)
err := p.StartRun(ctx)
require.NoError(t, err)
err = p.EnsureVersionTable(ctx)
require.NoError(t, err)
err = p.CommitRun(ctx)
require.NoError(t, err)
}
func TestEnsureVersion_NoTxn(t *testing.T) {
ctx := context.Background()
p, _, _ := setup(ctx, t)
err := p.EnsureVersionTable(ctx)
require.Error(t, err)
}
func TestEnsureVersion_UpdateForEditionSupport(t *testing.T) {
ctx := context.Background()
p, db, _ := setup(ctx, t)
_, err := db.ExecContext(ctx, `
create table boundary_schema_version (
version bigint primary key,
dirty boolean not null
);`)
require.NoError(t, err)
_, err = db.ExecContext(ctx, `
insert into boundary_schema_version
(version, dirty)
values
(1001, false);`)
require.NoError(t, err)
err = p.StartRun(ctx)
require.NoError(t, err)
err = p.EnsureVersionTable(ctx)
require.NoError(t, err)
err = p.CommitRun(ctx)
require.NoError(t, err)
var edition string
var version int
err = db.QueryRowContext(
ctx,
`select edition, version from boundary_schema_version`,
).Scan(&edition, &version)
require.NoError(t, err)
assert.Equal(t, edition, "oss")
assert.Equal(t, version, 1001)
}
func TestEnsureVersion_RenameOldSchemaTable(t *testing.T) {
ctx := context.Background()
p, db, _ := setup(ctx, t)
_, err := db.ExecContext(ctx, `
create table schema_migrations (
version bigint primary key,
dirty boolean not null
);`)
require.NoError(t, err)
require.NoError(t, err)
_, err = db.ExecContext(ctx, `
insert into schema_migrations
(version, dirty)
values
(1001, false);`)
require.NoError(t, err)
err = p.StartRun(ctx)
require.NoError(t, err)
err = p.EnsureVersionTable(ctx)
require.NoError(t, err)
err = p.CommitRun(ctx)
require.NoError(t, err)
var edition string
var version int
err = db.QueryRowContext(
ctx,
`select edition, version from boundary_schema_version`,
).Scan(&edition, &version)
require.NoError(t, err)
assert.Equal(t, edition, "oss")
assert.Equal(t, version, 1001)
}
@@ -0,0 +1,66 @@
// The MIT License (MIT)
//
// Original Work
// Copyright (c) 2016 Matthias Kadenbach
// https://github.com/mattes/migrate
//
// Modified Work
// Copyright (c) 2018 Dale Hui
// https://github.com/golang-migrate/migrate
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package postgres
import "strings"
const newLine = '\n'
func runesCount(input []rune, target rune) int {
var count int
for _, r := range input {
if r == target {
count++
}
}
return count
}
func runesLastIndex(input []rune, target rune) int {
for i := len(input) - 1; i >= 0; i-- {
if input[i] == target {
return i
}
}
return -1
}
func computeLineFromPos(s string, pos int) (line uint, col uint, ok bool) {
// replace crlf with lf
s = strings.Replace(s, "\r\n", "\n", -1)
// pg docs: pos uses index 1 for the first character, and positions are measured in characters not bytes
runes := []rune(s)
if pos > len(runes) {
return 0, 0, false
}
sel := runes[:pos]
line = uint(runesCount(sel, newLine) + 1)
col = uint(pos - 1 - runesLastIndex(sel, newLine))
return line, col, true
}
@@ -0,0 +1,115 @@
// The MIT License (MIT)
//
// Original Work
// Copyright (c) 2016 Matthias Kadenbach
// https://github.com/mattes/migrate
//
// Modified Work
// Copyright (c) 2018 Dale Hui
// https://github.com/golang-migrate/migrate
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package postgres
import (
"strconv"
"strings"
"testing"
)
func Test_computeLineFromPos(t *testing.T) {
testcases := []struct {
pos int
wantLine uint
wantCol uint
input string
wantOk bool
}{
{
15, 2, 6, "SELECT *\nFROM foo", true, // foo table does not exists
},
{
16, 3, 6, "SELECT *\n\nFROM foo", true, // foo table does not exists, empty line
},
{
25, 3, 7, "SELECT *\nFROM foo\nWHERE x", true, // x column error
},
{
27, 5, 7, "SELECT *\n\nFROM foo\n\nWHERE x", true, // x column error, empty lines
},
{
10, 2, 1, "SELECT *\nFROMM foo", true, // FROMM typo
},
{
11, 3, 1, "SELECT *\n\nFROMM foo", true, // FROMM typo, empty line
},
{
17, 2, 8, "SELECT *\nFROM foo", true, // last character
},
{
18, 0, 0, "SELECT *\nFROM foo", false, // invalid position
},
}
for i, tc := range testcases {
t.Run("tc"+strconv.Itoa(i), func(t *testing.T) {
run := func(crlf bool, nonASCII bool) {
var name string
if crlf {
name = "crlf"
} else {
name = "lf"
}
if nonASCII {
name += "-nonascii"
} else {
name += "-ascii"
}
t.Run(name, func(t *testing.T) {
input := tc.input
if crlf {
input = strings.Replace(input, "\n", "\r\n", -1)
}
if nonASCII {
input = strings.Replace(input, "FROM", "FRÖM", -1)
}
gotLine, gotCol, gotOK := computeLineFromPos(input, tc.pos)
if tc.wantOk {
t.Logf("pos %d, want %d:%d, %#v", tc.pos, tc.wantLine, tc.wantCol, input)
}
if gotOK != tc.wantOk {
t.Fatalf("expected ok %v but got %v", tc.wantOk, gotOK)
}
if gotLine != tc.wantLine {
t.Fatalf("expected line %d but got %d", tc.wantLine, gotLine)
}
if gotCol != tc.wantCol {
t.Fatalf("expected col %d but got %d", tc.wantCol, gotCol)
}
})
}
run(false, false)
run(true, false)
run(false, true)
run(true, true)
})
}
}
@@ -0,0 +1,152 @@
package postgres_test
import (
"context"
"testing"
"time"
"github.com/hashicorp/boundary/internal/db/common"
"github.com/hashicorp/boundary/internal/db/schema/internal/postgres"
"github.com/hashicorp/boundary/testing/dbtest"
"github.com/stretchr/testify/require"
)
// Tests that TrySharedLock:
// - can get a lock as the only connection
// - a second connection can also get the shared lock
// - a second connection cannot get an exclusive lock
func TestTrySharedLock(t *testing.T) {
t.Parallel()
ctx := context.Background()
p, _, u := setup(ctx, t)
err := p.TrySharedLock(ctx)
require.NoError(t, err)
d2, err := common.SqlOpen(dbtest.Postgres, u)
p2, err := postgres.New(ctx, d2)
require.NoError(t, err)
err = p2.TrySharedLock(ctx)
require.NoError(t, err)
err = p2.TryLock(ctx)
require.Error(t, err)
}
// Test that TryLock gets an exclusive lock and:
// - a separate connection cannot get the lock via TryLock
// - a separate connection cannot get the lock via TrySharedLock
// - a separate connection cannot get the lock via Lock
func TestTryLock(t *testing.T) {
t.Parallel()
ctx := context.Background()
p, _, u := setup(ctx, t)
err := p.TryLock(ctx)
require.NoError(t, err)
d2, err := common.SqlOpen(dbtest.Postgres, u)
p2, err := postgres.New(ctx, d2)
require.NoError(t, err)
err = p2.TrySharedLock(ctx)
require.Error(t, err)
err = p2.TryLock(ctx)
require.Error(t, err)
ctx2, _ := context.WithTimeout(ctx, time.Millisecond)
err = p2.Lock(ctx2)
require.Error(t, err)
}
// Test that Lock gets an exclusive lock and:
// - a separate connection cannot get the lock via TryLock
// - a separate connection cannot get the lock via TrySharedLock
// - a separate connection cannot get the lock via Lock
func TestLock(t *testing.T) {
t.Parallel()
ctx := context.Background()
p, _, u := setup(ctx, t)
err := p.Lock(ctx)
require.NoError(t, err)
d2, err := common.SqlOpen(dbtest.Postgres, u)
p2, err := postgres.New(ctx, d2)
require.NoError(t, err)
err = p2.TrySharedLock(ctx)
require.Error(t, err)
err = p2.TryLock(ctx)
require.Error(t, err)
ctx2, _ := context.WithTimeout(ctx, time.Millisecond)
err = p2.Lock(ctx2)
require.Error(t, err)
}
// Test the Unlock:
// - can unlock a lock granted via TryLock
// - can unlock a lock granted via Lock
func TestUnlock(t *testing.T) {
t.Parallel()
ctx := context.Background()
p, _, u := setup(ctx, t)
err := p.Lock(ctx)
require.NoError(t, err)
d2, err := common.SqlOpen(dbtest.Postgres, u)
p2, err := postgres.New(ctx, d2)
require.NoError(t, err)
// cannot get lock since p has it.
err = p2.TryLock(ctx)
require.Error(t, err)
// clear lock on p
err = p.Unlock(ctx)
require.NoError(t, err)
// p2 can now get lock
err = p2.TryLock(ctx)
require.NoError(t, err)
// p can not get the lock now
err = p.TryLock(ctx)
require.Error(t, err)
// clear lock on p2
err = p2.Unlock(ctx)
// p can now get lock
err = p.TryLock(ctx)
require.NoError(t, err)
}
// Test that UnlockShared
// - can unlock a lock granted by TrySharedLock
func TestUnlockShared(t *testing.T) {
t.Parallel()
ctx := context.Background()
p, _, u := setup(ctx, t)
err := p.TrySharedLock(ctx)
require.NoError(t, err)
d2, err := common.SqlOpen(dbtest.Postgres, u)
p2, err := postgres.New(ctx, d2)
require.NoError(t, err)
err = p2.TryLock(ctx)
require.Error(t, err)
err = p.UnlockShared(ctx)
require.NoError(t, err)
err = p2.TryLock(ctx)
require.NoError(t, err)
}
@@ -0,0 +1,203 @@
package postgres_test
import (
"context"
"testing"
"github.com/hashicorp/boundary/internal/db/schema/internal/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEnsureMigrationLog(t *testing.T) {
ctx := context.Background()
p, _, _ := setup(ctx, t)
err := p.StartRun(ctx)
require.NoError(t, err)
err = p.EnsureVersionTable(ctx)
require.NoError(t, err)
err = p.EnsureMigrationLogTable(ctx)
require.NoError(t, err)
err = p.CommitRun(ctx)
require.NoError(t, err)
}
func TestEnsureMigrationLog_NoTxn(t *testing.T) {
ctx := context.Background()
p, _, _ := setup(ctx, t)
err := p.EnsureMigrationLogTable(ctx)
require.Error(t, err)
}
// Test that a database that was created with a log_migration table
// prior to edition support, can be updated to support editions,
// and any existing log entries can be updated.
//
// The scenario involves a case where:
// - The database was initialized with the original boundary_schema_version table
// - The original migrations where run, creating the log_migration table and some entries in the table.
func TestEnsureMigrationLog_UpdateForEditionSupport(t *testing.T) {
ctx := context.Background()
p, db, _ := setup(ctx, t)
statements := []string{
// original boundary_schema_version table
`create table boundary_schema_version (
version bigint primary key,
dirty boolean not null);`,
// populate the table with a version
`insert into boundary_schema_version
(version, dirty)
values
(1001, false);`,
// need to create some things from other migrations that the original tables used
`create domain wt_timestamp as
timestamp with time zone
default current_timestamp;
create or replace function
default_create_time()
returns trigger
as $$
begin
if new.create_time is distinct from now() then
raise warning 'create_time cannot be set to %', new.create_time;
new.create_time = now();
end if;
return new;
end;
$$ language plpgsql;
create or replace function
immutable_columns()
returns trigger
as $$
declare
col_name text;
new_value text;
old_value text;
begin
foreach col_name in array tg_argv loop
execute format('SELECT $1.%I', col_name) into new_value using new;
execute format('SELECT $1.%I', col_name) into old_value using old;
if new_value is distinct from old_value then
raise exception 'immutable column: %.%', tg_table_name, col_name using
errcode = '23601',
schema = tg_table_schema,
table = tg_table_name,
column = col_name;
end if;
end loop;
return new;
end;
$$ language plpgsql;
`,
// create original log_migration table, triggers
`create table log_migration(
id bigint generated always as identity primary key,
migration_version bigint not null, -- cannot declare FK since the table is truncated during runtime
create_time wt_timestamp,
entry text not null
);
comment on table log_migration is
'log_migration entries are logging output from databaes migrations';
create trigger
default_create_time_column
before
insert on log_migration
for each row execute procedure default_create_time();
create trigger
immutable_columns
before
update on log_migration
for each row execute procedure immutable_columns('id', 'migration_version', 'create_time', 'entry');
create or replace function
log_migration_version()
returns trigger
as $$
declare current_version bigint;
begin
select max(version) from boundary_schema_version into current_version;
new.migration_version = current_version;
return new;
end;
$$ language plpgsql;
comment on function log_migration_version() is
'log_migration_version will set the log_migration entries to the current migration version';
create trigger
migration_version_column
before
insert on log_migration
for each row execute procedure log_migration_version();
`,
// insert some logs
`insert into log_migration (entry)
values
('log entry');`,
}
for _, s := range statements {
_, err := db.ExecContext(ctx, s)
require.NoError(t, err)
}
err := p.StartRun(ctx)
require.NoError(t, err)
err = p.EnsureVersionTable(ctx)
require.NoError(t, err)
err = p.EnsureMigrationLogTable(ctx)
require.NoError(t, err)
err = p.CommitRun(ctx)
require.NoError(t, err)
logs, err := p.GetMigrationLog(ctx, log.WithDeleteLog(true))
require.NoError(t, err)
assert.Equal(t, 1, len(logs))
assert.NotEqual(t, logs[0].Id, 0)
assert.False(t, logs[0].CreateTime.IsZero())
assert.Equal(t, "oss", logs[0].MigrationEdition)
assert.Equal(t, 1001, logs[0].MigrationVersion)
assert.Equal(t, "log entry", logs[0].Entry)
// now ensure the updated table and trigger still works
statements = []string{
// update the version, to make it easy to filter on log_migrations
`update boundary_schema_version set version = 1002;`,
// insert a new log entry
`insert into log_migration (entry, edition)
values
('new log entry', 'oss');`,
}
for _, s := range statements {
_, err := db.ExecContext(ctx, s)
require.NoError(t, err)
}
logs, err = p.GetMigrationLog(ctx, log.WithDeleteLog(true))
require.NoError(t, err)
assert.Equal(t, 1, len(logs))
assert.NotEqual(t, logs[0].Id, 0)
assert.False(t, logs[0].CreateTime.IsZero())
assert.Equal(t, "oss", logs[0].MigrationEdition)
assert.Equal(t, 1002, logs[0].MigrationVersion)
assert.Equal(t, "new log entry", logs[0].Entry)
// ensure it is idempotent
err = p.StartRun(ctx)
require.NoError(t, err)
err = p.EnsureMigrationLogTable(ctx)
require.NoError(t, err)
err = p.CommitRun(ctx)
require.NoError(t, err)
}
@@ -0,0 +1,457 @@
// The MIT License (MIT)
//
// Original Work
// Copyright (c) 2016 Matthias Kadenbach
// https://github.com/mattes/migrate
//
// Modified Work
// Copyright (c) 2018 Dale Hui
// https://github.com/golang-migrate/migrate
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package postgres
import (
"context"
"database/sql"
stderrors "errors"
"fmt"
"io"
"io/ioutil"
"github.com/hashicorp/boundary/internal/db/schema/internal/log"
"github.com/hashicorp/boundary/internal/db/schema/migrations"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/go-multierror"
"github.com/jackc/pgconn"
)
// schemaAccessLockId is a Lock key used to ensure a single boundary binary is operating
// on a postgres server at a time. The value has no meaning and was picked randomly.
const schemaAccessLockId int64 = 3865661975
// nilVersion is used to identify when a migration version has not be set.
const nilVersion = -1
// Postgres is a driver usable by a boundary schema.Manager.
// This struct is not thread safe.
type Postgres struct {
// Locking and unlocking need to use the same connection
conn *sql.Conn
tx *sql.Tx
}
// New creates a Postgres with the provided sql.DB verified as connectable
func New(ctx context.Context, db *sql.DB) (*Postgres, error) {
const op = "postgres.New"
if err := db.PingContext(ctx); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
conn, err := db.Conn(ctx)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
px := &Postgres{
conn: conn,
}
return px, nil
}
// TrySharedLock attempts to capture a shared lock. If it is not successful it returns an error.
// https://www.postgresql.org/docs/11/static/explicit-locking.html#ADVISORY-LOCKS
func (p *Postgres) TrySharedLock(ctx context.Context) error {
const op = "postgres.(Postgres).TrySharedLock"
r := p.conn.QueryRowContext(ctx, trySharedLock, schemaAccessLockId)
if r.Err() != nil {
return errors.Wrap(ctx, r.Err(), op)
}
var gotLock bool
if err := r.Scan(&gotLock); err != nil {
return errors.Wrap(ctx, err, op)
}
if !gotLock {
return errors.New(ctx, errors.MigrationLock, op, "Lock failed")
}
return nil
}
// TryLock attempts to capture an exclusive lock. If it is not successful it returns an error.
// https://www.postgresql.org/docs/11/static/explicit-locking.html#ADVISORY-LOCKS
func (p *Postgres) TryLock(ctx context.Context) error {
const op = "postgres.(Postgres).TryLock"
r := p.conn.QueryRowContext(ctx, tryLock, schemaAccessLockId)
if r.Err() != nil {
return errors.Wrap(ctx, r.Err(), op)
}
var gotLock bool
if err := r.Scan(&gotLock); err != nil {
return errors.Wrap(ctx, err, op)
}
if !gotLock {
return errors.New(ctx, errors.MigrationLock, op, "Lock failed")
}
return nil
}
// Lock calls pg_advisory_lock with the provided context and returns an error
// if we were unable to get the lock before the context cancels.
func (p *Postgres) Lock(ctx context.Context) error {
const op = "postgres.(Postgres).Lock"
if _, err := p.conn.ExecContext(ctx, lock, schemaAccessLockId); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// Unlock calls pg_advisory_unlock and returns an error if we were unable to
// release the lock before the context cancels.
func (p *Postgres) Unlock(ctx context.Context) error {
const op = "postgres.(Postgres).Unlock"
if _, err := p.conn.ExecContext(ctx, unlock, schemaAccessLockId); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// UnlockShared calls pg_advisory_unlock_shared and returns an error if we were unable to
// release the lock before the context cancels.
func (p *Postgres) UnlockShared(ctx context.Context) error {
const op = "postgres.(Postgres).UnlockShared"
if _, err := p.conn.ExecContext(ctx, unlockSharedLock, schemaAccessLockId); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// StartRun begins a transaction internal to the driver.
func (p *Postgres) StartRun(ctx context.Context) error {
tx, err := p.conn.BeginTx(ctx, nil)
if err != nil {
return err
}
p.tx = tx
return nil
}
// CommitRun commits a transaction, if there is an error it should rollback the transaction.
func (p *Postgres) CommitRun(ctx context.Context) error {
const op = "postgres.(Postgres).CommitRun"
defer func() {
p.tx = nil
}()
if p.tx == nil {
return errors.New(ctx, errors.MigrationIntegrity, op, "no pending transaction")
}
if err := p.tx.Commit(); err != nil {
if errRollback := p.tx.Rollback(); errRollback != nil && errRollback != sql.ErrTxDone {
err = multierror.Append(err, errRollback)
}
return errors.Wrap(ctx, err, op)
}
return nil
}
// Run will apply a migration. The io.Reader should provide the SQL
// statements to execute, and the int is the version for that set of
// statements. This should always be wrapped by StartRun and CommitRun.
func (p *Postgres) Run(ctx context.Context, migration io.Reader, version int, edition string) error {
const op = "postgres.(Postgres).Run"
if p.tx == nil {
return errors.New(ctx, errors.MigrationIntegrity, op, "no pending transaction")
}
migr, err := ioutil.ReadAll(migration)
if err != nil {
return errors.Wrap(ctx, err, op)
}
// Run migration
query := string(migr)
// set the version first, so logs will be associated with this new version.
// if there's an error, it will get rollback
if err := p.setVersion(ctx, version, edition); err != nil {
return errors.Wrap(ctx, err, op)
}
if _, err := p.conn.ExecContext(ctx, query); err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok {
var line uint
var col uint
var lineColOK bool
if pgErr.Position != 0 {
line, col, lineColOK = computeLineFromPos(query, int(pgErr.Position))
}
message := "migration failed"
if lineColOK {
message = fmt.Sprintf("%s (column %d)", message, col)
}
if pgErr.Detail != "" {
message = fmt.Sprintf("%s, %s", message, pgErr.Detail)
}
message = fmt.Sprintf("%s, on line %v: %s", message, line, migr)
return errors.Wrap(ctx, err, op, errors.WithMsg(message))
}
return errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("migration failed: %s", migr)))
}
return nil
}
var errOldMigrationTable = stderrors.New("old schema migration table")
func (p *Postgres) schemaInitialized(ctx context.Context) (bool, error) {
const op = "postgres.(Postgres).CurrentState"
var initialized bool
tableNames := make([]string, 0, 2)
tableResult, err := p.conn.QueryContext(ctx, tablesExist)
if err != nil {
return false, errors.Wrap(ctx, err, op)
}
defer tableResult.Close()
for tableResult.Next() {
var tableName string
if err := tableResult.Scan(&tableName); err != nil {
return false, errors.Wrap(ctx, err, op)
}
tableNames = append(tableNames, tableName)
}
if len(tableNames) <= 0 {
// No version table found
return initialized, nil
}
initialized = true
if len(tableNames) > 1 {
return initialized, errors.New(ctx, errors.MigrationIntegrity, op, "both old and new migration tables exist")
}
if tableNames[0] != schemaVersionTable {
return initialized, errOldMigrationTable
}
return initialized, nil
}
// CurrentState returns the state of the given edition.
// ver is the current migration version number as recorded in the database.
// A version of -1 indicates no version is set.
// initialized will be true if the schema was previously initialized.
func (p *Postgres) CurrentState(ctx context.Context, edition string) (version int, initialized bool, err error) {
const op = "postgres.(Postgres).CurrentState"
version = nilVersion
initialized, err = p.schemaInitialized(ctx)
switch {
case err == errOldMigrationTable:
// only oss edition had the old migration table
if edition != "oss" {
return nilVersion, initialized, nil
}
err = p.conn.QueryRowContext(ctx, selectOldVersion).Scan(&version)
switch {
case err == sql.ErrNoRows:
// no version recorded
return nilVersion, initialized, nil
case err != nil:
return nilVersion, initialized, errors.Wrap(ctx, err, op)
default:
return version, initialized, nil
}
case err != nil:
return nilVersion, initialized, err
default:
// continue
}
if !initialized {
return nilVersion, initialized, nil
}
err = p.conn.QueryRowContext(ctx, selectVersion, edition).Scan(&version)
switch {
case err == sql.ErrNoRows:
// no version recorded
return nilVersion, initialized, nil
case err != nil:
// try to query for an edition in the pre-edition version table
if edition == "oss" {
preEditionErr := p.conn.QueryRowContext(ctx, selectPreEditionVersion).Scan(&version)
switch {
case preEditionErr == sql.ErrNoRows:
return nilVersion, initialized, nil
case preEditionErr != nil:
// return the original error
return nilVersion, initialized, errors.Wrap(ctx, err, op)
default:
return version, initialized, nil
}
}
return nilVersion, initialized, errors.Wrap(ctx, err, op)
default:
return version, initialized, nil
}
}
// EnsureVersionTable ensures that the table used to record the schema versions for each edition
// exists and is in the correct state.
func (p *Postgres) EnsureVersionTable(ctx context.Context) error {
const op = "postgres.(Postgres).EnsureVersionTable"
if p.tx == nil {
return errors.New(ctx, errors.MigrationIntegrity, op, "no pending transaction")
}
exists := false
if err := p.tx.QueryRowContext(ctx, tableExists).Scan(&exists); err != nil {
return errors.Wrap(ctx, err, op)
}
if !exists {
oldExists := false
if err := p.tx.QueryRowContext(ctx, oldTableExists).Scan(&oldExists); err != nil {
return errors.Wrap(ctx, err, op)
}
if !oldExists {
// create table for the first time it its correct state
if _, err := p.tx.ExecContext(ctx, migrations.Base("postgres").CreateSchemaVersion); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// else convert old table to new table
if _, err := p.tx.ExecContext(ctx, updateTableName); err != nil {
return errors.Wrap(ctx, err, op)
}
}
// table exists, update it to ensure it has the correct columns and constraints
alterations := []string{
dropDirtyColumn,
addEditionColumn,
setVersionNotNull,
}
for _, a := range alterations {
if _, err := p.tx.ExecContext(ctx, a); err != nil {
return errors.Wrap(ctx, err, op)
}
}
return nil
}
// setVersion sets the version number, and whether the database is in a dirty state.
// A version value of -1 indicates no version is set.
func (p *Postgres) setVersion(ctx context.Context, version int, edition string) error {
const op = "postgres.(Postgres).setVersion"
if p.tx == nil {
return errors.New(ctx, errors.MigrationIntegrity, op, "no pending transaction")
}
if _, err := p.tx.ExecContext(ctx, upsertVersion, edition, version); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// EnsureMigrationLogTable ensures that the table used to record migration lgos
// exists and is in the correct state.
func (p *Postgres) EnsureMigrationLogTable(ctx context.Context) error {
const op = "postgres.(Postgres).EnsureMigrationLogTable"
if p.tx == nil {
return errors.New(ctx, errors.MigrationIntegrity, op, "no pending transaction")
}
exists := false
if err := p.tx.QueryRowContext(ctx, migrationLogTableExists).Scan(&exists); err != nil {
return errors.Wrap(ctx, err, op)
}
if !exists {
if _, err := p.tx.ExecContext(ctx, migrations.Base("postgres").CreateLogMigration); err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
// table exists, update it to ensure it has the correct columns and triggers
alterations := []string{
migrationLogAlterColumns,
migrationLogDropTriggers,
migrationLogAddEditionColumn,
migrationLogReplaceVersionTrigger,
}
for _, a := range alterations {
if _, err := p.tx.ExecContext(ctx, a); err != nil {
return errors.Wrap(ctx, err, op)
}
}
return nil
}
// GetMigrationLog will retrieve the migration logs from the db for the last
// migration.
// The WithDeleteLog option is supported and will remove all log entries,
// after reading the entries, when provided.
func (p *Postgres) GetMigrationLog(ctx context.Context, opt ...log.Option) ([]*log.Entry, error) {
const op = "postgres.(Postgres).GetMigrationLog"
rows, err := p.conn.QueryContext(ctx, getMigrationLogs)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
defer rows.Close()
var entries []*log.Entry
for rows.Next() {
e := &log.Entry{}
if err := rows.Scan(&e.Id, &e.CreateTime, &e.MigrationVersion, &e.MigrationEdition, &e.Entry); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
entries = append(entries, e)
}
if rows.Err() != nil {
return nil, errors.Wrap(ctx, err, op)
}
opts := log.GetOpts(opt...)
if opts.WithDeleteLog {
// this truncate could change to a delete if FKs are needed in the future
_, err = p.conn.ExecContext(ctx, truncateMigrationLogs)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
}
return entries, nil
}
@@ -0,0 +1,63 @@
package postgres_test
import (
"bytes"
"context"
"fmt"
"testing"
"github.com/hashicorp/boundary/internal/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRun(t *testing.T) {
ctx := context.Background()
p, _, _ := setup(ctx, t)
statements := bytes.NewReader([]byte(`
create table foo (
id bigint primary key,
bar text
);
`))
err := p.StartRun(ctx)
require.NoError(t, err)
err = p.EnsureVersionTable(ctx)
require.NoError(t, err)
err = p.EnsureMigrationLogTable(ctx)
require.NoError(t, err)
err = p.Run(ctx, statements, 1001, "oss")
require.NoError(t, err)
err = p.CommitRun(ctx)
require.NoError(t, err)
v, i, err := p.CurrentState(ctx, "oss")
require.NoError(t, err)
assert.True(t, i)
assert.Equal(t, v, 1001)
}
func TestRun_NoTxn(t *testing.T) {
ctx := context.Background()
p, _, _ := setup(ctx, t)
statements := bytes.NewReader([]byte(`
create table foo (
id bigint primary key,
bar text
);
`))
err := p.Run(ctx, statements, 1001, "oss")
require.EqualError(
t,
err,
fmt.Sprintf("postgres.(Postgres).Run: no pending transaction: integrity violation: error #%d", errors.MigrationIntegrity),
)
}
@@ -0,0 +1,139 @@
package postgres
// Queries for interacting with locks
const (
trySharedLock = `select pg_try_advisory_lock_shared($1)`
tryLock = `select pg_try_advisory_lock($1)`
lock = `select pg_advisory_lock($1)`
unlock = `select pg_advisory_unlock($1)`
unlockSharedLock = `select pg_advisory_unlock_shared($1)`
)
// Queries for interacting with the schema version table
const (
schemaVersionTable = `boundary_schema_version`
upsertVersion = `
insert into boundary_schema_version
(edition, version)
values
($1, $2)
on conflict (edition)
do update set
version = $2
;`
selectVersion = `
select version
from boundary_schema_version
where edition = $1
;`
selectOldVersion = `
select version from schema_migrations;
`
selectPreEditionVersion = `
select max(version) from boundary_schema_version;
`
tablesExist = `
select table_name from information_schema.tables
where table_schema = (select current_schema())
and table_name in ('schema_migrations', 'boundary_schema_version')
;`
tableExists = `
select exists (
select 1 from information_schema.tables
where table_schema = (select current_schema())
and table_name = 'boundary_schema_version'
);`
oldTableExists = `
select exists (
select 1 from information_schema.tables
where table_schema = (select current_schema())
and table_name = 'schema_migrations'
);`
updateTableName = `
alter table if exists schema_migrations
rename to boundary_schema_version
;`
dropDirtyColumn = `
alter table boundary_schema_version
drop column if exists dirty
;`
addEditionColumn = `
alter table boundary_schema_version
add column if not exists
edition text default 'oss';
alter table boundary_schema_version
alter column edition drop default;
alter table boundary_schema_version drop constraint if exists schema_migrations_pkey;
alter table boundary_schema_version drop constraint if exists boundary_schema_version_pkey;
alter table boundary_schema_version add primary key (edition);
`
setVersionNotNull = `
alter table boundary_schema_version
alter column version set not null;
`
)
// Quries for interacting with migration logs table
const (
migrationLogTableExists = `
select exists (
select 1 from information_schema.tables
where table_schema = (select current_schema())
and table_name = 'log_migration'
);`
getMigrationLogs = `
select
id, create_time, migration_version, edition, entry
from log_migration
where
migration_version in (
select max(version) from boundary_schema_version
);`
truncateMigrationLogs = `truncate log_migration;`
migrationLogAlterColumns = `
alter table log_migration
alter column create_time type timestamp with time zone;
alter table log_migration
alter column create_time set default current_timestamp;
`
migrationLogDropTriggers = `
drop trigger if exists default_create_time_column on log_migration;
drop trigger if exists immutable_columns on log_migration;
`
migrationLogAddEditionColumn = `
alter table log_migration
add column if not exists
edition text default 'oss';
alter table log_migration
alter column edition drop default;
`
migrationLogReplaceVersionTrigger = `
create or replace function
log_migration_version()
returns trigger
as $$
declare current_version bigint;
begin
select max(version)
from boundary_schema_version into current_version
where edition = new.edition;
new.migration_version = current_version;
return new;
end;
$$ language plpgsql;
comment on function log_migration_version() is
'log_migration_version will set the log_migration entries to the current migration version';
`
)
@@ -0,0 +1,56 @@
package postgres_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCurrentStatePreEdition(t *testing.T) {
ctx := context.Background()
p, db, _ := setup(ctx, t)
_, err := db.ExecContext(ctx, `
create table boundary_schema_version (
version bigint primary key,
dirty boolean not null
);`)
require.NoError(t, err)
_, err = db.ExecContext(ctx, `
insert into boundary_schema_version
(version, dirty)
values
(1001, false);`)
require.NoError(t, err)
version, initialized, err := p.CurrentState(ctx, "oss")
require.NoError(t, err)
assert.Equal(t, version, 1001)
assert.True(t, initialized)
}
func TestCurrentStateOldSchemaTable(t *testing.T) {
ctx := context.Background()
p, db, _ := setup(ctx, t)
_, err := db.ExecContext(ctx, `
create table schema_migrations (
version bigint primary key,
dirty boolean not null
);`)
require.NoError(t, err)
require.NoError(t, err)
_, err = db.ExecContext(ctx, `
insert into schema_migrations
(version, dirty)
values
(1001, false);`)
require.NoError(t, err)
version, initialized, err := p.CurrentState(ctx, "oss")
require.NoError(t, err)
assert.Equal(t, version, 1001)
assert.True(t, initialized)
}
@@ -0,0 +1,35 @@
package postgres_test
import (
"context"
"database/sql"
"testing"
"github.com/hashicorp/boundary/internal/db/common"
"github.com/hashicorp/boundary/internal/db/schema/internal/postgres"
"github.com/hashicorp/boundary/testing/dbtest"
"github.com/stretchr/testify/require"
)
// setup is a helper function for tests.
// It creates a new database using Template1, establishes an connection,
// and returns a *postgres.Postgrs.
// It also returns the underlying sql.DB and connection url so tests
// can establish additional connections if necessary.
func setup(ctx context.Context, t *testing.T) (*postgres.Postgres, *sql.DB, string) {
t.Helper()
c, u, _, err := dbtest.StartUsingTemplate(dbtest.Postgres, dbtest.WithTemplate(dbtest.Template1))
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, c())
})
d, err := common.SqlOpen(dbtest.Postgres, u)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, d.Close())
})
p, err := postgres.New(ctx, d)
return p, d, u
}