@friggframework/devtools 2.0.0--canary.623.b39b42d.0 → 2.0.0--canary.625.950ba82.0

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.
Files changed (39) hide show
  1. package/frigg-cli/__tests__/unit/commands/generate-iam.test.js +97 -0
  2. package/frigg-cli/deploy-command/index.js +44 -0
  3. package/frigg-cli/generate-command/index.js +2 -1
  4. package/frigg-cli/generate-iam-command.js +2 -1
  5. package/frigg-cli/index.js +16 -1
  6. package/frigg-cli/ssm-command/index.js +270 -0
  7. package/frigg-cli/ssm-command/index.test.js +301 -0
  8. package/infrastructure/__tests__/helpers/test-utils.js +3 -5
  9. package/infrastructure/__tests__/scoped-environment.test.js +126 -0
  10. package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
  11. package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
  12. package/infrastructure/domains/database/migration-builder.js +46 -9
  13. package/infrastructure/domains/database/migration-builder.test.js +101 -0
  14. package/infrastructure/domains/integration/integration-builder.js +65 -9
  15. package/infrastructure/domains/integration/integration-builder.test.js +95 -0
  16. package/infrastructure/domains/networking/vpc-builder.js +57 -7
  17. package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
  18. package/infrastructure/domains/networking/vpc-resolver.js +12 -3
  19. package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
  20. package/infrastructure/domains/parameters/offload-utils.js +175 -0
  21. package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
  22. package/infrastructure/domains/parameters/ssm-builder.js +60 -14
  23. package/infrastructure/domains/parameters/ssm-builder.test.js +145 -1
  24. package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
  25. package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
  26. package/infrastructure/domains/security/iam-generator.js +33 -1
  27. package/infrastructure/domains/security/iam-generator.test.js +73 -0
  28. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +15 -0
  29. package/infrastructure/domains/security/templates/iam-policy-full.json +7 -2
  30. package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
  31. package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
  32. package/infrastructure/domains/shared/environment-builder.js +43 -7
  33. package/infrastructure/domains/shared/environment-builder.test.js +97 -1
  34. package/infrastructure/domains/shared/function-environments.js +97 -0
  35. package/infrastructure/domains/shared/function-environments.test.js +146 -0
  36. package/infrastructure/infrastructure-composer.js +5 -0
  37. package/infrastructure/infrastructure-composer.test.js +69 -14
  38. package/infrastructure/integration.test.js +3 -5
  39. package/package.json +8 -7
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Test suite for generate-iam command
3
+ *
4
+ * Drives generateIamCommand end-to-end (real getFeatureSummary +
5
+ * generateIAMCloudFormation) to verify appDefinition.ssm.kmsKeyArn
6
+ * actually reaches the generated CloudFormation template. A unit test
7
+ * that hand-supplies ssmKmsKeyArn straight to generateIAMCloudFormation
8
+ * would not catch a caller that forgets to thread it through.
9
+ */
10
+
11
+ jest.mock('fs-extra');
12
+ jest.mock('@friggframework/core', () => ({
13
+ findNearestBackendPackageJson: jest.fn()
14
+ }));
15
+
16
+ const path = require('path');
17
+ const fs = require('fs-extra');
18
+ const { findNearestBackendPackageJson } = require('@friggframework/core');
19
+ const { generateIamCommand } = require('../../../generate-iam-command');
20
+
21
+ describe('CLI Command: generate-iam', () => {
22
+ const mockBackendPath = '/mock/backend/package.json';
23
+ const mockBackendDir = '/mock/backend';
24
+ const mockAppDefinitionPath = path.join(mockBackendDir, 'index.js');
25
+
26
+ beforeEach(() => {
27
+ jest.resetModules();
28
+ jest.clearAllMocks();
29
+
30
+ jest.spyOn(process, 'exit').mockImplementation(() => {});
31
+ jest.spyOn(console, 'log').mockImplementation(() => {});
32
+ jest.spyOn(console, 'error').mockImplementation(() => {});
33
+
34
+ findNearestBackendPackageJson.mockReturnValue(mockBackendPath);
35
+
36
+ fs.existsSync = jest.fn().mockReturnValue(true);
37
+ fs.ensureDir = jest.fn().mockResolvedValue();
38
+ fs.writeFile = jest.fn().mockResolvedValue();
39
+ });
40
+
41
+ afterEach(() => {
42
+ jest.restoreAllMocks();
43
+ jest.dontMock(mockAppDefinitionPath);
44
+ });
45
+
46
+ it('scopes the SSM-mediated KMS grant to ssm.kmsKeyArn when configured', async () => {
47
+ const mockAppDefinition = {
48
+ name: 'test-app',
49
+ ssm: {
50
+ enable: true,
51
+ kmsKeyArn:
52
+ 'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
53
+ }
54
+ };
55
+
56
+ jest.doMock(
57
+ mockAppDefinitionPath,
58
+ () => ({ Definition: mockAppDefinition }),
59
+ { virtual: true }
60
+ );
61
+
62
+ await generateIamCommand({});
63
+
64
+ expect(fs.writeFile).toHaveBeenCalled();
65
+ const [, generatedYaml] = fs.writeFile.mock.calls[0];
66
+
67
+ expect(generatedYaml).toContain('FriggSSMParameterKMSEncryption');
68
+ expect(generatedYaml).toContain(
69
+ 'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
70
+ );
71
+ expect(generatedYaml).not.toContain(
72
+ 'arn:aws:kms:*:${AWS::AccountId}:key/*'
73
+ );
74
+ });
75
+
76
+ it('falls back to the account-wide KMS wildcard when ssm.kmsKeyArn is not set', async () => {
77
+ const mockAppDefinition = {
78
+ name: 'test-app',
79
+ ssm: { enable: true }
80
+ };
81
+
82
+ jest.doMock(
83
+ mockAppDefinitionPath,
84
+ () => ({ Definition: mockAppDefinition }),
85
+ { virtual: true }
86
+ );
87
+
88
+ await generateIamCommand({});
89
+
90
+ expect(fs.writeFile).toHaveBeenCalled();
91
+ const [, generatedYaml] = fs.writeFile.mock.calls[0];
92
+
93
+ expect(generatedYaml).toContain(
94
+ 'arn:aws:kms:*:${AWS::AccountId}:key/*'
95
+ );
96
+ });
97
+ });
@@ -267,12 +267,56 @@ async function runPostDeploymentHealthCheck(stackName, options) {
267
267
  }
