Repository: supabase/cli
Pull request #1111 (2023-05-17 07:27:23)

feat: support deploying all functions
## What kind of change does this PR introduce?

feature

closes https://github.com/supabase/cli/issues/456

## What is the new behavior?

- deploys all functions in `supabase/functions` sequentially
- avoids copying build scripts repeatedly for each function
- allow configuring max concurrency

TODO:
- [ ] fix api race condition when deploying concurrently

## Additional context

We could also consider deploying a single bundle with different entrypoint for each function.

```bash
$ supabase functions deploy
Version 1.30.3 is already installed
Bundling hello
Deploying hello (script size: 306.9kB)
Deployed Function hello on project cimpzzikygouamvsjwxa
You can inspect your deployment in the Dashboard: https://app.supabase.com/project/cimpzzikygouamvsjwxa/functions/hello/details
Bundling hello_world
Deploying hello_world (script size: 21.14kB)
Deployed Function hello_world on project cimpzzikygouamvsjwxa
You can inspect your deployment in the Dashboard: https://app.supabase.com/project/cimpzzikygouamvsjwxa/functions/hello_world/details
```


coveralls: ## Pull Request Test Coverage Report for [Build 5000531365](https://coveralls.io/builds/60032763)

* **73** of **82**   **(89.02%)**  changed or added relevant lines in **2** files are covered.
* **31** unchanged lines in **2** files lost coverage.
* Overall coverage decreased (**-0.2%**) to **62.697%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [cmd/functions.go](https://coveralls.io/builds/60032763/source?filename=cmd%2Ffunctions.go#L59) | 0 | 1 | 0.0%
| [internal/functions/deploy/deploy.go](https://coveralls.io/builds/60032763/source?filename=internal%2Ffunctions%2Fdeploy%2Fdeploy.go#L46) | 73 | 81 | 90.12%
<!-- | **Total:** | **73** | **82** | **89.02%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [cmd/functions.go](https://coveralls.io/builds/60032763/source?filename=cmd%2Ffunctions.go#L54) | 1 | 32.26% |
| [internal/utils/deno.go](https://coveralls.io/builds/60032763/source?filename=internal%2Futils%2Fdeno.go#L72) | 30 | 55.23% |
<!-- | **Total:** | **31** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/60032763/badge)](https://coveralls.io/builds/60032763) |
| :-- | --: |
| Change from base [Build 4993800701](https://coveralls.io/builds/60008021): |  -0.2% |
| Covered Lines: | 4491 |
| Relevant Lines: | 7163 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/cmd/functions.go b/cmd/functions.go
index 129e54de..1160d33b 100644
--- a/cmd/functions.go
+++ b/cmd/functions.go
@@ -51,13 +51,12 @@ var (
 		Use:   "deploy <Function name>",
 		Short: "Deploy a Function to Supabase",
 		Long:  "Deploy a Function to the linked Supabase project.",
-		Args:  cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			// Fallback to config if user did not set the flag.
 			if !cmd.Flags().Changed("no-verify-jwt") {
 				noVerifyJWT = nil
 			}
-			return deploy.Run(cmd.Context(), args[0], flags.ProjectRef, noVerifyJWT, importMapPath, afero.NewOsFs())
+			return deploy.Run(cmd.Context(), args, flags.ProjectRef, noVerifyJWT, importMapPath, afero.NewOsFs())
 		},
 	}
 
diff --git a/internal/functions/deploy/deploy.go b/internal/functions/deploy/deploy.go
index 3f10b744..433472ce 100644
--- a/internal/functions/deploy/deploy.go
+++ b/internal/functions/deploy/deploy.go
@@ -18,66 +18,34 @@ import (
 
 const eszipContentType = "application/vnd.denoland.eszip"
 
-func Run(ctx context.Context, slug string, projectRef string, noVerifyJWT *bool, importMapPath string, fsys afero.Fs) error {
-	// 1. Sanity checks.
-	{
-		// Load function config if any for fallbacks for some flags, but continue on error.
-		_ = utils.LoadConfigFS(fsys)
-		// Ensure noVerifyJWT is not nil.
-		if noVerifyJWT == nil {
-			x := false
-			if functionConfig, ok := utils.Config.Functions[slug]; ok && !*functionConfig.VerifyJWT {
-				x = true
-			}
-			noVerifyJWT = &x
-		}
-		resolved, err := utils.AbsImportMapPath(importMapPath, slug, fsys)
+func Run(ctx context.Context, slugs []string, projectRef string, noVerifyJWT *bool, importMapPath string, fsys afero.Fs) error {
+	// Load function config if any for fallbacks for some flags, but continue on error.
+	_ = utils.LoadConfigFS(fsys)
+	if len(slugs) == 0 {
+		allSlugs, err := getFunctionSlugs(fsys)
 		if err != nil {
 			return err
 		}
-		// Upstream server expects import map to be always defined
-		if importMapPath == "" {
-			resolved, err = filepath.Abs(utils.FallbackImportMapPath)
-			if err != nil {
-				return err
-			}
-		}
-		importMapPath = resolved
-		if err := utils.ValidateFunctionSlug(slug); err != nil {
-			return err
-		}
-		if err := utils.InstallOrUpgradeDeno(ctx, fsys); err != nil {
-			return err
-		}
+		slugs = allSlugs
 	}
-
-	// 2. Bundle Function.
-	scriptDir, err := utils.CopyDenoScripts(ctx, fsys)
-	if err != nil {
-		return err
+	if len(slugs) == 0 {
+		return errors.New("No Functions specified or found in " + utils.Bold(utils.FunctionsDir))
 	}
-	entrypointPath, err := filepath.Abs(filepath.Join(utils.FunctionsDir, slug, "index.ts"))
+	return deployAll(ctx, slugs, projectRef, importMapPath, noVerifyJWT, fsys)
+}
+
+func getFunctionSlugs(fsys afero.Fs) ([]string, error) {
+	pattern := filepath.Join(utils.FunctionsDir, "*", "index.ts")
+	paths, err := afero.Glob(fsys, pattern)
 	if err != nil {
-		return err
+		return nil, err
 	}
-	fmt.Println("Bundling " + utils.Bold(slug))
-	functionBody, err := bundleFunction(ctx, entrypointPath, importMapPath, scriptDir.BuildPath, fsys)
-	if err != nil {
-		return err
+	var slugs []string
+	for _, path := range paths {
+		slug := filepath.Base(filepath.Dir(path))
+		slugs = append(slugs, slug)
 	}
-
-	// 3. Deploy new Function.
-	functionSize := units.HumanSize(float64(functionBody.Len()))
-	fmt.Println("Deploying " + utils.Bold(slug) + " (script size: " + utils.Bold(functionSize) + ")")
-	return deployFunction(
-		ctx,
-		projectRef,
-		slug,
-		"file://"+entrypointPath,
-		"file://"+importMapPath,
-		!*noVerifyJWT,
-		functionBody,
-	)
+	return slugs, nil
 }
 
 func bundleFunction(ctx context.Context, entrypointPath, importMapPath, buildScriptPath string, fsys afero.Fs) (*bytes.Buffer, error) {
@@ -139,3 +107,78 @@ func deployFunction(ctx context.Context, projectRef, slug, entrypointUrl, import
 	fmt.Println("You can inspect your deployment in the Dashboard: " + url)
 	return nil
 }
+
+func deployOne(ctx context.Context, slug, projectRef, importMapPath, buildScriptPath string, noVerifyJWT *bool, fsys afero.Fs) error {
+	// 1. Sanity checks.
+	if err := utils.ValidateFunctionSlug(slug); err != nil {
+		return err
+	}
+	// Ensure noVerifyJWT is not nil.
+	if noVerifyJWT == nil {
+		x := false
+		if functionConfig, ok := utils.Config.Functions[slug]; ok && !*functionConfig.VerifyJWT {
+			x = true
+		}
+		noVerifyJWT = &x
+	}
+	resolved, err := utils.AbsImportMapPath(importMapPath, slug, fsys)
+	if err != nil {
+		return err
+	}
+	// Upstream server expects import map to be always defined
+	if importMapPath == "" {
+		resolved, err = filepath.Abs(utils.FallbackImportMapPath)
+		if err != nil {
+			return err
+		}
+	}
+	importMapPath = resolved
+	// 2. Bundle Function.
+	entrypointPath, err := filepath.Abs(filepath.Join(utils.FunctionsDir, slug, "index.ts"))
+	if err != nil {
+		return err
+	}
+	fmt.Println("Bundling " + utils.Bold(slug))
+	functionBody, err := bundleFunction(ctx, entrypointPath, importMapPath, buildScriptPath, fsys)
+	if err != nil {
+		return err
+	}
+	// 3. Deploy new Function.
+	functionSize := units.HumanSize(float64(functionBody.Len()))
+	fmt.Println("Deploying " + utils.Bold(slug) + " (script size: " + utils.Bold(functionSize) + ")")
+	return deployFunction(
+		ctx,
+		projectRef,
+		slug,
+		"file://"+entrypointPath,
+		"file://"+importMapPath,
+		!*noVerifyJWT,
+		functionBody,
+	)
+}
+
+// TODO: api has a race condition that prevents deploying in parallel
+const maxConcurrency = 1
+
+func deployAll(ctx context.Context, slugs []string, projectRef, importMapPath string, noVerifyJWT *bool, fsys afero.Fs) error {
+	// Setup deno binaries
+	if err := utils.InstallOrUpgradeDeno(ctx, fsys); err != nil {
+		return err
+	}
+	scriptDir, err := utils.CopyDenoScripts(ctx, fsys)
+	if err != nil {
+		return err
+	}
+	errCh := make(chan error, maxConcurrency)
+	errCh <- nil
+	for _, slug := range slugs {
+		// Log all errors and proceed
+		if err := <-errCh; err != nil {
+			return err
+		}
+		go func(slug string) {
+			errCh <- deployOne(ctx, slug, projectRef, importMapPath, scriptDir.BuildPath, noVerifyJWT, fsys)
+		}(slug)
+	}
+	return <-errCh
+}


Test patch:
diff --git a/internal/functions/deploy/deploy_test.go b/internal/functions/deploy/deploy_test.go
index 4a5baf29..ec0635f9 100644
--- a/internal/functions/deploy/deploy_test.go
+++ b/internal/functions/deploy/deploy_test.go
@@ -41,10 +41,14 @@ func TestMain(m *testing.M) {
 	os.Exit(m.Run())
 }
 
-func TestDeployCommand(t *testing.T) {
+func TestDeployOne(t *testing.T) {
 	const slug = "test-func"
 
 	t.Run("deploys new function (ESZIP)", func(t *testing.T) {
+		entrypointPath, err := filepath.Abs(filepath.Join(utils.FunctionsDir, slug, "index.ts"))
+		require.NoError(t, err)
+		importMapPath, err := filepath.Abs(utils.FallbackImportMapPath)
+		require.NoError(t, err)
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		// Setup valid project ref
@@ -53,7 +57,7 @@ func TestDeployCommand(t *testing.T) {
 		token := apitest.RandomAccessToken(t)
 		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
 		// Setup valid deno path
-		_, err := fsys.Create(utils.DenoPathOverride)
+		_, err = fsys.Create(utils.DenoPathOverride)
 		require.NoError(t, err)
 		// Setup mock api
 		defer gock.OffAll()
@@ -61,13 +65,16 @@ func TestDeployCommand(t *testing.T) {
 			Get("/v1/projects/" + project + "/functions/" + slug).
 			Reply(http.StatusNotFound)
 		gock.New(utils.DefaultApiHost).
-			Post("/v1/projects/" + project + "/functions").
+			Post("/v1/projects/"+project+"/functions").
+			MatchParam("entrypoint_path", "file://"+entrypointPath).
+			MatchParam("import_map_path", "file://"+importMapPath).
 			Reply(http.StatusCreated).
 			JSON(api.FunctionResponse{Id: "1"})
 		// Run test
 		noVerifyJWT := true
-		assert.NoError(t, Run(context.Background(), slug, project, &noVerifyJWT, "", fsys))
-		// Validate api
+		err = deployOne(context.Background(), slug, project, "", "", &noVerifyJWT, fsys)
+		// Check error
+		assert.NoError(t, err)
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 
@@ -93,8 +100,9 @@ func TestDeployCommand(t *testing.T) {
 			Reply(http.StatusOK).
 			JSON(api.FunctionResponse{Id: "1"})
 		// Run test
-		assert.NoError(t, Run(context.Background(), slug, project, nil, "", fsys))
-		// Validate api
+		err = deployOne(context.Background(), slug, project, "", "", nil, fsys)
+		// Check error
+		assert.NoError(t, err)
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 
@@ -105,21 +113,20 @@ func TestDeployCommand(t *testing.T) {
 		project := apitest.RandomProjectRef()
 		// Run test
 		noVerifyJWT := true
-		err := Run(context.Background(), "@", project, &noVerifyJWT, "", fsys)
+		err := deployOne(context.Background(), "@", project, "", "", &noVerifyJWT, fsys)
 		// Check error
 		assert.ErrorContains(t, err, "Invalid Function name.")
 	})
 
-	t.Run("throws error on failure to install deno", func(t *testing.T) {
+	t.Run("throws error on missing import map", func(t *testing.T) {
 		// Setup in-memory fs
-		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
+		fsys := afero.NewMemMapFs()
 		// Setup valid project ref
 		project := apitest.RandomProjectRef()
 		// Run test
-		noVerifyJWT := true
-		err := Run(context.Background(), "test-func", project, &noVerifyJWT, "", fsys)
+		err := deployOne(context.Background(), slug, project, "import_map.json", "", nil, fsys)
 		// Check error
-		assert.ErrorContains(t, err, "operation not permitted")
+		assert.ErrorIs(t, err, os.ErrNotExist)
 	})
 
 	t.Run("throws error on bundle failure", func(t *testing.T) {
@@ -143,52 +150,118 @@ func TestDeployCommand(t *testing.T) {
 			Reply(http.StatusOK).
 			Body(&body)
 		// Run test
-		noVerifyJWT := true
-		err = Run(context.Background(), "test-func", project, &noVerifyJWT, "", fsys)
+		err = deployOne(context.Background(), slug, project, "", "", nil, fsys)
 		// Check error
 		assert.ErrorContains(t, err, "Error bundling function: exit status 1\nbundle failed\n")
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
+}
 
-	t.Run("throws error on ESZIP failure", func(t *testing.T) {
+func TestDeployAll(t *testing.T) {
+	const slug = "test-func"
+
+	t.Run("deploys multiple functions", func(t *testing.T) {
+		functions := []string{slug, slug + "-2"}
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		// Setup valid project ref
 		project := apitest.RandomProjectRef()
-		// Setup deno error
-		t.Setenv("TEST_DENO_ERROR", "eszip failed")
-		var body bytes.Buffer
-		archive := zip.NewWriter(&body)
-		w, err := archive.Create("deno")
-		require.NoError(t, err)
-		_, err = w.Write([]byte("binary"))
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+		// Setup valid deno path
+		_, err := fsys.Create(utils.DenoPathOverride)
 		require.NoError(t, err)
-		require.NoError(t, archive.Close())
 		// Setup mock api
 		defer gock.OffAll()
-		gock.New("https://github.com").
-			Get("/denoland/deno/releases/download/v" + utils.DenoVersion).
-			Reply(http.StatusOK).
-			Body(&body)
-
+		for i := range functions {
+			// Do not match slug to avoid flakey tests
+			gock.New(utils.DefaultApiHost).
+				Get("/v1/projects/" + project + "/functions/").
+				Reply(http.StatusNotFound)
+			gock.New(utils.DefaultApiHost).
+				Post("/v1/projects/" + project + "/functions").
+				Reply(http.StatusCreated).
+				JSON(api.FunctionResponse{Id: fmt.Sprintf("%d", i)})
+		}
+		// Run test
 		noVerifyJWT := true
-		err = Run(context.Background(), "test-func", project, &noVerifyJWT, "", fsys)
+		err = deployAll(context.Background(), functions, project, "", &noVerifyJWT, fsys)
 		// Check error
-		assert.ErrorContains(t, err, "Error bundling function: exit status 1\neszip failed\n")
+		assert.NoError(t, err)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 
-	t.Run("verify_jwt param falls back to config", func(t *testing.T) {
+	t.Run("throws error on failure to install deno", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
+		// Setup valid project ref
+		project := apitest.RandomProjectRef()
+		// Run test
+		err := deployAll(context.Background(), []string{slug}, project, "", nil, fsys)
+		// Check error
+		assert.ErrorContains(t, err, "operation not permitted")
+	})
+
+	t.Run("throws error on copy failure", func(t *testing.T) {
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
-		require.NoError(t, utils.WriteConfig(fsys, false))
-		f, err := fsys.OpenFile("supabase/config.toml", os.O_APPEND|os.O_WRONLY, 0600)
+		// Setup valid project ref
+		project := apitest.RandomProjectRef()
+		// Setup valid deno path
+		_, err := fsys.Create(utils.DenoPathOverride)
 		require.NoError(t, err)
-		_, err = f.WriteString(`
-[functions.` + slug + `]
-verify_jwt = false
-`)
+		// Run test
+		err = deployAll(context.Background(), []string{slug}, project, "", nil, afero.NewReadOnlyFs(fsys))
+		// Check error
+		assert.ErrorContains(t, err, "operation not permitted")
+	})
+}
+
+func TestDeployCommand(t *testing.T) {
+	const slug = "test-func"
+
+	t.Run("deploys multiple functions", func(t *testing.T) {
+		functions := []string{slug, slug + "-2"}
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Setup valid project ref
+		project := apitest.RandomProjectRef()
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+		// Setup valid deno path
+		_, err := fsys.Create(utils.DenoPathOverride)
 		require.NoError(t, err)
-		require.NoError(t, f.Close())
+		// Setup mock api
+		defer gock.OffAll()
+		for i := range functions {
+			// Do not match slug to avoid flakey tests
+			gock.New(utils.DefaultApiHost).
+				Get("/v1/projects/" + project + "/functions/").
+				Reply(http.StatusNotFound)
+			gock.New(utils.DefaultApiHost).
+				Post("/v1/projects/" + project + "/functions").
+				Reply(http.StatusCreated).
+				JSON(api.FunctionResponse{Id: fmt.Sprintf("%d", i)})
+		}
+		// Run test
+		noVerifyJWT := true
+		err = Run(context.Background(), functions, project, &noVerifyJWT, "", fsys)
+		// Check error
+		assert.NoError(t, err)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("deploys functions from directory", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		importMapPath, err := filepath.Abs(utils.FallbackImportMapPath)
+		require.NoError(t, err)
+		require.NoError(t, afero.WriteFile(fsys, importMapPath, []byte("{}"), 0644))
+		// Setup function entrypoint
+		entrypointPath := filepath.Join(utils.FunctionsDir, slug, "index.ts")
+		require.NoError(t, afero.WriteFile(fsys, entrypointPath, []byte{}, 0644))
 		// Setup valid project ref
 		project := apitest.RandomProjectRef()
 		// Setup valid access token
@@ -204,16 +277,28 @@ verify_jwt = false
 			Reply(http.StatusNotFound)
 		gock.New(utils.DefaultApiHost).
 			Post("/v1/projects/"+project+"/functions").
-			MatchParam("verify_jwt", "false").
+			MatchParam("slug", slug).
+			MatchParam("import_map_path", "file://"+importMapPath).
 			Reply(http.StatusCreated).
 			JSON(api.FunctionResponse{Id: "1"})
 		// Run test
-		assert.NoError(t, Run(context.Background(), slug, project, nil, "", fsys))
-		// Validate api
+		err = Run(context.Background(), nil, project, nil, "", fsys)
+		// Check error
+		assert.NoError(t, err)
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 
-	t.Run("verify_jwt flag overrides config", func(t *testing.T) {
+	t.Run("throws error on empty functions", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		require.NoError(t, fsys.MkdirAll(utils.FunctionsDir, 0755))
+		// Run test
+		err := Run(context.Background(), nil, "", nil, "", fsys)
+		// Check error
+		assert.ErrorContains(t, err, "No Functions specified or found in supabase/functions")
+	})
+
+	t.Run("verify_jwt param falls back to config", func(t *testing.T) {
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, utils.WriteConfig(fsys, false))
@@ -240,24 +325,27 @@ verify_jwt = false
 			Reply(http.StatusNotFound)
 		gock.New(utils.DefaultApiHost).
 			Post("/v1/projects/"+project+"/functions").
-			MatchParam("verify_jwt", "true").
+			MatchParam("verify_jwt", "false").
 			Reply(http.StatusCreated).
 			JSON(api.FunctionResponse{Id: "1"})
 		// Run test
-		noVerifyJwt := false
-		assert.NoError(t, Run(context.Background(), slug, project, &noVerifyJwt, "", fsys))
+		assert.NoError(t, Run(context.Background(), []string{slug}, project, nil, "", fsys))
 		// Validate api
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 
-	t.Run("uses fallback import map", func(t *testing.T) {
+	t.Run("verify_jwt flag overrides config", func(t *testing.T) {
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, utils.WriteConfig(fsys, false))
-		require.NoError(t, afero.WriteFile(fsys, utils.FallbackImportMapPath, []byte(""), 0644))
-		absPath, err := filepath.Abs(utils.FallbackImportMapPath)
+		f, err := fsys.OpenFile("supabase/config.toml", os.O_APPEND|os.O_WRONLY, 0600)
 		require.NoError(t, err)
-		require.NoError(t, afero.WriteFile(fsys, absPath, []byte("{}"), 0644))
+		_, err = f.WriteString(`
+[functions.` + slug + `]
+verify_jwt = false
+`)
+		require.NoError(t, err)
+		require.NoError(t, f.Close())
 		// Setup valid project ref
 		project := apitest.RandomProjectRef()
 		// Setup valid access token
@@ -273,12 +361,12 @@ verify_jwt = false
 			Reply(http.StatusNotFound)
 		gock.New(utils.DefaultApiHost).
 			Post("/v1/projects/"+project+"/functions").
-			MatchParam("import_map_path", absPath).
+			MatchParam("verify_jwt", "true").
 			Reply(http.StatusCreated).
 			JSON(api.FunctionResponse{Id: "1"})
 		// Run test
 		noVerifyJwt := false
-		assert.NoError(t, Run(context.Background(), slug, project, &noVerifyJwt, "", fsys))
+		assert.NoError(t, Run(context.Background(), []string{slug}, project, &noVerifyJwt, "", fsys))
 		// Validate api
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})