Skip to content

Commit

Permalink
feat: enable edge runtime inspector feature (#2308)
Browse files Browse the repository at this point in the history
* feat: enable edge runtime inspector feature

* stamp: typo

* stamp: add doc for functions inspector capability

* stamp: move `functions_inspector_port` to the edge runtime section

* stamp: reflect suggestions

* stamp: update doc

* stamp: oops

* stamp: add nil guard

* stamp: polishing

* stamp: dot

* stamp: disable wall clock limit when inspect capability has been enabled

* stamp: disambiguate acceptable value of `inspect-mode` flag

* stamp: reflect suggestions (2)

* stamp: remove `wallclock-limit-sec`

* stamp: make wallclock limit to zero when inspector is enabled

* chore: simplify flag parsing

* chore: remove wall clock option

* fix: move request policy to config toml

* stamp: add an extra service path for getting runtime metrics

* stamp: update doc

* chore: remove subheadings from docs

---------

Co-authored-by: Qiao Han <qiao@supabase.io>
Co-authored-by: Han Qiao <sweatybridge@gmail.com>
  • Loading branch information
3 people authored May 29, 2024
1 parent 672a9fb commit 6ff7f50
Show file tree
Hide file tree
Showing 9 changed files with 172 additions and 33 deletions.
30 changes: 28 additions & 2 deletions cmd/functions.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"fmt"

"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/supabase/cli/internal/functions/delete"
Expand All @@ -9,6 +11,7 @@ import (
"github.com/supabase/cli/internal/functions/list"
new_ "github.com/supabase/cli/internal/functions/new"
"github.com/supabase/cli/internal/functions/serve"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
)

Expand Down Expand Up @@ -79,6 +82,15 @@ var (
}

envFilePath string
inspectRun bool
inspectMode = utils.EnumFlag{
Allowed: []string{
string(serve.InspectModeRun),
string(serve.InspectModeBrk),
string(serve.InspectModeWait),
},
}
runtimeOption serve.RuntimeOption

functionsServeCmd = &cobra.Command{
Use: "serve",
Expand All @@ -92,7 +104,17 @@ var (
if !cmd.Flags().Changed("no-verify-jwt") {
noVerifyJWT = nil
}
return serve.Run(cmd.Context(), envFilePath, noVerifyJWT, importMapPath, afero.NewOsFs())

if len(inspectMode.Value) > 0 {
runtimeOption.InspectMode = utils.Ptr(serve.InspectMode(inspectMode.Value))
} else if inspectRun {
runtimeOption.InspectMode = utils.Ptr(serve.InspectModeRun)
}
if runtimeOption.InspectMode == nil && runtimeOption.InspectMain {
return fmt.Errorf("--inspect-main must be used together with one of these flags: [inspect inspect-mode]")
}

return serve.Run(cmd.Context(), envFilePath, noVerifyJWT, importMapPath, runtimeOption, afero.NewOsFs())
},
}
)
Expand All @@ -108,7 +130,11 @@ func init() {
functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
functionsServeCmd.Flags().StringVar(&envFilePath, "env-file", "", "Path to an env file to be populated to the Function environment.")
functionsServeCmd.Flags().StringVar(&importMapPath, "import-map", "", "Path to import map file.")
functionsServeCmd.Flags().Bool("all", true, "Serve all Functions")
functionsServeCmd.Flags().BoolVar(&inspectRun, "inspect", false, "Alias of --inspect-mode run.")
functionsServeCmd.Flags().Var(&inspectMode, "inspect-mode", "Activate inspector capability for debugging.")
functionsServeCmd.Flags().BoolVar(&runtimeOption.InspectMain, "inspect-main", false, "Allow inspecting the main worker.")
functionsServeCmd.MarkFlagsMutuallyExclusive("inspect", "inspect-mode")
functionsServeCmd.Flags().Bool("all", true, "Serve all Functions.")
cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all"))
functionsDownloadCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
functionsDownloadCmd.Flags().BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
Expand Down
28 changes: 28 additions & 0 deletions docs/supabase/functions/serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## supabase-functions-serve

Serve all Functions locally.

`supabase functions serve` command includes additional flags to assist developers in debugging using tools like `DevTools`.

1. `--inspect`
* Alias of `--inspect-mode run`.

2. `--inspect-mode [ run | brk | wait ]`
* Activates the inspector capability.
* `run` mode simply allows a connection without additional behavior. It is not ideal for short scripts, but it can be useful for long-running scripts where you might occasionally want to set breakpoints.
* `brk` mode same as `run` mode, but additionally sets a breakpoint at the first line to pause script execution before any code runs.
* `wait` mode similar to `brk` mode, but instead of setting a breakpoint at the first line, it pauses script execution until an inspector session is connected.

3. `--inspect-main`
* Can only be used when one of the above two flags is enabled.
* By default, creating an inspector session for the main worker is not allowed, but this flag allows it.
* Other behaviors follow the `inspect-mode` flag mentioned above.

Additionally, the following properties can be customised via `supabase/config.toml` under `edge_runtime` section.

1. `inspector_port`
* The port used to listen to the Inspector session, defaults to 8083.
2. `policy`
* A value that indicates how the edge-runtime should forward incoming HTTP requests to the worker.
* `per_worker` allows multiple HTTP requests to be forwarded to a worker that has already been created.
* `oneshot` will force the worker to process a single HTTP request and then exit. (Debugging purpose, This is especially useful if you want to reflect changes you've made immediately.)
95 changes: 75 additions & 20 deletions internal/functions/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,58 @@ import (
"github.com/supabase/cli/internal/utils"
)

type InspectMode string

const (
InspectModeRun InspectMode = "run"
InspectModeBrk InspectMode = "brk"
InspectModeWait InspectMode = "wait"
)

func (mode InspectMode) toFlag() string {
switch mode {
case InspectModeBrk:
return "inspect-brk"
case InspectModeWait:
return "inspect-wait"
case InspectModeRun:
fallthrough
default:
return "inspect"
}
}

type RuntimeOption struct {
InspectMode *InspectMode
InspectMain bool
}

func (i *RuntimeOption) toArgs() []string {
flags := []string{}
if i.InspectMode != nil {
flags = append(flags, fmt.Sprintf("--%s=0.0.0.0:%d", i.InspectMode.toFlag(), dockerRuntimeInspectorPort))
if i.InspectMain {
flags = append(flags, "--inspect-main")
}
}
return flags
}

const (
// Import Map from CLI flag, i.e. --import-map, takes priority over config.toml & fallback.
dockerFlagImportMapPath = utils.DockerDenoDir + "/flag_import_map.json"
dockerFallbackImportMapPath = utils.DockerDenoDir + "/fallback_import_map.json"
dockerRuntimeMainPath = utils.DockerDenoDir + "/main"
dockerRuntimeServerPort = 8081
dockerRuntimeInspectorPort = 8083
)

var (
//go:embed templates/main.ts
mainFuncEmbed string
)

func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, fsys afero.Fs) error {
func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs) error {
// 1. Sanity checks.
if err := utils.LoadConfigFS(fsys); err != nil {
return err
Expand All @@ -48,7 +88,7 @@ func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPa
// Use network alias because Deno cannot resolve `_` in hostname
dbUrl := "postgresql://postgres:postgres@" + utils.DbAliases[0] + ":5432/postgres"
// 3. Serve and log to console
if err := ServeFunctions(ctx, envFilePath, noVerifyJWT, importMapPath, dbUrl, os.Stderr, fsys); err != nil {
if err := ServeFunctions(ctx, envFilePath, noVerifyJWT, importMapPath, dbUrl, runtimeOption, os.Stderr, fsys); err != nil {
return err
}
if err := utils.DockerStreamLogs(ctx, utils.EdgeRuntimeId, os.Stdout, os.Stderr); err != nil {
Expand All @@ -58,7 +98,7 @@ func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPa
return nil
}

func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, dbUrl string, w io.Writer, fsys afero.Fs) error {
func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, dbUrl string, runtimeOption RuntimeOption, w io.Writer, fsys afero.Fs) error {
// 1. Load default values
if envFilePath == "" {
if f, err := fsys.Stat(utils.FallbackEnvFilePath); err == nil && !f.IsDir() {
Expand Down Expand Up @@ -96,6 +136,9 @@ func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool,
if viper.GetBool("DEBUG") {
env = append(env, "SUPABASE_INTERNAL_DEBUG=true")
}
if runtimeOption.InspectMode != nil {
env = append(env, "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0")
}
// 3. Parse custom import map
binds := []string{
// Reuse deno cache directory, ie. DENO_DIR, between container restarts
Expand Down Expand Up @@ -136,35 +179,48 @@ func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool,
return err
}
env = append(env, "SUPABASE_INTERNAL_FUNCTIONS_CONFIG="+functionsConfigString)

// 4. Start container
fmt.Fprintln(w, "Setting up Edge Functions runtime...")

var cmdString string
{
cmd := []string{"edge-runtime", "start", "--main-service", "/home/deno/main", "-p", "8081"}
if viper.GetBool("DEBUG") {
cmd = append(cmd, "--verbose")
}
cmdString = strings.Join(cmd, " ")
// 4. Parse entrypoint script
cmd := append([]string{
"edge-runtime",
"start",
"--main-service=.",
fmt.Sprintf("--port=%d", dockerRuntimeServerPort),
fmt.Sprintf("--policy=%s", utils.Config.EdgeRuntime.Policy),
}, runtimeOption.toArgs()...)
if viper.GetBool("DEBUG") {
cmd = append(cmd, "--verbose")
}
cmdString := strings.Join(cmd, " ")

entrypoint := []string{"sh", "-c", `mkdir -p /home/deno/main && cat <<'EOF' > /home/deno/main/index.ts && ` + cmdString + `
entrypoint := []string{"sh", "-c", `cat <<'EOF' > index.ts && ` + cmdString + `
` + mainFuncEmbed + `
EOF
`}
// 5. Parse exposed ports
ports := []string{fmt.Sprintf("::%d/tcp", dockerRuntimeServerPort)}
if runtimeOption.InspectMode != nil {
ports = append(ports, fmt.Sprintf(":%d:%d/tcp", utils.Config.EdgeRuntime.InspectorPort, dockerRuntimeInspectorPort))
}
exposedPorts, portBindings, err := nat.ParsePortSpecs(ports)
if err != nil {
return errors.Errorf("failed to expose ports: %w", err)
}
// 6. Start container
fmt.Fprintln(w, "Setting up Edge Functions runtime...")
_, err = utils.DockerStart(
ctx,
container.Config{
Image: utils.EdgeRuntimeImage,
Env: append(env, userEnv...),
Entrypoint: entrypoint,
ExposedPorts: nat.PortSet{"8081/tcp": {}},
ExposedPorts: exposedPorts,
WorkingDir: dockerRuntimeMainPath,
// No tcp health check because edge runtime logs them as client connection error
},
start.WithSyslogConfig(container.HostConfig{
Binds: binds,
ExtraHosts: []string{"host.docker.internal:host-gateway"},
Binds: binds,
PortBindings: portBindings,
ExtraHosts: []string{"host.docker.internal:host-gateway"},
}),
network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
Expand Down Expand Up @@ -225,12 +281,11 @@ func populatePerFunctionConfigs(binds []string, importMapPath string, noVerifyJW
}

// CLI flags take priority over config.toml.

dockerImportMapPath := dockerFallbackImportMapPath
if importMapPath != "" {
dockerImportMapPath = dockerFlagImportMapPath
} else if functionConfig, ok := utils.Config.Functions[functionName]; ok && functionConfig.ImportMap != "" {
dockerImportMapPath = "/home/deno/import_maps/" + functionName + "/import_map.json"
dockerImportMapPath = utils.DockerDenoDir + "/import_maps/" + functionName + "/import_map.json"
hostImportMapPath := filepath.Join(cwd, utils.SupabaseDirPath, functionConfig.ImportMap)
modules, err := utils.BindImportMap(hostImportMapPath, dockerImportMapPath, fsys)
if err != nil {
Expand Down
10 changes: 5 additions & 5 deletions internal/functions/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestServeCommand(t *testing.T) {
require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "success"))
// Run test
noVerifyJWT := true
err := Run(context.Background(), ".env", &noVerifyJWT, "", fsys)
err := Run(context.Background(), ".env", &noVerifyJWT, "", RuntimeOption{}, fsys)
// Check error
assert.NoError(t, err)
assert.Empty(t, apitest.ListUnmatchedRequests())
Expand All @@ -46,7 +46,7 @@ func TestServeCommand(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Run test
err := Run(context.Background(), "", nil, "", fsys)
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
// Check error
assert.ErrorContains(t, err, "open supabase/config.toml: file does not exist")
})
Expand All @@ -62,7 +62,7 @@ func TestServeCommand(t *testing.T) {
Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json").
Reply(http.StatusNotFound)
// Run test
err := Run(context.Background(), "", nil, "", fsys)
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
// Check error
assert.ErrorIs(t, err, utils.ErrNotRunning)
})
Expand All @@ -79,7 +79,7 @@ func TestServeCommand(t *testing.T) {
Reply(http.StatusOK).
JSON(types.ContainerJSON{})
// Run test
err := Run(context.Background(), ".env", nil, "", fsys)
err := Run(context.Background(), ".env", nil, "", RuntimeOption{}, fsys)
// Check error
assert.ErrorContains(t, err, "open .env: file does not exist")
})
Expand All @@ -97,7 +97,7 @@ func TestServeCommand(t *testing.T) {
Reply(http.StatusOK).
JSON(types.ContainerJSON{})
// Run test
err := Run(context.Background(), ".env", nil, "import_map.json", fsys)
err := Run(context.Background(), ".env", nil, "import_map.json", RuntimeOption{}, fsys)
// Check error
assert.ErrorContains(t, err, "Failed to read import map")
assert.ErrorContains(t, err, "file does not exist")
Expand Down
12 changes: 10 additions & 2 deletions internal/functions/serve/templates/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const FUNCTIONS_CONFIG_STRING = Deno.env.get(
"SUPABASE_INTERNAL_FUNCTIONS_CONFIG",
)!;

const WALLCLOCK_LIMIT_SEC = parseInt(Deno.env.get("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC"));

const DENO_SB_ERROR_MAP = new Map([
[Deno.errors.InvalidWorkerCreation, SB_SPECIFIC_ERROR_CODE.BootError],
[Deno.errors.InvalidWorkerResponse, SB_SPECIFIC_ERROR_CODE.WorkerLimit],
Expand Down Expand Up @@ -115,6 +117,12 @@ Deno.serve({
return getResponse({ message: "ok" }, STATUS_CODE.OK);
}

// handle metrics
if (pathname === '/_internal/metric') {
const metric = await EdgeRuntime.getRuntimeMetrics();
return Response.json(metric);
}

const pathParts = pathname.split("/");
const functionName = pathParts[1];

Expand All @@ -140,15 +148,15 @@ Deno.serve({
console.error(`serving the request with ${servicePath}`);

const memoryLimitMb = 150;
const workerTimeoutMs = 400 * 1000;
const workerTimeoutMs = isFinite(WALLCLOCK_LIMIT_SEC) ? WALLCLOCK_LIMIT_SEC * 1000 : 400 * 1000;
const noModuleCache = false;
const envVarsObj = Deno.env.toObject();
const envVars = Object.entries(envVarsObj)
.filter(([name, _]) =>
!EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_")
);

const forceCreate = true;
const forceCreate = false;
const customModuleRoot = ""; // empty string to allow any local path
const cpuTimeSoftLimitMs = 1000;
const cpuTimeHardLimitMs = 2000;
Expand Down
2 changes: 1 addition & 1 deletion internal/start/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ EOF
// Start all functions.
if utils.Config.EdgeRuntime.Enabled && !isContainerExcluded(utils.EdgeRuntimeImage, excluded) {
dbUrl := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database)
if err := serve.ServeFunctions(ctx, "", nil, "", dbUrl, w, fsys); err != nil {
if err := serve.ServeFunctions(ctx, "", nil, "", dbUrl, serve.RuntimeOption{}, w, fsys); err != nil {
return err
}
started = append(started, utils.EdgeRuntimeId)
Expand Down
20 changes: 17 additions & 3 deletions internal/utils/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ func (c CustomClaims) NewToken() *jwt.Token {
return jwt.NewWithClaims(jwt.SigningMethodHS256, c)
}

type RequestPolicy string

const (
PolicyPerWorker RequestPolicy = "per_worker"
PolicyOneshot RequestPolicy = "oneshot"
)

var Config = config{
Api: api{
Image: PostgrestImage,
Expand Down Expand Up @@ -441,7 +448,9 @@ type (
}

edgeRuntime struct {
Enabled bool `toml:"enabled"`
Enabled bool `toml:"enabled"`
Policy RequestPolicy `toml:"policy"`
InspectorPort uint16 `toml:"inspector_port"`
}

function struct {
Expand Down Expand Up @@ -769,10 +778,15 @@ func LoadConfigFS(fsys afero.Fs) error {
}
}
// Validate functions config
if Config.EdgeRuntime.Enabled {
allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot}
if !SliceContains(allowed, Config.EdgeRuntime.Policy) {
return errors.Errorf("Invalid config for edge_runtime.policy. Must be one of: %v", allowed)
}
}
for name, functionConfig := range Config.Functions {
if functionConfig.VerifyJWT == nil {
verifyJWT := true
functionConfig.VerifyJWT = &verifyJWT
functionConfig.VerifyJWT = Ptr(true)
Config.Functions[name] = functionConfig
}
}
Expand Down
Loading

0 comments on commit 6ff7f50

Please sign in to comment.