@friggframework/devtools 2.0.0--canary.625.3f500f2.0 → 2.0.0--canary.625.6bef7de.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.
@@ -163,21 +163,59 @@ async function pushOffloadedParameters(appDefinition, stage, options = {}) {
163
163
  options
164
164
  );
165
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) {
166
+ if (specs.length === 0 && skipped.length === 0) {
173
167
  return { pushed: [], skipped };
174
168
  }
175
169
 
176
- const { SSMClient, PutParameterCommand } = require('@aws-sdk/client-ssm');
170
+ const {
171
+ SSMClient,
172
+ PutParameterCommand,
173
+ GetParametersCommand,
174
+ } = require('@aws-sdk/client-ssm');
177
175
  const client = new SSMClient({
178
176
  region: resolvePushRegion(options),
179
177
  });
180
178
 
179
+ // --allow-empty only rotates keys that already exist. A skipped key still
180
+ // ships in FRIGG_SSM_OFFLOADED_KEYS, so if its parameter does not exist the
181
+ // runtime fails fast at cold start on every function — a green deploy that
182
+ // bricks the fleet. Verify existence and abort instead.
183
+ if (skipped.length > 0) {
184
+ const prefix = resolveParameterPrefix(appDefinition, stage);
185
+ const names = skipped.map((key) => `${prefix}/${key}`);
186
+ const existing = new Set();
187
+ for (let i = 0; i < names.length; i += 10) {
188
+ const { Parameters = [] } = await client.send(
189
+ new GetParametersCommand({ Names: names.slice(i, i + 10) })
190
+ );
191
+ for (const param of Parameters) {
192
+ existing.add(param.Name);
193
+ }
194
+ }
195
+ const orphaned = skipped.filter(
196
+ (key) => !existing.has(`${prefix}/${key}`)
197
+ );
198
+ if (orphaned.length > 0) {
199
+ throw new Error(
200
+ `--allow-empty skipped ${orphaned.join(
201
+ ', '
202
+ )}, but no such parameter exists in Parameter Store. --allow-empty ` +
203
+ `only rotates keys that already have a value; a skipped key with no ` +
204
+ `parameter stays in FRIGG_SSM_OFFLOADED_KEYS and fails every function ` +
205
+ `at cold start. Set a value and push without --allow-empty.`
206
+ );
207
+ }
208
+ for (const key of skipped) {
209
+ console.warn(
210
+ `⚠️ Skipping ${key}: no value in the environment; keeping the existing Parameter Store value (--allow-empty)`
211
+ );
212
+ }
213
+ }
214
+
215
+ if (specs.length === 0) {
216
+ return { pushed: [], skipped };
217
+ }
218
+
181
219
  const pushed = [];
182
220
  for (const spec of specs) {
183
221
  const input = {
@@ -1,5 +1,9 @@
1
1
  const { mockClient } = require('aws-sdk-client-mock');
2
- const { SSMClient, PutParameterCommand } = require('@aws-sdk/client-ssm');
2
+ const {
3
+ SSMClient,
4
+ PutParameterCommand,
5
+ GetParametersCommand,
6
+ } = require('@aws-sdk/client-ssm');
3
7
  const { pushOffloadedParameters, resolvePushRegion } = require('./index');
4
8
 
5
9
  describe('ssm-command pushOffloadedParameters', () => {
@@ -95,8 +99,11 @@ describe('ssm-command pushOffloadedParameters', () => {
95
99
  expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
96
100
  });
97
101
 
98
- it('skips missing values with allowEmpty instead of throwing', async () => {
102
+ it('skips missing values with allowEmpty when the parameter already exists', async () => {
99
103
  delete process.env.MY_SECRET;
104
+ ssmMock.on(GetParametersCommand).resolves({
105
+ Parameters: [{ Name: '/frigg/my-app/dev/MY_SECRET' }],
106
+ });
100
107
 
101
108
  const result = await pushOffloadedParameters(appDefinition, 'dev', {
102
109
  allowEmpty: true,
@@ -105,6 +112,16 @@ describe('ssm-command pushOffloadedParameters', () => {
105
112
  expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(1);
106
113
  });
107
114
 
115
+ it('aborts when allowEmpty skips a key that does not exist in Parameter Store', async () => {
116
+ delete process.env.MY_SECRET;
117
+ ssmMock.on(GetParametersCommand).resolves({ Parameters: [] });
118
+
119
+ await expect(
120
+ pushOffloadedParameters(appDefinition, 'dev', { allowEmpty: true })
121
+ ).rejects.toThrow(/MY_SECRET.*no such parameter|--allow-empty/s);
122
+ expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
123
+ });
124
+
108
125
  it('rejects values over the standard tier limit and suggests advanced', async () => {
109
126
  process.env.MY_CONFIG = 'x'.repeat(4097);
110
127
 
@@ -123,6 +123,10 @@ class VpcDiscovery {
123
123
  const sqsEndpoint = rawResources.vpcEndpoints.find(
124
124
  ep => ep.ServiceName && ep.ServiceName.includes('.sqs')
125
125
  );
126
+ // endsWith avoids matching sibling services like `.ssmmessages`
127
+ const ssmEndpoint = rawResources.vpcEndpoints.find(
128
+ ep => ep.ServiceName && ep.ServiceName.endsWith('.ssm')
129
+ );
126
130
 
127
131
  if (s3Endpoint) {
128
132
  result.s3VpcEndpointId = s3Endpoint.VpcEndpointId;
@@ -139,6 +143,9 @@ class VpcDiscovery {
139
143
  if (sqsEndpoint) {
140
144
  result.sqsVpcEndpointId = sqsEndpoint.VpcEndpointId;
141
145
  }
146
+ if (ssmEndpoint) {
147
+ result.ssmVpcEndpointId = ssmEndpoint.VpcEndpointId;
148
+ }
142
149
  }
143
150
 
144
151
  console.log(` ✓ Found VPC: ${result.defaultVpcId}`);
@@ -151,8 +158,8 @@ class VpcDiscovery {
151
158
  if (result.existingNatGatewayId) {
152
159
  console.log(` ✓ Found NAT Gateway: ${result.existingNatGatewayId}`);
153
160
  }
154
- if (result.s3VpcEndpointId || result.dynamodbVpcEndpointId || result.kmsVpcEndpointId || result.secretsManagerVpcEndpointId || result.sqsVpcEndpointId) {
155
- console.log(` ✓ Found VPC Endpoints: S3=${result.s3VpcEndpointId ? 'Yes' : 'No'}, DynamoDB=${result.dynamodbVpcEndpointId ? 'Yes' : 'No'}, KMS=${result.kmsVpcEndpointId ? 'Yes' : 'No'}, SecretsManager=${result.secretsManagerVpcEndpointId ? 'Yes' : 'No'}, SQS=${result.sqsVpcEndpointId ? 'Yes' : 'No'}`);
161
+ if (result.s3VpcEndpointId || result.dynamodbVpcEndpointId || result.kmsVpcEndpointId || result.secretsManagerVpcEndpointId || result.sqsVpcEndpointId || result.ssmVpcEndpointId) {
162
+ console.log(` ✓ Found VPC Endpoints: S3=${result.s3VpcEndpointId ? 'Yes' : 'No'}, DynamoDB=${result.dynamodbVpcEndpointId ? 'Yes' : 'No'}, KMS=${result.kmsVpcEndpointId ? 'Yes' : 'No'}, SecretsManager=${result.secretsManagerVpcEndpointId ? 'Yes' : 'No'}, SQS=${result.sqsVpcEndpointId ? 'Yes' : 'No'}, SSM=${result.ssmVpcEndpointId ? 'Yes' : 'No'}`);
156
163
  }
157
164
 
158
165
  return result;
@@ -253,7 +253,7 @@ describe('VpcDiscovery', () => {
253
253
  expect(mockProvider.discoverVpc).toHaveBeenCalledWith(config);
254
254
  });
255
255
 
256
- it('should discover all VPC endpoints (S3, DynamoDB, KMS, Secrets Manager)', async () => {
256
+ it('should discover all VPC endpoints (S3, DynamoDB, KMS, Secrets Manager, SQS, SSM)', async () => {
257
257
  mockProvider.discoverVpc.mockResolvedValue({
258
258
  vpcId: 'vpc-123',
259
259
  vpcCidr: '10.0.0.0/16',
@@ -283,6 +283,22 @@ describe('VpcDiscovery', () => {
283
283
  ServiceName: 'com.amazonaws.us-east-1.secretsmanager',
284
284
  State: 'available',
285
285
  },
286
+ {
287
+ VpcEndpointId: 'vpce-sqs-def',
288
+ ServiceName: 'com.amazonaws.us-east-1.sqs',
289
+ State: 'available',
290
+ },
291
+ // ssmmessages must NOT be mistaken for the ssm endpoint
292
+ {
293
+ VpcEndpointId: 'vpce-ssmmessages-000',
294
+ ServiceName: 'com.amazonaws.us-east-1.ssmmessages',
295
+ State: 'available',
296
+ },
297
+ {
298
+ VpcEndpointId: 'vpce-ssm-ghi',
299
+ ServiceName: 'com.amazonaws.us-east-1.ssm',
300
+ State: 'available',
301
+ },
286
302
  ],
287
303
  });
288
304
 
@@ -292,6 +308,8 @@ describe('VpcDiscovery', () => {
292
308
  expect(result.dynamodbVpcEndpointId).toBe('vpce-ddb-456');
293
309
  expect(result.kmsVpcEndpointId).toBe('vpce-kms-789');
294
310
  expect(result.secretsManagerVpcEndpointId).toBe('vpce-sm-abc');
311
+ expect(result.sqsVpcEndpointId).toBe('vpce-sqs-def');
312
+ expect(result.ssmVpcEndpointId).toBe('vpce-ssm-ghi');
295
313
  });
296
314
 
297
315
  it('should handle partial VPC endpoint discovery', async () => {
@@ -94,7 +94,7 @@ function getOffloadedKeys(appDefinition = {}) {
94
94
  keys.add(key);
95
95
  }
96
96
 
97
- return [...keys].sort();
97
+ return [...keys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
98
98
  }
99
99
 
100
100
  /**
@@ -703,7 +703,10 @@ function generateIAMCloudFormation(options = {}) {
703
703
  },
704
704
  ],
705
705
  Condition: {
706
- StringEquals: {
706
+ // StringLike: kms:ViaService is regional
707
+ // (ssm.us-east-1.amazonaws.com), so the wildcard
708
+ // must be matched, not compared literally.
709
+ StringLike: {
707
710
  'kms:ViaService': [
708
711
  'ssm.*.amazonaws.com',
709
712
  ],
@@ -166,6 +166,16 @@ describe('IAM Generator', () => {
166
166
  expect(yaml).toContain('ssm.*.amazonaws.com');
167
167
  // No customer-managed key configured: falls back to the account key wildcard
168
168
  expect(yaml).toContain('arn:aws:kms:*:${AWS::AccountId}:key/*');
169
+
170
+ // The regional ViaService wildcard must be matched with StringLike;
171
+ // StringEquals would compare literally and never match
172
+ // ssm.<region>.amazonaws.com, denying the SecureString KMS call.
173
+ const ssmKmsBlock = yaml.slice(
174
+ yaml.indexOf('FriggSSMParameterKMSEncryption'),
175
+ yaml.indexOf('FriggSSMParameterKMSEncryption') + 600
176
+ );
177
+ expect(ssmKmsBlock).toContain('StringLike');
178
+ expect(ssmKmsBlock).not.toContain('StringEquals');
169
179
  });
170
180
 
171
181
  it('should scope the SSM KMS grant to ssm.kmsKeyArn when provided', () => {
@@ -375,7 +375,9 @@ Resources:
375
375
  Resource:
376
376
  - !Sub 'arn:aws:kms:*:${AWS::AccountId}:key/*'
377
377
  Condition:
378
- StringEquals:
378
+ # StringLike: kms:ViaService is regional (ssm.us-east-1.amazonaws.com),
379
+ # so the wildcard must be matched, not compared literally.
380
+ StringLike:
379
381
  'kms:ViaService':
380
382
  - 'ssm.*.amazonaws.com'
381
383
 
@@ -264,7 +264,7 @@
264
264
  "arn:aws:kms:*:*:key/*"
265
265
  ],
266
266
  "Condition": {
267
- "StringEquals": {
267
+ "StringLike": {
268
268
  "kms:ViaService": [
269
269
  "lambda.*.amazonaws.com",
270
270
  "s3.*.amazonaws.com",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.625.3f500f2.0",
4
+ "version": "2.0.0--canary.625.6bef7de.0",
5
5
  "bin": {
6
6
  "frigg": "./frigg-cli/index.js"
7
7
  },
@@ -26,9 +26,9 @@
26
26
  "@babel/eslint-parser": "^7.18.9",
27
27
  "@babel/parser": "^7.25.3",
28
28
  "@babel/traverse": "^7.25.3",
29
- "@friggframework/core": "2.0.0--canary.625.3f500f2.0",
30
- "@friggframework/schemas": "2.0.0--canary.625.3f500f2.0",
31
- "@friggframework/test": "2.0.0--canary.625.3f500f2.0",
29
+ "@friggframework/core": "2.0.0--canary.625.6bef7de.0",
30
+ "@friggframework/schemas": "2.0.0--canary.625.6bef7de.0",
31
+ "@friggframework/test": "2.0.0--canary.625.6bef7de.0",
32
32
  "@hapi/boom": "^10.0.1",
33
33
  "@inquirer/prompts": "^5.3.8",
34
34
  "axios": "^1.18.0",
@@ -56,8 +56,8 @@
56
56
  "validate-npm-package-name": "^5.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@friggframework/eslint-config": "2.0.0--canary.625.3f500f2.0",
60
- "@friggframework/prettier-config": "2.0.0--canary.625.3f500f2.0",
59
+ "@friggframework/eslint-config": "2.0.0--canary.625.6bef7de.0",
60
+ "@friggframework/prettier-config": "2.0.0--canary.625.6bef7de.0",
61
61
  "aws-sdk-client-mock": "^4.1.0",
62
62
  "aws-sdk-client-mock-jest": "^4.1.0",
63
63
  "jest": "^30.1.3",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "3f500f202c8dcf2210c07835c76abbab30e0d0ee"
92
+ "gitHead": "6bef7de84e4deea31c8dea40c177e2f7343eea4c"
93
93
  }