diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index 091645b63c..32de4a3237 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -1568,7 +1568,7 @@ describe("legacy sso update integration", () => { // The merge seeds from the reconciled host's GET response. const domains = (put?.body as { domains?: string[] })?.domains ?? []; expect([...domains].sort()).toEqual(["old1.com", "old2.com"]); - expect(api.requests.some((r) => r.url.startsWith("http://first.example"))).toBe(false); + expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); // The raw GET stitches identity through the shared per-command guard, // like Go's identityTransport on every Management API response. expect(testSetup.stitchedResponses).toBeGreaterThan(0); @@ -1879,7 +1879,7 @@ describe("legacy sso update integration", () => { const entitlements = api.requests.find((r) => r.url.includes("/entitlements")); expect(project?.url).toBe(`http://second.example/v1/projects/${LEGACY_VALID_REF}`); expect(entitlements?.url).toBe("http://second.example/v1/organizations/acme/entitlements"); - expect(api.requests.some((r) => r.url.startsWith("http://first.example"))).toBe(false); + expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); }, ); diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts index 15097c8d1a..c616e4b06b 100644 --- a/packages/stack/src/BinaryResolver.ts +++ b/packages/stack/src/BinaryResolver.ts @@ -77,6 +77,35 @@ const cachePath = (baseDir: string, info: AssetInfo): string => */ const CACHE_COMPLETE_MARKER = ".supabase-cache-complete"; +/** + * The paths each service's runner actually executes from a resolved directory + * (see `services/*.ts`), checked as an AND-of-ORs: every inner group must have + * at least one member present (alternates cover e.g. postgrest's Windows .zip + * carrying the .exe suffix). A markerless legacy cache entry is only trusted + * as a download-failure fallback when the full layout is present — mere + * non-emptiness would also accept a partial leftover from a killed + * pre-staging writer, and "resolving" one of those masks the DownloadError + * that lets the stack fall back to a Docker image instead of exec-ing a + * missing binary. + */ +const SERVICE_ENTRYPOINTS: Partial< + Record>> +> = { + postgres: [ + ["share/supabase-cli/bin/supabase-postgres-init.sh"], + ["bin/pg_isready"], + ["bin/postgres", "bin/postgres.exe"], + // The init service drives all provisioning through psql, and the server + // loads its shared libraries from lib/ (LD_/DYLD_LIBRARY_PATH in + // services/postgres.ts) — a cache missing either can't boot. + ["bin/psql", "bin/psql.exe"], + ["lib"], + ], + postgrest: [["postgrest", "postgrest.exe"]], + auth: [["auth"]], + "edge-runtime": [["bin/edge-runtime"]], +}; + /** * Age threshold for reaping abandoned `.tmp-*` staging siblings (see the * sweep in `resolveWithMetadata`). Generous on purpose: well beyond how long @@ -433,7 +462,28 @@ export class BinaryResolver extends Context.Service< ); return yield* attemptPublish(); - }).pipe(Effect.ensuring(cleanupTmpDir)); + }).pipe( + Effect.ensuring(cleanupTmpDir), + // A cache entry written by a pre-marker CLI release is non-empty + // but markerless, so it fails the completeness check above and + // lands here to be replaced. When the replacement cannot be + // fetched (offline, GitHub outage), that previously-working + // binary is strictly better than a hard failure — the same + // trade every pre-marker release already made on every resolve. + Effect.catchTag("DownloadError", (error) => { + const requirements = SERVICE_ENTRYPOINTS[spec.service]; + if (requirements === undefined) return Effect.fail(error); + return Effect.forEach(requirements, (alternatives) => + Effect.forEach(alternatives, (entry) => + fs.exists(path.join(cacheDir, entry)).pipe(Effect.mapError(() => error)), + ).pipe(Effect.map((found) => found.some(Boolean))), + ).pipe( + Effect.flatMap((groups) => + groups.every(Boolean) ? Effect.succeed(false) : Effect.fail(error), + ), + ); + }), + ); return { path: cacheDir, diff --git a/packages/stack/src/BinaryResolver.unit.test.ts b/packages/stack/src/BinaryResolver.unit.test.ts index 4d3cebd91b..3c9faa8feb 100644 --- a/packages/stack/src/BinaryResolver.unit.test.ts +++ b/packages/stack/src/BinaryResolver.unit.test.ts @@ -16,7 +16,7 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver, type BinarySpec } from "./BinaryResolver.ts"; import { DownloadError } from "./errors.ts"; -import { detectPlatform, postgrestAssetName } from "./Platform.ts"; +import { detectPlatform, postgresAssetName, postgrestAssetName } from "./Platform.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; const postgresVersion = DEFAULT_VERSIONS.postgres; @@ -450,6 +450,20 @@ describe("BinaryResolver.resolveWithMetadata concurrency", () => { ); }); +/** Resolves the real cacheDir a `postgres` spec would use on the host running the test. */ +const resolvePostgresCacheDir = Effect.gen(function* () { + const platform = yield* detectPlatform; + const assetName = postgresAssetName(platform); + if (assetName === null) { + return yield* Effect.die(`unsupported test platform: ${platform.os}-${platform.arch}`); + } + return BinaryResolver.cachePath("/cache-root/bin", { + service: "postgres", + version: postgresVersion, + assetName, + }); +}); + /** Resolves the real cacheDir a `postgrest` spec would use on the host running the test. */ const resolvePostgrestCacheDir = Effect.gen(function* () { const platform = yield* detectPlatform; @@ -711,36 +725,165 @@ describe("BinaryResolver.resolveWithMetadata cache completeness", () => { }, ); - it.live( - "does not destroy a markerless legacy cacheDir before a download attempt that then fails", - () => { - const fakeFs = createFakeCacheFs(); - const spawner = mockExtractingSpawner(fakeFs); - const httpLayer = mockOfflineHttpClient(); + it.live("falls back to a markerless legacy cacheDir when the replacement download fails", () => { + const fakeFs = createFakeCacheFs(); + const spawner = mockExtractingSpawner(fakeFs); + const httpLayer = mockOfflineHttpClient(); - const layer = BinaryResolver.make("/cache-root").pipe( - Layer.provide(fakeFs.layer), - Layer.provide(Path.layer), - Layer.provide(httpLayer), - Layer.provide(spawner.layer), - ); + const layer = BinaryResolver.make("/cache-root").pipe( + Layer.provide(fakeFs.layer), + Layer.provide(Path.layer), + Layer.provide(httpLayer), + Layer.provide(spawner.layer), + ); - return Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const spec: BinarySpec = { service: "postgrest", version: postgrestVersion }; - const cacheDir = yield* resolvePostgrestCacheDir; + return Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const spec: BinarySpec = { service: "postgrest", version: postgrestVersion }; + const cacheDir = yield* resolvePostgrestCacheDir; - // A markerless legacy cacheDir from before this resolver's staging - // model existed — still a perfectly usable binary on disk. - fakeFs.seedDirWithFile(cacheDir, "bin/postgrest"); + // A markerless legacy cacheDir from before this resolver's staging + // model existed — a binary that served every earlier release. When + // the replacement cannot be fetched, resolving to it beats failing. + fakeFs.seedDirWithFile(cacheDir, "postgrest"); - const error = yield* resolver.resolveWithMetadata(spec).pipe(Effect.flip); + const result = yield* resolver.resolveWithMetadata(spec); - expect(error).toBeInstanceOf(DownloadError); - // The legacy binary must survive an offline/failed download attempt - // — it must not be deleted before we know we can replace it. - expect(fakeFs.files.has(`${cacheDir}/bin/postgrest`)).toBe(true); - }).pipe(Effect.provide(layer)); - }, - ); + expect(result.path).toBe(cacheDir); + expect(result.downloaded).toBe(false); + expect(fakeFs.files.has(`${cacheDir}/postgrest`)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("accepts a Windows legacy cache whose executable carries the .exe suffix", () => { + const fakeFs = createFakeCacheFs(); + const spawner = mockExtractingSpawner(fakeFs); + const httpLayer = mockOfflineHttpClient(); + + const layer = BinaryResolver.make("/cache-root").pipe( + Layer.provide(fakeFs.layer), + Layer.provide(Path.layer), + Layer.provide(httpLayer), + Layer.provide(spawner.layer), + ); + + return Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const spec: BinarySpec = { service: "postgrest", version: postgrestVersion }; + const cacheDir = yield* resolvePostgrestCacheDir; + + fakeFs.seedDirWithFile(cacheDir, "postgrest.exe"); + + const result = yield* resolver.resolveWithMetadata(spec); + + expect(result.path).toBe(cacheDir); + expect(result.downloaded).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a postgres legacy cache with the init script but no bin payload", () => { + // The init script alone cannot run postgres — the health check invokes + // bin/pg_isready and the script needs the server binaries. A partial + // extraction stopping after share/ must not suppress the Docker fallback. + const fakeFs = createFakeCacheFs(); + const spawner = mockExtractingSpawner(fakeFs); + const httpLayer = mockOfflineHttpClient(); + + const layer = BinaryResolver.make("/cache-root").pipe( + Layer.provide(fakeFs.layer), + Layer.provide(Path.layer), + Layer.provide(httpLayer), + Layer.provide(spawner.layer), + ); + + return Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const spec: BinarySpec = { service: "postgres", version: postgresVersion }; + const cacheDir = yield* resolvePostgresCacheDir; + + fakeFs.seedDirWithFile(cacheDir, "share/supabase-cli/bin/supabase-postgres-init.sh"); + + const error = yield* resolver.resolveWithMetadata(spec).pipe(Effect.flip); + expect(error).toBeInstanceOf(DownloadError); + }).pipe(Effect.provide(layer)); + }); + + it.live("accepts a postgres legacy cache carrying the full expected layout", () => { + const fakeFs = createFakeCacheFs(); + const spawner = mockExtractingSpawner(fakeFs); + const httpLayer = mockOfflineHttpClient(); + + const layer = BinaryResolver.make("/cache-root").pipe( + Layer.provide(fakeFs.layer), + Layer.provide(Path.layer), + Layer.provide(httpLayer), + Layer.provide(spawner.layer), + ); + + return Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const spec: BinarySpec = { service: "postgres", version: postgresVersion }; + const cacheDir = yield* resolvePostgresCacheDir; + + fakeFs.seedDirWithFile(cacheDir, "share/supabase-cli/bin/supabase-postgres-init.sh"); + fakeFs.seedDirWithFile(cacheDir, "bin/pg_isready"); + fakeFs.seedDirWithFile(cacheDir, "bin/postgres"); + fakeFs.seedDirWithFile(cacheDir, "bin/psql"); + fakeFs.seedDirWithFile(cacheDir, "lib/libpq.dylib"); + + const result = yield* resolver.resolveWithMetadata(spec); + expect(result.path).toBe(cacheDir); + expect(result.downloaded).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a partial markerless leftover that lacks the service entrypoint", () => { + // A pre-staging writer killed mid-extraction leaves a non-empty dir with + // no executable. Resolving it would mask the DownloadError that lets the + // stack fall back to a Docker image — so non-emptiness is not enough. + const fakeFs = createFakeCacheFs(); + const spawner = mockExtractingSpawner(fakeFs); + const httpLayer = mockOfflineHttpClient(); + + const layer = BinaryResolver.make("/cache-root").pipe( + Layer.provide(fakeFs.layer), + Layer.provide(Path.layer), + Layer.provide(httpLayer), + Layer.provide(spawner.layer), + ); + + return Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const spec: BinarySpec = { service: "postgrest", version: postgrestVersion }; + const cacheDir = yield* resolvePostgrestCacheDir; + + fakeFs.seedDirWithFile(cacheDir, "_download-interrupted.tar"); + + const error = yield* resolver.resolveWithMetadata(spec).pipe(Effect.flip); + + expect(error).toBeInstanceOf(DownloadError); + }).pipe(Effect.provide(layer)); + }); + + it.live("still fails offline when no legacy cache entry exists to fall back to", () => { + const fakeFs = createFakeCacheFs(); + const spawner = mockExtractingSpawner(fakeFs); + const httpLayer = mockOfflineHttpClient(); + + const layer = BinaryResolver.make("/cache-root").pipe( + Layer.provide(fakeFs.layer), + Layer.provide(Path.layer), + Layer.provide(httpLayer), + Layer.provide(spawner.layer), + ); + + return Effect.gen(function* () { + const resolver = yield* BinaryResolver; + const spec: BinarySpec = { service: "postgrest", version: postgrestVersion }; + + const error = yield* resolver.resolveWithMetadata(spec).pipe(Effect.flip); + + expect(error).toBeInstanceOf(DownloadError); + }).pipe(Effect.provide(layer)); + }); });