@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.
- package/frigg-cli/ssm-command/index.js +46 -8
- package/frigg-cli/ssm-command/index.test.js +19 -2
- package/infrastructure/domains/networking/vpc-discovery.js +9 -2
- package/infrastructure/domains/networking/vpc-discovery.test.js +19 -1
- package/infrastructure/domains/parameters/offload-utils.js +1 -1
- package/infrastructure/domains/security/iam-generator.js +4 -1
- package/infrastructure/domains/security/iam-generator.test.js +10 -0
- package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +3 -1
- package/infrastructure/domains/security/templates/iam-policy-full.json +1 -1
- package/package.json +7 -7
|
@@ -163,21 +163,59 @@ async function pushOffloadedParameters(appDefinition, stage, options = {}) {
|
|
|
163
163
|
options
|
|
164
164
|
);
|
|
165
165
|
|
|
166
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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 () => {
|
|
@@ -703,7 +703,10 @@ function generateIAMCloudFormation(options = {}) {
|
|
|
703
703
|
},
|
|
704
704
|
],
|
|
705
705
|
Condition: {
|
|
706
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
30
|
-
"@friggframework/schemas": "2.0.0--canary.625.
|
|
31
|
-
"@friggframework/test": "2.0.0--canary.625.
|
|
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.
|
|
60
|
-
"@friggframework/prettier-config": "2.0.0--canary.625.
|
|
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": "
|
|
92
|
+
"gitHead": "6bef7de84e4deea31c8dea40c177e2f7343eea4c"
|
|
93
93
|
}
|