Skip to content

Commit 372ed92

Browse files
feat: add --prune option to remove orphaned functions after deploy (#3720)
* Fix #3719 (Add --prune option to 'supabase functions deploy' to remove orphaned functions) * chore: address PR comments * chore: simplify unit tests * chore: update api spec --------- Co-authored-by: Qiao Han <qiao@supabase.io> Co-authored-by: Han Qiao <sweatybridge@gmail.com>
1 parent 67d36ee commit 372ed92

File tree

9 files changed

+396
-28
lines changed

9 files changed

+396
-28
lines changed

api/overlay.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,6 @@ actions:
2626
- target: $.components.schemas.*.properties.connectionString
2727
description: Removes deprecated field that conflicts with naming convention
2828
remove: true
29+
- target: $.components.schemas.*.properties.private_jwk.discriminator
30+
description: Replaces discriminated union with concrete type
31+
remove: true

cmd/functions.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ var (
5858
useLegacyBundle bool
5959
noVerifyJWT = new(bool)
6060
importMapPath string
61+
prune bool
6162

6263
functionsDeployCmd = &cobra.Command{
6364
Use: "deploy [Function name]",
@@ -73,7 +74,7 @@ var (
7374
} else if maxJobs > 1 {
7475
return errors.New("--jobs must be used together with --use-api")
7576
}
76-
return deploy.Run(cmd.Context(), args, useDocker, noVerifyJWT, importMapPath, maxJobs, afero.NewOsFs())
77+
return deploy.Run(cmd.Context(), args, useDocker, noVerifyJWT, importMapPath, maxJobs, prune, afero.NewOsFs())
7778
},
7879
}
7980

@@ -139,6 +140,7 @@ func init() {
139140
cobra.CheckErr(deployFlags.MarkHidden("legacy-bundle"))
140141
deployFlags.UintVarP(&maxJobs, "jobs", "j", 1, "Maximum number of parallel jobs.")
141142
deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
143+
deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.")
142144
deployFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
143145
deployFlags.StringVar(&importMapPath, "import-map", "", "Path to import map file.")
144146
functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ func init() {
232232
})
233233

234234
flags := rootCmd.PersistentFlags()
235+
flags.Bool("yes", false, "answer yes to all prompts")
235236
flags.Bool("debug", false, "output debug logs to stderr")
236237
flags.String("workdir", "", "path to a Supabase project directory")
237238
flags.Bool("experimental", false, "enable experimental features")

internal/functions/delete/delete.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,29 @@ import (
1111
)
1212

1313
func Run(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
14-
// 1. Sanity checks.
15-
{
16-
if err := utils.ValidateFunctionSlug(slug); err != nil {
17-
return err
18-
}
14+
if err := utils.ValidateFunctionSlug(slug); err != nil {
15+
return err
1916
}
17+
if err := Undeploy(ctx, projectRef, slug); err != nil {
18+
return err
19+
}
20+
fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), utils.Aqua(projectRef))
21+
return nil
22+
}
2023

21-
// 2. Delete Function.
24+
var ErrNoDelete = errors.New("nothing to delete")
25+
26+
func Undeploy(ctx context.Context, projectRef string, slug string) error {
2227
resp, err := utils.GetSupabase().V1DeleteAFunctionWithResponse(ctx, projectRef, slug)
2328
if err != nil {
2429
return errors.Errorf("failed to delete function: %w", err)
2530
}
2631
switch resp.StatusCode() {
2732
case http.StatusNotFound:
28-
return errors.New("Function " + utils.Aqua(slug) + " does not exist on the Supabase project.")
33+
return errors.Errorf("Function %s does not exist on the Supabase project: %w", slug, ErrNoDelete)
2934
case http.StatusOK:
30-
break
35+
return nil
3136
default:
32-
return errors.New("Failed to delete Function " + utils.Aqua(slug) + " on the Supabase project: " + string(resp.Body))
37+
return errors.Errorf("unexpected delete function status %d: %s", resp.StatusCode(), string(resp.Body))
3338
}
34-
35-
fmt.Println("Deleted Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
36-
return nil
3739
}

internal/functions/delete/delete_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ func TestDeleteCommand(t *testing.T) {
7373
// Run test
7474
err := Run(context.Background(), slug, project, fsys)
7575
// Check error
76-
assert.ErrorContains(t, err, "Function test-func does not exist on the Supabase project.")
76+
assert.ErrorIs(t, err, ErrNoDelete)
7777
assert.Empty(t, apitest.ListUnmatchedRequests())
7878
})
7979

