Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/overlay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ actions:
- target: $.components.schemas.*.properties.connectionString
description: Removes deprecated field that conflicts with naming convention
remove: true
- target: $.components.schemas.*.properties.private_jwk.discriminator
description: Replaces discriminated union with concrete type
remove: true
4 changes: 3 additions & 1 deletion cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ var (
useLegacyBundle bool
noVerifyJWT = new(bool)
importMapPath string
prune bool

functionsDeployCmd = &cobra.Command{
Use: "deploy [Function name]",
Expand All @@ -73,7 +74,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, afero.NewOsFs())
return deploy.Run(cmd.Context(), args, useDocker, noVerifyJWT, importMapPath, maxJobs, prune, afero.NewOsFs())
},
}

Expand Down Expand Up @@ -139,6 +140,7 @@ func init() {
cobra.CheckErr(deployFlags.MarkHidden("legacy-bundle"))
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.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.")
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ func init() {
})

flags := rootCmd.PersistentFlags()
flags.Bool("yes", false, "answer yes to all prompts")
flags.Bool("debug", false, "output debug logs to stderr")
flags.String("workdir", "", "path to a Supabase project directory")
flags.Bool("experimental", false, "enable experimental features")
Expand Down
26 changes: 14 additions & 12 deletions internal/functions/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,29 @@ import (
)

func Run(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
// 1. Sanity checks.
{
if err := utils.ValidateFunctionSlug(slug); err != nil {
return err
}
if err := utils.ValidateFunctionSlug(slug); err != nil {
return err
}
if err := Undeploy(ctx, projectRef, slug); err != nil {
return err
}
fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), utils.Aqua(projectRef))
return nil
}

// 2. Delete Function.
var ErrNoDelete = errors.New("nothing to delete")

func Undeploy(ctx context.Context, projectRef string, slug string) error {
resp, err := utils.GetSupabase().V1DeleteAFunctionWithResponse(ctx, projectRef, slug)
if err != nil {
return errors.Errorf("failed to delete function: %w", err)
}
switch resp.StatusCode() {
case http.StatusNotFound:
return errors.New("Function " + utils.Aqua(slug) + " does not exist on the Supabase project.")
return errors.Errorf("Function %s does not exist on the Supabase project: %w", slug, ErrNoDelete)
case http.StatusOK:
break
return nil
default:
return errors.New("Failed to delete Function " + utils.Aqua(slug) + " on the Supabase project: " + string(resp.Body))
return errors.Errorf("unexpected delete function status %d: %s", resp.StatusCode(), string(resp.Body))
}

fmt.Println("Deleted Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
return nil
}
4 changes: 2 additions & 2 deletions internal/functions/delete/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestDeleteCommand(t *testing.T) {
// Run test
err := Run(context.Background(), slug, project, fsys)
// Check error
assert.ErrorContains(t, err, "Function test-func does not exist on the Supabase project.")
assert.ErrorIs(t, err, ErrNoDelete)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

Expand All @@ -88,7 +88,7 @@ func TestDeleteCommand(t *testing.T) {
// Run test
err := Run(context.Background(), slug, project, fsys)
// Check error
assert.ErrorContains(t, err, "Failed to delete Function test-func on the Supabase project:")
assert.ErrorContains(t, err, "unexpected delete function status 503:")
assert.Empty(t, apitest.ListUnmatchedRequests())
})
}
58 changes: 56 additions & 2 deletions internal/functions/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import (

"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/functions/delete"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
"github.com/supabase/cli/pkg/api"
"github.com/supabase/cli/pkg/config"
"github.com/supabase/cli/pkg/function"
)

