diff --git a/cmd/config.go b/cmd/config.go index 1d0f60733..b382084ba 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -18,13 +18,14 @@ var ( Use: "push", Short: "Pushes local config.toml to the linked project", RunE: func(cmd *cobra.Command, args []string) error { - return push.Run(cmd.Context(), flags.ProjectRef, afero.NewOsFs()) + return push.Run(cmd.Context(), flags.ProjectRef, dryRun, afero.NewOsFs()) }, } ) func init() { configCmd.PersistentFlags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + configPushCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print operations that would be performed without executing them.") configCmd.AddCommand(configPushCmd) rootCmd.AddCommand(configCmd) } diff --git a/cmd/deploy.go b/cmd/deploy.go new file mode 100644 index 000000000..66209a033 --- /dev/null +++ b/cmd/deploy.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "os" + "os/signal" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/supabase/cli/internal/deploy" + "github.com/supabase/cli/internal/utils/flags" +) + +var ( + deployCmd = &cobra.Command{ + GroupID: groupQuickStart, + Use: "deploy", + Short: "Push all local changes to a Supabase project", + Long: `Deploy local changes to a remote Supabase project. + +By default, this command will: + - Push database migrations (supabase db push) + - Deploy edge functions (supabase functions deploy) + +You can optionally include config changes with --include-config. +Use individual flags to customize what gets deployed.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt) + fsys := afero.NewOsFs() + return deploy.Run(ctx, dryRun, fsys) + }, + Example: ` supabase deploy + supabase deploy --include-config + supabase deploy --include-db --include-functions + supabase deploy --dry-run`, + } +) + +func init() { + cmdFlags := deployCmd.Flags() + cmdFlags.BoolVar(&dryRun, "dry-run", false, "Print operations that would be performed without executing them") + cmdFlags.String("db-url", "", "Deploys to the database specified by the connection string (must be percent-encoded)") + cmdFlags.Bool("linked", true, "Deploys to the linked project") + cmdFlags.Bool("local", false, "Deploys to the local database") + deployCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local") + cmdFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database") + cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", cmdFlags.Lookup("password"))) + cmdFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project") + rootCmd.AddCommand(deployCmd) +} diff --git a/cmd/functions.go b/cmd/functions.go index 6b48ad662..f3f7df13b 100644 --- a/cmd/functions.go +++ b/cmd/functions.go @@ -77,7 +77,7 @@ var ( } else if maxJobs > 1 { return errors.New("--jobs must be used together with --use-api") } - return deploy.Run(cmd.Context(), args, useDocker, noVerifyJWT, importMapPath, maxJobs, prune, afero.NewOsFs()) + return deploy.Run(cmd.Context(), args, useDocker, noVerifyJWT, importMapPath, maxJobs, prune, dryRun, afero.NewOsFs()) }, } @@ -145,6 +145,7 @@ func init() { deployFlags.UintVarP(&maxJobs, "jobs", "j", 1, "Maximum number of parallel jobs.") deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.") deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.") + deployFlags.BoolVar(&dryRun, "dry-run", false, "Print operations that would be performed without executing them.") deployFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") deployFlags.StringVar(&importMapPath, "import-map", "", "Path to import map file.") functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.") diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 6e93c0acb..cd6d8b2ce 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -107,7 +107,7 @@ func Run(ctx context.Context, starter StarterTemplate, fsys afero.Fs, options .. policy.Reset() if err := backoff.RetryNotify(func() error { fmt.Fprintln(os.Stderr, "Checking project health...") - return checkProjectHealth(ctx) + return CheckProjectHealth(ctx, flags.ProjectRef, api.Db) }, policy, utils.NewErrorCallback()); err != nil { return err } @@ -154,23 +154,26 @@ func suggestAppStart(cwd, command string) string { return suggestion } -func checkProjectHealth(ctx context.Context) error { - params := api.V1GetServicesHealthParams{ - Services: []api.V1GetServicesHealthParamsServices{api.Db}, - } - resp, err := utils.GetSupabase().V1GetServicesHealthWithResponse(ctx, flags.ProjectRef, ¶ms) +func CheckProjectHealth(ctx context.Context, projectRef string, services ...api.V1GetServicesHealthParamsServices) error { + params := api.V1GetServicesHealthParams{Services: services} + resp, err := utils.GetSupabase().V1GetServicesHealthWithResponse(ctx, projectRef, ¶ms) if err != nil { - return err - } - if resp.JSON200 == nil { - return errors.Errorf("Error status %d: %s", resp.StatusCode(), resp.Body) + return errors.Errorf("failed to check health: %w", err) + } else if resp.JSON200 == nil { + return errors.Errorf("unexpected health check status %d: %s", resp.StatusCode(), string(resp.Body)) } + var allErrors []error for _, service := range *resp.JSON200 { if !service.Healthy { - return errors.Errorf("Service not healthy: %s (%s)", service.Name, service.Status) + msg := string(service.Status) + if service.Error != nil { + msg = *service.Error + } + err := errors.Errorf("%s service not healthy: %s", service.Name, msg) + allErrors = append(allErrors, err) } } - return nil + return errors.Join(allErrors...) } const ( diff --git a/internal/config/push/push.go b/internal/config/push/push.go index 1a2340060..35e003e60 100644 --- a/internal/config/push/push.go +++ b/internal/config/push/push.go @@ -12,7 +12,7 @@ import ( "github.com/supabase/cli/pkg/config" ) -func Run(ctx context.Context, ref string, fsys afero.Fs) error { +func Run(ctx context.Context, ref string, dryRun bool, fsys afero.Fs) error { if err := flags.LoadConfig(fsys); err != nil { return err } @@ -26,18 +26,23 @@ func Run(ctx context.Context, ref string, fsys afero.Fs) error { if err != nil { return err } - fmt.Fprintln(os.Stderr, "Pushing config to project:", remote.ProjectId) + fmt.Fprintln(os.Stderr, "Checking config for project:", remote.ProjectId) console := utils.NewConsole() keep := func(name string) bool { title := fmt.Sprintf("Do you want to push %s config to remote?", name) if item, exists := cost[name]; exists { title = fmt.Sprintf("Enabling %s will cost you %s. Keep it enabled?", item.Name, item.Price) } - shouldPush, err := console.PromptYesNo(ctx, title, true) - if err != nil { + if shouldPush, err := console.PromptYesNo(ctx, title, true); err != nil { fmt.Fprintln(os.Stderr, err) + } else if !shouldPush { + return false } - return shouldPush + if dryRun { + fmt.Fprintln(os.Stderr, "Would update config:", name) + return false + } + return true } return client.UpdateRemoteConfig(ctx, remote, keep) } diff --git a/internal/config/push/push_test.go b/internal/config/push/push_test.go index 5af8fb273..f826db693 100644 --- a/internal/config/push/push_test.go +++ b/internal/config/push/push_test.go @@ -25,7 +25,7 @@ func TestPushConfig(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, utils.WriteFile(utils.ConfigPath, []byte("malformed"), fsys)) // Run test - err := Run(context.Background(), "", fsys) + err := Run(context.Background(), "", false, fsys) // Check error assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there") }) @@ -39,7 +39,7 @@ func TestPushConfig(t *testing.T) { Get("/v1/projects/" + project + "/billing/addons"). Reply(http.StatusServiceUnavailable) // Run test - err := Run(context.Background(), project, fsys) + err := Run(context.Background(), project, false, fsys) // Check error assert.ErrorContains(t, err, "unexpected list addons status 503:") }) diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go new file mode 100644 index 000000000..ce42c76d9 --- /dev/null +++ b/internal/deploy/deploy.go @@ -0,0 +1,94 @@ +package deploy + +import ( + "context" + "fmt" + "os" + + "github.com/go-errors/errors" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/bootstrap" + configPush "github.com/supabase/cli/internal/config/push" + "github.com/supabase/cli/internal/db/push" + funcDeploy "github.com/supabase/cli/internal/functions/deploy" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" + "github.com/supabase/cli/pkg/api" + "github.com/supabase/cli/pkg/function" +) + +func Run(ctx context.Context, dryRun bool, fsys afero.Fs) error { + fmt.Fprintln(os.Stderr, "Deploying to project:", flags.ProjectRef) + services := []api.V1GetServicesHealthParamsServices{ + api.Auth, + // Not checking Realtime for now as it can be flaky + // api.Realtime, + api.Rest, + api.Storage, + api.Db, + } + if err := bootstrap.CheckProjectHealth(ctx, flags.ProjectRef, services...); err != nil { + return err + } + + var deployErrors []error + + // Maybe deploy database migrations + fmt.Fprintln(os.Stderr, utils.Aqua(">>>"), "Deploying database migrations...") + if err := push.Run(ctx, dryRun, false, false, false, flags.DbConfig, fsys); err != nil { + deployErrors = append(deployErrors, errors.Errorf("db push failed: %w", err)) + } + fmt.Fprintln(os.Stderr) + + // Maybe deploy edge functions + fmt.Fprintln(os.Stderr, utils.Aqua(">>>"), "Deploying edge functions...") + if err := funcDeploy.Run(ctx, []string{}, true, nil, "", 1, false, dryRun, fsys); err != nil && !errors.Is(err, function.ErrNoDeploy) { + deployErrors = append(deployErrors, errors.Errorf("functions deploy failed: %w", err)) + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:")+" Functions deployment failed:", err) + } else if errors.Is(err, function.ErrNoDeploy) { + fmt.Fprintln(os.Stderr, utils.Yellow("⏭ ")+"No functions to deploy") + } else { + // print error just in case + fmt.Fprintln(os.Stderr, err) + if dryRun { + fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Functions dry run complete") + } else { + fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Functions deployed successfully") + } + } + fmt.Fprintln(os.Stderr) + + // Maybe deploy config + fmt.Fprintln(os.Stderr, utils.Aqua(">>>"), "Deploying config...") + if err := configPush.Run(ctx, flags.ProjectRef, dryRun, fsys); err != nil { + deployErrors = append(deployErrors, errors.Errorf("config push failed: %w", err)) + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:")+" Config deployment failed:", err) + } else { + if dryRun { + fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Config dry run complete") + } else { + fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" Config deployed successfully") + } + } + fmt.Fprintln(os.Stderr) + + // Summary + if len(deployErrors) > 0 { + if dryRun { + fmt.Fprintln(os.Stderr, utils.Yellow("Dry run completed with warnings:")) + } else { + fmt.Fprintln(os.Stderr, utils.Yellow("Deploy completed with warnings:")) + } + for _, err := range deployErrors { + fmt.Fprintln(os.Stderr, " •", err) + } + return nil // Don't fail the command for non-critical errors + } + + if dryRun { + fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" "+utils.Bold("Dry run completed successfully!")) + } else { + fmt.Fprintln(os.Stderr, utils.Aqua("✓")+" "+utils.Bold("Deployment completed successfully!")) + } + return nil +} diff --git a/internal/functions/deploy/deploy.go b/internal/functions/deploy/deploy.go index bcee53c55..a27d7acc0 100644 --- a/internal/functions/deploy/deploy.go +++ b/internal/functions/deploy/deploy.go @@ -17,7 +17,7 @@ import ( "github.com/supabase/cli/pkg/function" ) -func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, prune bool, fsys afero.Fs) error { +func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, prune, dryRun bool, fsys afero.Fs) error { // Load function config and project id if err := flags.LoadConfig(fsys); err != nil { return err @@ -51,7 +51,7 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, if err != nil { return err } - // Deploy new and updated functions + // Setup API with optional bundler opt := function.WithMaxJobs(maxJobs) if useDocker { if utils.IsDockerRunning(ctx) { @@ -61,19 +61,33 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, } } api := function.NewEdgeRuntimeAPI(flags.ProjectRef, *utils.GetSupabase(), opt) - if err := api.Deploy(ctx, functionConfig, afero.NewIOFS(fsys)); errors.Is(err, function.ErrNoDeploy) { + // Deploy new and updated functions + var keep, drop []func(string) bool + if dryRun { + keep = append(keep, func(name string) bool { + fmt.Fprintln(os.Stderr, "Would deploy:", name) + return false + }) + drop = append(keep, func(name string) bool { + fmt.Fprintln(os.Stderr, "Would delete:", name) + return false + }) + } + if err := api.Deploy(ctx, functionConfig, afero.NewIOFS(fsys), keep...); errors.Is(err, function.ErrNoDeploy) { fmt.Fprintln(os.Stderr, err) return nil } else if err != nil { return err } - fmt.Printf("Deployed Functions on project %s: %s\n", utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", ")) - url := fmt.Sprintf("%s/project/%v/functions", utils.GetSupabaseDashboardURL(), flags.ProjectRef) - fmt.Println("You can inspect your deployment in the Dashboard: " + url) + if !dryRun { + fmt.Printf("Deployed Functions on project %s: %s\n", utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", ")) + url := fmt.Sprintf("%s/project/%v/functions", utils.GetSupabaseDashboardURL(), flags.ProjectRef) + fmt.Println("You can inspect your deployment in the Dashboard: " + url) + } if !prune { return nil } - return pruneFunctions(ctx, functionConfig) + return pruneFunctions(ctx, functionConfig, drop...) } func GetFunctionSlugs(fsys afero.Fs) (slugs []string, err error) { @@ -163,7 +177,7 @@ func GetFunctionConfig(slugs []string, importMapPath string, noVerifyJWT *bool, } // pruneFunctions deletes functions that exist remotely but not locally -func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig) error { +func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig, filter ...func(string) bool) error { resp, err := utils.GetSupabase().V1ListAllFunctionsWithResponse(ctx, flags.ProjectRef) if err != nil { return errors.Errorf("failed to list functions: %w", err) @@ -191,7 +205,13 @@ func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig) e } else if !shouldDelete { return errors.New(context.Canceled) } +OUTER: for _, slug := range toDelete { + for _, keep := range filter { + if !keep(slug) { + continue OUTER + } + } fmt.Fprintln(os.Stderr, "Deleting Function:", slug) if err := delete.Undeploy(ctx, flags.ProjectRef, slug); errors.Is(err, delete.ErrNoDelete) { fmt.Fprintln(utils.GetDebugLogger(), err) diff --git a/internal/functions/deploy/deploy_test.go b/internal/functions/deploy/deploy_test.go index e746da927..14c2cfb5f 100644 --- a/internal/functions/deploy/deploy_test.go +++ b/internal/functions/deploy/deploy_test.go @@ -75,7 +75,7 @@ func TestDeployCommand(t *testing.T) { } // Run test noVerifyJWT := true - err = Run(context.Background(), functions, true, &noVerifyJWT, "", 1, false, fsys) + err = Run(context.Background(), functions, true, &noVerifyJWT, "", 1, false, false, fsys) // Check error assert.NoError(t, err) assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -130,7 +130,7 @@ import_map = "./import_map.json" outputDir := filepath.Join(utils.TempDir, fmt.Sprintf(".output_%s", slug)) require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644)) // Run test - err = Run(context.Background(), nil, true, nil, "", 1, false, fsys) + err = Run(context.Background(), nil, true, nil, "", 1, false, false, fsys) // Check error assert.NoError(t, err) assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -183,7 +183,7 @@ import_map = "./import_map.json" outputDir := filepath.Join(utils.TempDir, ".output_enabled-func") require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644)) // Run test - err = Run(context.Background(), nil, true, nil, "", 1, false, fsys) + err = Run(context.Background(), nil, true, nil, "", 1, false, false, fsys) // Check error assert.NoError(t, err) assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -194,7 +194,7 @@ import_map = "./import_map.json" fsys := afero.NewMemMapFs() require.NoError(t, utils.WriteConfig(fsys, false)) // Run test - err := Run(context.Background(), []string{"_invalid"}, true, nil, "", 1, false, fsys) + err := Run(context.Background(), []string{"_invalid"}, true, nil, "", 1, false, false, fsys) // Check error assert.ErrorContains(t, err, "Invalid Function name.") }) @@ -204,7 +204,7 @@ import_map = "./import_map.json" fsys := afero.NewMemMapFs() require.NoError(t, utils.WriteConfig(fsys, false)) // Run test - err := Run(context.Background(), nil, true, nil, "", 1, false, fsys) + err := Run(context.Background(), nil, true, nil, "", 1, false, false, fsys) // Check error assert.ErrorContains(t, err, "No Functions specified or found in supabase/functions") }) @@ -250,7 +250,7 @@ verify_jwt = false outputDir := filepath.Join(utils.TempDir, fmt.Sprintf(".output_%s", slug)) require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644)) // Run test - assert.NoError(t, Run(context.Background(), []string{slug}, true, nil, "", 1, false, fsys)) + assert.NoError(t, Run(context.Background(), []string{slug}, true, nil, "", 1, false, false, fsys)) // Validate api assert.Empty(t, apitest.ListUnmatchedRequests()) }) @@ -297,7 +297,7 @@ verify_jwt = false require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644)) // Run test noVerifyJWT := false - assert.NoError(t, Run(context.Background(), []string{slug}, true, &noVerifyJWT, "", 1, false, fsys)) + assert.NoError(t, Run(context.Background(), []string{slug}, true, &noVerifyJWT, "", 1, false, false, fsys)) // Validate api assert.Empty(t, apitest.ListUnmatchedRequests()) }) diff --git a/pkg/function/deploy.go b/pkg/function/deploy.go index 88e2f464a..362d1a3b1 100644 --- a/pkg/function/deploy.go +++ b/pkg/function/deploy.go @@ -19,17 +19,23 @@ import ( var ErrNoDeploy = errors.New("All Functions are up to date.") -func (s *EdgeRuntimeAPI) Deploy(ctx context.Context, functionConfig config.FunctionConfig, fsys fs.FS) error { +func (s *EdgeRuntimeAPI) Deploy(ctx context.Context, functionConfig config.FunctionConfig, fsys fs.FS, filter ...func(string) bool) error { if s.eszip != nil { - return s.UpsertFunctions(ctx, functionConfig) + return s.UpsertFunctions(ctx, functionConfig, filter...) } // Convert all paths in functions config to relative when using api deploy var toDeploy []FunctionDeployMetadata +OUTER: for slug, fc := range functionConfig { if !fc.Enabled { fmt.Fprintln(os.Stderr, "Skipping disabled Function:", slug) continue } + for _, keep := range filter { + if !keep(slug) { + continue OUTER + } + } meta := FunctionDeployMetadata{ Name: &slug, EntrypointPath: toRelPath(fc.Entrypoint),