@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
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
|
|
16
|
+
const {
|
|
17
|
+
isScopedEnvironmentActive,
|
|
18
|
+
getIntegrationFunctionNames,
|
|
19
|
+
getAdminFunctionNames,
|
|
20
|
+
} = require('../shared/function-environments');
|
|
16
21
|
|
|
17
22
|
class SchedulerBuilder extends InfrastructureBuilder {
|
|
18
23
|
constructor() {
|
|
@@ -70,7 +75,7 @@ class SchedulerBuilder extends InfrastructureBuilder {
|
|
|
70
75
|
this.addSchedulerIamStatements(result);
|
|
71
76
|
|
|
72
77
|
// Add environment variables
|
|
73
|
-
this.addEnvironmentVariables(result);
|
|
78
|
+
this.addEnvironmentVariables(result, appDefinition);
|
|
74
79
|
|
|
75
80
|
console.log(`[${this.name}] ✅ Scheduler configuration completed`);
|
|
76
81
|
return result;
|
|
@@ -196,15 +201,46 @@ class SchedulerBuilder extends InfrastructureBuilder {
|
|
|
196
201
|
/**
|
|
197
202
|
* Add environment variables for scheduler configuration
|
|
198
203
|
*/
|
|
199
|
-
addEnvironmentVariables(result) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
addEnvironmentVariables(result, appDefinition = {}) {
|
|
205
|
+
const environment = {
|
|
206
|
+
SCHEDULER_ROLE_ARN: {
|
|
207
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
208
|
+
},
|
|
209
|
+
SCHEDULE_GROUP_NAME: {
|
|
210
|
+
Ref: 'FriggScheduleGroup',
|
|
211
|
+
},
|
|
205
212
|
};
|
|
206
213
|
|
|
207
|
-
|
|
214
|
+
if (!isScopedEnvironmentActive(appDefinition)) {
|
|
215
|
+
Object.assign(result.environment, environment);
|
|
216
|
+
console.log(' ✓ Added scheduler environment variables');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Consumers: auth (runs integration actions), the executor (runs
|
|
221
|
+
// admin scripts that instantiate integrations), and every
|
|
222
|
+
// integration function. NOT adminScriptRouter — it carries its own
|
|
223
|
+
// admin-scheduler role, set directly by the admin-script builder.
|
|
224
|
+
const targets = [
|
|
225
|
+
'auth',
|
|
226
|
+
...getAdminFunctionNames(appDefinition).filter(
|
|
227
|
+
(fnName) => fnName !== 'adminScriptRouter'
|
|
228
|
+
),
|
|
229
|
+
...(appDefinition.integrations || []).flatMap(
|
|
230
|
+
getIntegrationFunctionNames
|
|
231
|
+
),
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
235
|
+
for (const fnName of targets) {
|
|
236
|
+
result.functionEnvironments[fnName] = {
|
|
237
|
+
...result.functionEnvironments[fnName],
|
|
238
|
+
...environment,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
console.log(
|
|
242
|
+
` ✓ Scoped scheduler environment variables to: ${targets.join(', ')}`
|
|
243
|
+
);
|
|
208
244
|
}
|
|
209
245
|
}
|
|
210
246
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Scheduler Builder
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { SchedulerBuilder } = require('./scheduler-builder');
|
|
6
|
+
|
|
7
|
+
describe('SchedulerBuilder', () => {
|
|
8
|
+
let schedulerBuilder;
|
|
9
|
+
const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
schedulerBuilder = new SchedulerBuilder();
|
|
13
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (originalSkipDiscovery === undefined) {
|
|
18
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('shouldExecute()', () => {
|
|
25
|
+
it('runs when explicitly enabled', () => {
|
|
26
|
+
expect(
|
|
27
|
+
schedulerBuilder.shouldExecute({ scheduler: { enable: true } })
|
|
28
|
+
).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('runs when any integration has webhooks', () => {
|
|
32
|
+
expect(
|
|
33
|
+
schedulerBuilder.shouldExecute({
|
|
34
|
+
integrations: [
|
|
35
|
+
{ Definition: { name: 'a', webhooks: true } },
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('skips otherwise', () => {
|
|
42
|
+
expect(
|
|
43
|
+
schedulerBuilder.shouldExecute({
|
|
44
|
+
integrations: [{ Definition: { name: 'a' } }],
|
|
45
|
+
})
|
|
46
|
+
).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('build() environment', () => {
|
|
51
|
+
const scheduledApp = (extra = {}) => ({
|
|
52
|
+
scheduler: { enable: true },
|
|
53
|
+
integrations: [
|
|
54
|
+
{ Definition: { name: 'hubspot', webhooks: true } },
|
|
55
|
+
{ Definition: { name: 'slack' } },
|
|
56
|
+
],
|
|
57
|
+
...extra,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('broadcasts scheduler vars app-wide by default', async () => {
|
|
61
|
+
const result = await schedulerBuilder.build(scheduledApp(), {});
|
|
62
|
+
|
|
63
|
+
expect(result.environment.SCHEDULER_ROLE_ARN).toEqual({
|
|
64
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
65
|
+
});
|
|
66
|
+
expect(result.environment.SCHEDULE_GROUP_NAME).toEqual({
|
|
67
|
+
Ref: 'FriggScheduleGroup',
|
|
68
|
+
});
|
|
69
|
+
expect(result.functionEnvironments).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('scopes scheduler vars to auth, adminScriptExecutor, and integration functions with lambda.scopedEnvironment', async () => {
|
|
73
|
+
const result = await schedulerBuilder.build(
|
|
74
|
+
scheduledApp({
|
|
75
|
+
lambda: { scopedEnvironment: true },
|
|
76
|
+
adminScripts: [{ Definition: { name: 'fix-things' } }],
|
|
77
|
+
}),
|
|
78
|
+
{}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(result.environment.SCHEDULER_ROLE_ARN).toBeUndefined();
|
|
82
|
+
expect(result.environment.SCHEDULE_GROUP_NAME).toBeUndefined();
|
|
83
|
+
|
|
84
|
+
const scoped = result.functionEnvironments;
|
|
85
|
+
for (const fnName of [
|
|
86
|
+
'auth',
|
|
87
|
+
'adminScriptExecutor',
|
|
88
|
+
'hubspot',
|
|
89
|
+
'hubspotWebhook',
|
|
90
|
+
'hubspotQueueWorker',
|
|
91
|
+
'slack',
|
|
92
|
+
'slackQueueWorker',
|
|
93
|
+
]) {
|
|
94
|
+
expect(scoped[fnName].SCHEDULER_ROLE_ARN).toEqual({
|
|
95
|
+
'Fn::GetAtt': ['SchedulerExecutionRole', 'Arn'],
|
|
96
|
+
});
|
|
97
|
+
expect(scoped[fnName].SCHEDULE_GROUP_NAME).toEqual({
|
|
98
|
+
Ref: 'FriggScheduleGroup',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// the router keeps its own admin-scheduler role, set directly by
|
|
103
|
+
// the admin-script builder — never targeted here
|
|
104
|
+
expect(scoped.adminScriptRouter).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps broadcasting in local mode even with the flag on', async () => {
|
|
108
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
|
|
109
|
+
const result = await schedulerBuilder.build(
|
|
110
|
+
scheduledApp({ lambda: { scopedEnvironment: true } }),
|
|
111
|
+
{}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(result.environment.SCHEDULER_ROLE_ARN).toBeDefined();
|
|
115
|
+
expect(result.functionEnvironments).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -7,6 +7,7 @@ const path = require('path');
|
|
|
7
7
|
* @param {Object} [options.features={}] - Enabled features { vpc, kms, ssm, websockets }
|
|
8
8
|
* @param {string} [options.userPrefix='frigg-deployment-user'] - IAM user name prefix
|
|
9
9
|
* @param {string} [options.stackName='frigg-deployment-iam'] - CloudFormation stack name
|
|
10
|
+
* @param {string} [options.ssmKmsKeyArn] - Customer-managed KMS key ARN for SecureString offload (appDefinition.ssm.kmsKeyArn)
|
|
10
11
|
* @returns {string} CloudFormation YAML template
|
|
11
12
|
*/
|
|
12
13
|
function generateIAMCloudFormation(options = {}) {
|
|
@@ -14,7 +15,8 @@ function generateIAMCloudFormation(options = {}) {
|
|
|
14
15
|
appName = 'Frigg',
|
|
15
16
|
features = {},
|
|
16
17
|
userPrefix = 'frigg-deployment-user',
|
|
17
|
-
stackName = 'frigg-deployment-iam'
|
|
18
|
+
stackName = 'frigg-deployment-iam',
|
|
19
|
+
ssmKmsKeyArn
|
|
18
20
|
} = options;
|
|
19
21
|
|
|
20
22
|
const deploymentUserName = userPrefix;
|
|
@@ -667,6 +669,9 @@ function generateIAMCloudFormation(options = {}) {
|
|
|
667
669
|
'ssm:GetParameter',
|
|
668
670
|
'ssm:GetParameters',
|
|
669
671
|
'ssm:GetParametersByPath',
|
|
672
|
+
'ssm:PutParameter',
|
|
673
|
+
'ssm:DeleteParameter',
|
|
674
|
+
'ssm:AddTagsToResource',
|
|
670
675
|
],
|
|
671
676
|
Resource: [
|
|
672
677
|
{
|
|
@@ -679,6 +684,32 @@ function generateIAMCloudFormation(options = {}) {
|
|
|
679
684
|
},
|
|
680
685
|
],
|
|
681
686
|
},
|
|
687
|
+
{
|
|
688
|
+
// SecureString offload: SSM performs the KMS encrypt/decrypt
|
|
689
|
+
// on the caller's behalf, so the grant is scoped to ViaService ssm.*
|
|
690
|
+
Sid: 'FriggSSMParameterKMSEncryption',
|
|
691
|
+
Effect: 'Allow',
|
|
692
|
+
Action: [
|
|
693
|
+
'kms:Encrypt',
|
|
694
|
+
'kms:GenerateDataKey',
|
|
695
|
+
'kms:Decrypt',
|
|
696
|
+
],
|
|
697
|
+
Resource: ssmKmsKeyArn
|
|
698
|
+
? [ssmKmsKeyArn]
|
|
699
|
+
: [
|
|
700
|
+
{
|
|
701
|
+
'Fn::Sub':
|
|
702
|
+
'arn:aws:kms:*:${AWS::AccountId}:key/*',
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
Condition: {
|
|
706
|
+
StringEquals: {
|
|
707
|
+
'kms:ViaService': [
|
|
708
|
+
'ssm.*.amazonaws.com',
|
|
709
|
+
],
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
},
|
|
682
713
|
],
|
|
683
714
|
},
|
|
684
715
|
},
|
|
@@ -773,6 +804,7 @@ function getFeatureSummary(appDefinition) {
|
|
|
773
804
|
features,
|
|
774
805
|
integrationCount,
|
|
775
806
|
appName: appDefinition.name || 'Unnamed Frigg App',
|
|
807
|
+
ssmKmsKeyArn: appDefinition.ssm?.kmsKeyArn,
|
|
776
808
|
};
|
|
777
809
|
}
|
|
778
810
|
|
|
@@ -38,6 +38,34 @@ describe('IAM Generator', () => {
|
|
|
38
38
|
expect(summary.features.ssm).toBe(false);
|
|
39
39
|
expect(summary.features.websockets).toBe(false);
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
it('should surface ssm.kmsKeyArn from the app definition', () => {
|
|
43
|
+
const appDefinition = {
|
|
44
|
+
name: 'test-app',
|
|
45
|
+
ssm: {
|
|
46
|
+
enable: true,
|
|
47
|
+
kmsKeyArn:
|
|
48
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const summary = getFeatureSummary(appDefinition);
|
|
53
|
+
|
|
54
|
+
expect(summary.ssmKmsKeyArn).toBe(
|
|
55
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should leave ssmKmsKeyArn undefined when not configured', () => {
|
|
60
|
+
const appDefinition = {
|
|
61
|
+
name: 'test-app',
|
|
62
|
+
ssm: { enable: true }
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const summary = getFeatureSummary(appDefinition);
|
|
66
|
+
|
|
67
|
+
expect(summary.ssmKmsKeyArn).toBeUndefined();
|
|
68
|
+
});
|
|
41
69
|
});
|
|
42
70
|
|
|
43
71
|
describe('generateIAMCloudFormation', () => {
|
|
@@ -120,6 +148,51 @@ describe('IAM Generator', () => {
|
|
|
120
148
|
expect(yaml).toContain('EnableSSMSupport');
|
|
121
149
|
});
|
|
122
150
|
|
|
151
|
+
it('should grant SSM-mediated KMS access via ssm.*.amazonaws.com when SSM is enabled', () => {
|
|
152
|
+
const appDefinition = {
|
|
153
|
+
name: 'test-app',
|
|
154
|
+
integrations: [],
|
|
155
|
+
ssm: { enable: true }
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const summary = getFeatureSummary(appDefinition);
|
|
159
|
+
const yaml = generateIAMCloudFormation({
|
|
160
|
+
appName: summary.appName,
|
|
161
|
+
features: summary.features
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(yaml).toContain('FriggSSMParameterKMSEncryption');
|
|
165
|
+
expect(yaml).toContain('kms:Encrypt');
|
|
166
|
+
expect(yaml).toContain('ssm.*.amazonaws.com');
|
|
167
|
+
// No customer-managed key configured: falls back to the account key wildcard
|
|
168
|
+
expect(yaml).toContain('arn:aws:kms:*:${AWS::AccountId}:key/*');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should scope the SSM KMS grant to ssm.kmsKeyArn when provided', () => {
|
|
172
|
+
const appDefinition = {
|
|
173
|
+
name: 'test-app',
|
|
174
|
+
integrations: [],
|
|
175
|
+
ssm: {
|
|
176
|
+
enable: true,
|
|
177
|
+
kmsKeyArn:
|
|
178
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const summary = getFeatureSummary(appDefinition);
|
|
183
|
+
const yaml = generateIAMCloudFormation({
|
|
184
|
+
appName: summary.appName,
|
|
185
|
+
features: summary.features,
|
|
186
|
+
ssmKmsKeyArn: appDefinition.ssm.kmsKeyArn
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(yaml).toContain('FriggSSMParameterKMSEncryption');
|
|
190
|
+
expect(yaml).toContain(
|
|
191
|
+
'arn:aws:kms:us-east-1:123456789012:key/abcd-1234'
|
|
192
|
+
);
|
|
193
|
+
expect(yaml).not.toContain('arn:aws:kms:*:${AWS::AccountId}:key/*');
|
|
194
|
+
});
|
|
195
|
+
|
|
123
196
|
it('should set correct default parameter values based on features', () => {
|
|
124
197
|
const appDefinition = {
|
|
125
198
|
name: 'test-app',
|
|
@@ -360,9 +360,24 @@ Resources:
|
|
|
360
360
|
- 'ssm:GetParameter'
|
|
361
361
|
- 'ssm:GetParameters'
|
|
362
362
|
- 'ssm:GetParametersByPath'
|
|
363
|
+
- 'ssm:PutParameter'
|
|
364
|
+
- 'ssm:DeleteParameter'
|
|
365
|
+
- 'ssm:AddTagsToResource'
|
|
363
366
|
Resource:
|
|
364
367
|
- !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*frigg*'
|
|
365
368
|
- !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*frigg*/*'
|
|
369
|
+
- Sid: 'FriggSSMParameterKMSEncryption'
|
|
370
|
+
Effect: Allow
|
|
371
|
+
Action:
|
|
372
|
+
- 'kms:Encrypt'
|
|
373
|
+
- 'kms:GenerateDataKey'
|
|
374
|
+
- 'kms:Decrypt'
|
|
375
|
+
Resource:
|
|
376
|
+
- !Sub 'arn:aws:kms:*:${AWS::AccountId}:key/*'
|
|
377
|
+
Condition:
|
|
378
|
+
StringEquals:
|
|
379
|
+
'kms:ViaService':
|
|
380
|
+
- 'ssm.*.amazonaws.com'
|
|
366
381
|
|
|
367
382
|
# Store access key in Secrets Manager
|
|
368
383
|
FriggDeploymentCredentials:
|
|
@@ -256,6 +256,7 @@
|
|
|
256
256
|
"Sid": "FriggKMSEncryptionPermissions",
|
|
257
257
|
"Effect": "Allow",
|
|
258
258
|
"Action": [
|
|
259
|
+
"kms:Encrypt",
|
|
259
260
|
"kms:GenerateDataKey",
|
|
260
261
|
"kms:Decrypt"
|
|
261
262
|
],
|
|
@@ -266,7 +267,8 @@
|
|
|
266
267
|
"StringEquals": {
|
|
267
268
|
"kms:ViaService": [
|
|
268
269
|
"lambda.*.amazonaws.com",
|
|
269
|
-
"s3.*.amazonaws.com"
|
|
270
|
+
"s3.*.amazonaws.com",
|
|
271
|
+
"ssm.*.amazonaws.com"
|
|
270
272
|
]
|
|
271
273
|
}
|
|
272
274
|
}
|
|
@@ -277,7 +279,10 @@
|
|
|
277
279
|
"Action": [
|
|
278
280
|
"ssm:GetParameter",
|
|
279
281
|
"ssm:GetParameters",
|
|
280
|
-
"ssm:GetParametersByPath"
|
|
282
|
+
"ssm:GetParametersByPath",
|
|
283
|
+
"ssm:PutParameter",
|
|
284
|
+
"ssm:DeleteParameter",
|
|
285
|
+
"ssm:AddTagsToResource"
|
|
281
286
|
],
|
|
282
287
|
"Resource": [
|
|
283
288
|
"arn:aws:ssm:*:*:parameter/*frigg*",
|
|
@@ -144,6 +144,7 @@ class BuilderOrchestrator {
|
|
|
144
144
|
iamStatements: [],
|
|
145
145
|
environment: {},
|
|
146
146
|
functions: {},
|
|
147
|
+
functionEnvironments: {},
|
|
147
148
|
layers: {},
|
|
148
149
|
plugins: [],
|
|
149
150
|
custom: {},
|
|
@@ -172,6 +173,19 @@ class BuilderOrchestrator {
|
|
|
172
173
|
Object.assign(merged.functions, result.functions);
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// Merge function-scoped environment maps (applied by the
|
|
177
|
+
// composer once base + builder functions all exist)
|
|
178
|
+
if (result.functionEnvironments) {
|
|
179
|
+
for (const [fnName, env] of Object.entries(
|
|
180
|
+
result.functionEnvironments
|
|
181
|
+
)) {
|
|
182
|
+
merged.functionEnvironments[fnName] = {
|
|
183
|
+
...merged.functionEnvironments[fnName],
|
|
184
|
+
...env,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
175
189
|
// Merge layers
|
|
176
190
|
if (result.layers) {
|
|
177
191
|
Object.assign(merged.layers, result.layers);
|
|
@@ -208,6 +208,51 @@ describe('BuilderOrchestrator', () => {
|
|
|
208
208
|
|
|
209
209
|
await expect(orchestrator.buildAll({})).rejects.toThrow('Build failed');
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
it('should merge functionEnvironments per function, later builder winning on key conflict', async () => {
|
|
213
|
+
class ScopedEnvBuilderA extends InfrastructureBuilder {
|
|
214
|
+
getName() { return 'ScopedEnvBuilderA'; }
|
|
215
|
+
shouldExecute() { return true; }
|
|
216
|
+
validate() { return new ValidationResult(); }
|
|
217
|
+
async build() {
|
|
218
|
+
return {
|
|
219
|
+
functionEnvironments: {
|
|
220
|
+
auth: { QUEUE_A: 'url-a', SHARED: 'from-a' },
|
|
221
|
+
workerA: { QUEUE_A: 'url-a' },
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
class ScopedEnvBuilderB extends InfrastructureBuilder {
|
|
227
|
+
getName() { return 'ScopedEnvBuilderB'; }
|
|
228
|
+
shouldExecute() { return true; }
|
|
229
|
+
validate() { return new ValidationResult(); }
|
|
230
|
+
getDependencies() { return ['ScopedEnvBuilderA']; }
|
|
231
|
+
async build() {
|
|
232
|
+
return {
|
|
233
|
+
functionEnvironments: {
|
|
234
|
+
auth: { QUEUE_B: 'url-b', SHARED: 'from-b' },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
orchestrator = new BuilderOrchestrator([
|
|
241
|
+
new ScopedEnvBuilderA(),
|
|
242
|
+
new ScopedEnvBuilderB(),
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
const result = await orchestrator.buildAll({});
|
|
246
|
+
|
|
247
|
+
expect(result.merged.functionEnvironments).toEqual({
|
|
248
|
+
auth: {
|
|
249
|
+
QUEUE_A: 'url-a',
|
|
250
|
+
QUEUE_B: 'url-b',
|
|
251
|
+
SHARED: 'from-b',
|
|
252
|
+
},
|
|
253
|
+
workerA: { QUEUE_A: 'url-a' },
|
|
254
|
+
});
|
|
255
|
+
});
|
|
211
256
|
});
|
|
212
257
|
});
|
|
213
258
|
|
|
@@ -9,12 +9,19 @@
|
|
|
9
9
|
* 3. Generated resource references
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
const { isSsmOffloadActive, getOffloadedKeys } = require('../parameters/offload-utils');
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* Get environment variables from AppDefinition
|
|
14
|
-
*
|
|
16
|
+
*
|
|
15
17
|
* Extracts environment variable definitions where value is true,
|
|
16
18
|
* and creates Serverless variable references.
|
|
17
|
-
*
|
|
19
|
+
*
|
|
20
|
+
* A value of 'ssm' offloads the variable to Parameter Store when offload is
|
|
21
|
+
* active (see SsmBuilder), keeping it out of the Lambda env map. When offload
|
|
22
|
+
* is not active (local mode / ssm disabled) it falls back to the same
|
|
23
|
+
* `${env:KEY, ''}` reference as `true` so `frigg start` + dotenv keeps working.
|
|
24
|
+
*
|
|
18
25
|
* @param {Object} appDefinition - Application definition
|
|
19
26
|
* @returns {Object} Environment variable mappings
|
|
20
27
|
*/
|
|
@@ -38,16 +45,38 @@ function getAppEnvironmentVars(appDefinition) {
|
|
|
38
45
|
'AWS_SESSION_TOKEN',
|
|
39
46
|
]);
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
return envVars;
|
|
43
|
-
}
|
|
48
|
+
const environment = appDefinition.environment || {};
|
|
44
49
|
|
|
45
50
|
console.log('📋 Loading environment variables from appDefinition...');
|
|
46
51
|
const envKeys = [];
|
|
47
52
|
const skippedKeys = [];
|
|
53
|
+
const offloadedKeys = [];
|
|
54
|
+
const offloadActive = isSsmOffloadActive(appDefinition);
|
|
48
55
|
|
|
49
|
-
for (const [key, value] of Object.entries(
|
|
50
|
-
if (value
|
|
56
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
57
|
+
if (value === 'ssm' && offloadActive) {
|
|
58
|
+
offloadedKeys.push(key);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (value !== true && value !== 'ssm') continue;
|
|
62
|
+
if (reservedVars.has(key)) {
|
|
63
|
+
skippedKeys.push(key);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
envVars[key] = `\${env:${key}, ''}`;
|
|
67
|
+
envKeys.push(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Keys declared only in ssm.parameters (no matching `environment` entry)
|
|
71
|
+
// get the same local-fallback treatment as `environment`-valued 'ssm' keys.
|
|
72
|
+
const ssmOnlyKeys = getOffloadedKeys(appDefinition).filter(
|
|
73
|
+
(key) => !(key in environment)
|
|
74
|
+
);
|
|
75
|
+
for (const key of ssmOnlyKeys) {
|
|
76
|
+
if (offloadActive) {
|
|
77
|
+
offloadedKeys.push(key);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
51
80
|
if (reservedVars.has(key)) {
|
|
52
81
|
skippedKeys.push(key);
|
|
53
82
|
continue;
|
|
@@ -69,6 +98,13 @@ function getAppEnvironmentVars(appDefinition) {
|
|
|
69
98
|
} reserved AWS Lambda variables: ${skippedKeys.join(', ')}`
|
|
70
99
|
);
|
|
71
100
|
}
|
|
101
|
+
if (offloadedKeys.length > 0) {
|
|
102
|
+
console.log(
|
|
103
|
+
` 🔒 Offloaded ${offloadedKeys.length} variables to SSM: ${offloadedKeys.join(
|
|
104
|
+
', '
|
|
105
|
+
)}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
72
108
|
|
|
73
109
|
return envVars;
|
|
74
110
|
}
|
|
@@ -38,7 +38,7 @@ describe('Environment Builder', () => {
|
|
|
38
38
|
expect(result.DISABLED_VAR).toBeUndefined();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it(
|
|
41
|
+
it("ignores non-boolean values other than the meaningful 'ssm' marker", () => {
|
|
42
42
|
const appDefinition = {
|
|
43
43
|
environment: {
|
|
44
44
|
VALID: true,
|
|
@@ -108,6 +108,102 @@ describe('Environment Builder', () => {
|
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
describe("getAppEnvironmentVars() - 'ssm' offload", () => {
|
|
112
|
+
const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
if (originalSkipDiscovery === undefined) {
|
|
116
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
117
|
+
} else {
|
|
118
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("excludes 'ssm' keys when offload is active", () => {
|
|
123
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
124
|
+
const appDefinition = {
|
|
125
|
+
ssm: { enable: true },
|
|
126
|
+
environment: { FOO: 'ssm', BAR: true },
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = getAppEnvironmentVars(appDefinition);
|
|
130
|
+
|
|
131
|
+
expect(result.FOO).toBeUndefined();
|
|
132
|
+
expect(result.BAR).toBe("${env:BAR, ''}");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("falls back to env reference for 'ssm' keys when FRIGG_SKIP_AWS_DISCOVERY is set", () => {
|
|
136
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
|
|
137
|
+
const appDefinition = {
|
|
138
|
+
ssm: { enable: true },
|
|
139
|
+
environment: { FOO: 'ssm' },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const result = getAppEnvironmentVars(appDefinition);
|
|
143
|
+
|
|
144
|
+
expect(result.FOO).toBe("${env:FOO, ''}");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("falls back to env reference for 'ssm' keys when ssm is disabled", () => {
|
|
148
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
149
|
+
const appDefinition = {
|
|
150
|
+
ssm: { enable: false },
|
|
151
|
+
environment: { FOO: 'ssm' },
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const result = getAppEnvironmentVars(appDefinition);
|
|
155
|
+
|
|
156
|
+
expect(result.FOO).toBe("${env:FOO, ''}");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('falls back to env reference for a key declared only in ssm.parameters when FRIGG_SKIP_AWS_DISCOVERY is set', () => {
|
|
160
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
|
|
161
|
+
const appDefinition = {
|
|
162
|
+
ssm: {
|
|
163
|
+
enable: true,
|
|
164
|
+
parameters: { HUBSPOT_CLIENT_SECRET: { type: 'SecureString' } },
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const result = getAppEnvironmentVars(appDefinition);
|
|
169
|
+
|
|
170
|
+
expect(result.HUBSPOT_CLIENT_SECRET).toBe("${env:HUBSPOT_CLIENT_SECRET, ''}");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('excludes a key declared only in ssm.parameters when offload is active', () => {
|
|
174
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
175
|
+
const appDefinition = {
|
|
176
|
+
ssm: {
|
|
177
|
+
enable: true,
|
|
178
|
+
parameters: { HUBSPOT_CLIENT_SECRET: { type: 'SecureString' } },
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const result = getAppEnvironmentVars(appDefinition);
|
|
183
|
+
|
|
184
|
+
expect(result.HUBSPOT_CLIENT_SECRET).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("does not double-process a key present in both environment:'ssm' and ssm.parameters", () => {
|
|
188
|
+
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
189
|
+
const appDefinition = {
|
|
190
|
+
ssm: {
|
|
191
|
+
enable: true,
|
|
192
|
+
parameters: { FOO: { type: 'SecureString' } },
|
|
193
|
+
},
|
|
194
|
+
environment: { FOO: 'ssm' },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const result = getAppEnvironmentVars(appDefinition);
|
|
198
|
+
|
|
199
|
+
expect(result.FOO).toBeUndefined();
|
|
200
|
+
|
|
201
|
+
process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
|
|
202
|
+
const fallbackResult = getAppEnvironmentVars(appDefinition);
|
|
203
|
+
expect(fallbackResult.FOO).toBe("${env:FOO, ''}");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
111
207
|
describe('buildEnvironment()', () => {
|
|
112
208
|
it('should combine app vars with standard Frigg variables', () => {
|
|
113
209
|
const appEnvironmentVars = {
|