@everystack/cli 0.4.4 → 0.4.5
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/package.json +1 -1
- package/src/cli/commands/db.ts +97 -14
package/package.json
CHANGED
package/src/cli/commands/db.ts
CHANGED
|
@@ -144,6 +144,69 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** The username in a postgres URL — the only part of a stored secret we ever display. */
|
|
148
|
+
function roleOfUrl(url: string | undefined): string | null {
|
|
149
|
+
const match = url?.match(/^postgres(?:ql)?:\/\/([^:@/]+)[:@]/);
|
|
150
|
+
return match ? match[1] : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ProvisionSecretPlan {
|
|
154
|
+
/** Key → URL updates, merged into the store in ONE write. */
|
|
155
|
+
updates: Record<string, string>;
|
|
156
|
+
/** Per-secret before → after role notes (roles only — never a credential). */
|
|
157
|
+
notes: string[];
|
|
158
|
+
/** Loud warnings: flipping a live secret, an unverified admin login, an old server. */
|
|
159
|
+
warnings: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide what db:provision writes and what it announces. Both spellings of each secret
|
|
164
|
+
* are written — PascalCase is the only name an `sst.Secret` component can carry (so the
|
|
165
|
+
* wiring text and the store finally agree), the raw name keeps env-style consumers and
|
|
166
|
+
* `secrets export` working. Pure, so the announcement contract is pinned by tests.
|
|
167
|
+
*/
|
|
168
|
+
export function buildProvisionSecretPlan(args: {
|
|
169
|
+
result: { loginRole: string; adminRole?: string; adminVerified?: boolean | null };
|
|
170
|
+
authUrl: string;
|
|
171
|
+
adminUrl?: string;
|
|
172
|
+
existing: Record<string, string>;
|
|
173
|
+
}): ProvisionSecretPlan {
|
|
174
|
+
const { result, authUrl, adminUrl, existing } = args;
|
|
175
|
+
const updates: Record<string, string> = {
|
|
176
|
+
DATABASE_URL: authUrl,
|
|
177
|
+
DatabaseUrl: authUrl,
|
|
178
|
+
};
|
|
179
|
+
const notes: string[] = [];
|
|
180
|
+
const warnings: string[] = [];
|
|
181
|
+
|
|
182
|
+
const prevAuthRole = roleOfUrl(existing.DATABASE_URL ?? existing.DatabaseUrl);
|
|
183
|
+
notes.push(`DATABASE_URL / DatabaseUrl: ${prevAuthRole ?? 'unset'} → ${result.loginRole}`);
|
|
184
|
+
if (prevAuthRole && prevAuthRole !== result.loginRole) {
|
|
185
|
+
warnings.push(
|
|
186
|
+
`DATABASE_URL was already set (role '${prevAuthRole}') — any function linked to it connects as '${result.loginRole}' after its next cold start. Ensure grants are in place: everystack db:reconcile && everystack db:doctor.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (result.adminRole && adminUrl) {
|
|
191
|
+
updates.ADMIN_DATABASE_URL = adminUrl;
|
|
192
|
+
updates.AdminDatabaseUrl = adminUrl;
|
|
193
|
+
const prevAdminRole = roleOfUrl(existing.AdminDatabaseUrl ?? existing.ADMIN_DATABASE_URL);
|
|
194
|
+
const verified = result.adminVerified === true ? 'login verified' : 'login NOT verified';
|
|
195
|
+
notes.push(`AdminDatabaseUrl / ADMIN_DATABASE_URL: ${prevAdminRole ?? 'unset'} → ${result.adminRole} (${verified})`);
|
|
196
|
+
if (result.adminVerified !== true) {
|
|
197
|
+
warnings.push(
|
|
198
|
+
`The '${result.adminRole}' login could not be verified from the ops function — confirm operator connectivity before relying on it: everystack db:doctor.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
warnings.push(
|
|
203
|
+
'No operator login was created — this @everystack/server predates the both-sides split (<0.3.7). Operator tasks stay on the linked Postgres master; upgrade the server pin and re-run db:provision for the full credential split.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { updates, notes, warnings };
|
|
208
|
+
}
|
|
209
|
+
|
|
147
210
|
export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
|
|
148
211
|
if (!flags.stage) {
|
|
149
212
|
fail('--stage is required for db:provision (it writes the DATABASE_URL secret for that stage)');
|
|
@@ -163,26 +226,32 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
163
226
|
info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
|
|
164
227
|
step('Provisioning roles...');
|
|
165
228
|
|
|
166
|
-
// Generate
|
|
167
|
-
// the secret store and
|
|
229
|
+
// Generate BOTH login passwords and keep them in memory only — they are written straight
|
|
230
|
+
// into the secret store and are NEVER printed, logged, or returned to a human.
|
|
168
231
|
const { randomBytes } = await import('node:crypto');
|
|
169
232
|
const authPassword = randomBytes(24).toString('hex');
|
|
233
|
+
const adminPassword = randomBytes(24).toString('hex');
|
|
170
234
|
|
|
171
235
|
let result: any;
|
|
172
236
|
try {
|
|
173
|
-
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword });
|
|
237
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
|
|
174
238
|
} catch (err: any) {
|
|
175
239
|
fail(`db:provision failed: ${err.message}`);
|
|
176
240
|
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
177
241
|
process.exit(1);
|
|
178
242
|
}
|
|
179
243
|
if (result?.error) {
|
|
244
|
+
// Includes the admin-verification refusal: the action set NO authenticator password,
|
|
245
|
+
// so nothing here rewrites DATABASE_URL — the working credentials are untouched.
|
|
180
246
|
fail(`db:provision failed: ${result.error}`);
|
|
181
247
|
process.exit(1);
|
|
182
248
|
}
|
|
183
249
|
success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
|
|
250
|
+
if (result.adminRole) {
|
|
251
|
+
success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${result.adminVerified === true ? 'connectivity verified' : 'connectivity NOT verified'}`);
|
|
252
|
+
}
|
|
184
253
|
|
|
185
|
-
// Resolve host/port/db (not secret) to build the connection
|
|
254
|
+
// Resolve host/port/db (not secret) to build the connection strings in memory.
|
|
186
255
|
let conn: any;
|
|
187
256
|
try {
|
|
188
257
|
conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
|
|
@@ -194,28 +263,42 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
194
263
|
info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
|
|
195
264
|
process.exit(1);
|
|
196
265
|
}
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
266
|
+
const authUrl = `postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`;
|
|
267
|
+
const adminUrl = result.adminRole
|
|
268
|
+
? `postgresql://${result.adminRole}:${adminPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`
|
|
269
|
+
: undefined;
|
|
270
|
+
|
|
271
|
+
// Write the secrets directly to the SST secret store — never display them. One blob
|
|
272
|
+
// write updates every key together, so there is no window where the API side is the
|
|
273
|
+
// authenticator but the operator side is still unset.
|
|
274
|
+
step('Writing the database secrets (not displayed)...');
|
|
275
|
+
let plan: ProvisionSecretPlan;
|
|
201
276
|
try {
|
|
202
277
|
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
|
|
203
278
|
const { parseAppName } = await import('../discover.js');
|
|
204
279
|
const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
205
280
|
const secrets = await loadSstSecrets(ctx);
|
|
206
|
-
|
|
281
|
+
plan = buildProvisionSecretPlan({ result, authUrl, adminUrl, existing: secrets });
|
|
282
|
+
Object.assign(secrets, plan.updates);
|
|
207
283
|
await putSstSecrets(ctx, secrets);
|
|
208
284
|
} catch (err: any) {
|
|
209
|
-
fail(`Failed to write the
|
|
285
|
+
fail(`Failed to write the database secrets: ${err.message}`);
|
|
210
286
|
info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
|
|
211
287
|
process.exit(1);
|
|
212
288
|
}
|
|
213
289
|
|
|
214
|
-
success(`
|
|
290
|
+
success(`Secrets written for stage "${flags.stage}". No credential was displayed.`);
|
|
291
|
+
for (const note of plan.notes) info(` ${note}`);
|
|
292
|
+
for (const warning of plan.warnings) info(` ⚠ ${warning}`);
|
|
215
293
|
console.log('');
|
|
216
|
-
info('Finish the wiring in sst.config.ts (one-time):
|
|
217
|
-
info('link it to the Api function
|
|
218
|
-
info('(keep it on the ops/worker functions).
|
|
294
|
+
info('Finish the wiring in sst.config.ts (one-time):');
|
|
295
|
+
info(' API: declare `new sst.Secret("DatabaseUrl")` and link it to the Api function; remove the');
|
|
296
|
+
info(' raw Postgres component from the Api link (keep it on the ops/worker functions).');
|
|
297
|
+
if (result.adminRole) {
|
|
298
|
+
info(' Ops: declare `new sst.Secret("AdminDatabaseUrl", "")` and link it to the Ops function —');
|
|
299
|
+
info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
|
|
300
|
+
}
|
|
301
|
+
info('Then redeploy.');
|
|
219
302
|
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
220
303
|
}
|
|
221
304
|
|