diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 897b422bb9..5bab69030c 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -747,7 +747,8 @@ const errorMessage = (cause: unknown): string => * agent sandboxes that allow `*.amazonaws.com`. * @see ../../../infra/cli-artifacts/README.md */ -const SLIM_NATIVE_SUPABASE_S3_MIRROR = "https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com"; +export const SLIM_NATIVE_SUPABASE_S3_MIRROR = + "https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com"; /** ECR Public mirror of every slim image, under the same tags and digests. */ export const SLIM_IMAGE_SUPABASE_ECR_MIRROR = "public.ecr.aws/supabase/cli/"; diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index caba459117..f66d48bf3f 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -23,6 +23,7 @@ import { import { HttpClient } from "effect/http"; import { createHash } from "node:crypto"; import { createServer } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw release fixture server. +import { SLIM_NATIVE_SUPABASE_S3_MIRROR } from "../Artifacts.ts"; import { DEFAULT_POSTGRES_ROOT_KEY } from "../Defaults.ts"; import { makeStandaloneService } from "../../tests/standalone-service.ts"; import { databaseIdentity, recordDatabaseIdentity } from "../internal/database-reuse.ts"; @@ -374,14 +375,23 @@ describe("database component", { timeout: 180_000 }, () => { // The next revision is the published archive under a manifest that names the new revision. const targets = ["darwin-arm64", "linux-amd64", "linux-arm64"] as const; const manifestJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); - const client = HttpClient.followRedirects(yield* HttpClient.HttpClient); + const client = HttpClient.filterStatusOk( + HttpClient.followRedirects(yield* HttpClient.HttpClient), + ); const manifests = new Map(); const manifestPins = new Map(); for (const target of targets) { - const upstream = yield* client.get( - `${upstreamReleases}${assetPath(published.revision, target, "manifest.json")}`, + const publishedVersion = releaseVersion(published.revision); + const body = yield* Effect.firstSuccessOf( + [ + `${upstreamReleases}${assetPath(published.revision, target, "manifest.json")}`, + `${SLIM_NATIVE_SUPABASE_S3_MIRROR}/postgres/${publishedVersion}/postgres-${publishedVersion}-${target}.manifest.json`, + ].map((url) => client.get(url).pipe(Effect.flatMap((response) => response.text))), + ); + expect(createHash("sha256").update(body).digest("hex")).toBe( + published.natives[target].manifest, ); - const manifest = yield* Schema.decodeEffect(manifestJson)(yield* upstream.text); + const manifest = yield* Schema.decodeEffect(manifestJson)(body); const text = yield* Schema.encodeEffect(manifestJson)({ ...manifest, version: releaseVersion(nextRevision), diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 23ede1c3ec..b594c1d395 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -1,5 +1,5 @@ import { NodeCrypto, NodeHttpClient, NodePath, NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; +import { beforeAll, describe, expect, it } from "@effect/vitest"; import { Cause, Deferred, @@ -16,6 +16,7 @@ import { import { Digest, SecureRandom } from "effect/crypto"; import { HttpClient, HttpClientRequest } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { prepareNativeArtifact } from "../Artifacts.ts"; import type { FunctionsBootstrapOwner } from "../functions/FunctionsBootstrap.ts"; import { ContainerError, type ContainerRuntime } from "../runtime/Container.ts"; import { makeFakeNpmRegistry } from "../../tests/fake-npm-registry.ts"; @@ -154,61 +155,77 @@ describe("service catalog", () => { { timeout: 120_000 }, ); - it.live("destroy survives a symlink planted where its runtime directory used to be", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "functions-symlink-" }); - const stackId = "c".repeat(64); - const instanceId = "functions-symlink-instance"; - const callerRoot = yield* fs.makeTempDirectoryScoped({ - prefix: "functions-symlink-caller-", - }); - const functionsRoot = `${callerRoot}/user-functions`; - yield* fs.makeDirectory(functionsRoot, { recursive: true }); - yield* fs.writeFileString(`${functionsRoot}/index.ts`, "export default {};"); - const recipe = yield* makeServiceRecipe( - { - service: "functions", - pin: selection.functions, - config: { functionsRoot, databaseUrl: "postgres://functions-db" }, - }, - { - ...options(root), - stackId, - instanceId, - cacheRoot: testArtifactCacheRoot, - }, - ); - const config = recipe.creation; - yield* recipe.definition.prepare?.(config) ?? Effect.void; - const instanceRoot = path.join(root, instanceId); - const runtimeDir = path.join(instanceRoot, "runtime"); - expect(yield* fs.exists(runtimeDir)).toBe(true); - const outside = yield* fs.makeTempDirectoryScoped({ - prefix: "functions-symlink-outside-", - }); - // removeData reaches for `/functions` specifically, not the whole runtime - // directory; the sentinel sits there so this test proves that previously endangered - // data survives, not just the symlink target's top level. - const sentinel = path.join(outside, "functions", "sentinel"); - yield* fs.makeDirectory(path.join(outside, "functions"), { recursive: true }); - yield* fs.writeFileString(sentinel, "do not remove me"); - // The planted attack: the recipe's own runtime directory replaced by a symlink. - yield* fs.remove(runtimeDir, { recursive: true, force: true }); - yield* fs.symlink(outside, runtimeDir); - const scope = yield* Scope.make(); - yield* recipe.definition.removeData({ id: instanceId, config, scope }); - yield* Scope.close(scope, Exit.void); - expect(yield* fs.exists(sentinel)).toBe(true); - }), - ).pipe( - Effect.provide( - Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NodeHttpClient.layerNodeHttp), + describe("with a warm Edge Runtime artifact", () => { + // A cold download can outlast the test timeout, so it gets its own guard here. + beforeAll( + () => + prepareNativeArtifact( + { service: "functions", pin: selection.functions }, + testArtifactCacheRoot, + ).pipe( + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NodeHttpClient.layerNodeHttp), + ), + ), + 120_000, + ); + + it.live("destroy survives a symlink planted where its runtime directory used to be", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "functions-symlink-" }); + const stackId = "c".repeat(64); + const instanceId = "functions-symlink-instance"; + const callerRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "functions-symlink-caller-", + }); + const functionsRoot = `${callerRoot}/user-functions`; + yield* fs.makeDirectory(functionsRoot, { recursive: true }); + yield* fs.writeFileString(`${functionsRoot}/index.ts`, "export default {};"); + const recipe = yield* makeServiceRecipe( + { + service: "functions", + pin: selection.functions, + config: { functionsRoot, databaseUrl: "postgres://functions-db" }, + }, + { + ...options(root), + stackId, + instanceId, + cacheRoot: testArtifactCacheRoot, + }, + ); + const config = recipe.creation; + yield* recipe.definition.prepare?.(config) ?? Effect.void; + const instanceRoot = path.join(root, instanceId); + const runtimeDir = path.join(instanceRoot, "runtime"); + expect(yield* fs.exists(runtimeDir)).toBe(true); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "functions-symlink-outside-", + }); + // removeData reaches for `/functions` specifically, not the whole runtime + // directory; the sentinel sits there so this test proves that previously endangered + // data survives, not just the symlink target's top level. + const sentinel = path.join(outside, "functions", "sentinel"); + yield* fs.makeDirectory(path.join(outside, "functions"), { recursive: true }); + yield* fs.writeFileString(sentinel, "do not remove me"); + // The planted attack: the recipe's own runtime directory replaced by a symlink. + yield* fs.remove(runtimeDir, { recursive: true, force: true }); + yield* fs.symlink(outside, runtimeDir); + const scope = yield* Scope.make(); + yield* recipe.definition.removeData({ id: instanceId, config, scope }); + yield* Scope.close(scope, Exit.void); + expect(yield* fs.exists(sentinel)).toBe(true); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NodeHttpClient.layerNodeHttp), + ), ), - ), - ); + ); + }); const ancestors = [ {