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
4 changes: 4 additions & 0 deletions apps/cli/src/command-internal/docker-suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const SUGGEST_DOCKER_INSTALL =
export const SUGGEST_DOCKER_START =
"Docker is no longer reachable. Start Docker, then rerun `supabase functions serve`.";

/** Command-neutral variant of `SUGGEST_DOCKER_START` for errors shared by several commands; also covers a socket the user cannot access. */
export const SUGGEST_DOCKER_START_RERUN =
"Docker is not reachable. Start Docker, or check that your user can access its socket, then rerun the command.";

/** Remediation hint shown when the OOM killer kills a container. */
export const SUGGEST_CONTAINER_MEMORY_LIMIT =
"The container ran out of memory and was killed. Raise the memory allocated to your container runtime (Docker Desktop: Settings > Resources), or free up memory on the host, and try again.";
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ The `--output`/`-o` flag does not change deploy output.
- Bundles locally with Docker by default (`--use-docker` defaults to true and is hidden); `--use-api` selects server-side bundling, and a stopped Docker daemon falls back to it after a `WARNING: Docker is not running`.
- Local Docker bundling mounts existing local values declared under an import map's `scopes` read-only, including targets outside the nearest Git root; each such out-of-root mount prints a `WARN` naming the host path. The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. API source uploads retain their existing source-root restrictions.
- `--use-api`, `--use-docker`, and `--legacy-bundle` are mutually exclusive deploy modes.
- Local Docker bundling creates the project network and the Deno cache volume before the first bundle. If either creation fails, `--output json` reports `error.code` `DockerResourceCreateError` instead of `UnknownError`; the message and exit code are unchanged. When the Docker daemon is unreachable, the failure shows a start-Docker suggestion instead of the generic `--debug` hint.
- Prints the target banner before anything else.
- **Consent:** one question, `Deploy N Function(s)` on the target, asked after planning and
before the first bundle, upload or delete. The default answer is No. `--prune` adds
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ back to local keys. No scheme/host validation is performed on the discovered URL
| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags`, `serve_end_reason`, `edge_runtime_exit_code` |

Bring-up failures carry a specific `error_fingerprint` instead of a shared `error:unknown`: the Edge Runtime `docker create`, `docker cp` and `docker start` steps each have their own fingerprint plus a reason read from Docker's output at the failure site (for example `tag:EdgeRuntimeDockerStepError:port_conflict`), env-file failures name the reason (for example `tag:ServeEnvError:not_found`), and host filesystem failures are classified from the underlying filesystem error.

## Output

### `--output-format text`
Expand Down Expand Up @@ -169,6 +171,7 @@ per-function `import_map.json` is not read and does not fail the command.
- A container that stops on its own with exit code `0`, or that is torn down by an external supervisor (exit `129`/`130`/`131`/`143`, or `15` from edge-runtime's own `SIGTERM` exit, e.g. `supabase stop` or `docker stop` in another terminal), or that is already gone by the time a follow-up inspect runs, all end the command successfully — each prints its own distinct line (see Output above) rather than the user-initiated `Stopped serving …` line, so scrollback can tell "I stopped it" from "the runtime walked out" or "a supervisor tore it down". In a `functions serve &` CI step this means a runtime that exits on its own does not fail the step; the distinct message is the only signal, and a downstream failure otherwise only surfaces later as connection-refused. Exit `137` (SIGKILL) fails the command: `State.OOMKilled` separates a container that hit its memory limit, which reports the memory-allocation remediation, from one killed by something the CLI cannot identify. A non-OOM `137` gets one re-inspect first — if `supabase stop` force-killed a runtime that ignored `SIGTERM` and the follow-up prune has already removed the container, the command still ends successfully instead of failing on a container that is already gone. Any other non-zero container exit fails the command; the error message includes the container id. Only a watched-file change restarts the container itself — none of these outcomes ever restart it.
- A `docker logs -f` re-attach (the daemon can close the stream while the container keeps running) resumes with `--since <last forwarded log timestamp>` instead of replaying the full log history, and is capped at 5 consecutive re-attaches that forward no new output; exceeding the cap fails the command with a tagged error instead of looping forever.
- On the log-stream path, the Docker-daemon-unreachable classification comes from a follow-up `docker container inspect` failure, not from `docker logs -f`'s own stderr text. That same follow-up inspect's `State.OOMKilled` also drives the memory-limit remediation on this path, so a `docker logs -f` failure over a container killed for exceeding its memory limit reports the same suggestion as the clean-exit case above, instead of a bare unattributable failure.
- Bring-up failures use typed errors, so with `--output json` the `error.code` is the error's name (`ServeConfigError`, `ServeEnvError`, `ServeFilesystemError`, `EdgeRuntimeDockerStepError`, `DockerResourceCreateError`) instead of `UnknownError`; messages and exit codes are unchanged. A daemon-unreachable failure during the Edge Runtime `docker create`, `docker cp` or `docker start` step, or while creating the Docker network or volume, shows a start-Docker suggestion instead of the generic `--debug` hint; the suggestion also asks the user to check access to the Docker socket, since a socket permission failure takes the same path.
- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory.
- The aggregated bind mount list is pruned before `docker create`: a bind is dropped when another bind of the same mode already supplies the same content at the same container path — a file bind nested inside an already-bound read-only package directory would otherwise make the bootstrap `docker cp` fail with `destination "<container>:/" must be a directory` (supabase/supabase#50088). Pruned paths remain visible in the container through their covering parent mounts; the `--workdir` gate and the file-watch set are computed from the unpruned aggregate.
- Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior.
Expand Down
129 changes: 114 additions & 15 deletions apps/cli/src/commands/functions/serve/serve.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ import {
import { CommandSettings } from "../../../config/command-settings.service.ts";
import { StackApi } from "../../../command-internal/stack-api.ts";
import { functionsLocalConfigLoader } from "../../../command-internal/functions-local-config.ts";
import { SUGGEST_CONTAINER_MEMORY_LIMIT } from "../../../command-internal/docker-suggest.ts";
import {
SUGGEST_CONTAINER_MEMORY_LIMIT,
SUGGEST_DOCKER_START_RERUN,
} from "../../../command-internal/docker-suggest.ts";
import { DebugFlag, NetworkIdFlag } from "../../../command-internal/global-flags.ts";
import { FileWatcher, type FileWatchEvent } from "../../../shared/runtime/file-watcher.service.ts";
import {
Expand All @@ -64,12 +67,15 @@ import {
DockerLogsStreamError,
EdgeRuntimeContainerCrashedError,
EdgeRuntimeLogStreamLostError,
ServeEnvError,
ServeLocalDbInspectError,
ServeLocalDbNotRunningError,
} from "../../../shared/functions/serve.errors.ts";
import {
actionability,
classifyCliErrorActionability,
ErrorActionabilityId,
unwrapNativeFailure,
} from "../../../shared/telemetry/error-actionability.ts";
import { normalizeCliError } from "../../../shared/output/normalize-error.ts";
import {
Expand Down Expand Up @@ -823,14 +829,12 @@ describe("functions serve integration", () => {
const { layer } = setupServe();
const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip);

expect(error).toBeInstanceOf(Error);
if (error instanceof Error) {
expect(error.message).toContain(`failed to parse environment file: ${functionEnvPath}`);
expect(error.message).toContain("unexpected character '-' in variable name");
expect(error.message).not.toContain("secret-value");
expect(error.message).not.toContain('near "API-KEY=secret-value"');
}
expect(normalizeCliError(error).code).toBe("UnknownError");
expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvError);
expect(error.message).toContain(`failed to parse environment file: ${functionEnvPath}`);
expect(error.message).toContain("unexpected character '-' in variable name");
expect(error.message).not.toContain("secret-value");
expect(error.message).not.toContain('near "API-KEY=secret-value"');
expect(normalizeCliError(error).code).toBe("ServeEnvError");
expect(
deployMockState.runCalls.filter(
(call) => call.command === "docker" && call.args[0] === "create",
Expand All @@ -841,6 +845,98 @@ describe("functions serve integration", () => {
}).pipe(Effect.provide(BunServices.layer));
});

it.live("fails with a read env-file error when the --env-file path is a directory", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
yield* writeCliConfig(['project_id = "test-project"', ""].join("\n"));
yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n');
yield* writeProjectFile(path.join("envdir", "keep"), "");

const { layer } = setupServe();
const error = yield* functionsServe(baseFlags({ envFile: Option.some("envdir") })).pipe(
Effect.provide(layer),
Effect.flip,
);

expect(error.message).toContain("failed to load environment file:");
expect(normalizeCliError(error).code).toBe("ServeEnvError");
expect(classifyCliErrorActionability(error).error_fingerprint).toBe(
"tag:ServeEnvError:filesystem_io",
);
expect(deployMockState.runCalls.some((call) => call.args[0] === "create")).toBe(false);
}).pipe(Effect.provide(BunServices.layer)),
);

it.live.each([
[
"create",
"Bind for 0.0.0.0:54321 failed: port is already allocated",
"port_in_use",
"tag:EdgeRuntimeDockerStepError:port_conflict",
undefined,
],
[
"create",
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?",
"daemon_down",
"tag:EdgeRuntimeDockerStepError:docker_not_running",
SUGGEST_DOCKER_START_RERUN,
],
[
"cp",
"unexpected copy failure",
"other",
"tag:EdgeRuntimeDockerStepError:engine_command_copy",
undefined,
],
[
"start",
"unexpected start failure",
"other",
"tag:EdgeRuntimeDockerStepError:engine_command_start",
undefined,
],
] as const)(
"fails a %s step with a typed Docker step error read from stderr",
([step, stderr, reason, fingerprint, suggestion]) => {
deployMockState.runHandler = (command, args) => {
if (command !== "docker") {
throw new Error(`unexpected process: ${command}`);
}
if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === step) {
return { exitCode: 1, stdout: "", stderr };
}
if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
return { exitCode: 0, stdout: "", stderr: "" };
}
throw new Error(`unexpected docker args: ${args.join(" ")}`);
};

return Effect.gen(function* () {
yield* writeCliConfig(['project_id = "test-project"', ""].join("\n"));
yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n');

const { layer } = setupServe();
const error = yield* functionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip);

expect(unwrapNativeFailure(error)).toMatchObject({
_tag: "EdgeRuntimeDockerStepError",
step,
reason,
});
expect(error.message).toContain(stderr);
expect(classifyCliErrorActionability(error).error_fingerprint).toBe(fingerprint);
expect(normalizeCliError(error).suggestion).toBe(suggestion);
}).pipe(Effect.provide(BunServices.layer));
},
);

it.live(
"skips a disabled function and sets up the runtime before a malformed functions/.env fails",
() => {
Expand Down Expand Up @@ -1312,6 +1408,7 @@ describe("functions serve integration", () => {
expect(error.message).toContain("invalid multiline environment variable name");
expect(error.message).toContain("FOO.BAR");
}
expect(normalizeCliError(error).code).toBe("ServeEnvError");
expect(
deployMockState.runCalls.filter(
(call) => call.command === "docker" && call.args[0] === "create",
Expand Down Expand Up @@ -3854,6 +3951,7 @@ describe("functions serve integration", () => {
"if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set",
);
}
expect(normalizeCliError(error).code).toBe("ServeConfigError");
expect(deployMockState.runCalls).toHaveLength(0);
expect(deployMockState.volumeCalls).toHaveLength(0);
expect(deployMockState.networkCalls).toHaveLength(0);
Expand Down Expand Up @@ -4335,12 +4433,13 @@ describe("functions serve integration", () => {
}),
).pipe(Effect.provide(layer), Effect.flip);

expect(error).toBeInstanceOf(Error);
if (error instanceof Error) {
expect(error.message).toContain(".env");
expect(error.message).toContain("no such file or directory");
}
expect(normalizeCliError(error).code).toBe("UnknownError");
expect(unwrapNativeFailure(error)).toBeInstanceOf(ServeEnvError);
expect(error.message).toContain(".env");
expect(error.message).toContain("no such file or directory");
expect(normalizeCliError(error).code).toBe("ServeEnvError");
expect(classifyCliErrorActionability(error).error_fingerprint).toBe(
"tag:ServeEnvError:not_found",
);
expect(
deployMockState.runCalls.filter(
(call) => call.command === "docker" && call.args[0] === "create",
Expand Down
9 changes: 9 additions & 0 deletions apps/cli/src/commands/start/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ tar archive) → `docker start` sequence is assembled by that shared core, not b
health-checked via an HTTP probe through Kong (`/functions/v1/_internal/health`), not a
Docker healthcheck — mirroring PostgREST's own probe shape.

Its bring-up failures use the same typed errors as `functions serve` (`ServeConfigError` for
the missing-import check, `ServeEnvError` for a malformed `supabase/functions/.env` or an
invalid multiline env name from an env file or configured secret, `ServeFilesystemError` for
staging the env files on the host, and `EdgeRuntimeDockerStepError` for the container's
`docker create`/`cp`/`start`), so with `--output json` the `error.code` is the error's name
instead of `UnknownError`. A failure to reach the daemon, including a Docker socket the user
cannot access, shows a start-Docker suggestion instead of the generic `--debug` hint.
Messages and exit codes are unchanged.

### Readiness gating

Postgres's own wait does not read Docker's health status: Docker runs every `HEALTHCHECK` with
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/commands/start/start.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5701,7 +5701,7 @@ content_path = "./supabase/templates/custom_notice.html"
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

it.effect("fails on a malformed supabase/functions/.env with the legacy error code", () =>
it.effect("fails on a malformed supabase/functions/.env with a typed env-file error", () =>
Effect.gen(function* () {
const { layer, workdir } = yield* setup();

Expand All @@ -5712,7 +5712,7 @@ content_path = "./supabase/templates/custom_notice.html"

expect(error.message).toContain("failed to parse environment file:");
expect(error.message).not.toContain("secret-value");
expect(normalizeCliError(error).code).toBe("UnknownError");
expect(normalizeCliError(error).code).toBe("ServeEnvError");
}).pipe(Effect.provide([BunServices.layer, BunCrypto.layer])),
);

Expand Down
17 changes: 15 additions & 2 deletions apps/cli/src/shared/functions/functions-docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ChildProcessSpawner } from "effect/process";
import { spawnContainerCli } from "../../command-internal/container-cli.ts";
import { makeDockerImageResolver } from "../../command-internal/docker-image-resolve.ts";
import { DENO1_EDGE_RUNTIME_VERSION } from "./functions.shared.ts";
import { DockerResourceCreateError, classifyDockerResourceCreateStderr } from "./serve.errors.ts";
import { bitbucketCloneDir } from "../../command-internal/bitbucket-pipeline.ts";
import { sanitizeProjectId } from "../config/project-id.ts";

Expand Down Expand Up @@ -276,7 +277,13 @@ export const ensureDockerNetwork = Effect.fn("FunctionsDocker.ensureNetwork")(fu
},
);
if (create.exitCode !== 0 && !create.stderr.includes("already exists")) {
return yield* nativeFailure(new Error(`failed to create docker network: ${networkMode}`));
return yield* nativeFailure(
new DockerResourceCreateError({
resource: "network",
reason: classifyDockerResourceCreateStderr(create.stderr),
message: `failed to create docker network: ${networkMode}`,
}),
);
}
});

Expand Down Expand Up @@ -307,7 +314,13 @@ export const ensureDockerNamedVolume = Effect.fn("FunctionsDocker.ensureVolume")
},
);
if (create.exitCode !== 0 && !create.stderr.includes("already exists")) {
return yield* nativeFailure(new Error(`failed to create docker volume: ${volumeName}`));
return yield* nativeFailure(
new DockerResourceCreateError({
resource: "volume",
reason: classifyDockerResourceCreateStderr(create.stderr),
message: `failed to create docker volume: ${volumeName}`,
}),
);
}
});

Expand Down
Loading
Loading