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
29 changes: 25 additions & 4 deletions apps/cli/src/command-internal/command-permissions/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ interface ValueCondition extends FlagConditionBase {
readonly is?: never;
}

export type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition;
type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition;

/** The database a command talks to: `--local`, `--db-url`, or the linked project. */
/**
* The target a command talks to: `--local`, `--db-url`, or the linked project. `seed buckets` uses
* it for its Storage target, which `--linked`/`--local` select the same way.
*/
export type DbTarget = "local" | "db-url" | "linked";

/**
Expand Down Expand Up @@ -142,11 +145,12 @@ export function compose(...fragments: ReadonlyArray<PermissionFragment>): Permis

/**
* Adds `condition` to every operation entry in `fragment`, ahead of each entry's own `when`. It
* keeps no block `source`, so it must name a flag of the command that declares it.
* keeps no block `source`, so a flag condition must name a flag of the command that declares it,
* and a target condition a target that command can select.
*/
export function withCondition(
fragment: PermissionFragment,
condition: FlagCondition,
condition: EntryCondition,
): PermissionFragment {
return {
...fragment,
Expand All @@ -157,6 +161,23 @@ export function withCondition(
};
}

/**
* Adds `prerequisite` to every entry's `context`, for a condition that isn't a flag, e.g. a choice
* the user makes at a prompt.
*/
export function withContext(
fragment: PermissionFragment,
prerequisite: string,
): PermissionFragment {
return {
...fragment,
operations: fragment.operations.map((entry) => ({
...entry,
context: entry.context === undefined ? prerequisite : `${prerequisite}, and ${entry.context}`,
})),
};
}

/**
* Gives every entry in `fragment` the `kind` the including command handles it with, when that
* differs from the block's own (e.g. a command that carries on after a 403 on a call its
Expand Down
142 changes: 127 additions & 15 deletions apps/cli/src/command-internal/db-config.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { NetConnectOpts } from "node:net";
import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import {
Expand All @@ -19,20 +18,11 @@ import * as TestClock from "effect/testing/TestClock";
import type { DatabaseInstance, Stack } from "@supabase/stack/effect";
import { vi } from "vitest";

// Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing.
vi.mock("node:net", (importOriginal) =>
importOriginal<typeof import("node:net")>().then((net) => ({
...net,
connect: (options: NetConnectOpts) => {
const host = "host" in options ? options.host : undefined;
if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) {
return net.connect(options);
}
const socket = new net.Socket();
queueMicrotask(() => socket.emit("error", new Error("fixture connection refused")));
return socket;
},
})),
Promise.all([
import("../../tests/helpers/refuse-invalid-db-hosts.ts"),
importOriginal<typeof import("node:net")>(),
]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)),
);

import {
Expand All @@ -53,7 +43,12 @@ import {
} from "../../tests/helpers/permission-real-layers.ts";
import { applicableOperations } from "../../tests/helpers/permission-registry.ts";
import type { ActiveFlags } from "../../tests/helpers/permission-registry.ts";
import { dbConfig, dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts";
import {
dbConfig,
dbConfigLayer,
dbConfigPoolerFallback,
dbConfigResolverLayer,
} from "./db-config.layer.ts";
import {
DebugFlag,
DnsResolverFlag,
Expand Down Expand Up @@ -1866,3 +1861,120 @@ describe("dbConfigResolver (Management API calls)", () => {
);
}
});

describe("dbConfigResolver (container pooler fallback)", () => {
const ref = "fallbackrefabcdefghi";
const poolerPath = `/v1/projects/${ref}/config/database/pooler`;
const loginRolePath = `/v1/projects/${ref}/cli/login-role`;
const session: DbSession = {
exec: () => Effect.void,
execBatch: () => Effect.void,
query: () => Effect.succeed([]),
extensionExists: () => Effect.succeed(false),
copyToCsv: () => Effect.succeed(new Uint8Array()),
queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }),
};
const connects = Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) });
const assertFallbackDrift = (
requests: ReadonlyArray<{ readonly method: string; readonly path: string }>,
activeFlags: ActiveFlags,
) =>
assertBlockDrift(dbConfigPoolerFallback, {
activeFlags,
requests: requests.map((r) => ({
method: r.method,
url: `https://api.supabase.com${r.path}`,
})),
exact: true,
});

it.effect("fetches the pooler config, then mints a login role when no password is set", () => {
const requests: Array<{ readonly method: string; readonly path: string }> = [];
return Effect.gen(function* () {
yield* stubManagementApi(requests, (method, path) => {
if (method === "GET" && path === poolerPath)
return Response.json(primaryPoolerConfigs(ref));
if (method === "POST" && path === loginRolePath) {
return Response.json(
{ role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 },
{ status: 201 },
);
}
return undefined;
});
const dir = yield* withWorkdir();
const pooler = yield* resolvePoolerFallback(
dir,
{ ...linkedFlags, linkedProjectRef: Option.some(ref) },
{ dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } },
);
expect(Option.isSome(pooler)).toBe(true);
if (Option.isSome(pooler)) {
expect(pooler.value).toMatchObject({
host: "aws-0-us-east-1.pooler.supabase.com",
user: `cli_login_role.${ref}`,
password: "temporary-role-password",
});
}
expect(requests).toEqual([
{ method: "GET", path: poolerPath },
{ method: "POST", path: loginRolePath },
]);
assertFallbackDrift(requests, { linked: true });
}).pipe(Effect.scoped, Effect.provide(BunServices.layer));
});

it.effect("fetches the pooler config but mints no login role when a password is set", () => {
const requests: Array<{ readonly method: string; readonly path: string }> = [];
return Effect.gen(function* () {
yield* stubManagementApi(requests, (method, path) =>
method === "GET" && path === poolerPath
? Response.json(primaryPoolerConfigs(ref))
: undefined,
);
const dir = yield* withWorkdir();
const pooler = yield* resolvePoolerFallback(
dir,
{ ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("secret") },
{ dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } },
);
expect(pooler).toEqual(
Option.some(expect.objectContaining({ user: `postgres.${ref}`, password: "secret" })),
);
expect(requests).toEqual([{ method: "GET", path: poolerPath }]);
assertFallbackDrift(requests, { linked: true, password: "secret" });
}).pipe(Effect.scoped, Effect.provide(BunServices.layer));
});

it.effect("returns no pooler when the pooler config is forbidden", () => {
const requests: Array<{ readonly method: string; readonly path: string }> = [];
return Effect.gen(function* () {
yield* stubManagementApi(requests, (method, path) =>
method === "GET" && path === poolerPath
? Response.json({ message: "Forbidden" }, { status: 403 })
: undefined,
);
const dir = yield* withWorkdir();
const pooler = yield* resolvePoolerFallback(
dir,
{ ...linkedFlags, linkedProjectRef: Option.some(ref) },
{ dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } },
);
expect(Option.isNone(pooler)).toBe(true);
expect(requests).toEqual([{ method: "GET", path: poolerPath }]);
assertFallbackDrift(requests, { linked: true });
}).pipe(Effect.scoped, Effect.provide(BunServices.layer));
});

it.effect("--local returns no pooler and makes no Management API request", () => {
const requests: Array<{ readonly method: string; readonly path: string }> = [];
return Effect.gen(function* () {
yield* stubManagementApi(requests, () => undefined);
const dir = yield* withWorkdir();
const pooler = yield* resolvePoolerFallback(dir, localFlags);
expect(Option.isNone(pooler)).toBe(true);
expect(requests).toEqual([]);
assertFallbackDrift(requests, { local: true });
}).pipe(Effect.scoped, Effect.provide(BunServices.layer));
});
});
92 changes: 71 additions & 21 deletions apps/cli/src/command-internal/db-config.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,50 +45,98 @@ import { mapHttpError } from "./http-errors.ts";
import { currentStackBackend } from "./stack-backend.ts";
import { StackApi, stackApiLayer } from "./stack-api.ts";
import { stackLocalDatabaseConn } from "./stack-local-database.ts";
import { block } from "./command-permissions/model.ts";
import { block, type OperationEntry } from "./command-permissions/model.ts";

/**
* `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands
* include it with `withDbTarget`. `gen types --project-id` and `db dump`'s container fallback
* fetch the pooler config without `--project-ref`, so those commands declare that entry
* themselves.
*/
export const dbConfig = block("db-config", [
const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [
{
operationId: "v1-create-login-role",
kind: "required",
when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }],
when: [{ flag: "password", present: false }],
context:
"no database password from --password (an empty value counts as none), SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)",
"no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)",
},
{
operationId: "v1-get-pooler-config",
kind: "required",
when: [{ dbTarget: ["linked"] }, { flag: "project-ref" }],
when: [{ flag: "project-ref" }],
context:
"the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url",
},
{
operationId: "v1-get-pooler-config",
kind: "required",
when: [{ dbTarget: ["linked"] }, { flag: "project-ref", oneOf: [""] }],
when: [{ flag: "project-ref", oneOf: [""] }],
context:
"an empty --project-ref falls back to the linked project but still allows the pooler fetch, when the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url",
},
{
operationId: "v1-list-all-network-bans",
kind: "best-effort",
when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }],
when: [{ flag: "password", present: false }],
context:
"after three failed attempts to connect through the pooler as the temporary login role",
},
{
operationId: "v1-delete-network-bans",
kind: "best-effort",
when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }],
when: [{ flag: "password", present: false }],
context:
"after three failed attempts to connect through the pooler as the temporary login role",
},
];