func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, fsys afero.Fs) error {
func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, prune bool, fsys afero.Fs) error {
// Load function config and project id
if err := flags.LoadConfig(fsys); err != nil {
return err
Expand Down Expand Up @@ -49,6 +51,7 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool,
if err != nil {
return err
}
// Deploy new and updated functions
opt := function.WithMaxJobs(maxJobs)
if useDocker {
if utils.IsDockerRunning(ctx) {
Expand All @@ -67,7 +70,10 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool,
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)
return nil
if !prune {
return nil
}
return pruneFunctions(ctx, functionConfig)
}

func GetFunctionSlugs(fsys afero.Fs) (slugs []string, err error) {
Expand Down Expand Up @@ -155,3 +161,51 @@ func GetFunctionConfig(slugs []string, importMapPath string, noVerifyJWT *bool,
}
return functionConfig, nil
}

// pruneFunctions deletes functions that exist remotely but not locally
func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig) error {
resp, err := utils.GetSupabase().V1ListAllFunctionsWithResponse(ctx, flags.ProjectRef)
if err != nil {
return errors.Errorf("failed to list functions: %w", err)
} else if resp.JSON200 == nil {
return errors.Errorf("unexpected list functions status %d: %s", resp.StatusCode(), string(resp.Body))
}
// No need to delete disabled functions
var toDelete []string
for _, deployed := range *resp.JSON200 {
if deployed.Status == api.FunctionResponseStatusREMOVED {
continue
} else if _, exists := functionConfig[deployed.Slug]; exists {
continue
}
toDelete = append(toDelete, deployed.Slug)
}
if len(toDelete) == 0 {
fmt.Fprintln(os.Stderr, "No functions to prune.")
return nil
}
// Confirm before pruning functions
msg := fmt.Sprintln(confirmPruneAll(toDelete))
if shouldDelete, err := utils.NewConsole().PromptYesNo(ctx, msg, false); err != nil {
return err
} else if !shouldDelete {
return errors.New(context.Canceled)
}
for _, slug := range toDelete {
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)
} else if err != nil {
return err
}
}
return nil
}

func confirmPruneAll(pending []string) string {
msg := fmt.Sprintln("Do you want to delete the following functions?")
for _, slug := range pending {
msg += fmt.Sprintf(" • %s\n", utils.Bold(slug))
}
return msg
}
104 changes: 96 additions & 8 deletions internal/functions/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/h2non/gock"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/supabase/cli/internal/testing/apitest"
Expand Down Expand Up @@ -74,7 +75,7 @@ func TestDeployCommand(t *testing.T) {
}
// Run test
noVerifyJWT := true
err = Run(context.Background(), functions, true, &noVerifyJWT, "", 1, fsys)
err = Run(context.Background(), functions, true, &noVerifyJWT, "", 1, false, fsys)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
Expand Down Expand Up @@ -129,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, fsys)
err = Run(context.Background(), nil, true, nil, "", 1, false, fsys)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
Expand Down Expand Up @@ -182,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, fsys)
err = Run(context.Background(), nil, true, nil, "", 1, false, fsys)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
Expand All @@ -193,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, fsys)
err := Run(context.Background(), []string{"_invalid"}, true, nil, "", 1, false, fsys)
// Check error
assert.ErrorContains(t, err, "Invalid Function name.")
})
Expand All @@ -203,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, fsys)
err := Run(context.Background(), nil, true, nil, "", 1, false, fsys)
// Check error
assert.ErrorContains(t, err, "No Functions specified or found in supabase/functions")
})
Expand Down Expand Up @@ -249,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, fsys))
assert.NoError(t, Run(context.Background(), []string{slug}, true, nil, "", 1, false, fsys))
// Validate api
assert.Empty(t, apitest.ListUnmatchedRequests())
})
Expand Down Expand Up @@ -295,8 +296,8 @@ 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
noVerifyJwt := false
assert.NoError(t, Run(context.Background(), []string{slug}, true, &noVerifyJwt, "", 1, fsys))
noVerifyJWT := false
assert.NoError(t, Run(context.Background(), []string{slug}, true, &noVerifyJWT, "", 1, false, fsys))
// Validate api
assert.Empty(t, apitest.ListUnmatchedRequests())
})
Expand Down Expand Up @@ -372,3 +373,90 @@ func TestImportMapPath(t *testing.T) {
assert.Equal(t, path, fc["test"].ImportMap)
})
}

