@specific.dev/spectest 0.114.0 → 0.115.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/supabase-config.d.ts +5 -1
- package/dist/components/supabase-config.js +19 -1
- package/dist/components/supabase-pooler.d.ts +20 -0
- package/dist/components/supabase-pooler.js +118 -0
- package/dist/components/supabase.d.ts +4 -1
- package/dist/components/supabase.js +24 -0
- package/dist/daemon.js +1 -0
- package/package.json +1 -1
- package/src/components/supabase-config.test.ts +29 -2
- package/src/components/supabase-config.ts +18 -1
- package/src/components/supabase-pooler-docker.test.ts +129 -0
- package/src/components/supabase-pooler.test.ts +83 -0
- package/src/components/supabase-pooler.ts +137 -0
- package/src/components/supabase.ts +29 -1
- package/src/daemon.ts +1 -0
- package/src/harness/activity.test.ts +20 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SupabasePoolerConfig } from "./supabase-pooler.js";
|
|
1
2
|
/**
|
|
2
3
|
* GoTrue's mail template types, as `config.toml` spells them. The container
|
|
3
4
|
* variable is the id upper-cased (`magic_link` -> `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK`).
|
|
@@ -13,6 +14,8 @@ export interface MailTemplateSpec {
|
|
|
13
14
|
}
|
|
14
15
|
/** Everything the component takes from the config file, grouped by the service it lands on. */
|
|
15
16
|
export interface MappedSupabaseConfig {
|
|
17
|
+
/** Optional Supavisor service, configured by [db.pooler]. */
|
|
18
|
+
pooler: SupabasePoolerConfig;
|
|
16
19
|
/** GoTrue (`<name>-auth`) environment. */
|
|
17
20
|
auth: Record<string, string>;
|
|
18
21
|
/** GoTrue variables whose values come from the component's env option. */
|
|
@@ -78,7 +81,8 @@ export interface ExternalProvider {
|
|
|
78
81
|
/**
|
|
79
82
|
* Classify and map every leaf of a parsed config document.
|
|
80
83
|
*
|
|
81
|
-
*
|
|
84
|
+
* Unrecognized content is reported; invalid supported pooler settings fail
|
|
85
|
+
* before any service starts. A setting this component does not apply is
|
|
82
86
|
* *reported* (`unsupported`), not vetoed. The file belongs to the project and
|
|
83
87
|
* describes more than one environment — refusing to start over a key that is
|
|
84
88
|
* meaningless in a hermetic VM would make the whole option unadoptable for the
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
// copy-paste gets silently backwards, which is why they are marked at the def
|
|
35
35
|
// site.
|
|
36
36
|
import { readFileSync } from "node:fs";
|
|
37
|
+
import { DEFAULT_POOLER_CONFIG, setPoolerConfig } from "./supabase-pooler.js";
|
|
37
38
|
/**
|
|
38
39
|
* GoTrue's mail template types, as `config.toml` spells them. The container
|
|
39
40
|
* variable is the id upper-cased (`magic_link` -> `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK`).
|
|
@@ -119,6 +120,21 @@ const unsupported = (key, reason) => ({ key, bucket: "unsupported", reason });
|
|
|
119
120
|
// Order matters: the first matching rule wins, so specific keys precede the
|
|
120
121
|
// `**` prefixes that sweep up the rest of a section.
|
|
121
122
|
const RULES = [
|
|
123
|
+
{
|
|
124
|
+
key: "db.pooler.**",
|
|
125
|
+
bucket: "applied",
|
|
126
|
+
apply: (value, out) => {
|
|
127
|
+
if (!isTable(value))
|
|
128
|
+
throw new Error("supabase(): db.pooler must be a table.");
|
|
129
|
+
for (const [key, setting] of Object.entries(value)) {
|
|
130
|
+
if (Object.hasOwn(DEFAULT_POOLER_CONFIG, key))
|
|
131
|
+
setPoolerConfig(out.pooler, key, setting);
|
|
132
|
+
// Do not hide new settings under the broad db.** topology rule.
|
|
133
|
+
else
|
|
134
|
+
out.warnings.push(`db.pooler.${key}`);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
},
|
|
122
138
|
// ── auth: core (cli gotrue.service.ts:440-448) ──────────────────────────
|
|
123
139
|
// NEGATED — `enable_signup = true` means DISABLE_SIGNUP = false.
|
|
124
140
|
auth("auth.enable_signup", "GOTRUE_DISABLE_SIGNUP", not),
|
|
@@ -383,7 +399,8 @@ function isOff(v) {
|
|
|
383
399
|
/**
|
|
384
400
|
* Classify and map every leaf of a parsed config document.
|
|
385
401
|
*
|
|
386
|
-
*
|
|
402
|
+
* Unrecognized content is reported; invalid supported pooler settings fail
|
|
403
|
+
* before any service starts. A setting this component does not apply is
|
|
387
404
|
* *reported* (`unsupported`), not vetoed. The file belongs to the project and
|
|
388
405
|
* describes more than one environment — refusing to start over a key that is
|
|
389
406
|
* meaningless in a hermetic VM would make the whole option unadoptable for the
|
|
@@ -392,6 +409,7 @@ function isOff(v) {
|
|
|
392
409
|
*/
|
|
393
410
|
export function mapSupabaseConfig(doc) {
|
|
394
411
|
const out = {
|
|
412
|
+
pooler: { ...DEFAULT_POOLER_CONFIG },
|
|
395
413
|
auth: {},
|
|
396
414
|
deferredAuth: {},
|
|
397
415
|
rest: {},
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ServiceDefinition } from "../index.js";
|
|
2
|
+
/** Supabase CLI's [db.pooler] settings. Ports are local to the pooler container. */
|
|
3
|
+
export interface SupabasePoolerConfig {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
port: number;
|
|
6
|
+
pool_mode: "transaction" | "session";
|
|
7
|
+
default_pool_size: number;
|
|
8
|
+
max_client_conn: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const DEFAULT_POOLER_CONFIG: SupabasePoolerConfig;
|
|
11
|
+
export declare function setPoolerConfig(config: SupabasePoolerConfig, key: string, value: unknown): void;
|
|
12
|
+
export declare function supabasePoolerService(opts: {
|
|
13
|
+
config: SupabasePoolerConfig;
|
|
14
|
+
dbHost: string;
|
|
15
|
+
tenantId: string;
|
|
16
|
+
password: string;
|
|
17
|
+
jwtSecret: string;
|
|
18
|
+
tls: boolean;
|
|
19
|
+
url: string;
|
|
20
|
+
}): ServiceDefinition;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const DEFAULT_POOLER_CONFIG = {
|
|
2
|
+
enabled: false,
|
|
3
|
+
port: 54329,
|
|
4
|
+
pool_mode: "transaction",
|
|
5
|
+
default_pool_size: 20,
|
|
6
|
+
max_client_conn: 100,
|
|
7
|
+
};
|
|
8
|
+
export function setPoolerConfig(config, key, value) {
|
|
9
|
+
const invalid = (expected) => {
|
|
10
|
+
throw new Error(`supabase(): db.pooler.${key} must be ${expected}.`);
|
|
11
|
+
};
|
|
12
|
+
switch (key) {
|
|
13
|
+
case "enabled":
|
|
14
|
+
if (typeof value !== "boolean")
|
|
15
|
+
invalid("a boolean");
|
|
16
|
+
config.enabled = value;
|
|
17
|
+
break;
|
|
18
|
+
case "pool_mode":
|
|
19
|
+
if (value !== "transaction" && value !== "session")
|
|
20
|
+
invalid('"transaction" or "session"');
|
|
21
|
+
config.pool_mode = value;
|
|
22
|
+
break;
|
|
23
|
+
case "port":
|
|
24
|
+
case "default_pool_size":
|
|
25
|
+
case "max_client_conn":
|
|
26
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 ||
|
|
27
|
+
(key === "port" && value > 65535))
|
|
28
|
+
invalid(key === "port" ? "an integer from 1 to 65535" : "a positive integer");
|
|
29
|
+
config[key] = value;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Based on supabase/cli's start/templates/pooler.exs and the self-hosted
|
|
34
|
+
// docker/volumes/pooler/pooler.exs. Values come from env, never Elixir source
|
|
35
|
+
// interpolation (passwords may contain quotes, backslashes or #{...}).
|
|
36
|
+
const TENANT_SCRIPT = `{:ok, _} = Application.ensure_all_started(:supavisor)
|
|
37
|
+
{:ok, version} =
|
|
38
|
+
case Supavisor.Repo.query!("select version()") do
|
|
39
|
+
%{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
|
|
40
|
+
end
|
|
41
|
+
params = %{
|
|
42
|
+
"external_id" => System.fetch_env!("POOLER_TENANT_ID"),
|
|
43
|
+
"db_host" => System.fetch_env!("POSTGRES_HOST"),
|
|
44
|
+
"db_port" => 5432,
|
|
45
|
+
"db_database" => "postgres",
|
|
46
|
+
"require_user" => false,
|
|
47
|
+
"auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
|
|
48
|
+
"default_max_clients" => String.to_integer(System.fetch_env!("POOLER_MAX_CLIENT_CONN")),
|
|
49
|
+
"default_pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
|
|
50
|
+
"default_parameter_status" => %{"server_version" => version},
|
|
51
|
+
"users" => [%{
|
|
52
|
+
"db_user" => "pgbouncer",
|
|
53
|
+
"db_password" => System.fetch_env!("POSTGRES_PASSWORD"),
|
|
54
|
+
"mode_type" => System.fetch_env!("POOLER_POOL_MODE"),
|
|
55
|
+
"pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
|
|
56
|
+
"is_manager" => true
|
|
57
|
+
}]
|
|
58
|
+
}
|
|
59
|
+
if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
|
|
60
|
+
{:ok, _} = Supavisor.Tenants.create_tenant(params)
|
|
61
|
+
end
|
|
62
|
+
`;
|
|
63
|
+
export function supabasePoolerService(opts) {
|
|
64
|
+
const { config, dbHost, tenantId, password, jwtSecret, tls, url } = opts;
|
|
65
|
+
const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`;
|
|
66
|
+
return {
|
|
67
|
+
image: { type: "registry", reference: "supabase/supavisor:2.9.12" },
|
|
68
|
+
// eval starts the OTP application too. Give that temporary instance
|
|
69
|
+
// ephemeral SQL ports so readiness only sees the final server process.
|
|
70
|
+
command: '/app/bin/migrate && PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval "$(cat /etc/pooler/pooler.exs)" && exec /app/bin/server',
|
|
71
|
+
env: {
|
|
72
|
+
// Bind all auxiliary listeners to ephemeral ports, so any valid pooler
|
|
73
|
+
// port can be used without colliding with its management/internal ports.
|
|
74
|
+
PORT: "0",
|
|
75
|
+
PROXY_PORT: "0",
|
|
76
|
+
SESSION_PROXY_PORTS: "0",
|
|
77
|
+
TRANSACTION_PROXY_PORTS: "0",
|
|
78
|
+
PROXY_PORT_SESSION: config.pool_mode === "session" ? String(config.port) : "0",
|
|
79
|
+
PROXY_PORT_TRANSACTION: config.pool_mode === "transaction" ? String(config.port) : "0",
|
|
80
|
+
POSTGRES_HOST: dbHost,
|
|
81
|
+
POSTGRES_PASSWORD: password,
|
|
82
|
+
DATABASE_URL: `ecto://supabase_admin:${encodeURIComponent(password)}@${dbHost}:5432/_supabase`,
|
|
83
|
+
// Metadata connections are separate from the user pool. Keep this small
|
|
84
|
+
// in a single-node test environment (upstream defaults to 25).
|
|
85
|
+
DB_POOL_SIZE: "2",
|
|
86
|
+
SECRET_KEY_BASE: "spectest-supavisor-local-secret-key-base-not-for-production-use-0000",
|
|
87
|
+
VAULT_ENC_KEY: "spectest".repeat(4),
|
|
88
|
+
API_JWT_SECRET: jwtSecret,
|
|
89
|
+
METRICS_JWT_SECRET: jwtSecret,
|
|
90
|
+
REGION: "local",
|
|
91
|
+
ERL_AFLAGS: "-proto_dist inet_tcp",
|
|
92
|
+
POOLER_TENANT_ID: tenantId,
|
|
93
|
+
POOLER_DEFAULT_POOL_SIZE: String(config.default_pool_size),
|
|
94
|
+
POOLER_MAX_CLIENT_CONN: String(config.max_client_conn),
|
|
95
|
+
POOLER_POOL_MODE: config.pool_mode,
|
|
96
|
+
...(tls ? {
|
|
97
|
+
GLOBAL_DOWNSTREAM_CERT_PATH: "/tls/pooler.crt",
|
|
98
|
+
GLOBAL_DOWNSTREAM_KEY_PATH: "/tls/pooler.key",
|
|
99
|
+
} : {}),
|
|
100
|
+
},
|
|
101
|
+
files: [{ path: "/etc/pooler/pooler.exs", content: TENANT_SCRIPT }],
|
|
102
|
+
...(tls ? { certificates: [{
|
|
103
|
+
hostnames: ["{{SPECTEST_SERVICE}}", "{{SPECTEST_SERVICE}}.internal"],
|
|
104
|
+
certPath: "/tls/pooler.crt",
|
|
105
|
+
keyPath: "/tls/pooler.key",
|
|
106
|
+
user: "root",
|
|
107
|
+
}] } : {}),
|
|
108
|
+
ports: [config.port],
|
|
109
|
+
dependsOn: ["db"],
|
|
110
|
+
// The pinned image includes psql. Test real tenant authentication and TLS,
|
|
111
|
+
// with bounded attempts retried by the normal service readiness loop.
|
|
112
|
+
readyCheck: {
|
|
113
|
+
type: "exec",
|
|
114
|
+
command: `PGCONNECT_TIMEOUT=3 PGOPTIONS='-c statement_timeout=3000' psql -X --dbname ${quotedUrl} -v ON_ERROR_STOP=1 -c 'SELECT 1'`,
|
|
115
|
+
timeoutSecs: 120,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -294,8 +294,11 @@ export interface SupabaseStack {
|
|
|
294
294
|
anonKey: string;
|
|
295
295
|
/** `service_role` API key (HS256 JWT). */
|
|
296
296
|
serviceRoleKey: string;
|
|
297
|
-
/** Direct Postgres URL
|
|
297
|
+
/** Direct Postgres URL as `postgres`. Use for migrations and LISTEN/NOTIFY. */
|
|
298
298
|
dbUrl: string;
|
|
299
|
+
/** Supavisor URL as `postgres.<name>`, present when [db.pooler].enabled is true.
|
|
300
|
+
* Uses the configured pool_mode and port; dbUrl remains a direct connection. */
|
|
301
|
+
poolerUrl?: string;
|
|
299
302
|
/** The gateway service key — use in a dependent service's `dependsOn` to wait
|
|
300
303
|
* for the whole stack (the gateway waits on the services behind it). */
|
|
301
304
|
ready: string;
|
|
@@ -64,6 +64,7 @@ import { google } from "./google.js";
|
|
|
64
64
|
import { microsoft } from "./microsoft.js";
|
|
65
65
|
import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
|
|
66
66
|
import { postgresTls } from "./postgres-tls.js";
|
|
67
|
+
import { supabasePoolerService } from "./supabase-pooler.js";
|
|
67
68
|
import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
|
|
68
69
|
// ──────────────────────────────────────────────────────────────────────────
|
|
69
70
|
// Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
|
|
@@ -1384,6 +1385,11 @@ export function supabase(opts = {}) {
|
|
|
1384
1385
|
// `auth`/`storage` schemas — regardless of ownership.
|
|
1385
1386
|
const adminDbUrl = `postgresql://supabase_admin:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
|
|
1386
1387
|
(dbTls ? "?sslmode=verify-full" : "");
|
|
1388
|
+
const pooler = mapped?.pooler.enabled ? mapped.pooler : undefined;
|
|
1389
|
+
const poolerUrl = pooler
|
|
1390
|
+
? `postgresql://${encodeURIComponent(`postgres.${name}`)}:${encodeURIComponent(dbPassword)}@${name}-pooler:${pooler.port}/postgres` +
|
|
1391
|
+
(dbTls ? "?sslmode=verify-full" : "")
|
|
1392
|
+
: undefined;
|
|
1387
1393
|
// ── social sign-in ────────────────────────────────────────────────────
|
|
1388
1394
|
// Every `[auth.external.<name>]` the config enables: served in-environment
|
|
1389
1395
|
// where there is an emulator for it, reported where there isn't. The app
|
|
@@ -1482,12 +1488,29 @@ export function supabase(opts = {}) {
|
|
|
1482
1488
|
// superuser in modern supabase/postgres; `supabase_admin` is.
|
|
1483
1489
|
"supabase_admin");
|
|
1484
1490
|
await applySqlFile(ctx, SUPABASE_WEBHOOKS_SQL, "database webhooks", "supabase_admin");
|
|
1491
|
+
if (pooler) {
|
|
1492
|
+
// The image supplies pgbouncer.get_auth, but the self-hosted
|
|
1493
|
+
// compose files normally create the pooler's metadata database.
|
|
1494
|
+
await applySqlFile(ctx, `ALTER USER pgbouncer WITH PASSWORD ${pgLiteral(dbPassword)};
|
|
1495
|
+
SELECT 'CREATE DATABASE _supabase OWNER supabase_admin'
|
|
1496
|
+
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '_supabase')
|
|
1497
|
+
\\gexec
|
|
1498
|
+
\\connect _supabase supabase_admin
|
|
1499
|
+
CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION supabase_admin;
|
|
1500
|
+
`, "pooler bootstrap", "supabase_admin");
|
|
1501
|
+
}
|
|
1485
1502
|
const extensions = extensionSql(opts.db?.extensions);
|
|
1486
1503
|
if (extensions)
|
|
1487
1504
|
await applySqlFile(ctx, extensions, "configured extensions", "postgres");
|
|
1488
1505
|
},
|
|
1489
1506
|
};
|
|
1490
1507
|
parts.db = dbDef;
|
|
1508
|
+
if (pooler && poolerUrl) {
|
|
1509
|
+
parts.pooler = supabasePoolerService({
|
|
1510
|
+
config: pooler, dbHost: db, tenantId: name, password: dbPassword,
|
|
1511
|
+
jwtSecret, tls: !!dbTls, url: poolerUrl,
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1491
1514
|
// ── rest (PostgREST) ────────────────────────────────────────────
|
|
1492
1515
|
parts.rest = {
|
|
1493
1516
|
image: { type: "registry", reference: IMAGES.rest },
|
|
@@ -1888,6 +1911,7 @@ export function supabase(opts = {}) {
|
|
|
1888
1911
|
anonKey,
|
|
1889
1912
|
serviceRoleKey,
|
|
1890
1913
|
dbUrl,
|
|
1914
|
+
...(poolerUrl ? { poolerUrl } : {}),
|
|
1891
1915
|
ready: name,
|
|
1892
1916
|
...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
|
|
1893
1917
|
};
|
package/dist/daemon.js
CHANGED
|
@@ -5159,6 +5159,7 @@ export function harnessMethods(state) {
|
|
|
5159
5159
|
// new method. Check the loaded project, including after warm restore.
|
|
5160
5160
|
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
5161
5161
|
replayEnvironmentMode: true,
|
|
5162
|
+
inFlightTest: state.inFlightTest !== null,
|
|
5162
5163
|
}),
|
|
5163
5164
|
// Live bootstrap progress, polled by the control plane during
|
|
5164
5165
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
package/package.json
CHANGED
|
@@ -238,8 +238,7 @@ secret = "encrypted:c2hoaA=="
|
|
|
238
238
|
expect(out.warnings).toEqual(["auth.future_option"]);
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
test("drops the topology sections in silence", () => {
|
|
241
|
+
test("applies the pooler while dropping unrelated topology sections", () => {
|
|
243
242
|
const out = mapSupabaseConfig(
|
|
244
243
|
toml(`
|
|
245
244
|
project_id = "test"
|
|
@@ -247,6 +246,10 @@ project_id = "test"
|
|
|
247
246
|
port = 54322
|
|
248
247
|
[db.pooler]
|
|
249
248
|
enabled = true
|
|
249
|
+
port = 6544
|
|
250
|
+
pool_mode = "session"
|
|
251
|
+
default_pool_size = 7
|
|
252
|
+
max_client_conn = 150
|
|
250
253
|
[studio]
|
|
251
254
|
port = 54323
|
|
252
255
|
[inbucket]
|
|
@@ -257,5 +260,29 @@ site_url = "https://example.com"
|
|
|
257
260
|
);
|
|
258
261
|
expect(out.warnings).toEqual([]);
|
|
259
262
|
expect(out.auth).toEqual({});
|
|
263
|
+
expect(out.pooler).toEqual({ enabled: true, port: 6544, pool_mode: "session", default_pool_size: 7, max_client_conn: 150 });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("pooler defaults are independent and disabled until requested", () => {
|
|
267
|
+
const defaults = { enabled: false, port: 54329, pool_mode: "transaction", default_pool_size: 20, max_client_conn: 100 };
|
|
268
|
+
expect(mapSupabaseConfig({}).pooler).toEqual(defaults);
|
|
269
|
+
expect(mapSupabaseConfig({ db: { pooler: { enabled: true } } }).pooler).toEqual({ ...defaults, enabled: true });
|
|
270
|
+
expect(mapSupabaseConfig({}).pooler).toEqual(defaults);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test.each([
|
|
274
|
+
["enabled", "true"], ["enabled", {}], ["pool_mode", "statement"], ["pool_mode", false],
|
|
275
|
+
["default_pool_size", 0], ["default_pool_size", 1.5], ["max_client_conn", -1],
|
|
276
|
+
["max_client_conn", "100"], ["port", 0], ["port", 65536], ["port", NaN],
|
|
277
|
+
])("rejects invalid pooler %s = %p", (key, value) => {
|
|
278
|
+
expect(() => mapSupabaseConfig({ db: { pooler: { [key as string]: value } } })).toThrow(`db.pooler.${key}`);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("reports unknown pooler settings instead of silently ignoring them", () => {
|
|
282
|
+
expect(mapSupabaseConfig({ db: { pooler: { future_setting: 1 } } }).warnings).toEqual(["db.pooler.future_setting"]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("rejects a malformed pooler section", () => {
|
|
286
|
+
expect(() => mapSupabaseConfig({ db: { pooler: true } })).toThrow("db.pooler must be a table");
|
|
260
287
|
});
|
|
261
288
|
});
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// site.
|
|
36
36
|
|
|
37
37
|
import { readFileSync } from "node:fs";
|
|
38
|
+
import { DEFAULT_POOLER_CONFIG, setPoolerConfig, type SupabasePoolerConfig } from "./supabase-pooler.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* GoTrue's mail template types, as `config.toml` spells them. The container
|
|
@@ -61,6 +62,8 @@ export interface MailTemplateSpec {
|
|
|
61
62
|
|
|
62
63
|
/** Everything the component takes from the config file, grouped by the service it lands on. */
|
|
63
64
|
export interface MappedSupabaseConfig {
|
|
65
|
+
/** Optional Supavisor service, configured by [db.pooler]. */
|
|
66
|
+
pooler: SupabasePoolerConfig;
|
|
64
67
|
/** GoTrue (`<name>-auth`) environment. */
|
|
65
68
|
auth: Record<string, string>;
|
|
66
69
|
/** GoTrue variables whose values come from the component's env option. */
|
|
@@ -211,6 +214,18 @@ const unsupported = (key: string, reason: string): Rule => ({ key, bucket: "unsu
|
|
|
211
214
|
// `**` prefixes that sweep up the rest of a section.
|
|
212
215
|
|
|
213
216
|
const RULES: Rule[] = [
|
|
217
|
+
{
|
|
218
|
+
key: "db.pooler.**",
|
|
219
|
+
bucket: "applied",
|
|
220
|
+
apply: (value, out) => {
|
|
221
|
+
if (!isTable(value)) throw new Error("supabase(): db.pooler must be a table.");
|
|
222
|
+
for (const [key, setting] of Object.entries(value)) {
|
|
223
|
+
if (Object.hasOwn(DEFAULT_POOLER_CONFIG, key)) setPoolerConfig(out.pooler, key, setting);
|
|
224
|
+
// Do not hide new settings under the broad db.** topology rule.
|
|
225
|
+
else out.warnings.push(`db.pooler.${key}`);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
},
|
|
214
229
|
// ── auth: core (cli gotrue.service.ts:440-448) ──────────────────────────
|
|
215
230
|
// NEGATED — `enable_signup = true` means DISABLE_SIGNUP = false.
|
|
216
231
|
auth("auth.enable_signup", "GOTRUE_DISABLE_SIGNUP", not),
|
|
@@ -478,7 +493,8 @@ function isOff(v: unknown): boolean {
|
|
|
478
493
|
/**
|
|
479
494
|
* Classify and map every leaf of a parsed config document.
|
|
480
495
|
*
|
|
481
|
-
*
|
|
496
|
+
* Unrecognized content is reported; invalid supported pooler settings fail
|
|
497
|
+
* before any service starts. A setting this component does not apply is
|
|
482
498
|
* *reported* (`unsupported`), not vetoed. The file belongs to the project and
|
|
483
499
|
* describes more than one environment — refusing to start over a key that is
|
|
484
500
|
* meaningless in a hermetic VM would make the whole option unadoptable for the
|
|
@@ -487,6 +503,7 @@ function isOff(v: unknown): boolean {
|
|
|
487
503
|
*/
|
|
488
504
|
export function mapSupabaseConfig(doc: unknown): MappedSupabaseConfig {
|
|
489
505
|
const out: MappedSupabaseConfig = {
|
|
506
|
+
pooler: { ...DEFAULT_POOLER_CONFIG },
|
|
490
507
|
auth: {},
|
|
491
508
|
deferredAuth: {},
|
|
492
509
|
rest: {},
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Opt-in real-image regression: creates and removes its own containers/network.
|
|
2
|
+
// SPECTEST_TEST_SUPABASE_POOLER_DOCKER=1 bun test src/components/supabase-pooler-docker.test.ts
|
|
3
|
+
import { expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { supabase } from "./supabase.js";
|
|
8
|
+
import type { ServiceDefinition, ServiceSetupContext, ExecOpts } from "../index.js";
|
|
9
|
+
|
|
10
|
+
async function run(args: string[], stdin = "") {
|
|
11
|
+
const child = Bun.spawn(args, { stdin: new Blob([stdin]), stdout: "pipe", stderr: "pipe" });
|
|
12
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
13
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
14
|
+
]);
|
|
15
|
+
return { exitCode, stdout, stderr };
|
|
16
|
+
}
|
|
17
|
+
async function checked(args: string[], stdin = "") {
|
|
18
|
+
const result = await run(args, stdin);
|
|
19
|
+
if (result.exitCode) throw new Error(`${args[0]} failed: ${result.stderr}\n${result.stdout}`);
|
|
20
|
+
return result.stdout;
|
|
21
|
+
}
|
|
22
|
+
async function until(check: () => Promise<boolean>) {
|
|
23
|
+
const deadline = Date.now() + 60_000;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
if (await check()) return;
|
|
26
|
+
await Bun.sleep(250);
|
|
27
|
+
}
|
|
28
|
+
throw new Error("Container did not become ready within 60s");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test.skipIf(!process.env.SPECTEST_TEST_SUPABASE_POOLER_DOCKER)("Supavisor boots, verifies TLS, pools transactions and preserves sessions", async () => {
|
|
32
|
+
const root = mkdtempSync(join(tmpdir(), "spectest-pooler-docker-"));
|
|
33
|
+
const network = `spectest-pooler-${process.pid}-${Date.now()}`;
|
|
34
|
+
const dbContainer = `${network}-db`;
|
|
35
|
+
const containers: string[] = [];
|
|
36
|
+
const password = "p@ss/word:#%";
|
|
37
|
+
const stack = (name: string, mode: "transaction" | "session") => supabase({
|
|
38
|
+
name, dbPassword: password,
|
|
39
|
+
configOverrides: { db: { pooler: { enabled: true, pool_mode: mode,
|
|
40
|
+
port: mode === "transaction" ? 54329 : 6544, default_pool_size: 2, max_client_conn: 12 } } },
|
|
41
|
+
});
|
|
42
|
+
const sb = stack("tx", "transaction");
|
|
43
|
+
const db = sb.group.services({ name: "tx", key: (p) => `tx-${p}` }).db as ServiceDefinition;
|
|
44
|
+
const exec = (_service: string, args: string[], opts: ExecOpts = {}) =>
|
|
45
|
+
run(["docker", "exec", "-i", dbContainer, ...args], typeof opts.stdin === "string" ? opts.stdin : "");
|
|
46
|
+
const ctx = { name: "tx-db", exec } as unknown as ServiceSetupContext<any>;
|
|
47
|
+
try {
|
|
48
|
+
await checked(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
|
|
49
|
+
"-subj", "/CN=Spectest pooler test CA", "-keyout", `${root}/ca.key`, "-out", `${root}/ca.crt`]);
|
|
50
|
+
for (const host of ["tx-db", "tx-pooler", "session-pooler"]) {
|
|
51
|
+
await checked(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", `/CN=${host}`,
|
|
52
|
+
"-keyout", `${root}/${host}.key`, "-out", `${root}/${host}.csr`]);
|
|
53
|
+
writeFileSync(`${root}/${host}.ext`, `subjectAltName=DNS:${host}\nextendedKeyUsage=serverAuth\n`);
|
|
54
|
+
await checked(["openssl", "x509", "-req", "-days", "1", "-in", `${root}/${host}.csr`,
|
|
55
|
+
"-CA", `${root}/ca.crt`, "-CAkey", `${root}/ca.key`, "-CAcreateserial",
|
|
56
|
+
"-extfile", `${root}/${host}.ext`, "-out", `${root}/${host}.crt`]);
|
|
57
|
+
}
|
|
58
|
+
await checked(["docker", "network", "create", network]);
|
|
59
|
+
containers.push(dbContainer);
|
|
60
|
+
await checked(["docker", "run", "-d", "--name", dbContainer, "--network", network,
|
|
61
|
+
"--network-alias", "tx-db", "--network-alias", "session-db", "-v", `${root}:/fixtures:ro`,
|
|
62
|
+
"-e", `POSTGRES_PASSWORD=${password}`, "-e", "PGSSLROOTCERT=/fixtures/ca.crt",
|
|
63
|
+
"--entrypoint", "sh", (db.image as { reference: string }).reference, "-c",
|
|
64
|
+
'mkdir /tls; cp /fixtures/tx-db.crt /tls/postgres.crt; cp /fixtures/tx-db.key /tls/postgres.key; chown postgres:postgres /tls/*; chmod 600 /tls/postgres.key; exec docker-entrypoint.sh "$@"',
|
|
65
|
+
"sh", ...db.args!]);
|
|
66
|
+
await until(async () => (await exec("", ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"])).exitCode === 0);
|
|
67
|
+
await db.setup!(ctx);
|
|
68
|
+
await db.setup!(ctx); // Metadata bootstrap must be repeatable.
|
|
69
|
+
|
|
70
|
+
for (const [name, mode] of [["tx", "transaction"], ["session", "session"]] as const) {
|
|
71
|
+
const current = stack(name, mode);
|
|
72
|
+
const pooler = current.group.services({ name, key: (p) => `${name}-${p}` }).pooler as ServiceDefinition;
|
|
73
|
+
const container = `${network}-${name}`;
|
|
74
|
+
containers.push(container);
|
|
75
|
+
writeFileSync(`${root}/${name}.exs`, pooler.files![0]!.content);
|
|
76
|
+
const env = Object.entries(pooler.env!).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
77
|
+
await checked(["docker", "run", "-d", "--name", container, "--network", network,
|
|
78
|
+
"--network-alias", `${name}-pooler`, ...env,
|
|
79
|
+
"-e", "PGSSLROOTCERT=/tls/ca.crt", "-v", `${root}/ca.crt:/tls/ca.crt:ro`,
|
|
80
|
+
"-v", `${root}/${name}.exs:/etc/pooler/pooler.exs:ro`,
|
|
81
|
+
"-v", `${root}/${name}-pooler.crt:/tls/pooler.crt:ro`,
|
|
82
|
+
"-v", `${root}/${name}-pooler.key:/tls/pooler.key:ro`,
|
|
83
|
+
"--entrypoint", "sh", (pooler.image as { reference: string }).reference, "-c", pooler.command!]);
|
|
84
|
+
const query = (sql: string, url = current.poolerUrl!) => exec("", ["psql", "-XAt", "-v", "ON_ERROR_STOP=1", url, "-f", "-"], { stdin: sql });
|
|
85
|
+
if (pooler.readyCheck!.type !== "exec") throw new Error("Expected SQL readiness probe");
|
|
86
|
+
const probeCommand = pooler.readyCheck!.command;
|
|
87
|
+
const probe = () => run(["docker", "exec", container, "sh", "-c", probeCommand]);
|
|
88
|
+
await until(async () => (await probe()).exitCode === 0);
|
|
89
|
+
const auth = await query("SELECT current_user, current_database();");
|
|
90
|
+
expect(auth.stdout.trim()).toBe("postgres|postgres");
|
|
91
|
+
const badPassword = new URL(current.poolerUrl!); badPassword.password = "wrong";
|
|
92
|
+
expect((await query("SELECT 1;", badPassword.toString())).exitCode).not.toBe(0);
|
|
93
|
+
// A CA-trusted certificate for a different name must still fail verification.
|
|
94
|
+
const wrongHost = new URL(current.poolerUrl!); wrongHost.hostname = container;
|
|
95
|
+
expect((await query("SELECT 1;", wrongHost.toString())).exitCode).not.toBe(0);
|
|
96
|
+
if (mode === "transaction") {
|
|
97
|
+
const results = await Promise.all(Array.from({ length: 8 }, () => query("SELECT pg_backend_pid(), pg_sleep(0.15);")));
|
|
98
|
+
for (const result of results) expect(result.exitCode).toBe(0);
|
|
99
|
+
const backends = new Set(results.map((result) => result.stdout.trim().split("|")[0]));
|
|
100
|
+
// Supavisor 2.9.12 keeps one warm backend in addition to pool_size
|
|
101
|
+
// overflow slots (tenant_supervisor.ex). Eight clients share <= 3.
|
|
102
|
+
expect(backends.size).toBeLessThanOrEqual(3);
|
|
103
|
+
expect(backends.size).toBeGreaterThan(0);
|
|
104
|
+
} else {
|
|
105
|
+
const result = await query("CREATE TEMP TABLE session_probe (n int);\nINSERT INTO session_probe VALUES (42);\nSELECT n FROM session_probe;\n");
|
|
106
|
+
expect(result.exitCode).toBe(0);
|
|
107
|
+
expect(result.stdout).toContain("42");
|
|
108
|
+
}
|
|
109
|
+
// Tenant settings survive restart, and provisioning remains idempotent.
|
|
110
|
+
await checked(["docker", "restart", container]);
|
|
111
|
+
await until(async () => (await probe()).exitCode === 0);
|
|
112
|
+
await checked(["docker", "rm", "-f", container]);
|
|
113
|
+
containers.splice(containers.indexOf(container), 1);
|
|
114
|
+
}
|
|
115
|
+
const settings = await exec("", ["psql", "-U", "supabase_admin", "-d", "_supabase", "-Atc",
|
|
116
|
+
"SELECT external_id, default_pool_size, default_max_clients FROM _supavisor.tenants ORDER BY external_id"]);
|
|
117
|
+
expect(settings.stdout.trim()).toBe("session|2|12\ntx|2|12");
|
|
118
|
+
} catch (error) {
|
|
119
|
+
for (const container of containers) {
|
|
120
|
+
const logs = await run(["docker", "logs", "--tail", "30", container]);
|
|
121
|
+
console.error(container, logs.stdout, logs.stderr);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
} finally {
|
|
125
|
+
for (const container of containers.reverse()) await run(["docker", "rm", "-f", "-v", container]);
|
|
126
|
+
await run(["docker", "network", "rm", network]);
|
|
127
|
+
rmSync(root, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
}, 180_000);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { supabase } from "./supabase.js";
|
|
6
|
+
import { defineEnvironment, type ServiceDefinition } from "../index.js";
|
|
7
|
+
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), "supabase-pooler-"));
|
|
9
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
10
|
+
mkdirSync(join(root, "migrations"));
|
|
11
|
+
writeFileSync(join(root, "config.toml"), `[db.pooler]
|
|
12
|
+
enabled = true
|
|
13
|
+
port = 6544
|
|
14
|
+
pool_mode = "session"
|
|
15
|
+
default_pool_size = 3
|
|
16
|
+
max_client_conn = 40
|
|
17
|
+
`);
|
|
18
|
+
|
|
19
|
+
test("pooling is opt-in and does not change direct URLs", () => {
|
|
20
|
+
const direct = supabase();
|
|
21
|
+
const disabled = supabase({ configOverrides: { db: { pooler: { enabled: false } } } });
|
|
22
|
+
expect(disabled.dbUrl).toBe(direct.dbUrl);
|
|
23
|
+
expect(disabled.poolerUrl).toBeUndefined();
|
|
24
|
+
expect(disabled.group.services({ name: "supabase", key: (p) => `supabase-${p}` })).not.toHaveProperty("pooler");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("reads the project config and overrides it without redirecting internal clients", () => {
|
|
28
|
+
const password = "a@b/c'\\#{raise} $()";
|
|
29
|
+
const sb = supabase({ name: "other", dir: root, dbPassword: password,
|
|
30
|
+
configOverrides: { db: { pooler: { pool_mode: "transaction", max_client_conn: 80 } } } });
|
|
31
|
+
const parts = sb.group.services({ name: "other", key: (p) => `other-${p}` });
|
|
32
|
+
const pooler = parts.pooler as ServiceDefinition;
|
|
33
|
+
const url = new URL(sb.poolerUrl!);
|
|
34
|
+
expect(url.hostname).toBe("other-pooler");
|
|
35
|
+
expect(url.port).toBe("6544");
|
|
36
|
+
expect(decodeURIComponent(url.username)).toBe("postgres.other");
|
|
37
|
+
expect(decodeURIComponent(url.password)).toBe(password);
|
|
38
|
+
expect(url.searchParams.get("sslmode")).toBe("verify-full");
|
|
39
|
+
expect(new URL(sb.dbUrl).hostname).toBe("other-db");
|
|
40
|
+
expect(pooler.env).toMatchObject({ POOLER_DEFAULT_POOL_SIZE: "3", POOLER_MAX_CLIENT_CONN: "80",
|
|
41
|
+
POOLER_POOL_MODE: "transaction", PROXY_PORT_TRANSACTION: "6544", PROXY_PORT_SESSION: "0", POSTGRES_HOST: "other-db" });
|
|
42
|
+
expect(new URL(pooler.env!.DATABASE_URL!).password).toBe(encodeURIComponent(password));
|
|
43
|
+
expect(pooler.files![0]!.content).not.toContain(password);
|
|
44
|
+
expect(pooler.certificates![0]!.user).toBe("root");
|
|
45
|
+
expect((parts.rest as ServiceDefinition).env!.PGRST_DB_URI).toContain("@other-db:5432");
|
|
46
|
+
expect((parts.auth as ServiceDefinition).env!.GOTRUE_DB_DATABASE_URL).toContain("@other-db:5432");
|
|
47
|
+
expect((parts.storage as ServiceDefinition).env!.DATABASE_URL).toContain("@other-db:5432");
|
|
48
|
+
expect((parts.realtime as ServiceDefinition).env!.DB_HOST).toBe("other-db");
|
|
49
|
+
expect((parts.migrations as ServiceDefinition).dependsOn).not.toContain("pooler");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("session mode and disabling TLS apply to the selected endpoint", () => {
|
|
53
|
+
const sb = supabase({ dir: root, db: { tls: false } });
|
|
54
|
+
const pooler = sb.group.services({ name: "supabase", key: (p) => `supabase-${p}` }).pooler as ServiceDefinition;
|
|
55
|
+
expect(pooler.env).toMatchObject({ POOLER_POOL_MODE: "session", PROXY_PORT_SESSION: "6544", PROXY_PORT_TRANSACTION: "0" });
|
|
56
|
+
expect(pooler.certificates).toBeUndefined();
|
|
57
|
+
expect(sb.poolerUrl).not.toContain("sslmode");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("group readiness waits for authenticated pooled SQL, after tenant provisioning", () => {
|
|
61
|
+
const sb = supabase({ configOverrides: { db: { pooler: { enabled: true } } } });
|
|
62
|
+
const env = defineEnvironment({ name: "pooler-test", services: { supabase: sb.group } });
|
|
63
|
+
expect(env.config.services.supabase.dependsOn).toContain("supabase-pooler");
|
|
64
|
+
const pooler = sb.group.services({ name: "supabase", key: (p) => `supabase-${p}` }).pooler as ServiceDefinition;
|
|
65
|
+
expect(pooler.readyCheck!.type).toBe("exec");
|
|
66
|
+
if (pooler.readyCheck!.type !== "exec") throw new Error("Expected SQL probe");
|
|
67
|
+
expect(pooler.readyCheck!.command).toContain(sb.poolerUrl!);
|
|
68
|
+
expect(pooler.readyCheck!.command).toContain("ON_ERROR_STOP=1");
|
|
69
|
+
expect(pooler.command).toContain("PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("SQL readiness passes special credentials literally through the shell", async () => {
|
|
73
|
+
const sb = supabase({ dbPassword: "p@ss/word:'#{safe}%$()\\tail",
|
|
74
|
+
configOverrides: { db: { pooler: { enabled: true } } } });
|
|
75
|
+
const pooler = sb.group.services({ name: "supabase", key: (p) => `supabase-${p}` }).pooler as ServiceDefinition;
|
|
76
|
+
if (pooler.readyCheck!.type !== "exec") throw new Error("Expected SQL probe");
|
|
77
|
+
const child = Bun.spawn(["sh", "-c", `psql() { printf '%s\\n' "$@"; }; ${pooler.readyCheck!.command}`], {
|
|
78
|
+
stdout: "pipe", stderr: "pipe",
|
|
79
|
+
});
|
|
80
|
+
expect(await child.exited).toBe(0);
|
|
81
|
+
const args = (await new Response(child.stdout).text()).trim().split("\n");
|
|
82
|
+
expect(args[args.indexOf("--dbname") + 1]).toBe(sb.poolerUrl!);
|
|
83
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ServiceDefinition } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/** Supabase CLI's [db.pooler] settings. Ports are local to the pooler container. */
|
|
4
|
+
export interface SupabasePoolerConfig {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
port: number;
|
|
7
|
+
pool_mode: "transaction" | "session";
|
|
8
|
+
default_pool_size: number;
|
|
9
|
+
max_client_conn: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_POOLER_CONFIG: SupabasePoolerConfig = {
|
|
13
|
+
enabled: false,
|
|
14
|
+
port: 54329,
|
|
15
|
+
pool_mode: "transaction",
|
|
16
|
+
default_pool_size: 20,
|
|
17
|
+
max_client_conn: 100,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function setPoolerConfig(config: SupabasePoolerConfig, key: string, value: unknown): void {
|
|
21
|
+
const invalid = (expected: string): never => {
|
|
22
|
+
throw new Error(`supabase(): db.pooler.${key} must be ${expected}.`);
|
|
23
|
+
};
|
|
24
|
+
switch (key) {
|
|
25
|
+
case "enabled":
|
|
26
|
+
if (typeof value !== "boolean") invalid("a boolean");
|
|
27
|
+
config.enabled = value as boolean;
|
|
28
|
+
break;
|
|
29
|
+
case "pool_mode":
|
|
30
|
+
if (value !== "transaction" && value !== "session") invalid('"transaction" or "session"');
|
|
31
|
+
config.pool_mode = value as SupabasePoolerConfig["pool_mode"];
|
|
32
|
+
break;
|
|
33
|
+
case "port":
|
|
34
|
+
case "default_pool_size":
|
|
35
|
+
case "max_client_conn":
|
|
36
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 ||
|
|
37
|
+
(key === "port" && value > 65535)) invalid(key === "port" ? "an integer from 1 to 65535" : "a positive integer");
|
|
38
|
+
config[key] = value as number;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Based on supabase/cli's start/templates/pooler.exs and the self-hosted
|
|
44
|
+
// docker/volumes/pooler/pooler.exs. Values come from env, never Elixir source
|
|
45
|
+
// interpolation (passwords may contain quotes, backslashes or #{...}).
|
|
46
|
+
const TENANT_SCRIPT = `{:ok, _} = Application.ensure_all_started(:supavisor)
|
|
47
|
+
{:ok, version} =
|
|
48
|
+
case Supavisor.Repo.query!("select version()") do
|
|
49
|
+
%{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
|
|
50
|
+
end
|
|
51
|
+
params = %{
|
|
52
|
+
"external_id" => System.fetch_env!("POOLER_TENANT_ID"),
|
|
53
|
+
"db_host" => System.fetch_env!("POSTGRES_HOST"),
|
|
54
|
+
"db_port" => 5432,
|
|
55
|
+
"db_database" => "postgres",
|
|
56
|
+
"require_user" => false,
|
|
57
|
+
"auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
|
|
58
|
+
"default_max_clients" => String.to_integer(System.fetch_env!("POOLER_MAX_CLIENT_CONN")),
|
|
59
|
+
"default_pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
|
|
60
|
+
"default_parameter_status" => %{"server_version" => version},
|
|
61
|
+
"users" => [%{
|
|
62
|
+
"db_user" => "pgbouncer",
|
|
63
|
+
"db_password" => System.fetch_env!("POSTGRES_PASSWORD"),
|
|
64
|
+
"mode_type" => System.fetch_env!("POOLER_POOL_MODE"),
|
|
65
|
+
"pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
|
|
66
|
+
"is_manager" => true
|
|
67
|
+
}]
|
|
68
|
+
}
|
|
69
|
+
if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
|
|
70
|
+
{:ok, _} = Supavisor.Tenants.create_tenant(params)
|
|
71
|
+
end
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
export function supabasePoolerService(opts: {
|
|
75
|
+
config: SupabasePoolerConfig;
|
|
76
|
+
dbHost: string;
|
|
77
|
+
tenantId: string;
|
|
78
|
+
password: string;
|
|
79
|
+
jwtSecret: string;
|
|
80
|
+
tls: boolean;
|
|
81
|
+
url: string;
|
|
82
|
+
}): ServiceDefinition {
|
|
83
|
+
const { config, dbHost, tenantId, password, jwtSecret, tls, url } = opts;
|
|
84
|
+
const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`;
|
|
85
|
+
return {
|
|
86
|
+
image: { type: "registry", reference: "supabase/supavisor:2.9.12" },
|
|
87
|
+
// eval starts the OTP application too. Give that temporary instance
|
|
88
|
+
// ephemeral SQL ports so readiness only sees the final server process.
|
|
89
|
+
command: '/app/bin/migrate && PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval "$(cat /etc/pooler/pooler.exs)" && exec /app/bin/server',
|
|
90
|
+
env: {
|
|
91
|
+
// Bind all auxiliary listeners to ephemeral ports, so any valid pooler
|
|
92
|
+
// port can be used without colliding with its management/internal ports.
|
|
93
|
+
PORT: "0",
|
|
94
|
+
PROXY_PORT: "0",
|
|
95
|
+
SESSION_PROXY_PORTS: "0",
|
|
96
|
+
TRANSACTION_PROXY_PORTS: "0",
|
|
97
|
+
PROXY_PORT_SESSION: config.pool_mode === "session" ? String(config.port) : "0",
|
|
98
|
+
PROXY_PORT_TRANSACTION: config.pool_mode === "transaction" ? String(config.port) : "0",
|
|
99
|
+
POSTGRES_HOST: dbHost,
|
|
100
|
+
POSTGRES_PASSWORD: password,
|
|
101
|
+
DATABASE_URL: `ecto://supabase_admin:${encodeURIComponent(password)}@${dbHost}:5432/_supabase`,
|
|
102
|
+
// Metadata connections are separate from the user pool. Keep this small
|
|
103
|
+
// in a single-node test environment (upstream defaults to 25).
|
|
104
|
+
DB_POOL_SIZE: "2",
|
|
105
|
+
SECRET_KEY_BASE: "spectest-supavisor-local-secret-key-base-not-for-production-use-0000",
|
|
106
|
+
VAULT_ENC_KEY: "spectest".repeat(4),
|
|
107
|
+
API_JWT_SECRET: jwtSecret,
|
|
108
|
+
METRICS_JWT_SECRET: jwtSecret,
|
|
109
|
+
REGION: "local",
|
|
110
|
+
ERL_AFLAGS: "-proto_dist inet_tcp",
|
|
111
|
+
POOLER_TENANT_ID: tenantId,
|
|
112
|
+
POOLER_DEFAULT_POOL_SIZE: String(config.default_pool_size),
|
|
113
|
+
POOLER_MAX_CLIENT_CONN: String(config.max_client_conn),
|
|
114
|
+
POOLER_POOL_MODE: config.pool_mode,
|
|
115
|
+
...(tls ? {
|
|
116
|
+
GLOBAL_DOWNSTREAM_CERT_PATH: "/tls/pooler.crt",
|
|
117
|
+
GLOBAL_DOWNSTREAM_KEY_PATH: "/tls/pooler.key",
|
|
118
|
+
} : {}),
|
|
119
|
+
},
|
|
120
|
+
files: [{ path: "/etc/pooler/pooler.exs", content: TENANT_SCRIPT }],
|
|
121
|
+
...(tls ? { certificates: [{
|
|
122
|
+
hostnames: ["{{SPECTEST_SERVICE}}", "{{SPECTEST_SERVICE}}.internal"],
|
|
123
|
+
certPath: "/tls/pooler.crt",
|
|
124
|
+
keyPath: "/tls/pooler.key",
|
|
125
|
+
user: "root",
|
|
126
|
+
}] } : {}),
|
|
127
|
+
ports: [config.port],
|
|
128
|
+
dependsOn: ["db"],
|
|
129
|
+
// The pinned image includes psql. Test real tenant authentication and TLS,
|
|
130
|
+
// with bounded attempts retried by the normal service readiness loop.
|
|
131
|
+
readyCheck: {
|
|
132
|
+
type: "exec",
|
|
133
|
+
command: `PGCONNECT_TIMEOUT=3 PGOPTIONS='-c statement_timeout=3000' psql -X --dbname ${quotedUrl} -v ON_ERROR_STOP=1 -c 'SELECT 1'`,
|
|
134
|
+
timeoutSecs: 120,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -74,6 +74,7 @@ import { google } from "./google.js";
|
|
|
74
74
|
import { microsoft } from "./microsoft.js";
|
|
75
75
|
import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
|
|
76
76
|
import { postgresTls } from "./postgres-tls.js";
|
|
77
|
+
import { supabasePoolerService } from "./supabase-pooler.js";
|
|
77
78
|
import {
|
|
78
79
|
mapSupabaseConfig,
|
|
79
80
|
parseSupabaseConfigFile,
|
|
@@ -519,8 +520,11 @@ export interface SupabaseStack {
|
|
|
519
520
|
anonKey: string;
|
|
520
521
|
/** `service_role` API key (HS256 JWT). */
|
|
521
522
|
serviceRoleKey: string;
|
|
522
|
-
/** Direct Postgres URL
|
|
523
|
+
/** Direct Postgres URL as `postgres`. Use for migrations and LISTEN/NOTIFY. */
|
|
523
524
|
dbUrl: string;
|
|
525
|
+
/** Supavisor URL as `postgres.<name>`, present when [db.pooler].enabled is true.
|
|
526
|
+
* Uses the configured pool_mode and port; dbUrl remains a direct connection. */
|
|
527
|
+
poolerUrl?: string;
|
|
524
528
|
/** The gateway service key — use in a dependent service's `dependsOn` to wait
|
|
525
529
|
* for the whole stack (the gateway waits on the services behind it). */
|
|
526
530
|
ready: string;
|
|
@@ -1855,6 +1859,11 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1855
1859
|
// `auth`/`storage` schemas — regardless of ownership.
|
|
1856
1860
|
const adminDbUrl = `postgresql://supabase_admin:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
|
|
1857
1861
|
(dbTls ? "?sslmode=verify-full" : "");
|
|
1862
|
+
const pooler = mapped?.pooler.enabled ? mapped.pooler : undefined;
|
|
1863
|
+
const poolerUrl = pooler
|
|
1864
|
+
? `postgresql://${encodeURIComponent(`postgres.${name}`)}:${encodeURIComponent(dbPassword)}@${name}-pooler:${pooler.port}/postgres` +
|
|
1865
|
+
(dbTls ? "?sslmode=verify-full" : "")
|
|
1866
|
+
: undefined;
|
|
1858
1867
|
|
|
1859
1868
|
// ── social sign-in ────────────────────────────────────────────────────
|
|
1860
1869
|
// Every `[auth.external.<name>]` the config enables: served in-environment
|
|
@@ -1966,12 +1975,30 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1966
1975
|
"supabase_admin",
|
|
1967
1976
|
);
|
|
1968
1977
|
await applySqlFile(ctx, SUPABASE_WEBHOOKS_SQL, "database webhooks", "supabase_admin");
|
|
1978
|
+
if (pooler) {
|
|
1979
|
+
// The image supplies pgbouncer.get_auth, but the self-hosted
|
|
1980
|
+
// compose files normally create the pooler's metadata database.
|
|
1981
|
+
await applySqlFile(ctx, `ALTER USER pgbouncer WITH PASSWORD ${pgLiteral(dbPassword)};
|
|
1982
|
+
SELECT 'CREATE DATABASE _supabase OWNER supabase_admin'
|
|
1983
|
+
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '_supabase')
|
|
1984
|
+
\\gexec
|
|
1985
|
+
\\connect _supabase supabase_admin
|
|
1986
|
+
CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION supabase_admin;
|
|
1987
|
+
`, "pooler bootstrap", "supabase_admin");
|
|
1988
|
+
}
|
|
1969
1989
|
const extensions = extensionSql(opts.db?.extensions);
|
|
1970
1990
|
if (extensions) await applySqlFile(ctx, extensions, "configured extensions", "postgres");
|
|
1971
1991
|
},
|
|
1972
1992
|
};
|
|
1973
1993
|
parts.db = dbDef;
|
|
1974
1994
|
|
|
1995
|
+
if (pooler && poolerUrl) {
|
|
1996
|
+
parts.pooler = supabasePoolerService({
|
|
1997
|
+
config: pooler, dbHost: db, tenantId: name, password: dbPassword,
|
|
1998
|
+
jwtSecret, tls: !!dbTls, url: poolerUrl,
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1975
2002
|
// ── rest (PostgREST) ────────────────────────────────────────────
|
|
1976
2003
|
parts.rest = {
|
|
1977
2004
|
image: { type: "registry", reference: IMAGES.rest },
|
|
@@ -2395,6 +2422,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2395
2422
|
anonKey,
|
|
2396
2423
|
serviceRoleKey,
|
|
2397
2424
|
dbUrl,
|
|
2425
|
+
...(poolerUrl ? { poolerUrl } : {}),
|
|
2398
2426
|
ready: name,
|
|
2399
2427
|
...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
|
|
2400
2428
|
};
|
package/src/daemon.ts
CHANGED
|
@@ -6177,6 +6177,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6177
6177
|
// new method. Check the loaded project, including after warm restore.
|
|
6178
6178
|
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
6179
6179
|
replayEnvironmentMode: true,
|
|
6180
|
+
inFlightTest: state.inFlightTest !== null,
|
|
6180
6181
|
}),
|
|
6181
6182
|
|
|
6182
6183
|
// Live bootstrap progress, polled by the control plane during
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { harnessMethods } from "../daemon";
|
|
3
|
+
|
|
4
|
+
test("health reports pending work without acquiring its execution slot", async () => {
|
|
5
|
+
const state = {
|
|
6
|
+
inFlightTest: null as Promise<unknown> | null,
|
|
7
|
+
inFlightBootstrap: null,
|
|
8
|
+
inFlightProjectSetup: null,
|
|
9
|
+
};
|
|
10
|
+
const methods = harnessMethods(state);
|
|
11
|
+
expect((await methods.health({}) as any).inFlightTest).toBe(false);
|
|
12
|
+
let finish!: () => void;
|
|
13
|
+
state.inFlightTest = new Promise<void>((resolve) => { finish = resolve; });
|
|
14
|
+
expect((await methods.health({}) as any).inFlightTest).toBe(true);
|
|
15
|
+
finish();
|
|
16
|
+
await state.inFlightTest;
|
|
17
|
+
// Completion is acknowledged by the execution-slot owner, not just a request timeout.
|
|
18
|
+
state.inFlightTest = null;
|
|
19
|
+
expect((await methods.health({}) as any).inFlightTest).toBe(false);
|
|
20
|
+
});
|