@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.
- 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 +270 -0
- package/frigg-cli/ssm-command/index.test.js +301 -0
- package/infrastructure/__tests__/helpers/test-utils.js +3 -5
- package/infrastructure/__tests__/scoped-environment.test.js +126 -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-resolver.js +12 -3
- package/infrastructure/domains/networking/vpc-resolver.test.js +40 -0
- package/infrastructure/domains/parameters/offload-utils.js +175 -0
- package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
- package/infrastructure/domains/parameters/ssm-builder.js +60 -14
- package/infrastructure/domains/parameters/ssm-builder.test.js +145 -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 +33 -1
- package/infrastructure/domains/security/iam-generator.test.js +73 -0
- package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +15 -0
- package/infrastructure/domains/security/templates/iam-policy-full.json +7 -2
- 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 +5 -0
- package/infrastructure/infrastructure-composer.test.js +69 -14
- package/infrastructure/integration.test.js +3 -5
- package/package.json +8 -7
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
const { mockClient } = require('aws-sdk-client-mock');
|
|
2
|
+
const { SSMClient, PutParameterCommand } = require('@aws-sdk/client-ssm');
|
|
3
|
+
const { pushOffloadedParameters, resolvePushRegion } = require('./index');
|
|
4
|
+
|
|
5
|
+
describe('ssm-command pushOffloadedParameters', () => {
|
|
6
|
+
let ssmMock;
|
|
7
|
+
const originalEnv = process.env;
|
|
8
|
+
|
|
9
|
+
const appDefinition = {
|
|
10
|
+
name: 'my-app',
|
|
11
|
+
ssm: {
|
|
12
|
+
enable: true,
|
|
13
|
+
parameters: {
|
|
14
|
+
MY_SECRET: { type: 'SecureString' },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
environment: {
|
|
18
|
+
MY_CONFIG: 'ssm',
|
|
19
|
+
PLAIN: true,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
ssmMock = mockClient(SSMClient);
|
|
25
|
+
ssmMock.on(PutParameterCommand).resolves({ Version: 1 });
|
|
26
|
+
process.env = {
|
|
27
|
+
...originalEnv,
|
|
28
|
+
MY_SECRET: 'shh',
|
|
29
|
+
MY_CONFIG: 'value-1',
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
ssmMock.restore();
|
|
35
|
+
process.env = originalEnv;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('pushes each offloaded key with resolved name, type, and overwrite', async () => {
|
|
39
|
+
const result = await pushOffloadedParameters(appDefinition, 'prod');
|
|
40
|
+
|
|
41
|
+
const calls = ssmMock.commandCalls(PutParameterCommand);
|
|
42
|
+
expect(calls).toHaveLength(2);
|
|
43
|
+
|
|
44
|
+
const byName = Object.fromEntries(
|
|
45
|
+
calls.map((c) => [c.args[0].input.Name, c.args[0].input])
|
|
46
|
+
);
|
|
47
|
+
expect(byName['/frigg/my-app/prod/MY_SECRET']).toMatchObject({
|
|
48
|
+
Value: 'shh',
|
|
49
|
+
Type: 'SecureString',
|
|
50
|
+
Overwrite: true,
|
|
51
|
+
});
|
|
52
|
+
expect(byName['/frigg/my-app/prod/MY_CONFIG']).toMatchObject({
|
|
53
|
+
Value: 'value-1',
|
|
54
|
+
Type: 'String',
|
|
55
|
+
Overwrite: true,
|
|
56
|
+
});
|
|
57
|
+
expect(result.pushed).toHaveLength(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('passes KeyId for SecureString when ssm.kmsKeyArn is set', async () => {
|
|
61
|
+
const withKey = {
|
|
62
|
+
...appDefinition,
|
|
63
|
+
ssm: {
|
|
64
|
+
...appDefinition.ssm,
|
|
65
|
+
kmsKeyArn: 'arn:aws:kms:us-east-1:1:key/k',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
await pushOffloadedParameters(withKey, 'dev');
|
|
69
|
+
|
|
70
|
+
const inputs = ssmMock
|
|
71
|
+
.commandCalls(PutParameterCommand)
|
|
72
|
+
.map((c) => c.args[0].input);
|
|
73
|
+
const secure = inputs.find((i) => i.Type === 'SecureString');
|
|
74
|
+
const plain = inputs.find((i) => i.Type === 'String');
|
|
75
|
+
expect(secure.KeyId).toBe('arn:aws:kms:us-east-1:1:key/k');
|
|
76
|
+
expect(plain.KeyId).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is a silent no-op when nothing is offloaded', async () => {
|
|
80
|
+
const result = await pushOffloadedParameters(
|
|
81
|
+
{ name: 'x', environment: { PLAIN: true } },
|
|
82
|
+
'dev'
|
|
83
|
+
);
|
|
84
|
+
expect(result.pushed).toHaveLength(0);
|
|
85
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws listing keys with missing or empty values', async () => {
|
|
89
|
+
delete process.env.MY_SECRET;
|
|
90
|
+
process.env.MY_CONFIG = '';
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
pushOffloadedParameters(appDefinition, 'dev')
|
|
94
|
+
).rejects.toThrow(/MY_SECRET.*MY_CONFIG|MY_CONFIG.*MY_SECRET/s);
|
|
95
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('skips missing values with allowEmpty instead of throwing', async () => {
|
|
99
|
+
delete process.env.MY_SECRET;
|
|
100
|
+
|
|
101
|
+
const result = await pushOffloadedParameters(appDefinition, 'dev', {
|
|
102
|
+
allowEmpty: true,
|
|
103
|
+
});
|
|
104
|
+
expect(result.skipped).toEqual(['MY_SECRET']);
|
|
105
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects values over the standard tier limit and suggests advanced', async () => {
|
|
109
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
pushOffloadedParameters(appDefinition, 'dev')
|
|
113
|
+
).rejects.toThrow(/advanced/i);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts large values with the advanced tier', async () => {
|
|
117
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
118
|
+
|
|
119
|
+
await pushOffloadedParameters(appDefinition, 'dev', {
|
|
120
|
+
tier: 'advanced',
|
|
121
|
+
});
|
|
122
|
+
const inputs = ssmMock
|
|
123
|
+
.commandCalls(PutParameterCommand)
|
|
124
|
+
.map((c) => c.args[0].input);
|
|
125
|
+
expect(
|
|
126
|
+
inputs.find((i) => i.Name.endsWith('/MY_CONFIG')).Tier
|
|
127
|
+
).toBe('Advanced');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects values over the advanced tier limit', async () => {
|
|
131
|
+
process.env.MY_CONFIG = 'x'.repeat(8193);
|
|
132
|
+
|
|
133
|
+
await expect(
|
|
134
|
+
pushOffloadedParameters(appDefinition, 'dev', {
|
|
135
|
+
tier: 'advanced',
|
|
136
|
+
})
|
|
137
|
+
).rejects.toThrow(/8192|8 ?KB/i);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('per-key tier resolution', () => {
|
|
141
|
+
const withKeyTier = {
|
|
142
|
+
...appDefinition,
|
|
143
|
+
ssm: {
|
|
144
|
+
...appDefinition.ssm,
|
|
145
|
+
parameters: {
|
|
146
|
+
MY_SECRET: { type: 'SecureString' },
|
|
147
|
+
MY_CONFIG: { tier: 'advanced' },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
it('accepts a large value when the key is configured advanced in the app definition', async () => {
|
|
153
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
154
|
+
|
|
155
|
+
await pushOffloadedParameters(withKeyTier, 'dev');
|
|
156
|
+
|
|
157
|
+
const config = ssmMock
|
|
158
|
+
.commandCalls(PutParameterCommand)
|
|
159
|
+
.map((c) => c.args[0].input)
|
|
160
|
+
.find((i) => i.Name.endsWith('/MY_CONFIG'));
|
|
161
|
+
expect(config.Tier).toBe('Advanced');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('validates each key against its own configured tier', async () => {
|
|
165
|
+
// MY_CONFIG (advanced) tolerates 5000 bytes; MY_SECRET (standard) does not
|
|
166
|
+
process.env.MY_CONFIG = 'x'.repeat(5000);
|
|
167
|
+
process.env.MY_SECRET = 'y'.repeat(5000);
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
pushOffloadedParameters(withKeyTier, 'dev')
|
|
171
|
+
).rejects.toThrow(/MY_SECRET/);
|
|
172
|
+
expect(ssmMock.commandCalls(PutParameterCommand)).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not tag standard-tier keys with an Advanced tier', async () => {
|
|
176
|
+
await pushOffloadedParameters(withKeyTier, 'dev');
|
|
177
|
+
|
|
178
|
+
const secret = ssmMock
|
|
179
|
+
.commandCalls(PutParameterCommand)
|
|
180
|
+
.map((c) => c.args[0].input)
|
|
181
|
+
.find((i) => i.Name.endsWith('/MY_SECRET'));
|
|
182
|
+
expect(secret.Tier).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('lets the --tier CLI option override per-key config for all keys', async () => {
|
|
186
|
+
process.env.MY_CONFIG = 'x'.repeat(4097);
|
|
187
|
+
|
|
188
|
+
await expect(
|
|
189
|
+
pushOffloadedParameters(withKeyTier, 'dev', {
|
|
190
|
+
tier: 'standard',
|
|
191
|
+
})
|
|
192
|
+
).rejects.toThrow(/advanced/i);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('throws on invalid offload config (markers without ssm.enable)', async () => {
|
|
197
|
+
await expect(
|
|
198
|
+
pushOffloadedParameters(
|
|
199
|
+
{ name: 'x', environment: { FOO: 'ssm' } },
|
|
200
|
+
'dev'
|
|
201
|
+
)
|
|
202
|
+
).rejects.toThrow(/ssm\.enable/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('wraps AWS errors with the parameter name', async () => {
|
|
206
|
+
ssmMock
|
|
207
|
+
.on(PutParameterCommand, {
|
|
208
|
+
Name: '/frigg/my-app/dev/MY_SECRET',
|
|
209
|
+
})
|
|
210
|
+
.rejects(new Error('boom'));
|
|
211
|
+
|
|
212
|
+
await expect(
|
|
213
|
+
pushOffloadedParameters(appDefinition, 'dev')
|
|
214
|
+
).rejects.toThrow(/MY_SECRET/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('retries a throttled PutParameter and succeeds', async () => {
|
|
218
|
+
const throttle = Object.assign(new Error('Rate exceeded'), {
|
|
219
|
+
name: 'ThrottlingException',
|
|
220
|
+
});
|
|
221
|
+
ssmMock
|
|
222
|
+
.on(PutParameterCommand, {
|
|
223
|
+
Name: '/frigg/my-app/dev/MY_SECRET',
|
|
224
|
+
})
|
|
225
|
+
.rejectsOnce(throttle)
|
|
226
|
+
.resolves({ Version: 2 });
|
|
227
|
+
|
|
228
|
+
const result = await pushOffloadedParameters(appDefinition, 'dev');
|
|
229
|
+
|
|
230
|
+
expect(result.pushed).toHaveLength(2);
|
|
231
|
+
const secretCalls = ssmMock
|
|
232
|
+
.commandCalls(PutParameterCommand)
|
|
233
|
+
.filter(
|
|
234
|
+
(c) => c.args[0].input.Name === '/frigg/my-app/dev/MY_SECRET'
|
|
235
|
+
);
|
|
236
|
+
expect(secretCalls).toHaveLength(2);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('exposes keys pushed so far on a mid-batch failure', async () => {
|
|
240
|
+
// MY_CONFIG (sorted before MY_SECRET) succeeds; MY_SECRET fails.
|
|
241
|
+
ssmMock
|
|
242
|
+
.on(PutParameterCommand, {
|
|
243
|
+
Name: '/frigg/my-app/dev/MY_CONFIG',
|
|
244
|
+
})
|
|
245
|
+
.resolves({ Version: 1 });
|
|
246
|
+
ssmMock
|
|
247
|
+
.on(PutParameterCommand, {
|
|
248
|
+
Name: '/frigg/my-app/dev/MY_SECRET',
|
|
249
|
+
})
|
|
250
|
+
.rejects(new Error('boom'));
|
|
251
|
+
|
|
252
|
+
let caught;
|
|
253
|
+
try {
|
|
254
|
+
await pushOffloadedParameters(appDefinition, 'dev');
|
|
255
|
+
} catch (error) {
|
|
256
|
+
caught = error;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
expect(caught).toBeDefined();
|
|
260
|
+
expect(caught.pushed).toEqual([
|
|
261
|
+
{ name: '/frigg/my-app/dev/MY_CONFIG', version: 1 },
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('honors a custom parameterPrefix', async () => {
|
|
266
|
+
const custom = {
|
|
267
|
+
...appDefinition,
|
|
268
|
+
ssm: { ...appDefinition.ssm, parameterPrefix: '/team/x' },
|
|
269
|
+
};
|
|
270
|
+
await pushOffloadedParameters(custom, 'dev');
|
|
271
|
+
|
|
272
|
+
const names = ssmMock
|
|
273
|
+
.commandCalls(PutParameterCommand)
|
|
274
|
+
.map((c) => c.args[0].input.Name);
|
|
275
|
+
expect(names).toEqual(
|
|
276
|
+
expect.arrayContaining([
|
|
277
|
+
'/team/x/MY_SECRET',
|
|
278
|
+
'/team/x/MY_CONFIG',
|
|
279
|
+
])
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('resolvePushRegion', () => {
|
|
284
|
+
it('prefers an explicit --region option over the environment', () => {
|
|
285
|
+
process.env.AWS_REGION = 'us-west-2';
|
|
286
|
+
expect(resolvePushRegion({ region: 'eu-central-1' })).toBe(
|
|
287
|
+
'eu-central-1'
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('falls back to AWS_REGION when no option is given', () => {
|
|
292
|
+
process.env.AWS_REGION = 'us-west-2';
|
|
293
|
+
expect(resolvePushRegion({})).toBe('us-west-2');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('defaults to us-east-1 when neither option nor AWS_REGION is set', () => {
|
|
297
|
+
delete process.env.AWS_REGION;
|
|
298
|
+
expect(resolvePushRegion({})).toBe('us-east-1');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
});
|
|
@@ -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
|
const { MigrationResourceResolver } = require('./migration-resolver');
|
|
18
19
|
const { createEmptyDiscoveryResult, ResourceOwnership } = require('../shared/types');
|
|
19
20
|
|
|
@@ -564,14 +565,30 @@ class MigrationBuilder extends InfrastructureBuilder {
|
|
|
564
565
|
|
|
565
566
|
console.log(' ✓ Created DbMigrationQueue resource');
|
|
566
567
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
568
|
+
const migrationEnvironment = {
|
|
569
|
+
// S3 bucket for migration Lambda functions
|
|
570
|
+
S3_BUCKET_NAME: { Ref: 'FriggMigrationStatusBucket' },
|
|
571
|
+
MIGRATION_STATUS_BUCKET: { Ref: 'FriggMigrationStatusBucket' },
|
|
572
|
+
DB_MIGRATION_QUEUE_URL: { Ref: 'DbMigrationQueue' },
|
|
573
|
+
};
|
|
570
574
|
|
|
571
|
-
|
|
572
|
-
|
|
575
|
+
if (isScopedEnvironmentActive(appDefinition)) {
|
|
576
|
+
// Only the migration functions read these
|
|
577
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
578
|
+
for (const fnName of ['dbMigrationRouter', 'dbMigrationWorker']) {
|
|
579
|
+
result.functionEnvironments[fnName] = {
|
|
580
|
+
...result.functionEnvironments[fnName],
|
|
581
|
+
...migrationEnvironment,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
Object.assign(result.environment, migrationEnvironment);
|
|
586
|
+
}
|
|
573
587
|
|
|
574
|
-
// Hardcode DB_TYPE for PostgreSQL-only migrations
|
|
588
|
+
// Hardcode DB_TYPE for PostgreSQL-only migrations. Stays app-wide
|
|
589
|
+
// even when scoping: it is tiny and broadly consumed, and scoping it
|
|
590
|
+
// would push every function through the app-definition fallback in
|
|
591
|
+
// core's getDatabaseType() at cold start.
|
|
575
592
|
result.environment.DB_TYPE = 'postgresql';
|
|
576
593
|
|
|
577
594
|
console.log(' ✓ Added S3_BUCKET_NAME, DB_MIGRATION_QUEUE_URL, and DB_TYPE environment variables');
|
|
@@ -656,9 +673,29 @@ class MigrationBuilder extends InfrastructureBuilder {
|
|
|
656
673
|
.replace(/\//g, ':');
|
|
657
674
|
|
|
658
675
|
// Add environment variables (using external resource names/URLs)
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
676
|
+
const migrationEnvironment = {
|
|
677
|
+
S3_BUCKET_NAME: bucketName,
|
|
678
|
+
MIGRATION_STATUS_BUCKET: bucketName,
|
|
679
|
+
DB_MIGRATION_QUEUE_URL: queueUrl,
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
if (isScopedEnvironmentActive(appDefinition)) {
|
|
683
|
+
// Only the migration functions read these
|
|
684
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
685
|
+
for (const fnName of ['dbMigrationRouter', 'dbMigrationWorker']) {
|
|
686
|
+
result.functionEnvironments[fnName] = {
|
|
687
|
+
...result.functionEnvironments[fnName],
|
|
688
|
+
...migrationEnvironment,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
Object.assign(result.environment, migrationEnvironment);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Hardcode DB_TYPE for PostgreSQL-only migrations. Stays app-wide
|
|
696
|
+
// even when scoping: it is tiny and broadly consumed, and scoping it
|
|
697
|
+
// would push every function through the app-definition fallback in
|
|
698
|
+
// core's getDatabaseType() at cold start.
|
|
662
699
|
result.environment.DB_TYPE = 'postgresql';
|
|
663
700
|
|
|
664
701
|
console.log(' ✓ Added S3_BUCKET_NAME, DB_MIGRATION_QUEUE_URL, and DB_TYPE environment variables');
|