func TestPruneFunctions(t *testing.T) {
flags.ProjectRef = apitest.RandomProjectRef()
token := apitest.RandomAccessToken(t)
t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
viper.Set("YES", true)

t.Run("prunes functions not in local directory", func(t *testing.T) {
// Setup function entrypoints
localFunctions := config.FunctionConfig{
"local-func-1": {Enabled: true},
"local-func-2": {Enabled: true},
}
// Setup mock api - remote functions include local ones plus orphaned ones
defer gock.OffAll()
remoteFunctions := []api.FunctionResponse{
{Slug: "local-func-1"},
{Slug: "local-func-2"},
{Slug: "orphaned-func-1"},
{Slug: "orphaned-func-2"},
}
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + flags.ProjectRef + "/functions").
Reply(http.StatusOK).
JSON(remoteFunctions)
gock.New(utils.DefaultApiHost).
Delete("/v1/projects/" + flags.ProjectRef + "/functions/orphaned-func-1").
Reply(http.StatusOK)
gock.New(utils.DefaultApiHost).
Delete("/v1/projects/" + flags.ProjectRef + "/functions/orphaned-func-2").
Reply(http.StatusOK)
// Run test with prune and force (to skip confirmation)
err := pruneFunctions(context.Background(), localFunctions)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("skips pruning when no orphaned functions", func(t *testing.T) {
// Setup function entrypoints
localFunctions := config.FunctionConfig{
"local-func-1": {},
"local-func-2": {},
}
// Setup mock api - remote functions match local ones exactly
defer gock.OffAll()
remoteFunctions := []api.FunctionResponse{
{Slug: "local-func-1"},
{Slug: "local-func-2"},
{Slug: "orphaned-func-1", Status: api.FunctionResponseStatusREMOVED},
{Slug: "orphaned-func-2", Status: api.FunctionResponseStatusREMOVED},
}
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + flags.ProjectRef + "/functions").
Reply(http.StatusOK).
JSON(remoteFunctions)
// Run test with prune and force
err := pruneFunctions(context.Background(), localFunctions)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("handles 404 on delete gracefully", func(t *testing.T) {
// Setup function entrypoints
localFunctions := config.FunctionConfig{"local-func": {}}
// Setup mock api
defer gock.OffAll()
remoteFunctions := []api.FunctionResponse{
{Slug: "local-func"},
{Slug: "orphaned-func"},
}
gock.New(utils.DefaultApiHost).
Get("/v1/projects/" + flags.ProjectRef + "/functions").
Reply(http.StatusOK).
JSON(remoteFunctions)
// Mock delete endpoint with 404 (function already deleted)
gock.New(utils.DefaultApiHost).
Delete("/v1/projects/" + flags.ProjectRef + "/functions/orphaned-func").
Reply(http.StatusNotFound)
// Run test with prune and force
err := pruneFunctions(context.Background(), localFunctions)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
})
}
5 changes: 5 additions & 0 deletions internal/utils/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/go-errors/errors"
"github.com/spf13/viper"
"github.com/supabase/cli/pkg/cast"
"golang.org/x/term"
)
Expand Down Expand Up @@ -66,6 +67,10 @@ func (c *Console) PromptYesNo(ctx context.Context, label string, def bool) (bool
choices = "y/N"
}
labelWithChoice := fmt.Sprintf("%s [%s] ", label, choices)
if viper.GetBool("YES") {
fmt.Fprintln(os.Stderr, labelWithChoice+"y")
return true, nil
}
// Any error will be handled as default value
input, err := c.PromptText(ctx, labelWithChoice)
if len(input) > 0 {
Expand Down
Loading