@specific.dev/spectest 0.113.0 → 0.115.0
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/README.md +28 -0
- 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/index.d.ts +16 -0
- package/dist/index.js +13 -4
- package/dist/interactive.d.ts +18 -0
- package/dist/interactive.js +39 -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/index.ts +25 -4
- package/src/interactive-setup.test.ts +7 -2
- package/src/interactive.test.ts +79 -0
- package/src/interactive.ts +41 -0
package/README.md
CHANGED
|
@@ -20,6 +20,34 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
|
20
20
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
21
21
|
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
22
22
|
|
|
23
|
+
## Interactive entrypoints (0.114.0)
|
|
24
|
+
|
|
25
|
+
Choose the links shown in the interactive dashboard and PR preview comment:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const env = defineEnvironment({
|
|
29
|
+
name: "my-app",
|
|
30
|
+
services: { web, api },
|
|
31
|
+
interactive: {
|
|
32
|
+
instructions: "Sign in as demo@example.com and use 000000 as the code",
|
|
33
|
+
entrypoints: [
|
|
34
|
+
{ name: "App", url: "https://app.test" },
|
|
35
|
+
{ name: "Admin", url: "https://app.test/admin" },
|
|
36
|
+
{ name: "API shell", terminal: "api" },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
List order is preserved. URLs must match declared service hostnames and are
|
|
43
|
+
translated to public preview URLs, retaining paths, queries and fragments.
|
|
44
|
+
Terminal targets are typed service names and use dashboard member permissions.
|
|
45
|
+
Entrypoints are display metadata; they do not change exposure or test execution.
|
|
46
|
+
With no entries, the only preview link is **Open environment**. Optional
|
|
47
|
+
`interactive.instructions` is a concise, direct plain-text sign-in description,
|
|
48
|
+
shown in a collapsed **Instructions** section in the dashboard and PR
|
|
49
|
+
comment. Blank instructions are omitted.
|
|
50
|
+
|
|
23
51
|
|
|
24
52
|
## Microphone fixtures and WebSocket fakes (0.103.0)
|
|
25
53
|
|
|
@@ -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/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
2
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
3
3
|
import type { Android, AndroidApp } from "./android.js";
|
|
4
|
+
import { type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
|
|
5
|
+
export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
|
|
4
6
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
5
7
|
import { strict as nodeAssert } from "node:assert";
|
|
6
8
|
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
|
|
@@ -69,6 +71,11 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
|
69
71
|
services: S;
|
|
70
72
|
/** Sandbox timeout in seconds (default 1h). */
|
|
71
73
|
timeoutSecs?: number;
|
|
74
|
+
/** Display metadata only; entrypoints retain their author-declared order. */
|
|
75
|
+
interactive?: {
|
|
76
|
+
entrypoints: InteractiveEntrypoint[];
|
|
77
|
+
instructions?: string;
|
|
78
|
+
};
|
|
72
79
|
}
|
|
73
80
|
/**
|
|
74
81
|
* The shape that ends up in the JSON config the control plane and Rust
|
|
@@ -1717,6 +1724,15 @@ export interface EnvironmentInput<SI extends InputServicesMap, F extends FakesMa
|
|
|
1717
1724
|
/** Services and/or service groups, keyed by name. See
|
|
1718
1725
|
* {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
|
|
1719
1726
|
services: SI;
|
|
1727
|
+
/** Links highlighted in the dashboard and PR previews, in list order.
|
|
1728
|
+
* URL entries target declared service hostnames; terminal entries name services. */
|
|
1729
|
+
interactive?: {
|
|
1730
|
+
entrypoints?: InteractiveEntrypoints<keyof NoInfer<ExpandServices<SI>> & string>;
|
|
1731
|
+
/** Concise, direct plain-text sign-in instructions shown in the dashboard
|
|
1732
|
+
* and a collapsed PR comment section. Example: "Sign in as demo@example.com
|
|
1733
|
+
* and use 000000 as the code". */
|
|
1734
|
+
instructions?: string;
|
|
1735
|
+
};
|
|
1720
1736
|
/** Sandbox timeout in seconds (default 1h). */
|
|
1721
1737
|
timeoutSecs?: number;
|
|
1722
1738
|
/** Fake servers — see {@link defineFake}. Keyed by stable name; the key
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveEntrypoints } from "./interactive.js";
|
|
1
2
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
2
3
|
// `defineEnvironment({ name, services })` once, defines test cases via
|
|
3
4
|
// the returned `env.test(...)`, and default-exports `env.project([...])`.
|
|
@@ -422,10 +423,9 @@ export function defineFake(opts) {
|
|
|
422
423
|
* produces the file's default export.
|
|
423
424
|
*/
|
|
424
425
|
export function defineEnvironment(input) {
|
|
425
|
-
// Split fakes off the wire config
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
const { fakes, services: rawServices, ...rest } = input;
|
|
426
|
+
// Split fakes off the wire config, expand service groups, and validate
|
|
427
|
+
// interactive destinations against the final service names.
|
|
428
|
+
const { fakes, interactive, services: rawServices, ...rest } = input;
|
|
429
429
|
const expanded = expandServiceGroups(rawServices);
|
|
430
430
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
431
431
|
for (const build of svc.imageBuilds ?? [])
|
|
@@ -440,6 +440,15 @@ export function defineEnvironment(input) {
|
|
|
440
440
|
services: expanded,
|
|
441
441
|
};
|
|
442
442
|
validateEnvironmentConfig(config);
|
|
443
|
+
if (interactive?.instructions !== undefined && typeof interactive.instructions !== "string") {
|
|
444
|
+
throw new Error("interactive.instructions must be a string");
|
|
445
|
+
}
|
|
446
|
+
if (interactive?.entrypoints !== undefined || interactive?.instructions !== undefined) {
|
|
447
|
+
config.interactive = {
|
|
448
|
+
entrypoints: interactive.entrypoints === undefined ? [] : resolveEntrypoints(interactive.entrypoints, config.services),
|
|
449
|
+
...(interactive.instructions === undefined ? {} : { instructions: interactive.instructions }),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
443
452
|
if (fakes)
|
|
444
453
|
validateFakes(config, fakes);
|
|
445
454
|
// Every test created via this env's `.test(...)` is registered here. A
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** One destination highlighted in interactive environments and PR previews. */
|
|
2
|
+
export type InteractiveEntrypoint<Service extends string = string> = {
|
|
3
|
+
name: string;
|
|
4
|
+
} & ({
|
|
5
|
+
url: string;
|
|
6
|
+
terminal?: never;
|
|
7
|
+
} | {
|
|
8
|
+
terminal: Service;
|
|
9
|
+
url?: never;
|
|
10
|
+
});
|
|
11
|
+
/** Display order is exactly the order of this list. */
|
|
12
|
+
export type InteractiveEntrypoints<Service extends string = string> = readonly InteractiveEntrypoint<Service>[];
|
|
13
|
+
export declare function resolveEntrypoints(entries: InteractiveEntrypoints, services: Record<string, {
|
|
14
|
+
hostnames?: readonly string[];
|
|
15
|
+
tls?: readonly {
|
|
16
|
+
hostname: string;
|
|
17
|
+
}[];
|
|
18
|
+
}>): InteractiveEntrypoint[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function resolveEntrypoints(entries, services) {
|
|
2
|
+
if (!Array.isArray(entries))
|
|
3
|
+
throw new Error("interactive.entrypoints must be a list");
|
|
4
|
+
const names = new Set();
|
|
5
|
+
return entries.map((entry, index) => {
|
|
6
|
+
const fail = (reason) => { throw new Error(`interactive.entrypoints[${index}]: ${reason}`); };
|
|
7
|
+
if (!entry || typeof entry.name !== "string" || !entry.name.trim())
|
|
8
|
+
fail("name must be a non-empty string");
|
|
9
|
+
const { name } = entry;
|
|
10
|
+
if (names.has(name))
|
|
11
|
+
fail(`duplicate name ${JSON.stringify(name)}`);
|
|
12
|
+
names.add(name);
|
|
13
|
+
if ((entry.url !== undefined) === (entry.terminal !== undefined))
|
|
14
|
+
fail("specify exactly one of url or terminal");
|
|
15
|
+
if (entry.terminal !== undefined) {
|
|
16
|
+
if (typeof entry.terminal !== "string" || !Object.hasOwn(services, entry.terminal)) {
|
|
17
|
+
fail("terminal must name a declared service");
|
|
18
|
+
}
|
|
19
|
+
return { name, terminal: entry.terminal };
|
|
20
|
+
}
|
|
21
|
+
if (typeof entry.url !== "string")
|
|
22
|
+
return fail("expected an absolute HTTP or HTTPS URL");
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(entry.url);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return fail("expected an absolute HTTP or HTTPS URL");
|
|
29
|
+
}
|
|
30
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port) {
|
|
31
|
+
fail("use an HTTP or HTTPS service URL without credentials or a non-default port");
|
|
32
|
+
}
|
|
33
|
+
const declared = Object.values(services).some((service) => [...(service.hostnames ?? []), ...(service.tls ?? []).map((tls) => tls.hostname)]
|
|
34
|
+
.some((host) => !host.startsWith("*.") && host.toLowerCase() === url.hostname));
|
|
35
|
+
if (!declared)
|
|
36
|
+
fail(`hostname ${JSON.stringify(url.hostname)} is not a declared service endpoint`);
|
|
37
|
+
return { name, url: url.href };
|
|
38
|
+
});
|
|
39
|
+
}
|
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/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
2
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
3
3
|
import type { Android, AndroidApp } from "./android.js";
|
|
4
|
+
import { resolveEntrypoints, type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
|
|
5
|
+
export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
|
|
4
6
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
5
7
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
6
8
|
// `defineEnvironment({ name, services })` once, defines test cases via
|
|
@@ -245,6 +247,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
|
245
247
|
services: S;
|
|
246
248
|
/** Sandbox timeout in seconds (default 1h). */
|
|
247
249
|
timeoutSecs?: number;
|
|
250
|
+
/** Display metadata only; entrypoints retain their author-declared order. */
|
|
251
|
+
interactive?: { entrypoints: InteractiveEntrypoint[]; instructions?: string };
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
/**
|
|
@@ -2481,6 +2485,15 @@ export interface EnvironmentInput<
|
|
|
2481
2485
|
/** Services and/or service groups, keyed by name. See
|
|
2482
2486
|
* {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
|
|
2483
2487
|
services: SI;
|
|
2488
|
+
/** Links highlighted in the dashboard and PR previews, in list order.
|
|
2489
|
+
* URL entries target declared service hostnames; terminal entries name services. */
|
|
2490
|
+
interactive?: {
|
|
2491
|
+
entrypoints?: InteractiveEntrypoints<keyof NoInfer<ExpandServices<SI>> & string>;
|
|
2492
|
+
/** Concise, direct plain-text sign-in instructions shown in the dashboard
|
|
2493
|
+
* and a collapsed PR comment section. Example: "Sign in as demo@example.com
|
|
2494
|
+
* and use 000000 as the code". */
|
|
2495
|
+
instructions?: string;
|
|
2496
|
+
};
|
|
2484
2497
|
/** Sandbox timeout in seconds (default 1h). */
|
|
2485
2498
|
timeoutSecs?: number;
|
|
2486
2499
|
/** Fake servers — see {@link defineFake}. Keyed by stable name; the key
|
|
@@ -2591,10 +2604,9 @@ export function defineEnvironment<
|
|
|
2591
2604
|
F extends FakesMap = FakesMap,
|
|
2592
2605
|
>(input: EnvironmentInput<SI, F>): DefinedEnvironment<ExpandServices<SI>, F> {
|
|
2593
2606
|
type S = ExpandServices<SI>;
|
|
2594
|
-
// Split fakes off the wire config
|
|
2595
|
-
//
|
|
2596
|
-
|
|
2597
|
-
const { fakes, services: rawServices, ...rest } = input;
|
|
2607
|
+
// Split fakes off the wire config, expand service groups, and validate
|
|
2608
|
+
// interactive destinations against the final service names.
|
|
2609
|
+
const { fakes, interactive, services: rawServices, ...rest } = input;
|
|
2598
2610
|
const expanded = expandServiceGroups(rawServices);
|
|
2599
2611
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
2600
2612
|
for (const build of svc.imageBuilds ?? []) validateServiceImage(`${key} image ${build.reference}`, build.image);
|
|
@@ -2608,6 +2620,15 @@ export function defineEnvironment<
|
|
|
2608
2620
|
services: expanded as S,
|
|
2609
2621
|
};
|
|
2610
2622
|
validateEnvironmentConfig(config);
|
|
2623
|
+
if (interactive?.instructions !== undefined && typeof interactive.instructions !== "string") {
|
|
2624
|
+
throw new Error("interactive.instructions must be a string");
|
|
2625
|
+
}
|
|
2626
|
+
if (interactive?.entrypoints !== undefined || interactive?.instructions !== undefined) {
|
|
2627
|
+
config.interactive = {
|
|
2628
|
+
entrypoints: interactive.entrypoints === undefined ? [] : resolveEntrypoints(interactive.entrypoints, config.services),
|
|
2629
|
+
...(interactive.instructions === undefined ? {} : { instructions: interactive.instructions }),
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2611
2632
|
if (fakes) validateFakes(config, fakes);
|
|
2612
2633
|
|
|
2613
2634
|
// Every test created via this env's `.test(...)` is registered here. A
|
|
@@ -16,7 +16,10 @@ for (const withHook of [false, true]) {
|
|
|
16
16
|
await writeFile(entry, `
|
|
17
17
|
import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
|
|
18
18
|
export const calls = [];
|
|
19
|
-
const env = defineEnvironment({ name: "seed",
|
|
19
|
+
const env = defineEnvironment({ name: "seed", interactive: {
|
|
20
|
+
entrypoints: [{ name: "App shell", terminal: "app" }],
|
|
21
|
+
instructions: "Sign in as demo@example.com and use 000000 as the code",
|
|
22
|
+
}, services: {
|
|
20
23
|
app: { image: { type: "registry", reference: "busybox" },
|
|
21
24
|
helpers: () => ({ label: "typed helper" }) },
|
|
22
25
|
}});
|
|
@@ -43,7 +46,9 @@ for (const withHook of [false, true]) {
|
|
|
43
46
|
import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
|
|
44
47
|
import { calls } from ${JSON.stringify(entry)};
|
|
45
48
|
const nativeFetch = globalThis.fetch;
|
|
46
|
-
await METHODS.load({});
|
|
49
|
+
const loaded = await METHODS.load({});
|
|
50
|
+
assert.deepEqual(loaded.environment.interactive.entrypoints, [{ name: "App shell", terminal: "app" }]);
|
|
51
|
+
assert.equal(loaded.environment.interactive.instructions, "Sign in as demo@example.com and use 000000 as the code");
|
|
47
52
|
assert.equal((await METHODS.health({})).setupInteractive, ${withHook});
|
|
48
53
|
await METHODS.projectSetup({});
|
|
49
54
|
assert.deepEqual(calls, ["baseline"]);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineEnvironment, serviceGroup, type InteractiveEntrypoints } from "./index.js";
|
|
3
|
+
|
|
4
|
+
const services = {
|
|
5
|
+
web: { image: { type: "registry" as const, reference: "nginx" }, tls: [{ hostname: "app.test", port: 80 }] },
|
|
6
|
+
api: { image: { type: "registry" as const, reference: "nginx" }, hostnames: ["api.test"] },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("interactive entrypoints", () => {
|
|
10
|
+
test("instructions survive serialization without any entrypoints", () => {
|
|
11
|
+
const instructions = "Sign in as demo@example.com and use 000000 as the code";
|
|
12
|
+
const env = defineEnvironment({ name: "app", services, interactive: { instructions } });
|
|
13
|
+
expect(JSON.parse(JSON.stringify(env.project().environment)).interactive).toEqual({ entrypoints: [], instructions });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("instructions must be a string", () => {
|
|
17
|
+
const invalid = () => defineEnvironment({ name: "app", services, interactive: {
|
|
18
|
+
// @ts-expect-error instructions are plain text
|
|
19
|
+
instructions: 123,
|
|
20
|
+
} });
|
|
21
|
+
expect(invalid).toThrow("interactive.instructions must be a string");
|
|
22
|
+
});
|
|
23
|
+
test("keeps mixed destinations in list order on the project wire config", () => {
|
|
24
|
+
const env = defineEnvironment({ name: "app", services, interactive: { entrypoints: [
|
|
25
|
+
{ name: "Website", url: "https://app.test/admin?tab=users#invite" },
|
|
26
|
+
{ name: "2", terminal: "api" },
|
|
27
|
+
{ name: "1", url: "http://api.test/docs" },
|
|
28
|
+
] } });
|
|
29
|
+
const config = JSON.parse(JSON.stringify(env.project().environment));
|
|
30
|
+
expect(config.interactive.entrypoints).toEqual([
|
|
31
|
+
{ name: "Website", url: "https://app.test/admin?tab=users#invite" },
|
|
32
|
+
{ name: "2", terminal: "api" },
|
|
33
|
+
{ name: "1", url: "http://api.test/docs" },
|
|
34
|
+
]);
|
|
35
|
+
expect(env.project().tests).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("omitting entrypoints leaves existing projects unchanged", () => {
|
|
39
|
+
expect(defineEnvironment({ name: "app", services }).config.interactive).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("terminal destinations can name expanded service-group members", () => {
|
|
43
|
+
const stack = serviceGroup({ primary: "api", services: () => ({ api: services.api, worker: services.web }) });
|
|
44
|
+
const env = defineEnvironment({ name: "app", services: { stack }, interactive: {
|
|
45
|
+
entrypoints: [{ name: "Worker shell", terminal: "stack-worker" }],
|
|
46
|
+
} });
|
|
47
|
+
expect(env.config.interactive?.entrypoints).toEqual([{ name: "Worker shell", terminal: "stack-worker" }]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test.each([null, { App: "https://app.test" }])("requires a list: %j", (entries) => {
|
|
51
|
+
expect(() => defineEnvironment({ name: "app", services, interactive: {
|
|
52
|
+
entrypoints: entries as unknown as InteractiveEntrypoints<"web" | "api">,
|
|
53
|
+
} })).toThrow("must be a list");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("terminal service names are checked statically and at runtime", () => {
|
|
57
|
+
const invalid = () => defineEnvironment({ name: "app", services, interactive: { entrypoints: [
|
|
58
|
+
// @ts-expect-error terminal targets must be declared services
|
|
59
|
+
{ name: "Shell", terminal: "missing" },
|
|
60
|
+
] } });
|
|
61
|
+
expect(invalid).toThrow("terminal must name a declared service");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test.each([
|
|
65
|
+
[{ name: "App", url: "https://external.test" }],
|
|
66
|
+
[{ name: "App", url: "/relative" }],
|
|
67
|
+
[{ name: "App", url: "javascript:alert(1)" }],
|
|
68
|
+
[{ name: "App", url: "https://user:pass@app.test" }],
|
|
69
|
+
[{ name: "App", url: "https://app.test:8443" }],
|
|
70
|
+
[{ name: "App", url: "https://app.test", terminal: "web" }],
|
|
71
|
+
[{ name: "App" }],
|
|
72
|
+
[{ name: "", terminal: "web" }],
|
|
73
|
+
[{ name: "Same", terminal: "web" }, { name: "Same", terminal: "api" }],
|
|
74
|
+
])("rejects invalid destinations %j", (...entries) => {
|
|
75
|
+
expect(() => defineEnvironment({ name: "app", services, interactive: {
|
|
76
|
+
entrypoints: entries as unknown as InteractiveEntrypoints<"web" | "api">,
|
|
77
|
+
} })).toThrow("interactive.entrypoints");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** One destination highlighted in interactive environments and PR previews. */
|
|
2
|
+
export type InteractiveEntrypoint<Service extends string = string> = { name: string } & (
|
|
3
|
+
| { url: string; terminal?: never }
|
|
4
|
+
| { terminal: Service; url?: never }
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
/** Display order is exactly the order of this list. */
|
|
8
|
+
export type InteractiveEntrypoints<Service extends string = string> = readonly InteractiveEntrypoint<Service>[];
|
|
9
|
+
|
|
10
|
+
export function resolveEntrypoints(
|
|
11
|
+
entries: InteractiveEntrypoints,
|
|
12
|
+
services: Record<string, { hostnames?: readonly string[]; tls?: readonly { hostname: string }[] }>,
|
|
13
|
+
): InteractiveEntrypoint[] {
|
|
14
|
+
if (!Array.isArray(entries)) throw new Error("interactive.entrypoints must be a list");
|
|
15
|
+
const names = new Set<string>();
|
|
16
|
+
return entries.map((entry, index) => {
|
|
17
|
+
const fail = (reason: string): never => { throw new Error(`interactive.entrypoints[${index}]: ${reason}`); };
|
|
18
|
+
if (!entry || typeof entry.name !== "string" || !entry.name.trim()) fail("name must be a non-empty string");
|
|
19
|
+
const { name } = entry;
|
|
20
|
+
if (names.has(name)) fail(`duplicate name ${JSON.stringify(name)}`);
|
|
21
|
+
names.add(name);
|
|
22
|
+
if ((entry.url !== undefined) === (entry.terminal !== undefined)) fail("specify exactly one of url or terminal");
|
|
23
|
+
if (entry.terminal !== undefined) {
|
|
24
|
+
if (typeof entry.terminal !== "string" || !Object.hasOwn(services, entry.terminal)) {
|
|
25
|
+
fail("terminal must name a declared service");
|
|
26
|
+
}
|
|
27
|
+
return { name, terminal: entry.terminal };
|
|
28
|
+
}
|
|
29
|
+
if (typeof entry.url !== "string") return fail("expected an absolute HTTP or HTTPS URL");
|
|
30
|
+
let url: URL;
|
|
31
|
+
try { url = new URL(entry.url); } catch { return fail("expected an absolute HTTP or HTTPS URL"); }
|
|
32
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port) {
|
|
33
|
+
fail("use an HTTP or HTTPS service URL without credentials or a non-default port");
|
|
34
|
+
}
|
|
35
|
+
const declared = Object.values(services).some((service) =>
|
|
36
|
+
[...(service.hostnames ?? []), ...(service.tls ?? []).map((tls) => tls.hostname)]
|
|
37
|
+
.some((host) => !host.startsWith("*.") && host.toLowerCase() === url.hostname));
|
|
38
|
+
if (!declared) fail(`hostname ${JSON.stringify(url.hostname)} is not a declared service endpoint`);
|
|
39
|
+
return { name, url: url.href };
|
|
40
|
+
});
|
|
41
|
+
}
|