From 71d4a0aaf6d436996a0b0d5ef3428cdfe22d0d32 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:02:39 -0300 Subject: [PATCH 1/2] 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 the workers root and the `[workers.]` entries, so one with a `source` pointing outside that root 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. Polling is a `Schedule`, and tolerates a few consecutive read failures so one blip does not throw away a deploy that is progressing. 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, reported on stderr with a nudge to pin it down. An empty source directory is refused rather than deployed as an image with nothing in it. 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. 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. --- .../commands/workers/push/SIDE_EFFECTS.md | 57 ++ .../commands/workers/push/push.command.ts | 52 ++ .../commands/workers/push/push.handler.ts | 335 ++++++++++++ .../workers/push/push.integration.test.ts | 486 ++++++++++++++++++ .../commands/workers/workers.command.ts | 3 +- .../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-package.ts | 115 +++++ .../workers/worker-package.unit.test.ts | 113 ++++ apps/cli/src/shared/workers/worker-url.ts | 17 + apps/cli/src/shared/workers/workers-api.ts | 362 +++++++++++++ apps/cli/src/shared/workers/workers.errors.ts | 87 ++++ 14 files changed, 2015 insertions(+), 1 deletion(-) 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/commands/workers/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md new file mode 100644 index 0000000000..11e38b5b4b --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/SIDE_EFFECTS.md @@ -0,0 +1,57 @@ +# `supabase workers push [name...] (alias: deploy)` + +> **TS-only command.** `supabase workers` has no Go counterpart — there is no +> `apps/cli-go/internal/workers` to match, and nothing is proxied. See +> `docs/go-cli-divergences.md`. + +## Files Read + +| Path | Format | When | +| -------------------------------- | ------ | ----------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for each worker's runtime, size, source | +| `/**` | any | always — packaged into the build context | + +## Files Written + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +## 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` 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) | + +## 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. `workers` has no Go counterpart, so there is no +`phtelemetry.*` call to reproduce. 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..4b47ed41a9 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.command.ts @@ -0,0 +1,52 @@ +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( + Flag.withDescription("Number of instances to run."), + Flag.withDefault(1), + ), + 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..3ec4fb4b52 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.handler.ts @@ -0,0 +1,335 @@ +import { Effect, FileSystem, type Schedule } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersGoOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.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_SIZE, + exposureFor, + 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; +}); + +const deployOneWorker = Effect.fnUntraced(function* (input: { + readonly project: LegacyWorkersProject; + readonly name: string; + readonly projectRef: string; + readonly instances: number; + readonly pollSchedule?: 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 = legacyDescribeWorker(project, name); + + const runtime = yield* resolveRuntime({ + name, + recorded: worker.entry?.runtime, + sourceDir: worker.sourceDir, + }); + + const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); + + { + 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\`.`, + }), + ); + } + } + + // 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 }); + + let contextUploadId: string; + { + const packaging = yield* output.task(`Packaging ${sourceDisplay}...`); + const packaged = yield* packageWorkerDirectory(worker.sourceDir).pipe( + Effect.tapError(() => packaging.fail()), + ); + yield* packaging.succeed( + `Packaged ${sourceDisplay} (${packaged.fileCount} files, ${formatBytes( + packaged.archive.length, + )}).`, + ); + + const uploading = yield* output.task("Uploading the 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.succeed("Uploaded the build context."); + 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), + exposure: exposureFor(runtime), + instances: input.instances, + }; + + const deploying = yield* output.task(`Deploying "${name}"...`); + yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( + Effect.tapError(() => deploying.fail()), + ); + + const settled = yield* awaitWorkerBuild(api, projectRef, name, { + schedule: input.pollSchedule, + onPoll: (polled) => + polled.buildState === "building" ? deploying.message(`Building "${name}"...`) : Effect.void, + }).pipe(Effect.tapError(() => deploying.fail())); + + if (settled.buildState === "failed") { + yield* deploying.fail(`Deploying "${name}" failed.`); + 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.succeed(`Deployed "${name}".`); + + 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) { + yield* output.raw( + renderWorkerDetails([ + ["runtime", runtime], + ["size", formatApiSize(settled.spec.size)], + ...(settled.imageVersion === undefined + ? [] + : ([["image", settled.imageVersion]] as Array)), + url === undefined ? ["access", "private (no HTTP endpoint)"] : ["url", url], + ]), + ); + } + + return { + worker_name: name, + runtime, + size: settled.spec.size, + exposure: settled.spec.exposure, + instances: settled.spec.instances, + 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 } = {}, +) { + const output = yield* Output; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const project = yield* legacyLoadWorkersProject(); + const projectRef = yield* resolver.resolve(flags.projectRef); + + // Go writes the linked-project cache and flushes telemetry in + // `PersistentPostRun`, so both happen whether the command succeeds or fails. + yield* Effect.gen(function* () { + 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.rootDir, + )}.`, + suggestion: "Scaffold one with `supabase workers new `.", + }), + ); + } + + const names = [...new Set(requested)]; + + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const deployed: Array> = []; + for (const name of names) { + if (names.length > 1 && !machineOutput) { + yield* output.raw(`\n${name}\n`); + } + deployed.push( + yield* deployOneWorker({ + project, + name, + projectRef, + instances: flags.instances, + machineOutput, + ...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }), + }), + ); + } + + 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* legacyEmitWorkersGoOutput(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..fad065aa0f --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -0,0 +1,486 @@ +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, + 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: 1, + 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, + }; +} + +function push(flagOverrides: Partial = {}) { + return legacyWorkersPush(flags(flagOverrides), { pollSchedule: 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("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: 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("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(error._tag).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))); + }); + + 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: { 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("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("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))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index 51efd2b354..ac8defa4d2 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/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index e182e4f9ff..1e0cafbfbe 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-package.ts b/apps/cli/src/shared/workers/worker-package.ts new file mode 100644 index 0000000000..d1e0f23517 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -0,0 +1,115 @@ +import { gzipSync } from "node:zlib"; +import { Effect, FileSystem } from "effect"; +import { createTar, type TarEntry } 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; +} + +const collectEntries = ( + root: string, + relativeDir: string, +): Effect.Effect, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const absoluteDir = relativeDir === "" ? root : `${root}/${relativeDir}`; + + const names = yield* fs + .readDirectory(absoluteDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + 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).pipe(Effect.option); + if (info._tag === "None") { + continue; + } + + const modified = info.value.mtime; + const mtime = modified._tag === "Some" ? Math.floor(modified.value.getTime() / 1000) : 0; + + if (info.value.type === "Directory") { + entries.push({ path: `${relativePath}/`, contents: new Uint8Array(0), mode: 0o755, mtime }); + entries.push(...(yield* collectEntries(root, relativePath))); + continue; + } + + if (info.value.type !== "File") { + // Sockets, FIFOs and devices have nothing meaningful to send. + continue; + } + + const contents = yield* fs + .readFile(absolutePath) + .pipe(Effect.orElseSucceed(() => new Uint8Array(0))); + // 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. + const executable = (Number(info.value.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, ""); + const archive = gzipSync(createTar(entries)); + + 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..5e488621f6 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, mkdtempSync, 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"; + +/** 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); + }); +}); + +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-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..7ac1a0fa98 --- /dev/null +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -0,0 +1,362 @@ +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, + 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."; + +/** + * 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( + new WorkersUnavailableError({ + detail: `Workers are not available for project ${projectRef}.`, + suggestion: workersSuggestion, + }), + ); + } + 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 on the plain HTTP client with no + * Supabase credentials attached — the signature in the URL is the authorization. + */ +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( + new WorkersUnavailableError({ + detail: `Workers are not available for project ${projectRef}.`, + suggestion: workersSuggestion, + }), + ); + } + 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" }), +); + +export const awaitWorkerBuild = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, + options: { + readonly schedule?: 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. A few immediate retries absorb + // that; anything that keeps failing is reported as the real error rather + // than silently waited out until the timeout. + const worker = yield* getWorker(api, projectRef, name).pipe( + Effect.retry({ schedule: Schedule.recurs(2) }), + ); + 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 3ee033f0d5..fe4e3cea89 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -21,6 +21,16 @@ 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; + } +} + export class UnknownWorkerRuntimeError extends Data.TaggedError("UnknownWorkerRuntimeError")<{ readonly detail: string; readonly suggestion: string; @@ -48,6 +58,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 a directory `--force` will delete outright, so a @@ -72,3 +91,71 @@ export class InvalidWorkersRootError extends Data.TaggedError("InvalidWorkersRoo return actionability.invalidConfig; } } + +/** 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; + } +} + +/** Any other status the Workers routes answered with. */ +export class WorkersApiUnexpectedStatusError extends Data.TaggedError( + "WorkersApiUnexpectedStatusError", +)<{ + readonly detail: string; + readonly suggestion: string; + readonly status: number; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} From aa0ea2704695251bec2c9e47c4853c41e28d43ae Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 19 Aug 2026 00:06:25 -0300 Subject: [PATCH 2/2] fix(cli): stop reporting a missing project as an alpha enrolment gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Workers routes answer 404 both for a project outside the alpha's allow-list and for a project ref that names nothing this account can see, and the CLI read every one of them as the former. A mistyped `--project-ref` was answered with "Workers are in private alpha. Ask in the Supabase dashboard to have this project enrolled." — sending someone to request enrolment for a project that does not exist, and never mentioning the ref. The two are distinguishable in the body: 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"}} so classify on `error.code` and raise the new WorkerProjectNotFoundError for `not_found`, pointing at the ref, `supabase link` and `supabase login`. Only that exact code is treated as a missing project; an unrecognized body keeps the enrolment answer, since that is what the allow-list has historically returned and guessing the other way would send someone to check a ref that is fine. The existing coverage asserted against a `{message}` body the API does not send, so it is retargeted at the real shapes. --- .../workers/push/push.integration.test.ts | 48 +++++++++++++++- apps/cli/src/shared/workers/workers-api.ts | 57 +++++++++++++++++-- apps/cli/src/shared/workers/workers.errors.ts | 16 ++++++ 3 files changed, 114 insertions(+), 7 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 fad065aa0f..56a163e813 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 @@ -14,6 +14,7 @@ import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref. import { NoWorkersToDeployError, WorkerBuildFailedError, + WorkerProjectNotFoundError, WorkersUnavailableError, WorkerSourceMissingError, WorkerUploadFailedError, @@ -284,6 +285,9 @@ describe("legacy workers push", () => { }).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({ @@ -291,7 +295,12 @@ describe("legacy workers push", () => { routes: routes({ [`POST ${workersRoute("/api/uploads")}`]: { status: 404, - body: { message: "Workers are not available for this project" }, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, }, }), }); @@ -304,6 +313,43 @@ describe("legacy workers push", () => { }).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 }); diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index 7ac1a0fa98..eb40a60542 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -14,6 +14,7 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { WorkerBuildTimeoutError, WorkersApiNetworkError, + WorkerProjectNotFoundError, WorkersApiUnexpectedStatusError, WorkersUnavailableError, WorkerUploadFailedError, @@ -90,6 +91,50 @@ function toWorkerRecord(data: WorkerResourceData): WorkerRecord { 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. @@ -188,9 +233,9 @@ export const createWorkerUpload = Effect.fnUntraced(function* ( if (response.status === 404) { return yield* Effect.fail( - new WorkersUnavailableError({ - detail: `Workers are not available for project ${projectRef}.`, - suggestion: workersSuggestion, + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), }), ); } @@ -282,9 +327,9 @@ export const deployWorker = Effect.fnUntraced(function* ( if (response.status === 404) { return yield* Effect.fail( - new WorkersUnavailableError({ - detail: `Workers are not available for project ${projectRef}.`, - suggestion: workersSuggestion, + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), }), ); } diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index fe4e3cea89..c0cbe967c9 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -147,6 +147,22 @@ export class WorkersUnavailableError extends Data.TaggedError("WorkersUnavailabl } } +/** + * 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. */ export class WorkersApiUnexpectedStatusError extends Data.TaggedError( "WorkersApiUnexpectedStatusError",