@friggframework/devtools 2.0.0--canary.461.84ff4f5.0 → 2.0.0--canary.461.ec909cf.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.
@@ -356,13 +356,31 @@ class AWSDiscovery {
356
356
  console.warn(
357
357
  'Please create a public subnet or use VPC endpoints instead'
358
358
  );
359
- return null;
359
+ return { primary: null, secondary: null, all: [] };
360
360
  }
361
361
 
362
+ // Sort by AZ to get subnets in different zones
363
+ const sortedByAz = publicSubnets.sort((a, b) =>
364
+ a.AvailabilityZone.localeCompare(b.AvailabilityZone)
365
+ );
366
+
367
+ // Get subnets from different AZs if possible
368
+ const primary = sortedByAz[0];
369
+ const secondary = sortedByAz.find(s => s.AvailabilityZone !== primary.AvailabilityZone)
370
+ || sortedByAz[1]
371
+ || sortedByAz[0]; // Fallback to same subnet if only one exists
372
+
362
373
  console.log(
363
- `Found ${publicSubnets.length} public subnets, using ${publicSubnets[0].SubnetId} for NAT Gateway`
374
+ `Found ${publicSubnets.length} public subnet(s)`
364
375
  );
365
- return publicSubnets[0];
376
+ console.log(` Primary (NAT Gateway): ${primary.SubnetId} (${primary.AvailabilityZone})`);
377
+ if (secondary.SubnetId !== primary.SubnetId) {
378
+ console.log(` Secondary (Aurora): ${secondary.SubnetId} (${secondary.AvailabilityZone})`);
379
+ } else {
380
+ console.warn(` ⚠️ Only one public subnet found - Aurora public deployments will require creating a second subnet`);
381
+ }
382
+
383
+ return { primary, secondary, all: publicSubnets };
366
384
  } catch (error) {
367
385
  console.error('Error finding public subnets:', error);
368
386
  throw error;
@@ -1147,10 +1165,10 @@ class AWSDiscovery {
1147
1165
  .join(', ')}`
1148
1166
  );
1149
1167
 
1150
- const publicSubnet = await this.findPublicSubnets(vpc.VpcId);
1151
- if (publicSubnet) {
1168
+ const publicSubnets = await this.findPublicSubnets(vpc.VpcId);
1169
+ if (publicSubnets.primary) {
1152
1170
  console.log(
1153
- `\n✅ Found public subnet for NAT Gateway: ${publicSubnet.SubnetId}`
1171
+ `\n✅ Found public subnet(s) for NAT Gateway and Aurora`
1154
1172
  );
1155
1173
  } else {
1156
1174
  console.log(
@@ -1221,15 +1239,15 @@ class AWSDiscovery {
1221
1239
 
1222
1240
  const subnet1IsActuallyPrivate = privateSubnets[0]
1223
1241
  ? await this.isSubnetPrivate(
1224
- privateSubnets[0].SubnetId,
1225
- privateSubnets[0].VpcId || vpc.VpcId
1226
- )
1242
+ privateSubnets[0].SubnetId,
1243
+ privateSubnets[0].VpcId || vpc.VpcId
1244
+ )
1227
1245
  : false;
1228
1246
  const subnet2IsActuallyPrivate = privateSubnets[1]
1229
1247
  ? await this.isSubnetPrivate(
1230
- privateSubnets[1].SubnetId,
1231
- privateSubnets[1].VpcId || vpc.VpcId
1232
- )
1248
+ privateSubnets[1].SubnetId,
1249
+ privateSubnets[1].VpcId || vpc.VpcId
1250
+ )
1233
1251
  : subnet1IsActuallyPrivate;
1234
1252
 
1235
1253
  const subnetStatus = {
@@ -1265,16 +1283,19 @@ class AWSDiscovery {
1265
1283
  .join(', ')}`
1266
1284
  );