268
268
  }
269
269
 
270
+ /**
271
+ * Push SSM-offloaded parameters before deploying so they exist before new
272
+ * code cold-starts (ADR-027). A push failure aborts the deploy: shipping
273
+ * code whose parameters are missing would fail every cold start anyway.
274
+ */
275
+ async function pushOffloadedParametersOrAbort(appDefinition, options) {
276
+ const {
277
+ getOffloadedKeys,
278
+ } = require('../../infrastructure/domains/parameters/offload-utils');
279
+ if (!appDefinition || getOffloadedKeys(appDefinition).length === 0) {
280
+ return;
281
+ }
282
+
283
+ require('dotenv').config();
284
+ const { pushOffloadedParameters } = require('../ssm-command');
285
+
286
+ console.log('🔒 Pushing SSM-offloaded parameters before deploy...');
287
+ try {
288
+ const { pushed } = await pushOffloadedParameters(
289
+ appDefinition,
290
+ options.stage,
291
+ options
292
+ );
293
+ console.log(` ✓ ${pushed.length} parameter(s) up to date`);
294
+ } catch (error) {
295
+ console.error(`\n✗ SSM parameter push failed: ${error.message}`);
296
+ const pushedBeforeFailure = error.pushed || [];
297
+ if (pushedBeforeFailure.length === 0) {
298
+ console.error(' Deployment aborted — no parameters were changed.');
299
+ } else {
300
+ const names = pushedBeforeFailure.map((p) => p.name).join(', ');
301
+ console.error(
302
+ ` Deployment aborted, but ${pushedBeforeFailure.length} parameter(s) were already pushed and are now live in Parameter Store: ${names}`
303
+ );
304
+ console.error(
305
+ ' Those values will take effect on old Lambda code at its next SSM cache TTL refresh. Redeploy soon so the running code matches.'
306
+ );
307
+ }
308
+ process.exit(1);
309
+ }
310
+ }
311
+
270
312
  async function deployCommand(options) {
271
313
  console.log('Deploying the serverless application...');
272
314
 
273
315
  const appDefinition = loadAppDefinition();
274
316
  const environment = validateAndBuildEnvironment(appDefinition, options);
275
317
 
318
+ await pushOffloadedParametersOrAbort(appDefinition, options);
319
+
276
320
  // Execute deployment
277
321
  const exitCode = await executeServerlessDeployment(environment, options);
278
322
 
@@ -120,7 +120,8 @@ async function generateCommand(options = {}) {
120
120
  appName,
121
121
  features,
122
122
  userPrefix: options.user || 'frigg-deployment-user',
123
- stackName: options.stackName || 'frigg-deployment-iam'
123
+ stackName: options.stackName || 'frigg-deployment-iam',
124
+ ssmKmsKeyArn: appDefinition.ssm?.kmsKeyArn
124
125
  });
