diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 3b9ab95e6a..8f4031c045 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" @@ -136,6 +138,39 @@ func ensureLocalDatabaseStarted(ctx context.Context, local bool, isRunning func( return nil } +type inspectContainerFunc func(context.Context, string) (container.InspectResponse, error) + +func dockerImageTag(image string) string { + image = strings.TrimSpace(image) + index := strings.LastIndexByte(image, ':') + if index < 0 || index == len(image)-1 { + return "" + } + return image[index+1:] +} + +func ensureLocalPostgresImageCurrent(ctx context.Context, inspect inspectContainerFunc) error { + resp, err := inspect(ctx, utils.DbId) + if err != nil { + if errdefs.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to inspect local Postgres container: %w", err) + } + if resp.Config == nil || len(strings.TrimSpace(resp.Config.Image)) == 0 { + return nil + } + actual := strings.TrimSpace(resp.Config.Image) + expected := strings.TrimSpace(utils.GetRegistryImageUrl(utils.Config.Db.Image)) + actualTag := dockerImageTag(actual) + expectedTag := dockerImageTag(expected) + if len(actualTag) == 0 || len(expectedTag) == 0 || actualTag == expectedTag { + return nil + } + utils.CmdSuggestion = fmt.Sprintf("Run %s, then %s before syncing declarative schemas.", utils.Aqua("supabase stop --all --no-backup"), utils.Aqua("supabase start")) + return fmt.Errorf("local Postgres container image is stale: running %s but expected %s", actual, expected) +} + // hasExplicitTargetFlag returns true if the user explicitly set --local, --linked, or --db-url. func hasExplicitTargetFlag(cmd *cobra.Command) bool { return cmd.Flags().Changed("local") || cmd.Flags().Changed("linked") || cmd.Flags().Changed("db-url") @@ -305,6 +340,10 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { fsys := afero.NewOsFs() console := utils.NewConsole() + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } + // Step 1: Check if declarative dir has files if !hasDeclarativeFiles(fsys) { if !isTTY() && !viper.GetBool("YES") { diff --git a/apps/cli-go/cmd/db_schema_declarative_test.go b/apps/cli-go/cmd/db_schema_declarative_test.go index a799ad0fb8..738e204111 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -194,6 +196,74 @@ func TestEnsureLocalDatabaseStarted(t *testing.T) { }) } +func TestDockerImageTag(t *testing.T) { + testCases := map[string]string{ + "public.ecr.aws/supabase/postgres:17.6.1.138": "17.6.1.138", + "localhost:5000/supabase/postgres:17.6.1.138": "17.6.1.138", + "supabase/postgres": "", + } + for image, expected := range testCases { + t.Run(image, func(t *testing.T) { + assert.Equal(t, expected, dockerImageTag(image)) + }) + } +} + +func TestEnsureLocalPostgresImageCurrent(t *testing.T) { + originalImage := utils.Config.Db.Image + originalSuggestion := utils.CmdSuggestion + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.CmdSuggestion = originalSuggestion + }) + utils.Config.Db.Image = "supabase/postgres:17.6.1.138" + expectedImage := utils.GetRegistryImageUrl(utils.Config.Db.Image) + + t.Run("passes when no local container exists", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{}, errdefs.ErrNotFound + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("passes when local container image matches expected postgres image", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(_ context.Context, containerID string) (container.InspectResponse, error) { + assert.Equal(t, utils.DbId, containerID) + return container.InspectResponse{Config: &container.Config{Image: expectedImage}}, nil + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("passes when registry differs but postgres tag matches", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{Config: &container.Config{Image: "docker.io/supabase/postgres:17.6.1.138"}}, nil + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("fails when local container image is stale", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{Config: &container.Config{Image: "public.ecr.aws/supabase/postgres:17.6.1.106"}}, nil + }) + + assert.ErrorContains(t, err, "local Postgres container image is stale") + assert.ErrorContains(t, err, "17.6.1.106") + assert.ErrorContains(t, err, "17.6.1.138") + assert.Contains(t, utils.CmdSuggestion, "supabase stop --all --no-backup") + assert.Contains(t, utils.CmdSuggestion, "supabase start") + }) +} + func TestHasDeclarativeFiles(t *testing.T) { t.Run("returns false when dir does not exist", func(t *testing.T) { assert.False(t, hasDeclarativeFiles(mockFsys()))