/**
* The same calls as {@link dbConfig} without its target condition, for a command that reaches the
* linked path some other way: `db diff --from linked`, a `--linked` flag with no default target, or
* a target prompt.
*/
export const dbConfigLinked = block("db-config", linkedEntries);

/**
* `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands
* include it with `withDbTarget`. The container retry that `db dump` and `db pull` run is
* {@link dbConfigPoolerFallback}; `gen types --project-id` fetches the
* pooler config without `--project-ref`, so that command declares its own entry.
*/
export const dbConfig = block(
"db-config",
linkedEntries.map((entry) => ({
...entry,
when: [{ dbTarget: ["linked" as const] }, ...(entry.when ?? [])],
})),
);

/**
* `DbConfigResolver.resolvePoolerFallback`, the pooler retry after a linked dump, declarative export
* or pull diff fails with an IPv6 connectivity error against a direct host the CLI could reach.
*/
export const dbConfigPoolerFallback = block("db-config-pooler-fallback", [
{
operationId: "v1-get-pooler-config",
kind: "required",
when: [{ dbTarget: ["linked"] }],
context:
"a linked dump, declarative export or pull diff fails with an IPv6 connectivity error against the direct database host, and there is no usable saved supabase/.temp/pooler-url",
},
{
operationId: "v1-create-login-role",
kind: "required",
when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }],
context:
"the same retry, and no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env",
},
{
operationId: "v1-list-all-network-bans",
kind: "best-effort",
when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }],
context: "the same retry, after three failed attempts to connect as the temporary login role",
},
{
operationId: "v1-delete-network-bans",
kind: "best-effort",
when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }],
context: "the same retry, after three failed attempts to connect as the temporary login role",
},
]);

