diff --git a/apps/cli/src/command-internal/docker-suggest.ts b/apps/cli/src/command-internal/docker-suggest.ts index 483ae36735..85932886d1 100644 --- a/apps/cli/src/command-internal/docker-suggest.ts +++ b/apps/cli/src/command-internal/docker-suggest.ts @@ -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."; diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 4074209f75..028d87eb7c 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -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 diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index f0cf7b2394..31fe9f087b 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -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` @@ -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 ` 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 ":/" 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. diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index fc03242585..d43cbb9e9d 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -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 { @@ -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 { @@ -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", @@ -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", () => { @@ -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", @@ -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); @@ -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", diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 2c4c72505a..e50f532c50 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -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 diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 9156f1b1f2..32c5da4202 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -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(); @@ -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])), ); diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 9ea76486bd..50800f92f5 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -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"; @@ -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}`, + }), + ); } }); @@ -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}`, + }), + ); } }); diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 69992b99d0..f1e046282c 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -24,10 +24,12 @@ import { ErrorActionabilityId, unwrapNativeFailure, } from "../telemetry/error-actionability.ts"; +import { DockerResourceCreateError } from "./serve.errors.ts"; import { buildFunctionsDockerRunArgs, containerArchiveBytes, edgeRuntimeCacheVolume, + ensureDockerNetwork, localDockerId, NativeFailure, nativeFailure, @@ -48,12 +50,13 @@ function mockStreamingChildProcessLayer( opts: { readonly stdout?: ReadonlyArray; readonly stderr?: ReadonlyArray; + readonly exitCode?: number; } = {}, ) { const spawner = ChildProcessSpawner.make(() => Effect.gen(function* () { const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: Stream.fromIterable(opts.stdout ?? []), @@ -547,3 +550,29 @@ describe("native failure compatibility", () => { expect(Runtime.getErrorExitCode(probe)).toBe(1); }); }); + +describe("ensureDockerNetwork", () => { + it.effect("fails with an address-pool reason when docker cannot allocate a subnet", () => + Effect.gen(function* () { + const error = yield* ensureDockerNetwork("supabase_network_p", "p").pipe( + Effect.provide( + mockStreamingChildProcessLayer({ + exitCode: 1, + stderr: [ + new TextEncoder().encode("all predefined address pools have been fully subnetted"), + ], + }), + ), + Effect.flip, + ); + + const cause = unwrapNativeFailure(error); + expect(cause).toBeInstanceOf(DockerResourceCreateError); + expect(cause).toMatchObject({ + resource: "network", + reason: "address_pool", + message: "failed to create docker network: supabase_network_p", + }); + }), + ); +}); diff --git a/apps/cli/src/shared/functions/serve.errors.ts b/apps/cli/src/shared/functions/serve.errors.ts index 432f13c748..b86c5f3d1d 100644 --- a/apps/cli/src/shared/functions/serve.errors.ts +++ b/apps/cli/src/shared/functions/serve.errors.ts @@ -3,9 +3,12 @@ import { SUGGEST_CONTAINER_MEMORY_LIMIT, SUGGEST_DOCKER_INSTALL, SUGGEST_DOCKER_START, + SUGGEST_DOCKER_START_RERUN, + isDockerDaemonUnreachable, } from "../../command-internal/docker-suggest.ts"; import { actionability, + causeDeclaration, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../telemetry/error-actionability.ts"; @@ -152,3 +155,168 @@ export class ServeLocalDbInspectError extends Data.TaggedError("ServeLocalDbInsp return this.daemonDown ? SUGGEST_DOCKER_INSTALL : undefined; } } + +/** A `functions serve` input or project setting is invalid or missing before the container starts. */ +export class ServeConfigError extends Data.TaggedError("ServeConfigError")<{ + readonly message: string; + readonly reason: "inspect_flags" | "missing_import"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "inspect_flags": + return { ...actionability.provideFlags, fingerprint_suffix: "flags" }; + case "missing_import": + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + default: + return actionability.invalidInput; + } + } +} + +/** The functions env input, an env file or a configured secret, is missing, can't be read, doesn't parse, or has an invalid name. */ +export class ServeEnvError extends Data.TaggedError("ServeEnvError")<{ + readonly message: string; + readonly reason: "not_found" | "read" | "syntax" | "env_name"; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "not_found": + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + case "syntax": + return { ...actionability.invalidInput, fingerprint_suffix: "invalid_content" }; + case "env_name": + return { ...actionability.invalidInput, fingerprint_suffix: "functions_env" }; + default: + return ( + causeDeclaration(this.cause) ?? { + ...actionability.toolFailed, + fingerprint_suffix: "filesystem_io", + } + ); + } + } +} + +/** Staging env files on the host failed during `functions serve`. */ +export class ServeFilesystemError extends Data.TaggedError("ServeFilesystemError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return ( + causeDeclaration(this.cause) ?? { + ...actionability.toolFailed, + fingerprint_suffix: "filesystem_io", + } + ); + } +} + +export type EdgeRuntimeDockerStepReason = + | "daemon_down" + | "port_in_use" + | "name_conflict" + | "mount_denied" + | "disk_full" + | "other"; + +const edgeRuntimeStepSuffix = { + create: "engine_command_create", + cp: "engine_command_copy", + start: "engine_command_start", +} as const; + +/** A `docker create`, `docker cp` or `docker start` step for the Edge Runtime container failed. */ +export class EdgeRuntimeDockerStepError extends Data.TaggedError("EdgeRuntimeDockerStepError")<{ + readonly message: string; + readonly step: "create" | "cp" | "start"; + readonly reason: EdgeRuntimeDockerStepReason; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "daemon_down": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "port_in_use": + return { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }; + case "name_conflict": + return { ...actionability.invalidInput, fingerprint_suffix: "conflict" }; + case "mount_denied": + return { ...actionability.invalidConfig, fingerprint_suffix: "container_configuration" }; + case "disk_full": + return { ...actionability.resourceLimit, fingerprint_suffix: "disk_full" }; + default: + return { + ...actionability.runtimeCrash, + fingerprint_suffix: edgeRuntimeStepSuffix[this.step] ?? "engine_command_create", + }; + } + } + + get suggestion(): string | undefined { + return this.reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined; + } +} + +/** Creating the Docker network or named volume that `functions serve` needs failed. */ +export class DockerResourceCreateError extends Data.TaggedError("DockerResourceCreateError")<{ + readonly message: string; + readonly resource: "network" | "volume"; + readonly reason: "daemon_down" | "address_pool" | "other"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "daemon_down": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "address_pool": + return { ...actionability.invalidConfig, fingerprint_suffix: "container_configuration" }; + default: + return { ...actionability.runtimeCrash, fingerprint_suffix: "engine_command_create" }; + } + } + + get suggestion(): string | undefined { + return this.reason === "daemon_down" ? SUGGEST_DOCKER_START_RERUN : undefined; + } +} + +/** Reads the stderr of a failed Edge Runtime `docker create|cp|start` into a closed reason. */ +export const classifyEdgeRuntimeDockerStepStderr = ( + stderr: string, +): EdgeRuntimeDockerStepReason => { + if (isDockerDaemonUnreachable(stderr)) return "daemon_down"; + if ( + /port is already allocated|address already in use|ports are not available|bind: an attempt was made to access a socket/iu.test( + stderr, + ) + ) { + return "port_in_use"; + } + if (/is already in use by container|container name .* is already in use/iu.test(stderr)) { + return "name_conflict"; + } + if ( + /mounts denied|is not shared from the host|bind source path does not exist|has not been shared/iu.test( + stderr, + ) + ) { + return "mount_denied"; + } + if (/no space left on device/iu.test(stderr)) return "disk_full"; + return "other"; +}; + +/** Reads the stderr of a failed `docker network|volume create` into a closed reason. */ +export const classifyDockerResourceCreateStderr = ( + stderr: string, +): "daemon_down" | "address_pool" | "other" => { + if (isDockerDaemonUnreachable(stderr)) return "daemon_down"; + if ( + /pool overlaps|all predefined address pools have been fully subnetted|non-overlapping .*address pool/iu.test( + stderr, + ) + ) { + return "address_pool"; + } + return "other"; +}; diff --git a/apps/cli/src/shared/functions/serve.errors.unit.test.ts b/apps/cli/src/shared/functions/serve.errors.unit.test.ts index a3acd14d47..6f4ac4d86a 100644 --- a/apps/cli/src/shared/functions/serve.errors.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.errors.unit.test.ts @@ -1,14 +1,23 @@ +import { PlatformError } from "effect"; import { describe, expect, it } from "vitest"; import { SUGGEST_CONTAINER_MEMORY_LIMIT, SUGGEST_DOCKER_INSTALL, SUGGEST_DOCKER_START, + SUGGEST_DOCKER_START_RERUN, } from "../../command-internal/docker-suggest.ts"; import { actionability, classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; import { + classifyDockerResourceCreateStderr, + classifyEdgeRuntimeDockerStepStderr, DockerLogsStreamError, + DockerResourceCreateError, EdgeRuntimeContainerCrashedError, + EdgeRuntimeDockerStepError, EdgeRuntimeLogStreamLostError, + ServeConfigError, + ServeEnvError, + ServeFilesystemError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, } from "./serve.errors.ts"; @@ -238,3 +247,211 @@ describe("ServeLocalDbInspectError actionability", () => { expect(result.error_fingerprint).toBe("tag:ServeLocalDbInspectError"); }); }); + +const platformCause = (tag: "NotFound" | "PermissionDenied") => + new PlatformError.PlatformError( + new PlatformError.SystemError({ _tag: tag, module: "FileSystem", method: "readFile" }), + ); + +describe("ServeConfigError actionability", () => { + it.each([ + ["inspect_flags", "user_actionable", "invalid_input", "tag:ServeConfigError:flags"], + ["missing_import", "user_actionable", "invalid_input", "tag:ServeConfigError:not_found"], + ] as const)("classifies %s", (reason, kind, category, fingerprint) => { + const result = classifyCliErrorActionability(new ServeConfigError({ message: "m", reason })); + + expect(result.error_kind).toBe(kind); + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + }); +}); + +describe("ServeEnvError actionability", () => { + it.each([ + ["syntax", undefined, "invalid_input", "tag:ServeEnvError:invalid_content"], + ["env_name", undefined, "invalid_input", "tag:ServeEnvError:functions_env"], + ["not_found", undefined, "invalid_input", "tag:ServeEnvError:not_found"], + [ + "read", + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }), + "unknown", + "tag:ServeEnvError:filesystem_io", + ], + ["read", undefined, "unknown", "tag:ServeEnvError:filesystem_io"], + ] as const)("classifies a %s failure with cause %#", (reason, cause, category, fingerprint) => { + const result = classifyCliErrorActionability( + new ServeEnvError({ message: "m", reason, cause }), + ); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + }); +}); + +describe("ServeFilesystemError actionability", () => { + it.each([ + [platformCause("NotFound"), "invalid_input", "tag:ServeFilesystemError:not_found"], + [platformCause("PermissionDenied"), "permission", "tag:ServeFilesystemError:filesystem"], + [undefined, "unknown", "tag:ServeFilesystemError:filesystem_io"], + ] as const)("classifies cause %#", (cause, category, fingerprint) => { + const result = classifyCliErrorActionability(new ServeFilesystemError({ message: "m", cause })); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + }); +}); + +describe("EdgeRuntimeDockerStepError actionability", () => { + it.each([ + [ + "create", + "daemon_down", + "docker_not_running", + "tag:EdgeRuntimeDockerStepError:docker_not_running", + SUGGEST_DOCKER_START_RERUN, + ], + [ + "create", + "port_in_use", + "invalid_config", + "tag:EdgeRuntimeDockerStepError:port_conflict", + undefined, + ], + [ + "start", + "name_conflict", + "invalid_input", + "tag:EdgeRuntimeDockerStepError:conflict", + undefined, + ], + [ + "cp", + "mount_denied", + "invalid_config", + "tag:EdgeRuntimeDockerStepError:container_configuration", + undefined, + ], + ["cp", "disk_full", "resource_limit", "tag:EdgeRuntimeDockerStepError:disk_full", undefined], + [ + "create", + "other", + "runtime_crash", + "tag:EdgeRuntimeDockerStepError:engine_command_create", + undefined, + ], + [ + "cp", + "other", + "runtime_crash", + "tag:EdgeRuntimeDockerStepError:engine_command_copy", + undefined, + ], + [ + "start", + "other", + "runtime_crash", + "tag:EdgeRuntimeDockerStepError:engine_command_start", + undefined, + ], + ] as const)( + "classifies %s step failing with %s", + (step, reason, category, fingerprint, suggestion) => { + const error = new EdgeRuntimeDockerStepError({ message: "m", step, reason }); + + const result = classifyCliErrorActionability(error); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + expect(error.suggestion).toBe(suggestion); + }, + ); +}); + +describe("DockerResourceCreateError actionability", () => { + it.each([ + [ + "network", + "daemon_down", + "docker_not_running", + "tag:DockerResourceCreateError:docker_not_running", + SUGGEST_DOCKER_START_RERUN, + ], + [ + "network", + "address_pool", + "invalid_config", + "tag:DockerResourceCreateError:container_configuration", + undefined, + ], + [ + "volume", + "other", + "runtime_crash", + "tag:DockerResourceCreateError:engine_command_create", + undefined, + ], + ] as const)( + "classifies a %s create failing with %s", + (resource, reason, category, fingerprint, suggestion) => { + const error = new DockerResourceCreateError({ message: "m", resource, reason }); + + const result = classifyCliErrorActionability(error); + + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(fingerprint); + expect(error.suggestion).toBe(suggestion); + }, + ); +}); + +describe("new serve errors built without fields", () => { + it.each([ + ServeConfigError, + ServeEnvError, + ServeFilesystemError, + EdgeRuntimeDockerStepError, + DockerResourceCreateError, + ])("keeps a tagged fingerprint for $name", (ErrorClass) => { + const error: unknown = Reflect.construct(ErrorClass, [{}]); + + const result = classifyCliErrorActionability(error); + + expect(result.error_fingerprint).toMatch(new RegExp(`^tag:${ErrorClass.name}`)); + }); +}); + +describe("classifyEdgeRuntimeDockerStepStderr", () => { + it.each([ + [ + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + "daemon_down", + ], + ["Bind for 0.0.0.0:54321 failed: port is already allocated", "port_in_use"], + [ + 'Conflict. The container name "/supabase_edge_runtime_x" is already in use by container "abc"', + "name_conflict", + ], + ["Mounts denied: The path /Users/x is not shared from the host", "mount_denied"], + ["write /var/lib/docker/tmp: no space left on device", "disk_full"], + ["something unexpected", "other"], + ["", "other"], + ["permission denied while trying to connect to the Docker daemon socket", "daemon_down"], + ] as const)("reads %j as %s", (stderr, reason) => { + expect(classifyEdgeRuntimeDockerStepStderr(stderr)).toBe(reason); + }); +}); + +describe("classifyDockerResourceCreateStderr", () => { + it.each([ + ["Cannot connect to the Docker daemon at unix:///var/run/docker.sock", "daemon_down"], + ["all predefined address pools have been fully subnetted", "address_pool"], + ["Pool overlaps with other one on this address space", "address_pool"], + [ + "could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network", + "address_pool", + ], + ["something unexpected", "other"], + ] as const)("reads %j as %s", (stderr, reason) => { + expect(classifyDockerResourceCreateStderr(stderr)).toBe(reason); + }); +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2cbf9cc070..6f2350efb6 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -19,6 +19,7 @@ import { type JsonWebKeyInput, } from "node:crypto"; import { styleText } from "node:util"; +import type { PlatformError } from "effect/PlatformError"; import { Clock, Data, @@ -30,6 +31,7 @@ import { Exit, Fiber, Option, + Predicate, Redacted, Result, Schema, @@ -93,9 +95,14 @@ import { slimImagesEnabled } from "../services/slim-images.ts"; import { DockerLogsStreamError, EdgeRuntimeContainerCrashedError, + EdgeRuntimeDockerStepError, EdgeRuntimeLogStreamLostError, + ServeConfigError, + ServeEnvError, + ServeFilesystemError, ServeLocalDbInspectError, ServeLocalDbNotRunningError, + classifyEdgeRuntimeDockerStepStderr, externalTerminationExitCodes, } from "./serve.errors.ts"; import { CliConfigKeys } from "../../config/cli-config-keys.ts"; @@ -460,14 +467,21 @@ export function toPlainFunctionRecord( ); } -/* Env file failures keep the plain error `start` and `serve` have always reported. */ -const legacyEnvFailure = (effect: Effect.Effect) => +/* Parse failures carry no cause; a missing file is a Node `ENOENT`. */ +const typedEnvFileFailure = (effect: Effect.Effect) => effect.pipe( Effect.mapError((error) => nativeFailure( - error.cause === undefined - ? new Error(error.message) - : new Error(error.message, { cause: error.cause }), + new ServeEnvError({ + reason: + error.cause === undefined + ? "syntax" + : Predicate.hasProperty(error.cause, "code") && error.cause.code === "ENOENT" + ? "not_found" + : "read", + message: error.message, + cause: error.cause, + }), ), ), ); @@ -814,6 +828,14 @@ function splitEnvEntry(entry: string) { : ([entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)] as const); } +const envStagingFailure = (error: PlatformError, pathname: string) => + nativeFailure( + new ServeFilesystemError({ + message: nativePlatformFailure(error, pathname).message, + cause: error, + }), + ); + const writeDockerEnvFile = Effect.fn("functions.serve.writeDockerEnvFile")(function* ( { fs, path }: { readonly fs: FileSystem.FileSystem; readonly path: Path.Path }, env: Readonly>, @@ -831,10 +853,10 @@ const writeDockerEnvFile = Effect.fn("functions.serve.writeDockerEnvFile")(funct // alongside the fresh write. yield* fs .remove(dir, { recursive: true, force: true }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); yield* fs .makeDirectory(dir, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); const pathname = path.join(dir, "docker.env"); // The file holds the JWT secret, anon/service-role keys, and JWKS, so keep it // owner-only rather than relying on the process umask. @@ -846,7 +868,7 @@ const writeDockerEnvFile = Effect.fn("functions.serve.writeDockerEnvFile")(funct .join("\n"), { mode: 0o600 }, ) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, pathname))); + .pipe(Effect.mapError((error) => envStagingFailure(error, pathname))); return { path: pathname }; }); @@ -863,7 +885,7 @@ const writeDockerMultilineEnvScript = Effect.fn("functions.serve.writeDockerMult // earlier invocation that needed multiline secrets is still reclaimed. yield* fs .remove(dir, { recursive: true, force: true }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); if (env.length === 0) { return undefined; @@ -871,7 +893,7 @@ const writeDockerMultilineEnvScript = Effect.fn("functions.serve.writeDockerMult yield* fs .makeDirectory(dir, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, dir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, dir))); const scriptName = "multiline-env.sh"; const pathname = path.join(dir, scriptName); const envDir = path.join(containerDir, "values"); @@ -887,20 +909,20 @@ export ${name}="\${${name}%x}"`; .join("\n"); yield* fs .makeDirectory(hostEnvDir, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, hostEnvDir))); + .pipe(Effect.mapError((error) => envStagingFailure(error, hostEnvDir))); // The value files hold secret env values, so keep them owner-only. yield* Effect.all( env.map(([, value], index) => { const valuePath = path.join(hostEnvDir, `env-${index}`); return fs .writeFileString(valuePath, value, { mode: 0o600 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, valuePath))); + .pipe(Effect.mapError((error) => envStagingFailure(error, valuePath))); }), { concurrency: "unbounded" }, ); yield* fs .writeFileString(pathname, script, { mode: 0o600 }) - .pipe(Effect.mapError((error) => nativePlatformFailure(error, pathname))); + .pipe(Effect.mapError((error) => envStagingFailure(error, pathname))); return { // `Z`: private SELinux relabel of this CLI-staged dir (supabase/cli#5989); @@ -1362,7 +1384,11 @@ const bestEffortRemoveContainer = Effect.fn("functions.serve.removeContainer")(f // passes a `messagePrefix`, since its raw stderr is uninterpretable alone. const runEdgeRuntimeDockerStep = Effect.fn("functions.serve.dockerStep")(function* ( args: ReadonlyArray, - opts: { readonly messagePrefix?: string; readonly stdin?: Stream.Stream } = {}, + opts: { + readonly step: "create" | "cp" | "start"; + readonly messagePrefix?: string; + readonly stdin?: Stream.Stream; + }, ) { const result = yield* runChildProcess("docker", args, { stdin: opts.stdin, @@ -1377,7 +1403,14 @@ const runEdgeRuntimeDockerStep = Effect.fn("functions.serve.dockerStep")(functio : detail.length > 0 ? `${opts.messagePrefix}: ${detail}` : opts.messagePrefix; - return yield* nativeFailure(new Error(message)); + const reason = classifyEdgeRuntimeDockerStepStderr(detail); + yield* Effect.annotateCurrentSpan({ + "edge_runtime.docker_step": opts.step, + "edge_runtime.failure_reason": reason, + }); + return yield* nativeFailure( + new EdgeRuntimeDockerStepError({ step: opts.step, reason, message }), + ); } }); @@ -1535,7 +1568,10 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin ); if (missingSourceWarning !== undefined) { return yield* nativeFailure( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), + new ServeConfigError({ + reason: "missing_import", + message: missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), + }), ); } } @@ -1629,7 +1665,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ); if (missingSourceWarning !== undefined) { return yield* nativeFailure( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), + new ServeConfigError({ + reason: "missing_import", + message: missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), + }), ); } for (const warning of bindWarnings) { @@ -1644,7 +1683,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const functionEnv = input.discoverFunctionEnvFiles && Option.isNone(input.envFile) ? yield* resolveFunctionEnv({ functionsDir, slug: config.slug, warn: true }).pipe( - legacyEnvFailure, + typedEnvFileFailure, ) : {}; functionsConfig[config.slug] = toFunctionContainerConfig( @@ -1678,7 +1717,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo normalizeEnvPath(input.flagCwd, pathname, path), ), warn: true, - }).pipe(legacyEnvFailure), + }).pipe(typedEnvFileFailure), ).map(([name, value]) => `${name}=${value}`), "SUPABASE_URL=http://kong:8000", `SUPABASE_ANON_KEY=${input.authArtifacts.anonKey}`, @@ -1704,7 +1743,13 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo return yield* Effect.gen(function* () { yield* Effect.try({ try: () => validateDockerMultilineEnvNames(multilineDockerEnv), - catch: nativeFailure, + catch: (cause) => + nativeFailure( + new ServeEnvError({ + reason: "env_name", + message: cause instanceof Error ? cause.message : String(cause), + }), + ), }); const dockerEnvFile = yield* writeDockerEnvFile( { fs, path }, @@ -1781,15 +1826,16 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // The container must exist for `docker cp` to have a target, and must not be running // yet so edge-runtime never races the copy. yield* Effect.uninterruptibleMask((restore) => - restore(runEdgeRuntimeDockerStep(command)).pipe( + restore(runEdgeRuntimeDockerStep(command, { step: "create" })).pipe( Effect.tap(() => Effect.sync(() => input.onContainerCreated?.())), ), ); yield* runEdgeRuntimeDockerStep(["cp", "-", `${containerId}:/`], { + step: "cp", messagePrefix: "failed to copy edge runtime main service into container", stdin: Stream.make(serveMainArchive), }); - yield* runEdgeRuntimeDockerStep(["start", containerId]); + yield* runEdgeRuntimeDockerStep(["start", containerId], { step: "start" }); return { containerId, @@ -1962,7 +2008,13 @@ export const serveFunctions = Effect.fn("functions.serve")(function* ( buildFunctionsServeInspectArgs(resolvedInspectMode, flags.inspectMain); return resolvedInspectMode; }, - catch: nativeFailure, + catch: (cause) => + nativeFailure( + new ServeConfigError({ + reason: "inspect_flags", + message: cause instanceof Error ? cause.message : String(cause), + }), + ), }); const gracePeriod = dependencies.timers?.shutdownSignalGracePeriod ?? shutdownSignalGracePeriod; const retryDelay = dependencies.timers?.dockerLogRetryDelay ?? dockerLogRetryDelay; diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index aaef9c3cc8..0677268c68 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -204,6 +204,7 @@ DockerRemoveAllListError DockerRemoveAllNetworkPruneError DockerRemoveAllStopError DockerRemoveAllVolumePruneError +DockerResourceCreateError DockerRunError DocsExamplesError DomainsCnameError @@ -211,6 +212,7 @@ DomainsNetworkError DomainsUnexpectedStatusError DuplicateRemoteProjectIdError EdgeRuntimeContainerCrashedError +EdgeRuntimeDockerStepError EdgeRuntimeLogStreamLostError EdgeRuntimeScriptError EncryptionNetworkError @@ -447,6 +449,9 @@ SecretsUnsetUnexpectedStatusError SeedConfigLoadError SeedMissingProjectConfigError SeedWorkdirError +ServeConfigError +ServeEnvError +ServeFilesystemError ServeLocalDbInspectError ServeLocalDbNotRunningError ServiceVersionNotFoundError diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 62c9ac7231..70d0ed9a2a 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -106,8 +106,10 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "daemon_upgrade_restart", "database", "database_bootstrap", + "disk_full", "docker_not_running", "engine_command", + "engine_command_copy", "engine_command_create", "engine_command_inspect", "engine_command_remove",