mirror of https://github.com/garrytan/gstack.git
fix(gbrain): DSNs percent-encode the password; body reads retry; stdout drains
Three codex-adversarial findings in the provision port: (1) raw DB_PASS interpolation — a reserved character (/ # ? % @) restructured the URI, provisioning succeeded, and every consumer then failed to parse the DSN (unusable billable orphan); now encodeURIComponent, round-trip pinned. (2) await res.text() sat outside the transport try — a server that sent headers then reset the stream was an uncaught exit 1 instead of a retry-then-exit-8. (3) The bin entrypoint called process.exit() after unawaited stdout writes, truncating piped JSON; exitCode lets writes drain.
This commit is contained in:
parent
7e17691af4
commit
127eb33bc6
|
|
@ -17,10 +17,13 @@
|
||||||
|
|
||||||
import { runProvision } from '../lib/gbrain-supabase-provision';
|
import { runProvision } from '../lib/gbrain-supabase-provision';
|
||||||
|
|
||||||
|
// exitCode, not process.exit(): exit() drops pending stdout writes, which
|
||||||
|
// truncates piped JSON / large listings; setting exitCode lets writes drain
|
||||||
|
// and the process exit naturally.
|
||||||
runProvision(process.argv.slice(2)).then(
|
runProvision(process.argv.slice(2)).then(
|
||||||
(code) => process.exit(code),
|
(code) => { process.exitCode = code; },
|
||||||
(error) => {
|
(error) => {
|
||||||
process.stderr.write(`gstack-gbrain-supabase-provision: ${(error as Error)?.stack ?? error}\n`);
|
process.stderr.write(`gstack-gbrain-supabase-provision: ${(error as Error)?.stack ?? error}\n`);
|
||||||
process.exit(1);
|
process.exitCode = 1;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,7 @@ async function apiCall(ctx: Ctx, method: string, apipath: string, body?: string)
|
||||||
}
|
}
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
let text: string;
|
||||||
try {
|
try {
|
||||||
res = await ctx.fetchImpl(url, {
|
res = await ctx.fetchImpl(url, {
|
||||||
method,
|
method,
|
||||||
|
|
@ -262,6 +263,10 @@ async function apiCall(ctx: Ctx, method: string, apipath: string, body?: string)
|
||||||
body,
|
body,
|
||||||
signal: AbortSignal.timeout(CURL_TIMEOUT_MS),
|
signal: AbortSignal.timeout(CURL_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
// Body read stays INSIDE the transport try: a server that sends
|
||||||
|
// headers then resets or stalls the stream is a transport failure
|
||||||
|
// (retry, then exit 8) — not an uncaught exception at exit 1.
|
||||||
|
text = await res.text();
|
||||||
} catch {
|
} catch {
|
||||||
// Transport failure (connect refused, timeout, DNS). Best-effort
|
// Transport failure (connect refused, timeout, DNS). Best-effort
|
||||||
// outcome record, then retry — same as the bash curl-failed branch.
|
// outcome record, then retry — same as the bash curl-failed branch.
|
||||||
|
|
@ -278,7 +283,6 @@ async function apiCall(ctx: Ctx, method: string, apipath: string, body?: string)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
try {
|
try {
|
||||||
writeOutcome({ env: ctx.env, receipt: receiptId, status: 'exit:0' });
|
writeOutcome({ env: ctx.env, receipt: receiptId, status: 'exit:0' });
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -508,7 +512,11 @@ async function cmdPoolerUrl(ctx: Ctx, args: string[]): Promise<void> {
|
||||||
poolMode = 'session';
|
poolMode = 'session';
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `postgresql://${dbUser}:${dbPass}@${dbHost}:${dbPort}/${dbName}`;
|
// Percent-encode the password segment: DB_PASS is caller-controlled and a
|
||||||
|
// reserved character (/ # ? % @) changes URI structure — the project
|
||||||
|
// provisions fine and then every consumer fails to parse the DSN, leaving
|
||||||
|
// an unusable billable orphan.
|
||||||
|
const url = `postgresql://${dbUser}:${encodeURIComponent(dbPass)}@${dbHost}:${dbPort}/${dbName}`;
|
||||||
|
|
||||||
if (jsonMode) {
|
if (jsonMode) {
|
||||||
ctx.stdout(JSON.stringify({ ref, pooler_url: url }, null, 2) + '\n');
|
ctx.stdout(JSON.stringify({ ref, pooler_url: url }, null, 2) + '\n');
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,27 @@ describe('pooler-url', () => {
|
||||||
expect(j.pooler_url).not.toContain('[PASSWORD]');
|
expect(j.pooler_url).not.toContain('[PASSWORD]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('percent-encodes reserved characters in DB_PASS (DSN stays parseable)', async () => {
|
||||||
|
// Raw interpolation of a password containing / # ? % @ changes URI
|
||||||
|
// structure: provisioning succeeds, every consumer then fails to parse
|
||||||
|
// the DSN — an unusable billable orphan.
|
||||||
|
mock = startMock({
|
||||||
|
[`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp(POOLER_OK),
|
||||||
|
});
|
||||||
|
const r = await runCmd(['pooler-url', REF, '--json'], {
|
||||||
|
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||||
|
DB_PASS: 'p@ss/w#rd?100%',
|
||||||
|
SUPABASE_API_BASE: mock.url,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
const j = JSON.parse(r.stdout);
|
||||||
|
expect(j.pooler_url).toBe(
|
||||||
|
`postgresql://postgres.${REF}:${encodeURIComponent('p@ss/w#rd?100%')}@aws-0-us-east-1.pooler.supabase.com:6543/postgres`
|
||||||
|
);
|
||||||
|
// The password segment must parse back out intact.
|
||||||
|
expect(decodeURIComponent(new URL(j.pooler_url).password)).toBe('p@ss/w#rd?100%');
|
||||||
|
});
|
||||||
|
|
||||||
test('handles array response by preferring session pool_mode entry', async () => {
|
test('handles array response by preferring session pool_mode entry', async () => {
|
||||||
mock = startMock({
|
mock = startMock({
|
||||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue