@everystack/cli 0.4.1 → 0.4.3
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/aws.ts +75 -38
- package/src/cli/commands/deploy.ts +19 -0
- package/src/cli/commands/logs.ts +71 -5
- package/src/cli/deploy-probe.ts +142 -0
- package/src/cli/derived-render.ts +1 -1
- package/src/cli/model-render.ts +1 -1
- package/src/cli/ops-diagnostics.ts +65 -5
- package/src/.pdr-tmp-20277-1783706384842.ts +0 -59
- package/src/.roundtrip-tmp-20275-1783706385459.ts +0 -121
package/package.json
CHANGED
package/src/cli/aws.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Authentication uses the developer's IAM credentials (default credential chain).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { isOpsHandlerCrash,
|
|
8
|
+
import { isOpsHandlerCrash, formatOpsCrashReport, fetchDiagnosisWithRetry } from './ops-diagnostics.js';
|
|
9
9
|
import { tagInvokeTransport } from './ops-advice.js';
|
|
10
10
|
|
|
11
11
|
let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
|
|
@@ -325,50 +325,94 @@ async function fetchRecentLogText(region: string, logGroupName: string, windowMs
|
|
|
325
325
|
return (res.events ?? []).map((e) => e.message ?? '').join('\n');
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/** Raw CloudWatch events for a function's log group (last `windowMs`, capped). */
|
|
329
|
+
export async function fetchRecentLogEvents(
|
|
330
|
+
region: string,
|
|
331
|
+
logGroupName: string,
|
|
332
|
+
windowMs: number,
|
|
333
|
+
): Promise<Array<{ timestamp?: number; message?: string }>> {
|
|
334
|
+
const { CloudWatchLogsClient, FilterLogEventsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
|
|
335
|
+
const client = new CloudWatchLogsClient({ region });
|
|
336
|
+
const res = await client.send(new FilterLogEventsCommand({
|
|
337
|
+
logGroupName,
|
|
338
|
+
startTime: Date.now() - windowMs,
|
|
339
|
+
limit: 500,
|
|
340
|
+
}));
|
|
341
|
+
return res.events ?? [];
|
|
342
|
+
}
|
|
343
|
+
|
|
328
344
|
/**
|
|
329
345
|
* Turn an opaque ops-handler runtime crash into a report naming the real cause: resolve the ops
|
|
330
|
-
* function's log group, read the recent lines
|
|
331
|
-
*
|
|
346
|
+
* function's log group, read the recent lines (retrying through CloudWatch ingest lag — a crash
|
|
347
|
+
* that JUST happened takes a few seconds to land), and extract the failing import. Best-effort —
|
|
348
|
+
* any failure (no log access, no group) falls back to the everystack-native next step.
|
|
332
349
|
*/
|
|
333
350
|
export async function diagnoseOpsCrash(region: string, functionName: string, baseMessage: string): Promise<string> {
|
|
351
|
+
let logGroup: string | undefined;
|
|
334
352
|
try {
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
353
|
+
logGroup = await resolveLogGroup(region, functionName);
|
|
354
|
+
const group = logGroup;
|
|
355
|
+
const diagnosis = await fetchDiagnosisWithRetry(() => fetchRecentLogText(region, group));
|
|
356
|
+
return formatOpsCrashReport(baseMessage, logGroup, diagnosis);
|
|
338
357
|
} catch {
|
|
339
|
-
return formatOpsCrashReport(baseMessage);
|
|
358
|
+
return formatOpsCrashReport(baseMessage, logGroup);
|
|
340
359
|
}
|
|
341
360
|
}
|
|
342
361
|
|
|
362
|
+
/** IO seam for resolveLogGroup — injectable so the resolution order tests without the SDK. */
|
|
363
|
+
export interface LogGroupIo {
|
|
364
|
+
/** The log group the function's own LoggingConfig names, if readable. */
|
|
365
|
+
getFunctionLogGroup: (functionName: string) => Promise<string | undefined>;
|
|
366
|
+
/** Log groups matching a name prefix. */
|
|
367
|
+
describeLogGroups: (prefix: string, limit: number) => Promise<Array<{ logGroupName?: string; creationTime?: number }>>;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function defaultLogGroupIo(region: string): Promise<LogGroupIo> {
|
|
371
|
+
return {
|
|
372
|
+
getFunctionLogGroup: async (functionName) => {
|
|
373
|
+
const client = await getLambda(region);
|
|
374
|
+
const { GetFunctionConfigurationCommand } = await import('@aws-sdk/client-lambda');
|
|
375
|
+
const cfg = await client.send(new GetFunctionConfigurationCommand({ FunctionName: functionName }));
|
|
376
|
+
return cfg.LoggingConfig?.LogGroup ?? undefined;
|
|
377
|
+
},
|
|
378
|
+
describeLogGroups: async (prefix, limit) => {
|
|
379
|
+
const { CloudWatchLogsClient, DescribeLogGroupsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
|
|
380
|
+
const client = new CloudWatchLogsClient({ region });
|
|
381
|
+
const res = await client.send(new DescribeLogGroupsCommand({ logGroupNamePrefix: prefix, limit }));
|
|
382
|
+
return res.logGroups ?? [];
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
343
387
|
/**
|
|
344
388
|
* Resolve the CloudWatch log group for a Lambda function.
|
|
345
389
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
390
|
+
* The function's own LoggingConfig is authoritative: SST Ion creates the log group as its OWN
|
|
391
|
+
* resource, so the function and its group carry INDEPENDENT random suffixes (`OpsFunction-vnmzcmed`
|
|
392
|
+
* writes to `/aws/lambda/...-OpsFunction-ubzfsutk`) — deriving the group from the function name is
|
|
393
|
+
* wrong for every SST function. Name heuristics (conventional `/aws/lambda/{name}`, then a prefix
|
|
394
|
+
* search for a rotated suffix) are fallbacks for when the config read is denied.
|
|
349
395
|
*/
|
|
350
396
|
export async function resolveLogGroup(
|
|
351
397
|
region: string,
|
|
352
398
|
functionName: string,
|
|
399
|
+
io?: LogGroupIo,
|
|
353
400
|
): Promise<string> {
|
|
401
|
+
const ops = io ?? await defaultLogGroupIo(region);
|
|
354
402
|
const conventional = `/aws/lambda/${functionName}`;
|
|
355
403
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
404
|
+
// Authoritative: what the Lambda itself says it logs to.
|
|
405
|
+
try {
|
|
406
|
+
const configured = await ops.getFunctionLogGroup(functionName);
|
|
407
|
+
if (configured) return configured;
|
|
408
|
+
} catch {
|
|
409
|
+
// No lambda:GetFunctionConfiguration (or the function is gone) — fall back to name heuristics
|
|
410
|
+
}
|
|
362
411
|
|
|
363
|
-
//
|
|
412
|
+
// Conventional name exists
|
|
364
413
|
try {
|
|
365
|
-
const res = await
|
|
366
|
-
|
|
367
|
-
logGroupNamePrefix: conventional,
|
|
368
|
-
limit: 1,
|
|
369
|
-
}),
|
|
370
|
-
);
|
|
371
|
-
if (res.logGroups?.some(lg => lg.logGroupName === conventional)) {
|
|
414
|
+
const res = await ops.describeLogGroups(conventional, 1);
|
|
415
|
+
if (res.some(lg => lg.logGroupName === conventional)) {
|
|
372
416
|
return conventional;
|
|
373
417
|
}
|
|
374
418
|
} catch {
|
|
@@ -382,20 +426,13 @@ export async function resolveLogGroup(
|
|
|
382
426
|
if (lastDash > 0) {
|
|
383
427
|
const basePrefix = `/aws/lambda/${functionName.slice(0, lastDash)}`;
|
|
384
428
|
try {
|
|
385
|
-
const res = await
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
// Return the most recently created matching log group
|
|
393
|
-
const sorted = res.logGroups
|
|
394
|
-
.filter(lg => lg.logGroupName)
|
|
395
|
-
.sort((a, b) => (b.creationTime || 0) - (a.creationTime || 0));
|
|
396
|
-
if (sorted.length > 0) {
|
|
397
|
-
return sorted[0].logGroupName!;
|
|
398
|
-
}
|
|
429
|
+
const res = await ops.describeLogGroups(basePrefix, 5);
|
|
430
|
+
// Return the most recently created matching log group
|
|
431
|
+
const sorted = res
|
|
432
|
+
.filter(lg => lg.logGroupName)
|
|
433
|
+
.sort((a, b) => (b.creationTime || 0) - (a.creationTime || 0));
|
|
434
|
+
if (sorted.length > 0) {
|
|
435
|
+
return sorted[0].logGroupName!;
|
|
399
436
|
}
|
|
400
437
|
} catch {
|
|
401
438
|
// Fall through to conventional name
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
14
|
import { step, success, fail, info } from '../output.js';
|
|
15
15
|
import { invalidateCachedConfig } from '../discover.js';
|
|
16
|
+
import { resolveConfig } from '../config.js';
|
|
17
|
+
import { probeDeployedFunctions } from '../deploy-probe.js';
|
|
16
18
|
|
|
17
19
|
/** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
|
|
18
20
|
export function buildDeployArgs(flags: Record<string, string>): string[] {
|
|
@@ -46,5 +48,22 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
|
|
|
46
48
|
// A deploy can change topology (a new Ops/Worker function, rotated Lambda hashes) that a
|
|
47
49
|
// stale per-stage cache would shadow — bust it so the next stage command re-discovers live.
|
|
48
50
|
await invalidateCachedConfig(stage);
|
|
51
|
+
|
|
52
|
+
// `sst deploy` exiting 0 only proves the code UPLOADED — a bundle that crashes at INIT still
|
|
53
|
+
// deploys "green" and takes the whole ops plane down until the next verb trips over it. Probe
|
|
54
|
+
// the deployed functions now so the operator learns here, with the real cause attached.
|
|
55
|
+
try {
|
|
56
|
+
const config = await resolveConfig(stage);
|
|
57
|
+
const { fatal } = await probeDeployedFunctions(stage, config);
|
|
58
|
+
if (fatal) {
|
|
59
|
+
fail('Deploy landed, but a deployed function cannot serve — every --stage verb will fail until this is fixed and redeployed.');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
// The probe is a safety net, not a gate on discovery: a stack with nothing to probe
|
|
64
|
+
// (V1 static) or an operator without invoke rights still gets their deploy.
|
|
65
|
+
info(`Skipped the post-deploy probe: ${err?.message ?? err}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
info('Next: `everystack db:migrate --stage ' + stage + '` to apply migrations, `everystack update` to publish the app bundle.');
|
|
50
69
|
}
|
package/src/cli/commands/logs.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { resolveConfig } from '../config.js';
|
|
14
|
-
import { resolveLogGroup } from '../aws.js';
|
|
14
|
+
import { resolveLogGroup, fetchRecentLogEvents } from '../aws.js';
|
|
15
|
+
import { summarizeRuntimeFailures } from '../ops-diagnostics.js';
|
|
15
16
|
import { queryLogArchive, type ObservabilityQuery } from '../observability.js';
|
|
16
17
|
import { step, success, fail, info } from '../output.js';
|
|
17
18
|
|
|
@@ -32,6 +33,46 @@ function parseDurationMs(duration: string): number {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Sweep the stage's Lambda log groups for runtime-level failures — INIT crashes, import errors,
|
|
38
|
+
* timeouts. These kill the handler BEFORE it can ship anything to the S3 archive, so without this
|
|
39
|
+
* sweep `logs:errors` answers "No errors found!" while the ops plane is down on every invoke (a
|
|
40
|
+
* real consumer incident). Best-effort per function: no CloudWatch read access just means the S3
|
|
41
|
+
* results stand alone.
|
|
42
|
+
*/
|
|
43
|
+
async function sweepRuntimeFailures(
|
|
44
|
+
config: {
|
|
45
|
+
region: string;
|
|
46
|
+
opsFunctionName?: string;
|
|
47
|
+
apiFunctionName?: string;
|
|
48
|
+
workerFunctionName?: string;
|
|
49
|
+
imageFunctionName?: string;
|
|
50
|
+
},
|
|
51
|
+
windowMs: number,
|
|
52
|
+
): Promise<Array<{ label: string; functionName: string; lines: string[] }>> {
|
|
53
|
+
const candidates: Array<[string, string | undefined]> = [
|
|
54
|
+
['ops', config.opsFunctionName],
|
|
55
|
+
['api', config.apiFunctionName],
|
|
56
|
+
['worker', config.workerFunctionName],
|
|
57
|
+
['media', config.imageFunctionName],
|
|
58
|
+
];
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const failures: Array<{ label: string; functionName: string; lines: string[] }> = [];
|
|
61
|
+
for (const [label, functionName] of candidates) {
|
|
62
|
+
if (!functionName || seen.has(functionName)) continue;
|
|
63
|
+
seen.add(functionName);
|
|
64
|
+
try {
|
|
65
|
+
const logGroup = await resolveLogGroup(config.region, functionName);
|
|
66
|
+
const events = await fetchRecentLogEvents(config.region, logGroup, windowMs);
|
|
67
|
+
const lines = summarizeRuntimeFailures(events);
|
|
68
|
+
if (lines.length) failures.push({ label, functionName, lines });
|
|
69
|
+
} catch {
|
|
70
|
+
// No logs:FilterLogEvents (or no group yet) — skip; the S3 results stand alone.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return failures;
|
|
74
|
+
}
|
|
75
|
+
|
|
35
76
|
export async function logsErrorsCommand(flags: Record<string, string>): Promise<void> {
|
|
36
77
|
step('Resolving deployed config...');
|
|
37
78
|
let config;
|
|
@@ -60,12 +101,30 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
|
|
|
60
101
|
try {
|
|
61
102
|
const errors = await queryLogArchive(config.region, config.updatesBucket, prefix, query);
|
|
62
103
|
|
|
63
|
-
|
|
104
|
+
// Runtime-level failures never reach the S3 archive — sweep CloudWatch in the same window.
|
|
105
|
+
const windowMs = (flags.since && parseDurationMs(flags.since)) || 60 * 60 * 1000;
|
|
106
|
+
const runtimeFailures = await sweepRuntimeFailures(config, windowMs);
|
|
107
|
+
|
|
108
|
+
if (errors.length === 0 && runtimeFailures.length === 0) {
|
|
64
109
|
success('No errors found!');
|
|
65
110
|
return;
|
|
66
111
|
}
|
|
67
112
|
|
|
68
|
-
|
|
113
|
+
if (errors.length === 0) {
|
|
114
|
+
info('No app-level errors in the S3 archive.');
|
|
115
|
+
} else {
|
|
116
|
+
success(`Found ${errors.length} ${level} log(s) (s3):\n`);
|
|
117
|
+
}
|
|
118
|
+
if (runtimeFailures.length > 0) {
|
|
119
|
+
fail(`Lambda runtime failures (CloudWatch) — these crash the handler before it can log to S3:\n`);
|
|
120
|
+
for (const failure of runtimeFailures) {
|
|
121
|
+
console.log(`${failure.label} (${failure.functionName}):`);
|
|
122
|
+
for (const line of failure.lines) console.log(` ${line}`);
|
|
123
|
+
console.log('');
|
|
124
|
+
}
|
|
125
|
+
info(`Inspect further: everystack logs:tail --origin ${runtimeFailures[0].label}${flags.stage ? ` --stage ${flags.stage}` : ''}\n`);
|
|
126
|
+
}
|
|
127
|
+
if (errors.length === 0) return;
|
|
69
128
|
|
|
70
129
|
for (const error of errors) {
|
|
71
130
|
const ts = new Date(error.timestamp).toISOString();
|
|
@@ -97,14 +156,14 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
|
|
|
97
156
|
}
|
|
98
157
|
}
|
|
99
158
|
|
|
100
|
-
const VALID_LOG_ORIGINS = ['api', 'media', 'worker'] as const;
|
|
159
|
+
const VALID_LOG_ORIGINS = ['api', 'media', 'worker', 'ops'] as const;
|
|
101
160
|
|
|
102
161
|
/**
|
|
103
162
|
* Resolve a Lambda function name from the --origin flag.
|
|
104
163
|
* Defaults to 'api' if no origin is specified.
|
|
105
164
|
*/
|
|
106
165
|
function resolveFunctionForOrigin(
|
|
107
|
-
config: { apiFunctionName: string; imageFunctionName?: string; workerFunctionName?: string },
|
|
166
|
+
config: { apiFunctionName: string; imageFunctionName?: string; workerFunctionName?: string; opsFunctionName?: string },
|
|
108
167
|
origin?: string,
|
|
109
168
|
): string {
|
|
110
169
|
const resolved = origin || 'api';
|
|
@@ -128,6 +187,13 @@ function resolveFunctionForOrigin(
|
|
|
128
187
|
);
|
|
129
188
|
}
|
|
130
189
|
return config.workerFunctionName;
|
|
190
|
+
case 'ops':
|
|
191
|
+
if (!config.opsFunctionName) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
'No ops function found. Add opsFunctionName to sst.config.ts outputs or use --stage for auto-discovery.',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return config.opsFunctionName;
|
|
131
197
|
default:
|
|
132
198
|
return config.apiFunctionName;
|
|
133
199
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-deploy ops-plane probe — deploy must not exit green when the functions it just shipped
|
|
3
|
+
* cannot boot.
|
|
4
|
+
*
|
|
5
|
+
* A deploy that lands a Lambda whose bundle crashes at INIT (a failing top-level import, an
|
|
6
|
+
* import-time side effect) reports success from `sst deploy` — CloudFormation only proves the
|
|
7
|
+
* code UPLOADED, not that it runs. The operator then learns at the next db verb, from an opaque
|
|
8
|
+
* "Runtime exited with error: exit status 1", with the whole ops plane already down. The probe
|
|
9
|
+
* invokes each deployed function with `_action: '_health'` right after deploy, so the failure is
|
|
10
|
+
* caught at deploy time with the real cause attached.
|
|
11
|
+
*
|
|
12
|
+
* The probe proves BOOT, not features: an older @everystack/server without a `_health` action
|
|
13
|
+
* answers `{ error: 'Unknown action: _health' }` — the runtime evaluated the bundle, initialized,
|
|
14
|
+
* and dispatched, which is exactly what deploy needs to verify. Only a FunctionError (the handler
|
|
15
|
+
* never answered) can fail the probe; a transport/IAM error means the probe itself couldn't run
|
|
16
|
+
* and must never fail the deploy.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { isOpsHandlerCrash } from './ops-diagnostics.js';
|
|
20
|
+
import { diagnoseOpsCrash } from './aws.js';
|
|
21
|
+
import { step, success, fail, info } from './output.js';
|
|
22
|
+
|
|
23
|
+
/** The raw outcome of one probe invoke, before classification. */
|
|
24
|
+
export interface ProbeOutcome {
|
|
25
|
+
/** Lambda FunctionError message — the handler threw uncaught or the runtime crashed. */
|
|
26
|
+
functionError?: string;
|
|
27
|
+
/** Parsed response payload of a successful invoke. */
|
|
28
|
+
payload?: unknown;
|
|
29
|
+
/** The invoke call itself failed (transport/IAM) — the probe could not run. */
|
|
30
|
+
invokeError?: { name?: string; message?: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProbeVerdict {
|
|
34
|
+
verdict: 'healthy' | 'boot-ok' | 'crashed' | 'failed' | 'unreachable';
|
|
35
|
+
detail?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pure classification of a probe outcome — the decision table this feature IS. */
|
|
39
|
+
export function classifyProbe(outcome: ProbeOutcome): ProbeVerdict {
|
|
40
|
+
if (outcome.invokeError) {
|
|
41
|
+
return {
|
|
42
|
+
verdict: 'unreachable',
|
|
43
|
+
detail: outcome.invokeError.message ?? outcome.invokeError.name ?? 'invoke failed',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (outcome.functionError) {
|
|
47
|
+
return {
|
|
48
|
+
verdict: isOpsHandlerCrash(outcome.functionError) ? 'crashed' : 'failed',
|
|
49
|
+
detail: outcome.functionError,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const payload = outcome.payload;
|
|
53
|
+
if (payload && typeof payload === 'object') {
|
|
54
|
+
const p = payload as { ok?: unknown; error?: unknown };
|
|
55
|
+
if (p.ok === true) return { verdict: 'healthy' };
|
|
56
|
+
if (p.ok === false) return { verdict: 'failed', detail: String(p.error ?? 'handler reported unhealthy') };
|
|
57
|
+
if (typeof p.error === 'string') {
|
|
58
|
+
// Pre-_health servers answer "Unknown action: _health" — boot proven, feature absent.
|
|
59
|
+
if (p.error.startsWith('Unknown action')) return { verdict: 'boot-ok' };
|
|
60
|
+
return { verdict: 'failed', detail: p.error };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Any other answer (null, {}, a string) still came from a booted handler.
|
|
64
|
+
return { verdict: 'boot-ok' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Which deployed functions to probe. Deduped: older apps route ops actions to the api function. */
|
|
68
|
+
export function probeTargets(config: {
|
|
69
|
+
opsFunctionName?: string;
|
|
70
|
+
apiFunctionName?: string;
|
|
71
|
+
}): Array<{ label: string; functionName: string }> {
|
|
72
|
+
const targets: Array<{ label: string; functionName: string }> = [];
|
|
73
|
+
if (config.opsFunctionName) targets.push({ label: 'ops', functionName: config.opsFunctionName });
|
|
74
|
+
if (config.apiFunctionName && config.apiFunctionName !== config.opsFunctionName) {
|
|
75
|
+
targets.push({ label: 'api', functionName: config.apiFunctionName });
|
|
76
|
+
}
|
|
77
|
+
return targets;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One raw `_health` invoke → outcome. Never throws; every failure mode lands in the outcome. */
|
|
81
|
+
async function probeFunction(region: string, functionName: string): Promise<ProbeOutcome> {
|
|
82
|
+
try {
|
|
83
|
+
const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
|
|
84
|
+
const client = new LambdaClient({ region });
|
|
85
|
+
const response = await client.send(new InvokeCommand({
|
|
86
|
+
FunctionName: functionName,
|
|
87
|
+
Payload: new TextEncoder().encode(JSON.stringify({ _action: '_health', _payload: {} })),
|
|
88
|
+
}));
|
|
89
|
+
const payload = response.Payload
|
|
90
|
+
? JSON.parse(new TextDecoder().decode(response.Payload))
|
|
91
|
+
: null;
|
|
92
|
+
if (response.FunctionError) {
|
|
93
|
+
return { functionError: String((payload as { errorMessage?: string })?.errorMessage ?? response.FunctionError) };
|
|
94
|
+
}
|
|
95
|
+
return { payload };
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
const e = err as { name?: string; message?: string };
|
|
98
|
+
return { invokeError: { name: e?.name, message: e?.message } };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Probe the stage's deployed functions and report. Returns whether deploy must fail: a function
|
|
104
|
+
* that cannot boot (or answers unhealthy) is fatal; a probe that couldn't run is a warning.
|
|
105
|
+
*/
|
|
106
|
+
export async function probeDeployedFunctions(
|
|
107
|
+
stage: string,
|
|
108
|
+
config: { region: string; opsFunctionName?: string; apiFunctionName?: string },
|
|
109
|
+
): Promise<{ fatal: boolean }> {
|
|
110
|
+
const targets = probeTargets(config);
|
|
111
|
+
if (targets.length === 0) return { fatal: false };
|
|
112
|
+
|
|
113
|
+
step('Probing deployed functions...');
|
|
114
|
+
let fatal = false;
|
|
115
|
+
for (const target of targets) {
|
|
116
|
+
const outcome = await probeFunction(config.region, target.functionName);
|
|
117
|
+
const result = classifyProbe(outcome);
|
|
118
|
+
switch (result.verdict) {
|
|
119
|
+
case 'healthy':
|
|
120
|
+
success(`${target.label} function healthy (${target.functionName})`);
|
|
121
|
+
break;
|
|
122
|
+
case 'boot-ok':
|
|
123
|
+
success(`${target.label} function boots (${target.functionName})`);
|
|
124
|
+
break;
|
|
125
|
+
case 'crashed': {
|
|
126
|
+
fatal = true;
|
|
127
|
+
const report = await diagnoseOpsCrash(config.region, target.functionName, result.detail ?? 'Runtime crash');
|
|
128
|
+
fail(`${target.label} function crashed at startup (${target.functionName})`);
|
|
129
|
+
console.error(report);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'failed':
|
|
133
|
+
fatal = true;
|
|
134
|
+
fail(`${target.label} function is unhealthy (${target.functionName}): ${result.detail}`);
|
|
135
|
+
break;
|
|
136
|
+
case 'unreachable':
|
|
137
|
+
info(`Could not probe ${target.label} function (${target.functionName}): ${result.detail}`);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { fatal };
|
|
142
|
+
}
|
|
@@ -45,7 +45,7 @@ const PLAIN_IDENT = /^[a-z_][a-z0-9_$]*$/;
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Derived symbols are camelCase VERBATIM — never singularized (deliberately distinct
|
|
48
|
-
* from modelVarName's rule; a matview named `
|
|
48
|
+
* from modelVarName's rule; a matview named `user_sessions` is `userSessions`).
|
|
49
49
|
* NAMING CONTRACT — a rename here breaks consumer splices; pinned by
|
|
50
50
|
* naming-contract.test.ts.
|
|
51
51
|
*/
|
package/src/cli/model-render.ts
CHANGED
|
@@ -186,7 +186,7 @@ export function modelVarName(table: string): string {
|
|
|
186
186
|
* `author_id` → `authorId` — a SQL column to its field key, LOSSLESSLY: the compiler's
|
|
187
187
|
* snake_case must reconstruct the exact column (`snake(key) === column`, always). Only a
|
|
188
188
|
* letter after `_` camelizes, because only a capital letter survives the trip back — an
|
|
189
|
-
* underscore before a digit is preserved (`
|
|
189
|
+
* underscore before a digit is preserved (`percentile_100` stays `percentile_100`,
|
|
190
190
|
* `top_3_best` → `top_3Best`). Swallowing it (the old `_([a-z0-9])` rule) generated a
|
|
191
191
|
* phantom rename for every digit-boundary column and COLLIDED distinct columns
|
|
192
192
|
* (`a_1` and `a1`) onto one key.
|
|
@@ -43,8 +43,11 @@ export function extractCrashDiagnosis(logText: string): string | null {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Assemble the operator-facing report for an ops-handler crash: the raw message, the "it's the
|
|
46
|
-
* handler, not your command" framing, and either the real cause from CloudWatch or
|
|
47
|
-
*
|
|
46
|
+
* handler, not your command" framing, and either the real cause from CloudWatch or an
|
|
47
|
+
* everystack-native next step when the crash lines couldn't be read. The fallback must never
|
|
48
|
+
* send the operator to the AWS console — a consumer operator may have no console access at all,
|
|
49
|
+
* and an agent told to "check CloudWatch" will reach for raw `aws` with whatever credentials the
|
|
50
|
+
* shell holds.
|
|
48
51
|
*/
|
|
49
52
|
export function formatOpsCrashReport(
|
|
50
53
|
baseMessage: string,
|
|
@@ -54,7 +57,7 @@ export function formatOpsCrashReport(
|
|
|
54
57
|
const lines = [
|
|
55
58
|
baseMessage,
|
|
56
59
|
'',
|
|
57
|
-
'The ops handler crashed at startup —
|
|
60
|
+
'The ops handler crashed at startup — a failing top-level import or import-time side effect in the deployed bundle, not a problem with this command.',
|
|
58
61
|
];
|
|
59
62
|
if (diagnosis) {
|
|
60
63
|
lines.push(
|
|
@@ -62,10 +65,67 @@ export function formatOpsCrashReport(
|
|
|
62
65
|
`From CloudWatch${logGroup ? ` (${logGroup})` : ''}:`,
|
|
63
66
|
diagnosis,
|
|
64
67
|
'',
|
|
65
|
-
'Fix the import
|
|
68
|
+
'Fix the failing import in the ops handler and redeploy.',
|
|
66
69
|
);
|
|
67
70
|
} else {
|
|
68
|
-
lines.push(
|
|
71
|
+
lines.push(
|
|
72
|
+
'',
|
|
73
|
+
logGroup
|
|
74
|
+
? `No diagnostic lines in ${logGroup} yet — CloudWatch ingest can lag by a few seconds.`
|
|
75
|
+
: 'The crash lines could not be read from CloudWatch.',
|
|
76
|
+
'See the startup error with: everystack logs:tail --origin ops --stage <stage>',
|
|
77
|
+
'Fix the failing import in the ops handler and redeploy.',
|
|
78
|
+
);
|
|
69
79
|
}
|
|
70
80
|
return lines.join('\n');
|
|
71
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fetch-and-extract with retry: CloudWatch ingest lags a crash by a few seconds, so a single
|
|
85
|
+
* fetch right after the invoke routinely reads an empty window and the diagnosis degrades to
|
|
86
|
+
* the where-to-look fallback (a real consumer incident). Retries until lines land or the
|
|
87
|
+
* attempt budget runs out. Takes the fetch and sleep as thunks so it tests without the SDK.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchDiagnosisWithRetry(
|
|
90
|
+
fetchText: () => Promise<string>,
|
|
91
|
+
opts?: { attempts?: number; sleep?: () => Promise<void> },
|
|
92
|
+
): Promise<string | null> {
|
|
93
|
+
const attempts = opts?.attempts ?? 4;
|
|
94
|
+
const sleep = opts?.sleep ?? (() => new Promise<void>((r) => setTimeout(r, 2500)));
|
|
95
|
+
for (let i = 0; i < attempts; i++) {
|
|
96
|
+
if (i > 0) await sleep();
|
|
97
|
+
const diagnosis = extractCrashDiagnosis(await fetchText());
|
|
98
|
+
if (diagnosis) return diagnosis;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Runtime-level failure lines: the Lambda platform's own signals that a handler never ran —
|
|
105
|
+
* INIT crashes, import errors, timeouts. These never reach the app-level S3 log archive (the
|
|
106
|
+
* handler dies before it can ship them), so `logs:errors` reads them from CloudWatch and this
|
|
107
|
+
* decides which lines are worth showing.
|
|
108
|
+
*/
|
|
109
|
+
const RUNTIME_FAILURE = /Runtime exited|exited with error|INIT_REPORT.*Status: (error|timeout)|Runtime\.\w+(Error|Exit)|Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b|Task timed out|Process exited before completing request|(?:Reference|Type|Syntax|Range)?Error:/i;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reduce raw CloudWatch events to the runtime-failure lines an operator needs: filter to the
|
|
113
|
+
* platform failure signals, de-duplicate (a crash-looping Lambda emits the same line on every
|
|
114
|
+
* invoke), stamp each with its event time, and cap the output.
|
|
115
|
+
*/
|
|
116
|
+
export function summarizeRuntimeFailures(
|
|
117
|
+
events: Array<{ timestamp?: number; message?: string }>,
|
|
118
|
+
cap = 8,
|
|
119
|
+
): string[] {
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
const out: string[] = [];
|
|
122
|
+
for (const event of events) {
|
|
123
|
+
const line = (event.message ?? '').trim();
|
|
124
|
+
if (!line || !RUNTIME_FAILURE.test(line) || seen.has(line)) continue;
|
|
125
|
+
seen.add(line);
|
|
126
|
+
const stamp = event.timestamp ? `[${new Date(event.timestamp).toISOString()}] ` : '';
|
|
127
|
+
out.push(`${stamp}${line}`);
|
|
128
|
+
if (out.length >= cap) break;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { defineModel, field, can, index, defineModule, sql, arg, defineFunction, defineMaterializedView, defineSequence, defineView } from '@everystack/model';
|
|
2
|
-
|
|
3
|
-
export const PdrPost = defineModel('pdr_posts', {
|
|
4
|
-
abilities: [can('read'), can('manage', { role: 'admin' })],
|
|
5
|
-
fields: {
|
|
6
|
-
id: field.integer().primaryKey(),
|
|
7
|
-
title: field.text().notNull(),
|
|
8
|
-
tags: field.text().array().notNull().default("{}"),
|
|
9
|
-
},
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
export const pdrTicketSeq = defineSequence('pdr_ticket_seq', { start: 500 });
|
|
13
|
-
|
|
14
|
-
export const pdrsPdrArea = defineFunction('pdrs.pdr_area', {
|
|
15
|
-
args: [arg('w', 'integer'), arg('h', 'integer')],
|
|
16
|
-
returns: 'integer',
|
|
17
|
-
language: 'sql',
|
|
18
|
-
volatility: 'immutable',
|
|
19
|
-
abilities: [can('execute', { role: 'authenticated' })],
|
|
20
|
-
body: sql`SELECT w * h`,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
export const pdrBoard = defineMaterializedView('pdr_board', {
|
|
24
|
-
abilities: [can('read')],
|
|
25
|
-
dependsOn: [PdrPost],
|
|
26
|
-
indexes: [index('tags').using('gin')],
|
|
27
|
-
as: sql`SELECT tags,
|
|
28
|
-
count(*) AS n
|
|
29
|
-
FROM pdr_posts
|
|
30
|
-
GROUP BY tags`,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
export const pdrScore = defineFunction('pdr_score', {
|
|
34
|
-
args: [arg('points', 'integer', { default: sql`1` })],
|
|
35
|
-
returns: 'integer',
|
|
36
|
-
language: 'sql',
|
|
37
|
-
volatility: 'stable',
|
|
38
|
-
abilities: [can('execute', { role: 'authenticated' })],
|
|
39
|
-
body: sql`SELECT points * 2`,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
export const pdrTitles = defineView('pdr_titles', {
|
|
43
|
-
securityInvoker: false,
|
|
44
|
-
abilities: [can('read')],
|
|
45
|
-
dependsOn: [PdrPost],
|
|
46
|
-
as: sql`SELECT id,
|
|
47
|
-
title
|
|
48
|
-
FROM pdr_posts`,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
export const models = [PdrPost];
|
|
52
|
-
|
|
53
|
-
export const sequences = [pdrTicketSeq];
|
|
54
|
-
|
|
55
|
-
export const derived = [pdrsPdrArea, pdrBoard, pdrScore, pdrTitles];
|
|
56
|
-
|
|
57
|
-
export const appModule = defineModule({ models, sequences, derived });
|
|
58
|
-
|
|
59
|
-
export const modules = [appModule];
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { defineModel, field, unique, check, index, foreignKey, defineModule, sql } from '@everystack/model';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
|
|
4
|
-
export const RtAccount = defineModel('rt_accounts', {
|
|
5
|
-
// Declare the read model — db:check fails this model until its authz is authored:
|
|
6
|
-
// abilities: [can('read')], // public data
|
|
7
|
-
// abilities: [can('read', { owner: '<column>' })], // rows owned by a user
|
|
8
|
-
// private: true, // not part of the data API
|
|
9
|
-
fields: {
|
|
10
|
-
id: field.uuid().primaryKey().defaultRandom(),
|
|
11
|
-
email: field.text().notNull().unique(),
|
|
12
|
-
displayName: field.text().notNull(),
|
|
13
|
-
age: field.integer(),
|
|
14
|
-
balance: field.bigint().notNull().default(0),
|
|
15
|
-
creditLimit: field.numeric(12, 2),
|
|
16
|
-
score: field.numeric(8),
|
|
17
|
-
slug: field.varchar(255).notNull(),
|
|
18
|
-
region: field.varchar(),
|
|
19
|
-
bio: field.text().validate(z.string().max(160)),
|
|
20
|
-
active: field.boolean().notNull().default(true),
|
|
21
|
-
role: field.text().notNull().default("member"),
|
|
22
|
-
status: field.enum('account_status', ['active', 'suspended', 'closed']).notNull().default("active"),
|
|
23
|
-
metadata: field.jsonb(),
|
|
24
|
-
bornOn: field.date(),
|
|
25
|
-
createdAt: field.timestamptz().notNull().defaultNow(),
|
|
26
|
-
updatedAt: field.timestamp(),
|
|
27
|
-
},
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export const RtMetricArchive = defineModel('rt_metric_archives', {
|
|
31
|
-
// Declare the read model — db:check fails this model until its authz is authored:
|
|
32
|
-
// abilities: [can('read')], // public data
|
|
33
|
-
// abilities: [can('read', { owner: '<column>' })], // rows owned by a user
|
|
34
|
-
// private: true, // not part of the data API
|
|
35
|
-
fields: {
|
|
36
|
-
id: field.integer().primaryKey().defaultSql(sql`nextval('rt_metrics_id_seq'::regclass)`),
|
|
37
|
-
note: field.text(),
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
export const RtMetric = defineModel('rt_metrics', {
|
|
42
|
-
// Declare the read model — db:check fails this model until its authz is authored:
|
|
43
|
-
// abilities: [can('read')], // public data
|
|
44
|
-
// abilities: [can('read', { owner: '<column>' })], // rows owned by a user
|
|
45
|
-
// private: true, // not part of the data API
|
|
46
|
-
fields: {
|
|
47
|
-
id: field.serial().primaryKey(),
|
|
48
|
-
noteId: field.uuid().notNull().references(() => RtNote),
|
|
49
|
-
coverRate: field.real(),
|
|
50
|
-
meanMiss: field.doublePrecision().notNull(),
|
|
51
|
-
counter: field.bigserial(),
|
|
52
|
-
yardline_100: field.integer(),
|
|
53
|
-
top_3Best: field.integer(),
|
|
54
|
-
tags: field.text().array(),
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
export const RtNoteTag = defineModel('rt_note_tags', {
|
|
59
|
-
// Declare the read model — db:check fails this model until its authz is authored:
|
|
60
|
-
// abilities: [can('read')], // public data
|
|
61
|
-
// abilities: [can('read', { owner: '<column>' })], // rows owned by a user
|
|
62
|
-
// private: true, // not part of the data API
|
|
63
|
-
fields: {
|
|
64
|
-
noteId: field.uuid().primaryKey().references(() => RtNote),
|
|
65
|
-
label: field.text().primaryKey(),
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
export const RtNote = defineModel('rt_notes', {
|
|
70
|
-
// Declare the read model — db:check fails this model until its authz is authored:
|
|
71
|
-
// abilities: [can('read')], // public data
|
|
72
|
-
// abilities: [can('read', { owner: '<column>' })], // rows owned by a user
|
|
73
|
-
// private: true, // not part of the data API
|
|
74
|
-
fields: {
|
|
75
|
-
id: field.uuid().primaryKey().defaultRandom(),
|
|
76
|
-
accountId: field.uuid().notNull().references(() => RtAccount, { onDelete: 'cascade' }),
|
|
77
|
-
parentId: field.uuid().references(() => RtNote, { onDelete: 'set null', onUpdate: 'cascade' }),
|
|
78
|
-
title: field.text().notNull(),
|
|
79
|
-
body: field.text(),
|
|
80
|
-
pinned: field.boolean().notNull().default(false),
|
|
81
|
-
createdAt: field.timestamptz().notNull().defaultNow(),
|
|
82
|
-
},
|
|
83
|
-
constraints: [
|
|
84
|
-
unique('accountId', 'title'),
|
|
85
|
-
check(sql`char_length(title) > 0`),
|
|
86
|
-
index('createdAt').where(sql`pinned = true`),
|
|
87
|
-
index('parentId'),
|
|
88
|
-
],
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
export const RtTagLink = defineModel('rt_tag_links', {
|
|
92
|
-
// Declare the read model — db:check fails this model until its authz is authored:
|
|
93
|
-
// abilities: [can('read')], // public data
|
|
94
|
-
// abilities: [can('read', { owner: '<column>' })], // rows owned by a user
|
|
95
|
-
// private: true, // not part of the data API
|
|
96
|
-
fields: {
|
|
97
|
-
id: field.uuid().primaryKey().defaultRandom(),
|
|
98
|
-
noteId: field.uuid().notNull(),
|
|
99
|
-
label: field.text().notNull(),
|
|
100
|
-
},
|
|
101
|
-
constraints: [
|
|
102
|
-
foreignKey(['noteId', 'label'], () => RtNoteTag, ['noteId', 'label'], { onDelete: 'cascade' }),
|
|
103
|
-
],
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
export const RtTicket = defineModel('rt_tickets', {
|
|
107
|
-
// Declare the read model — db:check fails this model until its authz is authored:
|
|
108
|
-
// abilities: [can('read')], // public data
|
|
109
|
-
// abilities: [can('read', { owner: '<column>' })], // rows owned by a user
|
|
110
|
-
// private: true, // not part of the data API
|
|
111
|
-
fields: {
|
|
112
|
-
id: field.uuid().primaryKey().defaultRandom(),
|
|
113
|
-
ticketNumber: field.bigint().notNull().defaultSql(sql`nextval('rt_ticket_counter'::regclass)`),
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
export const models = [RtAccount, RtMetricArchive, RtMetric, RtNoteTag, RtNote, RtTagLink, RtTicket];
|
|
118
|
-
|
|
119
|
-
export const appModule = defineModule({ models });
|
|
120
|
-
|
|
121
|
-
export const modules = [appModule];
|