const DIRECT_PORT = 5432;
Expand Down Expand Up @@ -266,12 +314,14 @@ const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => {
};

/**
* An explicitly empty `--password` asks for a temporary login role even when
* `SUPABASE_DB_PASSWORD` or the project `.env` holds one. The flag binding drops empty strings, so
* pass it to {@link resolveLinkedPassword} as the caller's password instead.
* A `--password` the caller bound on {@link DbConfigFlags} is passed to
* {@link resolveLinkedPassword} as the caller's password, so it wins over `SUPABASE_DB_PASSWORD` and
* the project `.env` just like the flag tier does, and no login role is minted for it. An
* explicitly empty `--password` asks for a temporary login role even when the env holds one; the
* flag binding drops empty strings, so this is the only way that request reaches the resolver.
*/
const emptyPasswordFlag = (password: Option.Option<string> | undefined): Option.Option<string> =>
Option.filter(password ?? Option.none(), (value) => value.length === 0);
const passwordFlag = (password: Option.Option<string> | undefined): Option.Option<string> =>
password ?? Option.none();

const loadFailureToDbConfigError = <E extends { readonly _tag: string; readonly message: string }>(
error: E,
Expand Down Expand Up @@ -690,7 +740,7 @@ export const dbConfigResolverLayer = Layer.effect(
cliSettings.projectHost,
cliSettings.poolerHost,
flags.dnsResolver,
emptyPasswordFlag(flags.password),
passwordFlag(flags.password),
{
adHocProjectRef: flags.adHocProjectRef ?? false,
resolveVaultSecrets,
Expand Down Expand Up @@ -759,7 +809,7 @@ export const dbConfigResolverLayer = Layer.effect(
const password = yield* resolveLinkedPassword(
ref,
cliSettings.workdir,
emptyPasswordFlag(flags.password),
passwordFlag(flags.password),
flags.password !== undefined,
);
// Container-fallback: fetch the primary pooler config from the Management API when
Expand Down
Loading
Loading