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
2 changes: 1 addition & 1 deletion apps/cli-e2e/src/tests/stack.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe("services", () => {
expect(result.stdout).toContain("storage");
});

testParity(["services"]);
testParity(["services"], { normalizeVersions: false });
});

// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions apps/cli-e2e/src/tests/test-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function testParity(
failureType?: FailureType;
workspaceSetup?: (dir: string) => void;
sortStdoutRows?: boolean;
normalizeVersions?: boolean;
},
): void {
const label = opts?.failureType
Expand All @@ -169,6 +170,7 @@ export function testParity(
accessToken: ACCESS_TOKEN,
workspaceSetup: opts?.workspaceSetup,
sortStdoutRows: opts?.sortStdoutRows,
normalize: { versions: opts?.normalizeVersions },
},
cmd,
);
Expand Down
6 changes: 3 additions & 3 deletions apps/cli-go/pkg/config/templates/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ FROM library/kong:2.8.1 AS kong
FROM axllent/mailpit:v1.22.3 AS mailpit
FROM postgrest/postgrest:v14.13 AS postgrest
FROM supabase/postgres-meta:v0.96.6 AS pgmeta
FROM supabase/studio:2026.06.03-sha-0bca601 AS studio
FROM supabase/studio:2026.06.08-sha-8af2bb0 AS studio
FROM darthsim/imgproxy:v3.8.0 AS imgproxy
FROM supabase/edge-runtime:v1.74.0 AS edgeruntime
FROM timberio/vector:0.53.0-alpine AS vector
FROM supabase/supavisor:2.9.7 AS supavisor
FROM supabase/gotrue:v2.189.0 AS gotrue
FROM supabase/realtime:v2.103.4 AS realtime
FROM supabase/storage-api:v1.60.8 AS storage
FROM supabase/realtime:v2.105.0 AS realtime
FROM supabase/storage-api:v1.60.11 AS storage
FROM supabase/logflare:1.43.4 AS logflare
# Append to JobImages when adding new dependencies below
FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ declare module "*.md" {
const content: string;
export default content;
}

declare module "*Dockerfile" {
const content: string;
export default content;
}
100 changes: 73 additions & 27 deletions apps/cli/src/shared/services/services.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { makeApiClient, type ApiClient } from "@supabase/api/effect";
import { Data, Duration, Effect, Exit, Redacted } from "effect";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" };
import { renderGlamourTable } from "../../legacy/output/legacy-glamour-table.ts";

export type RemoteServiceName = "postgres" | "auth" | "postgrest" | "storage";
Expand All @@ -19,28 +20,73 @@ interface ServiceImageSpec {
readonly remoteService: RemoteServiceName | undefined;
}

// Mirrors the legacy `services` image matrix:
// - source versions: `apps/cli-go/pkg/config/templates/Dockerfile`
// - source order: `apps/cli-go/pkg/config/config.go` `GetServiceImages()`
//
// We keep this compiled into the TS CLI because the published package does not
// ship the Go source tree at runtime, but the user-visible `services` output
// still needs to match the bundled image manifest.
const LOCAL_SERVICE_IMAGES = [
{ image: "supabase/postgres:17.6.1.132", remoteService: "postgres" },
{ image: "supabase/gotrue:v2.189.0", remoteService: "auth" },
{ image: "postgrest/postgrest:v14.12", remoteService: "postgrest" },
{ image: "supabase/realtime:v2.103.2", remoteService: undefined },
{ image: "supabase/storage-api:v1.60.4", remoteService: "storage" },
{ image: "supabase/edge-runtime:v1.74.0", remoteService: undefined },
{
image: "supabase/studio:2026.06.03-sha-0bca601",
remoteService: undefined,
},
{ image: "supabase/postgres-meta:v0.96.6", remoteService: undefined },
{ image: "supabase/logflare:1.43.3", remoteService: undefined },
{ image: "supabase/supavisor:2.9.7", remoteService: undefined },
] as const satisfies ReadonlyArray<ServiceImageSpec>;
interface DockerfileImageSpec {
readonly alias: string;
readonly image: string;
}

interface ServiceImageAliasSpec {
readonly alias: string;
readonly remoteService: RemoteServiceName | undefined;
}

const SERVICE_IMAGE_ALIASES: ReadonlyArray<ServiceImageAliasSpec> = [
{ alias: "pg", remoteService: "postgres" },
{ alias: "gotrue", remoteService: "auth" },
{ alias: "postgrest", remoteService: "postgrest" },
{ alias: "realtime", remoteService: undefined },
{ alias: "storage", remoteService: "storage" },
{ alias: "edgeruntime", remoteService: undefined },
{ alias: "studio", remoteService: undefined },
{ alias: "pgmeta", remoteService: undefined },
{ alias: "logflare", remoteService: undefined },
{ alias: "supavisor", remoteService: undefined },
];

