diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..b12b991f06 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -163,12 +163,13 @@ var ( includeAll bool includeRoles bool includeSeed bool + skipVault bool dbPushCmd = &cobra.Command{ Use: "push", Short: "Push new migrations to the remote database", RunE: func(cmd *cobra.Command, args []string) error { - return push.Run(cmd.Context(), dryRun, includeAll, includeRoles, includeSeed, flags.DbConfig, afero.NewOsFs()) + return push.Run(cmd.Context(), dryRun, includeAll, includeRoles, includeSeed, skipVault, flags.DbConfig, afero.NewOsFs()) }, } @@ -586,6 +587,7 @@ func init() { pushFlags.BoolVar(&includeAll, "include-all", false, "Include all migrations not found on remote history table.") pushFlags.BoolVar(&includeRoles, "include-roles", false, "Include custom roles from "+utils.CustomRolesPath+".") pushFlags.BoolVar(&includeSeed, "include-seed", false, "Include seed data from your config.") + pushFlags.BoolVar(&skipVault, "skip-vault", false, "Skip updating vault secrets from config.toml.") pushFlags.BoolVar(&dryRun, "dry-run", false, "Print the migrations that would be applied, but don't actually apply them.") pushFlags.String("db-url", "", "Pushes to the database specified by the connection string (must be percent-encoded).") pushFlags.Bool("linked", true, "Pushes to the linked project.") diff --git a/apps/cli-go/internal/bootstrap/bootstrap.go b/apps/cli-go/internal/bootstrap/bootstrap.go index 81fe310869..e83eac61f1 100644 --- a/apps/cli-go/internal/bootstrap/bootstrap.go +++ b/apps/cli-go/internal/bootstrap/bootstrap.go @@ -121,7 +121,7 @@ func Run(ctx context.Context, starter StarterTemplate, fsys afero.Fs, options .. } policy.Reset() if err := backoff.RetryNotify(func() error { - return push.Run(ctx, false, false, true, true, config, fsys) + return push.Run(ctx, false, false, true, true, false, config, fsys) }, policy, utils.NewErrorCallback()); err != nil { return err } diff --git a/apps/cli-go/internal/db/push/push.go b/apps/cli-go/internal/db/push/push.go index bc7b7c977f..706b1cfd09 100644 --- a/apps/cli-go/internal/db/push/push.go +++ b/apps/cli-go/internal/db/push/push.go @@ -18,7 +18,7 @@ import ( "github.com/supabase/cli/pkg/vault" ) -func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, includeSeed bool, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { +func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, includeSeed, skipVault bool, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if dryRun { fmt.Fprintln(os.Stderr, "DRY RUN: migrations will *not* be pushed to the database.") } @@ -66,6 +66,14 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, if len(pending) > 0 { fmt.Fprintln(os.Stderr, "Would push these migrations:") fmt.Fprint(os.Stderr, confirmPushAll(pending)) + if names := vault.ResolvedSecretNames(utils.Config.Db.Vault); len(names) > 0 { + if skipVault { + fmt.Fprintln(os.Stderr, "Would skip vault secrets from config.toml:") + } else { + fmt.Fprintln(os.Stderr, "Would update vault secrets from config.toml:") + } + fmt.Fprint(os.Stderr, confirmVaultPushAll(names)) + } } if len(seeds) > 0 { fmt.Fprintln(os.Stderr, "Would seed these files:") @@ -90,8 +98,12 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, } else if !shouldPush { return errors.New(context.Canceled) } - if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { - return err + if !skipVault { + if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { + return err + } + } else if len(vault.ResolvedSecretNames(utils.Config.Db.Vault)) > 0 { + fmt.Fprintln(os.Stderr, "Skipping vault secrets.") } if err := migration.ApplyMigrations(ctx, pending, conn, afero.NewIOFS(fsys)); err != nil { return err @@ -128,6 +140,13 @@ func confirmPushAll(pending []string) (msg string) { return msg } +func confirmVaultPushAll(names []string) (msg string) { + for _, name := range names { + msg += fmt.Sprintf(" • %s\n", utils.Bold(name)) + } + return msg +} + func confirmSeedAll(pending []migration.SeedFile) (msg string) { for _, seed := range pending { notice := seed.Path diff --git a/apps/cli-go/internal/db/push/push_test.go b/apps/cli-go/internal/db/push/push_test.go index b5d0d2c7f3..d5cc84a09d 100644 --- a/apps/cli-go/internal/db/push/push_test.go +++ b/apps/cli-go/internal/db/push/push_test.go @@ -16,8 +16,10 @@ import ( "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/pgtest" + "github.com/supabase/cli/pkg/vault" ) var dbConfig = pgconn.Config{ @@ -28,6 +30,14 @@ var dbConfig = pgconn.Config{ Database: "postgres", } +var remoteDbConfig = pgconn.Config{ + Host: "db.supabase.co", + Port: 5432, + User: "admin", + Password: "password", + Database: "postgres", +} + func TestMigrationPush(t *testing.T) { t.Run("dry run", func(t *testing.T) { // Setup in-memory fs @@ -40,7 +50,7 @@ func TestMigrationPush(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") // Run test - err := Run(context.Background(), true, false, true, true, dbConfig, fsys, conn.Intercept) + err := Run(context.Background(), true, false, true, true, false, dbConfig, fsys, conn.Intercept) // Check error assert.NoError(t, err) }) @@ -54,7 +64,7 @@ func TestMigrationPush(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") // Run test - err := Run(context.Background(), false, false, false, false, dbConfig, fsys, conn.Intercept) + err := Run(context.Background(), false, false, false, false, false, dbConfig, fsys, conn.Intercept) // Check error assert.NoError(t, err) }) @@ -63,7 +73,7 @@ func TestMigrationPush(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() // Run test - err := Run(context.Background(), false, false, false, false, pgconn.Config{}, fsys) + err := Run(context.Background(), false, false, false, false, false, pgconn.Config{}, fsys) // Check error assert.ErrorContains(t, err, "invalid port (outside range)") }) @@ -77,7 +87,7 @@ func TestMigrationPush(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). ReplyError(pgerrcode.InvalidCatalogName, `database "target" does not exist`) // Run test - err := Run(context.Background(), false, false, false, false, pgconn.Config{ + err := Run(context.Background(), false, false, false, false, false, pgconn.Config{ Host: "db.supabase.co", Port: 5432, User: "admin", @@ -104,7 +114,7 @@ func TestMigrationPush(t *testing.T) { Query(migration.INSERT_MIGRATION_VERSION, "0", "test", nil). ReplyError(pgerrcode.NotNullViolation, `null value in column "version" of relation "schema_migrations"`) // Run test - err := Run(context.Background(), false, false, false, false, dbConfig, fsys, conn.Intercept) + err := Run(context.Background(), false, false, false, false, false, dbConfig, fsys, conn.Intercept) // Check error assert.ErrorContains(t, err, `ERROR: null value in column "version" of relation "schema_migrations" (SQLSTATE 23502)`) assert.ErrorContains(t, err, "At statement: 0\n"+migration.INSERT_MIGRATION_VERSION) @@ -128,7 +138,7 @@ func TestPushAll(t *testing.T) { Query(migration.INSERT_MIGRATION_VERSION, "0", "test", nil). Reply("INSERT 0 1") // Run test - err := Run(context.Background(), false, false, true, true, dbConfig, fsys, conn.Intercept) + err := Run(context.Background(), false, false, true, true, false, dbConfig, fsys, conn.Intercept) // Check error assert.NoError(t, err) }) @@ -145,7 +155,7 @@ func TestPushAll(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") // Run test - err := Run(context.Background(), false, false, true, true, dbConfig, fsys, conn.Intercept) + err := Run(context.Background(), false, false, true, true, false, dbConfig, fsys, conn.Intercept) // Check error assert.ErrorIs(t, err, context.Canceled) }) @@ -161,7 +171,7 @@ func TestPushAll(t *testing.T) { conn.Query(migration.LIST_MIGRATION_VERSION). Reply("SELECT 0") // Run test - err := Run(context.Background(), false, false, true, false, dbConfig, fsys, conn.Intercept) + err := Run(context.Background(), false, false, true, false, false, dbConfig, fsys, conn.Intercept) // Check error assert.ErrorIs(t, err, os.ErrPermission) }) @@ -191,8 +201,60 @@ func TestPushAll(t *testing.T) { Query(migration.UPSERT_SEED_FILE, seedPath, digest). ReplyError(pgerrcode.NotNullViolation, `null value in column "hash" of relation "seed_files"`) // Run test - err := Run(context.Background(), false, false, false, true, dbConfig, fsys, conn.Intercept) + err := Run(context.Background(), false, false, false, true, false, dbConfig, fsys, conn.Intercept) // Check error assert.ErrorContains(t, err, `ERROR: null value in column "hash" of relation "seed_files" (SQLSTATE 23502)`) }) + + t.Run("skips vault secrets when --skip-vault is set", func(t *testing.T) { + originalVault := utils.Config.Db.Vault + utils.Config.Db.Vault = map[string]config.Secret{ + "API_KEY": {Value: "local-dev", SHA256: "hash"}, + } + t.Cleanup(func() { + utils.Config.Db.Vault = originalVault + }) + fsys := afero.NewMemMapFs() + path := filepath.Join(utils.MigrationsDir, "0_test.sql") + require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query(migration.LIST_MIGRATION_VERSION). + Reply("SELECT 0") + helper.MockMigrationHistory(conn). + Query("RESET ALL"). + Reply("RESET"). + Query(migration.INSERT_MIGRATION_VERSION, "0", "test", nil). + Reply("INSERT 0 1") + err := Run(context.Background(), false, false, false, false, true, remoteDbConfig, fsys, conn.Intercept) + assert.NoError(t, err) + }) + + t.Run("updates vault secrets by default", func(t *testing.T) { + originalVault := utils.Config.Db.Vault + utils.Config.Db.Vault = map[string]config.Secret{ + "API_KEY": {Value: "local-dev", SHA256: "hash"}, + } + t.Cleanup(func() { + utils.Config.Db.Vault = originalVault + }) + fsys := afero.NewMemMapFs() + path := filepath.Join(utils.MigrationsDir, "0_test.sql") + require.NoError(t, afero.WriteFile(fsys, path, []byte{}, 0644)) + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query(migration.LIST_MIGRATION_VERSION). + Reply("SELECT 0") + conn.Query(vault.READ_VAULT_KV, []string{"API_KEY"}). + Reply("SELECT 0"). + Query(vault.CREATE_VAULT_KV, "local-dev", "API_KEY"). + Reply("SELECT 1") + helper.MockMigrationHistory(conn). + Query("RESET ALL"). + Reply("RESET"). + Query(migration.INSERT_MIGRATION_VERSION, "0", "test", nil). + Reply("INSERT 0 1") + err := Run(context.Background(), false, false, false, false, false, remoteDbConfig, fsys, conn.Intercept) + assert.NoError(t, err) + }) } diff --git a/apps/cli-go/pkg/vault/batch.go b/apps/cli-go/pkg/vault/batch.go index 8f44bb797e..6de5c026dd 100644 --- a/apps/cli-go/pkg/vault/batch.go +++ b/apps/cli-go/pkg/vault/batch.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "slices" "github.com/go-errors/errors" "github.com/jackc/pgx/v4" @@ -22,20 +23,29 @@ type VaultTable struct { Name string } -func UpsertVaultSecrets(ctx context.Context, secrets map[string]config.Secret, conn *pgx.Conn) error { - var keys []string - toInsert := map[string]string{} - for k, v := range secrets { - if len(v.SHA256) > 0 { - keys = append(keys, k) - toInsert[k] = v.Value +// ResolvedSecretNames returns vault secret names from config that would be upserted. +func ResolvedSecretNames(secrets map[string]config.Secret) []string { + var names []string + for name, secret := range secrets { + if len(secret.SHA256) > 0 { + names = append(names, name) } } - if len(keys) == 0 { + slices.Sort(names) + return names +} + +func UpsertVaultSecrets(ctx context.Context, secrets map[string]config.Secret, conn *pgx.Conn) error { + names := ResolvedSecretNames(secrets) + if len(names) == 0 { return nil } + toInsert := map[string]string{} + for _, name := range names { + toInsert[name] = secrets[name].Value + } fmt.Fprintln(os.Stderr, "Updating vault secrets...") - rows, err := conn.Query(ctx, READ_VAULT_KV, keys) + rows, err := conn.Query(ctx, READ_VAULT_KV, names) if err != nil { return errors.Errorf("failed to read vault: %w", err) } diff --git a/apps/cli-go/pkg/vault/batch_test.go b/apps/cli-go/pkg/vault/batch_test.go new file mode 100644 index 0000000000..463b0c9fbf --- /dev/null +++ b/apps/cli-go/pkg/vault/batch_test.go @@ -0,0 +1,26 @@ +package vault + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/pkg/config" +) + +func TestResolvedSecretNames(t *testing.T) { + t.Run("returns sorted resolved names", func(t *testing.T) { + names := ResolvedSecretNames(map[string]config.Secret{ + "beta": {SHA256: "hash-beta"}, + "alpha": {SHA256: "hash-alpha"}, + "empty": {}, + }) + assert.Equal(t, []string{"alpha", "beta"}, names) + }) + + t.Run("returns nil when no secrets resolve", func(t *testing.T) { + assert.Nil(t, ResolvedSecretNames(nil)) + assert.Nil(t, ResolvedSecretNames(map[string]config.Secret{ + "env_only": {Value: "env(MISSING)"}, + })) + }) +} diff --git a/apps/cli/docs/go-cli-reference.md b/apps/cli/docs/go-cli-reference.md index c23f01e7fe..8fe2d40335 100644 --- a/apps/cli/docs/go-cli-reference.md +++ b/apps/cli/docs/go-cli-reference.md @@ -328,6 +328,7 @@ Flags: --include-all Include all migrations not found on remote history table. --include-roles Include custom roles from supabase/roles.sql. --include-seed Include seed data from your config. + --skip-vault Skip updating vault secrets from config.toml. --linked Pushes to the linked project. (default true) --local Pushes to the local database. -p, --password string Password to your remote Postgres database. diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 11337a8955..03862d7f28 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -5,10 +5,17 @@ | Path | Format | When | | -------------------------------- | ---------- | ------------------------------------------------- | | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/config.toml` | TOML | when pushing migrations with `[db.vault]` entries | | `/supabase/migrations/` | directory | always, to list migration files to push | | `/supabase/roles.sql` | SQL | when `--include-roles` is set | | seed files from config | SQL | when `--include-seed` is set | +## Remote Database Side Effects + +| Target | Effect | When | +| ------ | ------ | ---- | +| `vault.secrets` | upsert secrets from `[db.vault]` in config.toml | after migration confirmation, unless `--skip-vault` is set | + ## Files Written | Path | Format | When | @@ -56,4 +63,5 @@ Not applicable. - `--include-all` includes all migrations not found in remote history table. - `--include-roles` includes custom roles from the roles file. - `--include-seed` includes seed data from config. +- `--skip-vault` skips updating `[db.vault]` secrets while still applying migrations. - `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 2d6d8d17e9..8a6a83c812 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -12,6 +12,9 @@ const config = { includeSeed: Flag.boolean("include-seed").pipe( Flag.withDescription("Include seed data from your config."), ), + skipVault: Flag.boolean("skip-vault").pipe( + Flag.withDescription("Skip updating vault secrets from config.toml."), + ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription( "Print the migrations that would be applied, but don't actually apply them.", diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index fddb270174..16c73c9701 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -8,6 +8,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy if (flags.includeAll) args.push("--include-all"); if (flags.includeRoles) args.push("--include-roles"); if (flags.includeSeed) args.push("--include-seed"); + if (flags.skipVault) args.push("--skip-vault"); if (flags.dryRun) args.push("--dry-run"); if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); if (flags.linked) args.push("--linked");