125
126
  fileExtension = 'yaml';
126
127
  deploymentInstructions = generateCloudFormationInstructions(options);
@@ -64,7 +64,8 @@ async function generateIamCommand(options = {}) {
64
64
  appName: summary.appName,
65
65
  features: summary.features,
66
66
  userPrefix: deploymentUserName,
67
- stackName
67
+ stackName,
68
+ ssmKmsKeyArn: summary.ssmKmsKeyArn
68
69
  });
69
70
 
70
71
  // Determine output file path
@@ -86,6 +86,7 @@ const { dbSetupCommand } = require('./db-setup-command');
86
86
  const { doctorCommand } = require('./doctor-command');
87
87
  const { repairCommand } = require('./repair-command');
88
88
  const { authCommand } = require('./auth-command');
89
+ const { ssmPushCommand } = require('./ssm-command');
89
90
 
90
91
  const program = new Command();
91
92
 
@@ -169,6 +170,20 @@ program
169
170
  .option('-v, --verbose', 'enable verbose output')
170
171
  .action(repairCommand);
171
172
 
173
+ // SSM command group for the parameter offload feature (ADR-027)
174
+ const ssmProgram = program
175
+ .command('ssm')
176
+ .description('Manage SSM Parameter Store offloaded configuration');
177
+
178
+ ssmProgram
179
+ .command('push')
180
+ .description('Push SSM-offloaded environment values to Parameter Store')
181
+ .option('-s, --stage <stage>', 'deployment stage', 'dev')
182
+ .option('-r, --region <region>', 'AWS region (defaults to AWS_REGION env var or us-east-1)')
183
+ .option('--allow-empty', 'skip (instead of fail on) keys with missing or empty values')
184
+ .option('--tier <tier>', 'override tier for all keys: standard (4KB values) or advanced (8KB, billed)')
185
+ .action(ssmPushCommand);
186
+
172
187
  // Auth command group for testing API module authentication
173
188
  const authProgram = program
174
189
  .command('auth')
@@ -205,4 +220,4 @@ authProgram
205
220
 
206
221
  program.parse(process.argv);
207
222
 
208
- module.exports = { initCommand, installCommand, startCommand, buildCommand, deployCommand, generateIamCommand, uiCommand, dbSetupCommand, doctorCommand, repairCommand, authCommand };
223
+ module.exports = { initCommand, installCommand, startCommand, buildCommand, deployCommand, generateIamCommand, uiCommand, dbSetupCommand, doctorCommand, repairCommand, authCommand, ssmPushCommand };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * frigg ssm push
3
+ *
4
+ * Writes SSM-offloaded environment values (see ADR-027) from the CLI
5
+ * process environment into Parameter Store. Runs automatically before
6
+ * `frigg deploy` so parameters exist before new code cold-starts; also
7
+ * available standalone to rotate values without deploying.
8
+ */
9
+
10
+ const path = require('path');
11
+ const dotenv = require('dotenv');
12
+ const {
13
+ getOffloadedKeys,
14
+ resolveParameterPrefix,
15
+ validateOffloadConfig,
16
+ } = require('../../infrastructure/domains/parameters/offload-utils');
17
+
18
+ const TIER_LIMITS = {
19
+ standard: 4096,
20
+ advanced: 8192,
21
+ };
22
+
23
+ const THROTTLE_BACKOFF_MS = [100, 200, 400];
24
+
25
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
26
+
27
+ /**
28
+ * Send a PutParameterCommand, retrying ThrottlingException with short
29
+ * exponential backoff (mirrors sendWithRetry in core/parameters-to-env.js).
30
+ */
31
+ async function putParameterWithRetry(client, PutParameterCommand, input) {
32
+ let attempt = 0;
33
+ for (;;) {
34
+ try {
35
+ return await client.send(new PutParameterCommand(input));
36
+ } catch (error) {
37
+ if (
38
+ error.name === 'ThrottlingException' &&
39
+ attempt < THROTTLE_BACKOFF_MS.length
40
+ ) {
41
+ await sleep(THROTTLE_BACKOFF_MS[attempt]);
42
+ attempt += 1;
43
+ continue;
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Resolve the tier for a single offloaded key. An explicit `--tier` CLI
52
+ * option overrides every key; otherwise each key uses its own
53
+ * ssm.parameters[KEY].tier, defaulting to 'standard'.
54
+ */
55
+ function resolveKeyTier(appDefinition, key, options = {}) {
56
+ const raw =
57
+ options.tier ||
58
+ appDefinition.ssm?.parameters?.[key]?.tier ||
59
+ 'standard';
60
+ const tier = String(raw).toLowerCase();
61
+ if (!TIER_LIMITS[tier]) {
62
+ throw new Error(
63
+ `Unknown parameter tier '${raw}' for ${key}. Use 'standard' or 'advanced'.`
64
+ );
65
+ }
66
+ return tier;
67
+ }
68
+
69
+ /**
70
+ * Build the PutParameter specs for every offloaded key, reading values
71
+ * from process.env. Throws on invalid config, missing values (unless
72
+ * allowEmpty), or values that exceed their own tier limit.
73
+ */
74
+ function collectParameterSpecs(appDefinition, stage, options = {}) {
75
+ const { errors: configErrors } = validateOffloadConfig(appDefinition);
76
+ if (configErrors.length > 0) {
77
+ throw new Error(
78
+ `Invalid SSM offload configuration:\n - ${configErrors.join(
79
+ '\n - '
80
+ )}`
81
+ );
82
+ }
83
+
84
+ const keys = getOffloadedKeys(appDefinition);
85
+ if (keys.length === 0) {
86
+ return { specs: [], skipped: [] };
87
+ }
88
+
89
+ const prefix = resolveParameterPrefix(appDefinition, stage);
90
+ const specs = [];
91
+ const skipped = [];
92
+ const missing = [];
93
+ const oversized = [];
94
+
95
+ for (const key of keys) {
96
+ const value = process.env[key];
97
+
98
+ if (value === undefined || value === '') {
99
+ if (options.allowEmpty) {
100
+ skipped.push(key);
101
+ } else {
102
+ missing.push(key);
103
+ }
104
+ continue;
105
+ }
106
+
107
+ const tier = resolveKeyTier(appDefinition, key, options);
108
+ const maxBytes = TIER_LIMITS[tier];
109
+ const bytes = Buffer.byteLength(value, 'utf8');
110
+ if (bytes > maxBytes) {
111
+ oversized.push(
112
+ `${key} (${bytes} bytes > ${tier} tier limit ${maxBytes})`
113
+ );
114
+ continue;
115
+ }
116
+
117
+ specs.push({
118
+ key,
119
+ name: `${prefix}/${key}`,
120
+ value,
121
+ type: appDefinition.ssm?.parameters?.[key]?.type || 'String',
122
+ tier,
123
+ });
124
+ }
125
+
126
+ if (missing.length > 0) {
127
+ throw new Error(
128
+ `Missing or empty values for offloaded keys: ${missing.join(
129
+ ', '
130
+ )}. Set them in the environment (or .env) before pushing, or pass --allow-empty to skip them.`
131
+ );
132
+ }
133
+
134
+ if (oversized.length > 0) {
135
+ throw new Error(
136
+ `Offloaded values exceed their parameter tier limit: ${oversized.join(
137
+ ', '
138
+ )}. Values over ${TIER_LIMITS.standard} bytes need the advanced tier (up to ${TIER_LIMITS.advanced} bytes, billed by AWS): set ssm.parameters.<KEY>.tier: 'advanced' in the app definition, or pass --tier advanced to 'frigg ssm push'.`
139
+ );
140
+ }
141
+
142
+ return { specs, skipped };
143
+ }
144
+
145
+ /**
146
+ * Resolve the region parameters are pushed to. An explicit `--region`
147
+ * option wins; otherwise fall back to AWS_REGION and finally to the same
148
+ * default the composed stack uses ('us-east-1'), so pushes and cold-start
149
+ * reads always target the same region.
150
+ */
151
+ function resolvePushRegion(options = {}) {
152
+ return options.region || process.env.AWS_REGION || 'us-east-1';
153
+ }
154
+
155
+ /**
156
+ * Push all offloaded parameters for an app definition. Silent no-op when
157
+ * the offload set is empty. Returns { pushed, skipped }.
158
+ */
159
+ async function pushOffloadedParameters(appDefinition, stage, options = {}) {
160
+ const { specs, skipped } = collectParameterSpecs(
161
+ appDefinition,
162
+ stage,
163
+ options
164
+ );
165
+
166
+ for (const key of skipped) {
167
+ console.warn(
168
+ `⚠️ Skipping ${key}: no value in the environment (--allow-empty)`
169
+ );
170
+ }
171
+
172
+ if (specs.length === 0) {
173
+ return { pushed: [], skipped };
174
+ }
175
+
176
+ const { SSMClient, PutParameterCommand } = require('@aws-sdk/client-ssm');
177
+ const client = new SSMClient({
178
+ region: resolvePushRegion(options),
179
+ });
180
+
181
+ const pushed = [];
182
+ for (const spec of specs) {
183
+ const input = {
184
+ Name: spec.name,
185
+ Value: spec.value,
186
+ Type: spec.type,
187
+ Overwrite: true,
188
+ };
189
+ if (spec.tier === 'advanced') {
190
+ input.Tier = 'Advanced';
191
+ }
192
+ if (spec.type === 'SecureString' && appDefinition.ssm?.kmsKeyArn) {
193
+ input.KeyId = appDefinition.ssm.kmsKeyArn;
194
+ }
195
+
196
+ try {
197
+ const result = await putParameterWithRetry(
198
+ client,
199
+ PutParameterCommand,
200
+ input
201
+ );
202
+ pushed.push({ name: spec.name, version: result.Version });
203
+ console.log(
204
+ ` ✅ ${spec.name} (${spec.type}, v${result.Version})`
205
+ );
206
+ } catch (error) {
207
+ const failure = new Error(
208
+ `Failed to push parameter ${spec.name} (${spec.key}): ${error.message}`
209
+ );
210
+ failure.pushed = pushed;
211
+ throw failure;
212
+ }
213
+ }
214
+
215
+ return { pushed, skipped };
216
+ }
217
+
218
+ function loadAppDefinition() {
219
+ const appDefPath = path.join(process.cwd(), 'index.js');
220
+ const { Definition } = require(appDefPath);
221
+ return Definition;
222
+ }
223
+
224
+ /**
225
+ * `frigg ssm push` command action.
226
+ */
227
+ async function ssmPushCommand(options) {
228
+ dotenv.config();
229
+
230
+ let appDefinition;
231
+ try {
232
+ appDefinition = loadAppDefinition();
233
+ } catch (error) {
234
+ console.error(
235
+ `✗ Could not load the app definition from ${process.cwd()}/index.js: ${error.message}`
236
+ );
237
+ process.exit(1);
238
+ }
239
+
240
+ const keys = getOffloadedKeys(appDefinition);
241
+ if (keys.length === 0) {
242
+ console.log(
243
+ 'No environment variables are marked for SSM offload — nothing to push.'
244
+ );
245
+ return;
246
+ }
247
+
248
+ console.log(
249
+ `🔒 Pushing ${keys.length} offloaded parameter(s) for stage '${options.stage}'...`
250
+ );
251
+
252
+ try {
253
+ const { pushed } = await pushOffloadedParameters(
254
+ appDefinition,
255
+ options.stage,
256
+ options
257
+ );
258
+ console.log(`✓ Pushed ${pushed.length} parameter(s)`);
259
+ } catch (error) {
260
+ console.error(`✗ ${error.message}`);
261
+ process.exit(1);
262
+ }
263
+ }
264
+
265
+ module.exports = {
266
+ ssmPushCommand,
267
+ pushOffloadedParameters,
268
+ collectParameterSpecs,
269
+ resolvePushRegion,
270
+ };