@everystack/cli 0.4.5 → 0.4.7
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-snapshot.ts +3 -1
- package/src/cli/commands/db.ts +9 -3
- package/src/cli/commands/deploy.ts +22 -2
- package/src/cli/config.ts +31 -2
- package/src/cli/deploy-probe.ts +119 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/runbook/sections.ts +12 -2
package/package.json
CHANGED
|
@@ -23,7 +23,9 @@ function resolveInstanceId(config: CliConfig, flags: Record<string, string>): st
|
|
|
23
23
|
if (!id || id === 'placeholder') {
|
|
24
24
|
fail(
|
|
25
25
|
'No RDS instance id available. db:snapshot needs an RDS instance.\n' +
|
|
26
|
-
' - If your DB is RDS:
|
|
26
|
+
' - If your DB is RDS: add `databaseInstanceId: database.id` to the outputs return block in\n' +
|
|
27
|
+
' sst.config.ts and redeploy (run db:snapshot from the app directory so .sst/outputs.json is\n' +
|
|
28
|
+
' readable), or pass --instance <id>.\n' +
|
|
27
29
|
' - If your DB is not RDS (Aurora, a container, self-hosted): use `everystack db:backup` for a portable logical backup.',
|
|
28
30
|
);
|
|
29
31
|
process.exit(1);
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -292,13 +292,19 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
292
292
|
for (const warning of plan.warnings) info(` ⚠ ${warning}`);
|
|
293
293
|
console.log('');
|
|
294
294
|
info('Finish the wiring in sst.config.ts (one-time):');
|
|
295
|
-
info(' API:
|
|
296
|
-
info('
|
|
295
|
+
info(' API — PRECONDITION: your handler must escalate per request (pgSettings + SET ROLE /');
|
|
296
|
+
info(' withRole). The authenticator holds no privileges of its own (NOINHERIT); a handler');
|
|
297
|
+
info(' that never escalates cannot see the schema — every endpoint fails "relation does');
|
|
298
|
+
info(' not exist". If unsure, do NOT flip the API yet.');
|
|
299
|
+
info(' Flip in TWO deploys: (1) declare `new sst.Secret("DatabaseUrl")` and link it to the');
|
|
300
|
+
info(' Api function, KEEPING the raw Postgres component linked (the explicit secret already');
|
|
301
|
+
info(' outranks it; the component is your instant rollback). Verify with real requests +');
|
|
302
|
+
info(` db:doctor. (2) Then remove the raw Postgres component from the Api link (keep it on`);
|
|
303
|
+
info(' the ops/worker functions) and redeploy.');
|
|
297
304
|
if (result.adminRole) {
|
|
298
305
|
info(' Ops: declare `new sst.Secret("AdminDatabaseUrl", "")` and link it to the Ops function —');
|
|
299
306
|
info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
|
|
300
307
|
}
|
|
301
|
-
info('Then redeploy.');
|
|
302
308
|
info(`Verify: everystack db:doctor --stage ${flags.stage}`);
|
|
303
309
|
}
|
|
304
310
|
|
|
@@ -22,14 +22,31 @@ export function buildDeployArgs(flags: Record<string, string>): string[] {
|
|
|
22
22
|
return ['sst', 'deploy', '--stage', stage];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The environment the sst child receives: the FULL ambient environment plus color.
|
|
27
|
+
* Pinned as an explicit, tested contract because a consumer observed PGDUMP_LAYER_ARN
|
|
28
|
+
* reaching sst when invoked directly but not through this wrapper — sst.config.ts
|
|
29
|
+
* conditionals like `process.env.PGDUMP_LAYER_ARN ? { layers: [...] } : {}` silently
|
|
30
|
+
* collapse when a variable goes missing, and the deploy still exits green.
|
|
31
|
+
*/
|
|
32
|
+
export function deployEnv(base: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
33
|
+
return { ...base, FORCE_COLOR: '1' };
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
export async function deployCommand(flags: Record<string, string>): Promise<void> {
|
|
26
37
|
const stage = flags.stage || 'dev';
|
|
27
38
|
const args = buildDeployArgs(flags);
|
|
28
39
|
|
|
29
40
|
step(`Deploying stage "${stage}"...`);
|
|
41
|
+
// Say what the wrapper sees: an sst.config.ts conditional on an env var collapses
|
|
42
|
+
// SILENTLY when the var is missing, and the deploy still exits green — the operator
|
|
43
|
+
// must be able to tell "the wrapper never saw it" from "sst ignored it" at a glance.
|
|
44
|
+
if (process.env.PGDUMP_LAYER_ARN) {
|
|
45
|
+
info(`pg_dump layer: PGDUMP_LAYER_ARN is set → forwarded to sst (${process.env.PGDUMP_LAYER_ARN})`);
|
|
46
|
+
}
|
|
30
47
|
await new Promise<void>((resolve) => {
|
|
31
48
|
// `npx` resolves the consumer's local sst (the same way the export step runs expo).
|
|
32
|
-
const child = spawn('npx', args, { stdio: 'inherit', env:
|
|
49
|
+
const child = spawn('npx', args, { stdio: 'inherit', env: deployEnv(process.env) as NodeJS.ProcessEnv });
|
|
33
50
|
child.on('close', (code) => {
|
|
34
51
|
if (code === 0) {
|
|
35
52
|
resolve();
|
|
@@ -65,5 +82,8 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
|
|
|
65
82
|
info(`Skipped the post-deploy probe: ${err?.message ?? err}`);
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
|
|
85
|
+
// The 0.4 flow: schema changes ride db:plan → db:apply. Migration-based apps still have
|
|
86
|
+
// db:migrate, but the copy-paste hint must advertise the canonical path — this exact
|
|
87
|
+
// line got copied into consumer runbooks with the retired verb.
|
|
88
|
+
info('Next: schema changes via `everystack db:plan --stage ' + stage + '` → `db:apply`; `everystack update` to publish the app bundle.');
|
|
69
89
|
}
|
package/src/cli/config.ts
CHANGED
|
@@ -52,6 +52,35 @@ interface SstOutputs {
|
|
|
52
52
|
backupsBucket?: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Merge deploy-stable fields from a local .sst/outputs.json into a discovered config.
|
|
57
|
+
* Discovery finds live resources (functions, buckets) but cannot see plain stack outputs
|
|
58
|
+
* like `databaseInstanceId` — a consumer's fresh deploy DID add the output and
|
|
59
|
+
* `db:snapshot --stage` still failed, because the discovery path never carried it.
|
|
60
|
+
* Only fields that are stable across deploys are merged (an instance id never rotates
|
|
61
|
+
* the way a Lambda hash does), so a stale outputs file cannot poison the live discovery.
|
|
62
|
+
*/
|
|
63
|
+
export function enrichConfigFromOutputs(
|
|
64
|
+
config: CliConfig,
|
|
65
|
+
outputs: Partial<SstOutputs> | null,
|
|
66
|
+
): CliConfig {
|
|
67
|
+
if (!outputs) return config;
|
|
68
|
+
return {
|
|
69
|
+
...config,
|
|
70
|
+
...(config.databaseInstanceId ? {} : outputs.databaseInstanceId ? { databaseInstanceId: outputs.databaseInstanceId } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Best-effort read of the local .sst/outputs.json; null when absent/unreadable. */
|
|
75
|
+
async function readLocalOutputs(): Promise<Partial<SstOutputs> | null> {
|
|
76
|
+
try {
|
|
77
|
+
const raw = await fs.readFile(path.resolve('.sst', 'outputs.json'), 'utf8');
|
|
78
|
+
return JSON.parse(raw) as Partial<SstOutputs>;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
56
85
|
const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
57
86
|
|
|
@@ -66,7 +95,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
66
95
|
|
|
67
96
|
// Check cache first
|
|
68
97
|
const cached = await getCachedConfig(stage);
|
|
69
|
-
if (cached) return cached;
|
|
98
|
+
if (cached) return enrichConfigFromOutputs(cached, await readLocalOutputs());
|
|
70
99
|
|
|
71
100
|
step(`Discovering resources for stage "${stage}"...`);
|
|
72
101
|
const appName = await parseAppName();
|
|
@@ -74,7 +103,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
74
103
|
|
|
75
104
|
// Cache for future fast lookups
|
|
76
105
|
await setCachedConfig(stage, config);
|
|
77
|
-
return config;
|
|
106
|
+
return enrichConfigFromOutputs(config, await readLocalOutputs());
|
|
78
107
|
}
|
|
79
108
|
|
|
80
109
|
// Default: read .sst/outputs.json (unchanged behavior)
|
package/src/cli/deploy-probe.ts
CHANGED
|
@@ -99,6 +99,104 @@ async function probeFunction(region: string, functionName: string): Promise<Prob
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** A minimal APIGW v2 GET event — what a Function URL delivers for a real request. */
|
|
103
|
+
export function syntheticGetEvent(path: string, queryString = ''): {
|
|
104
|
+
version: string;
|
|
105
|
+
routeKey: string;
|
|
106
|
+
rawPath: string;
|
|
107
|
+
rawQueryString: string;
|
|
108
|
+
headers: Record<string, string>;
|
|
109
|
+
requestContext: { http: { method: string; path: string; protocol: string; sourceIp: string; userAgent: string } };
|
|
110
|
+
isBase64Encoded: boolean;
|
|
111
|
+
} {
|
|
112
|
+
return {
|
|
113
|
+
version: '2.0',
|
|
114
|
+
routeKey: '$default',
|
|
115
|
+
rawPath: path,
|
|
116
|
+
rawQueryString: queryString,
|
|
117
|
+
headers: { 'user-agent': 'everystack-deploy-probe' },
|
|
118
|
+
requestContext: {
|
|
119
|
+
http: { method: 'GET', path, protocol: 'HTTP/1.1', sourceIp: '127.0.0.1', userAgent: 'everystack-deploy-probe' },
|
|
120
|
+
},
|
|
121
|
+
isBase64Encoded: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Classify one table GET driven through the api function. `_health` proves BOOT; it
|
|
127
|
+
* touches no tables — a stage whose API role cannot see the schema (a NOINHERIT
|
|
128
|
+
* authenticator behind a handler that never escalates) boots fine and 500s on every
|
|
129
|
+
* real request. A consumer shipped exactly that dark site; this is the check that
|
|
130
|
+
* catches it at deploy time, with the failure named.
|
|
131
|
+
*/
|
|
132
|
+
export interface ReadProbeVerdict {
|
|
133
|
+
verdict: 'reads-ok' | 'auth-gated' | 'dark-site' | 'failed' | 'no-surface';
|
|
134
|
+
detail?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function classifyReadProbe(response: { statusCode?: number; body?: string }): ReadProbeVerdict {
|
|
138
|
+
const status = response.statusCode ?? 0;
|
|
139
|
+
if (status >= 200 && status < 300) return { verdict: 'reads-ok' };
|
|
140
|
+
if (status === 401 || status === 403) return { verdict: 'auth-gated' };
|
|
141
|
+
if (status === 404) return { verdict: 'no-surface' };
|
|
142
|
+
const body = response.body ?? '';
|
|
143
|
+
if (status >= 500 && /relation .* does not exist/i.test(body)) {
|
|
144
|
+
return {
|
|
145
|
+
verdict: 'dark-site',
|
|
146
|
+
detail:
|
|
147
|
+
'the API role cannot see the schema — the handler never escalates. The authenticator holds no '
|
|
148
|
+
+ 'privileges of its own (NOINHERIT); the handler must SET ROLE per request (pgSettings + withRole). '
|
|
149
|
+
+ `Raw response: ${body}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { verdict: 'failed', detail: body || `HTTP ${status}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Invoke the api function with a synthetic HTTP GET; null when the invoke itself failed. */
|
|
156
|
+
async function invokeHttpGet(
|
|
157
|
+
region: string,
|
|
158
|
+
functionName: string,
|
|
159
|
+
path: string,
|
|
160
|
+
queryString = '',
|
|
161
|
+
): Promise<{ statusCode?: number; body?: string } | null> {
|
|
162
|
+
try {
|
|
163
|
+
const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
|
|
164
|
+
const client = new LambdaClient({ region });
|
|
165
|
+
const response = await client.send(new InvokeCommand({
|
|
166
|
+
FunctionName: functionName,
|
|
167
|
+
Payload: new TextEncoder().encode(JSON.stringify(syntheticGetEvent(path, queryString))),
|
|
168
|
+
}));
|
|
169
|
+
if (response.FunctionError || !response.Payload) return null;
|
|
170
|
+
const parsed = JSON.parse(new TextDecoder().decode(response.Payload));
|
|
171
|
+
if (parsed && typeof parsed === 'object' && 'statusCode' in parsed) {
|
|
172
|
+
return { statusCode: parsed.statusCode, body: parsed.body };
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drive one real table read through the api function: GET / (the public table listing)
|
|
182
|
+
* names an exposed table, GET /<table>?limit=1 exercises role → schema → RLS. Best-effort:
|
|
183
|
+
* anything unprobeable stays neutral — only a positively classified failure is fatal.
|
|
184
|
+
*/
|
|
185
|
+
async function probeApiRead(region: string, functionName: string): Promise<ReadProbeVerdict | null> {
|
|
186
|
+
const listing = await invokeHttpGet(region, functionName, '/');
|
|
187
|
+
if (!listing || listing.statusCode !== 200 || !listing.body) return null;
|
|
188
|
+
let tables: string[];
|
|
189
|
+
try {
|
|
190
|
+
tables = (JSON.parse(listing.body) as { tables?: string[] }).tables ?? [];
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (tables.length === 0) return null;
|
|
195
|
+
const read = await invokeHttpGet(region, functionName, `/${tables[0]}`, 'limit=1');
|
|
196
|
+
if (!read) return null;
|
|
197
|
+
return classifyReadProbe(read);
|
|
198
|
+
}
|
|
199
|
+
|
|
102
200
|
/**
|
|
103
201
|
* Probe the stage's deployed functions and report. Returns whether deploy must fail: a function
|
|
104
202
|
* that cannot boot (or answers unhealthy) is fatal; a probe that couldn't run is a warning.
|
|
@@ -137,6 +235,27 @@ export async function probeDeployedFunctions(
|
|
|
137
235
|
info(`Could not probe ${target.label} function (${target.functionName}): ${result.detail}`);
|
|
138
236
|
break;
|
|
139
237
|
}
|
|
238
|
+
|
|
239
|
+
// Boot is necessary, not sufficient: drive one table read through the api function so a
|
|
240
|
+
// schema-invisible role (the dark-site shape) fails HERE, named, not on the first user.
|
|
241
|
+
if (target.label === 'api' && (result.verdict === 'healthy' || result.verdict === 'boot-ok')) {
|
|
242
|
+
const read = await probeApiRead(config.region, target.functionName);
|
|
243
|
+
if (read === null) {
|
|
244
|
+
// Nothing conclusive to probe (listing empty/unreadable) — stay neutral.
|
|
245
|
+
} else if (read.verdict === 'reads-ok') {
|
|
246
|
+
success('api read path OK (table read through the handler)');
|
|
247
|
+
} else if (read.verdict === 'auth-gated') {
|
|
248
|
+
info('api read path is auth-gated — boot verified, deeper read needs credentials');
|
|
249
|
+
} else if (read.verdict === 'no-surface') {
|
|
250
|
+
info('api exposes no tables to probe — skipped the read check');
|
|
251
|
+
} else if (read.verdict === 'dark-site') {
|
|
252
|
+
fatal = true;
|
|
253
|
+
fail(`api function is DARK: ${read.detail}`);
|
|
254
|
+
} else {
|
|
255
|
+
fatal = true;
|
|
256
|
+
fail(`api read probe failed: ${read.detail}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
140
259
|
}
|
|
141
260
|
return { fatal };
|
|
142
261
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -339,7 +339,7 @@ Usage:
|
|
|
339
339
|
everystack deploy [--stage <name>] Deploy infrastructure (wraps sst deploy — no need to know SST)
|
|
340
340
|
everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
341
341
|
everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
342
|
-
everystack db:migrate [--stage <name>] Run
|
|
342
|
+
everystack db:migrate [--stage <name>] Run drizzle migrations (legacy — Models apps use db:plan → db:apply)
|
|
343
343
|
everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
|
|
344
344
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
345
345
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
@@ -136,7 +136,12 @@ function deploy(r: ProjectReality): Block {
|
|
|
136
136
|
'- `everystack channels list` — see the channels that exist.',
|
|
137
137
|
];
|
|
138
138
|
if (r.hasDatabase) {
|
|
139
|
-
lines.push(
|
|
139
|
+
lines.push(
|
|
140
|
+
'',
|
|
141
|
+
r.migrationMode === 'everystack'
|
|
142
|
+
? 'After a deploy, schema changes land via `everystack db:plan --stage <name>` → `db:apply`.'
|
|
143
|
+
: 'After a deploy that includes new migrations: `everystack db:migrate --stage <name>`.',
|
|
144
|
+
);
|
|
140
145
|
}
|
|
141
146
|
return gen('deploy', lines);
|
|
142
147
|
}
|
|
@@ -222,7 +227,12 @@ function database(r: ProjectReality): Block {
|
|
|
222
227
|
'## Database operations',
|
|
223
228
|
'',
|
|
224
229
|
...flow,
|
|
225
|
-
|
|
230
|
+
// db:migrate is the RETIRED verb for everystack-mode apps (plan → apply is the flow);
|
|
231
|
+
// advertising it here got copied into consumer runbooks verbatim. Migration-based
|
|
232
|
+
// apps still get it — it is their flow.
|
|
233
|
+
...(r.migrationMode === 'everystack'
|
|
234
|
+
? []
|
|
235
|
+
: ['- `everystack db:migrate --stage <name>` — apply migrations on the deployed Lambda']),
|
|
226
236
|
'- `everystack db:seed --stage dev` — seed the database (dev only)',
|
|
227
237
|
'- `everystack db:psql --stage dev` — psql into the stage database (credentials never exposed)',
|
|
228
238
|
'- `everystack db:doctor` — verify the security posture (RLS, roles, search_path)',
|