@friggframework/devtools 2.0.0-next.101 → 2.0.0-next.102
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/__tests__/unit/commands/generate-iam.test.js +97 -0
- package/frigg-cli/deploy-command/index.js +44 -0
- package/frigg-cli/generate-command/index.js +2 -1
- package/frigg-cli/generate-iam-command.js +2 -1
- package/frigg-cli/index.js +16 -1
- package/frigg-cli/ssm-command/index.js +308 -0
- package/frigg-cli/ssm-command/index.test.js +318 -0
- package/infrastructure/__tests__/helpers/test-utils.js +3 -5
- package/infrastructure/__tests__/scoped-environment.test.js +126 -0
- package/infrastructure/__tests__/ssm-preload-node-options.test.js +79 -0
- package/infrastructure/domains/admin-scripts/admin-script-builder.js +17 -3
- package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +45 -0
- package/infrastructure/domains/database/migration-builder.js +46 -9
- package/infrastructure/domains/database/migration-builder.test.js +101 -0
- package/infrastructure/domains/integration/integration-builder.js +65 -9
- package/infrastructure/domains/integration/integration-builder.test.js +95 -0
- package/infrastructure/domains/networking/vpc-builder.js +57 -7
- package/infrastructure/domains/networking/vpc-builder.test.js +41 -0
- package/infrastructure/domains/networking/vpc-discovery.js +9 -2
- package/infrastructure/domains/networking/vpc-discovery.test.js +19 -1
- package/infrastructure/domains/networking/vpc-resolver.js +12 -3
- package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
- package/infrastructure/domains/parameters/offload-utils.js +190 -0
- package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
- package/infrastructure/domains/parameters/ssm-builder.js +67 -14
- package/infrastructure/domains/parameters/ssm-builder.test.js +158 -1
- package/infrastructure/domains/scheduler/scheduler-builder.js +44 -8
- package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
- package/infrastructure/domains/security/iam-generator.js +36 -1
- package/infrastructure/domains/security/iam-generator.test.js +83 -0
- package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +17 -0
- package/infrastructure/domains/security/templates/iam-policy-full.json +8 -3
- package/infrastructure/domains/shared/builder-orchestrator.js +14 -0
- package/infrastructure/domains/shared/builder-orchestrator.test.js +45 -0
- package/infrastructure/domains/shared/environment-builder.js +43 -7
- package/infrastructure/domains/shared/environment-builder.test.js +97 -1
- package/infrastructure/domains/shared/function-environments.js +97 -0
- package/infrastructure/domains/shared/function-environments.test.js +146 -0
- package/infrastructure/infrastructure-composer.js +48 -1
- package/infrastructure/infrastructure-composer.test.js +161 -15
- package/infrastructure/integration.test.js +3 -5
- package/package.json +8 -7
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
const { mockClient } = require('aws-sdk-client-mock');
|
|
2
|
+
const {
|
|
3
|
+
SSMClient,
|
|
4
|
+
PutParameterCommand,
|
|
5
|
+
GetParametersCommand,
|
|
6
|
+
} = require('@aws-sdk/client-ssm');
|
|
7
|
+
const { pushOffloadedParameters, resolvePushRegion } = require('./index');
|
|
8
|
+
|
|
9
|
+
describe('ssm-command pushOffloadedParameters', () => {
|
|
10
|
+
let ssmMock;
|
|
11
|
+
const originalEnv = process.env;
|
|
12
|
+
|
|
13
|
+
const appDefinition = {
|
|
14
|
+
name: 'my-app',
|
|
15
|
+
ssm: {
|
|
16
|
+
enable: true,
|
|
17
|
+
parameters: {
|
|
18
|
+
MY_SECRET: { type: 'SecureString' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
environment: {
|
|
22
|
+
MY_CONFIG: 'ssm',
|
|
23
|
+
PLAIN: true,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
ssmMock = mockClient(SSMClient);
|
|
29
|
+
ssmMock.on(PutParameterCommand).resolves({ Version: 1 });
|
|
30
|
+
process.env = {
|
|
31
|
+
...originalEnv,
|
|
32
|
+
MY_SECRET: 'shh',
|
|
33
|
+
MY_CONFIG: 'value-1',
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
ssmMock.restore();
|
|
39
|
+
process.env = originalEnv;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('pushes each offloaded key with resolved name, type, and overwrite', async () => {
|
|
43
|
+
const result = await pushOffloadedParameters(appDefinition, 'prod');
|
|
44
|
+
|
|
45
|
+
const calls = ssmMock.commandCalls(PutParameterCommand);
|
|
46
|
+
expect(calls).toHaveLength(2);
|
|
47
|
+
|
|
48
|
+
const byName = Object.fromEntries(
|
|
49
|
+
calls.map((c) => [c.args[0].input.Name, c.args[0].input])
|
|
50
|
+
);
|
|
51
|
+
expect(byName['/frigg/my-app/prod/MY_SECRET']).toMatchObject({
|
|
52
|
+
Value: 'shh',
|
|
53
|
+
Type: 'SecureString',
|
|
54
|
+
Overwrite: true,
|
|
55
|
+
});
|
|
56
|
+
expect(byName['/frigg/my-app/prod/MY_CONFIG']).toMatchObject({
|
|
57
|
+
Value: 'value-1',
|
|
58
|
+
Type: 'String',
|
|
59
|
+
Overwrite: true,
|
|
60
|
+
});
|
|
61
|
+
expect(result.pushed).toHaveLength(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('passes KeyId for SecureString when ssm.kmsKeyArn is set', async () => {
|
|
65
|
+
const withKey = {
|
|
66
|
+
...appDefinition,
|
|
67
|
+
ssm: {
|
|
68
|
+
...appDefinition.ssm,
|
|
69
|
+
kmsKeyArn: 'arn:aws:kms:us-east-1:1:key/k',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
await pushOffloadedParameters(withKey, 'dev');
|
|
73
|
+
|
|
74
|
+
const inputs = ssmMock
|
|
75
|
+
.commandCalls(PutParameterCommand)
|
|
76
|
+
.map((c) => c.args[0].input);
|
|
77
|
+
const secure = inputs.find((i) => i.Type === 'SecureString');
|
|
78
|
+
const plain = inputs.find((i) => i.Type === 'String');
|
|
79
|
+
expect(secure.KeyId).toBe('arn:aws:kms:us-east-1:1:key/k');
|
|
80
|
+
expect(plain.KeyId).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('is a silent no-op when nothing is offloaded', async () => {
|
|
84
|
+
const result = await pushOffloadedParameters(
|
|
85
|
+
{ name: 'x', environment: { PLAIN: true } },
|
|
86
|
+
'dev'
|
|
87
|
+
);
|
|
88
|
+
expect(result.pushed).toHaveLength(0);
|
|
89
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('throws listing keys with missing or empty values', async () => {
|
|
93
|
+
delete process.env.MY_SECRET;
|
|
94
|
+
process.env.MY_CONFIG = '';
|
|
95
|
+
|
|
96
|
+
await expect(
|
|
97
|
+
pushOffloadedParameters(appDefinition, 'dev')
|
|
98
|
+
).rejects.toThrow(/MY_SECRET.*MY_CONFIG|MY_CONFIG.*MY_SECRET/s);
|
|
99
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('skips missing values with allowEmpty when the parameter already exists', async () => {
|
|
103
|
+
delete process.env.MY_SECRET;
|
|
104
|
+
ssmMock.on(GetParametersCommand).resolves({
|
|
105
|
+
Parameters: [{ Name: '/frigg/my-app/dev/MY_SECRET' }],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await pushOffloadedParameters(appDefinition, 'dev', {
|
|
109
|
+
allowEmpty: true,
|
|
110
|
+
});
|
|
111
|
+
expect(result.skipped).toEqual(['MY_SECRET']);
|
|
112
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(1);
|
|
113
|
+
});
|
|
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
|
+
|
|
125
|
+
it('rejects values over the standard tier limit and suggests advanced', async () => {
|
|
126
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
127
|
+
|
|
128
|
+
await expect(
|
|
129
|
+
pushOffloadedParameters(appDefinition, 'dev')
|
|
130
|
+
).rejects.toThrow(/advanced/i);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('accepts large values with the advanced tier', async () => {
|
|
134
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
135
|
+
|
|
136
|
+
await pushOffloadedParameters(appDefinition, 'dev', {
|
|
137
|
+
tier: 'advanced',
|
|
138
|
+
});
|
|
139
|
+
const inputs = ssmMock
|
|
140
|
+
.commandCalls(PutParameterCommand)
|
|
141
|
+
.map((c) => c.args[0].input);
|
|
142
|
+
expect(
|
|
143
|
+
inputs.find((i) => i.Name.endsWith('/MY_CONFIG')).Tier
|
|
144
|
+
).toBe('Advanced');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects values over the advanced tier limit', async () => {
|
|
148
|
+
process.env.MY_CONFIG = 'x'.repeat(8193);
|
|
149
|
+
|
|
150
|
+
await expect(
|
|
151
|
+
pushOffloadedParameters(appDefinition, 'dev', {
|
|
152
|
+
tier: 'advanced',
|
|
153
|
+
})
|
|
154
|
+
).rejects.toThrow(/8192|8 ?KB/i);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('per-key tier resolution', () => {
|
|
158
|
+
const withKeyTier = {
|
|
159
|
+
...appDefinition,
|
|
160
|
+
ssm: {
|
|
161
|
+
...appDefinition.ssm,
|
|
162
|
+
parameters: {
|
|
163
|
+
MY_SECRET: { type: 'SecureString' },
|
|
164
|
+
MY_CONFIG: { tier: 'advanced' },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
it('accepts a large value when the key is configured advanced in the app definition', async () => {
|
|
170
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
171
|
+
|
|
172
|
+
await pushOffloadedParameters(withKeyTier, 'dev');
|
|
173
|
+
|
|
174
|
+
const config = ssmMock
|
|
175
|
+
.commandCalls(PutParameterCommand)
|
|
176
|
+
.map((c) => c.args[0].input)
|
|
177
|
+
.find((i) => i.Name.endsWith('/MY_CONFIG'));
|
|
178
|
+
expect(config.Tier).toBe('Advanced');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('validates each key against its own configured tier', async () => {
|
|
182
|
+
// MY_CONFIG (advanced) tolerates 5000 bytes; MY_SECRET (standard) does not
|
|
183
|
+
process.env.MY_CONFIG = 'x'.repeat(5000);
|
|
184
|
+
process.env.MY_SECRET = 'y'.repeat(5000);
|
|
185
|
+
|
|
186
|
+
await expect(
|
|
187
|
+
pushOffloadedParameters(withKeyTier, 'dev')
|
|
188
|
+
).rejects.toThrow(/MY_SECRET/);
|
|
189
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('does not tag standard-tier keys with an Advanced tier', async () => {
|
|
193
|
+
await pushOffloadedParameters(withKeyTier, 'dev');
|
|
194
|
+
|
|
195
|
+
const secret = ssmMock
|
|
196
|
+
.commandCalls(PutParameterCommand)
|
|
197
|
+
.map((c) => c.args[0].input)
|
|
198
|
+
.find((i) => i.Name.endsWith('/MY_SECRET'));
|
|
199
|
+
expect(secret.Tier).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('lets the --tier CLI option override per-key config for all keys', async () => {
|
|
203
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
pushOffloadedParameters(withKeyTier, 'dev', {
|
|
207
|
+
tier: 'standard',
|
|
208
|
+
})
|
|
209
|
+
).rejects.toThrow(/advanced/i);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('throws on invalid offload config (markers without ssm.enable)', async () => {
|
|
214
|
+
await expect(
|
|
215
|
+
pushOffloadedParameters(
|
|
216
|
+
{ name: 'x', environment: { FOO: 'ssm' } },
|
|
217
|
+
'dev'
|
|
218
|
+
)
|
|
219
|
+
).rejects.toThrow(/ssm\.enable/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('wraps AWS errors with the parameter name', async () => {
|
|
223
|
+
ssmMock
|
|
224
|
+
.on(PutParameterCommand, {
|
|
225
|
+
Name: '/frigg/my-app/dev/MY_SECRET',
|
|
226
|
+
})
|
|
227
|
+
.rejects(new Error('boom'));
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
pushOffloadedParameters(appDefinition, 'dev')
|
|
231
|
+
).rejects.toThrow(/MY_SECRET/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('retries a throttled PutParameter and succeeds', async () => {
|
|
235
|
+
const throttle = Object.assign(new Error('Rate exceeded'), {
|
|
236
|
+
name: 'ThrottlingException',
|
|
237
|
+
});
|
|
238
|
+
ssmMock
|
|
239
|
+
.on(PutParameterCommand, {
|
|
240
|
+
Name: '/frigg/my-app/dev/MY_SECRET',
|
|
241
|
+
})
|
|
242
|
+
.rejectsOnce(throttle)
|
|
243
|
+
.resolves({ Version: 2 });
|
|
244
|
+
|
|
245
|
+
const result = await pushOffloadedParameters(appDefinition, 'dev');
|
|
246
|
+
|
|
247
|
+
expect(result.pushed).toHaveLength(2);
|
|
248
|
+
const secretCalls = ssmMock
|
|
249
|
+
.commandCalls(PutParameterCommand)
|
|
250
|
+
.filter(
|
|
251
|
+
(c) => c.args[0].input.Name === '/frigg/my-app/dev/MY_SECRET'
|
|
252
|
+
);
|
|
253
|
+
expect(secretCalls).toHaveLength(2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('exposes keys pushed so far on a mid-batch failure', async () => {
|
|
257
|
+
// MY_CONFIG (sorted before MY_SECRET) succeeds; MY_SECRET fails.
|
|
258
|
+
ssmMock
|
|
259
|
+
.on(PutParameterCommand, {
|
|
260
|
+
Name: '/frigg/my-app/dev/MY_CONFIG',
|
|
261
|
+
})
|
|
262
|
+
.resolves({ Version: 1 });
|
|
263
|
+
ssmMock
|
|
264
|
+
.on(PutParameterCommand, {
|
|
265
|
+
Name: '/frigg/my-app/dev/MY_SECRET',
|
|
266
|
+
})
|
|
267
|
+
.rejects(new Error('boom'));
|
|
268
|
+
|
|
269
|
+
let caught;
|
|
270
|
+
try {
|
|
271
|
+
await pushOffloadedParameters(appDefinition, 'dev');
|
|
272
|
+
} catch (error) {
|
|
273
|
+
caught = error;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
expect(caught).toBeDefined();
|
|
277
|
+
expect(caught.pushed).toEqual([
|
|
278
|
+
{ name: '/frigg/my-app/dev/MY_CONFIG', version: 1 },
|
|
279
|
+
]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('honors a custom parameterPrefix', async () => {
|
|
283
|
+
const custom = {
|
|
284
|
+
...appDefinition,
|
|
285
|
+
ssm: { ...appDefinition.ssm, parameterPrefix: '/team/x' },
|
|
286
|
+
};
|
|
287
|
+
await pushOffloadedParameters(custom, 'dev');
|
|
288
|
+
|
|
289
|
+
const names = ssmMock
|
|
290
|
+
.commandCalls(PutParameterCommand)
|
|
291
|
+
.map((c) => c.args[0].input.Name);
|
|
292
|
+
expect(names).toEqual(
|
|
293
|
+
expect.arrayContaining([
|
|
294
|
+
'/team/x/MY_SECRET',
|
|
295
|
+
'/team/x/MY_CONFIG',
|
|
296
|
+
])
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('resolvePushRegion', () => {
|
|
301
|
+
it('prefers an explicit --region option over the environment', () => {
|
|
302
|
+
process.env.AWS_REGION = 'us-west-2';
|
|
303
|
+
expect(resolvePushRegion({ region: 'eu-central-1' })).toBe(
|
|
304
|
+
'eu-central-1'
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('falls back to AWS_REGION when no option is given', () => {
|
|
309
|
+
process.env.AWS_REGION = 'us-west-2';
|
|
310
|
+
expect(resolvePushRegion({})).toBe('us-west-2');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('defaults to us-east-1 when neither option nor AWS_REGION is set', () => {
|
|
314
|
+
delete process.env.AWS_REGION;
|
|
315
|
+
expect(resolvePushRegion({})).toBe('us-east-1');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -166,12 +166,10 @@ function verifyKmsConfiguration(config) {
|
|
|
166
166
|
* @param {Object} config - Serverless configuration
|
|
167
167
|
*/
|
|
168
168
|
function verifySsmConfiguration(config) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
]);
|
|
172
|
-
expect(config.provider.environment.SSM_PARAMETER_PREFIX).toBe('/${self:service}/${self:provider.stage}');
|
|
169
|
+
// The framework does NOT use the AWS Parameters-and-Secrets extension layer
|
|
170
|
+
expect(config.provider.layers).toBeUndefined();
|
|
173
171
|
|
|
174
|
-
// Verify SSM IAM permissions
|
|
172
|
+
// Verify SSM IAM permissions (broad read grant added whenever SSM is enabled)
|
|
175
173
|
const ssmPermission = config.provider.iamRoleStatements.find(
|
|
176
174
|
statement => statement.Action.includes('ssm:GetParameter')
|
|
177
175
|
);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end composer tests for per-function environment scoping
|
|
3
|
+
* (lambda.scopedEnvironment, ADR-027).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { composeServerlessDefinition } = require('../infrastructure-composer');
|
|
7
|
+
|
|
8
|
+
jest.mock('../domains/shared/resource-discovery', () => {
|
|
9
|
+
const originalModule = jest.requireActual(
|
|
10
|
+
'../domains/shared/resource-discovery'
|
|
11
|
+
);
|
|
12
|
+
return {
|
|
13
|
+
...originalModule,
|
|
14
|
+
gatherDiscoveredResources: jest.fn().mockResolvedValue({
|
|
15
|
+
defaultVpcId: 'vpc-123456',
|
|
16
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
17
|
+
privateSubnetId1: 'subnet-123456',
|
|
18
|
+
privateSubnetId2: 'subnet-789012',
|
|
19
|
+
defaultKmsKeyId:
|
|
20
|
+
'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012',
|
|
21
|
+
auroraClusterEndpoint:
|
|
22
|
+
'test-cluster.cluster-abc123.us-east-1.rds.amazonaws.com',
|
|
23
|
+
auroraPort: 5432,
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const buildApp = (overrides = {}) => ({
|
|
29
|
+
name: 'scoped-test-app',
|
|
30
|
+
provider: 'aws',
|
|
31
|
+
usePrismaLambdaLayer: false,
|
|
32
|
+
encryption: { fieldLevelEncryptionMethod: 'aes' },
|
|
33
|
+
vpc: { enable: false },
|
|
34
|
+
database: { postgres: { enable: true } },
|
|
35
|
+
adminScripts: [{ Definition: { name: 'fixThings' } }],
|
|
36
|
+
integrations: [
|
|
37
|
+
{ Definition: { name: 'hubspot', webhooks: true } },
|
|
38
|
+
{ Definition: { name: 'slack' } },
|
|
39
|
+
],
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const SCOPED_VARS = [
|
|
44
|
+
'HUBSPOT_QUEUE_URL',
|
|
45
|
+
'SLACK_QUEUE_URL',
|
|
46
|
+
'SCHEDULER_ROLE_ARN',
|
|
47
|
+
'SCHEDULE_GROUP_NAME',
|
|
48
|
+
'S3_BUCKET_NAME',
|
|
49
|
+
'MIGRATION_STATUS_BUCKET',
|
|
50
|
+
'DB_MIGRATION_QUEUE_URL',
|
|
51
|
+
'ADMIN_SCRIPT_QUEUE_URL',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
describe('lambda.scopedEnvironment composer e2e', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
process.argv = ['node', 'test'];
|
|
57
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('removes scoped vars from provider.environment and lands them on exact consumer sets', async () => {
|
|
65
|
+
const definition = await composeServerlessDefinition(
|
|
66
|
+
buildApp({ lambda: { scopedEnvironment: true } })
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
for (const key of SCOPED_VARS) {
|
|
70
|
+
expect(definition.provider.environment[key]).toBeUndefined();
|
|
71
|
+
}
|
|
72
|
+
// DB_TYPE deliberately stays global
|
|
73
|
+
expect(definition.provider.environment.DB_TYPE).toBe('postgresql');
|
|
74
|
+
|
|
75
|
+
const env = (fnName) => definition.functions[fnName].environment || {};
|
|
76
|
+
|
|
77
|
+
// auth + admin functions can reach every integration queue
|
|
78
|
+
for (const fnName of [
|
|
79
|
+
'auth',
|
|
80
|
+
'adminScriptRouter',
|
|
81
|
+
'adminScriptExecutor',
|
|
82
|
+
]) {
|
|
83
|
+
expect(env(fnName).HUBSPOT_QUEUE_URL).toBeDefined();
|
|
84
|
+
expect(env(fnName).SLACK_QUEUE_URL).toBeDefined();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// owning integration set, cross-integration isolation
|
|
88
|
+
expect(env('hubspot').HUBSPOT_QUEUE_URL).toBeDefined();
|
|
89
|
+
expect(env('hubspotWebhook').HUBSPOT_QUEUE_URL).toBeDefined();
|
|
90
|
+
expect(env('hubspotQueueWorker').HUBSPOT_QUEUE_URL).toBeDefined();
|
|
91
|
+
expect(env('hubspotQueueWorker').SLACK_QUEUE_URL).toBeUndefined();
|
|
92
|
+
expect(env('slackQueueWorker').HUBSPOT_QUEUE_URL).toBeUndefined();
|
|
93
|
+
|
|
94
|
+
// scheduler vars on integration functions and the executor, but the
|
|
95
|
+
// router keeps its own admin-scheduler role reference untouched
|
|
96
|
+
expect(env('hubspot').SCHEDULER_ROLE_ARN).toEqual({
|
|
97
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
98
|
+
});
|
|
99
|
+
expect(env('adminScriptExecutor').SCHEDULER_ROLE_ARN).toEqual({
|
|
100
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// migration vars only on the migration functions
|
|
104
|
+
expect(env('dbMigrationRouter').DB_MIGRATION_QUEUE_URL).toBeDefined();
|
|
105
|
+
expect(env('dbMigrationWorker').S3_BUCKET_NAME).toBeDefined();
|
|
106
|
+
|
|
107
|
+
// base functions that consume none of this carry none of it
|
|
108
|
+
for (const fnName of ['user', 'health']) {
|
|
109
|
+
for (const key of SCOPED_VARS) {
|
|
110
|
+
expect(env(fnName)[key]).toBeUndefined();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps full broadcast when the flag is off, byte-identical to omitting the lambda key', async () => {
|
|
116
|
+
const withoutKey = await composeServerlessDefinition(buildApp());
|
|
117
|
+
const withFlagOff = await composeServerlessDefinition(
|
|
118
|
+
buildApp({ lambda: { scopedEnvironment: false } })
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(withFlagOff).toEqual(withoutKey);
|
|
122
|
+
for (const key of SCOPED_VARS) {
|
|
123
|
+
expect(withoutKey.provider.environment[key]).toBeDefined();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSM INIT preload (NODE_OPTIONS=--import) must be scoped to skipEsbuild
|
|
3
|
+
* functions only. esbuild-bundled functions (e.g. defaultWebsocket) do not
|
|
4
|
+
* package the preload .mjs, and a missing --import target fatally aborts Node
|
|
5
|
+
* startup — so a global NODE_OPTIONS would brick them (ADR-027 review finding).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { composeServerlessDefinition } = require('../infrastructure-composer');
|
|
9
|
+
|
|
10
|
+
jest.mock('../domains/shared/resource-discovery', () => {
|
|
11
|
+
const original = jest.requireActual('../domains/shared/resource-discovery');
|
|
12
|
+
return {
|
|
13
|
+
...original,
|
|
14
|
+
gatherDiscoveredResources: jest.fn().mockResolvedValue({
|
|
15
|
+
defaultVpcId: 'vpc-123456',
|
|
16
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
17
|
+
privateSubnetId1: 'subnet-1',
|
|
18
|
+
privateSubnetId2: 'subnet-2',
|
|
19
|
+
defaultKmsKeyId:
|
|
20
|
+
'arn:aws:kms:us-east-1:123456789012:key/abc-123',
|
|
21
|
+
auroraClusterEndpoint: 'c.cluster-x.us-east-1.rds.amazonaws.com',
|
|
22
|
+
auroraPort: 5432,
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const buildApp = (overrides = {}) => ({
|
|
28
|
+
name: 'preload-test',
|
|
29
|
+
provider: 'aws',
|
|
30
|
+
usePrismaLambdaLayer: false,
|
|
31
|
+
encryption: { fieldLevelEncryptionMethod: 'aes' },
|
|
32
|
+
vpc: { enable: false },
|
|
33
|
+
database: { postgres: { enable: true } },
|
|
34
|
+
websockets: { enable: true },
|
|
35
|
+
integrations: [{ Definition: { name: 'hubspot' } }],
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const IMPORT_FRAGMENT = '--import file:///var/task/node_modules/@friggframework/core/core/ssm-preload.mjs';
|
|
40
|
+
|
|
41
|
+
describe('SSM preload NODE_OPTIONS scoping', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
process.argv = ['node', 'test'];
|
|
44
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('sets NODE_OPTIONS --import on skipEsbuild functions and NOT on esbuild-bundled ones when offload is active', async () => {
|
|
48
|
+
const definition = await composeServerlessDefinition(
|
|
49
|
+
buildApp({
|
|
50
|
+
ssm: { enable: true },
|
|
51
|
+
environment: { HUBSPOT_SECRET: 'ssm' },
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
const fns = definition.functions;
|
|
55
|
+
|
|
56
|
+
// skipEsbuild handlers (auth/user/health + integration) package the
|
|
57
|
+
// preload → they get the flag.
|
|
58
|
+
expect(fns.auth.skipEsbuild).toBe(true);
|
|
59
|
+
expect(fns.auth.environment.NODE_OPTIONS).toBe(IMPORT_FRAGMENT);
|
|
60
|
+
expect(fns.hubspot.environment.NODE_OPTIONS).toBe(IMPORT_FRAGMENT);
|
|
61
|
+
|
|
62
|
+
// defaultWebsocket is esbuild-bundled (no skipEsbuild) → MUST NOT get it.
|
|
63
|
+
expect(fns.defaultWebsocket.skipEsbuild).toBeFalsy();
|
|
64
|
+
expect(fns.defaultWebsocket.environment?.NODE_OPTIONS).toBeUndefined();
|
|
65
|
+
|
|
66
|
+
// Never global.
|
|
67
|
+
expect(definition.provider.environment.NODE_OPTIONS).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('sets no NODE_OPTIONS anywhere when offload is inactive', async () => {
|
|
71
|
+
const definition = await composeServerlessDefinition(
|
|
72
|
+
buildApp({ ssm: { enable: true } }) // enabled, but no 'ssm' keys
|
|
73
|
+
);
|
|
74
|
+
for (const fn of Object.values(definition.functions)) {
|
|
75
|
+
expect(fn.environment?.NODE_OPTIONS).toBeUndefined();
|
|
76
|
+
}
|
|
77
|
+
expect(definition.provider.environment.NODE_OPTIONS).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
|
|
17
|
+
const { isScopedEnvironmentActive } = require('../shared/function-environments');
|
|
17
18
|
|
|
18
19
|
class AdminScriptBuilder extends InfrastructureBuilder {
|
|
19
20
|
constructor() {
|
|
@@ -67,7 +68,7 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
// Create admin script queue
|
|
70
|
-
this.createAdminScriptQueue(result);
|
|
71
|
+
this.createAdminScriptQueue(result, appDefinition);
|
|
71
72
|
|
|
72
73
|
// Create Lambda function for script execution
|
|
73
74
|
this.createScriptExecutorFunction(appDefinition, result, usePrismaLayer);
|
|
@@ -90,7 +91,7 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
90
91
|
return result;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
createAdminScriptQueue(result) {
|
|
94
|
+
createAdminScriptQueue(result, appDefinition) {
|
|
94
95
|
result.resources.AdminScriptQueue = {
|
|
95
96
|
Type: 'AWS::SQS::Queue',
|
|
96
97
|
Properties: {
|
|
@@ -106,7 +107,20 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
106
107
|
},
|
|
107
108
|
};
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
if (isScopedEnvironmentActive(appDefinition)) {
|
|
111
|
+
// Only the admin functions read this queue URL
|
|
112
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
113
|
+
for (const fnName of ['adminScriptRouter', 'adminScriptExecutor']) {
|
|
114
|
+
result.functionEnvironments[fnName] = {
|
|
115
|
+
...result.functionEnvironments[fnName],
|
|
116
|
+
ADMIN_SCRIPT_QUEUE_URL: { Ref: 'AdminScriptQueue' },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
result.environment.ADMIN_SCRIPT_QUEUE_URL = {
|
|
121
|
+
Ref: 'AdminScriptQueue',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
110
124
|
|
|
111
125
|
// The router enqueues async executions and scripts enqueue continuations
|
|
112
126
|
// via queueScript()/queueScriptBatch(). The base role's wildcard does not
|
|
@@ -625,4 +625,49 @@ describe('AdminScriptBuilder', () => {
|
|
|
625
625
|
expect(adminScriptBuilder.getName()).toBe('AdminScriptBuilder');
|
|
626
626
|
});
|
|
627
627
|
});
|
|
628
|
+
|
|
629
|
+
describe('scoped environment (lambda.scopedEnvironment)', () => {
|
|
630
|
+
const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
631
|
+
|
|
632
|
+
beforeEach(() => {
|
|
633
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
afterEach(() => {
|
|
637
|
+
if (originalSkipDiscovery === undefined) {
|
|
638
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
639
|
+
} else {
|
|
640
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('scopes ADMIN_SCRIPT_QUEUE_URL to the admin functions only', async () => {
|
|
645
|
+
const result = await adminScriptBuilder.build(
|
|
646
|
+
{
|
|
647
|
+
lambda: { scopedEnvironment: true },
|
|
648
|
+
adminScripts: [{ Definition: { name: 'fix-things' } }],
|
|
649
|
+
},
|
|
650
|
+
{}
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toBeUndefined();
|
|
654
|
+
for (const fnName of ['adminScriptRouter', 'adminScriptExecutor']) {
|
|
655
|
+
expect(
|
|
656
|
+
result.functionEnvironments[fnName].ADMIN_SCRIPT_QUEUE_URL
|
|
657
|
+
).toEqual({ Ref: 'AdminScriptQueue' });
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('broadcasts app-wide when the flag is off', async () => {
|
|
662
|
+
const result = await adminScriptBuilder.build(
|
|
663
|
+
{ adminScripts: [{ Definition: { name: 'fix-things' } }] },
|
|
664
|
+
{}
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toEqual({
|
|
668
|
+
Ref: 'AdminScriptQueue',
|
|
669
|
+
});
|
|
670
|
+
expect(result.functionEnvironments).toBeUndefined();
|
|
671
|
+
});
|
|
672
|
+
});
|
|
628
673
|
});
|