const FROM_LINE_PATTERN = /^FROM\s+(.+):([^:\s]+)\s+AS\s+([^\s#]+)/i;

export function parseDockerfileServiceImages(
dockerfile: string,
): ReadonlyArray<DockerfileImageSpec> {
return dockerfile
.split("\n")
.map((line) => line.trim())
.flatMap((line) => {
const match = FROM_LINE_PATTERN.exec(line);
if (match === null) {
return [];
}

const [, repository, tag, alias] = match;
if (repository === undefined || tag === undefined || alias === undefined) {
return [];
}

return [{ alias, image: `${repository}:${tag}` }];
});
}

export function localServiceImagesFromDockerfile(
dockerfile: string,
): ReadonlyArray<ServiceImageSpec> {
const imagesByAlias = new Map(
parseDockerfileServiceImages(dockerfile).map((service) => [service.alias, service.image]),
);

return SERVICE_IMAGE_ALIASES.map((service) => {
const image = imagesByAlias.get(service.alias);
if (image === undefined) {
throw new Error(`Missing service image alias '${service.alias}' in Dockerfile manifest.`);
}

return {
image,
remoteService: service.remoteService,
};
});
}

const LOCAL_SERVICE_IMAGES = localServiceImagesFromDockerfile(serviceImagesDockerfile);

const TABLE_HEADERS = ["SERVICE IMAGE", "LOCAL", "LINKED"] as const;

Expand All @@ -61,14 +107,14 @@ function toServiceVersionRow(
service: ServiceImageSpec,
remote: Partial<Record<RemoteServiceName, string>> = {},
): ServiceVersionRow {
const parts = service.image.split(":");
const name = parts[0];
const local = parts[1];

if (name === undefined || local === undefined) {
const tagSeparator = service.image.lastIndexOf(":");
if (tagSeparator === -1) {
throw new Error(`Invalid service image entry: ${service.image}`);
}

const name = service.image.slice(0, tagSeparator);
const local = service.image.slice(tagSeparator + 1);

return {
name,
local,
Expand Down
51 changes: 51 additions & 0 deletions apps/cli/src/shared/services/services.shared.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, expect, test } from "vitest";
import { Effect, Redacted } from "effect";
import { FetchHttpClient } from "effect/unstable/http";
import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" };
import {
fetchLinkedServiceVersions,
listLocalServiceVersions,
localServiceImagesFromDockerfile,
parseDockerfileServiceImages,
renderServicesTable,
renderServicesWarning,
} from "./services.shared.ts";
Expand All @@ -17,6 +20,54 @@ const runLinkedFetch = (input: Parameters<typeof fetchLinkedServiceVersions>[0])
Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)));

describe("services shared", () => {
test("parses service images from Dockerfile FROM aliases", () => {
expect(
parseDockerfileServiceImages(`
# comment
FROM supabase/postgres:17.6.1.132 AS pg

RUN echo ignored
FROM localhost:5000/custom/image:1.2.3 AS custom
`),
).toEqual([
{ alias: "pg", image: "supabase/postgres:17.6.1.132" },
{ alias: "custom", image: "localhost:5000/custom/image:1.2.3" },
]);
});

test("fails clearly when the Dockerfile manifest misses a required service alias", () => {
expect(() =>
localServiceImagesFromDockerfile("FROM supabase/postgres:17.6.1.132 AS pg\n"),
).toThrow("Missing service image alias 'gotrue' in Dockerfile manifest.");
});

test("derives local service versions from the Go Dockerfile manifest", () => {
const rows = listLocalServiceVersions();
const dockerfileImages = localServiceImagesFromDockerfile(serviceImagesDockerfile);
const expectedRows = dockerfileImages.map((service) => {
const tagSeparator = service.image.lastIndexOf(":");
return {
name: service.image.slice(0, tagSeparator),
local: service.image.slice(tagSeparator + 1),
remote: "",
};
});

expect(rows).toEqual(expectedRows);
expect(rows.map((row) => row.name)).toEqual([
"supabase/postgres",
"supabase/gotrue",
"postgrest/postgrest",
"supabase/realtime",
"supabase/storage-api",
"supabase/edge-runtime",
"supabase/studio",
"supabase/postgres-meta",
"supabase/logflare",
"supabase/supavisor",
]);
});

test("returns postgres only when no service-role key is available", async () => {
const server = Bun.serve({
port: 0,
Expand Down
19 changes: 19 additions & 0 deletions apps/cli/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { readFileSync } from "node:fs";
import { defineConfig } from "vitest/config";

function dockerfileTextPlugin() {
return {
name: "dockerfile-text-loader",
load(id: string) {
const [filePath] = id.split("?", 2);
if (filePath?.endsWith("/Dockerfile") !== true) {
return undefined;
}

return `export default ${JSON.stringify(readFileSync(filePath, "utf8"))};`;
},
};
}

export default defineConfig({
plugins: [dockerfileTextPlugin()],
test: {
passWithNoTests: true,
coverage: {
Expand All @@ -24,18 +40,21 @@ export default defineConfig({
},
projects: [
{
plugins: [dockerfileTextPlugin()],
test: {
name: "unit",
include: ["**/*.unit.test.ts"],
},
},
{
plugins: [dockerfileTextPlugin()],
test: {
name: "integration",
include: ["**/*.integration.test.ts"],
},
},
{
plugins: [dockerfileTextPlugin()],
test: {
name: "e2e",
include: ["**/*.e2e.test.ts"],
Expand Down
30 changes: 21 additions & 9 deletions packages/cli-test-helpers/src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,33 @@ export function sortTableRows(output: string): string {
return result.join("\n");
}

export interface NormalizeOptions {
readonly versions?: boolean;
}

/**
* Normalize CLI output by stripping non-deterministic content before parity
* comparisons. Applied to both Go and ts-legacy output so spurious differences
* in timestamps, versions, paths, and stack traces don't produce false failures.
*/
export function normalize(output: string): string {
export function normalize(output: string, options: NormalizeOptions = {}): string {
const normalizeVersions = options.versions ?? true;
const withoutAnsi = output
// 1. Strip ANSI escape codes (color, bold, reset, etc.) — \u001b is ESC
// eslint-disable-next-line no-control-regex
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, "");
const withoutVersions = normalizeVersions
? withoutAnsi.replace(
// 2. Semantic version strings (e.g. 1.187.0, v2.0.0-rc.1, v14.13).
// Lookbehind prevents matching mid-IP-address (e.g. 0.0.1 inside 127.0.0.1).
// Lookahead prevents matching where more dotted-number segments follow.
/(?<![.\d])\bv?\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?\b(?!\.)/g,
"<VERSION>",
)
: withoutAnsi;

return (
output
// 1. Strip ANSI escape codes (color, bold, reset, etc.) — \u001b is ESC
// eslint-disable-next-line no-control-regex
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, "")
// 2. Semantic version strings (e.g. 1.187.0, v2.0.0-rc.1, v14.13).
// Lookbehind prevents matching mid-IP-address (e.g. 0.0.1 inside 127.0.0.1).
// Lookahead prevents matching where more dotted-number segments follow.
.replace(/(?<![.\d])\bv?\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?\b(?!\.)/g, "<VERSION>")
withoutVersions
// 3. ISO-8601 timestamps (2026-04-15T10:46:15Z or with milliseconds)
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, "<TIMESTAMP>")
// 4. Display timestamps (2026-04-15 10:46:15 — space-separated, no T)
Expand Down
6 changes: 6 additions & 0 deletions packages/cli-test-helpers/src/normalize.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ describe("normalize", () => {
expect(normalize("Version: 0.1.0-rc.1")).toBe("Version: <VERSION>");
});

it("can preserve semantic version strings", () => {
expect(normalize("postgrest/postgrest:v14.13", { versions: false })).toBe(
"postgrest/postgrest:v14.13",
);
});

it("does not normalize IP addresses as version strings", () => {
expect(normalize("host 127.0.0.1")).toBe("host 127.0.0.1");
expect(normalize("192.168.1.1")).toBe("192.168.1.1");
Expand Down
11 changes: 8 additions & 3 deletions packages/cli-test-helpers/src/parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createHarness, exec, makeTempDir } from "./harness.ts";
import { normalize, sortTableRows } from "./normalize.ts";
import { normalize, type NormalizeOptions, sortTableRows } from "./normalize.ts";

// ---------------------------------------------------------------------------
// Table parsing (Level 2)
Expand Down Expand Up @@ -215,13 +215,14 @@ async function collectRunResult(
dir: string,
apiUrl: string,
extraEnv?: Record<string, string>,
normalizeOptions?: NormalizeOptions,
): Promise<RunResult> {
const result = await exec(harness, cmd, extraEnv ? { env: extraEnv } : undefined);
const requests = await fetchRequestLog(apiUrl);
const files = snapshotChangedFiles(dir);
return {
stdout: normalize(result.stdout),
stderr: normalize(result.stderr),
stdout: normalize(result.stdout, normalizeOptions),
stderr: normalize(result.stderr, normalizeOptions),
exitCode: result.exitCode,
requests,
files,
Expand Down Expand Up @@ -311,6 +312,8 @@ export interface ParityOptions {
sortStdoutRows?: boolean;
/** Additional environment variables injected into both CLI subprocesses. */
extraEnv?: Record<string, string>;
/** Fine-grained normalization controls for stdout/stderr parity comparison. */
normalize?: NormalizeOptions;
}

/**
Expand Down Expand Up @@ -341,6 +344,7 @@ export async function runParity(opts: ParityOptions, cmd: string[]): Promise<voi
goDir.path,
opts.apiUrl,
opts.extraEnv,
opts.normalize,
);

await fetch(`${opts.apiUrl}/_ctrl/requests`, { method: "DELETE" });
Expand All @@ -357,6 +361,7 @@ export async function runParity(opts: ParityOptions, cmd: string[]): Promise<voi
tsDir.path,
opts.apiUrl,
opts.extraEnv,
opts.normalize,
);

// Self-cleaning: reset after ts-legacy so callers start with a clean log.
Expand Down
Loading