Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
},
);
Expand Down
52 changes: 51 additions & 1 deletion packages/stack/src/BinaryResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BinarySpec["service"], ReadonlyArray<ReadonlyArray<string>>>
> = {
postgres: [
["share/supabase-cli/bin/supabase-postgres-init.sh"],
["bin/pg_isready"],
["bin/postgres", "bin/postgres.exe"],
Comment thread
7ttp marked this conversation as resolved.
// 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"],
Comment thread
7ttp marked this conversation as resolved.
],
postgrest: [["postgrest", "postgrest.exe"]],
Comment thread
7ttp marked this conversation as resolved.
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
Expand Down Expand Up @@ -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,
Expand Down
199 changes: 171 additions & 28 deletions packages/stack/src/BinaryResolver.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
});
});
Loading