github.com/supabase/cli@v1.168.1/internal/functions/deploy/deploy.go (about)

     1  package deploy
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"os"
    10  	"path"
    11  	"path/filepath"
    12  
    13  	"github.com/andybalholm/brotli"
    14  	"github.com/cenkalti/backoff/v4"
    15  	"github.com/docker/docker/api/types/container"
    16  	"github.com/docker/docker/api/types/network"
    17  	"github.com/docker/go-units"
    18  	"github.com/go-errors/errors"
    19  	"github.com/spf13/afero"
    20  	"github.com/spf13/viper"
    21  	"github.com/supabase/cli/internal/db/start"
    22  	"github.com/supabase/cli/internal/utils"
    23  	"github.com/supabase/cli/pkg/api"
    24  )
    25  
    26  const (
    27  	eszipContentType       = "application/vnd.denoland.eszip"
    28  	compressedEszipMagicId = "EZBR"
    29  
    30  	// Import Map from CLI flag, i.e. --import-map, takes priority over config.toml & fallback.
    31  	dockerImportMapPath = utils.DockerDenoDir + "/import_map.json"
    32  	dockerOutputDir     = "/root/eszips"
    33  )
    34  
    35  func Run(ctx context.Context, slugs []string, projectRef string, noVerifyJWT *bool, importMapPath string, fsys afero.Fs) error {
    36  	// Load function config and project id
    37  	if err := utils.LoadConfigFS(fsys); err != nil {
    38  		return err
    39  	}
    40  	if len(slugs) == 0 {
    41  		allSlugs, err := getFunctionSlugs(fsys)
    42  		if err != nil {
    43  			return err
    44  		}
    45  		slugs = allSlugs
    46  	} else {
    47  		for _, slug := range slugs {
    48  			if err := utils.ValidateFunctionSlug(slug); err != nil {
    49  				return err
    50  			}
    51  		}
    52  	}
    53  	if len(slugs) == 0 {
    54  		return errors.New("No Functions specified or found in " + utils.Bold(utils.FunctionsDir))
    55  	}
    56  	return deployAll(ctx, slugs, projectRef, importMapPath, noVerifyJWT, fsys)
    57  }
    58  
    59  func RunDefault(ctx context.Context, projectRef string, fsys afero.Fs) error {
    60  	slugs, err := getFunctionSlugs(fsys)
    61  	if len(slugs) == 0 {
    62  		return err
    63  	}
    64  	return deployAll(ctx, slugs, projectRef, "", nil, fsys)
    65  }
    66  
    67  func getFunctionSlugs(fsys afero.Fs) ([]string, error) {
    68  	pattern := filepath.Join(utils.FunctionsDir, "*", "index.ts")
    69  	paths, err := afero.Glob(fsys, pattern)
    70  	if err != nil {
    71  		return nil, errors.Errorf("failed to glob function slugs: %w", err)
    72  	}
    73  	var slugs []string
    74  	for _, path := range paths {
    75  		slug := filepath.Base(filepath.Dir(path))
    76  		if utils.FuncSlugPattern.MatchString(slug) {
    77  			slugs = append(slugs, slug)
    78  		}
    79  	}
    80  	return slugs, nil
    81  }
    82  
    83  func bundleFunction(ctx context.Context, slug, dockerEntrypointPath, importMapPath string, fsys afero.Fs) (*bytes.Buffer, error) {
    84  	cwd, err := os.Getwd()
    85  	if err != nil {
    86  		return nil, errors.Errorf("failed to get working directory: %w", err)
    87  	}
    88  
    89  	// Create temp directory to store generated eszip
    90  	hostOutputDir := filepath.Join(utils.TempDir, fmt.Sprintf(".output_%s", slug))
    91  	// BitBucket pipelines require docker bind mounts to be world writable
    92  	if err := fsys.MkdirAll(hostOutputDir, 0777); err != nil {
    93  		return nil, errors.Errorf("failed to mkdir: %w", err)
    94  	}
    95  	defer func() {
    96  		if err := fsys.RemoveAll(hostOutputDir); err != nil {
    97  			fmt.Fprintln(os.Stderr, err)
    98  		}
    99  	}()
   100  
   101  	outputPath := dockerOutputDir + "/output.eszip"
   102  	binds := []string{
   103  		// Reuse deno cache directory, ie. DENO_DIR, between container restarts
   104  		// https://denolib.gitbook.io/guide/advanced/deno_dir-code-fetch-and-cache
   105  		utils.EdgeRuntimeId + ":/root/.cache/deno:rw,z",
   106  		filepath.Join(cwd, utils.FunctionsDir) + ":" + utils.DockerFuncDirPath + ":ro,z",
   107  		filepath.Join(cwd, hostOutputDir) + ":" + dockerOutputDir + ":rw,z",
   108  	}
   109  
   110  	cmd := []string{"bundle", "--entrypoint", dockerEntrypointPath, "--output", outputPath}
   111  	if viper.GetBool("DEBUG") {
   112  		cmd = append(cmd, "--verbose")
   113  	}
   114  
   115  	if importMapPath != "" {
   116  		modules, err := utils.BindImportMap(importMapPath, dockerImportMapPath, fsys)
   117  		if err != nil {
   118  			return nil, err
   119  		}
   120  		binds = append(binds, modules...)
   121  		cmd = append(cmd, "--import-map", dockerImportMapPath)
   122  	}
   123  
   124  	err = utils.DockerRunOnceWithConfig(
   125  		ctx,
   126  		container.Config{
   127  			Image: utils.EdgeRuntimeImage,
   128  			Env:   []string{},
   129  			Cmd:   cmd,
   130  		},
   131  		start.WithSyslogConfig(container.HostConfig{
   132  			Binds:      binds,
   133  			ExtraHosts: []string{"host.docker.internal:host-gateway"},
   134  		}),
   135  		network.NetworkingConfig{},
   136  		"",
   137  		os.Stdout,
   138  		os.Stderr,
   139  	)
   140  	if err != nil {
   141  		return nil, err
   142  	}
   143  
   144  	eszipBytes, err := fsys.Open(filepath.Join(hostOutputDir, "output.eszip"))
   145  	if err != nil {
   146  		return nil, errors.Errorf("failed to open eszip: %w", err)
   147  	}
   148  	defer eszipBytes.Close()
   149  
   150  	compressedBuf := bytes.NewBufferString(compressedEszipMagicId)
   151  	brw := brotli.NewWriter(compressedBuf)
   152  	defer brw.Close()
   153  
   154  	_, err = io.Copy(brw, eszipBytes)
   155  	if err != nil {
   156  		return nil, errors.Errorf("failed to compress brotli: %w", err)
   157  	}
   158  
   159  	return compressedBuf, nil
   160  }
   161  
   162  func deployFunction(ctx context.Context, projectRef, slug, entrypointUrl, importMapUrl string, verifyJWT bool, functionBody io.Reader) error {
   163  	resp, err := utils.GetSupabase().GetFunctionWithResponse(ctx, projectRef, slug)
   164  	if err != nil {
   165  		return errors.Errorf("failed to retrieve function: %w", err)
   166  	}
   167  
   168  	switch resp.StatusCode() {
   169  	case http.StatusNotFound: // Function doesn't exist yet, so do a POST
   170  		resp, err := utils.GetSupabase().CreateFunctionWithBodyWithResponse(ctx, projectRef, &api.CreateFunctionParams{
   171  			Slug:           &slug,
   172  			Name:           &slug,
   173  			VerifyJwt:      &verifyJWT,
   174  			ImportMapPath:  &importMapUrl,
   175  			EntrypointPath: &entrypointUrl,
   176  		}, eszipContentType, functionBody)
   177  		if err != nil {
   178  			return errors.Errorf("failed to create function: %w", err)
   179  		}
   180  		if resp.JSON201 == nil {
   181  			return errors.New("Failed to create a new Function on the Supabase project: " + string(resp.Body))
   182  		}
   183  	case http.StatusOK: // Function already exists, so do a PATCH
   184  		resp, err := utils.GetSupabase().UpdateFunctionWithBodyWithResponse(ctx, projectRef, slug, &api.UpdateFunctionParams{
   185  			VerifyJwt:      &verifyJWT,
   186  			ImportMapPath:  &importMapUrl,
   187  			EntrypointPath: &entrypointUrl,
   188  		}, eszipContentType, functionBody)
   189  		if err != nil {
   190  			return errors.Errorf("failed to update function: %w", err)
   191  		}
   192  		if resp.JSON200 == nil {
   193  			return errors.New("Failed to update an existing Function's body on the Supabase project: " + string(resp.Body))
   194  		}
   195  	default:
   196  		return errors.New("Unexpected error deploying Function: " + string(resp.Body))
   197  	}
   198  
   199  	fmt.Println("Deployed Function " + utils.Aqua(slug) + " on project " + utils.Aqua(projectRef))
   200  	url := fmt.Sprintf("%s/project/%v/functions/%v/details", utils.GetSupabaseDashboardURL(), projectRef, slug)
   201  	fmt.Println("You can inspect your deployment in the Dashboard: " + url)
   202  	return nil
   203  }
   204  
   205  func deployOne(ctx context.Context, slug, projectRef, importMapPath string, noVerifyJWT *bool, fsys afero.Fs) error {
   206  	// 1. Ensure noVerifyJWT is not nil.
   207  	if noVerifyJWT == nil {
   208  		x := false
   209  		if functionConfig, ok := utils.Config.Functions[slug]; ok && !*functionConfig.VerifyJWT {
   210  			x = true
   211  		}
   212  		noVerifyJWT = &x
   213  	}
   214  	resolved, err := utils.AbsImportMapPath(importMapPath, slug, fsys)
   215  	if err != nil {
   216  		return err
   217  	}
   218  	// Upstream server expects import map to be always defined
   219  	if importMapPath == "" {
   220  		resolved, err = filepath.Abs(utils.FallbackImportMapPath)
   221  		if err != nil {
   222  			return errors.Errorf("failed to resolve absolute path: %w", err)
   223  		}
   224  	}
   225  	exists, err := afero.Exists(fsys, resolved)
   226  	if err != nil {
   227  		logger := utils.GetDebugLogger()
   228  		fmt.Fprintln(logger, err)
   229  	}
   230  	if exists {
   231  		importMapPath = resolved
   232  	} else {
   233  		importMapPath = ""
   234  	}
   235  
   236  	// 2. Bundle Function.
   237  	fmt.Println("Bundling " + utils.Bold(slug))
   238  	dockerEntrypointPath := path.Join(utils.DockerFuncDirPath, slug, "index.ts")
   239  	functionBody, err := bundleFunction(ctx, slug, dockerEntrypointPath, importMapPath, fsys)
   240  	if err != nil {
   241  		return err
   242  	}
   243  	// 3. Deploy new Function.
   244  	functionSize := units.HumanSize(float64(functionBody.Len()))
   245  	fmt.Println("Deploying " + utils.Bold(slug) + " (script size: " + utils.Bold(functionSize) + ")")
   246  	policy := backoff.WithContext(backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3), ctx)
   247  	return backoff.Retry(func() error {
   248  		return deployFunction(
   249  			ctx,
   250  			projectRef,
   251  			slug,
   252  			"file://"+dockerEntrypointPath,
   253  			"file://"+dockerImportMapPath,
   254  			!*noVerifyJWT,
   255  			functionBody,
   256  		)
   257  	}, policy)
   258  }
   259  
   260  func deployAll(ctx context.Context, slugs []string, projectRef, importMapPath string, noVerifyJWT *bool, fsys afero.Fs) error {
   261  	// TODO: api has a race condition that prevents deploying in parallel
   262  	for _, slug := range slugs {
   263  		if err := deployOne(ctx, slug, projectRef, importMapPath, noVerifyJWT, fsys); err != nil {
   264  			return err
   265  		}
   266  	}
   267  	return nil
   268  }