@everystack/cli 0.4.17 → 0.4.19
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 +8 -28
- package/src/cli/apply-authority.ts +14 -4
- package/src/cli/aws.ts +81 -18
- package/src/cli/commands/db-reconcile.ts +10 -2
- package/src/cli/commands/logs.ts +4 -4
- package/src/cli/commands/secrets.ts +6 -1
- package/src/cli/commands/status.ts +4 -2
- package/src/cli/deploy-probe.ts +7 -3
- package/src/cli/derived-apply.ts +38 -0
- package/src/cli/discover.ts +10 -9
- package/src/cli/swap-execute.ts +1 -1
- package/src/cli/utils/secrets.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.19",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -76,6 +76,11 @@
|
|
|
76
76
|
"dependencies": {
|
|
77
77
|
"@aws-sdk/client-cloudfront": "3.1053.0",
|
|
78
78
|
"@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
|
|
79
|
+
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
80
|
+
"@aws-sdk/client-lambda": "3.1053.0",
|
|
81
|
+
"@aws-sdk/client-s3": "3.1053.0",
|
|
82
|
+
"@aws-sdk/client-ssm": "3.1053.0",
|
|
83
|
+
"@aws-sdk/client-sts": "3.1053.0",
|
|
79
84
|
"@aws-sdk/signature-v4a": "3.1048.0",
|
|
80
85
|
"glob": "13.0.6",
|
|
81
86
|
"node-forge": "1.4.0",
|
|
@@ -87,18 +92,13 @@
|
|
|
87
92
|
"peerDependencies": {
|
|
88
93
|
"@everystack/server": ">=0.1.0",
|
|
89
94
|
"@aws-sdk/client-cloudwatch": "3.1053.0",
|
|
90
|
-
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
91
|
-
"@aws-sdk/client-lambda": "3.1053.0",
|
|
92
95
|
"@aws-sdk/client-rds": "3.1053.0",
|
|
93
|
-
"@aws-sdk/client-s3": "3.1053.0",
|
|
94
|
-
"@aws-sdk/client-ssm": "3.1053.0",
|
|
95
96
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
96
97
|
"drizzle-orm": "0.41.0",
|
|
97
98
|
"expo-updates": "55.0.21",
|
|
98
99
|
"postgres": "3.4.9",
|
|
99
100
|
"react": "19.2.0",
|
|
100
|
-
"react-native": "0.83.6"
|
|
101
|
-
"@aws-sdk/client-sts": "3.1053.0"
|
|
101
|
+
"react-native": "0.83.6"
|
|
102
102
|
},
|
|
103
103
|
"peerDependenciesMeta": {
|
|
104
104
|
"postgres": {
|
|
@@ -110,21 +110,9 @@
|
|
|
110
110
|
"@aws-sdk/client-cloudwatch": {
|
|
111
111
|
"optional": true
|
|
112
112
|
},
|
|
113
|
-
"@aws-sdk/client-cloudwatch-logs": {
|
|
114
|
-
"optional": true
|
|
115
|
-
},
|
|
116
|
-
"@aws-sdk/client-s3": {
|
|
117
|
-
"optional": true
|
|
118
|
-
},
|
|
119
|
-
"@aws-sdk/client-lambda": {
|
|
120
|
-
"optional": true
|
|
121
|
-
},
|
|
122
113
|
"@aws-sdk/client-rds": {
|
|
123
114
|
"optional": true
|
|
124
115
|
},
|
|
125
|
-
"@aws-sdk/client-ssm": {
|
|
126
|
-
"optional": true
|
|
127
|
-
},
|
|
128
116
|
"expo-updates": {
|
|
129
117
|
"optional": true
|
|
130
118
|
},
|
|
@@ -136,18 +124,11 @@
|
|
|
136
124
|
},
|
|
137
125
|
"@aws-sdk/s3-request-presigner": {
|
|
138
126
|
"optional": true
|
|
139
|
-
},
|
|
140
|
-
"@aws-sdk/client-sts": {
|
|
141
|
-
"optional": true
|
|
142
127
|
}
|
|
143
128
|
},
|
|
144
129
|
"devDependencies": {
|
|
145
130
|
"@aws-sdk/client-cloudwatch": "3.1053.0",
|
|
146
|
-
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
147
|
-
"@aws-sdk/client-lambda": "3.1053.0",
|
|
148
131
|
"@aws-sdk/client-rds": "3.1053.0",
|
|
149
|
-
"@aws-sdk/client-s3": "3.1053.0",
|
|
150
|
-
"@aws-sdk/client-ssm": "3.1053.0",
|
|
151
132
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
152
133
|
"@types/jest": "29.5.14",
|
|
153
134
|
"@types/node": "22.19.18",
|
|
@@ -156,8 +137,7 @@
|
|
|
156
137
|
"drizzle-orm": "0.41.0",
|
|
157
138
|
"jest": "29.7.0",
|
|
158
139
|
"react": "19.2.0",
|
|
159
|
-
"ts-jest": "29.4.9"
|
|
160
|
-
"@aws-sdk/client-sts": "3.1053.0"
|
|
140
|
+
"ts-jest": "29.4.9"
|
|
161
141
|
},
|
|
162
142
|
"scripts": {
|
|
163
143
|
"test": "jest",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* by the command shell.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { importOptionalAws } from './aws.js';
|
|
28
|
+
|
|
27
29
|
/** The SSM parameter holding the approver set — a JSON array of entries. */
|
|
28
30
|
export function approverParamName(appName: string, stage: string): string {
|
|
29
31
|
return `/everystack/${appName}/${stage}/db-approvers`;
|
|
@@ -89,7 +91,9 @@ export function checkDestructiveAuthority({ approvers, identity }: AuthorityInpu
|
|
|
89
91
|
|
|
90
92
|
/** Read the approver set from SSM. Null when the parameter does not exist. */
|
|
91
93
|
export async function readApproverParam(region: string, appName: string, stage: string): Promise<string[] | null> {
|
|
92
|
-
const { SSMClient, GetParameterCommand } = await
|
|
94
|
+
const { SSMClient, GetParameterCommand } = await importOptionalAws(
|
|
95
|
+
() => import('@aws-sdk/client-ssm'), '@aws-sdk/client-ssm', 'db:approvers',
|
|
96
|
+
);
|
|
93
97
|
const client = new SSMClient({ region });
|
|
94
98
|
try {
|
|
95
99
|
const result = await client.send(new GetParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
@@ -116,7 +120,9 @@ export function parseApproverValue(value: string): string[] {
|
|
|
116
120
|
|
|
117
121
|
/** Declare (or replace) the approver set. Stored as a JSON array. */
|
|
118
122
|
export async function writeApproverParam(region: string, appName: string, stage: string, approvers: string[]): Promise<void> {
|
|
119
|
-
const { SSMClient, PutParameterCommand } = await
|
|
123
|
+
const { SSMClient, PutParameterCommand } = await importOptionalAws(
|
|
124
|
+
() => import('@aws-sdk/client-ssm'), '@aws-sdk/client-ssm', 'db:approvers',
|
|
125
|
+
);
|
|
120
126
|
const client = new SSMClient({ region });
|
|
121
127
|
await client.send(new PutParameterCommand({
|
|
122
128
|
Name: approverParamName(appName, stage),
|
|
@@ -128,7 +134,9 @@ export async function writeApproverParam(region: string, appName: string, stage:
|
|
|
128
134
|
|
|
129
135
|
/** Remove the declaration entirely — the stage returns to the undeclared (ceremony-only) posture. */
|
|
130
136
|
export async function removeApproverParam(region: string, appName: string, stage: string): Promise<void> {
|
|
131
|
-
const { SSMClient, DeleteParameterCommand } = await
|
|
137
|
+
const { SSMClient, DeleteParameterCommand } = await importOptionalAws(
|
|
138
|
+
() => import('@aws-sdk/client-ssm'), '@aws-sdk/client-ssm', 'db:approvers',
|
|
139
|
+
);
|
|
132
140
|
const client = new SSMClient({ region });
|
|
133
141
|
try {
|
|
134
142
|
await client.send(new DeleteParameterCommand({ Name: approverParamName(appName, stage) }));
|
|
@@ -139,7 +147,9 @@ export async function removeApproverParam(region: string, appName: string, stage
|
|
|
139
147
|
|
|
140
148
|
/** The caller's real identity — the STS ARN. Null when credentials can't answer. */
|
|
141
149
|
export async function resolveCallerIdentity(region: string): Promise<string | null> {
|
|
142
|
-
const { STSClient, GetCallerIdentityCommand } = await
|
|
150
|
+
const { STSClient, GetCallerIdentityCommand } = await importOptionalAws(
|
|
151
|
+
() => import('@aws-sdk/client-sts'), '@aws-sdk/client-sts', 'destructive apply authorization',
|
|
152
|
+
);
|
|
143
153
|
const client = new STSClient({ region });
|
|
144
154
|
try {
|
|
145
155
|
const result = await client.send(new GetCallerIdentityCommand({}));
|
package/src/cli/aws.ts
CHANGED
|
@@ -15,7 +15,9 @@ let rdsClient: InstanceType<typeof import('@aws-sdk/client-rds').RDSClient> | nu
|
|
|
15
15
|
|
|
16
16
|
async function getS3(region: string) {
|
|
17
17
|
if (!s3Client) {
|
|
18
|
-
const { S3Client } = await
|
|
18
|
+
const { S3Client } = await importOptionalAws(
|
|
19
|
+
() => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'this command',
|
|
20
|
+
);
|
|
19
21
|
s3Client = new S3Client({ region });
|
|
20
22
|
}
|
|
21
23
|
return s3Client;
|
|
@@ -23,12 +25,49 @@ async function getS3(region: string) {
|
|
|
23
25
|
|
|
24
26
|
async function getLambda(region: string) {
|
|
25
27
|
if (!lambdaClient) {
|
|
26
|
-
const { LambdaClient } = await
|
|
28
|
+
const { LambdaClient } = await importOptionalAws(
|
|
29
|
+
() => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'this command',
|
|
30
|
+
);
|
|
27
31
|
lambdaClient = new LambdaClient({ region });
|
|
28
32
|
}
|
|
29
33
|
return lambdaClient;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A missing optional-peer dependency, marked so a command's catch block can print the install
|
|
38
|
+
* remedy INSTEAD of its default AWS/IAM advice. Blaming IAM for a missing npm package sends the
|
|
39
|
+
* operator to debug permissions they already have (a consumer field report).
|
|
40
|
+
*/
|
|
41
|
+
export class MissingOptionalDependencyError extends Error {
|
|
42
|
+
readonly missing: string[];
|
|
43
|
+
constructor(feature: string, missing: string[]) {
|
|
44
|
+
const plural = missing.length > 1;
|
|
45
|
+
super(
|
|
46
|
+
`${feature} needs ${missing.join(', ')}, which ${plural ? 'are' : 'is'} not installed `
|
|
47
|
+
+ `(optional peer ${plural ? 'dependencies' : 'dependency'}). Install ${plural ? 'them' : 'it'}:\n`
|
|
48
|
+
+ ` pnpm add ${missing.join(' ')}`,
|
|
49
|
+
);
|
|
50
|
+
this.name = 'MissingOptionalDependencyError';
|
|
51
|
+
this.missing = missing;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when `err` is a missing optional dependency — the signal to skip IAM advice. */
|
|
56
|
+
export function isMissingOptionalDependencyError(err: unknown): boolean {
|
|
57
|
+
return err instanceof MissingOptionalDependencyError;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a caught error is Node's "package/module not found" (ESM or CJS). */
|
|
61
|
+
function isModuleNotFound(err: unknown): boolean {
|
|
62
|
+
const code = (err as { code?: string })?.code;
|
|
63
|
+
const message = String((err as { message?: string })?.message ?? err);
|
|
64
|
+
return (
|
|
65
|
+
code === 'ERR_MODULE_NOT_FOUND' ||
|
|
66
|
+
code === 'MODULE_NOT_FOUND' ||
|
|
67
|
+
/Cannot find (package|module)/.test(message)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
/**
|
|
33
72
|
* Lazily load an optional-peer AWS SDK client, turning a missing dependency into a one-line
|
|
34
73
|
* install remedy instead of a raw `ERR_MODULE_NOT_FOUND` at the call site. The `@aws-sdk/*`
|
|
@@ -44,21 +83,35 @@ export async function importOptionalAws<T>(
|
|
|
44
83
|
try {
|
|
45
84
|
return await load();
|
|
46
85
|
} catch (err: unknown) {
|
|
47
|
-
|
|
48
|
-
const message = String((err as { message?: string })?.message ?? err);
|
|
49
|
-
if (
|
|
50
|
-
code === 'ERR_MODULE_NOT_FOUND' ||
|
|
51
|
-
code === 'MODULE_NOT_FOUND' ||
|
|
52
|
-
/Cannot find (package|module)/.test(message)
|
|
53
|
-
) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
`${feature} needs ${pkg}, which isn't installed (it's an optional peer dependency). Install it:\n pnpm add ${pkg}`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
86
|
+
if (isModuleNotFound(err)) throw new MissingOptionalDependencyError(feature, [pkg]);
|
|
58
87
|
throw err;
|
|
59
88
|
}
|
|
60
89
|
}
|
|
61
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Preflight EVERY optional-peer package a command needs, and name the FULL missing set in one
|
|
93
|
+
* error — not one crash at a time. A command that uses ssm+s3 (secrets) reports both missing at
|
|
94
|
+
* once with a single `pnpm add` line, so the operator installs everything the step needs in one
|
|
95
|
+
* go instead of discovering them serially through repeated failures (a consumer field report).
|
|
96
|
+
* Each `load` is a literal `() => import('@aws-sdk/...')` so the specifiers stay statically
|
|
97
|
+
* analyzable; ESM caches the module, so the real call site re-imports for free.
|
|
98
|
+
*/
|
|
99
|
+
export async function ensureOptionalAws(
|
|
100
|
+
deps: Array<{ load: () => Promise<unknown>; pkg: string }>,
|
|
101
|
+
feature: string,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const missing: string[] = [];
|
|
104
|
+
for (const { load, pkg } of deps) {
|
|
105
|
+
try {
|
|
106
|
+
await load();
|
|
107
|
+
} catch (err: unknown) {
|
|
108
|
+
if (isModuleNotFound(err)) missing.push(pkg);
|
|
109
|
+
else throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (missing.length > 0) throw new MissingOptionalDependencyError(feature, missing);
|
|
113
|
+
}
|
|
114
|
+
|
|
62
115
|
async function getRds(region: string) {
|
|
63
116
|
if (!rdsClient) {
|
|
64
117
|
const { RDSClient } = await importOptionalAws(
|
|
@@ -206,7 +259,9 @@ export async function resolveFunctionName(
|
|
|
206
259
|
// Include the trailing dash so the prefix can't match a longer resource name.
|
|
207
260
|
const basePrefix = functionName.slice(0, lastDash + 1).toLowerCase();
|
|
208
261
|
|
|
209
|
-
const { LambdaClient, ListFunctionsCommand } = await
|
|
262
|
+
const { LambdaClient, ListFunctionsCommand } = await importOptionalAws(
|
|
263
|
+
() => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'this command',
|
|
264
|
+
);
|
|
210
265
|
const client = new LambdaClient({ region });
|
|
211
266
|
|
|
212
267
|
const candidates: Array<{ name: string; modified: number }> = [];
|
|
@@ -315,7 +370,9 @@ export async function invokeAction(
|
|
|
315
370
|
|
|
316
371
|
/** Recent CloudWatch log text for a function's log group (last `windowMs`, capped). */
|
|
317
372
|
async function fetchRecentLogText(region: string, logGroupName: string, windowMs = 5 * 60_000): Promise<string> {
|
|
318
|
-
const { CloudWatchLogsClient, FilterLogEventsCommand } = await
|
|
373
|
+
const { CloudWatchLogsClient, FilterLogEventsCommand } = await importOptionalAws(
|
|
374
|
+
() => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'reading logs',
|
|
375
|
+
);
|
|
319
376
|
const client = new CloudWatchLogsClient({ region });
|
|
320
377
|
const res = await client.send(new FilterLogEventsCommand({
|
|
321
378
|
logGroupName,
|
|
@@ -331,7 +388,9 @@ export async function fetchRecentLogEvents(
|
|
|
331
388
|
logGroupName: string,
|
|
332
389
|
windowMs: number,
|
|
333
390
|
): Promise<Array<{ timestamp?: number; message?: string }>> {
|
|
334
|
-
const { CloudWatchLogsClient, FilterLogEventsCommand } = await
|
|
391
|
+
const { CloudWatchLogsClient, FilterLogEventsCommand } = await importOptionalAws(
|
|
392
|
+
() => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'reading logs',
|
|
393
|
+
);
|
|
335
394
|
const client = new CloudWatchLogsClient({ region });
|
|
336
395
|
const res = await client.send(new FilterLogEventsCommand({
|
|
337
396
|
logGroupName,
|
|
@@ -376,7 +435,9 @@ async function defaultLogGroupIo(region: string): Promise<LogGroupIo> {
|
|
|
376
435
|
return cfg.LoggingConfig?.LogGroup ?? undefined;
|
|
377
436
|
},
|
|
378
437
|
describeLogGroups: async (prefix, limit) => {
|
|
379
|
-
const { CloudWatchLogsClient, DescribeLogGroupsCommand } = await
|
|
438
|
+
const { CloudWatchLogsClient, DescribeLogGroupsCommand } = await importOptionalAws(
|
|
439
|
+
() => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'reading logs',
|
|
440
|
+
);
|
|
380
441
|
const client = new CloudWatchLogsClient({ region });
|
|
381
442
|
const res = await client.send(new DescribeLogGroupsCommand({ logGroupNamePrefix: prefix, limit }));
|
|
382
443
|
return res.logGroups ?? [];
|
|
@@ -528,7 +589,9 @@ export async function presignGet(
|
|
|
528
589
|
): Promise<string> {
|
|
529
590
|
const client = await getS3(region);
|
|
530
591
|
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
531
|
-
const { getSignedUrl } = await
|
|
592
|
+
const { getSignedUrl } = await importOptionalAws(
|
|
593
|
+
() => import('@aws-sdk/s3-request-presigner'), '@aws-sdk/s3-request-presigner', 'db:backup:download',
|
|
594
|
+
);
|
|
532
595
|
return await getSignedUrl(client as any, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn });
|
|
533
596
|
}
|
|
534
597
|
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
renderSchemaLogInsert,
|
|
43
43
|
derivedSearchPath,
|
|
44
44
|
renderSetSearchPath,
|
|
45
|
+
renderEnsureObjectSchemas,
|
|
45
46
|
ENSURE_RECONCILER_SQL,
|
|
46
47
|
} from '../derived-apply.js';
|
|
47
48
|
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
@@ -151,6 +152,12 @@ export async function executeReconcile(
|
|
|
151
152
|
// as it was. See derivedSearchPath.
|
|
152
153
|
const setSearchPath = renderSetSearchPath(derivedSearchPath(parsed.objects), atomic);
|
|
153
154
|
|
|
155
|
+
// A schema populated only by derived objects has no model in it, so the state layer never
|
|
156
|
+
// CREATE SCHEMAs it — the derived apply ensures its own object schemas (+ their USAGE
|
|
157
|
+
// grants) before anything is created in them. Empty for all-public apps. See
|
|
158
|
+
// renderEnsureObjectSchemas.
|
|
159
|
+
const ensureSchemas = renderEnsureObjectSchemas(parsed.objects);
|
|
160
|
+
|
|
154
161
|
if (atomic) await runner('BEGIN');
|
|
155
162
|
try {
|
|
156
163
|
// The session guard: postgres-js `max: 1` is a pool of one, not a session lease — a
|
|
@@ -166,7 +173,7 @@ export async function executeReconcile(
|
|
|
166
173
|
if (atomic) {
|
|
167
174
|
// The SET rides the same simple-query batch (and the same transaction) as the DDL,
|
|
168
175
|
// so bare body refs resolve to their real schema; SET LOCAL resets it at COMMIT.
|
|
169
|
-
await runner([setSearchPath, ...rendered.statements].filter(Boolean).join(';\n'));
|
|
176
|
+
await runner([...ensureSchemas, setSearchPath, ...rendered.statements].filter(Boolean).join(';\n'));
|
|
170
177
|
} else {
|
|
171
178
|
// Self-committing statements (CONCURRENTLY, VACUUM) refuse ANY transaction block —
|
|
172
179
|
// including the implicit one a joined multi-statement simple query runs in, so the
|
|
@@ -174,6 +181,7 @@ export async function executeReconcile(
|
|
|
174
181
|
// earlier statements committed: the documented cost of the unwrapped path; the next
|
|
175
182
|
// plan re-converges from what actually landed. The session SET precedes them so every
|
|
176
183
|
// create resolves its bare refs.
|
|
184
|
+
for (const statement of ensureSchemas) await runner(statement);
|
|
177
185
|
if (setSearchPath) await runner(setSearchPath);
|
|
178
186
|
for (const statement of rendered.statements) await runner(statement);
|
|
179
187
|
}
|
|
@@ -211,7 +219,7 @@ export async function executeReconcile(
|
|
|
211
219
|
bookkeeping.push(renderSchemaLogInsert({
|
|
212
220
|
kind: 'compute reconcile',
|
|
213
221
|
sql: rendered.statements.length > 0
|
|
214
|
-
? [setSearchPath, ...rendered.statements].filter(Boolean).join(';\n')
|
|
222
|
+
? [...ensureSchemas, setSearchPath, ...rendered.statements].filter(Boolean).join(';\n')
|
|
215
223
|
: '-- provenance-only (baseline/rebaseline/prune)',
|
|
216
224
|
outcome: 'applied',
|
|
217
225
|
actor: options.actor, gitRef: options.gitRef, planRef: options.planRef,
|
package/src/cli/commands/logs.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { resolveConfig } from '../config.js';
|
|
14
|
-
import { resolveLogGroup, fetchRecentLogEvents } from '../aws.js';
|
|
14
|
+
import { resolveLogGroup, fetchRecentLogEvents, importOptionalAws } from '../aws.js';
|
|
15
15
|
import { summarizeRuntimeFailures } from '../ops-diagnostics.js';
|
|
16
16
|
import { queryLogArchive, type ObservabilityQuery } from '../observability.js';
|
|
17
17
|
import { step, success, fail, info } from '../output.js';
|
|
@@ -281,9 +281,9 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
|
|
|
281
281
|
step(`Tailing logs (filter: ${filterPattern || 'none'}, since: ${since})...`);
|
|
282
282
|
|
|
283
283
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
const { CloudWatchLogsClient, FilterLogEventsCommand } = await importOptionalAws(
|
|
285
|
+
() => import('@aws-sdk/client-cloudwatch-logs'), '@aws-sdk/client-cloudwatch-logs', 'logs:tail',
|
|
286
|
+
);
|
|
287
287
|
|
|
288
288
|
const client = new CloudWatchLogsClient({ region: config.region });
|
|
289
289
|
|
|
@@ -18,6 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { step, success, fail, info, warn } from '../output.js';
|
|
19
19
|
import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
|
|
20
20
|
import { parseDotenv, serializeDotenv } from '../utils/dotenv.js';
|
|
21
|
+
import { isMissingOptionalDependencyError } from '../aws.js';
|
|
21
22
|
|
|
22
23
|
interface SecretsContext {
|
|
23
24
|
appName: string;
|
|
@@ -296,7 +297,11 @@ export async function secretsCommand(
|
|
|
296
297
|
}
|
|
297
298
|
} catch (err: any) {
|
|
298
299
|
fail(err.message);
|
|
299
|
-
|
|
300
|
+
// A missing npm package is not an IAM problem — the message already names the install.
|
|
301
|
+
// Printing IAM advice here is the field-reported misdirection.
|
|
302
|
+
if (!isMissingOptionalDependencyError(err)) {
|
|
303
|
+
info('Ensure your IAM user/role has ssm:GetParameter, s3:GetObject, and kms:Decrypt permissions.');
|
|
304
|
+
}
|
|
300
305
|
process.exit(1);
|
|
301
306
|
}
|
|
302
307
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
15
|
-
import { invokeAction } from '../aws.js';
|
|
15
|
+
import { invokeAction, importOptionalAws } from '../aws.js';
|
|
16
16
|
import { fail, info, step } from '../output.js';
|
|
17
17
|
|
|
18
18
|
export interface MetricSpec {
|
|
@@ -70,7 +70,9 @@ async function defaultGetMetric(
|
|
|
70
70
|
spec: MetricSpec,
|
|
71
71
|
hours: number,
|
|
72
72
|
): Promise<number | null> {
|
|
73
|
-
const { CloudWatchClient, GetMetricDataCommand } = await
|
|
73
|
+
const { CloudWatchClient, GetMetricDataCommand } = await importOptionalAws(
|
|
74
|
+
() => import('@aws-sdk/client-cloudwatch'), '@aws-sdk/client-cloudwatch', 'status metrics',
|
|
75
|
+
);
|
|
74
76
|
const client = new CloudWatchClient({ region: spec.region ?? region });
|
|
75
77
|
const end = new Date();
|
|
76
78
|
const start = new Date(end.getTime() - hours * 3600_000);
|
package/src/cli/deploy-probe.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { isOpsHandlerCrash } from './ops-diagnostics.js';
|
|
20
|
-
import { diagnoseOpsCrash } from './aws.js';
|
|
20
|
+
import { diagnoseOpsCrash, importOptionalAws } from './aws.js';
|
|
21
21
|
import { step, success, fail, info } from './output.js';
|
|
22
22
|
|
|
23
23
|
/** The raw outcome of one probe invoke, before classification. */
|
|
@@ -80,7 +80,9 @@ export function probeTargets(config: {
|
|
|
80
80
|
/** One raw `_health` invoke → outcome. Never throws; every failure mode lands in the outcome. */
|
|
81
81
|
async function probeFunction(region: string, functionName: string): Promise<ProbeOutcome> {
|
|
82
82
|
try {
|
|
83
|
-
const { LambdaClient, InvokeCommand } = await
|
|
83
|
+
const { LambdaClient, InvokeCommand } = await importOptionalAws(
|
|
84
|
+
() => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'the deploy health probe',
|
|
85
|
+
);
|
|
84
86
|
const client = new LambdaClient({ region });
|
|
85
87
|
const response = await client.send(new InvokeCommand({
|
|
86
88
|
FunctionName: functionName,
|
|
@@ -160,7 +162,9 @@ async function invokeHttpGet(
|
|
|
160
162
|
queryString = '',
|
|
161
163
|
): Promise<{ statusCode?: number; body?: string } | null> {
|
|
162
164
|
try {
|
|
163
|
-
const { LambdaClient, InvokeCommand } = await
|
|
165
|
+
const { LambdaClient, InvokeCommand } = await importOptionalAws(
|
|
166
|
+
() => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'the deploy health probe',
|
|
167
|
+
);
|
|
164
168
|
const client = new LambdaClient({ region });
|
|
165
169
|
const response = await client.send(new InvokeCommand({
|
|
166
170
|
FunctionName: functionName,
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { normalizeSql, parseQualified, type SourceObject } from './derived-source.js';
|
|
17
17
|
import type { ReconcilePlan } from './derived-plan.js';
|
|
18
|
+
import { parseGrantAttachments } from './derived-grants.js';
|
|
18
19
|
import { quoteIdent, quoteQualified } from './pg-ident.js';
|
|
19
20
|
|
|
20
21
|
/** SQL string literal with '' doubling (standard_conforming_strings). */
|
|
@@ -136,6 +137,43 @@ export function renderSetSearchPath(schemas: string[], local: boolean): string |
|
|
|
136
137
|
return `SET ${local ? 'LOCAL ' : ''}search_path = ${schemas.map(quoteIdent).join(', ')}`;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* `CREATE SCHEMA IF NOT EXISTS` for each non-public schema the declared objects LIVE
|
|
142
|
+
* in — migration-compile's rule 0a mirrored into the derived layer. Rule 0a only
|
|
143
|
+
* creates schemas a MODEL lives in, so a schema populated purely by derived objects
|
|
144
|
+
* (the derivation half of a stats/stat_views split) was never created and a
|
|
145
|
+
* from-scratch apply died on its first CREATE. Object schemas only, never
|
|
146
|
+
* declaredDeps schemas: a dependency's schema belongs to the models that own it, and
|
|
147
|
+
* a missing one must still fail at CREATE instead of being silently minted empty.
|
|
148
|
+
*
|
|
149
|
+
* `GRANT USAGE` rides along per schema (0a-bis mirrored), derived from the objects'
|
|
150
|
+
* own grant attachments: a role granted SELECT on a matview in a non-public schema
|
|
151
|
+
* cannot reach it without USAGE, so the object grant is dead without this. A PUBLIC
|
|
152
|
+
* that appears only as explicitly-revoked gets nothing. Empty for all-public apps —
|
|
153
|
+
* their apply stays byte-identical. Idempotent when the state layer already created
|
|
154
|
+
* the schema (a model schema also carrying derived objects).
|
|
155
|
+
*/
|
|
156
|
+
export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
157
|
+
const statements: string[] = [];
|
|
158
|
+
const schemas = [...new Set(objects.map((o) => o.schema))].filter((s) => s !== 'public').sort();
|
|
159
|
+
for (const schema of schemas) {
|
|
160
|
+
statements.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`);
|
|
161
|
+
const roles = new Set<string>();
|
|
162
|
+
for (const o of objects) {
|
|
163
|
+
if (o.schema !== schema) continue;
|
|
164
|
+
const { grants } = parseGrantAttachments(o.attachments);
|
|
165
|
+
for (const [role, privileges] of Object.entries(grants)) {
|
|
166
|
+
if (privileges.length > 0) roles.add(role);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (roles.size > 0) {
|
|
170
|
+
const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
|
|
171
|
+
statements.push(`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${targets}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return statements;
|
|
175
|
+
}
|
|
176
|
+
|
|
139
177
|
export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]): RenderedReconcile {
|
|
140
178
|
const srcById = new Map(source.map((o) => [o.identity, o]));
|
|
141
179
|
const statements: string[] = [];
|
package/src/cli/discover.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import fs from 'node:fs/promises';
|
|
15
15
|
import path from 'node:path';
|
|
16
|
+
import { importOptionalAws } from './aws.js';
|
|
16
17
|
|
|
17
18
|
/** Mirrors CliConfig from config.ts — defined locally to avoid circular imports. */
|
|
18
19
|
interface DiscoveredConfig {
|
|
@@ -141,10 +142,9 @@ export async function discoverFunction(
|
|
|
141
142
|
prefix: string,
|
|
142
143
|
resourceName: string,
|
|
143
144
|
): Promise<string | undefined> {
|
|
144
|
-
const {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
} = await import('@aws-sdk/client-lambda');
|
|
145
|
+
const { LambdaClient, ListFunctionsCommand } = await importOptionalAws(
|
|
146
|
+
() => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'resource discovery',
|
|
147
|
+
);
|
|
148
148
|
|
|
149
149
|
const client = new LambdaClient({ region });
|
|
150
150
|
const needle = `${prefix}${resourceName}`.toLowerCase();
|
|
@@ -185,10 +185,9 @@ export async function discoverLambda(
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
// Get env vars for routerUrl (CDN_URL)
|
|
188
|
-
const {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
} = await import('@aws-sdk/client-lambda');
|
|
188
|
+
const { LambdaClient, GetFunctionConfigurationCommand } = await importOptionalAws(
|
|
189
|
+
() => import('@aws-sdk/client-lambda'), '@aws-sdk/client-lambda', 'resource discovery',
|
|
190
|
+
);
|
|
192
191
|
|
|
193
192
|
const client = new LambdaClient({ region });
|
|
194
193
|
const config = await client.send(
|
|
@@ -219,7 +218,9 @@ export async function discoverBuckets(
|
|
|
219
218
|
region: string,
|
|
220
219
|
prefix: string,
|
|
221
220
|
): Promise<BucketsResult> {
|
|
222
|
-
const { S3Client, ListBucketsCommand } = await
|
|
221
|
+
const { S3Client, ListBucketsCommand } = await importOptionalAws(
|
|
222
|
+
() => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'resource discovery',
|
|
223
|
+
);
|
|
223
224
|
|
|
224
225
|
const client = new S3Client({ region });
|
|
225
226
|
const res = await client.send(new ListBucketsCommand({}));
|
package/src/cli/swap-execute.ts
CHANGED
|
@@ -107,7 +107,7 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
|
|
|
107
107
|
try { await runner(`DROP SCHEMA IF EXISTS "${plan.incoming}" CASCADE`); } catch { /* best effort */ }
|
|
108
108
|
return {
|
|
109
109
|
status: 'refused-integrity',
|
|
110
|
-
reason: `the swap transaction failed
|
|
110
|
+
reason: `the swap transaction failed and rolled back whole: ${String(err?.message ?? err)}. Candidate causes: a cross-schema FK re-validating against the new data (an app row references a ${opts.schema} key the artifact dropped), or an authz statement colliding with authz the artifact already carries — the message above names the actual failing statement.`,
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
|
package/src/cli/utils/secrets.ts
CHANGED
|
@@ -15,6 +15,22 @@
|
|
|
15
15
|
|
|
16
16
|
import crypto from 'node:crypto';
|
|
17
17
|
import { gunzipSync } from 'node:zlib';
|
|
18
|
+
import { ensureOptionalAws } from '../aws.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Preflight the two optional-peer clients EVERY secrets operation needs (ssm for the passphrase,
|
|
22
|
+
* s3 for the blob), naming both at once if missing — the operator installs everything in one
|
|
23
|
+
* `pnpm add` instead of discovering them one crash at a time (a consumer field report).
|
|
24
|
+
*/
|
|
25
|
+
function ensureSecretsAws(): Promise<void> {
|
|
26
|
+
return ensureOptionalAws(
|
|
27
|
+
[
|
|
28
|
+
{ load: () => import('@aws-sdk/client-ssm'), pkg: '@aws-sdk/client-ssm' },
|
|
29
|
+
{ load: () => import('@aws-sdk/client-s3'), pkg: '@aws-sdk/client-s3' },
|
|
30
|
+
],
|
|
31
|
+
'secrets',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
18
34
|
|
|
19
35
|
export interface LoadSecretsOptions {
|
|
20
36
|
appName: string;
|
|
@@ -130,6 +146,8 @@ export async function loadSstSecrets(
|
|
|
130
146
|
): Promise<Record<string, string>> {
|
|
131
147
|
const { appName, stage, region } = options;
|
|
132
148
|
|
|
149
|
+
await ensureSecretsAws();
|
|
150
|
+
|
|
133
151
|
// 1. Get the state bucket name from /sst/bootstrap
|
|
134
152
|
const bucket = await getStateBucket(region);
|
|
135
153
|
|
|
@@ -166,6 +184,8 @@ export async function putSstSecrets(
|
|
|
166
184
|
): Promise<void> {
|
|
167
185
|
const { appName, stage, region } = options;
|
|
168
186
|
|
|
187
|
+
await ensureSecretsAws();
|
|
188
|
+
|
|
169
189
|
const bucket = await getStateBucket(region);
|
|
170
190
|
const passphrase = await getPassphrase(region, appName, stage);
|
|
171
191
|
|