1267
1285
  console.log(
1268
- ` NAT Subnet: ${
1269
- publicSubnet?.SubnetId || 'None (needs creation)'
1286
+ ` NAT Subnet: ${publicSubnets.primary?.SubnetId || 'None (needs creation)'
1270
1287
  }`
1271
1288
  );
1289
+ if (publicSubnets.secondary && publicSubnets.secondary.SubnetId !== publicSubnets.primary?.SubnetId) {
1290
+ console.log(
1291
+ ` Aurora Public Subnet 2: ${publicSubnets.secondary.SubnetId}`
1292
+ );
1293
+ }
1272
1294
  console.log(
1273
1295
  ` NAT Gateway: ${natGatewayId || 'None (will be created)'}`
1274
1296
  );
1275
1297
  console.log(
1276
- ` Elastic IP: ${
1277
- elasticIpAllocationId || 'None (will be allocated)'
1298
+ ` Elastic IP: ${elasticIpAllocationId || 'None (will be allocated)'
1278
1299
  }`
1279
1300
  );
1280
1301
  if (subnetStatus.requiresConversion) {
@@ -1289,7 +1310,9 @@ class AWSDiscovery {
1289
1310
  privateSubnetId1: privateSubnets[0]?.SubnetId,
1290
1311
  privateSubnetId2:
1291
1312
  privateSubnets[1]?.SubnetId || privateSubnets[0]?.SubnetId,
1292
- publicSubnetId: publicSubnet?.SubnetId || null,
1313
+ publicSubnetId: publicSubnets.primary?.SubnetId || null, // Keep for NAT Gateway backward compat
1314
+ publicSubnetId1: publicSubnets.primary?.SubnetId || null,
1315
+ publicSubnetId2: publicSubnets.secondary?.SubnetId || null,
1293
1316
  privateRouteTableId: routeTable.RouteTableId,
1294
1317
  defaultKmsKeyId: kmsKeyArn,
1295
1318
  kmsAliasExists: kmsAliasExists,
@@ -1607,8 +1630,8 @@ class AWSDiscovery {
1607
1630
 
1608
1631
  throw new Error(
1609
1632
  `No private subnets found in VPC ${vpcId}. ` +
1610
- `Found ${publicSubnets.length} public subnets. ` +
1611
- `Lambda requires private subnets. Enable selfHeal or create private subnets.`
1633
+ `Found ${publicSubnets.length} public subnets. ` +
1634
+ `Lambda requires private subnets. Enable selfHeal or create private subnets.`
1612
1635
  );
1613
1636
  }
1614
1637
 
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Aurora PostgreSQL Builder
3
+ *
4
+ * Domain Layer - Hexagonal Architecture
5
+ *
6
+ * Responsible for:
7
+ * - Aurora Serverless v2 cluster creation or discovery
8
+ * - Database subnet groups
9
+ * - Database security groups
10
+ * - Secrets Manager integration for credentials
11
+ * - Database connection environment variables
12
+ *
13
+ * Supports three management modes:
14
+ * 1. create-new: Creates new Aurora cluster
15
+ * 2. use-existing: Uses explicitly provided cluster
16
+ * 3. discover (default): Discovers existing cluster
17
+ */
18
+
19
+ const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
20
+
21
+ class AuroraBuilder extends InfrastructureBuilder {
22
+ constructor() {
23
+ super();
24
+ this.name = 'AuroraBuilder';
25
+ }
26
+
27
+ shouldExecute(appDefinition) {
28
+ // Skip Aurora in local mode (when FRIGG_SKIP_AWS_DISCOVERY is set)
29
+ // Aurora is an AWS-specific service that should only be created in production
30
+ if (process.env.FRIGG_SKIP_AWS_DISCOVERY === 'true') {
31
+ return false;
32
+ }
33
+
34
+ return appDefinition.database?.postgres?.enable === true;
35
+ }
36
+
37
+ getDependencies() {
38
+ return ['VpcBuilder']; // Aurora requires VPC to be configured first
39
+ }
40
+
41
+ validate(appDefinition) {
42
+ const result = new ValidationResult();
43
+
44
+ if (!appDefinition.database?.postgres) {
45
+ result.addError('PostgreSQL database configuration is missing');
46
+ return result;
47
+ }
48
+
49
+ const dbConfig = appDefinition.database.postgres;
50
+
51
+ // Validate management mode
52
+ const validModes = ['discover', 'create-new', 'use-existing'];
53
+ const management = dbConfig.management || 'discover';
54
+ if (!validModes.includes(management)) {
55
+ result.addError(`Invalid database.postgres.management: "${management}"`);
56
+ }
57
+
58
+ // Validate use-existing requirements
59
+ if (management === 'use-existing' && !dbConfig.endpoint) {
60
+ result.addError('database.postgres.endpoint is required when management="use-existing"');
61
+ }
62
+
63
+ // Validate capacity settings
64
+ if (dbConfig.minCapacity !== undefined && (dbConfig.minCapacity < 0.5 || dbConfig.minCapacity > 128)) {
65
+ result.addError('database.postgres.minCapacity must be between 0.5 and 128');
66
+ }
67
+ if (dbConfig.maxCapacity !== undefined && (dbConfig.maxCapacity < 0.5 || dbConfig.maxCapacity > 128)) {
68
+ result.addError('database.postgres.maxCapacity must be between 0.5 and 128');
69
+ }
70
+
71
+ // Warn about public accessibility in production
72
+ if (dbConfig.publiclyAccessible === true) {
73
+ result.addWarning('database.postgres.publiclyAccessible=true is not recommended for production');
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Build Aurora infrastructure
81
+ */
82
+ async build(appDefinition, discoveredResources) {
83
+ console.log(`\n[${this.name}] Configuring Aurora PostgreSQL...`);
84
+
85
+ const dbConfig = appDefinition.database.postgres;
86
+ const management = dbConfig.management || 'discover';
87
+
88
+ console.log(` PostgreSQL Management Mode: ${management}`);
89
+
90
+ const result = {
91
+ resources: {},
92
+ iamStatements: [],
93
+ environment: {},
94
+ };
95
+
96
+ // Handle different management modes
97
+ switch (management) {
98
+ case 'create-new':
99
+ await this.createNewAurora(appDefinition, discoveredResources, result);
100
+ break;
101
+ case 'use-existing':
102
+ await this.useExistingAurora(appDefinition, discoveredResources, result);
103
+ break;
104
+ case 'discover':
105
+ default:
106
+ await this.discoverAurora(appDefinition, discoveredResources, result);
107
+ break;
108
+ }
109
+
110
+ console.log(`[${this.name}] ✅ Aurora PostgreSQL configuration completed`);
111
+ return result;
112
+ }
113
+
114
+ /**
115
+ * Create new Aurora cluster
116
+ */
117
+ async createNewAurora(appDefinition, discoveredResources, result) {
118
+ console.log(' Creating new Aurora Serverless v2 cluster...');
119
+
120
+ const dbConfig = appDefinition.database.postgres;
121
+ const publiclyAccessible = dbConfig.publiclyAccessible === true;
122
+
123
+ // Get subnet IDs for DB Subnet Group
124
+ const subnetIds = publiclyAccessible
125
+ ? [discoveredResources.publicSubnetId1, discoveredResources.publicSubnetId2]
126
+ : [discoveredResources.privateSubnetId1, discoveredResources.privateSubnetId2];
127
+
128
+ if (!subnetIds[0] || !subnetIds[1]) {
129
+ throw new Error(
130
+ `Aurora requires 2 ${publiclyAccessible ? 'public' : 'private'} subnets in different AZs. ` +
131
+ 'Ensure VPC is configured correctly.'
132
+ );
133
+ }
134
+
135
+ // Database Subnet Group
136
+ result.resources.FriggDBSubnetGroup = {
137
+ Type: 'AWS::RDS::DBSubnetGroup',
138
+ Properties: {
139
+ DBSubnetGroupName: '${self:service}-${self:provider.stage}-db-subnet-group',
140
+ DBSubnetGroupDescription: 'Subnet group for Frigg Aurora cluster',
141
+ SubnetIds: subnetIds,
142
+ Tags: [
143
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-subnet' },
144
+ { Key: 'ManagedBy', Value: 'Frigg' },
145
+ ],
146
+ },
147
+ };
148
+
149
+ // Database Credentials Secret
150
+ result.resources.FriggDBSecret = {
151
+ Type: 'AWS::SecretsManager::Secret',
152
+ Properties: {
153
+ Name: '${self:service}-${self:provider.stage}-db-credentials',
154
+ Description: 'Aurora database credentials',
155
+ GenerateSecretString: {
156
+ SecretStringTemplate: JSON.stringify({ username: dbConfig.username || 'postgres' }),
157
+ GenerateStringKey: 'password',
158
+ PasswordLength: 32,
159
+ ExcludeCharacters: '"@/\\',
160
+ },
161
+ Tags: [
162
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-secret' },
163
+ { Key: 'ManagedBy', Value: 'Frigg' },
164
+ ],
165
+ },
166
+ };
167
+
168
+ // Aurora Cluster
169
+ result.resources.FriggAuroraCluster = {
170
+ Type: 'AWS::RDS::DBCluster',
171
+ DeletionPolicy: 'Snapshot',
172
+ Properties: {
173
+ Engine: 'aurora-postgresql',
174
+ EngineMode: 'provisioned',
175
+ EngineVersion: '15.5',
176
+ DatabaseName: dbConfig.database || 'frigg',
177
+ MasterUsername: {
178
+ 'Fn::Sub': '{{resolve:secretsmanager:${FriggDBSecret}:SecretString:username}}',
179
+ },
180
+ MasterUserPassword: {
181
+ 'Fn::Sub': '{{resolve:secretsmanager:${FriggDBSecret}:SecretString:password}}',
182
+ },
183
+ DBSubnetGroupName: { Ref: 'FriggDBSubnetGroup' },
184
+ VpcSecurityGroupIds: discoveredResources.vpcSecurityGroupIds || [
185
+ { Ref: 'FriggLambdaSecurityGroup' },
186
+ ],
187
+ PubliclyAccessible: publiclyAccessible,
188
+ ServerlessV2ScalingConfiguration: {
189
+ MinCapacity: dbConfig.minCapacity || 0.5,
190
+ MaxCapacity: dbConfig.maxCapacity || 1,
191
+ },
192
+ EnableHttpEndpoint: false,
193
+ BackupRetentionPeriod: 7,
194
+ PreferredBackupWindow: '03:00-04:00',
195
+ PreferredMaintenanceWindow: 'sun:04:00-sun:05:00',
196
+ Tags: [
197
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-aurora' },
198
+ { Key: 'ManagedBy', Value: 'Frigg' },
199
+ ],
200
+ },
201
+ };
202
+
203
+ // Aurora Instance
204
+ result.resources.FriggAuroraInstance = {
205
+ Type: 'AWS::RDS::DBInstance',
206
+ Properties: {
207
+ Engine: 'aurora-postgresql',
208
+ DBInstanceClass: 'db.serverless',
209
+ DBClusterIdentifier: { Ref: 'FriggAuroraCluster' },
210
+ PubliclyAccessible: publiclyAccessible,
211
+ Tags: [
212
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-aurora-instance' },
213
+ { Key: 'ManagedBy', Value: 'Frigg' },
214
+ ],
215
+ },
216
+ };
217
+
218
+ // Environment variables
219
+ result.environment.DATABASE_URL = this.buildDatabaseUrl(
220
+ { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Address'] },
221
+ { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Port'] },
222
+ dbConfig.database || 'frigg',
223
+ { Ref: 'FriggDBSecret' }
224
+ );
225
+
226
+ // IAM permissions for Secrets Manager
227
+ result.iamStatements.push({
228
+ Effect: 'Allow',
229
+ Action: ['secretsmanager:GetSecretValue'],
230
+ Resource: { Ref: 'FriggDBSecret' },
231
+ });
232
+
233
+ console.log(' ✅ Aurora Serverless v2 cluster resources created');
234
+ }
235
+
236
+ /**
237
+ * Use existing Aurora cluster
238
+ */
239
+ async useExistingAurora(appDefinition, discoveredResources, result) {
240
+ console.log(' Using existing Aurora cluster...');
241
+
242
+ const dbConfig = appDefinition.database.postgres;
243
+
244
+ if (!dbConfig.endpoint) {
245
+ throw new Error('database.postgres.endpoint is required when management="use-existing"');
246
+ }
247
+
248
+ // Set environment variables for existing cluster
249
+ result.environment.DATABASE_HOST = dbConfig.endpoint;
250
+ result.environment.DATABASE_PORT = String(dbConfig.port || 5432);
251
+ result.environment.DATABASE_NAME = dbConfig.database || 'frigg';
252
+ result.environment.DATABASE_USER = dbConfig.username || 'postgres';
253
+
254
+ console.log(` ✅ Using existing cluster: ${dbConfig.endpoint}`);
255
+ }
256
+
257
+ /**
258
+ * Discover existing Aurora cluster
259
+ */
260
+ async discoverAurora(appDefinition, discoveredResources, result) {
261
+ console.log(' Discovering Aurora cluster...');
262
+
263
+ if (!discoveredResources.auroraClusterEndpoint) {
264
+ throw new Error(
265
+ 'No Aurora cluster found in discovery mode. Set management to "create-new" or provide endpoint with "use-existing".'
266
+ );
267
+ }
268
+
269
+ console.log(` ✅ Using discovered Aurora cluster: ${discoveredResources.auroraClusterEndpoint}`);
270
+
271
+ // Use discovered cluster details
272
+ result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
273
+ result.environment.DATABASE_PORT = String(discoveredResources.auroraPort || 5432);
274
+
275
+ if (discoveredResources.databaseSecretArn) {
276
+ result.environment.DATABASE_SECRET_ARN = discoveredResources.databaseSecretArn;
277
+ result.iamStatements.push({
278
+ Effect: 'Allow',
279
+ Action: ['secretsmanager:GetSecretValue'],
280
+ Resource: discoveredResources.databaseSecretArn,
281
+ });
282
+ }
283
+
284
+ console.log(` ✅ Discovered cluster configuration complete`);
285
+ }
286
+
287
+ /**
288
+ * Build DATABASE_URL connection string
289
+ */
290
+ buildDatabaseUrl(host, port, database, secretRef) {
291
+ return {
292
+ 'Fn::Sub': [
293
+ `postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}`,
294
+ {
295
+ Username: `{{resolve:secretsmanager:${secretRef}:SecretString:username}}`,
296
+ Password: `{{resolve:secretsmanager:${secretRef}:SecretString:password}}`,
297
+ Host: host,
298
+ Port: port,
299
+ Database: database,
300
+ },
301
+ ],
302
+ };
303
+ }
304
+ }
305
+
306
+ module.exports = { AuroraBuilder };
307
+