@@ -88,7 +88,7 @@ func TestDeleteCommand(t *testing.T) {
8888
// Run test
8989
err := Run(context.Background(), slug, project, fsys)
9090
// Check error
91-
assert.ErrorContains(t, err, "Failed to delete Function test-func on the Supabase project:")
91+
assert.ErrorContains(t, err, "unexpected delete function status 503:")
9292
assert.Empty(t, apitest.ListUnmatchedRequests())
9393
})
9494
}

internal/functions/deploy/deploy.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import (
99

1010
"github.com/go-errors/errors"
1111
"github.com/spf13/afero"
12+
"github.com/supabase/cli/internal/functions/delete"
1213
"github.com/supabase/cli/internal/utils"
1314
"github.com/supabase/cli/internal/utils/flags"
15+
"github.com/supabase/cli/pkg/api"
1416
"github.com/supabase/cli/pkg/config"
1517
"github.com/supabase/cli/pkg/function"
1618
)
1719

18-
func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, fsys afero.Fs) error {
20+
func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool, importMapPath string, maxJobs uint, prune bool, fsys afero.Fs) error {
1921
// Load function config and project id
2022
if err := flags.LoadConfig(fsys); err != nil {
2123
return err
@@ -49,6 +51,7 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool,
4951
if err != nil {
5052
return err
5153
}
54+
// Deploy new and updated functions
5255
opt := function.WithMaxJobs(maxJobs)
5356
if useDocker {
5457
if utils.IsDockerRunning(ctx) {
@@ -67,7 +70,10 @@ func Run(ctx context.Context, slugs []string, useDocker bool, noVerifyJWT *bool,
6770
fmt.Printf("Deployed Functions on project %s: %s\n", utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", "))
6871
url := fmt.Sprintf("%s/project/%v/functions", utils.GetSupabaseDashboardURL(), flags.ProjectRef)
6972
fmt.Println("You can inspect your deployment in the Dashboard: " + url)
70-
return nil
73+
if !prune {
74+
return nil
75+
}
76+
return pruneFunctions(ctx, functionConfig)
7177
}
7278

7379
func GetFunctionSlugs(fsys afero.Fs) (slugs []string, err error) {
@@ -155,3 +161,51 @@ func GetFunctionConfig(slugs []string, importMapPath string, noVerifyJWT *bool,
155161
}
156162
return functionConfig, nil
157163
}
164+
165+
// pruneFunctions deletes functions that exist remotely but not locally
166+
func pruneFunctions(ctx context.Context, functionConfig config.FunctionConfig) error {
167+
resp, err := utils.GetSupabase().V1ListAllFunctionsWithResponse(ctx, flags.ProjectRef)
168+
if err != nil {
169+
return errors.Errorf("failed to list functions: %w", err)
170+
} else if resp.JSON200 == nil {
171+
return errors.Errorf("unexpected list functions status %d: %s", resp.StatusCode(), string(resp.Body))
172+
}
173+
// No need to delete disabled functions
174+
var toDelete []string
175+
for _, deployed := range *resp.JSON200 {
176+
if deployed.Status == api.FunctionResponseStatusREMOVED {
177+
continue
178+
} else if _, exists := functionConfig[deployed.Slug]; exists {
179+
continue
180+
}
181+
toDelete = append(toDelete, deployed.Slug)
182+
}
183+
if len(toDelete) == 0 {
184+
fmt.Fprintln(os.Stderr, "No functions to prune.")
185+
return nil
186+
}
187+
// Confirm before pruning functions
188+
msg := fmt.Sprintln(confirmPruneAll(toDelete))
189+
if shouldDelete, err := utils.NewConsole().PromptYesNo(ctx, msg, false); err != nil {
190+
return err
191+
} else if !shouldDelete {
192+
return errors.New(context.Canceled)
193+
}
194+
for _, slug := range toDelete {
195+
fmt.Fprintln(os.Stderr, "Deleting Function:", slug)
196+
if err := delete.Undeploy(ctx, flags.ProjectRef, slug); errors.Is(err, delete.ErrNoDelete) {
197+
fmt.Fprintln(utils.GetDebugLogger(), err)
198+
} else if err != nil {
199+
return err
200+
}
201+
}
202+
return nil
203+
}
204+
205+
func confirmPruneAll(pending []string) string {
206+
msg := fmt.Sprintln("Do you want to delete the following functions?")
207+
for _, slug := range pending {
208+
msg += fmt.Sprintf(" • %s\n", utils.Bold(slug))
209+
}
210+
return msg
211+
}

internal/functions/deploy/deploy_test.go

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/h2non/gock"
1313
"github.com/spf13/afero"
14+
"github.com/spf13/viper"
1415
"github.com/stretchr/testify/assert"
1516
"github.com/stretchr/testify/require"
1617
"github.com/supabase/cli/internal/testing/apitest"
@@ -74,7 +75,7 @@ func TestDeployCommand(t *testing.T) {
7475
}
7576
// Run test
7677
noVerifyJWT := true
77-
err = Run(context.Background(), functions, true, &noVerifyJWT, "", 1, fsys)
78+
err = Run(context.Background(), functions, true, &noVerifyJWT, "", 1, false, fsys)
7879
// Check error
7980
assert.NoError(t, err)
8081
assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -129,7 +130,7 @@ import_map = "./import_map.json"
129130
outputDir := filepath.Join(utils.TempDir, fmt.Sprintf(".output_%s", slug))
130131
require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644))
131132
// Run test
132-
err = Run(context.Background(), nil, true, nil, "", 1, fsys)
133+
err = Run(context.Background(), nil, true, nil, "", 1, false, fsys)
133134
// Check error
134135
assert.NoError(t, err)
135136
assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -182,7 +183,7 @@ import_map = "./import_map.json"
182183
outputDir := filepath.Join(utils.TempDir, ".output_enabled-func")
183184
require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644))
184185
// Run test
185-
err = Run(context.Background(), nil, true, nil, "", 1, fsys)
186+
err = Run(context.Background(), nil, true, nil, "", 1, false, fsys)
186187
// Check error
187188
assert.NoError(t, err)
188189
assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -193,7 +194,7 @@ import_map = "./import_map.json"
193194
fsys := afero.NewMemMapFs()
194195
require.NoError(t, utils.WriteConfig(fsys, false))
195196
// Run test
196-
err := Run(context.Background(), []string{"_invalid"}, true, nil, "", 1, fsys)
197+
err := Run(context.Background(), []string{"_invalid"}, true, nil, "", 1, false, fsys)
197198
// Check error
198199
assert.ErrorContains(t, err, "Invalid Function name.")
199200
})
@@ -203,7 +204,7 @@ import_map = "./import_map.json"
203204
fsys := afero.NewMemMapFs()
204205
require.NoError(t, utils.WriteConfig(fsys, false))
205206
// Run test
206-
err := Run(context.Background(), nil, true, nil, "", 1, fsys)
207+
err := Run(context.Background(), nil, true, nil, "", 1, false, fsys)
207208
// Check error
208209
assert.ErrorContains(t, err, "No Functions specified or found in supabase/functions")
209210
})
@@ -249,7 +250,7 @@ verify_jwt = false
249250
outputDir := filepath.Join(utils.TempDir, fmt.Sprintf(".output_%s", slug))
250251
require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644))
251252
// Run test
252-
assert.NoError(t, Run(context.Background(), []string{slug}, true, nil, "", 1, fsys))
253+
assert.NoError(t, Run(context.Background(), []string{slug}, true, nil, "", 1, false, fsys))
253254
// Validate api
254255
assert.Empty(t, apitest.ListUnmatchedRequests())
255256
})
@@ -295,8 +296,8 @@ verify_jwt = false
295296
outputDir := filepath.Join(utils.TempDir, fmt.Sprintf(".output_%s", slug))
296297
require.NoError(t, afero.WriteFile(fsys, filepath.Join(outputDir, "output.eszip"), []byte(""), 0644))
297298
// Run test
298-
noVerifyJwt := false
299-
assert.NoError(t, Run(context.Background(), []string{slug}, true, &noVerifyJwt, "", 1, fsys))
299+
noVerifyJWT := false
300+
assert.NoError(t, Run(context.Background(), []string{slug}, true, &noVerifyJWT, "", 1, false, fsys))
300301
// Validate api
301302
assert.Empty(t, apitest.ListUnmatchedRequests())
302303
})
@@ -372,3 +373,90 @@ func TestImportMapPath(t *testing.T) {
372373
assert.Equal(t, path, fc["test"].ImportMap)
373374
})
374375
}
376+
377+
func TestPruneFunctions(t *testing.T) {
378+
flags.ProjectRef = apitest.RandomProjectRef()
379+
token := apitest.RandomAccessToken(t)
380+
t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
381+
viper.Set("YES", true)
382+
383+
t.Run("prunes functions not in local directory", func(t *testing.T) {
384+
// Setup function entrypoints
385+
localFunctions := config.FunctionConfig{
386+
"local-func-1": {Enabled: true},
387+
"local-func-2": {Enabled: true},
388+
}
389+
// Setup mock api - remote functions include local ones plus orphaned ones
390+
defer gock.OffAll()
391+
remoteFunctions := []api.FunctionResponse{
392+
{Slug: "local-func-1"},
393+
{Slug: "local-func-2"},
394+
{Slug: "orphaned-func-1"},
395+
{Slug: "orphaned-func-2"},
396+
}
397+
gock.New(utils.DefaultApiHost).
398+
Get("/v1/projects/" + flags.ProjectRef + "/functions").
399+
Reply(http.StatusOK).
400+
JSON(remoteFunctions)
401+
gock.New(utils.DefaultApiHost).
402+
Delete("/v1/projects/" + flags.ProjectRef + "/functions/orphaned-func-1").
403+
Reply(http.StatusOK)
404+
gock.New(utils.DefaultApiHost).
405+
Delete("/v1/projects/" + flags.ProjectRef + "/functions/orphaned-func-2").
406+
Reply(http.StatusOK)
407+
// Run test with prune and force (to skip confirmation)
408+
err := pruneFunctions(context.Background(), localFunctions)
409+
// Check error
410+
assert.NoError(t, err)
411+
assert.Empty(t, apitest.ListUnmatchedRequests())
412+
})
413+
414+
t.Run("skips pruning when no orphaned functions", func(t *testing.T) {
415+
// Setup function entrypoints
416+
localFunctions := config.FunctionConfig{
417+
"local-func-1": {},
418+
"local-func-2": {},
419+
}
420+
// Setup mock api - remote functions match local ones exactly
421+
defer gock.OffAll()
422+
remoteFunctions := []api.FunctionResponse{
423+
{Slug: "local-func-1"},
424+
{Slug: "local-func-2"},
425+
{Slug: "orphaned-func-1", Status: api.FunctionResponseStatusREMOVED},
426+
{Slug: "orphaned-func-2", Status: api.FunctionResponseStatusREMOVED},
427+
}
428+
gock.New(utils.DefaultApiHost).
429+
Get("/v1/projects/" + flags.ProjectRef + "/functions").
430+
Reply(http.StatusOK).
431+
JSON(remoteFunctions)
432+
// Run test with prune and force
433+
err := pruneFunctions(context.Background(), localFunctions)
434+
// Check error
435+
assert.NoError(t, err)
436+
assert.Empty(t, apitest.ListUnmatchedRequests())
437+
})
438+
439+
t.Run("handles 404 on delete gracefully", func(t *testing.T) {
440+
// Setup function entrypoints
441+
localFunctions := config.FunctionConfig{"local-func": {}}
442+
// Setup mock api
443+
defer gock.OffAll()
444+
remoteFunctions := []api.FunctionResponse{
445+
{Slug: "local-func"},
446+
{Slug: "orphaned-func"},
447+
}
448+
gock.New(utils.DefaultApiHost).
449+
Get("/v1/projects/" + flags.ProjectRef + "/functions").
450+
Reply(http.StatusOK).
451+
JSON(remoteFunctions)
452+
// Mock delete endpoint with 404 (function already deleted)
453+
gock.New(utils.DefaultApiHost).
454+
Delete("/v1/projects/" + flags.ProjectRef + "/functions/orphaned-func").
455+
Reply(http.StatusNotFound)
456+
// Run test with prune and force
457+
err := pruneFunctions(context.Background(), localFunctions)
458+
// Check error
459+
assert.NoError(t, err)
460+
assert.Empty(t, apitest.ListUnmatchedRequests())
461+
})
462+
}

internal/utils/console.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

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

0 commit comments

Comments
 (0)