From 3d25f928b3e970619c982ebe65a7713070d6a9a2 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:02:39 -0300 Subject: [PATCH 01/17] feat(cli): add supabase workers push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds and deploys workers into the linked project, and brings the Management API seam with it. Registered under `deploy` as an alias, for anyone reaching for the `supabase functions` verb out of habit. Given no names it deploys every worker in the project, matching `supabase functions deploy`, whose conventions this command set otherwise mirrors. "Every worker" is the union of the directories under `supabase/workers/` and the `[workers.]` entries, so one with a `source` pointing elsewhere is not missed, and the order is sorted rather than whatever the filesystem returned. Deploys run one at a time: each is a server-side container build, so interleaving them would both compete for the alpha's per-project capacity and shred the progress output; the first failure stops the run. The flow is mint an upload slot, PUT the `.tar.gz` build context straight at the presigned URL, deploy, then poll until `build_state` leaves `building`. The upload carries no Supabase credentials: the signature in the URL is the authorization, and the bytes never pass through the management API. That signature is also a write-capable credential for the archive a deploy is about to build from, so `legacyHttpClientLayer` redacts presigned URLs at the logging boundary — `--debug` scrollback and CI logs are not where it belongs, and redacting there covers every presigned URL the CLI might log rather than only this one. Polling is a `Schedule`, and the read inside it retries on a wall-clock budget so a blip of a second or two does not throw away a deploy that still has minutes of build ahead of it. Which spec is sent depends on the runtime: a `dockerfile` worker sends a context and no `spec.runtime`, a catalog runtime sends both, and a bare `sandbox` sends the runtime alone and skips packaging, so it has no URL. A directory with no `[workers.] runtime` has one guessed from marker files once the source is known to exist, reported on stderr with a nudge to pin it down. Everything that can fail deterministically fails before the remote project changes. `-o env` and a `-o toml` payload carrying an absent optional are settled up front rather than at emit time, where the command would exit non-zero having already deployed and invite a retry that deployed again; `--instances` is bounded at the parser the way the config schema bounds `[workers.] instances`, instead of carrying an impossible scaling request through a packaged upload; and a source of nothing but empty directories is refused before an upload slot is minted, rather than deployed as an image with no handler. The build context is packaged in-process rather than by shelling out to `tar`, whose BSD, GNU and absent-on-Windows variants each produce a different archive from the same tree. `tar.ts` writes USTAR directly: files, directories and symlinks, refusing a value too large for an octal header field instead of letting it spill into the next one and read back as a plausible but wrong size. Symlinks are stored as links rather than followed — anything pnpm installs is symlink-dense, so following them would inline every dependency and walk into a link pointing at an ancestor. Every filesystem error propagates: an unreadable file archived as zero bytes, a dropped subtree or an entry lost between `readDirectory` and its stat all mean a successful `push` reporting an image built from an application with a hole in it. The Workers routes answer 404 both for a project outside the alpha's allow-list and for a ref that names nothing this account can see, so the classification reads `error.code`: `not_found` raises `WorkerProjectNotFoundError` naming the ref, `supabase link` and `supabase login`, and anything unrecognized keeps the enrolment answer, since that is what the allow-list has historically returned and guessing the other way sends someone to check a ref that is fine. This is the first command in this shell to call a v2 Management API route; every other one here is a Go-parity port and uses v1 only. Two findings are deliberate follow-ups rather than defects: streaming the build context instead of buffering it, and an ignore mechanism so `.env` and `.git` can be kept out of the uploaded archive. --- .../legacy/auth/legacy-http-debug.layer.ts | 67 +- .../auth/legacy-http-debug.unit.test.ts | 56 ++ .../commands/workers/push/SIDE_EFFECTS.md | 74 ++ .../commands/workers/push/push.command.ts | 62 ++ .../commands/workers/push/push.handler.ts | 406 +++++++++++ .../workers/push/push.integration.test.ts | 665 ++++++++++++++++++ .../commands/workers/workers.command.ts | 3 +- .../legacy/docs/legacy-docs-spec.tables.ts | 1 + .../legacy/shared/legacy-db-target-flags.ts | 1 + apps/cli/src/shared/workers/tar.ts | 224 ++++++ apps/cli/src/shared/workers/tar.unit.test.ts | 116 +++ .../cli/src/shared/workers/worker-classify.ts | 48 ++ apps/cli/src/shared/workers/worker-config.ts | 10 + .../shared/workers/worker-config.unit.test.ts | 26 +- apps/cli/src/shared/workers/worker-package.ts | 133 ++++ .../workers/worker-package.unit.test.ts | 216 ++++++ .../cli/src/shared/workers/worker-runtimes.ts | 7 + apps/cli/src/shared/workers/worker-url.ts | 17 + apps/cli/src/shared/workers/workers-api.ts | 429 +++++++++++ apps/cli/src/shared/workers/workers.errors.ts | 136 ++++ apps/cli/tests/helpers/legacy-workers.ts | 34 +- 21 files changed, 2717 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/push/push.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/push/push.integration.test.ts create mode 100644 apps/cli/src/shared/workers/tar.ts create mode 100644 apps/cli/src/shared/workers/tar.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-classify.ts create mode 100644 apps/cli/src/shared/workers/worker-package.ts create mode 100644 apps/cli/src/shared/workers/worker-package.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-url.ts create mode 100644 apps/cli/src/shared/workers/workers-api.ts diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts index 9e34b6437d..bf93986607 100644 --- a/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-http-debug.layer.ts @@ -6,9 +6,68 @@ import { legacyDohFetchLayer } from "../shared/legacy-http-dns.ts"; import { LegacyDebugLogger } from "../shared/legacy-debug-logger.service.ts"; /** - * Wraps `FetchHttpClient.layer` so every HTTP request can go through the - * legacy Go-parity debug side channel. The logger itself owns the `--debug` - * guard and byte-for-byte line formatting. + * Query parameters that mean the URL *is* a credential. + * + * A presigned object-store URL authorizes whoever holds it — for the Workers + * build-context upload, to overwrite the archive a deploy is about to build + * from. Logging one verbatim under `--debug` puts that in terminal scrollback + * and in any CI log or bug report the output is pasted into. + */ +const PRESIGNED_QUERY_KEYS = [ + // AWS SigV4 and SigV2 + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + "awsaccesskeyid", + // Google Cloud Storage V4 + "x-goog-signature", + "x-goog-credential", + // Azure SAS, and the generic spellings everything else uses + "sig", + "se", + "signature", + "token", +]; + +/** + * The URL as it should appear in a debug log: unchanged, unless its query string + * carries a signature, in which case the query is replaced wholesale. + * + * Redacting the whole query rather than the matched parameters keeps the + * decision simple and cannot leak a sibling parameter that turns out to matter. + * The path survives, which is what makes the line useful for debugging in the + * first place. + * + * A denylist of known signature parameters, so it is by nature incomplete: a + * provider spelling its signature something new would log verbatim until the + * list learns about it. The alternative — redacting every query string — would + * cost the debug log its usefulness on the Management API calls that are the + * whole reason `--debug` exists. Add spellings here as they turn up. + */ +export function legacyRedactHttpUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // Not a URL we can reason about; log it as-is rather than swallow it. + return url; + } + if (parsed.search === "") { + return url; + } + const presigned = [...parsed.searchParams.keys()].some((key) => + PRESIGNED_QUERY_KEYS.includes(key.toLowerCase()), + ); + if (!presigned) { + return url; + } + return `${parsed.origin}${parsed.pathname}?`; +} + +/** + * Wraps `FetchHttpClient.layer` so every HTTP request goes through the legacy + * debug side channel. The logger itself owns the `--debug` guard and the + * line formatting. * * `legacyDohFetchLayer` overrides `FetchHttpClient.Fetch` with a * DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. @@ -19,7 +78,7 @@ export const legacyHttpClientLayer = Layer.effect( const logger = yield* LegacyDebugLogger; const base = yield* HttpClient.HttpClient; return HttpClient.mapRequestEffect(base, (req) => - logger.http(req.method, req.url).pipe(Effect.as(req)), + logger.http(req.method, legacyRedactHttpUrl(req.url)).pipe(Effect.as(req)), ); }), ).pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(legacyDohFetchLayer)); diff --git a/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts new file mode 100644 index 0000000000..c77cd9bace --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-http-debug.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { legacyRedactHttpUrl } from "./legacy-http-debug.layer.ts"; + +/** + * `--debug` logs every request URL to stderr. For a presigned object-store URL + * the query string *is* the credential — for the Workers build-context upload, + * one that authorizes overwriting the archive a deploy is about to build from — + * so it must not survive into scrollback or a CI log. + */ +describe("legacyRedactHttpUrl", () => { + test.each([ + [ + "an AWS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a GCS presigned upload", + "https://store.example/bucket/ctx.tar.gz?X-Goog-Signature=deadbeef", + "https://store.example/bucket/ctx.tar.gz?", + ], + [ + "a lowercase signature parameter", + "https://store.example/o/ctx?signature=deadbeef&expires=123", + "https://store.example/o/ctx?", + ], + [ + "a bare token parameter", + "https://store.example/o/ctx?token=deadbeef", + "https://store.example/o/ctx?", + ], + ])("redacts the query string of %s", (_label, url, expected) => { + expect(legacyRedactHttpUrl(url)).toBe(expected); + expect(legacyRedactHttpUrl(url)).not.toContain("deadbeef"); + }); + + // The debug log is only useful if ordinary requests still read normally, so + // redaction has to be the exception rather than the rule. + test.each([ + ["a Management API route", "https://api.supabase.com/v2/projects/abc/workers/api"], + ["an ordinary query string", "https://api.supabase.com/v1/projects?limit=10"], + ["a URL with no query at all", "https://api.supabase.com/v1/projects"], + ])("leaves %s untouched", (_label, url) => { + expect(legacyRedactHttpUrl(url)).toBe(url); + }); + + test("passes through something that is not a parseable URL", () => { + expect(legacyRedactHttpUrl("not a url at all")).toBe("not a url at all"); + }); + + test("keeps the path, which is what makes the log line worth having", () => { + expect(legacyRedactHttpUrl("https://store.example/bucket/deep/ctx.tar.gz?sig=x")).toContain( + "/bucket/deep/ctx.tar.gz", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md new file mode 100644 index 0000000000..b145692970 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -0,0 +1,74 @@ +# `supabase workers push [name...] (alias: deploy)` + +> **No live test yet.** `workers` runs against the v2 Management API, which the +> supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` +> here would be permanently skipped or permanently red. Revisit when the v2 +> Workers routes are available on that stack. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for each worker's runtime, size, source | +| `/**` | any | always — packaged into the build context | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | + +## Files Written + +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | +| `POST` | `/v2/projects/{ref}/workers/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | +| `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only | +| `POST` | `/v2/projects/{ref}/workers/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region | + +`GET` is polled until `build_state` leaves `building`. + +## Exit Codes + +| Code | Condition | +| ---- | ---------------------------------------------------- | +| `0` | success | +| `1` | no workers named and none found in the project | +| `1` | a worker's source directory is missing or empty | +| `1` | build context upload failed | +| `1` | the build reached `failed`, or never left `building` | +| `1` | API error, or project not enrolled in the alpha | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. + +## Output Formats + +`-o env` is refused **before** the first deploy rather than at emit time: the +payload always carries a `workers` array, which a flat `KEY=value` list cannot +express, and discovering that at the end would fail the command with the remote +project already changed. + +The presigned `PUT` above is the one request whose URL is itself a credential. +`--debug` logs every request URL, so `legacyHttpClientLayer` redacts query +strings that carry a signature. diff --git a/apps/cli/src/legacy/commands/workers/push/push.command.ts b/apps/cli/src/legacy/commands/workers/push/push.command.ts new file mode 100644 index 0000000000..9262f028a8 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.command.ts @@ -0,0 +1,62 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; + +const config = { + names: Argument.string("name").pipe( + Argument.withDescription("Workers to deploy. Deploys every worker in the project if omitted."), + Argument.variadic(), + ), + instances: Flag.integer("instances").pipe( + // Bounded at the parser, the same way `[workers.] instances` is bounded + // in the config schema. Left unchecked it reached the deploy endpoint — after + // the build context had been packaged and uploaded — as a scaling request the + // platform cannot honour. + Flag.filter( + (instances) => instances >= 0, + (instances) => `--instances ${instances} is negative; pass zero or more.`, + ), + Flag.withDescription( + "Number of instances to run, overriding `instances` in supabase/config.toml for this deploy. Falls back to the recorded value, then 1.", + ), + Flag.optional, + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersPushFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersPushCommand = Command.make("push", config).pipe( + Command.withAlias("deploy"), + Command.withDescription( + "Build and deploy workers into the linked Supabase project. Reads each worker's runtime, size and source directory from supabase/config.toml.", + ), + Command.withShortDescription("Build and deploy workers"), + Command.withExamples([ + { + command: "supabase workers push", + description: "Deploy every worker in the project", + }, + { + command: "supabase workers push api", + description: "Deploy a single worker", + }, + { + command: "supabase workers push api web", + description: "Deploy several workers by name", + }, + ]), + Command.withHandler((flags) => + legacyWorkersPush(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "push"])), +); diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts new file mode 100644 index 0000000000..86b9a068d3 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -0,0 +1,406 @@ +import { Effect, FileSystem, Option, type Schedule } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyRejectWorkersEnvOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; +import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { + apiSizeFor, + DEFAULT_WORKER_INSTANCES, + DEFAULT_WORKER_SIZE, + formatApiSize, + parseWorkerRuntime, + parseWorkerSize, + WORKER_RUNTIMES, + WORKER_SIZES, +} from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { + awaitWorkerBuild, + createWorkerUpload, + deployWorker, + uploadBuildContext, + type WorkerDeploySpec, +} from "../../../../shared/workers/workers-api.ts"; +import { + NoWorkersToDeployError, + UnknownWorkerRuntimeError, + UnknownWorkerSizeError, + WorkerBuildFailedError, + WorkerSourceMissingError, +} from "../../../../shared/workers/workers.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyDescribeWorker, + legacyDiscoverWorkerNames, + legacyLoadWorkersProject, + legacyValidateWorkerName, + type LegacyWorkersProject, +} from "../workers.shared.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +/** + * `supabase workers push [name]` — build (when there is code to build) and + * deploy the worker into the linked project. Registered under `deploy` as an + * alias, for anyone reaching for the `supabase functions` verb out of habit. + * + * The runtime, size and source directory come from `[workers.]` in + * `supabase/config.toml`. A directory pushed without ever running `new` gets + * its runtime guessed from marker files instead — reported, with a nudge to pin + * it down rather than re-guess on every push. + * + * A `dockerfile` worker is tarred and uploaded, and the build happens + * server-side from that context, never on your machine. A catalog runtime with + * code takes the same path, with the base image and a copy synthesized in place + * of your Dockerfile. Every runtime this CLI offers has code to package, so + * there is no path here that skips the upload. + */ + +const resolveRuntime = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; + readonly sourceDir: string; +}) { + if (options.recorded !== undefined) { + const recorded = parseWorkerRuntime(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerRuntimeError({ + detail: `supabase/config.toml records an unknown runtime "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] runtime to one of: ${WORKER_RUNTIMES.join(", ")}.`, + }), + ); + } + return recorded; + } + + const output = yield* Output; + const classified = yield* classifyWorkerDir(options.sourceDir); + // A guess the user should pin down: stderr, so it never lands inside a + // payload stdout is carrying. + yield* output.raw( + `No runtime configured for ${options.name}: guessed ${classified.runtime} (${classified.reason}). ` + + `Pin it down by adding [workers.${options.name}] runtime = "${classified.runtime}" to supabase/config.toml.\n`, + "stderr", + ); + return classified.runtime; +}); + +const resolveSize = Effect.fnUntraced(function* (options: { + readonly name: string; + readonly recorded: string | undefined; +}) { + if (options.recorded === undefined) { + return DEFAULT_WORKER_SIZE; + } + const recorded = parseWorkerSize(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerSizeError({ + detail: `supabase/config.toml records an unknown size "${options.recorded}" for "${options.name}".`, + suggestion: `Set [workers.${options.name}] size to one of: ${WORKER_SIZES.join(", ")}.`, + }), + ); + } + return recorded; +}); + +/** + * `--instances` for one deploy, then the recorded count, then + * {@link DEFAULT_WORKER_INSTANCES}. Never left unset, because every deploy sends + * a complete spec and an omitted count rescales the worker. + * + * No unparseable case to report: the config schema and the flag are both bounded + * to a non-negative integer before the handler runs. + */ +function resolveInstances(options: { + readonly recorded: number | undefined; + readonly override: Option.Option; +}): number { + return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); +} + +const deployOneWorker = Effect.fnUntraced(function* (input: { + readonly project: LegacyWorkersProject; + readonly name: string; + readonly projectRef: string; + readonly instances: Option.Option; + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + /** Suppresses this step's human output when `-o` owns stdout. */ + readonly machineOutput: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + + const { project, name, projectRef } = input; + const worker = yield* legacyDescribeWorker(project, name); + + const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); + + // Checked before the runtime is resolved, not after: with no recorded + // runtime, `resolveRuntime` classifies the directory and announces what it + // guessed. Doing that first meant reporting an inference about a path that + // does not exist, and only then failing on the path. + { + const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); + if (stat._tag === "None" || stat.value.type !== "Directory") { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `There is no worker source at ${sourceDisplay}.`, + suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + }), + ); + } + // An empty directory packages and deploys perfectly happily, producing an + // image with nothing in it — a success message for a worker that cannot + // serve anything. Refuse before uploading rather than after. + const contents = yield* fs.readDirectory(worker.sourceDir).pipe(Effect.orElseSucceed(() => [])); + if (contents.length === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} is empty, so there is nothing to deploy.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + } + + const runtime = yield* resolveRuntime({ + name, + recorded: worker.entry?.runtime, + sourceDir: worker.sourceDir, + }); + + // Size: whatever `new --size` recorded, else the alpha envelope's own + // default. Never left unset, because a worker that is actually running always + // has some concrete size — and never silently coerced, because a size the CLI + // does not recognize is a config mistake worth naming. + const size = yield* resolveSize({ name, recorded: worker.entry?.size }); + + const instances = resolveInstances({ + recorded: worker.entry?.instances, + override: input.instances, + }); + + let contextUploadId: string; + { + const packaging = yield* output.task("Packaging worker..."); + const packaged = yield* packageWorkerDirectory(worker.sourceDir).pipe( + Effect.tapError(() => packaging.fail()), + ); + yield* packaging.clear(); + yield* output.raw( + `Packaged ${sourceDisplay} (${packaged.fileCount} files, ${formatBytes( + packaged.archive.length, + )}).\n`, + "stderr", + ); + + // The guard above counts directory entries, so a tree of nothing but empty + // subdirectories reaches here and packages to zero files. For a catalog + // runtime that deploys an image with no handler in it — the exact "nothing + // to deploy" case that guard exists to refuse. + if (packaged.fileCount === 0) { + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} holds no files to deploy, only empty directories.`, + suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + }), + ); + } + + const uploading = yield* output.task("Uploading build context..."); + const slot = yield* createWorkerUpload(api, projectRef, name).pipe( + Effect.tapError(() => uploading.fail()), + ); + yield* uploadBuildContext(slot, packaged.archive).pipe(Effect.tapError(() => uploading.fail())); + yield* uploading.clear(); + yield* output.raw("Uploaded build context.\n", "stderr"); + contextUploadId = slot.uploadId; + } + + const spec: WorkerDeploySpec = { + // A plain Dockerfile build has no catalog runtime to name; the uploaded + // context carries its own Dockerfile and is built as-is. + ...(runtime === "dockerfile" ? {} : { runtime }), + size: apiSizeFor(size), + // Every runtime offered today serves HTTP. A sandbox runtime would need a + // branch here. + exposure: "public", + instances, + }; + + const deploying = yield* output.task("Deploying worker..."); + yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( + Effect.tapError(() => deploying.fail()), + ); + + const settled = yield* awaitWorkerBuild(api, projectRef, name, { + schedule: input.pollSchedule, + retrySchedule: input.pollRetrySchedule, + onPoll: (polled) => + polled.buildState === "building" ? deploying.message("Building worker...") : Effect.void, + }).pipe(Effect.tapError(() => deploying.fail())); + + if (settled.buildState === "failed") { + yield* deploying.clear(); + return yield* Effect.fail( + new WorkerBuildFailedError({ + detail: `The build for "${name}" failed${ + settled.stateReason === undefined ? "" : `: ${settled.stateReason}` + }.`, + suggestion: `Fix the issue, then re-run \`supabase workers push ${name}\`.`, + }), + ); + } + + yield* deploying.clear(); + + const url = + settled.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined; + + // Suppressed when `-o` is in play: the payload owns stdout, and these lines + // would land in the middle of it. + if (output.format === "text" && !input.machineOutput) { + // Declarative line first, then the details — the shape every other command + // that reports a completed remote change uses. `legacyRenderWorkerDetails` drops + // empty-valued rows, so optional fields need no conditional spreads. + yield* output.raw( + `Deployed Worker ${legacyAqua(name, process.stdout)} to project ${projectRef}\n`, + ); + yield* output.raw( + legacyRenderWorkerDetails([ + ["Runtime", runtime], + ["Size", formatApiSize(settled.spec.size)], + ["Image", settled.imageVersion ?? ""], + ["Access", settled.spec.exposure], + ["URL", url ?? ""], + ]), + ); + } + + return { + worker_name: name, + runtime, + size: settled.spec.size, + exposure: settled.spec.exposure, + instances: settled.spec.instances, + // Omitted rather than present-and-undefined: `-o toml` hands the payload to + // smol-toml, which cannot represent undefined and would throw *after* the + // upload and deploy had completed. Same reason `url` is spread below. + ...(settled.imageVersion === undefined ? {} : { image_version: settled.imageVersion }), + build_state: settled.buildState, + ...(url === undefined ? {} : { url }), + }; +}); + +/** + * `supabase workers push [name...]` — deploy the named workers, or every worker + * in the project when none are named, mirroring `supabase functions deploy`. + * + * Deploys run one at a time rather than concurrently: each is a server-side + * container build, and interleaving several would both hammer the alpha's + * per-project capacity and shred the progress output. The first failure stops + * the run, because a build that failed is usually the thing to fix before + * spending minutes on the rest. + */ +export const legacyWorkersPush = Effect.fn("legacy.workers.push")(function* ( + flags: LegacyWorkersPushFlags, + options: { + readonly pollSchedule?: Schedule.Schedule; + readonly pollRetrySchedule?: Schedule.Schedule; + } = {}, +) { + const output = yield* Output; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + // The ref is resolved outside the finalizers because caching it is one of + // them; everything that can fail on its own — loading `config.toml`, + // validating names, discovering workers — belongs inside, so a malformed + // config still flushes telemetry. Same shape as `config/push`. + const projectRef = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const requested = + flags.names.length > 0 + ? yield* Effect.forEach(flags.names, legacyValidateWorkerName) + : yield* legacyDiscoverWorkerNames(project); + + if (requested.length === 0) { + return yield* Effect.fail( + new NoWorkersToDeployError({ + detail: `No workers were named, and none were found in ${displayPath( + project.projectRoot, + project.workersDir, + )}.`, + suggestion: "Scaffold one with `supabase workers new `.", + }), + ); + } + + const names = [...new Set(requested)]; + + // Before the first deploy, not after the last one: this payload always + // carries a `workers` array, so `-o env` can never encode it, and finding + // that out at the end means failing with the remote project already changed. + yield* legacyRejectWorkersEnvOutput(); + + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const deployed: Array> = []; + for (const name of names) { + if (names.length > 1 && !machineOutput) { + // stderr, unblanked and labelled, the way `functions deploy` announces + // each function: a bare name with a leading blank line put a section + // header into whatever was consuming stdout. + yield* output.raw(`Deploying Worker: ${legacyAqua(name)}\n`, "stderr"); + } + deployed.push( + yield* deployOneWorker({ + project, + name, + projectRef, + instances: flags.instances, + machineOutput, + ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), + ...(options.pollRetrySchedule === undefined + ? {} + : { pollRetrySchedule: options.pollRetrySchedule }), + }), + ); + } + + const payload = { project_ref: projectRef, workers: deployed }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersMachineOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts new file mode 100644 index 0000000000..0c16266931 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -0,0 +1,665 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Schedule } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, + type WorkersHttpRoutes, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + NoWorkersToDeployError, + WorkerBuildFailedError, + WorkerProjectNotFoundError, + WorkersUnavailableError, + WorkerSourceMissingError, + WorkerUploadFailedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersPush } from "./push.handler.ts"; +import type { LegacyWorkersPushFlags } from "./push.command.ts"; + +const UPLOAD_URL = "https://storage.example/deploy-context/api.tar.gz?signed"; +const UPLOAD_ID = "cafe0000000000000000000000000000"; + +/** Polls run with no delay so a build sequence resolves at test speed. */ +const IMMEDIATE = Schedule.recurs(20); + +const uploadSlot = { + data: { + type: "project_worker_upload", + id: UPLOAD_ID, + attributes: { url: UPLOAD_URL, method: "PUT", expires_at: "2026-08-12T00:15:00Z" }, + }, +}; + +function flags(overrides: Partial = {}): LegacyWorkersPushFlags { + return { + names: ["api"], + instances: Option.none(), + projectRef: Option.none(), + ...overrides, + }; +} + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + "supabase/workers/api/index.js": "export default { fetch: () => new Response('ok') };\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +function routes(overrides: WorkersHttpRoutes = {}): WorkersHttpRoutes { + return { + [`POST ${workersRoute("/api/uploads")}`]: { status: 201, body: uploadSlot }, + "PUT /deploy-context/api.tar.gz": { status: 200 }, + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "active", + imageVersion: "v1", + }), + }, + }, + ...overrides, + }; +} + +/** + * The `_tag` of a failure, for a channel that also carries plain `Error` + * subclasses — `TarPathTooLongError` has no tag. + */ +function tagOf(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "_tag" in error + ? String((error as { _tag: unknown })._tag) + : undefined; +} + +function push(flagOverrides: Partial = {}) { + // Both schedules are injected: the outer poll and the per-read retry. The + // production retry is spaced in seconds, so leaving it in place made the + // transient-failure test wait on a real clock. + return legacyWorkersPush(flags(flagOverrides), { + pollSchedule: IMMEDIATE, + pollRetrySchedule: IMMEDIATE, + }); +} + +describe("legacy workers push", () => { + it.live("packages, uploads, deploys and waits for the build to settle", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(http.routeKeys).toEqual([ + `POST ${workersRoute("/api/uploads")}`, + "PUT /deploy-context/api.tar.gz", + `POST ${workersRoute("/api/deploy")}`, + `GET ${workersRoute("/api")}`, + ]); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}")).toEqual({ + data: { + type: "project_worker", + attributes: { + spec: { + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }, + context_upload_id: UPLOAD_ID, + }, + }, + }); + + const upload = http.requests.find((request) => request.method === "PUT"); + expect(upload?.byteLength).toBeGreaterThan(0); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + expect(out.stdoutText).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("omits the runtime for a Dockerfile worker and builds from the uploaded context", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + "supabase/workers/api/Dockerfile": "FROM node:24-alpine\nEXPOSE 8080\n", + }); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + const attributes = JSON.parse(deploy?.body ?? "{}").data.attributes; + expect(attributes.spec).toEqual({ + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + }); + expect(attributes.context_upload_id).toBe(UPLOAD_ID); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("guesses the runtime for a directory with no config entry and says so", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n`, + "supabase/workers/api/package.json": "{}\n", + }); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stderrText).toContain("guessed node"); + expect(out.stderrText).toContain("found package.json"); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBe("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("sends the recorded size and the requested instance count", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "4gb"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(3) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec).toEqual({ + runtime: "node", + size: "4gb-2vcpu", + exposure: "public", + instances: 3, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps a worker scaled at the count recorded in config", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.instances).toBe(4); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("lets --instances override the recorded count for one deploy", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ instances: Option.some(1) }); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.instances).toBe(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("polls until the build leaves `building`", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + { + status: 200, + body: { + data: workerResource({ name: "api", buildState: "active", imageVersion: "v2" }), + }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const polls = http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`); + expect(polls).toHaveLength(3); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with the build's own reason when the build fails", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { + data: workerResource({ + name: "api", + buildState: "failed", + stateReason: "error building image: exit status 1", + }), + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerBuildFailedError); + expect((error as WorkerBuildFailedError).detail).toContain("error building image"); + expect((error as WorkerBuildFailedError).suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("stops waiting on a build that never settles, and says where to look", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", buildState: "building" }) }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersPush(flags(), { pollSchedule: Schedule.recurs(2) }).pipe( + Effect.flip, + ); + + expect(tagOf(error)).toBe("WorkerBuildTimeoutError"); + expect((error as { suggestion: string }).suggestion).toContain("supabase workers status api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails before deploying when the presigned upload is rejected", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ "PUT /deploy-context/api.tar.gz": { status: 403, body: "expired" } }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerUploadFailedError); + expect(http.routeKeys).not.toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Both of the next two arrive as a 404 on the same route; only `error.code` + // separates them, so they are asserted against the bodies the API really + // sends rather than a shape of our own invention. + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + expect((error as WorkersUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points at the project ref when no such project exists", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerProjectNotFoundError); + expect((error as WorkerProjectNotFoundError).suggestion).not.toContain("private alpha"); + expect((error as WorkerProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("keeps the enrolment answer for a 404 body it does not recognize", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/uploads")}`]: { status: 404, body: { unexpected: true } }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when the worker has no source on disk", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses an empty source directory instead of deploying nothing", () => { + const repo = project({}); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js"), { force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).detail).toContain("is empty"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rides out a transient failure while polling the build", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`GET ${workersRoute("/api")}`]: [ + { status: 500, body: { message: "blip" } }, + { + status: 200, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + ], + }), + }); + + return Effect.gen(function* () { + yield* push(); + + // The blip was retried rather than aborting a deploy already in flight. + expect( + http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`).length, + ).toBeGreaterThan(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("acts on the workdir's project, not the process's directory", () => { + // `--workdir`/`SUPABASE_WORKDIR` names the project every legacy command acts + // on, so the worker discovered here comes from that tree even though the + // process is somewhere else entirely. + const repo = project(); + const elsewhere = makeWorkersProject(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + repo.cleanup(); + rmSync(elsewhere.dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.live("deploys every worker in the project when none are named", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\n\n[workers.web]\nruntime = "node"\n`, + "supabase/workers/web/index.js": "export default {};\n", + }); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + ...routes(), + [`POST ${workersRoute("/web/uploads")}`]: { status: 201, body: uploadSlot }, + [`POST ${workersRoute("/web/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "building" }) }, + }, + [`GET ${workersRoute("/web")}`]: { + status: 200, + body: { data: workerResource({ name: "web", runtime: "node", buildState: "active" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* push({ names: [] }); + + // Both deployed, in a stable (sorted) order. + expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); + expect(http.routeKeys).toContain(`POST ${workersRoute("/web/deploy")}`); + expect(http.routeKeys.indexOf(`POST ${workersRoute("/api/deploy")}`)).toBeLessThan( + http.routeKeys.indexOf(`POST ${workersRoute("/web/deploy")}`), + ); + expect(out.stdoutText).toContain("web"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when there are no workers to deploy at all", () => { + const repo = project({ "supabase/config.toml": `project_id = "demo"\n` }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push({ names: [] }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NoWorkersToDeployError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project or an explicit --project-ref", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("packages a --source worker from where its code actually lives", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("Deployed Worker api"); + expect(out.stdoutText).toContain("Runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: routes(), + }); + + return Effect.gen(function* () { + yield* push(); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + // One entry per worker deployed, since a bare push can deploy several. + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + worker_name: "api", + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 1, + build_state: "active", + image_version: "v1", + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `-o env` cannot express the `workers` array. Discovering that at emit time + // meant failing with the project already changed, inviting a retry that + // deployed all over again. + it.live("refuses -o env before making any request at all", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes(), + goOutput: "env", + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(tagOf(error)).toBe("LegacyWorkersEnvNotSupportedError"); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The "nothing to deploy" guard counts directory entries, so a tree of empty + // subdirectories used to package to zero files and deploy an image with no + // handler in it. + it.live("refuses a source holding only empty directories, before minting a slot", () => { + const repo = project({ "supabase/workers/api/nested/.keep": "" }); + rmSync(join(repo.dir, "supabase", "workers", "api", "index.js")); + rmSync(join(repo.dir, "supabase", "workers", "api", "nested", ".keep")); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(http.routeKeys).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The runtime guess is an inference about the contents of a directory, so it + // has no business being reported for a directory that is not there. + it.live("does not report a guessed runtime when the source is missing", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect(out.stderrText).not.toContain("guessed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `image_version` is optional in the response. Present-but-undefined made the + // TOML encoder throw, after the upload and deploy had already completed. + it.live("encodes -o toml when the deployed worker has no image version", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "toml", + routes: routes({ + [`GET ${workersRoute("/api")}`]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + expect(out.stdoutText).toContain("worker_name"); + expect(out.stdoutText).not.toContain("image_version"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A malformed config.toml used to fail outside the finalizers, so the run + // skipped the telemetry flush every invocation is supposed to perform. + it.live("flushes telemetry when the project config cannot be loaded", () => { + const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); + const { layer, telemetry } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push().pipe(Effect.flip); + + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index ac4555f3de..d575670118 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,10 +1,11 @@ import { Command } from "effect/unstable/cli"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; +import { legacyWorkersPushCommand } from "./push/push.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand]), + Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), ); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index bd9659d06f..124f2423fa 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -198,6 +198,7 @@ export const LEGACY_DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-storage-rm linked": "true", "supabase-test-db local": "true", "supabase-test-new template": "pgtap", + "supabase-workers-push instances": "1", }; /** diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index d9ad846999..963e9b295e 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -120,6 +120,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "git-branch", "import-map", "inspect-mode", + "instances", "lang", "last", "metadata-file", diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts new file mode 100644 index 0000000000..37f28ad05c --- /dev/null +++ b/apps/cli/src/shared/workers/tar.ts @@ -0,0 +1,224 @@ +/** + * A minimal USTAR writer, for the `.tar.gz` build context `supabase workers + * push` uploads. + * + * Shelling out to `tar` would be shorter, but the CLI ships as a single + * compiled binary to machines where `tar` may be BSD tar, GNU tar, or absent + * (Windows), and each writes a different archive for the same directory. The + * server only ever untars what we send, so producing the bytes here keeps the + * upload identical on every platform and keeps packaging out of the process + * table. + */ + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +const BLOCK_SIZE = 512; + +export interface TarEntry { + /** Path inside the archive, always `/`-separated and relative. */ + readonly path: string; + readonly contents: Uint8Array; + /** Unix mode bits. Defaults to `0o644`. */ + readonly mode?: number; + /** Modification time in seconds since the epoch. Defaults to `0`. */ + readonly mtime?: number; + /** + * Target of a symbolic link. When set the entry is stored as a link rather + * than as its contents, which is what keeps a symlink-dense tree (anything + * pnpm installed) from being inlined — and what stops a link to a directory + * from being walked into. + */ + readonly linkTarget?: string; +} + +/** + * The largest value an 11-digit octal field can hold: 8 GiB minus one byte for + * a size, and a little past the year 2242 for an mtime. + */ +const MAX_OCTAL_FIELD = 8 ** 11 - 1; + +/** + * USTAR stores numbers as zero-padded octal followed by a NUL. + * + * A value too large for the field renders one digit too long and spills into the + * next field, producing an archive that reads back with a plausible but wrong + * size — corruption no reader can detect. Real tars switch to base-256 here; + * this writer refuses instead, because a build context carrying an 8 GiB file is + * already a mistake worth naming rather than silently mangling. + */ +function writeOctal(block: Uint8Array, offset: number, length: number, value: number): void { + const text = Math.floor(value) + .toString(8) + .padStart(length - 1, "0"); + if (text.length > length - 1) { + throw new TarFieldTooLargeError(value); + } + writeAscii(block, offset, text); +} + +function writeAscii(block: Uint8Array, offset: number, value: string): void { + for (let index = 0; index < value.length; index++) { + block[offset + index] = value.charCodeAt(index) & 0xff; + } +} + +/** + * Split a path into USTAR's `prefix` (155 bytes) and `name` (100 bytes) fields. + * The split has to fall on a `/`, so a single path component longer than 100 + * bytes cannot be represented at all. + */ +function splitPath(path: string): { name: string; prefix: string } | undefined { + if (byteLength(path) <= 100) { + return { name: path, prefix: "" }; + } + + for (let index = path.indexOf("/"); index !== -1; index = path.indexOf("/", index + 1)) { + const prefix = path.slice(0, index); + const name = path.slice(index + 1); + if (byteLength(prefix) <= 155 && byteLength(name) <= 100) { + return { name, prefix }; + } + } + + return undefined; +} + +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).length; +} + +/** + * Thrown for a path USTAR cannot represent. A plain `Error` rather than a + * tagged one because `createTar` is a pure synchronous function with no Effect + * semantics of its own; the caller's own error channel is where this surfaces. + * It is still user-actionable — renaming the offending file fixes it — so it + * carries its own classification, and the static identifier keeps the + * fingerprint stable through minification. + */ +export class TarPathTooLongError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarPathTooLongError"; + + constructor(path: string) { + super( + `"${path}" is too long for a tar archive (over 100 bytes with no directory boundary to split on)`, + ); + this.name = "TarPathTooLongError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * Thrown for a number USTAR's octal fields cannot hold — see {@link writeOctal}. + * Untagged for the same reason as {@link TarPathTooLongError}: `createTar` is a + * pure function, and the caller's error channel is where this surfaces. + */ +export class TarFieldTooLargeError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarFieldTooLargeError"; + + constructor(value: number) { + super( + `${value} is too large for a tar header field (the limit is ${MAX_OCTAL_FIELD} bytes per file)`, + ); + this.name = "TarFieldTooLargeError"; + } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +function header(entry: TarEntry, typeflag: "0" | "2" | "5", size: number): Uint8Array { + const block = new Uint8Array(BLOCK_SIZE); + const split = splitPath(entry.path); + if (split === undefined) { + throw new TarPathTooLongError(entry.path); + } + + const encodedName = encoder.encode(split.name); + block.set(encodedName, 0); + writeOctal(block, 100, 8, entry.mode ?? 0o644); + writeOctal(block, 108, 8, 0); // uid + writeOctal(block, 116, 8, 0); // gid + writeOctal(block, 124, 12, size); + writeOctal(block, 136, 12, entry.mtime ?? 0); + // The checksum field is treated as spaces while the checksum is computed. + block.fill(0x20, 148, 156); + block[156] = typeflag.charCodeAt(0); + if (entry.linkTarget !== undefined) { + const encodedTarget = encoder.encode(entry.linkTarget); + if (encodedTarget.length > 100) { + throw new TarPathTooLongError(entry.linkTarget); + } + block.set(encodedTarget, 157); + } + writeAscii(block, 257, "ustar"); + writeAscii(block, 263, "00"); + block.set(encoder.encode(split.prefix), 345); + + let checksum = 0; + for (const byte of block) { + checksum += byte; + } + // Six octal digits, a NUL, then a space — the form every tar reads. + writeAscii(block, 148, checksum.toString(8).padStart(6, "0")); + block[154] = 0; + block[155] = 0x20; + + return block; +} + +function padding(size: number): number { + const remainder = size % BLOCK_SIZE; + return remainder === 0 ? 0 : BLOCK_SIZE - remainder; +} + +/** + * Build a USTAR archive from `entries`, in the order given. An entry with a + * `linkTarget` is stored as a symbolic link, a path ending in `/` as a + * directory, and everything else as a regular file. The archive ends with the + * two zero blocks every reader expects. + */ +export function createTar(entries: ReadonlyArray): Uint8Array { + const blocks: Array = []; + let total = 0; + + const push = (block: Uint8Array) => { + blocks.push(block); + total += block.length; + }; + + for (const entry of entries) { + const isSymlink = entry.linkTarget !== undefined; + const isDirectory = !isSymlink && entry.path.endsWith("/"); + // A link's target lives in the header, so it carries no content blocks. + const size = isDirectory || isSymlink ? 0 : entry.contents.length; + push(header(entry, isSymlink ? "2" : isDirectory ? "5" : "0", size)); + if (size > 0) { + push(entry.contents); + const pad = padding(size); + if (pad > 0) { + push(new Uint8Array(pad)); + } + } + } + + push(new Uint8Array(BLOCK_SIZE * 2)); + + const archive = new Uint8Array(total); + let offset = 0; + for (const block of blocks) { + archive.set(block, offset); + offset += block.length; + } + return archive; +} diff --git a/apps/cli/src/shared/workers/tar.unit.test.ts b/apps/cli/src/shared/workers/tar.unit.test.ts new file mode 100644 index 0000000000..c7449efeb6 --- /dev/null +++ b/apps/cli/src/shared/workers/tar.unit.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { createTar, TarFieldTooLargeError, TarPathTooLongError } from "./tar.ts"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function field(archive: Uint8Array, block: number, offset: number, length: number): string { + return decoder.decode(archive.subarray(block * 512 + offset, block * 512 + offset + length)); +} + +/** Trim a NUL-padded USTAR field down to its value. */ +function value(archive: Uint8Array, block: number, offset: number, length: number): string { + return (field(archive, block, offset, length).split("\u0000")[0] ?? "").trim(); +} + +describe("createTar", () => { + test("writes a readable ustar header for a file", () => { + const archive = createTar([ + { path: "index.js", contents: encoder.encode("hello"), mode: 0o644, mtime: 1_700_000_000 }, + ]); + + expect(value(archive, 0, 0, 100)).toBe("index.js"); + expect(value(archive, 0, 100, 8)).toBe("0000644"); + expect(value(archive, 0, 124, 12)).toBe("00000000005"); + expect(value(archive, 0, 136, 12)).toBe("14524770400"); + expect(field(archive, 0, 156, 1)).toBe("0"); + expect(value(archive, 0, 257, 6)).toBe("ustar"); + }); + + test("computes a checksum the standard algorithm reproduces", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("a") }]); + const header = archive.subarray(0, 512); + + const recorded = Number.parseInt(value(archive, 0, 148, 8), 8); + let computed = 0; + for (let index = 0; index < 512; index++) { + // The checksum field itself counts as spaces. + computed += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0); + } + + expect(recorded).toBe(computed); + }); + + test("pads content to a 512-byte boundary and ends with two zero blocks", () => { + const archive = createTar([{ path: "a.txt", contents: encoder.encode("hello") }]); + + // header + one padded content block + two trailing zero blocks + expect(archive.length).toBe(512 * 4); + expect(decoder.decode(archive.subarray(512, 517))).toBe("hello"); + expect(archive.subarray(512 * 2).every((byte) => byte === 0)).toBe(true); + }); + + test("emits directory entries with no content and the directory typeflag", () => { + const archive = createTar([ + { path: "nested/", contents: new Uint8Array(0), mode: 0o755 }, + { path: "nested/a.txt", contents: encoder.encode("a") }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("5"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // The directory has no content block, so the next header follows immediately. + expect(value(archive, 1, 0, 100)).toBe("nested/a.txt"); + }); + + test("stores a symlink as a link entry with no content blocks", () => { + const archive = createTar([ + { path: "link.txt", contents: new Uint8Array(0), linkTarget: "target.txt", mode: 0o777 }, + ]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + expect(value(archive, 0, 157, 100)).toBe("target.txt"); + expect(value(archive, 0, 124, 12)).toBe("00000000000"); + // Header plus the two trailing zero blocks — no content block in between. + expect(archive.length).toBe(512 * 3); + }); + + test("a symlink entry wins over the trailing-slash directory rule", () => { + const archive = createTar([{ path: "dir", contents: new Uint8Array(0), linkTarget: ".." }]); + + expect(field(archive, 0, 156, 1)).toBe("2"); + }); + + test("refuses a link target too long for the header field", () => { + expect(() => + createTar([ + { path: "link", contents: new Uint8Array(0), linkTarget: `${"t".repeat(120)}.txt` }, + ]), + ).toThrow(TarPathTooLongError); + }); + + test("splits a long path across the prefix and name fields", () => { + const deep = `${"d".repeat(120)}/${"f".repeat(60)}.txt`; + const archive = createTar([{ path: deep, contents: new Uint8Array(0) }]); + + expect(value(archive, 0, 345, 155)).toBe("d".repeat(120)); + expect(value(archive, 0, 0, 100)).toBe(`${"f".repeat(60)}.txt`); + }); + + test("refuses a value too large for an octal header field rather than truncating it", () => { + // One past the 11-digit octal ceiling. Encoding it would spill a digit into + // the next field and read back as a plausible but wrong number. + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), + ).toThrow(TarFieldTooLargeError); + + expect(() => + createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 - 1 }]), + ).not.toThrow(); + }); + + test("refuses a path component too long to represent", () => { + expect(() => + createTar([{ path: `${"f".repeat(120)}.txt`, contents: new Uint8Array(0) }]), + ).toThrow(TarPathTooLongError); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-classify.ts b/apps/cli/src/shared/workers/worker-classify.ts new file mode 100644 index 0000000000..64e19906ec --- /dev/null +++ b/apps/cli/src/shared/workers/worker-classify.ts @@ -0,0 +1,48 @@ +import { join } from "node:path"; +import { Effect, FileSystem } from "effect"; +import { DEFAULT_WORKER_RUNTIME, type WorkerRuntime } from "./worker-runtimes.ts"; + +/** + * Best-effort classification of a worker directory into a {@link WorkerRuntime} + * from common marker files, so `supabase workers push` can deploy a directory + * that has no `[workers.] runtime` at all. The guess is always reported, + * with a nudge to pin it down, rather than applied silently. + */ + +interface WorkerClassification { + readonly runtime: WorkerRuntime; + /** Human-readable reason, for the line `push` logs about the guess. */ + readonly reason: string; +} + +const MARKERS: ReadonlyArray<{ + readonly runtime: WorkerRuntime; + readonly files: ReadonlyArray; +}> = [ + // An explicit Dockerfile always wins: it is a deliberate signal, not an + // inference. + { runtime: "dockerfile", files: ["Dockerfile"] }, + // Deno is checked before plain `package.json` because a Deno project can + // still have one (editor tooling, a stray dependency) while a Node project + // has no `deno.json`. + { runtime: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"] }, + { runtime: "node", files: ["package.json"] }, +]; + +export const classifyWorkerDir = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + + for (const marker of MARKERS) { + for (const file of marker.files) { + const found = yield* fs.exists(join(dir, file)).pipe(Effect.orElseSucceed(() => false)); + if (found) { + return { runtime: marker.runtime, reason: `found ${file}` } satisfies WorkerClassification; + } + } + } + + return { + runtime: DEFAULT_WORKER_RUNTIME, + reason: `no recognized marker files, defaulting to ${DEFAULT_WORKER_RUNTIME}`, + } satisfies WorkerClassification; +}); diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index 04a46cccaa..6d09c9ccb3 100644 --- a/apps/cli/src/shared/workers/worker-config.ts +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -21,6 +21,7 @@ import { appendTomlSection, tomlKey } from "./toml-section.ts"; export interface WorkerEntry { readonly runtime?: string; readonly size?: string; + readonly instances?: number; readonly source?: string; } @@ -75,6 +76,14 @@ const stringOrUndefined = (value: unknown): string | undefined => const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +/** + * A count only counts if it is a non-negative whole number. Anything else is + * dropped so `push` falls back to its own default; the config schema is what + * tells the user the value was wrong. + */ +const instanceCountOrUndefined = (value: unknown): number | undefined => + typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; + /** * The decoded `[workers]` section as per-worker tables. Anything that is not an * object is dropped rather than read as a worker named after it. @@ -98,6 +107,7 @@ export function readWorkersSection(workers: unknown): WorkersSection { entries[key] = { runtime: stringOrUndefined(value["runtime"]), size: stringOrUndefined(value["size"]), + instances: instanceCountOrUndefined(value["instances"]), source: stringOrUndefined(value["source"]), }; } diff --git a/apps/cli/src/shared/workers/worker-config.unit.test.ts b/apps/cli/src/shared/workers/worker-config.unit.test.ts index fc7fc7411a..d1439e57ac 100644 --- a/apps/cli/src/shared/workers/worker-config.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -16,23 +16,41 @@ describe("readWorkersSection", () => { test("reads each worker's recorded dials", () => { expect( readWorkersSection({ - api: { runtime: "node", size: "2gb", source: "packages/api" }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, box: { runtime: "sandbox" }, }), ).toEqual({ workers: { - api: { runtime: "node", size: "2gb", source: "packages/api" }, - box: { runtime: "sandbox", size: undefined, source: undefined }, + api: { runtime: "node", size: "2gb", instances: 4, source: "packages/api" }, + box: { runtime: "sandbox", size: undefined, instances: undefined, source: undefined }, }, }); }); test("drops non-object values so a stray scalar is not read as a worker", () => { expect(readWorkersSection({ stray: "oops", api: {} })).toEqual({ - workers: { api: { runtime: undefined, size: undefined, source: undefined } }, + workers: { + api: { runtime: undefined, size: undefined, instances: undefined, source: undefined }, + }, }); }); + // `push` has to send a count with every deploy, so a value the API would + // reject is dropped here and the default used instead. + test.each([ + ["a float", 1.5], + ["a negative", -1], + ["a string", "3"], + ])("drops %s instance count", (_label, value) => { + expect(readWorkersSection({ api: { instances: value } }).workers["api"]?.instances).toBe( + undefined, + ); + }); + + test("keeps a zero instance count, which scales a worker down rather than being absent", () => { + expect(readWorkersSection({ api: { instances: 0 } }).workers["api"]?.instances).toBe(0); + }); + test("treats a missing or malformed section as empty", () => { expect(readWorkersSection(undefined)).toEqual({ workers: {} }); expect(readWorkersSection([])).toEqual({ workers: {} }); diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts new file mode 100644 index 0000000000..50078f9363 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -0,0 +1,133 @@ +import { gzipSync } from "node:zlib"; +import { Effect, FileSystem } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; + +/** + * Package a worker's source directory into the `.tar.gz` build context the + * Workers API's upload slot expects. + * + * Nothing is excluded. For a `dockerfile` worker the archive is the build + * context, so it has to be what the user's own `Dockerfile` expects to find; + * for a catalog runtime the server synthesizes `FROM ` + `COPY` with no + * install step of its own, so an installed `node_modules/` is a dependency of + * the deploy rather than noise in it. The packaged size is reported back so a + * directory that has grown past what anyone meant to upload is visible before + * the upload rather than after it. + */ + +interface PackagedWorker { + readonly archive: Uint8Array; + readonly fileCount: number; +} + +/** + * Every entry under `root`, as tar entries. + * + * Filesystem errors propagate rather than being skipped: an entry missing from + * the archive means deploying an application with a hole in it, reported as a + * success. A directory the walk cannot read, a file it cannot open and an entry + * that vanishes mid-walk are all that case. + */ +const collectEntries = ( + root: string, + relativeDir: string, +): Effect.Effect, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const absoluteDir = relativeDir === "" ? root : `${root}/${relativeDir}`; + + const names = yield* fs.readDirectory(absoluteDir); + const entries: Array = []; + + for (const name of [...names].sort()) { + const relativePath = relativeDir === "" ? name : `${relativeDir}/${name}`; + const absolutePath = `${root}/${relativePath}`; + + // `readLink` succeeds only for symlinks, so it stands in for the `lstat` + // this FileSystem service does not expose (the same probe + // `legacy-sql-files-glob.ts` uses). Storing the link rather than following + // it is what keeps a pnpm-installed `node_modules` from being inlined file + // by file, keeps a broken link from vanishing, and stops a link pointing at + // an ancestor from being walked into. + const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); + if (linkTarget._tag === "Some") { + entries.push({ + path: relativePath, + contents: new Uint8Array(0), + mode: 0o777, + mtime: 0, + linkTarget: linkTarget.value, + }); + continue; + } + + const info = yield* fs.stat(absolutePath); + + const modified = info.mtime; + const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + + if (info.type === "Directory") { + entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); + entries.push(...(yield* collectEntries(root, relativePath))); + continue; + } + + if (info.type !== "File") { + // Sockets, FIFOs and devices have nothing meaningful to send. + continue; + } + + const contents = yield* fs.readFile(absolutePath); + // The executable bit is the only permission that changes what the image + // does; everything else is normalized so the same tree packages + // identically on every machine. `mode` is a plain number here, unlike the + // `Option`-wrapped `mtime` above. + const executable = (info.mode & 0o111) !== 0; + entries.push({ + path: relativePath, + contents: new Uint8Array(contents), + mode: executable ? 0o755 : 0o644, + mtime, + }); + } + + return entries; + }); + +export const packageWorkerDirectory = Effect.fnUntraced(function* (dir: string) { + const entries = yield* collectEntries(dir, ""); + + // `createTar` throws for a name USTAR cannot represent, such as a path + // component over 100 bytes. That is user-actionable, so it belongs in the + // failure channel: `withJsonErrorHandling` only catches failures, and a defect + // would exit `--output-format json` with no structured error. + const archive = yield* Effect.try({ + try: () => gzipSync(createTar(entries)), + catch: (cause) => { + if (cause instanceof TarPathTooLongError) { + return cause; + } + // Anything else here really is a bug, so let it stay a defect rather than + // dressing it up as a failure the user could act on. + throw cause; + }, + }); + + return { + archive: new Uint8Array(archive), + fileCount: entries.filter((entry) => !entry.path.endsWith("/")).length, + } satisfies PackagedWorker; +}); + +/** `10 KiB` / `1.4 MiB` — the packaged size, as `push` reports it. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const kib = bytes / 1024; + if (kib < 1024) { + return `${Math.ceil(kib)} KiB`; + } + return `${(kib / 1024).toFixed(1)} MiB`; +} diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts new file mode 100644 index 0000000000..83a1e6545d --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -0,0 +1,216 @@ +import { + accessSync, + chmodSync, + constants, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { gunzipSync } from "node:zlib"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; + +/** + * Whether the current user can still read `path` after it was chmod-ed shut. + * + * Root ignores the permission bits, and CI sometimes runs as root, so the + * permission-denied tests below assert the opposite outcome instead of skipping + * — either way the behaviour under test is pinned. + */ +function readableAsCurrentUser(path: string): boolean { + try { + accessSync(path, constants.R_OK); + return true; + } catch { + return false; + } +} + +function listableAsCurrentUser(path: string): boolean { + try { + readdirSync(path); + return true; + } catch { + return false; + } +} + +/** Entry paths and their USTAR typeflags, read back out of the archive. */ +function readEntries(archive: Uint8Array): Array<{ path: string; type: string; link: string }> { + const raw = new Uint8Array(gunzipSync(archive)); + const decoder = new TextDecoder(); + const trim = (value: string) => value.split("\u0000")[0] ?? ""; + const entries: Array<{ path: string; type: string; link: string }> = []; + + for (let offset = 0; offset + 512 <= raw.length;) { + const name = trim(decoder.decode(raw.subarray(offset, offset + 100))); + if (name === "") { + break; + } + const size = Number.parseInt(trim(decoder.decode(raw.subarray(offset + 124, offset + 136))), 8); + entries.push({ + path: name, + type: decoder.decode(raw.subarray(offset + 156, offset + 157)), + link: trim(decoder.decode(raw.subarray(offset + 157, offset + 257))), + }); + offset += 512 + Math.ceil(size / 512) * 512; + } + return entries; +} + +describe("packageWorkerDirectory", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-package-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const pack = (root: string) => + Effect.runPromise(packageWorkerDirectory(root).pipe(Effect.provide(BunServices.layer))); + + test("packages files and nested directories in a stable order", async () => { + mkdirSync(join(dir, "nested")); + writeFileSync(join(dir, "b.txt"), "b"); + writeFileSync(join(dir, "a.txt"), "a"); + writeFileSync(join(dir, "nested", "c.txt"), "c"); + + const result = await pack(dir); + + expect(readEntries(result.archive).map((entry) => entry.path)).toEqual([ + "a.txt", + "b.txt", + "nested/", + "nested/c.txt", + ]); + expect(result.fileCount).toBe(3); + }); + + // Anything pnpm installs is symlink-dense, so following links would inline + // every dependency's real contents — and a link pointing at an ancestor would + // be walked into until the OS refused. + test("stores symlinks as links rather than following them", async () => { + writeFileSync(join(dir, "target.txt"), "hello"); + symlinkSync("target.txt", join(dir, "link.txt")); + + const entries = readEntries((await pack(dir)).archive); + const link = entries.find((entry) => entry.path === "link.txt"); + + expect(link?.type).toBe("2"); + expect(link?.link).toBe("target.txt"); + }); + + test("keeps a broken symlink instead of dropping it", async () => { + symlinkSync("/nowhere-at-all", join(dir, "broken.txt")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.find((entry) => entry.path === "broken.txt")?.type).toBe("2"); + }); + + test("does not recurse through a directory symlink that points at an ancestor", async () => { + mkdirSync(join(dir, "sub")); + writeFileSync(join(dir, "keep.txt"), "k"); + symlinkSync("..", join(dir, "sub", "up")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.map((entry) => entry.path).sort()).toEqual(["keep.txt", "sub/", "sub/up"]); + expect(entries.find((entry) => entry.path === "sub/up")?.type).toBe("2"); + }); + + test("packages an empty directory to an archive with no entries", async () => { + const result = await pack(dir); + + expect(readEntries(result.archive)).toEqual([]); + expect(result.fileCount).toBe(0); + }); + + // A file that cannot be read used to be archived as zero bytes, so `push` + // reported success for a deploy that shipped an empty file. Failing is the + // only honest answer: the archive is the application. + test("fails rather than archiving a file it cannot read as empty", async () => { + const unreadable = join(dir, "secret.txt"); + writeFileSync(unreadable, "important"); + chmodSync(unreadable, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + // Running as root defeats the permission, so only assert when it took hold. + if (readableAsCurrentUser(unreadable)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(unreadable, 0o600); + }); + + test("fails rather than silently dropping a directory it cannot read", async () => { + const locked = join(dir, "locked"); + mkdirSync(locked); + writeFileSync(join(locked, "inside.txt"), "content"); + chmodSync(locked, 0o000); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + if (listableAsCurrentUser(locked)) { + expect(exit._tag).toBe("Success"); + } else { + expect(exit._tag).toBe("Failure"); + } + chmodSync(locked, 0o700); + }); +}); + +// `createTar` throws for a name USTAR cannot represent. Called directly inside +// the generator that became a defect, which `withJsonErrorHandling` does not +// catch — so `--output-format json` would have died with no structured error. +describe("packageWorkerDirectory tar limits", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-tar-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("reports an unrepresentable path as a failure rather than a defect", async () => { + // One component over 100 bytes, with no directory boundary to split on. + writeFileSync(join(dir, "a".repeat(120)), "contents"); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + // A failure, not a defect: the difference is whether the JSON error handler + // ever sees it. + expect(JSON.stringify(exit)).toContain("TarPathTooLong"); + }); +}); + +describe("formatBytes", () => { + test("reports each magnitude in the unit a reader expects", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1023)).toBe("1023 B"); + expect(formatBytes(1024)).toBe("1 KiB"); + expect(formatBytes(1024 * 1024)).toBe("1.0 MiB"); + expect(formatBytes(1024 * 1024 * 1.5)).toBe("1.5 MiB"); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 7c9f93e8eb..897087b073 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -65,6 +65,13 @@ export type WorkerSize = (typeof WORKER_SIZES)[number]; /** The first available option — what `new` records when `--size` is omitted. */ export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; +/** + * Instances a worker runs when neither `--instances` nor `[workers.] + * instances` says otherwise. One, because a deploy has to name a count — the + * API's spec requires it — and a worker nobody has scaled is a single instance. + */ +export const DEFAULT_WORKER_INSTANCES = 1; + function isWorkerSize(value: string): value is WorkerSize { return WORKER_SIZES.some((size) => size === value); } diff --git a/apps/cli/src/shared/workers/worker-url.ts b/apps/cli/src/shared/workers/worker-url.ts new file mode 100644 index 0000000000..d82da066f3 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-url.ts @@ -0,0 +1,17 @@ +/** + * Where a worker is served. + * + * Every worker gets a path on the project's own API host, exactly like an Edge + * Function — `.supabase.co/workers/v1/` next to + * `.supabase.co/functions/v1/`. One host per project, one path per + * worker: nothing per-worker is provisioned in DNS, so the URL is derived from + * the name rather than returned by the API. + */ + +/** Path prefix workers are served under, mirroring `functions/v1`. */ +const WORKERS_PATH_PREFIX = "/workers/v1"; + +/** The canonical URL of a worker on its project's API host. */ +export function workerUrl(projectRef: string, projectHost: string, name: string): string { + return `https://${projectRef}.${projectHost}${WORKERS_PATH_PREFIX}/${name}`; +} diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts new file mode 100644 index 0000000000..79409d05f6 --- /dev/null +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -0,0 +1,429 @@ +import { + markSupabaseApiInputErrorAsUserInput, + operationDefinitions, + SupabaseApiInputError, + V2CreateWorkerUploadOutput, + V2DeployAWorkerOutput, + V2GetAWorkerOutput, + type ApiClient, +} from "@supabase/api/effect"; +import { Effect, Option, Schedule, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { + WorkerBuildTimeoutError, + WorkersApiNetworkError, + WorkerProjectNotFoundError, + WorkersApiUnexpectedStatusError, + WorkersUnavailableError, + WorkerUploadFailedError, +} from "./workers.errors.ts"; + +/** + * The seam every worker command talks to: `/v2/projects/{ref}/workers` on the + * Management API. + * + * The routes are deliberately few — list, get, mint an upload slot, deploy, + * delete — so this module is thin, and what it mostly adds is status handling. + * The alpha's allow-list answers 404 for a project that is not enrolled, which + * at the transport level is indistinguishable from "no such worker"; so a 404 + * on a collection endpoint (where no worker name could have been wrong) becomes + * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by + * the caller as "not deployed". + */ + +/** The worker shape the API returns, flattened out of its JSON:API envelope. */ +export interface WorkerRecord { + readonly name: string; + readonly spec: { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; + readonly backend?: string; + }; + readonly buildState: "building" | "active" | "failed"; + readonly stateReason?: string; + readonly imageVersion?: string; + readonly deleting?: boolean; + /** Present only on single-worker reads; a fresh deploy has nothing to report yet. */ + readonly instances?: { + readonly declared: number; + readonly live: number; + readonly ready: number; + readonly stale: number; + }; + /** Set instead of `instances` when the instance read-through failed. */ + readonly instancesError?: string; +} + +export interface WorkerUploadSlot { + readonly uploadId: string; + readonly url: string; + readonly method: string; + readonly expiresAt: string; +} + +/** The `spec` a deploy sends. Mirrors the API's own field names exactly. */ +export interface WorkerDeploySpec { + readonly runtime?: string; + readonly size: string; + readonly exposure: string; + readonly instances: number; +} + +type WorkerResourceData = typeof V2GetAWorkerOutput.Type extends { data: infer D } ? D : never; + +function toWorkerRecord(data: WorkerResourceData): WorkerRecord { + return { + name: data.id, + spec: data.attributes.spec, + buildState: data.attributes.build_state, + stateReason: data.attributes.state_reason, + imageVersion: data.attributes.image_version, + deleting: data.attributes.deleting, + instances: data.attributes.instances, + instancesError: data.attributes.instances_error, + }; +} + +const workersSuggestion = + "Workers are in private alpha. Ask in the Supabase dashboard to have this project enrolled."; + +/** + * The `error.code` a 404 carries, which is the only thing separating a project + * outside the alpha's allow-list from one that does not exist. Both answer 404 + * on the same routes; the bodies differ: + * + * - not enrolled -> `{"error":{"code":"generic_not_found","message":"Workers are not available for this project"}}` + * - no such project -> `{"error":{"code":"not_found","message":"Not Found"}}` + */ +const NotFoundBody = Schema.Struct({ + error: Schema.Struct({ code: Schema.String }), +}); + +/** + * Which of the two a project-scoped 404 was. + * + * Only `not_found` is read as a missing project — an unrecognized body keeps + * the enrolment answer, because that is what the alpha's allow-list has + * historically returned and guessing the other way would send someone to check + * a ref that is fine. + */ +const projectScoped404 = Effect.fnUntraced(function* (options: { + readonly projectRef: string; + readonly body: string; +}) { + const parsed = yield* Effect.try(() => JSON.parse(options.body) as unknown).pipe( + Effect.flatMap((json) => Schema.decodeUnknownEffect(NotFoundBody)(json)), + Effect.option, + ); + + if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { + return new WorkerProjectNotFoundError({ + detail: `No project ${options.projectRef} was found for this account.`, + suggestion: + "Check the project ref, or pick the project again with `supabase link`. " + + "If it belongs to another account, log in with `supabase login`.", + }); + } + + return new WorkersUnavailableError({ + detail: `Workers are not available for project ${options.projectRef}.`, + suggestion: workersSuggestion, + }); +}); + +/** + * Everything that can go wrong before a status code exists: the generated input + * schema rejecting the request, or the transport failing outright. + */ +function mapRequestError(operation: string) { + return (error: unknown) => { + if (error instanceof SupabaseApiInputError) { + // The only inputs these operations take are the resolved project ref and + // the prevalidated worker name, so a schema rejection is user-derived. + return markSupabaseApiInputErrorAsUserInput(error); + } + if (HttpClientError.isHttpClientError(error)) { + const description = error.reason.description ?? error.reason._tag; + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${description}.`, + suggestion: "Check your network connection and retry.", + }); + } + return new WorkersApiNetworkError({ + detail: `Could not reach the Workers API while trying to ${operation}: ${String(error)}.`, + suggestion: "Check your network connection and retry.", + }); + }; +} + +const unexpectedStatus = Effect.fnUntraced(function* (options: { + readonly operation: string; + readonly status: number; + readonly body: string; +}) { + const trimmed = options.body.trim(); + return yield* Effect.fail( + new WorkersApiUnexpectedStatusError({ + status: options.status, + detail: `The Workers API answered ${options.status} while trying to ${options.operation}${ + trimmed === "" ? "" : `: ${trimmed}` + }.`, + suggestion: "Retry shortly; if it persists, report it with `supabase issue`.", + }), + ); +}); + +const decodeBody = ( + schema: Schema.Codec, + operation: string, + body: unknown, + status: number, +) => + Schema.decodeUnknownEffect(schema)(body).pipe( + Effect.mapError( + (error) => + new WorkersApiUnexpectedStatusError({ + status, + detail: `The Workers API returned a response this CLI could not read while trying to ${operation}: ${error.message}.`, + suggestion: "Update the CLI with `supabase update`, then retry.", + }), + ), + ); + +/** + * One worker, or `None` when the API has no record of it — which is also what a + * project outside the alpha's allow-list answers, so callers report it as "not + * deployed" and point at `push` rather than guessing which of the two it was. + */ +const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { + const operation = `read worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return Option.none(); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2GetAWorkerOutput, operation, body, response.status); + return Option.some(toWorkerRecord(decoded.data)); +}); + +export const createWorkerUpload = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `stage a build context for "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2CreateWorkerUpload, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 201 && response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2CreateWorkerUploadOutput, operation, body, response.status); + return { + uploadId: decoded.data.id, + url: decoded.data.attributes.url, + method: decoded.data.attributes.method, + expiresAt: decoded.data.attributes.expires_at, + } satisfies WorkerUploadSlot; +}); + +/** + * PUT the archive straight at the presigned slot. The bytes never pass through + * the Management API, so this goes out with no Supabase credentials attached — + * the signature in the URL is the authorization. + * + * That signature is why `legacyHttpClientLayer` redacts query strings before + * logging them — under `--debug` this URL is a write-capable credential. Done + * there rather than here, so the client stays injectable and every presigned URL + * is covered rather than this one call site. + */ +export const uploadBuildContext = Effect.fnUntraced(function* ( + slot: WorkerUploadSlot, + archive: Uint8Array, +) { + const client = yield* HttpClient.HttpClient; + + // The slot names its own method; the API documents `PUT` and nothing else is + // meaningful for a presigned object-store destination, so anything unexpected + // falls back to it rather than assembling a request we cannot build. + const request = ( + slot.method.toUpperCase() === "POST" + ? HttpClientRequest.post(slot.url) + : HttpClientRequest.put(slot.url) + ).pipe(HttpClientRequest.bodyUint8Array(archive, "application/gzip")); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (error) => + new WorkerUploadFailedError({ + detail: `Uploading the build context failed: ${ + error.reason.description ?? error.reason._tag + }.`, + suggestion: "Check your network connection, then re-run the same command.", + }), + ), + ); + + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new WorkerUploadFailedError({ + detail: `Uploading the build context failed with status ${response.status}${ + body.trim() === "" ? "" : `: ${body.trim()}` + }.`, + suggestion: "Re-run the same command; the upload slot is minted fresh each time.", + }), + ); + } +}); + +export const deployWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + attributes: { readonly spec: WorkerDeploySpec; readonly contextUploadId?: string }, +) { + const operation = `deploy worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeployAWorker, { + ref: projectRef, + name, + data: { + type: "project_worker", + attributes: { + spec: attributes.spec, + ...(attributes.contextUploadId === undefined + ? {} + : { context_upload_id: attributes.contextUploadId }), + }, + }, + }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 202 && response.status !== 200 && response.status !== 201) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2DeployAWorkerOutput, operation, body, response.status); + return toWorkerRecord(decoded.data); +}); + +/** + * The build runs asynchronously — deploy answers 202 and the worker reaches + * `active` or `failed` later — so `push` polls `get` until `build_state` leaves + * `building`. + * + * The schedule is a parameter so tests can drive the same loop without waiting + * on wall-clock delays. + */ +const WORKER_BUILD_POLL_SCHEDULE = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "10 minutes" }), +); + +/** + * How long one poll read is allowed to keep failing before the deploy is called + * off. + * + * Bounded by elapsed time, not attempts: unspaced attempts are exhausted by a + * two-second blip, abandoning a build the server is still running. Half a minute + * of spaced retries rides that out, and anything still failing after it is the + * real error. + */ +const WORKER_POLL_READ_RETRY = Schedule.spaced("2 seconds").pipe( + Schedule.upTo({ duration: "30 seconds" }), +); + +export const awaitWorkerBuild = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + options: { + readonly schedule?: Schedule.Schedule; + /** + * Retry schedule for one poll read. A parameter for the same reason + * `schedule` is: it is spaced in seconds, and a test exercising the + * transient-failure path should not wait on a real clock to do it. + */ + readonly retrySchedule?: Schedule.Schedule; + /** Called with each poll's result, for progress reporting. */ + readonly onPoll?: (worker: WorkerRecord) => Effect.Effect; + } = {}, +) { + const poll = Effect.gen(function* () { + // A build can run for minutes, so a single blip on one read should not throw + // away a deploy that is progressing fine. + const worker = yield* getWorker(api, projectRef, name).pipe( + Effect.retry({ schedule: options.retrySchedule ?? WORKER_POLL_READ_RETRY }), + ); + if (Option.isNone(worker)) { + // The deploy was accepted, so the worker exists; a 404 here is the read + // racing the write. Report it as still building and poll again. + return undefined; + } + if (options.onPoll !== undefined) { + yield* options.onPoll(worker.value); + } + return worker.value.buildState === "building" ? undefined : worker.value; + }); + + const settled = yield* poll.pipe( + Effect.repeat({ + schedule: options.schedule ?? WORKER_BUILD_POLL_SCHEDULE, + until: (result) => result !== undefined, + }), + ); + + if (settled === undefined) { + return yield* Effect.fail( + new WorkerBuildTimeoutError({ + detail: `"${name}" was still building when this command stopped waiting.`, + suggestion: `Check on it with \`supabase workers status ${name}\`.`, + }), + ); + } + + return settled; +}); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index ecd09ac1fb..7e01fc5bfb 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -3,6 +3,7 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../telemetry/error-actionability.ts"; /** @@ -20,6 +21,41 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** A bare `push` found no workers to deploy — none named, none in the project. */ +export class NoWorkersToDeployError extends Data.TaggedError("NoWorkersToDeployError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `config.toml` records a runtime this CLI does not offer. + * + * Raised by `push`, the command that reads a worker's runtime back out of + * config; `new` writes one and never reads it. + */ +export class UnknownWorkerRuntimeError extends Data.TaggedError("UnknownWorkerRuntimeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** As {@link UnknownWorkerRuntimeError}, for a recorded instance size. */ +export class UnknownWorkerSizeError extends Data.TaggedError("UnknownWorkerSizeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + export class WorkerDirectoryExistsError extends Data.TaggedError("WorkerDirectoryExistsError")<{ readonly detail: string; readonly suggestion: string; @@ -29,6 +65,15 @@ export class WorkerDirectoryExistsError extends Data.TaggedError("WorkerDirector } } +export class WorkerSourceMissingError extends Data.TaggedError("WorkerSourceMissingError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * `--source` names a directory it is not allowed to name. Worth its own error * because the destination is where the starter files land, so a value that @@ -43,3 +88,94 @@ export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSou return actionability.provideFlags; } } + +/** The deploy finished, and the build it started failed. */ +export class WorkerBuildFailedError extends Data.TaggedError("WorkerBuildFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** The build never left `building` inside the CLI's polling budget. */ +export class WorkerBuildTimeoutError extends Data.TaggedError("WorkerBuildTimeoutError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} + +/** PUTting the build context to the presigned slot failed. */ +export class WorkerUploadFailedError extends Data.TaggedError("WorkerUploadFailedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** Transport failure talking to the Management API. */ +export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +/** + * Workers are in private alpha: the routes answer 404 for a project that is not + * enrolled, which is indistinguishable from an unknown worker at the transport + * level — so this is only raised for the collection endpoints, where there is + * no worker name that could have been wrong. + */ +export class WorkersUnavailableError extends Data.TaggedError("WorkersUnavailableError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +/** + * The project ref names no project this account can see. + * + * Separated from {@link WorkersUnavailableError} because both arrive as a 404 + * on the same routes, and telling someone to request alpha enrolment for a + * project that does not exist sends them somewhere that cannot help. + */ +export class WorkerProjectNotFoundError extends Data.TaggedError("WorkerProjectNotFoundError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * Any other status the Workers routes answered with. + * + * Classified from the status it carries rather than bucketed as a service + * failure: a 401 is the user's to fix by logging in and a 403 by getting access, + * and reporting either as `api_status` both misleads the user and blurs the + * actionability signal for every Workers endpoint at once. + */ +export class WorkersApiUnexpectedStatusError extends Data.TaggedError( + "WorkersApiUnexpectedStatusError", +)<{ + readonly detail: string; + readonly suggestion: string; + readonly status: number; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 459977e351..98a0535feb 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -14,10 +14,8 @@ import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; -import { - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, -} from "./legacy-mocks.ts"; +import { mockLegacyLinkedProjectCacheLayer } from "./legacy-mocks.ts"; +import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetry-state.service.ts"; import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; /** @@ -233,6 +231,30 @@ export interface WorkersSetupOptions { readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; } +/** + * `LegacyTelemetryState`, recording whether it was flushed. + * + * Every worker command is supposed to write the telemetry state file on every + * invocation, success or failure — which is only observable if the mock says so, + * so the shared always-void mock cannot cover it. + */ +function mockWorkersTelemetryState() { + let flushed = false; + return { + layer: Layer.succeed(LegacyTelemetryState, { + flush: Effect.sync(() => { + flushed = true; + }), + stitchLogin: () => Effect.void, + clearDistinctId: Effect.void, + resetIdentity: Effect.void, + } as unknown as LegacyTelemetryState["Service"]), + get flushed() { + return flushed; + }, + }; +} + export function setupLegacyWorkers(options: WorkersSetupOptions) { const out = mockOutput({ format: options.format ?? "text", @@ -245,17 +267,19 @@ export function setupLegacyWorkers(options: WorkersSetupOptions) { : { promptSelectResponses: options.promptSelectResponses }), }); const http = mockWorkersHttp(options.routes ?? {}); + const telemetry = mockWorkersTelemetryState(); return { out, http, + telemetry, layer: Layer.mergeAll( out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), legacyTestCliConfigLayer(options.workdir), legacyTestProjectRefLayer(options.linked !== false), - mockLegacyTelemetryStateLayer, + telemetry.layer, mockLegacyLinkedProjectCacheLayer, randomLayer, Layer.succeed( From d5b804f5ab1cda1c2363953142eb588313fa07f4 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 17:09:23 -0300 Subject: [PATCH 02/17] refactor(cli): read Option through its public helpers in workers push The same finding as the `workers new` change one commit down the stack, applied to the three occurrences this branch adds: the symlink probe and the mtime fallback in `worker-package.ts`, and the source-directory check in `push.handler.ts`. `Option.isSome`/`isNone` are type guards, so the narrowing after each check is unchanged. `worker-package.unit.test.ts` read `exit._tag` for the same reason; the repo guidance names `Exit.isSuccess`/`Exit.isFailure` and applies to tests too. `push.integration.test.ts`'s `tagOf` keeps its `_tag` access. It classifies values that may be a `Data.TaggedError` or a plain `Error` subclass with no tag at all, which is the dynamic boundary the guidance carves out. Also corrects the `push.handler.ts` module docblock: the argument is variadic, so it is `[name...]`, matching the SIDE_EFFECTS title. --- .../src/legacy/commands/workers/push/push.handler.ts | 4 ++-- apps/cli/src/shared/workers/worker-package.ts | 6 +++--- .../src/shared/workers/worker-package.unit.test.ts | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index 86b9a068d3..caf90a67d2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -50,7 +50,7 @@ import { import type { LegacyWorkersPushFlags } from "./push.command.ts"; /** - * `supabase workers push [name]` — build (when there is code to build) and + * `supabase workers push [name...]` — build (when there is code to build) and * deploy the worker into the linked project. Registered under `deploy` as an * alias, for anyone reaching for the `supabase functions` verb out of habit. * @@ -156,7 +156,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { // does not exist, and only then failing on the path. { const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); - if (stat._tag === "None" || stat.value.type !== "Directory") { + if (Option.isNone(stat) || stat.value.type !== "Directory") { return yield* Effect.fail( new WorkerSourceMissingError({ detail: `There is no worker source at ${sourceDisplay}.`, diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 50078f9363..4202d152df 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -1,5 +1,5 @@ import { gzipSync } from "node:zlib"; -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Option } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; @@ -51,7 +51,7 @@ const collectEntries = ( // by file, keeps a broken link from vanishing, and stops a link pointing at // an ancestor from being walked into. const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); - if (linkTarget._tag === "Some") { + if (Option.isSome(linkTarget)) { entries.push({ path: relativePath, contents: new Uint8Array(0), @@ -65,7 +65,7 @@ const collectEntries = ( const info = yield* fs.stat(absolutePath); const modified = info.mtime; - const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + const mtime = Option.isSome(modified) ? Math.floor(modified.value.getTime() / 1000) : 0; if (info.type === "Directory") { entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index 83a1e6545d..de6e2f8cb0 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { gunzipSync } from "node:zlib"; -import { Effect } from "effect"; +import { Effect, Exit } from "effect"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; @@ -150,9 +150,9 @@ describe("packageWorkerDirectory", () => { // Running as root defeats the permission, so only assert when it took hold. if (readableAsCurrentUser(unreadable)) { - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); } else { - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); } chmodSync(unreadable, 0o600); }); @@ -168,9 +168,9 @@ describe("packageWorkerDirectory", () => { ); if (listableAsCurrentUser(locked)) { - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); } else { - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); } chmodSync(locked, 0o700); }); @@ -198,7 +198,7 @@ describe("packageWorkerDirectory tar limits", () => { packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), ); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); // A failure, not a defect: the difference is whether the JSON error handler // ever sees it. expect(JSON.stringify(exit)).toContain("TarPathTooLong"); From 0fef65eb7377eff591ed73011c236f223658a929 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 25 Aug 2026 18:26:19 -0300 Subject: [PATCH 03/17] fix(cli): follow the LegacyCliSettings rename in workers push develop renamed `LegacyCliConfig` to `LegacyCliSettings` (and its module), which this branch's push handler still imported under the old path. The unresolved import widened the handler's requirements to `unknown`, so the 27 knock-on errors in `push.integration.test.ts` all came from this one line. --- apps/cli/src/legacy/commands/workers/push/push.handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index caf90a67d2..de587c16dd 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -8,7 +8,7 @@ import { } from "../workers.output.ts"; import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; @@ -143,7 +143,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { const fs = yield* FileSystem.FileSystem; const output = yield* Output; const api = yield* LegacyPlatformApi; - const cliConfig = yield* LegacyCliConfig; + const settings = yield* LegacyCliSettings; const { project, name, projectRef } = input; const worker = yield* legacyDescribeWorker(project, name); @@ -271,7 +271,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { const url = settled.spec.exposure === "public" - ? workerUrl(projectRef, cliConfig.projectHost, name) + ? workerUrl(projectRef, settings.projectHost, name) : undefined; // Suppressed when `-o` is in play: the payload owns stdout, and these lines From a595b6007f8746c6ad1c4255ab97b70818bb2b11 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:29:44 -0300 Subject: [PATCH 04/17] fix(cli): reject tar header values octal fields cannot represent `writeOctal` only checked the rendered width, which a negative or non-finite value passes: `(-1).toString(8)` is `"-1"` and `NaN.toString(8)` is `"NaN"`, and both pad to exactly the field width. The header went out unparseable, so GNU tar rejected the whole archive server-side after the build context had already uploaded. The reachable path is a file mtime: a pre-1970 timestamp is negative, and a corrupt one decodes to an `Invalid Date` whose `getTime()` is `NaN`. Neither is worth failing a deploy over, so `packageWorkerDirectory` now collapses both to the epoch before they reach the writer, and the writer checks the range as well as the width for anything that still gets there. `TarFieldTooLargeError` is renamed `TarFieldOutOfRangeError`, since it no longer only reports values that are too large. --- apps/cli/src/shared/workers/tar.ts | 23 ++++++----- apps/cli/src/shared/workers/tar.unit.test.ts | 24 +++++++++++- apps/cli/src/shared/workers/worker-package.ts | 20 +++++++++- .../workers/worker-package.unit.test.ts | 38 +++++++++++++++++-- 4 files changed, 89 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts index 37f28ad05c..8ec0bfd4ff 100644 --- a/apps/cli/src/shared/workers/tar.ts +++ b/apps/cli/src/shared/workers/tar.ts @@ -50,13 +50,18 @@ const MAX_OCTAL_FIELD = 8 ** 11 - 1; * size — corruption no reader can detect. Real tars switch to base-256 here; * this writer refuses instead, because a build context carrying an 8 GiB file is * already a mistake worth naming rather than silently mangling. + * + * The range is checked, not just the rendered width, because the width check + * alone does not catch a value that is not a whole non-negative number: + * `(-1).toString(8)` is `"-1"` and `NaN.toString(8)` is `"NaN"`, both of which + * pad to exactly `length - 1` characters and slip through while writing a field + * no tar can parse. */ function writeOctal(block: Uint8Array, offset: number, length: number, value: number): void { - const text = Math.floor(value) - .toString(8) - .padStart(length - 1, "0"); - if (text.length > length - 1) { - throw new TarFieldTooLargeError(value); + const digits = Math.floor(value); + const text = digits.toString(8).padStart(length - 1, "0"); + if (digits < 0 || !Number.isSafeInteger(digits) || text.length > length - 1) { + throw new TarFieldOutOfRangeError(value); } writeAscii(block, offset, text); } @@ -122,14 +127,14 @@ export class TarPathTooLongError extends Error { * Untagged for the same reason as {@link TarPathTooLongError}: `createTar` is a * pure function, and the caller's error channel is where this surfaces. */ -export class TarFieldTooLargeError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "TarFieldTooLargeError"; +export class TarFieldOutOfRangeError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "TarFieldOutOfRangeError"; constructor(value: number) { super( - `${value} is too large for a tar header field (the limit is ${MAX_OCTAL_FIELD} bytes per file)`, + `${value} cannot be written to a tar header field (values must be whole numbers from 0 to ${MAX_OCTAL_FIELD})`, ); - this.name = "TarFieldTooLargeError"; + this.name = "TarFieldOutOfRangeError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/shared/workers/tar.unit.test.ts b/apps/cli/src/shared/workers/tar.unit.test.ts index c7449efeb6..31cc74fddd 100644 --- a/apps/cli/src/shared/workers/tar.unit.test.ts +++ b/apps/cli/src/shared/workers/tar.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createTar, TarFieldTooLargeError, TarPathTooLongError } from "./tar.ts"; +import { createTar, TarFieldOutOfRangeError, TarPathTooLongError } from "./tar.ts"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); @@ -101,13 +101,33 @@ describe("createTar", () => { // the next field and read back as a plausible but wrong number. expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), - ).toThrow(TarFieldTooLargeError); + ).toThrow(TarFieldOutOfRangeError); expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 - 1 }]), ).not.toThrow(); }); + // Each of these renders to exactly the field width once padded, so the width + // check alone waves it through and the header goes out unparseable: GNU tar + // rejects the whole archive, which surfaces server-side after the upload + // rather than here. + test.each([ + ["a pre-epoch mtime", -1], + ["an mtime from an invalid date", Number.NaN], + ["an infinite mtime", Number.POSITIVE_INFINITY], + ])("refuses %s rather than writing a field no tar can parse", (_label, mtime) => { + expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime }])).toThrow( + TarFieldOutOfRangeError, + ); + }); + + test("refuses a negative mode rather than writing a field no tar can parse", () => { + expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mode: -1 }])).toThrow( + TarFieldOutOfRangeError, + ); + }); + test("refuses a path component too long to represent", () => { expect(() => createTar([{ path: `${"f".repeat(120)}.txt`, contents: new Uint8Array(0) }]), diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 4202d152df..fa8092f8a5 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -21,6 +21,23 @@ interface PackagedWorker { readonly fileCount: number; } +/** + * Seconds since the epoch, as a USTAR octal field can hold them. + * + * A filesystem timestamp is not always a sane one. A pre-1970 mtime is negative + * — a botched `touch` and some archive extractors both produce them — and a + * corrupt one decodes to an `Invalid Date` whose `getTime()` is `NaN`. Neither + * is representable, and neither is worth failing a deploy over, so both collapse + * to the epoch rather than reaching `writeOctal`'s range check. + */ +function tarMtime(modified: Option.Option): number { + if (Option.isNone(modified)) { + return 0; + } + const seconds = Math.floor(modified.value.getTime() / 1000); + return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : 0; +} + /** * Every entry under `root`, as tar entries. * @@ -64,8 +81,7 @@ const collectEntries = ( const info = yield* fs.stat(absolutePath); - const modified = info.mtime; - const mtime = Option.isSome(modified) ? Math.floor(modified.value.getTime() / 1000) : 0; + const mtime = tarMtime(info.mtime); if (info.type === "Directory") { entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index de6e2f8cb0..d00cbfbe13 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -6,7 +6,9 @@ import { mkdtempSync, readdirSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -42,12 +44,14 @@ function listableAsCurrentUser(path: string): boolean { } } -/** Entry paths and their USTAR typeflags, read back out of the archive. */ -function readEntries(archive: Uint8Array): Array<{ path: string; type: string; link: string }> { +/** Entry paths, USTAR typeflags and mtimes, read back out of the archive. */ +function readEntries( + archive: Uint8Array, +): Array<{ path: string; type: string; link: string; mtime: string }> { const raw = new Uint8Array(gunzipSync(archive)); const decoder = new TextDecoder(); const trim = (value: string) => value.split("\u0000")[0] ?? ""; - const entries: Array<{ path: string; type: string; link: string }> = []; + const entries: Array<{ path: string; type: string; link: string; mtime: string }> = []; for (let offset = 0; offset + 512 <= raw.length;) { const name = trim(decoder.decode(raw.subarray(offset, offset + 100))); @@ -59,12 +63,20 @@ function readEntries(archive: Uint8Array): Array<{ path: string; type: string; l path: name, type: decoder.decode(raw.subarray(offset + 156, offset + 157)), link: trim(decoder.decode(raw.subarray(offset + 157, offset + 257))), + mtime: trim(decoder.decode(raw.subarray(offset + 136, offset + 148))), }); offset += 512 + Math.ceil(size / 512) * 512; } return entries; } +/** The 11-digit octal a tar header carries for `mtimeMs`. */ +function expectedOctalMtime(mtimeMs: number): string { + return Math.floor(mtimeMs / 1000) + .toString(8) + .padStart(11, "0"); +} + describe("packageWorkerDirectory", () => { let dir: string; @@ -129,6 +141,26 @@ describe("packageWorkerDirectory", () => { expect(entries.find((entry) => entry.path === "sub/up")?.type).toBe("2"); }); + // A pre-1970 mtime is negative, and a negative number is not representable in + // a USTAR octal field: `(-1).toString(8)` renders to exactly the field width, + // so it would sail past the width check and ship a header GNU tar rejects + // after the upload. A botched `touch` is not worth failing a deploy over, so + // the timestamp collapses to the epoch instead. + test("packages a file with a pre-epoch mtime, timestamped at the epoch", async () => { + const file = join(dir, "a.txt"); + writeFileSync(file, "a"); + utimesSync(file, new Date(-86_400_000), new Date(-86_400_000)); + + const result = await pack(dir); + + const entry = readEntries(result.archive).find((candidate) => candidate.path === "a.txt"); + // Some filesystems refuse a pre-epoch timestamp and clamp it on the way in, + // in which case there is nothing to collapse — either way the field has to + // be a plain octal number the archive can carry. + const stored = statSync(file).mtimeMs; + expect(entry?.mtime).toBe(stored < 0 ? "00000000000" : expectedOctalMtime(stored)); + }); + test("packages an empty directory to an archive with no entries", async () => { const result = await pack(dir); From 3a78cd010c5fb899312b100e2c348becc1ae4886 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:31:47 -0300 Subject: [PATCH 05/17] fix(cli): keep out-of-range tar fields in the failure channel `TarFieldOutOfRangeError` carries `actionability.invalidInput` and documents itself as user-actionable, but `packageWorkerDirectory` narrowed its catch to `TarPathTooLongError` and rethrew the other as a defect. The classification could therefore never take effect, and because `withJsonErrorHandling` catches failures and not defects, `-o json` exited with no structured error at all. An 8 GiB file in the source directory is the realistic way in, through the size field. The sibling test on the path error asserted only `Exit.isFailure`, which a defect also satisfies, so it never pinned the distinction it was named for. Both tests now check the cause. --- apps/cli/src/shared/workers/worker-package.ts | 14 ++++---- .../workers/worker-package.unit.test.ts | 33 +++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index fa8092f8a5..5f366ec362 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -1,7 +1,7 @@ import { gzipSync } from "node:zlib"; import { Effect, FileSystem, Option } from "effect"; import type { PlatformError } from "effect/PlatformError"; -import { createTar, type TarEntry, TarPathTooLongError } from "./tar.ts"; +import { createTar, type TarEntry, TarFieldOutOfRangeError, TarPathTooLongError } from "./tar.ts"; /** * Package a worker's source directory into the `.tar.gz` build context the @@ -114,14 +114,16 @@ const collectEntries = ( export const packageWorkerDirectory = Effect.fnUntraced(function* (dir: string) { const entries = yield* collectEntries(dir, ""); - // `createTar` throws for a name USTAR cannot represent, such as a path - // component over 100 bytes. That is user-actionable, so it belongs in the - // failure channel: `withJsonErrorHandling` only catches failures, and a defect - // would exit `--output-format json` with no structured error. + // `createTar` throws for anything USTAR cannot represent: a path component + // over 100 bytes, or a size past the 8 GiB an octal field holds. Both are + // user-actionable, and both declare themselves so, which only takes effect if + // they reach the failure channel — `withJsonErrorHandling` catches failures + // and not defects, so a defect exits `--output-format json` with no + // structured error at all. const archive = yield* Effect.try({ try: () => gzipSync(createTar(entries)), catch: (cause) => { - if (cause instanceof TarPathTooLongError) { + if (cause instanceof TarPathTooLongError || cause instanceof TarFieldOutOfRangeError) { return cause; } // Anything else here really is a bug, so let it stay a defect rather than diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index d00cbfbe13..21401ed8c3 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { gunzipSync } from "node:zlib"; -import { Effect, Exit } from "effect"; +import { Cause, Effect, Exit } from "effect"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; @@ -232,9 +232,38 @@ describe("packageWorkerDirectory tar limits", () => { expect(Exit.isFailure(exit)).toBe(true); // A failure, not a defect: the difference is whether the JSON error handler - // ever sees it. + // ever sees it. `Exit.isFailure` alone does not say which, since a defect + // exits that way too — the cause is what tells them apart. + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(false); + expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true); expect(JSON.stringify(exit)).toContain("TarPathTooLong"); }); + + // The other half of the same rule. `TarFieldOutOfRangeError` declares itself + // user-actionable too, and that declaration can only take effect if the error + // reaches the failure channel rather than being rethrown as a defect. An 8 GiB + // file trips it through the size field; a far-future mtime is the same check + // for the price of a `utimes` call. + test("reports an out-of-range header field as a failure rather than a defect", async () => { + const file = join(dir, "a.txt"); + writeFileSync(file, "contents"); + // One past the 11-digit octal ceiling, a little past the year 2242. + utimesSync(file, 8 ** 11, 8 ** 11); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + // Filesystems that cannot hold a timestamp that far out clamp it on the way + // in, which leaves nothing out of range to report. + if (Math.floor(statSync(file).mtimeMs / 1000) > 8 ** 11 - 1) { + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(false); + expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true); + expect(JSON.stringify(exit)).toContain("TarFieldOutOfRange"); + } else { + expect(Exit.isSuccess(exit)).toBe(true); + } + }); }); describe("formatBytes", () => { From 323852d898a6cc1ada40250d4f5d5c5deeb61889 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:37:45 -0300 Subject: [PATCH 06/17] fix(cli): recover only a missing source, not every filesystem error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Effect.option` on the source-directory stat swallowed every failure, so a permission or I/O error was reported as "There is no worker source at " with a suggestion to scaffold one — a misdiagnosis whose remediation points at a path that is already occupied. The `readDirectory` a few lines down had the same shape through `orElseSucceed(() => [])`, reading an unopenable directory as an empty one. Only a `NotFound` reason now maps to `WorkerSourceMissingError`; every other `PlatformError` propagates as itself. `PlatformError` was already in this handler's error channel via `packageWorkerDirectory`, so nothing downstream changes. --- .../commands/workers/push/push.handler.ts | 35 +++++++--- .../workers/push/push.integration.test.ts | 67 ++++++++++++++++++- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index de587c16dd..e22359e011 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Option, type Schedule } from "effect"; +import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyRenderWorkerDetails } from "../workers.format.ts"; import { @@ -155,19 +156,35 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { // guessed. Doing that first meant reporting an inference about a path that // does not exist, and only then failing on the path. { - const stat = yield* fs.stat(worker.sourceDir).pipe(Effect.option); - if (Option.isNone(stat) || stat.value.type !== "Directory") { - return yield* Effect.fail( - new WorkerSourceMissingError({ - detail: `There is no worker source at ${sourceDisplay}.`, - suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, - }), + const sourceMissing = new WorkerSourceMissingError({ + detail: `There is no worker source at ${sourceDisplay}.`, + suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + }); + // Only "no such path" means the worker was never scaffolded. A permission + // or I/O error on the directory is a different problem with a different + // fix, and answering it with "there is no worker source, run `workers new`" + // both misdiagnoses it and points at a directory that already exists — so + // every other reason propagates as itself. + const info = yield* fs + .stat(worker.sourceDir) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.fail(sourceMissing) + : Effect.fail(error), + ), ); + if (info.type !== "Directory") { + return yield* Effect.fail(sourceMissing); } // An empty directory packages and deploys perfectly happily, producing an // image with nothing in it — a success message for a worker that cannot // serve anything. Refuse before uploading rather than after. - const contents = yield* fs.readDirectory(worker.sourceDir).pipe(Effect.orElseSucceed(() => [])); + // + // Read errors propagate rather than reading as empty: a directory the CLI + // cannot open is not a directory with nothing in it, and the two want + // opposite things from the user. + const contents = yield* fs.readDirectory(worker.sourceDir); if (contents.length === 0) { return yield* Effect.fail( new WorkerSourceMissingError({ diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 0c16266931..c23b74baa2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -1,4 +1,4 @@ -import { rmSync } from "node:fs"; +import { chmodSync, readdirSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Option, Schedule } from "effect"; @@ -90,6 +90,20 @@ function tagOf(error: unknown): string | undefined { : undefined; } +/** + * Whether the current user can still list `path` after it was chmod-ed shut. + * Root ignores the permission bits, and CI sometimes runs as root, so the + * permission test below asserts the opposite outcome instead of skipping. + */ +function listableAsCurrentUser(path: string): boolean { + try { + readdirSync(path); + return true; + } catch { + return false; + } +} + function push(flagOverrides: Partial = {}) { // Both schedules are injected: the outer poll and the per-read retry. The // production retry is spaced in seconds, so leaving it in place made the @@ -423,6 +437,57 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // "Cannot read it" and "it is not there" want opposite things from the user, + // and `Effect.option` on the stat collapsed them into the second — so an + // unreadable source was reported as an unscaffolded worker, with a suggestion + // to run `workers new` over a path that is already occupied. A symlink loop + // is the cheapest stat failure that is not a missing path, and unlike a + // chmod it behaves the same when the suite runs as root. + it.live("reports an unstattable source rather than calling it missing", () => { + const repo = project({}); + const source = join(repo.dir, "supabase", "workers", "api"); + rmSync(source, { recursive: true, force: true }); + symlinkSync("api", source); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).not.toBeInstanceOf(WorkerSourceMissingError); + expect(tagOf(error)).toBe("PlatformError"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Same rule one line down: `orElseSucceed([])` on the read reported a + // directory the CLI cannot open as a directory with nothing in it. + it.live("reports an unreadable source rather than calling it empty", () => { + const repo = project({}); + const source = join(repo.dir, "supabase", "workers", "api"); + chmodSync(source, 0o000); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + if (listableAsCurrentUser(source)) { + expect(http.requests.length).toBeGreaterThan(0); + } else { + expect(error).not.toBeInstanceOf(WorkerSourceMissingError); + expect(tagOf(error)).toBe("PlatformError"); + expect(http.requests).toHaveLength(0); + } + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + chmodSync(source, 0o700); + repo.cleanup(); + }), + ), + ); + }); + it.live("rides out a transient failure while polling the build", () => { const repo = project(); const { layer, http } = setupLegacyWorkers({ From 31305259f447d54d3189331e7ecd47a1f51e6bb5 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:39:07 -0300 Subject: [PATCH 07/17] fix(cli): stop pointing an empty worker source at `workers new` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both "nothing to deploy" guards suggested re-scaffolding with `supabase workers new --force`. `new` defines no `--force` flag, so following the suggestion exits with an unknown-option error — and dropping the flag would not save it: `new` refuses any name already present in `config.toml`, which is where a pushed worker almost always comes from, and refuses a directory that exists and is not empty, which covers the empty-subdirectories guard. The directory is already there and already wired up, so the only honest instruction is to put the code in it. Both call sites now share one suggestion, and the tests pin that neither names `new`. --- .../commands/workers/push/push.handler.ts | 18 ++++++++++++++++-- .../workers/push/push.integration.test.ts | 7 +++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index e22359e011..a83558505a 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -131,6 +131,20 @@ function resolveInstances(options: { return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); } +/** + * What to do about a source directory that exists but holds nothing to deploy. + * + * Deliberately does not point at `supabase workers new`. That command refuses + * any name already present in `config.toml`, which is where a pushed worker + * almost always comes from, and it refuses a directory that exists and is not + * empty — so for both callers here it would answer with a second error rather + * than a fix. The directory is already in place and already wired up; the only + * thing missing is the code. + */ +function addYourCode(sourceDisplay: string): string { + return `Add your worker's code to ${sourceDisplay}, then run this command again.`; +} + const deployOneWorker = Effect.fnUntraced(function* (input: { readonly project: LegacyWorkersProject; readonly name: string; @@ -189,7 +203,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { return yield* Effect.fail( new WorkerSourceMissingError({ detail: `${sourceDisplay} is empty, so there is nothing to deploy.`, - suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + suggestion: addYourCode(sourceDisplay), }), ); } @@ -234,7 +248,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { return yield* Effect.fail( new WorkerSourceMissingError({ detail: `${sourceDisplay} holds no files to deploy, only empty directories.`, - suggestion: `Add your code there, or re-scaffold it with \`supabase workers new ${name} --force\`.`, + suggestion: addYourCode(sourceDisplay), }), ); } diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index c23b74baa2..4d4303e2f8 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -433,6 +433,11 @@ describe("legacy workers push", () => { expect(error).toBeInstanceOf(WorkerSourceMissingError); expect((error as WorkerSourceMissingError).detail).toContain("is empty"); + // `workers new` defines no `--force`, and refuses both a name already in + // `config.toml` and a directory that is not empty — so recovery advice + // that names it would answer with a second error instead of a fix. + expect((error as WorkerSourceMissingError).suggestion).not.toContain("--force"); + expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -673,6 +678,8 @@ describe("legacy workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).not.toContain("--force"); + expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); expect(http.routeKeys).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); From e831044de1a5c215e659a12b507fa1c0fbb91834 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:39:26 -0300 Subject: [PATCH 08/17] docs(cli): say why the tar writer does not use Bun.Archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The why-not paragraph only covered shelling out to `tar`, leaving the next reader to wonder why this does not reuse `Bun.Archive`, which the repo already builds tar bytes with. Verified against Bun 1.3.14: creation takes path-to-contents pairs and nothing else, and every entry is emitted as a regular file with mode 0644 and the current wall-clock time — no symlinks, no executable bit, no reproducible output. --- apps/cli/src/shared/workers/tar.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts index 8ec0bfd4ff..0dccc1de48 100644 --- a/apps/cli/src/shared/workers/tar.ts +++ b/apps/cli/src/shared/workers/tar.ts @@ -8,6 +8,14 @@ * server only ever untars what we send, so producing the bytes here keeps the * upload identical on every platform and keeps packaging out of the process * table. + * + * `Bun.Archive` — which this repo already uses to build the pgdata baseline + * marker, `legacyPgDataBaselineMarkerTar` — is not the same tool. It builds + * from path-to-contents pairs and exposes no per-entry metadata: every entry + * comes out as a regular file with mode `0644` and the current wall-clock time, + * so a symlink cannot be stored at all, an executable loses its bit, and the + * same tree packages to different bytes on every run. A single-file marker + * needs none of that; a build context needs all of it. */ import { From 1e966613ad12e36cf90564c39ea38a8b9a94276d Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 17:59:36 -0300 Subject: [PATCH 09/17] fix(cli): report a non-directory worker source as what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file sitting where the source directory should be fell into the same `WorkerSourceMissingError` as a missing path, so `push` said "There is no worker source at " about a path that is occupied, and suggested `supabase workers new ` — which refuses a destination that exists and is not a directory, and refuses any name already in `config.toml`. The user got a false diagnosis followed by a command that errors out. The missing-path branch keeps that message, where both halves are true: a name is only validated as a DNS label before dispatch, so `push ` for a worker that is in neither `config.toml` nor the workers directory does reach it, and `workers new ` is the right answer there. SIDE_EFFECTS.md picks up this condition and the unreadable-source one from the preceding commit. --- .../commands/workers/push/SIDE_EFFECTS.md | 17 +++++++------ .../commands/workers/push/push.handler.ts | 11 +++++++- .../workers/push/push.integration.test.ts | 25 ++++++++++++++++++- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index b145692970..e6b9692640 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -35,14 +35,15 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------- | -| `0` | success | -| `1` | no workers named and none found in the project | -| `1` | a worker's source directory is missing or empty | -| `1` | build context upload failed | -| `1` | the build reached `failed`, or never left `building` | -| `1` | API error, or project not enrolled in the alpha | +| Code | Condition | +| ---- | ------------------------------------------------------- | +| `0` | success | +| `1` | no workers named and none found in the project | +| `1` | a worker's source is missing, not a directory, or empty | +| `1` | a worker's source directory cannot be read | +| `1` | build context upload failed | +| `1` | the build reached `failed`, or never left `building` | +| `1` | API error, or project not enrolled in the alpha | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index a83558505a..1d623f2041 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -188,8 +188,17 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { : Effect.fail(error), ), ); + // Something is there, it is just not a directory. Reporting that as "there + // is no worker source" is false twice over: the path is occupied, and + // `workers new` refuses a destination that exists and is not a directory, + // so the scaffold suggestion would answer with a second error. if (info.type !== "Directory") { - return yield* Effect.fail(sourceMissing); + return yield* Effect.fail( + new WorkerSourceMissingError({ + detail: `${sourceDisplay} is not a directory.`, + suggestion: `Replace it with a directory holding your worker's code, then run this command again.`, + }), + ); } // An empty directory packages and deploys perfectly happily, producing an // image with nothing in it — a success message for a worker that cannot diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 4d4303e2f8..f1dbc3a97e 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, readdirSync, rmSync, symlinkSync } from "node:fs"; +import { chmodSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Option, Schedule } from "effect"; @@ -442,6 +442,29 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // A file sitting where the source directory should be is not a missing + // worker: the path is occupied, and `workers new` refuses a destination that + // exists and is not a directory, so pointing there would answer with a second + // error. + it.live("reports a file at the source path as not a directory", () => { + const repo = project({}); + const source = join(repo.dir, "supabase", "workers", "api"); + rmSync(source, { recursive: true, force: true }); + writeFileSync(source, "not a directory"); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + const failure = error as WorkerSourceMissingError; + expect(failure.detail).toContain("is not a directory"); + expect(failure.detail).not.toContain("There is no worker source"); + expect(failure.suggestion).not.toContain("workers new"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // "Cannot read it" and "it is not there" want opposite things from the user, // and `Effect.option` on the stat collapsed them into the second — so an // unreadable source was reported as an unscaffolded worker, with a suggestion From f0d3477d64c8d210f9c39287c145a03f1439174e Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:02:45 -0300 Subject: [PATCH 10/17] fix(cli): suggest a scaffold only where `workers new` would work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The missing-source suggestion always said `supabase workers new `, but `new` refuses any name already under `[workers.]` — so for a configured worker whose directory is gone, the one recovery offered exits with "already configured". The suggestion now depends on how the worker got here: - no config entry — the name reached `push` from argv alone, `new` is the answer, message unchanged; - configured, default directory — the entry is fine and the directory is not, so say to create it; - configured with an explicit `source` — the path in config is as likely to be the mistake as the absent directory, so name both. --- .../commands/workers/push/push.handler.ts | 33 ++++++++++++- .../workers/push/push.integration.test.ts | 47 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index 1d623f2041..cc2678c66c 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -13,6 +13,7 @@ import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.t import { classifyWorkerDir } from "../../../../shared/workers/worker-classify.ts"; import { formatBytes, packageWorkerDirectory } from "../../../../shared/workers/worker-package.ts"; import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import type { WorkerEntry } from "../../../../shared/workers/worker-config.ts"; import { apiSizeFor, DEFAULT_WORKER_INSTANCES, @@ -131,6 +132,31 @@ function resolveInstances(options: { return Option.getOrElse(options.override, () => options.recorded ?? DEFAULT_WORKER_INSTANCES); } +/** + * What to do about a worker whose source directory is not there at all. + * + * `supabase workers new` is only an answer for a name the config has never + * heard of — `new` refuses any name already under `[workers.]`, so + * offering it to a configured worker would answer with a second error. A + * configured worker is missing a directory, not a config entry, and when the + * entry pins an explicit `source` the path itself is as likely to be the + * mistake as the absent directory. + */ +function missingSourceSuggestion(input: { + readonly name: string; + readonly sourceDisplay: string; + readonly configPath: string; + readonly entry: WorkerEntry | undefined; +}): string { + if (input.entry === undefined) { + return `Scaffold it with \`supabase workers new ${input.name}\`.`; + } + if (input.entry.source !== undefined) { + return `Create ${input.sourceDisplay}, or correct \`source\` under [workers.${input.name}] in ${input.configPath}.`; + } + return `Create ${input.sourceDisplay} and add your worker's code, then run this command again.`; +} + /** * What to do about a source directory that exists but holds nothing to deploy. * @@ -172,7 +198,12 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { { const sourceMissing = new WorkerSourceMissingError({ detail: `There is no worker source at ${sourceDisplay}.`, - suggestion: `Scaffold it with \`supabase workers new ${name}\`.`, + suggestion: missingSourceSuggestion({ + name, + sourceDisplay, + configPath: displayPath(project.projectRoot, project.configPath), + entry: worker.entry, + }), }); // Only "no such path" means the worker was never scaffolded. A permission // or I/O error on the directory is a different problem with a different diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index f1dbc3a97e..11989f277f 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -418,7 +418,12 @@ describe("legacy workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerSourceMissingError); - expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + // `api` is under `[workers.api]`, and `new` refuses a name the config + // already carries — so the answer is the absent directory, not a scaffold. + expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); + expect((error as WorkerSourceMissingError).suggestion).toContain( + "supabase/workers/api and add your worker's code", + ); expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -442,6 +447,46 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The one case `workers new` really does answer: a name that reached `push` + // from argv alone, with no `[workers.]` entry and nothing on disk. + // Names are only validated as DNS labels before dispatch, so this is + // reachable — a typo, or a worker nobody has scaffolded yet. + it.live("offers to scaffold a worker the config has never heard of", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + expect((error as WorkerSourceMissingError).suggestion).toContain("supabase workers new api"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A worker whose `source` points somewhere that is not there: the path in + // config is as likely to be the mistake as the absent directory, so the + // suggestion names both. + it.live("points at the config entry when a configured source is missing", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "./services/api"\n`, + }); + rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerSourceMissingError); + const failure = error as WorkerSourceMissingError; + expect(failure.suggestion).not.toContain("workers new"); + expect(failure.suggestion).toContain("[workers.api]"); + expect(failure.suggestion).toContain("source"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // A file sitting where the source directory should be is not a missing // worker: the path is occupied, and `workers new` refuses a destination that // exists and is not a directory, so pointing there would answer with a second From e14bedc82e73fed8d346644b49de9cb66bb535fd Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:31:58 -0300 Subject: [PATCH 11/17] test(cli): make the unreadable-source test survive a root runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test chmod-ed the source directory shut, then ran the deploy through `Effect.flip`. Root ignores the permission bits, so the deploy succeeds there — and `Effect.flip` turns a success into a failure, which fails the test before the branch written to handle exactly that case can run. The root-safe branch was unreachable. The permission probe now happens before the run and selects which effect to run, so both permission models are asserted rather than one of them crashing. `tagOf` goes with it: every call site knows the variant it expects, so they use `Predicate.isTagged` or the error class instead of reaching for `_tag`, per the repo rule that tests follow the same narrowing rules as production code. --- .../workers/push/push.integration.test.ts | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 11989f277f..2b84a385d4 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -1,7 +1,7 @@ import { chmodSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Schedule } from "effect"; +import { Effect, Option, Predicate, Schedule } from "effect"; import { makeWorkersProject, setupLegacyWorkers, @@ -11,9 +11,11 @@ import { type WorkersHttpRoutes, } from "../../../../../tests/helpers/legacy-workers.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; import { NoWorkersToDeployError, WorkerBuildFailedError, + WorkerBuildTimeoutError, WorkerProjectNotFoundError, WorkersUnavailableError, WorkerSourceMissingError, @@ -80,16 +82,6 @@ function routes(overrides: WorkersHttpRoutes = {}): WorkersHttpRoutes { }; } -/** - * The `_tag` of a failure, for a channel that also carries plain `Error` - * subclasses — `TarPathTooLongError` has no tag. - */ -function tagOf(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "_tag" in error - ? String((error as { _tag: unknown })._tag) - : undefined; -} - /** * Whether the current user can still list `path` after it was chmod-ed shut. * Root ignores the permission bits, and CI sometimes runs as root, so the @@ -324,7 +316,7 @@ describe("legacy workers push", () => { Effect.flip, ); - expect(tagOf(error)).toBe("WorkerBuildTimeoutError"); + expect(error).toBeInstanceOf(WorkerBuildTimeoutError); expect((error as { suggestion: string }).suggestion).toContain("supabase workers status api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -527,7 +519,7 @@ describe("legacy workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).not.toBeInstanceOf(WorkerSourceMissingError); - expect(tagOf(error)).toBe("PlatformError"); + expect(Predicate.isTagged(error, "PlatformError")).toBe(true); expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -538,18 +530,24 @@ describe("legacy workers push", () => { const repo = project({}); const source = join(repo.dir, "supabase", "workers", "api"); chmodSync(source, 0o000); + // Probed before the run, not inside it: root ignores the permission bits, so + // the deploy would succeed, and `Effect.flip` turns a success into a failure + // — the branch below would never be reached to handle that case. + const unreadable = !listableAsCurrentUser(source); const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); return Effect.gen(function* () { - const error = yield* push().pipe(Effect.flip); - - if (listableAsCurrentUser(source)) { + if (!unreadable) { + yield* push(); expect(http.requests.length).toBeGreaterThan(0); - } else { - expect(error).not.toBeInstanceOf(WorkerSourceMissingError); - expect(tagOf(error)).toBe("PlatformError"); - expect(http.requests).toHaveLength(0); + return; } + + const error = yield* push().pipe(Effect.flip); + + expect(error).not.toBeInstanceOf(WorkerSourceMissingError); + expect(Predicate.isTagged(error, "PlatformError")).toBe(true); + expect(http.requests).toHaveLength(0); }).pipe( Effect.provide(layer), Effect.ensuring( @@ -728,7 +726,7 @@ describe("legacy workers push", () => { return Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); - expect(tagOf(error)).toBe("LegacyWorkersEnvNotSupportedError"); + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); expect(http.routeKeys).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); From e41948be8651594bda212cd1ce8c6e880d1d7cb8 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:35:54 -0300 Subject: [PATCH 12/17] fix(cli): render transport failures without reaching for `_tag` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fallbacks read `error.reason._tag` when a transport error carried no description, which reinvents `HttpClientError.message` and does it worse — the library renders `Transport error (POST https://...)` where this rendered the bare class name. The two sites cannot take the same fix. `mapRequestError` talks to the Management API, so `error.message` is a straight upgrade. `uploadBuildContext` cannot use it: the message appends the URL that failed, and there that URL is the presigned signature — a write-capable credential, and the same leak `legacyRedactHttpUrl` exists to prevent on the debug log. That site keeps the reason's own description with a fixed fallback. The workers HTTP harness gains a transport-failure stub, so the leak has a test rather than only a comment. Swapping `error.message` back in turns it red. --- .../workers/push/push.integration.test.ts | 25 +++++++++++ apps/cli/src/shared/workers/workers-api.ts | 14 ++++-- apps/cli/tests/helpers/legacy-workers.ts | 44 ++++++++++++++++--- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 2b84a385d4..982fd10f84 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -336,6 +336,31 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // The presigned URL's query string is a write-capable credential, so it must + // not ride along in the error text — which rules out the library's own + // `HttpClientError.message`, since that appends the method and URL that + // failed. A transport failure is the case that would carry it. + it.live("keeps the presigned signature out of an upload transport failure", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + "PUT /deploy-context/api.tar.gz": { transportError: "connection reset by peer" }, + }), + }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerUploadFailedError); + const failure = error as WorkerUploadFailedError; + expect(failure.detail).toContain("connection reset by peer"); + expect(failure.detail).not.toContain("signed"); + expect(failure.detail).not.toContain(UPLOAD_URL); + expect(http.routeKeys).not.toContain(`POST ${workersRoute("/api/deploy")}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // Both of the next two arrive as a 404 on the same route; only `error.code` // separates them, so they are asserted against the bodies the API really // sends rather than a shape of our own invention. diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index 79409d05f6..1d15a12cc9 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -147,9 +147,12 @@ function mapRequestError(operation: string) { return markSupabaseApiInputErrorAsUserInput(error); } if (HttpClientError.isHttpClientError(error)) { - const description = error.reason.description ?? error.reason._tag; + // `message` is the library's own rendering of the reason — its label, the + // description when there is one, and the method and URL that failed. + // These requests all go to the Management API, so that URL is safe to + // show and is the most useful thing in the sentence. return new WorkersApiNetworkError({ - detail: `Could not reach the Workers API while trying to ${operation}: ${description}.`, + detail: `Could not reach the Workers API while trying to ${operation}: ${error.message}.`, suggestion: "Check your network connection and retry.", }); } @@ -286,8 +289,13 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( Effect.mapError( (error) => new WorkerUploadFailedError({ + // Deliberately not `error.message`, which is what the other transport + // failures in this module use: it appends the URL that failed, and + // here that URL is the write-capable signature. The reason's own + // description is the part worth showing, and the destination is + // already named by the step the user is watching. detail: `Uploading the build context failed: ${ - error.reason.description ?? error.reason._tag + error.reason.description ?? "the upload request did not complete" }.`, suggestion: "Check your network connection, then re-run the same command.", }), diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts index 98a0535feb..2a838b1aba 100644 --- a/apps/cli/tests/helpers/legacy-workers.ts +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -5,7 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { makeApiClient } from "@supabase/api/effect"; import { Effect, Layer, Option, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; @@ -43,8 +43,26 @@ export interface StubResponse { readonly body?: unknown; } +/** + * A request that never reaches a status code — the connection itself failed. + * Distinct from a `StubResponse` with an error status, which is a server that + * answered. + */ +export interface StubTransportFailure { + readonly transportError: string; +} + +function isTransportFailure( + stub: StubResponse | StubTransportFailure, +): stub is StubTransportFailure { + return "transportError" in stub; +} + /** How a test answers one request; sequential entries reply to repeated calls. */ -export type RouteHandler = StubResponse | ReadonlyArray; +export type RouteHandler = + | StubResponse + | StubTransportFailure + | ReadonlyArray; export interface WorkersHttpRoutes { /** Keyed `" "`, e.g. `"GET /v2/projects/abc.../workers"`. */ @@ -72,17 +90,17 @@ function respond( */ export function mockWorkersHttp(routes: WorkersHttpRoutes) { const requests: Array = []; - const remaining = new Map>( + const remaining = new Map>( Object.entries(routes).map(([route, handler]) => [ route, - Array.isArray(handler) ? [...handler] : [handler as StubResponse], + Array.isArray(handler) ? [...handler] : [handler as StubResponse | StubTransportFailure], ]), ); const handle = ( request: HttpClientRequest.HttpClientRequest, ): Effect.Effect => - Effect.sync(() => { + Effect.suspend(() => { const bytes = request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0); const url = new URL(request.url); requests.push({ @@ -95,12 +113,24 @@ export function mockWorkersHttp(routes: WorkersHttpRoutes) { const key = `${request.method} ${url.pathname}`; const queue = remaining.get(key); if (queue === undefined || queue.length === 0) { - return respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }); + return Effect.succeed( + respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }), + ); } // The last stub for a route keeps answering, so a poll loop does not have // to be stubbed a fixed number of times. const stub = queue.length === 1 ? queue[0]! : queue.shift()!; - return respond(request, stub); + if (isTransportFailure(stub)) { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: stub.transportError, + }), + }), + ); + } + return Effect.succeed(respond(request, stub)); }); const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make(handle)); From c2a6eaf6970bc562433e56b86ce6e51da80de069 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 18:38:49 -0300 Subject: [PATCH 13/17] fix(cli): read JSON project config when deploying workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `push` reused `legacyLoadWorkersProject`, which pins the loader to `tomlOnly`. That constraint belongs to `workers new`, whose entry writer is a TOML text editor and would corrupt a `config.json` by appending a `[workers.]` table to it. `push` only reads, and inherited it by sharing one function. With only a `config.json` on disk the loader returns null, so the workers section came back empty: a bare `push` skipped any worker whose `source` sits outside `supabase/workers/`, and a named one deployed with a guessed runtime and the default size and instance count instead of its configured values. The loader now takes the flag, with two named entry points so the call site says which it wants — `legacyLoadWorkersProject` for readers, `legacyLoadWorkersProjectForEntryWrite` for the scaffolder. The TOML-only gap is now the writer's alone. --- .../commands/workers/new/new.handler.ts | 4 +- .../commands/workers/push/SIDE_EFFECTS.md | 3 +- .../workers/push/push.integration.test.ts | 33 ++++++++++++++ .../legacy/commands/workers/workers.shared.ts | 43 +++++++++++++------ 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts index d6df968137..9b5e73774c 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -36,7 +36,7 @@ import { InvalidWorkerNameError, WorkerDirectoryExistsError, } from "../../../../shared/workers/workers.errors.ts"; -import { legacyLoadWorkersProject } from "../workers.shared.ts"; +import { legacyLoadWorkersProjectForEntryWrite } from "../workers.shared.ts"; import type { LegacyWorkersNewFlags } from "./new.command.ts"; /** @@ -132,7 +132,7 @@ export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( // The telemetry state file is written on every invocation, success or failure. yield* Effect.gen(function* () { - const project = yield* legacyLoadWorkersProject(); + const project = yield* legacyLoadWorkersProjectForEntryWrite(); const name = flags.name; const invalid = validateWorkerNameMessage(name); diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index e6b9692640..693de7002f 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -9,7 +9,8 @@ | Path | Format | When | | ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, for each worker's runtime, size, source | +| `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each worker's runtime, size, instances, source | +| `/supabase/config.toml` | TOML | always when no `config.json` exists — the same worker fields | | `/**` | any | always — packaged into the build context | | `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 982fd10f84..52daf33332 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -336,6 +336,39 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // `config.json` is a supported project format. `push` only reads the workers + // section, so it has to honour one: loading TOML-only left the section empty, + // which meant a guessed runtime and default size and instance count for a + // worker that had configured all three. + it.live("deploys a worker configured in config.json, not just config.toml", () => { + const created = makeWorkersProject({ + "supabase/config.json": JSON.stringify({ + project_id: "demo", + workers: { api: { runtime: "node", size: "2gb", instances: 3 } }, + }), + "supabase/workers/api/index.js": "export default { fetch: () => new Response('ok') };\n", + }); + const repo = { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; + const { layer, http, out } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec).toEqual({ + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + instances: 3, + }); + // Every value came from config, so nothing was inferred from the files. + expect(out.stderrText).not.toContain("guessed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + // The presigned URL's query string is a write-capable credential, so it must // not ride along in the error text — which rules out the library's own // `HttpClientError.message`, since that appends the method and URL that diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index 2a7a311982..10ab03cff9 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -32,22 +32,11 @@ export interface LegacyWorkersProject { readonly workersDir: string; } -export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { +const loadWorkersProject = Effect.fnUntraced(function* (options: { readonly tomlOnly: boolean }) { const settings = yield* LegacyCliSettings; const projectRoot = settings.workdir; const supabaseDir = join(projectRoot, "supabase"); - // `tomlOnly`: the entry writer is a TOML text editor. Without this the loader - // prefers `supabase/config.json` when one exists, `configPath` becomes the - // JSON file, and `commitWorkerEntry` appends a `[workers.]` table to it - // — leaving the project config unparseable after the scaffold is on disk. - // `functions new` avoids the same trap by resolving `supabase/config.toml` - // directly; this is that, through the loader. - // - // A JSON project therefore gets a `config.toml` written beside its - // `config.json`, which the default loader lists in `ignoredPaths`. That is a - // known gap: workers are TOML-only until config writing is overhauled. - // // `search: false`: `settings.workdir` is already an authoritative project // root — `--workdir`/`SUPABASE_WORKDIR` as given, else the one ancestor walk // Go's `getProjectRoot` performs — so letting the loader climb again resolves @@ -59,7 +48,7 @@ export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { // // `loadCliConfig` returns null when the directory holds no project yet, // which is what lets `workers new` scaffold into a bare one. - const loaded = yield* loadCliConfig(projectRoot, { tomlOnly: true, search: false }); + const loaded = yield* loadCliConfig(projectRoot, { tomlOnly: options.tomlOnly, search: false }); const section = readWorkersSection(loaded?.config.workers); return { @@ -71,6 +60,34 @@ export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { } satisfies LegacyWorkersProject; }); +/** + * The project as a reader sees it, following the loader's normal + * JSON-over-TOML selection. `config.json` is a supported project format, so a + * command that only reads `[workers.*]` has to honour it — otherwise a JSON + * project deploys with a guessed runtime and default size and instance counts + * instead of the ones it configured, and a worker whose `source` sits outside + * `supabase/workers/` is not discovered at all. + */ +export const legacyLoadWorkersProject = () => loadWorkersProject({ tomlOnly: false }); + +/** + * The project as the `[workers.]` entry writer needs to see it: TOML + * only. + * + * `commitWorkerEntry` is a TOML text editor. Without `tomlOnly` the loader + * prefers `supabase/config.json` when one exists, `configPath` becomes the JSON + * file, and the writer appends a `[workers.]` table to it — leaving the + * project config unparseable after the scaffold is already on disk. + * `functions new` avoids the same trap by resolving `supabase/config.toml` + * directly; this is that, through the loader. + * + * A JSON project therefore gets a `config.toml` written beside its + * `config.json`, which the loader lists in `ignoredPaths`. That gap is the + * writer's alone — reads go through {@link legacyLoadWorkersProject} — and it + * closes when config writing is overhauled. + */ +export const legacyLoadWorkersProjectForEntryWrite = () => loadWorkersProject({ tomlOnly: true }); + export interface LegacyResolvedWorker { readonly name: string; readonly entry: WorkerEntry | undefined; From db35bcee7357a78008f1fd1c8acc8b36f0d4c068 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:02:30 -0300 Subject: [PATCH 14/17] fix(cli): refuse a build context that links outside itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collectEntries` stored every symlink as a link entry, which is right for a link inside the packaged tree and wrong for one pointing out of it. The archive is the whole of what the server gets — it runs no install step and has no view of the surrounding repository — so an escaping link arrives dangling: a catalog runtime boots without the dependency, a Dockerfile build fails on the `COPY`, both minutes later with nothing naming the cause. A worker directory that is a pnpm workspace member is the common way in. Its dependencies link to the repository-root store, so every one of them escapes. A worker with `source` pointing at an existing monorepo package is the same case, and that is the use `source` exists for. An escaping link is now refused before the upload. An absolute target that does land back inside the tree is rewritten relative to the link, since a path on this machine resolves to nothing on the other end. Not runtime-specific, so not gated on one: the walk never sees the runtime, and a Dockerfile worker with a symlinked config has the same problem. --- .../commands/workers/push/SIDE_EFFECTS.md | 1 + apps/cli/src/shared/workers/worker-package.ts | 46 ++++++++++++++++++- .../workers/worker-package.unit.test.ts | 37 ++++++++++++++- apps/cli/src/shared/workers/workers.errors.ts | 24 ++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index 693de7002f..52e5ab4366 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -42,6 +42,7 @@ | `1` | no workers named and none found in the project | | `1` | a worker's source is missing, not a directory, or empty | | `1` | a worker's source directory cannot be read | +| `1` | a worker's source links to a path outside itself | | `1` | build context upload failed | | `1` | the build reached `failed`, or never left `building` | | `1` | API error, or project not enrolled in the alpha | diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 5f366ec362..3e4d5a300f 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -1,6 +1,8 @@ import { gzipSync } from "node:zlib"; +import { isAbsolute, relative, resolve } from "node:path"; import { Effect, FileSystem, Option } from "effect"; import type { PlatformError } from "effect/PlatformError"; +import { WorkerSourceEscapingLinkError } from "./workers.errors.ts"; import { createTar, type TarEntry, TarFieldOutOfRangeError, TarPathTooLongError } from "./tar.ts"; /** @@ -21,6 +23,28 @@ interface PackagedWorker { readonly fileCount: number; } +/** + * Where a symlink points, relative to the packaged tree — or `undefined` when it + * points outside it. + * + * A link is stored rather than followed, so the target has to be packaged too + * for the link to mean anything on the other end. Targets are also rewritten + * relative to the link's own directory: an absolute one is a path on this + * machine and would not resolve anywhere else. + */ +function confinedLinkTarget(input: { + readonly root: string; + readonly linkDir: string; + readonly target: string; +}): string | undefined { + const resolved = resolve(input.linkDir, input.target); + const fromRoot = relative(input.root, resolved); + if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) { + return undefined; + } + return isAbsolute(input.target) ? relative(input.linkDir, resolved) : input.target; +} + /** * Seconds since the epoch, as a USTAR octal field can hold them. * @@ -49,7 +73,11 @@ function tarMtime(modified: Option.Option): number { const collectEntries = ( root: string, relativeDir: string, -): Effect.Effect, PlatformError, FileSystem.FileSystem> => +): Effect.Effect< + Array, + PlatformError | WorkerSourceEscapingLinkError, + FileSystem.FileSystem +> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const absoluteDir = relativeDir === "" ? root : `${root}/${relativeDir}`; @@ -69,12 +97,26 @@ const collectEntries = ( // an ancestor from being walked into. const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); if (Option.isSome(linkTarget)) { + const confined = confinedLinkTarget({ + root, + linkDir: absoluteDir, + target: linkTarget.value, + }); + if (confined === undefined) { + return yield* Effect.fail( + new WorkerSourceEscapingLinkError({ + detail: `${relativePath} links to ${linkTarget.value}, which is outside the worker source and cannot be packaged with it.`, + suggestion: + "Install the worker's dependencies inside its own directory, or point `source` at a directory that contains everything the build needs.", + }), + ); + } entries.push({ path: relativePath, contents: new Uint8Array(0), mode: 0o777, mtime: 0, - linkTarget: linkTarget.value, + linkTarget: confined, }); continue; } diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index 21401ed8c3..d95169a2d7 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -122,14 +122,49 @@ describe("packageWorkerDirectory", () => { expect(link?.link).toBe("target.txt"); }); + // Broken, but pointing at a name inside the tree: whether the target exists is + // the server's problem once the archive is extracted, and dropping the link + // would change the tree the build sees. test("keeps a broken symlink instead of dropping it", async () => { - symlinkSync("/nowhere-at-all", join(dir, "broken.txt")); + symlinkSync("nowhere-at-all.txt", join(dir, "broken.txt")); const entries = readEntries((await pack(dir)).archive); expect(entries.find((entry) => entry.path === "broken.txt")?.type).toBe("2"); }); + // The archive is the whole of what the server gets, so a link out of it + // arrives dangling however valid it is here. Refused while the user is still + // at the terminal, rather than surfacing as a remote build failure. + test.each([ + ["a relative escape", "../../outside.txt"], + ["an absolute escape", "/nowhere-at-all"], + ["a hoisted dependency", "../../node_modules/.pnpm/left-pad@1.3.0/node_modules/left-pad"], + ])("refuses %s out of the build context", async (_label, target) => { + mkdirSync(join(dir, "nested")); + symlinkSync(target, join(dir, "nested", "dep")); + + const exit = await Effect.runPromise( + packageWorkerDirectory(dir).pipe(Effect.provide(BunServices.layer), Effect.exit), + ); + + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(false); + expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true); + expect(JSON.stringify(exit)).toContain("WorkerSourceEscapingLinkError"); + }); + + // An absolute target that lands back inside the tree is a path on this + // machine; stored verbatim it would resolve to nothing on the other end. + test("rewrites an absolute in-tree link target as a relative one", async () => { + writeFileSync(join(dir, "target.txt"), "t"); + mkdirSync(join(dir, "nested")); + symlinkSync(join(dir, "target.txt"), join(dir, "nested", "link.txt")); + + const entries = readEntries((await pack(dir)).archive); + + expect(entries.find((entry) => entry.path === "nested/link.txt")?.link).toBe("../target.txt"); + }); + test("does not recurse through a directory symlink that points at an ancestor", async () => { mkdirSync(join(dir, "sub")); writeFileSync(join(dir, "keep.txt"), "k"); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 7e01fc5bfb..2cdfc97e35 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -21,6 +21,30 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** + * A symlink in the worker source points outside the build context. + * + * The archive is everything the server gets — it runs no install step and has + * no view of the surrounding repository — so a link whose target is not also + * packaged arrives dangling. The catalog runtimes then boot without the + * dependency and a Dockerfile build fails on the `COPY`, both of them minutes + * later and with nothing naming the cause. Refused here instead. + * + * The common source is a package manager that hoists: a worker directory that + * is a pnpm workspace member links its dependencies at the repository root + * rather than under its own `node_modules`. + */ +export class WorkerSourceEscapingLinkError extends Data.TaggedError( + "WorkerSourceEscapingLinkError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** A bare `push` found no workers to deploy — none named, none in the project. */ export class NoWorkersToDeployError extends Data.TaggedError("NoWorkersToDeployError")<{ readonly detail: string; From 9690274d38849b59b5d4510dff1ae8d81b3f4e05 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:04:32 -0300 Subject: [PATCH 15/17] fix(cli): stop reading an unlistable workers root as an empty one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `legacyDiscoverWorkerNames` mapped every `readDirectory` failure to `[]` and every per-entry `stat` failure to `None`. A bare `push` on a workers root it cannot list therefore reported "no workers were named, and none were found" — or, when config named some, deployed those and exited 0 having silently skipped every directory-only worker. Absence and unreadable again, one level above the source-directory guards. A missing workers root still reads as nothing: a project may never have scaffolded one, and `[workers.]` entries can name workers that live elsewhere. Every other reason propagates. The per-entry stat keeps skipping a name that vanished between the listing and the stat, and nothing else. --- .../workers/push/push.integration.test.ts | 34 +++++++++++++++++++ .../legacy/commands/workers/workers.shared.ts | 27 +++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 52daf33332..38b92c5fa2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -699,6 +699,40 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // A bare `push` promises to deploy every worker in the project, and a worker + // with no config entry is known only by its directory. Reading an unlistable + // workers root as "no workers here" therefore answers a real filesystem + // problem with "nothing to deploy" — the same absence-versus-unreadable + // confusion as the source-directory guards, one level up. + it.live("fails rather than reporting an unlistable workers root as empty", () => { + const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); + const workersRoot = join(repo.dir, "supabase", "workers"); + chmodSync(workersRoot, 0o000); + const listable = listableAsCurrentUser(workersRoot); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push({ names: [] }).pipe(Effect.flip); + + if (listable) { + // Root ignores the permission bits, so the root lists and `api` is found. + expect(error).not.toBeInstanceOf(NoWorkersToDeployError); + } else { + expect(error).not.toBeInstanceOf(NoWorkersToDeployError); + expect(Predicate.isTagged(error, "PlatformError")).toBe(true); + expect(http.requests).toHaveLength(0); + } + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + chmodSync(workersRoot, 0o700); + repo.cleanup(); + }), + ), + ); + }); + it.live("fails when there are no workers to deploy at all", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n` }); rmSync(join(repo.dir, "supabase", "workers"), { recursive: true, force: true }); diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts index 10ab03cff9..ccc5762b92 100644 --- a/apps/cli/src/legacy/commands/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { loadCliConfig } from "@supabase/config/effect"; -import { Effect, FileSystem, Option } from "effect"; +import { Effect, FileSystem, Option, Predicate } from "effect"; import { LegacyCliSettings } from "../../config/legacy-cli-settings.service.ts"; import { readWorkersSection, @@ -147,11 +147,32 @@ export const legacyDiscoverWorkerNames = Effect.fnUntraced(function* ( project: LegacyWorkersProject, ) { const fs = yield* FileSystem.FileSystem; - const entries = yield* fs.readDirectory(project.workersDir).pipe(Effect.orElseSucceed(() => [])); + + // No workers root at all is a project that has never scaffolded one, and the + // config entries below may still name workers living elsewhere — so absence + // reads as nothing here. Any other reason propagates: a root the CLI cannot + // list is not a project with no workers in it, and answering a bare `push` + // with "deployed everything" after silently skipping them is the worst + // possible reading of it. + const entries = yield* fs + .readDirectory(project.workersDir) + .pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed>([]) + : Effect.fail(error), + ), + ); const scaffolded: Array = []; for (const entry of entries) { - const info = yield* fs.stat(join(project.workersDir, entry)).pipe(Effect.option); + // Only a name that vanished between the listing and this stat is skipped. + const info = yield* fs.stat(join(project.workersDir, entry)).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeedNone : Effect.fail(error), + ), + ); if (Option.isSome(info) && info.value.type === "Directory") { scaffolded.push(entry); } From 8bac2f2772a90c60b3a15f2fdf53e269254ad125 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:06:34 -0300 Subject: [PATCH 16/17] fix(cli): refuse a Dockerfile worker with no Dockerfile A worker recorded as `runtime = "dockerfile"` deploys its uploaded context as-is, so with no top-level `Dockerfile` the server has nothing to build. That only surfaced as a remote build failure, minutes after the archive had uploaded and a deployment had started, when the CLI was already standing in the directory that answers the question. Only reachable from a recorded runtime. A guessed `dockerfile` always passes, because the classifier picks it by finding this exact file. Classified `invalidConfig` rather than the `provideFlags` its neighbours in this file use: `push` has no runtime flag, so the fix is in `config.toml` or the directory, and the suggestion names both. --- .../commands/workers/push/SIDE_EFFECTS.md | 1 + .../commands/workers/push/push.handler.ts | 27 +++++++++++ .../workers/push/push.integration.test.ts | 45 +++++++++++++++++++ apps/cli/src/shared/workers/workers.errors.ts | 18 ++++++++ 4 files changed, 91 insertions(+) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index 52e5ab4366..f76178266a 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -43,6 +43,7 @@ | `1` | a worker's source is missing, not a directory, or empty | | `1` | a worker's source directory cannot be read | | `1` | a worker's source links to a path outside itself | +| `1` | a `dockerfile` worker's source holds no `Dockerfile` | | `1` | build context upload failed | | `1` | the build reached `failed`, or never left `building` | | `1` | API error, or project not enrolled in the alpha | diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index cc2678c66c..274acf26e8 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -37,6 +38,7 @@ import { UnknownWorkerRuntimeError, UnknownWorkerSizeError, WorkerBuildFailedError, + WorkerDockerfileMissingError, WorkerSourceMissingError, } from "../../../../shared/workers/workers.errors.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -255,6 +257,31 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { sourceDir: worker.sourceDir, }); + // A `dockerfile` worker ships its own build instructions, and the server has + // nothing to do without them. Checked before the archive is built rather than + // after the remote build fails — the CLI is already standing in the directory + // that either has the file or does not. A guessed `dockerfile` runtime always + // passes, since the classifier chose it by finding this exact file. + if (runtime === "dockerfile") { + const dockerfile = yield* fs.stat(join(worker.sourceDir, "Dockerfile")).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeedNone : Effect.fail(error), + ), + ); + if (Option.isNone(dockerfile) || dockerfile.value.type !== "File") { + return yield* Effect.fail( + new WorkerDockerfileMissingError({ + detail: `${name} is configured to build its own Dockerfile, but there is no Dockerfile in ${sourceDisplay}.`, + suggestion: `Add a Dockerfile there, or set a catalog runtime under [workers.${name}] in ${displayPath( + project.projectRoot, + project.configPath, + )}.`, + }), + ); + } + } + // Size: whatever `new --size` recorded, else the alpha envelope's own // default. Never left unset, because a worker that is actually running always // has some concrete size — and never silently coerced, because a size the CLI diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 38b92c5fa2..e7b9ad7524 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -16,6 +16,7 @@ import { NoWorkersToDeployError, WorkerBuildFailedError, WorkerBuildTimeoutError, + WorkerDockerfileMissingError, WorkerProjectNotFoundError, WorkersUnavailableError, WorkerSourceMissingError, @@ -175,6 +176,50 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); + // Configured `runtime = "dockerfile"` with nothing to build: the server can + // only report this after the context has uploaded and a build has started, so + // the CLI answers it from the directory it is already looking at. + it.live("refuses a Dockerfile worker with no Dockerfile, before uploading", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + }); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); + + return Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDockerfileMissingError); + expect((error as WorkerDockerfileMissingError).suggestion).toContain("config.toml"); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // The same worker with the file present deploys as a Dockerfile build, which + // is what keeps the guard above from being a blanket refusal. + it.live("deploys a Dockerfile worker that has one", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, + "supabase/workers/api/Dockerfile": "FROM scratch\n", + }); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes: routes({ + [`POST ${workersRoute("/api/deploy")}`]: { + status: 202, + body: { data: workerResource({ name: "api", buildState: "active" }) }, + }, + }), + }); + + return Effect.gen(function* () { + yield* push(); + + const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); + // No catalog runtime: the uploaded context carries its own Dockerfile. + expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBeUndefined(); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + it.live("guesses the runtime for a directory with no config entry and says so", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n`, diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 2cdfc97e35..d6be3d60c9 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -21,6 +21,24 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } +/** + * A worker is configured `runtime = "dockerfile"` but its source holds no + * top-level `Dockerfile`. + * + * Only reachable from a recorded runtime: when the runtime is guessed instead, + * the classifier picked `dockerfile` precisely because it found the file. The + * server has nothing to build without it, so refusing here costs the user a + * message instead of an upload, a deploy and a remote build failure. + */ +export class WorkerDockerfileMissingError extends Data.TaggedError("WorkerDockerfileMissingError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + /** * A symlink in the worker source points outside the build context. * From 9619d2ec81dcfc91d6f6edbd1607a44376e6103b Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 26 Aug 2026 19:12:45 -0300 Subject: [PATCH 17/17] Revert "fix(cli): refuse a Dockerfile worker with no Dockerfile" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8bac2f277. The guard bought a clearer message for one misconfiguration and cost a runtime-specific branch in a handler that had none, plus a second copy of the `"Dockerfile"` literal already held by the classifier's marker table. The refactor that would have justified it does not exist: markers are evidence for a guess, not requirements. `deno.json` and `package.json` are both optional — `workers new` scaffolds neither — so there is no shared "required source" contract to hoist the check into, and `dockerfile` would stay the lone special case however it were written. Deploying and letting the build report it is the honest cost of not modelling this yet. --- .../commands/workers/push/SIDE_EFFECTS.md | 1 - .../commands/workers/push/push.handler.ts | 27 ----------- .../workers/push/push.integration.test.ts | 45 ------------------- apps/cli/src/shared/workers/workers.errors.ts | 18 -------- 4 files changed, 91 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md index f76178266a..52e5ab4366 100644 --- a/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -43,7 +43,6 @@ | `1` | a worker's source is missing, not a directory, or empty | | `1` | a worker's source directory cannot be read | | `1` | a worker's source links to a path outside itself | -| `1` | a `dockerfile` worker's source holds no `Dockerfile` | | `1` | build context upload failed | | `1` | the build reached `failed`, or never left `building` | | `1` | API error, or project not enrolled in the alpha | diff --git a/apps/cli/src/legacy/commands/workers/push/push.handler.ts b/apps/cli/src/legacy/commands/workers/push/push.handler.ts index 274acf26e8..cc2678c66c 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -1,4 +1,3 @@ -import { join } from "node:path"; import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -38,7 +37,6 @@ import { UnknownWorkerRuntimeError, UnknownWorkerSizeError, WorkerBuildFailedError, - WorkerDockerfileMissingError, WorkerSourceMissingError, } from "../../../../shared/workers/workers.errors.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -257,31 +255,6 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { sourceDir: worker.sourceDir, }); - // A `dockerfile` worker ships its own build instructions, and the server has - // nothing to do without them. Checked before the archive is built rather than - // after the remote build fails — the CLI is already standing in the directory - // that either has the file or does not. A guessed `dockerfile` runtime always - // passes, since the classifier chose it by finding this exact file. - if (runtime === "dockerfile") { - const dockerfile = yield* fs.stat(join(worker.sourceDir, "Dockerfile")).pipe( - Effect.map(Option.some), - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") ? Effect.succeedNone : Effect.fail(error), - ), - ); - if (Option.isNone(dockerfile) || dockerfile.value.type !== "File") { - return yield* Effect.fail( - new WorkerDockerfileMissingError({ - detail: `${name} is configured to build its own Dockerfile, but there is no Dockerfile in ${sourceDisplay}.`, - suggestion: `Add a Dockerfile there, or set a catalog runtime under [workers.${name}] in ${displayPath( - project.projectRoot, - project.configPath, - )}.`, - }), - ); - } - } - // Size: whatever `new --size` recorded, else the alpha envelope's own // default. Never left unset, because a worker that is actually running always // has some concrete size — and never silently coerced, because a size the CLI diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index e7b9ad7524..38b92c5fa2 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -16,7 +16,6 @@ import { NoWorkersToDeployError, WorkerBuildFailedError, WorkerBuildTimeoutError, - WorkerDockerfileMissingError, WorkerProjectNotFoundError, WorkersUnavailableError, WorkerSourceMissingError, @@ -176,50 +175,6 @@ describe("legacy workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Configured `runtime = "dockerfile"` with nothing to build: the server can - // only report this after the context has uploaded and a build has started, so - // the CLI answers it from the directory it is already looking at. - it.live("refuses a Dockerfile worker with no Dockerfile, before uploading", () => { - const repo = project({ - "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, - }); - const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); - - return Effect.gen(function* () { - const error = yield* push().pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkerDockerfileMissingError); - expect((error as WorkerDockerfileMissingError).suggestion).toContain("config.toml"); - expect(http.requests).toHaveLength(0); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); - }); - - // The same worker with the file present deploys as a Dockerfile build, which - // is what keeps the guard above from being a blanket refusal. - it.live("deploys a Dockerfile worker that has one", () => { - const repo = project({ - "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "dockerfile"\n`, - "supabase/workers/api/Dockerfile": "FROM scratch\n", - }); - const { layer, http } = setupLegacyWorkers({ - workdir: repo.dir, - routes: routes({ - [`POST ${workersRoute("/api/deploy")}`]: { - status: 202, - body: { data: workerResource({ name: "api", buildState: "active" }) }, - }, - }), - }); - - return Effect.gen(function* () { - yield* push(); - - const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); - // No catalog runtime: the uploaded context carries its own Dockerfile. - expect(JSON.parse(deploy?.body ?? "{}").data.attributes.spec.runtime).toBeUndefined(); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); - }); - it.live("guesses the runtime for a directory with no config entry and says so", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n`, diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index d6be3d60c9..2cdfc97e35 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -21,24 +21,6 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } } -/** - * A worker is configured `runtime = "dockerfile"` but its source holds no - * top-level `Dockerfile`. - * - * Only reachable from a recorded runtime: when the runtime is guessed instead, - * the classifier picked `dockerfile` precisely because it found the file. The - * server has nothing to build without it, so refusing here costs the user a - * message instead of an upload, a deploy and a remote build failure. - */ -export class WorkerDockerfileMissingError extends Data.TaggedError("WorkerDockerfileMissingError")<{ - readonly detail: string; - readonly suggestion: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - /** * A symlink in the worker source points outside the build context. *