@friggframework/devtools 2.0.0--canary.640.5140601.0 → 2.0.0--canary.643.5ac10b7.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/infrastructure/domains/database/aurora-builder.js +40 -329
- package/infrastructure/domains/database/aurora-builder.test.js +1 -1
- package/infrastructure/domains/networking/vpc-builder.js +6 -97
- package/infrastructure/domains/shared/types/app-definition.js +2 -18
- package/package.json +7 -7
- package/infrastructure/__tests__/aurora-scale-to-zero-connectivity.test.js +0 -356
|
@@ -94,206 +94,21 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// Validate capacity settings
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// capacity — reject them explicitly. See ADR-033.
|
|
100
|
-
if (
|
|
101
|
-
dbConfig.minCapacity !== undefined &&
|
|
102
|
-
dbConfig.minCapacity !== 0 &&
|
|
103
|
-
(dbConfig.minCapacity < 0.5 || dbConfig.minCapacity > 128)
|
|
104
|
-
) {
|
|
105
|
-
result.addError('database.postgres.minCapacity must be 0 (scale-to-zero) or between 0.5 and 128');
|
|
97
|
+
if (dbConfig.minCapacity !== undefined && (dbConfig.minCapacity < 0.5 || dbConfig.minCapacity > 128)) {
|
|
98
|
+
result.addError('database.postgres.minCapacity must be between 0.5 and 128');
|
|
106
99
|
}
|
|
107
100
|
if (dbConfig.maxCapacity !== undefined && (dbConfig.maxCapacity < 0.5 || dbConfig.maxCapacity > 128)) {
|
|
108
101
|
result.addError('database.postgres.maxCapacity must be between 0.5 and 128');
|
|
109
102
|
}
|
|
110
|
-
// Cross-check: minCapacity must not exceed maxCapacity, or CloudFormation
|
|
111
|
-
// rejects the ServerlessV2ScalingConfiguration at deploy time.
|
|
112
|
-
if (
|
|
113
|
-
dbConfig.minCapacity !== undefined &&
|
|
114
|
-
dbConfig.maxCapacity !== undefined &&
|
|
115
|
-
dbConfig.minCapacity > dbConfig.maxCapacity
|
|
116
|
-
) {
|
|
117
|
-
result.addError(
|
|
118
|
-
`database.postgres.minCapacity (${dbConfig.minCapacity}) must be <= maxCapacity (${dbConfig.maxCapacity})`
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Validate scale-to-zero auto-pause window (seconds). AWS-valid range is
|
|
123
|
-
// 300–86400 (5 minutes to 24 hours) and it must be an integer.
|
|
124
|
-
if (dbConfig.secondsUntilAutoPause !== undefined) {
|
|
125
|
-
const s = dbConfig.secondsUntilAutoPause;
|
|
126
|
-
if (!Number.isInteger(s) || s < 300 || s > 86400) {
|
|
127
|
-
result.addError('database.postgres.secondsUntilAutoPause must be an integer between 300 and 86400');
|
|
128
|
-
}
|
|
129
|
-
// secondsUntilAutoPause only takes effect for scale-to-zero clusters.
|
|
130
|
-
if (dbConfig.minCapacity !== 0) {
|
|
131
|
-
result.addWarning(
|
|
132
|
-
'database.postgres.secondsUntilAutoPause is ignored unless minCapacity is 0 (scale-to-zero).'
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Validate connectivity mode. 'vpc' (default) keeps today's behavior;
|
|
138
|
-
// 'public' places Aurora in public subnets and leaves the Lambda out of
|
|
139
|
-
// the VPC (NAT-free). See ADR-033.
|
|
140
|
-
if (dbConfig.connectivity !== undefined) {
|
|
141
|
-
const validConnectivity = ['vpc', 'public'];
|
|
142
|
-
if (!validConnectivity.includes(dbConfig.connectivity)) {
|
|
143
|
-
result.addError(
|
|
144
|
-
`Invalid database.postgres.connectivity: "${dbConfig.connectivity}". Must be one of: ${validConnectivity.join(', ')}`
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Public connectivity requires an enabled VPC (Aurora must live in a VPC —
|
|
150
|
-
// an AWS constraint — and the builder creates the public subnets there).
|
|
151
|
-
// Validate it here so the failure is a clear message rather than a downstream
|
|
152
|
-
// "Aurora requires 2 public subnets" throw.
|
|
153
|
-
if (dbConfig.connectivity === 'public' && appDefinition.vpc?.enable !== true) {
|
|
154
|
-
result.addError(
|
|
155
|
-
'database.postgres.connectivity="public" requires vpc.enable=true (Aurora needs public subnets in a VPC).'
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Validate allowedCidrs is an array of syntactically valid IPv4 CIDRs when
|
|
160
|
-
// present. NOTE: IPv4 only — IPv6 (CidrIpv6) is not supported here.
|
|
161
|
-
if (dbConfig.allowedCidrs !== undefined) {
|
|
162
|
-
if (!Array.isArray(dbConfig.allowedCidrs)) {
|
|
163
|
-
result.addError('database.postgres.allowedCidrs must be an array of CIDR strings');
|
|
164
|
-
} else {
|
|
165
|
-
const bad = dbConfig.allowedCidrs.filter((c) => !this.isValidIpv4Cidr(c));
|
|
166
|
-
if (bad.length > 0) {
|
|
167
|
-
result.addError(
|
|
168
|
-
`database.postgres.allowedCidrs contains invalid CIDR value(s): ${bad.join(', ')}`
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
// An empty allowlist in public mode is almost always a mistake that
|
|
172
|
-
// would silently fall back to 0.0.0.0/0 (full internet exposure) —
|
|
173
|
-
// reject it so "allow nothing" cannot mean "allow everything".
|
|
174
|
-
if (dbConfig.allowedCidrs.length === 0 && dbConfig.connectivity === 'public') {
|
|
175
|
-
result.addError(
|
|
176
|
-
'database.postgres.allowedCidrs is empty with connectivity="public". List at least one CIDR ' +
|
|
177
|
-
"(use ['0.0.0.0/0'] to intentionally allow the whole internet)."
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Scale-to-zero requires a supported engine version. Warn (do not
|
|
184
|
-
// hard-fail) if the user pins an older version with minCapacity: 0.
|
|
185
|
-
// Frigg's default engineVersion (15.13) qualifies. Minimum
|
|
186
|
-
// scale-to-zero-capable versions: Aurora PG 13.15 / 14.12 / 15.7 / 16.3.
|
|
187
|
-
if (dbConfig.minCapacity === 0 && dbConfig.engineVersion) {
|
|
188
|
-
if (!this.engineSupportsScaleToZero(dbConfig.engineVersion)) {
|
|
189
|
-
result.addWarning(
|
|
190
|
-
`database.postgres.engineVersion="${dbConfig.engineVersion}" may not support Aurora Serverless v2 scale-to-zero (minCapacity: 0). ` +
|
|
191
|
-
'Scale-to-zero requires Aurora PostgreSQL 13.15+/14.12+/15.7+/16.3+.'
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
103
|
|
|
196
104
|
// Warn about public accessibility in production
|
|
197
105
|
if (dbConfig.publiclyAccessible === true) {
|
|
198
106
|
result.addWarning('database.postgres.publiclyAccessible=true is not recommended for production');
|
|
199
107
|
}
|
|
200
108
|
|
|
201
|
-
// ADR-033 security posture: public connectivity exposes the DB endpoint
|
|
202
|
-
// to the internet. Make the trade-off loud.
|
|
203
|
-
if (dbConfig.connectivity === 'public') {
|
|
204
|
-
result.addWarning(
|
|
205
|
-
'database.postgres.connectivity="public" exposes the Aurora endpoint to the internet. ' +
|
|
206
|
-
'TLS is enforced (sslmode=require) and access is restricted to allowedCidrs, but prefer "vpc" for production.'
|
|
207
|
-
);
|
|
208
|
-
// Only the default fallback (empty array is now an error above) or an
|
|
209
|
-
// explicit 0.0.0.0/0 reaches here as "whole internet".
|
|
210
|
-
const cidrs =
|
|
211
|
-
Array.isArray(dbConfig.allowedCidrs) && dbConfig.allowedCidrs.length > 0
|
|
212
|
-
? dbConfig.allowedCidrs
|
|
213
|
-
: ['0.0.0.0/0'];
|
|
214
|
-
if (cidrs.includes('0.0.0.0/0')) {
|
|
215
|
-
result.addWarning(
|
|
216
|
-
'database.postgres.allowedCidrs allows 0.0.0.0/0 (the whole internet). ' +
|
|
217
|
-
'Narrow this to known egress ranges wherever the deployment can.'
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Public connectivity only makes Frigg place the cluster in public
|
|
222
|
-
// subnets when Frigg CREATES the cluster (management='managed'). In
|
|
223
|
-
// discover/use-existing mode Frigg cannot flip an existing cluster to
|
|
224
|
-
// public subnets / PubliclyAccessible, yet the Lambda is still detached
|
|
225
|
-
// from the VPC — so if the existing cluster is private it becomes
|
|
226
|
-
// unreachable. Warn loudly. (management defaults to 'discover'.)
|
|
227
|
-
const mgmt = dbConfig.management || 'discover';
|
|
228
|
-
if (mgmt === 'discover' || mgmt === 'use-existing') {
|
|
229
|
-
result.addWarning(
|
|
230
|
-
`database.postgres.connectivity="public" with management="${mgmt}" assumes the EXISTING Aurora cluster ` +
|
|
231
|
-
'is already publicly accessible. Frigg detaches the Lambda from the VPC in public mode, so a private ' +
|
|
232
|
-
'existing cluster will be unreachable. Ensure the cluster has a public endpoint and open security group.'
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
if (mgmt === 'use-existing') {
|
|
236
|
-
result.addWarning(
|
|
237
|
-
'database.postgres.connectivity="public" with management="use-existing" only affects TLS (sslmode=require) on the ' +
|
|
238
|
-
'connection string — Frigg does not manage the existing cluster\'s subnets or ingress.'
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
109
|
return result;
|
|
244
110
|
}
|
|
245
111
|
|
|
246
|
-
/**
|
|
247
|
-
* Validate an IPv4 CIDR string with real numeric range checks (each octet
|
|
248
|
-
* 0–255, prefix 0–32) — not just shape — so values like 999.999.999.999/99
|
|
249
|
-
* are rejected. IPv4 only; IPv6 is out of scope for allowedCidrs.
|
|
250
|
-
* @param {string} cidr
|
|
251
|
-
* @returns {boolean}
|
|
252
|
-
*/
|
|
253
|
-
isValidIpv4Cidr(cidr) {
|
|
254
|
-
if (typeof cidr !== 'string') {
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(cidr);
|
|
258
|
-
if (!match) {
|
|
259
|
-
return false;
|
|
260
|
-
}
|
|
261
|
-
const octets = [match[1], match[2], match[3], match[4]].map(Number);
|
|
262
|
-
if (octets.some((o) => o > 255)) {
|
|
263
|
-
return false;
|
|
264
|
-
}
|
|
265
|
-
const prefix = Number(match[5]);
|
|
266
|
-
return prefix >= 0 && prefix <= 32;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Determine whether an Aurora PostgreSQL engine version supports
|
|
271
|
-
* Serverless v2 scale-to-zero (minCapacity: 0). Minimum capable versions:
|
|
272
|
-
* 13.15+, 14.12+, 15.7+, 16.3+. Unknown/unparseable versions return true
|
|
273
|
-
* (assume-capable) so we never hard-block on a version string we can't read;
|
|
274
|
-
* this only gates a warning, never a hard failure.
|
|
275
|
-
* @param {string} engineVersion e.g. '15.13'
|
|
276
|
-
* @returns {boolean}
|
|
277
|
-
*/
|
|
278
|
-
engineSupportsScaleToZero(engineVersion) {
|
|
279
|
-
const minByMajor = { 13: 15, 14: 12, 15: 7, 16: 3 };
|
|
280
|
-
const match = /^(\d+)\.(\d+)/.exec(String(engineVersion));
|
|
281
|
-
if (!match) {
|
|
282
|
-
return true; // can't parse — don't warn spuriously
|
|
283
|
-
}
|
|
284
|
-
const major = Number(match[1]);
|
|
285
|
-
const minor = Number(match[2]);
|
|
286
|
-
// Majors newer than the table are assumed capable.
|
|
287
|
-
if (major > 16) {
|
|
288
|
-
return true;
|
|
289
|
-
}
|
|
290
|
-
// Majors older than 13 never support scale-to-zero.
|
|
291
|
-
if (!(major in minByMajor)) {
|
|
292
|
-
return false;
|
|
293
|
-
}
|
|
294
|
-
return minor >= minByMajor[major];
|
|
295
|
-
}
|
|
296
|
-
|
|
297
112
|
/**
|
|
298
113
|
* Build Aurora infrastructure using ownership-based architecture
|
|
299
114
|
*/
|
|
@@ -512,32 +327,16 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
512
327
|
}
|
|
513
328
|
}
|
|
514
329
|
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
// read the TOP-LEVEL database.postgres fields (preserved by the deep clone
|
|
518
|
-
// above), and 0-propagation for scale-to-zero comes from that clone plus the
|
|
519
|
-
// `?? 0.5` in the scaling config, not from here. These `.config.*` copies are
|
|
520
|
-
// kept only so any future consumer that reads `config` sees the same values;
|
|
521
|
-
// `!== undefined` (not truthiness) is used so a minCapacity of 0 is mirrored
|
|
522
|
-
// rather than dropped.
|
|
523
|
-
if (appDefinition.database?.postgres?.minCapacity !== undefined) {
|
|
330
|
+
// Preserve other database config
|
|
331
|
+
if (appDefinition.database?.postgres?.minCapacity) {
|
|
524
332
|
translated.database.postgres.config.minCapacity = appDefinition.database.postgres.minCapacity;
|
|
525
333
|
}
|
|
526
|
-
if (appDefinition.database?.postgres?.maxCapacity
|
|
334
|
+
if (appDefinition.database?.postgres?.maxCapacity) {
|
|
527
335
|
translated.database.postgres.config.maxCapacity = appDefinition.database.postgres.maxCapacity;
|
|
528
336
|
}
|
|
529
|
-
if (appDefinition.database?.postgres?.secondsUntilAutoPause !== undefined) {
|
|
530
|
-
translated.database.postgres.config.secondsUntilAutoPause = appDefinition.database.postgres.secondsUntilAutoPause;
|
|
531
|
-
}
|
|
532
337
|
if (appDefinition.database?.postgres?.publiclyAccessible !== undefined) {
|
|
533
338
|
translated.database.postgres.config.publiclyAccessible = appDefinition.database.postgres.publiclyAccessible;
|
|
534
339
|
}
|
|
535
|
-
if (appDefinition.database?.postgres?.connectivity !== undefined) {
|
|
536
|
-
translated.database.postgres.config.connectivity = appDefinition.database.postgres.connectivity;
|
|
537
|
-
}
|
|
538
|
-
if (appDefinition.database?.postgres?.allowedCidrs !== undefined) {
|
|
539
|
-
translated.database.postgres.config.allowedCidrs = appDefinition.database.postgres.allowedCidrs;
|
|
540
|
-
}
|
|
541
340
|
|
|
542
341
|
return translated;
|
|
543
342
|
}
|
|
@@ -574,10 +373,7 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
574
373
|
console.log(' Creating new Aurora Serverless v2 cluster...');
|
|
575
374
|
|
|
576
375
|
const dbConfig = appDefinition.database.postgres;
|
|
577
|
-
|
|
578
|
-
// public subnets. It combines with the legacy publiclyAccessible flag.
|
|
579
|
-
const publicConnectivity = dbConfig.connectivity === 'public';
|
|
580
|
-
const publiclyAccessible = publicConnectivity || dbConfig.publiclyAccessible === true;
|
|
376
|
+
const publiclyAccessible = dbConfig.publiclyAccessible === true;
|
|
581
377
|
|
|
582
378
|
// Get subnet IDs for DB Subnet Group
|
|
583
379
|
const subnetIds = publiclyAccessible
|
|
@@ -656,16 +452,9 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
656
452
|
// min when idle) and gives the DB enough headroom to
|
|
657
453
|
// absorb bursty sync traffic. Apps can still override both
|
|
658
454
|
// via app definition dbConfig.
|
|
659
|
-
// ADR-033: use nullish coalescing — `|| 0.5` silently turned a
|
|
660
|
-
// requested MinCapacity of 0 (scale-to-zero) back into 0.5.
|
|
661
|
-
// When MinCapacity is 0, emit SecondsUntilAutoPause so the cluster
|
|
662
|
-
// pauses to 0 ACU after the idle window (default 300s).
|
|
663
455
|
ServerlessV2ScalingConfiguration: {
|
|
664
|
-
MinCapacity: dbConfig.minCapacity
|
|
665
|
-
MaxCapacity: dbConfig.maxCapacity
|
|
666
|
-
...(dbConfig.minCapacity === 0
|
|
667
|
-
? { SecondsUntilAutoPause: dbConfig.secondsUntilAutoPause ?? 300 }
|
|
668
|
-
: {}),
|
|
456
|
+
MinCapacity: dbConfig.minCapacity || 0.5,
|
|
457
|
+
MaxCapacity: dbConfig.maxCapacity || 4,
|
|
669
458
|
},
|
|
670
459
|
EnableHttpEndpoint: false,
|
|
671
460
|
BackupRetentionPeriod: 7,
|
|
@@ -693,15 +482,12 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
693
482
|
},
|
|
694
483
|
};
|
|
695
484
|
|
|
696
|
-
// Environment variables
|
|
697
|
-
// ADR-033: public connectivity requires TLS (sslmode=require) since the
|
|
698
|
-
// endpoint is reachable over the internet.
|
|
485
|
+
// Environment variables
|
|
699
486
|
result.environment.DATABASE_URL = this.buildDatabaseUrl(
|
|
700
487
|
{ 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Address'] },
|
|
701
488
|
{ 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Port'] },
|
|
702
489
|
dbConfig.database || 'frigg',
|
|
703
|
-
{ Ref: 'FriggDBSecret' }
|
|
704
|
-
{ requireTls: publicConnectivity }
|
|
490
|
+
{ Ref: 'FriggDBSecret' }
|
|
705
491
|
);
|
|
706
492
|
|
|
707
493
|
// IAM permissions for Secrets Manager
|
|
@@ -711,46 +497,19 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
711
497
|
Resource: { Ref: 'FriggDBSecret' },
|
|
712
498
|
});
|
|
713
499
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
result.resources[`FriggAuroraIngressRule${index}`] = {
|
|
728
|
-
Type: 'AWS::EC2::SecurityGroupIngress',
|
|
729
|
-
Properties: {
|
|
730
|
-
GroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
731
|
-
IpProtocol: 'tcp',
|
|
732
|
-
FromPort: 5432,
|
|
733
|
-
ToPort: 5432,
|
|
734
|
-
CidrIp: cidr,
|
|
735
|
-
Description: `Allow PostgreSQL access from ${cidr} (public connectivity)`,
|
|
736
|
-
},
|
|
737
|
-
};
|
|
738
|
-
});
|
|
739
|
-
} else {
|
|
740
|
-
// Add self-referencing security group ingress rule to allow Lambda to connect to Aurora
|
|
741
|
-
// Since both Lambda and Aurora share the same security group, we need to allow the SG to accept traffic from itself
|
|
742
|
-
result.resources.FriggAuroraIngressRule = {
|
|
743
|
-
Type: 'AWS::EC2::SecurityGroupIngress',
|
|
744
|
-
Properties: {
|
|
745
|
-
GroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
746
|
-
IpProtocol: 'tcp',
|
|
747
|
-
FromPort: 5432,
|
|
748
|
-
ToPort: 5432,
|
|
749
|
-
SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
750
|
-
Description: 'Allow Lambda functions to connect to Aurora PostgreSQL (self-referencing rule)',
|
|
751
|
-
},
|
|
752
|
-
};
|
|
753
|
-
}
|
|
500
|
+
// Add self-referencing security group ingress rule to allow Lambda to connect to Aurora
|
|
501
|
+
// Since both Lambda and Aurora share the same security group, we need to allow the SG to accept traffic from itself
|
|
502
|
+
result.resources.FriggAuroraIngressRule = {
|
|
503
|
+
Type: 'AWS::EC2::SecurityGroupIngress',
|
|
504
|
+
Properties: {
|
|
505
|
+
GroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
506
|
+
IpProtocol: 'tcp',
|
|
507
|
+
FromPort: 5432,
|
|
508
|
+
ToPort: 5432,
|
|
509
|
+
SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
510
|
+
Description: 'Allow Lambda functions to connect to Aurora PostgreSQL (self-referencing rule)',
|
|
511
|
+
},
|
|
512
|
+
};
|
|
754
513
|
|
|
755
514
|
console.log(' ✅ Aurora Serverless v2 cluster resources created');
|
|
756
515
|
}
|
|
@@ -767,12 +526,6 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
767
526
|
throw new Error('database.postgres.endpoint is required when management="use-existing"');
|
|
768
527
|
}
|
|
769
528
|
|
|
770
|
-
// ADR-033: for use-existing, `connectivity` only affects TLS on the
|
|
771
|
-
// connection params — Frigg does not own the cluster, so it manages neither
|
|
772
|
-
// its subnets nor its ingress (the validator warns about this). Public mode
|
|
773
|
-
// still requires TLS, so append sslmode=require.
|
|
774
|
-
const publicConnectivity = dbConfig.connectivity === 'public';
|
|
775
|
-
|
|
776
529
|
// Set environment variables for existing cluster
|
|
777
530
|
result.environment.DATABASE_HOST = dbConfig.endpoint;
|
|
778
531
|
result.environment.DATABASE_PORT = String(dbConfig.port || 5432);
|
|
@@ -781,9 +534,7 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
781
534
|
// Consumers that build DATABASE_URL from components at runtime MUST
|
|
782
535
|
// append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
|
|
783
536
|
// timeouts as the managed path.
|
|
784
|
-
result.environment.DATABASE_URL_PARAMS =
|
|
785
|
-
? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
|
|
786
|
-
: LAMBDA_DATABASE_URL_QUERY_PARAMS;
|
|
537
|
+
result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
|
|
787
538
|
|
|
788
539
|
console.log(` ✅ Using existing cluster: ${dbConfig.endpoint}`);
|
|
789
540
|
}
|
|
@@ -803,9 +554,6 @@ class AuroraBuilder extends InfrastructureBuilder {
|
|
|
803
554
|
console.log(` ✅ Using discovered Aurora cluster: ${discoveredResources.auroraClusterEndpoint}`);
|
|
804
555
|
|
|
805
556
|
const dbConfig = appDefinition.database.postgres;
|
|
806
|
-
// ADR-033: public connectivity requires TLS on the connection string and a
|
|
807
|
-
// CIDR-based ingress rule (the Lambda is not VPC-attached).
|
|
808
|
-
const publicConnectivity = dbConfig.connectivity === 'public';
|
|
809
557
|
|
|
810
558
|
// Use discovered cluster details
|
|
811
559
|
result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
|
|
@@ -981,8 +729,7 @@ exports.handler = async (event, context) => {
|
|
|
981
729
|
discoveredResources.auroraClusterEndpoint,
|
|
982
730
|
discoveredResources.auroraPort || 5432,
|
|
983
731
|
dbConfig.database || 'frigg',
|
|
984
|
-
{ Ref: 'FriggDBSecret' }
|
|
985
|
-
{ requireTls: publicConnectivity }
|
|
732
|
+
{ Ref: 'FriggDBSecret' }
|
|
986
733
|
);
|
|
987
734
|
|
|
988
735
|
// Grant Lambda functions permission to read the secret
|
|
@@ -1000,8 +747,7 @@ exports.handler = async (event, context) => {
|
|
|
1000
747
|
discoveredResources.auroraClusterEndpoint,
|
|
1001
748
|
discoveredResources.auroraPort || 5432,
|
|
1002
749
|
dbConfig.database || 'frigg',
|
|
1003
|
-
discoveredResources.databaseSecretArn
|
|
1004
|
-
{ requireTls: publicConnectivity }
|
|
750
|
+
discoveredResources.databaseSecretArn
|
|
1005
751
|
);
|
|
1006
752
|
|
|
1007
753
|
result.iamStatements.push({
|
|
@@ -1022,10 +768,7 @@ exports.handler = async (event, context) => {
|
|
|
1022
768
|
// Consumers that build DATABASE_URL from components at runtime MUST
|
|
1023
769
|
// append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
|
|
1024
770
|
// timeouts as the managed path.
|
|
1025
|
-
|
|
1026
|
-
result.environment.DATABASE_URL_PARAMS = publicConnectivity
|
|
1027
|
-
? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
|
|
1028
|
-
: LAMBDA_DATABASE_URL_QUERY_PARAMS;
|
|
771
|
+
result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
|
|
1029
772
|
|
|
1030
773
|
// Note: DATABASE_URL is NOT set here to avoid Serverless variable resolution errors
|
|
1031
774
|
// The application (Frigg Core) should construct it at runtime from:
|
|
@@ -1039,39 +782,17 @@ exports.handler = async (event, context) => {
|
|
|
1039
782
|
|
|
1040
783
|
// Add security group ingress rule to allow Lambda to connect to Aurora
|
|
1041
784
|
if (discoveredResources.auroraSecurityGroupId) {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
GroupId: discoveredResources.auroraSecurityGroupId,
|
|
1054
|
-
IpProtocol: 'tcp',
|
|
1055
|
-
FromPort: discoveredResources.auroraPort || 5432,
|
|
1056
|
-
ToPort: discoveredResources.auroraPort || 5432,
|
|
1057
|
-
CidrIp: cidr,
|
|
1058
|
-
Description: `Allow PostgreSQL access from ${cidr} (public connectivity)`,
|
|
1059
|
-
},
|
|
1060
|
-
};
|
|
1061
|
-
});
|
|
1062
|
-
} else {
|
|
1063
|
-
result.resources.FriggAuroraIngressRule = {
|
|
1064
|
-
Type: 'AWS::EC2::SecurityGroupIngress',
|
|
1065
|
-
Properties: {
|
|
1066
|
-
GroupId: discoveredResources.auroraSecurityGroupId,
|
|
1067
|
-
IpProtocol: 'tcp',
|
|
1068
|
-
FromPort: discoveredResources.auroraPort || 5432,
|
|
1069
|
-
ToPort: discoveredResources.auroraPort || 5432,
|
|
1070
|
-
SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
1071
|
-
Description: 'Allow Lambda functions to connect to Aurora PostgreSQL',
|
|
1072
|
-
},
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
785
|
+
result.resources.FriggAuroraIngressRule = {
|
|
786
|
+
Type: 'AWS::EC2::SecurityGroupIngress',
|
|
787
|
+
Properties: {
|
|
788
|
+
GroupId: discoveredResources.auroraSecurityGroupId,
|
|
789
|
+
IpProtocol: 'tcp',
|
|
790
|
+
FromPort: discoveredResources.auroraPort || 5432,
|
|
791
|
+
ToPort: discoveredResources.auroraPort || 5432,
|
|
792
|
+
SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
793
|
+
Description: 'Allow Lambda functions to connect to Aurora PostgreSQL',
|
|
794
|
+
},
|
|
795
|
+
};
|
|
1075
796
|
console.log(` ✅ Added security group ingress rule for Lambda → Aurora connectivity`);
|
|
1076
797
|
}
|
|
1077
798
|
|
|
@@ -1084,11 +805,8 @@ exports.handler = async (event, context) => {
|
|
|
1084
805
|
* @param {string|number|object} port - Database port (string/number or CloudFormation intrinsic function)
|
|
1085
806
|
* @param {string} database - Database name
|
|
1086
807
|
* @param {string|object} secretRef - Secret ARN (string) or CloudFormation Ref object
|
|
1087
|
-
* @param {object} [options]
|
|
1088
|
-
* @param {boolean} [options.requireTls] - Append sslmode=require (ADR-033 public connectivity)
|
|
1089
808
|
*/
|
|
1090
|
-
buildDatabaseUrl(host, port, database, secretRef
|
|
1091
|
-
const { requireTls = false } = options;
|
|
809
|
+
buildDatabaseUrl(host, port, database, secretRef) {
|
|
1092
810
|
// Handle secretRef as either a string ARN or CloudFormation Ref object
|
|
1093
811
|
const resolveSecretRef = (secretRefValue) => {
|
|
1094
812
|
if (typeof secretRefValue === 'object' && secretRefValue.Ref) {
|
|
@@ -1120,16 +838,9 @@ exports.handler = async (event, context) => {
|
|
|
1120
838
|
|
|
1121
839
|
// Query params are defined at module scope (LAMBDA_DATABASE_URL_QUERY_PARAMS)
|
|
1122
840
|
// so runtime-URL-construction paths can emit the same timeouts as an env var.
|
|
1123
|
-
// ADR-033: for public connectivity, TLS is mandatory — append sslmode=require
|
|
1124
|
-
// unless it's already present in the base params.
|
|
1125
|
-
const queryParams =
|
|
1126
|
-
requireTls && !/(^|&)sslmode=/.test(LAMBDA_DATABASE_URL_QUERY_PARAMS)
|
|
1127
|
-
? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
|
|
1128
|
-
: LAMBDA_DATABASE_URL_QUERY_PARAMS;
|
|
1129
|
-
|
|
1130
841
|
return {
|
|
1131
842
|
'Fn::Sub': [
|
|
1132
|
-
`postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${
|
|
843
|
+
`postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${LAMBDA_DATABASE_URL_QUERY_PARAMS}`,
|
|
1133
844
|
{
|
|
1134
845
|
Username: resolveSecretRef(secretRef),
|
|
1135
846
|
Password: resolveSecretPassword(secretRef),
|
|
@@ -194,7 +194,7 @@ describe('AuroraBuilder', () => {
|
|
|
194
194
|
const result = auroraBuilder.validate(appDefinition);
|
|
195
195
|
|
|
196
196
|
expect(result.valid).toBe(false);
|
|
197
|
-
expect(result.errors.some(e => e.includes('minCapacity must be
|
|
197
|
+
expect(result.errors.some(e => e.includes('minCapacity must be between 0.5 and 128'))).toBe(true);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
200
|
it('should error when maxCapacity is out of range', () => {
|
|
@@ -24,31 +24,6 @@ const { createEmptyDiscoveryResult } = require('../shared/types/discovery-result
|
|
|
24
24
|
const { ResourceOwnership } = require('../shared/types/resource-ownership');
|
|
25
25
|
const { isSsmOffloadActive } = require('../parameters/offload-utils');
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* ADR-033: NAT-free public database connectivity.
|
|
29
|
-
*
|
|
30
|
-
* When the app opts into `database.postgres.connectivity: 'public'`, Aurora is
|
|
31
|
-
* placed in public subnets with a public endpoint and the app's Lambdas are
|
|
32
|
-
* intentionally left OUTSIDE the VPC so they keep default internet egress and
|
|
33
|
-
* need no NAT Gateway. This function is the single seam that DECOUPLES the
|
|
34
|
-
* Lambda's VPC attachment + NAT from Aurora's networking:
|
|
35
|
-
*
|
|
36
|
-
* - The VpcBuilder still builds the VPC / public subnets / DB subnet group
|
|
37
|
-
* that Aurora requires (Aurora must live in a VPC — an AWS constraint).
|
|
38
|
-
* - But it does NOT emit a NAT Gateway, and it clears `result.vpcConfig` so the
|
|
39
|
-
* composer never sets `provider.vpc` (see infrastructure-composer.js:126) —
|
|
40
|
-
* leaving the Lambda un-attached with normal egress.
|
|
41
|
-
*
|
|
42
|
-
* @param {Object} appDefinition
|
|
43
|
-
* @returns {boolean}
|
|
44
|
-
*/
|
|
45
|
-
function isPublicDatabaseConnectivity(appDefinition) {
|
|
46
|
-
return (
|
|
47
|
-
appDefinition?.database?.postgres?.enable === true &&
|
|
48
|
-
appDefinition?.database?.postgres?.connectivity === 'public'
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
27
|
class VpcBuilder extends InfrastructureBuilder {
|
|
53
28
|
constructor() {
|
|
54
29
|
super();
|
|
@@ -590,86 +565,20 @@ class VpcBuilder extends InfrastructureBuilder {
|
|
|
590
565
|
// Build Subnets based on ownership decision
|
|
591
566
|
this.buildSubnetsFromDecision(decisions.subnets, appDefinition, discoveredResources, result);
|
|
592
567
|
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
// the VPC — so it keeps default internet egress and needs no NAT Gateway.
|
|
596
|
-
// We still built the VPC + public subnets above (Aurora requires them), but
|
|
597
|
-
// we skip the NAT Gateway here and clear vpcConfig below so the composer
|
|
598
|
-
// leaves provider.vpc unset. This is the seam that decouples the Lambda's
|
|
599
|
-
// VPC attachment + NAT from Aurora's networking.
|
|
600
|
-
const publicDbConnectivity = isPublicDatabaseConnectivity(appDefinition);
|
|
601
|
-
|
|
602
|
-
if (publicDbConnectivity) {
|
|
603
|
-
console.log(
|
|
604
|
-
' ⊝ NAT Gateway skipped (database.postgres.connectivity=public — Lambda is not VPC-attached, so no NAT is needed)'
|
|
605
|
-
);
|
|
606
|
-
|
|
607
|
-
// ADR-033: the public subnets Aurora sits in still need an Internet
|
|
608
|
-
// Gateway default route + subnet→route-table associations to be
|
|
609
|
-
// internet-routable. Those are normally emitted only as a SIDE EFFECT
|
|
610
|
-
// of the NAT build (createPublicRouting is called from inside the NAT
|
|
611
|
-
// methods), so skipping NAT would otherwise leave the public subnets on
|
|
612
|
-
// the VPC main route table (local-only) and the public Aurora endpoint
|
|
613
|
-
// unreachable — a green deploy with a dead DB. Decouple the public-subnet
|
|
614
|
-
// routing from NAT here.
|
|
615
|
-
//
|
|
616
|
-
// GUARD: only do this for a Frigg-created (stack) VPC — signalled by the
|
|
617
|
-
// presence of FriggInternetGateway in the template (emitted by
|
|
618
|
-
// buildVpcFromDecision only for STACK ownership). createPublicRouting
|
|
619
|
-
// references { Ref: 'FriggInternetGateway' } and DependsOn
|
|
620
|
-
// 'FriggVPCGatewayAttachment', and associates the stack-created
|
|
621
|
-
// FriggPublicSubnet* — all of which exist only in that case. For a
|
|
622
|
-
// discovered/existing VPC, its public subnets already route to an IGW, so
|
|
623
|
-
// creating our own public route table would be redundant/conflicting.
|
|
624
|
-
if (result.resources.FriggInternetGateway) {
|
|
625
|
-
console.log(
|
|
626
|
-
' → Public-subnet routing (IGW default route + associations) for stack-created VPC'
|
|
627
|
-
);
|
|
628
|
-
this.createPublicRouting(appDefinition, discoveredResources, result);
|
|
629
|
-
} else {
|
|
630
|
-
console.log(
|
|
631
|
-
' ℹ Public connectivity on a discovered/existing VPC — assuming its public subnets already route to an Internet Gateway; not creating conflicting routing'
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
} else {
|
|
635
|
-
// Build NAT Gateway based on ownership decision
|
|
636
|
-
this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
|
|
637
|
-
}
|
|
568
|
+
// Build NAT Gateway based on ownership decision
|
|
569
|
+
this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
|
|
638
570
|
|
|
639
|
-
// Build VPC Endpoints based on ownership decisions
|
|
640
|
-
|
|
641
|
-
// endpoints (which give in-VPC functions private AWS access) would be
|
|
642
|
-
// wasted spend — skip them along with the NAT Gateway.
|
|
643
|
-
if (publicDbConnectivity) {
|
|
644
|
-
console.log(
|
|
645
|
-
' ⊝ VPC Endpoints skipped (database.postgres.connectivity=public — Lambda is not VPC-attached)'
|
|
646
|
-
);
|
|
647
|
-
} else {
|
|
648
|
-
this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, decisions.securityGroup, appDefinition, discoveredResources, result);
|
|
649
|
-
}
|
|
571
|
+
// Build VPC Endpoints based on ownership decisions
|
|
572
|
+
this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, decisions.securityGroup, appDefinition, discoveredResources, result);
|
|
650
573
|
|
|
651
|
-
// Set VPC_ENABLED environment variable
|
|
652
|
-
|
|
653
|
-
// report VPC_ENABLED=false — otherwise /health would misreport isInVpc:true.
|
|
654
|
-
result.environment.VPC_ENABLED = publicDbConnectivity ? 'false' : 'true';
|
|
574
|
+
// Set VPC_ENABLED environment variable
|
|
575
|
+
result.environment.VPC_ENABLED = 'true';
|
|
655
576
|
|
|
656
577
|
console.log(`\n[${this.name}] ✅ VPC infrastructure built successfully`);
|
|
657
578
|
console.log(` - VPC ID: ${result.vpcId || 'from discovery'}`);
|
|
658
579
|
console.log(` - Subnets: ${result.vpcConfig.subnetIds.length}`);
|
|
659
580
|
console.log(` - Security Groups: ${result.vpcConfig.securityGroupIds.length}`);
|
|
660
581
|
|
|
661
|
-
if (publicDbConnectivity) {
|
|
662
|
-
// Do NOT attach the Lambda to the VPC: leaving vpcConfig null means the
|
|
663
|
-
// composer never sets provider.vpc, so the function keeps normal
|
|
664
|
-
// internet egress. The Aurora subnets/DB subnet group built above are
|
|
665
|
-
// still emitted (AuroraBuilder consumes discoveredResources.publicSubnetId*),
|
|
666
|
-
// and the Lambda reaches Aurora over its public endpoint + TLS.
|
|
667
|
-
console.log(
|
|
668
|
-
' ⊝ Lambda VPC attachment skipped (database.postgres.connectivity=public — provider.vpc will be unset)'
|
|
669
|
-
);
|
|
670
|
-
result.vpcConfig = null;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
582
|
return result;
|
|
674
583
|
}
|
|
675
584
|
|
|
@@ -55,26 +55,10 @@
|
|
|
55
55
|
*
|
|
56
56
|
* @property {Object} [config] - Configuration preferences
|
|
57
57
|
* @property {'aurora-postgresql'|'aurora-mysql'} [config.engine] - Database engine
|
|
58
|
-
* @property {number} [config.minCapacity] - Min serverless capacity
|
|
59
|
-
*
|
|
60
|
-
* requires Aurora PostgreSQL 13.15+/14.12+/15.7+/16.3+, and the default 15.13
|
|
61
|
-
* qualifies) OR a value in [0.5, 128]. Values in (0, 0.5) are invalid. (default: 0.5)
|
|
62
|
-
* @property {number} [config.maxCapacity] - Max serverless capacity in ACU, range
|
|
63
|
-
* [0.5, 128] (default: 4)
|
|
64
|
-
* @property {number} [config.secondsUntilAutoPause] - Idle window before a
|
|
65
|
-
* scale-to-zero cluster (minCapacity: 0) pauses to 0 ACU. Integer seconds in
|
|
66
|
-
* [300, 86400]. Only applied when minCapacity === 0 (default: 300)
|
|
58
|
+
* @property {number} [config.minCapacity] - Min serverless capacity (default: 0.5)
|
|
59
|
+
* @property {number} [config.maxCapacity] - Max serverless capacity (default: 1)
|
|
67
60
|
* @property {string} [config.database] - Database name (default: 'frigg')
|
|
68
61
|
* @property {boolean} [config.publiclyAccessible] - Public access (default: false)
|
|
69
|
-
* @property {'vpc'|'public'} [config.connectivity] - Lambda↔Aurora connectivity
|
|
70
|
-
* mode. `'vpc'` (default): Aurora in private subnets, Lambda attached to the VPC,
|
|
71
|
-
* ingress from the Lambda security group (needs a NAT for external egress).
|
|
72
|
-
* `'public'` (NAT-free): Aurora in public subnets with a public endpoint, Lambda
|
|
73
|
-
* left OUTSIDE the VPC (no NAT Gateway, no VPC endpoints), ingress opened to
|
|
74
|
-
* `allowedCidrs`, TLS enforced (sslmode=require). (default: 'vpc')
|
|
75
|
-
* @property {string[]} [config.allowedCidrs] - CIDR blocks allowed to reach Aurora
|
|
76
|
-
* on 5432 when connectivity is 'public'. One ingress rule is emitted per CIDR.
|
|
77
|
-
* (default: ['0.0.0.0/0'] — narrow this for production)
|
|
78
62
|
* @property {boolean} [config.autoCreateCredentials] - Auto-create credentials in Secrets Manager
|
|
79
63
|
*/
|
|
80
64
|
|
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.
|
|
4
|
+
"version": "2.0.0--canary.643.5ac10b7.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.
|
|
30
|
-
"@friggframework/schemas": "2.0.0--canary.
|
|
31
|
-
"@friggframework/test": "2.0.0--canary.
|
|
29
|
+
"@friggframework/core": "2.0.0--canary.643.5ac10b7.0",
|
|
30
|
+
"@friggframework/schemas": "2.0.0--canary.643.5ac10b7.0",
|
|
31
|
+
"@friggframework/test": "2.0.0--canary.643.5ac10b7.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.
|
|
60
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
59
|
+
"@friggframework/eslint-config": "2.0.0--canary.643.5ac10b7.0",
|
|
60
|
+
"@friggframework/prettier-config": "2.0.0--canary.643.5ac10b7.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": "5ac10b78dec8cf766dd80584f201c7e132aeee12"
|
|
93
93
|
}
|
|
@@ -1,356 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ADR-033: Aurora Serverless v2 scale-to-zero + NAT-free public connectivity
|
|
3
|
-
*
|
|
4
|
-
* These tests assert on the GENERATED CloudFormation template (via the full
|
|
5
|
-
* composer) plus the AuroraBuilder validator. They are deterministic and require
|
|
6
|
-
* neither a live AWS account nor Prisma client generation:
|
|
7
|
-
* - Validator cases call AuroraBuilder.validate() directly (pure, no I/O).
|
|
8
|
-
* - Template cases use vpc.management='create-new' + database.postgres
|
|
9
|
-
* management='managed', which resolve to STACK ownership without depending on
|
|
10
|
-
* any discovered AWS resource, so cloud discovery returning empty is fine.
|
|
11
|
-
*
|
|
12
|
-
* Both new capabilities are opt-in and default-off; the final describe block
|
|
13
|
-
* guards the no-regression promise: a default (vpc, no minCapacity) definition
|
|
14
|
-
* composes the same template as before.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const { composeServerlessDefinition } = require('../infrastructure-composer');
|
|
18
|
-
const { AuroraBuilder } = require('../domains/database/aurora-builder');
|
|
19
|
-
|
|
20
|
-
// Shared: an app definition that creates a fresh VPC + a Frigg-owned Aurora
|
|
21
|
-
// cluster in-stack, with overridable postgres config.
|
|
22
|
-
function makeApp(postgresOverrides = {}) {
|
|
23
|
-
return {
|
|
24
|
-
name: 'adr033-app',
|
|
25
|
-
provider: 'aws',
|
|
26
|
-
region: 'us-east-1',
|
|
27
|
-
integrations: [],
|
|
28
|
-
vpc: { enable: true, management: 'create-new' },
|
|
29
|
-
database: {
|
|
30
|
-
postgres: {
|
|
31
|
-
enable: true,
|
|
32
|
-
management: 'managed',
|
|
33
|
-
...postgresOverrides,
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function findResources(template, predicate) {
|
|
40
|
-
return Object.entries(template.resources.Resources).filter(([, r]) => predicate(r));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
|
|
44
|
-
beforeAll(() => {
|
|
45
|
-
process.env.AWS_REGION = 'us-east-1';
|
|
46
|
-
// Intentionally NOT setting FRIGG_SKIP_AWS_DISCOVERY — the builders must
|
|
47
|
-
// execute. create-new/managed resolve to STACK without needing discovery.
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterAll(() => {
|
|
51
|
-
delete process.env.AWS_REGION;
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------
|
|
55
|
-
// Validator
|
|
56
|
-
// ---------------------------------------------------------------------
|
|
57
|
-
describe('validator (AuroraBuilder.validate)', () => {
|
|
58
|
-
const build = new AuroraBuilder();
|
|
59
|
-
// ValidationResult exposes hasErrors(); "valid" means no errors.
|
|
60
|
-
// vpc.enable defaults to true so connectivity:'public' cases don't trip the
|
|
61
|
-
// "public requires vpc.enable" rule; pass appOverrides to change it.
|
|
62
|
-
const validateResult = (postgres, appOverrides = {}) =>
|
|
63
|
-
build.validate({
|
|
64
|
-
vpc: { enable: true },
|
|
65
|
-
database: { postgres: { enable: true, ...postgres } },
|
|
66
|
-
...appOverrides,
|
|
67
|
-
});
|
|
68
|
-
const isValid = (postgres, appOverrides) => !validateResult(postgres, appOverrides).hasErrors();
|
|
69
|
-
|
|
70
|
-
test('accepts minCapacity: 0 (scale-to-zero)', () => {
|
|
71
|
-
expect(isValid({ minCapacity: 0 })).toBe(true);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('rejects minCapacity: 0.3 (inside the forbidden (0, 0.5) band)', () => {
|
|
75
|
-
const r = validateResult({ minCapacity: 0.3 });
|
|
76
|
-
expect(r.hasErrors()).toBe(true);
|
|
77
|
-
expect(r.errors.join(' ')).toMatch(/minCapacity must be 0 \(scale-to-zero\) or between 0\.5 and 128/);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test('accepts minCapacity: 0.5 and minCapacity: 64', () => {
|
|
81
|
-
expect(isValid({ minCapacity: 0.5 })).toBe(true);
|
|
82
|
-
expect(isValid({ minCapacity: 64 })).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('rejects secondsUntilAutoPause: 100 (below 300)', () => {
|
|
86
|
-
const r = validateResult({ minCapacity: 0, secondsUntilAutoPause: 100 });
|
|
87
|
-
expect(r.hasErrors()).toBe(true);
|
|
88
|
-
expect(r.errors.join(' ')).toMatch(/secondsUntilAutoPause must be an integer between 300 and 86400/);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('accepts secondsUntilAutoPause: 3600', () => {
|
|
92
|
-
expect(isValid({ minCapacity: 0, secondsUntilAutoPause: 3600 })).toBe(true);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("accepts connectivity: 'public'", () => {
|
|
96
|
-
expect(isValid({ connectivity: 'public' })).toBe(true);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("rejects connectivity: 'nope'", () => {
|
|
100
|
-
const r = validateResult({ connectivity: 'nope' });
|
|
101
|
-
expect(r.hasErrors()).toBe(true);
|
|
102
|
-
expect(r.errors.join(' ')).toMatch(/Invalid database\.postgres\.connectivity/);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('rejects non-array / non-CIDR allowedCidrs, accepts valid CIDRs', () => {
|
|
106
|
-
expect(isValid({ allowedCidrs: 'nope' })).toBe(false);
|
|
107
|
-
expect(isValid({ allowedCidrs: ['not-a-cidr'] })).toBe(false);
|
|
108
|
-
expect(isValid({ allowedCidrs: ['10.0.0.0/8', '203.0.113.5/32'] })).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test('rejects out-of-range CIDR octets/prefix (numeric, not just shape)', () => {
|
|
112
|
-
expect(isValid({ allowedCidrs: ['999.999.999.999/99'] })).toBe(false);
|
|
113
|
-
expect(isValid({ allowedCidrs: ['10.0.0.0/33'] })).toBe(false);
|
|
114
|
-
expect(isValid({ allowedCidrs: ['256.1.1.1/24'] })).toBe(false);
|
|
115
|
-
expect(isValid({ allowedCidrs: ['0.0.0.0/0'] })).toBe(true);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('rejects empty allowedCidrs in public mode (no silent full-internet fallback)', () => {
|
|
119
|
-
const r = validateResult({ connectivity: 'public', allowedCidrs: [] });
|
|
120
|
-
expect(r.hasErrors()).toBe(true);
|
|
121
|
-
expect(r.errors.join(' ')).toMatch(/allowedCidrs is empty with connectivity="public"/);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test('rejects minCapacity > maxCapacity', () => {
|
|
125
|
-
const r = validateResult({ minCapacity: 8, maxCapacity: 4 });
|
|
126
|
-
expect(r.hasErrors()).toBe(true);
|
|
127
|
-
expect(r.errors.join(' ')).toMatch(/minCapacity \(8\) must be <= maxCapacity \(4\)/);
|
|
128
|
-
// equal is fine
|
|
129
|
-
expect(isValid({ minCapacity: 4, maxCapacity: 4 })).toBe(true);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("connectivity:'public' requires vpc.enable=true", () => {
|
|
133
|
-
const r = validateResult({ connectivity: 'public' }, { vpc: { enable: false } });
|
|
134
|
-
expect(r.hasErrors()).toBe(true);
|
|
135
|
-
expect(r.errors.join(' ')).toMatch(/connectivity="public" requires vpc\.enable=true/);
|
|
136
|
-
// With vpc.enable true (default helper), it's valid
|
|
137
|
-
expect(isValid({ connectivity: 'public' })).toBe(true);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('warns when secondsUntilAutoPause set with minCapacity !== 0', () => {
|
|
141
|
-
const r = validateResult({ minCapacity: 0.5, secondsUntilAutoPause: 3600 });
|
|
142
|
-
expect(r.hasErrors()).toBe(false);
|
|
143
|
-
expect(r.warnings.join(' ')).toMatch(/secondsUntilAutoPause is ignored unless minCapacity is 0/);
|
|
144
|
-
// No such warning when minCapacity is 0
|
|
145
|
-
const r0 = validateResult({ minCapacity: 0, secondsUntilAutoPause: 3600 });
|
|
146
|
-
expect(r0.warnings.join(' ')).not.toMatch(/ignored unless minCapacity is 0/);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("warns (not errors) for connectivity:'public' with discover/use-existing management", () => {
|
|
150
|
-
const rDiscover = validateResult({ connectivity: 'public', management: 'discover' });
|
|
151
|
-
expect(rDiscover.hasErrors()).toBe(false);
|
|
152
|
-
expect(rDiscover.warnings.join(' ')).toMatch(/assumes the EXISTING Aurora cluster is already publicly accessible/);
|
|
153
|
-
|
|
154
|
-
const rUseExisting = validateResult({
|
|
155
|
-
connectivity: 'public',
|
|
156
|
-
management: 'use-existing',
|
|
157
|
-
endpoint: 'db.example.com',
|
|
158
|
-
});
|
|
159
|
-
expect(rUseExisting.warnings.join(' ')).toMatch(/only affects TLS/);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('warns (does not fail) when minCapacity:0 with an older pinned engine version', () => {
|
|
163
|
-
const r = validateResult({ minCapacity: 0, engineVersion: '15.4' });
|
|
164
|
-
expect(r.hasErrors()).toBe(false); // warning, not error
|
|
165
|
-
expect(r.warnings.join(' ')).toMatch(/may not support .*scale-to-zero/);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test('does not warn about engine when minCapacity:0 on a capable version', () => {
|
|
169
|
-
const r = validateResult({ minCapacity: 0, engineVersion: '15.13' });
|
|
170
|
-
expect(r.warnings.join(' ')).not.toMatch(/may not support .*scale-to-zero/);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// ---------------------------------------------------------------------
|
|
175
|
-
// Scale-to-zero (template shape)
|
|
176
|
-
// ---------------------------------------------------------------------
|
|
177
|
-
describe('scale-to-zero (minCapacity: 0)', () => {
|
|
178
|
-
test('MinCapacity is exactly 0 and SecondsUntilAutoPause defaults to 300', async () => {
|
|
179
|
-
const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
|
|
180
|
-
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
181
|
-
|
|
182
|
-
// Mutation guard: with the old `|| 0.5` bug this would be 0.5, not 0.
|
|
183
|
-
expect(scaling.MinCapacity).toBe(0);
|
|
184
|
-
expect(scaling.MinCapacity).not.toBe(0.5);
|
|
185
|
-
expect(scaling.SecondsUntilAutoPause).toBe(300);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('SecondsUntilAutoPause honors a custom value', async () => {
|
|
189
|
-
const t = await composeServerlessDefinition(makeApp({ minCapacity: 0, secondsUntilAutoPause: 1800 }));
|
|
190
|
-
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
191
|
-
expect(scaling.MinCapacity).toBe(0);
|
|
192
|
-
expect(scaling.SecondsUntilAutoPause).toBe(1800);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('MaxCapacity defaults to 4 and is preserved with scale-to-zero', async () => {
|
|
196
|
-
const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
|
|
197
|
-
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
198
|
-
expect(scaling.MaxCapacity).toBe(4);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// ---------------------------------------------------------------------
|
|
203
|
-
// Public connectivity (template shape)
|
|
204
|
-
// ---------------------------------------------------------------------
|
|
205
|
-
describe("connectivity: 'public'", () => {
|
|
206
|
-
test('Aurora ingress uses CidrIp — one rule per allowedCidr — not SourceSecurityGroupId', async () => {
|
|
207
|
-
const t = await composeServerlessDefinition(
|
|
208
|
-
makeApp({ connectivity: 'public', allowedCidrs: ['10.1.0.0/16', '203.0.113.7/32'] })
|
|
209
|
-
);
|
|
210
|
-
const ingress = findResources(
|
|
211
|
-
t,
|
|
212
|
-
(r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
|
|
213
|
-
);
|
|
214
|
-
expect(ingress).toHaveLength(2);
|
|
215
|
-
const cidrs = ingress.map(([, r]) => r.Properties.CidrIp).sort();
|
|
216
|
-
expect(cidrs).toEqual(['10.1.0.0/16', '203.0.113.7/32']);
|
|
217
|
-
ingress.forEach(([, r]) => {
|
|
218
|
-
expect(r.Properties.CidrIp).toBeDefined();
|
|
219
|
-
expect(r.Properties.SourceSecurityGroupId).toBeUndefined();
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test('allowedCidrs defaults to 0.0.0.0/0 when omitted', async () => {
|
|
224
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
225
|
-
const ingress = findResources(
|
|
226
|
-
t,
|
|
227
|
-
(r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
|
|
228
|
-
);
|
|
229
|
-
expect(ingress).toHaveLength(1);
|
|
230
|
-
expect(ingress[0][1].Properties.CidrIp).toBe('0.0.0.0/0');
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test('Aurora instance is PubliclyAccessible and cluster sits in public subnets', async () => {
|
|
234
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
235
|
-
expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(true);
|
|
236
|
-
expect(t.resources.Resources.FriggDBSubnetGroup.Properties.SubnetIds).toEqual([
|
|
237
|
-
{ Ref: 'FriggPublicSubnet' },
|
|
238
|
-
{ Ref: 'FriggPublicSubnet2' },
|
|
239
|
-
]);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test('NO NAT Gateway resource is emitted', async () => {
|
|
243
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
244
|
-
const nats = findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway');
|
|
245
|
-
expect(nats).toHaveLength(0);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test('public subnets get an IGW default route + route table + both associations (create-new VPC)', async () => {
|
|
249
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
250
|
-
const R = t.resources.Resources;
|
|
251
|
-
|
|
252
|
-
// Public route table
|
|
253
|
-
expect(R.FriggPublicRouteTable).toBeDefined();
|
|
254
|
-
expect(R.FriggPublicRouteTable.Type).toBe('AWS::EC2::RouteTable');
|
|
255
|
-
|
|
256
|
-
// 0.0.0.0/0 -> Internet Gateway default route
|
|
257
|
-
const igwRoutes = findResources(
|
|
258
|
-
t,
|
|
259
|
-
(r) =>
|
|
260
|
-
r.Type === 'AWS::EC2::Route' &&
|
|
261
|
-
r.Properties.DestinationCidrBlock === '0.0.0.0/0' &&
|
|
262
|
-
r.Properties.GatewayId &&
|
|
263
|
-
r.Properties.GatewayId.Ref === 'FriggInternetGateway'
|
|
264
|
-
);
|
|
265
|
-
expect(igwRoutes).toHaveLength(1);
|
|
266
|
-
|
|
267
|
-
// Both public subnet associations
|
|
268
|
-
expect(R.FriggPublicSubnet1RouteTableAssociation).toBeDefined();
|
|
269
|
-
expect(R.FriggPublicSubnet2RouteTableAssociation).toBeDefined();
|
|
270
|
-
expect(R.FriggPublicSubnet1RouteTableAssociation.Properties.RouteTableId).toEqual({
|
|
271
|
-
Ref: 'FriggPublicRouteTable',
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
// And there must be NO NAT route (that would imply a NAT default route)
|
|
275
|
-
const natRoutes = findResources(
|
|
276
|
-
t,
|
|
277
|
-
(r) => r.Type === 'AWS::EC2::Route' && r.Properties.NatGatewayId
|
|
278
|
-
);
|
|
279
|
-
expect(natRoutes).toHaveLength(0);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test('VPC_ENABLED is false in public mode (Lambda not VPC-attached)', async () => {
|
|
283
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
284
|
-
expect(t.provider.environment.VPC_ENABLED).toBe('false');
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
test('Lambda is NOT attached to the VPC (provider.vpc unset)', async () => {
|
|
288
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
289
|
-
expect(t.provider.vpc).toBeUndefined();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test('DATABASE_URL enforces TLS (sslmode=require)', async () => {
|
|
293
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
294
|
-
const url = t.provider.environment.DATABASE_URL;
|
|
295
|
-
expect(url['Fn::Sub'][0]).toContain('sslmode=require');
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
test('combines with scale-to-zero: $0-idle NAT-free Aurora', async () => {
|
|
299
|
-
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public', minCapacity: 0 }));
|
|
300
|
-
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
301
|
-
expect(scaling.MinCapacity).toBe(0);
|
|
302
|
-
expect(scaling.SecondsUntilAutoPause).toBe(300);
|
|
303
|
-
expect(findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway')).toHaveLength(0);
|
|
304
|
-
expect(t.provider.vpc).toBeUndefined();
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// ---------------------------------------------------------------------
|
|
309
|
-
// No-regression: default (vpc) connectivity, no new fields
|
|
310
|
-
// ---------------------------------------------------------------------
|
|
311
|
-
describe("default connectivity: 'vpc' (no regression)", () => {
|
|
312
|
-
test('MinCapacity defaults to 0.5 with no SecondsUntilAutoPause', async () => {
|
|
313
|
-
const t = await composeServerlessDefinition(makeApp());
|
|
314
|
-
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
315
|
-
expect(scaling.MinCapacity).toBe(0.5);
|
|
316
|
-
expect(scaling.MaxCapacity).toBe(4);
|
|
317
|
-
expect(scaling.SecondsUntilAutoPause).toBeUndefined();
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test('Aurora ingress uses SourceSecurityGroupId (Lambda SG), not CidrIp', async () => {
|
|
321
|
-
const t = await composeServerlessDefinition(makeApp());
|
|
322
|
-
const ingress = findResources(
|
|
323
|
-
t,
|
|
324
|
-
(r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
|
|
325
|
-
);
|
|
326
|
-
expect(ingress).toHaveLength(1);
|
|
327
|
-
expect(ingress[0][1].Properties.SourceSecurityGroupId).toEqual({ Ref: 'FriggLambdaSecurityGroup' });
|
|
328
|
-
expect(ingress[0][1].Properties.CidrIp).toBeUndefined();
|
|
329
|
-
// Logical ID unchanged for the vpc path
|
|
330
|
-
expect(ingress[0][0]).toBe('FriggAuroraIngressRule');
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
test('Aurora instance is not publicly accessible by default', async () => {
|
|
334
|
-
const t = await composeServerlessDefinition(makeApp());
|
|
335
|
-
expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(false);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test('Lambda IS attached to the VPC (provider.vpc set)', async () => {
|
|
339
|
-
const t = await composeServerlessDefinition(makeApp());
|
|
340
|
-
expect(t.provider.vpc).toBeDefined();
|
|
341
|
-
expect(t.provider.vpc.subnetIds).toBeDefined();
|
|
342
|
-
expect(t.provider.vpc.securityGroupIds).toBeDefined();
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
test('DATABASE_URL does not add sslmode in vpc mode', async () => {
|
|
346
|
-
const t = await composeServerlessDefinition(makeApp());
|
|
347
|
-
const url = t.provider.environment.DATABASE_URL;
|
|
348
|
-
expect(url['Fn::Sub'][0]).not.toContain('sslmode=require');
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test('VPC_ENABLED stays true in vpc mode', async () => {
|
|
352
|
-
const t = await composeServerlessDefinition(makeApp());
|
|
353
|
-
expect(t.provider.environment.VPC_ENABLED).toBe('true');
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
});
|