@friggframework/devtools 2.0.0--canary.461.849e166.0 → 2.0.0--canary.474.aa465e4.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.
Files changed (32) hide show
  1. package/infrastructure/ARCHITECTURE.md +487 -0
  2. package/infrastructure/domains/database/aurora-builder.js +234 -57
  3. package/infrastructure/domains/database/aurora-builder.test.js +7 -2
  4. package/infrastructure/domains/database/aurora-resolver.js +210 -0
  5. package/infrastructure/domains/database/aurora-resolver.test.js +347 -0
  6. package/infrastructure/domains/database/migration-builder.js +256 -215
  7. package/infrastructure/domains/database/migration-builder.test.js +5 -111
  8. package/infrastructure/domains/database/migration-resolver.js +163 -0
  9. package/infrastructure/domains/database/migration-resolver.test.js +337 -0
  10. package/infrastructure/domains/integration/integration-builder.js +258 -84
  11. package/infrastructure/domains/integration/integration-resolver.js +170 -0
  12. package/infrastructure/domains/integration/integration-resolver.test.js +369 -0
  13. package/infrastructure/domains/networking/vpc-builder.js +856 -135
  14. package/infrastructure/domains/networking/vpc-builder.test.js +10 -6
  15. package/infrastructure/domains/networking/vpc-resolver.js +324 -0
  16. package/infrastructure/domains/networking/vpc-resolver.test.js +501 -0
  17. package/infrastructure/domains/security/kms-builder.js +179 -22
  18. package/infrastructure/domains/security/kms-resolver.js +96 -0
  19. package/infrastructure/domains/security/kms-resolver.test.js +216 -0
  20. package/infrastructure/domains/shared/base-resolver.js +186 -0
  21. package/infrastructure/domains/shared/base-resolver.test.js +305 -0
  22. package/infrastructure/domains/shared/cloudformation-discovery-v2.js +334 -0
  23. package/infrastructure/domains/shared/cloudformation-discovery.test.js +26 -1
  24. package/infrastructure/domains/shared/types/app-definition.js +205 -0
  25. package/infrastructure/domains/shared/types/discovery-result.js +106 -0
  26. package/infrastructure/domains/shared/types/discovery-result.test.js +258 -0
  27. package/infrastructure/domains/shared/types/index.js +46 -0
  28. package/infrastructure/domains/shared/types/resource-ownership.js +108 -0
  29. package/infrastructure/domains/shared/types/resource-ownership.test.js +101 -0
  30. package/package.json +6 -6
  31. package/infrastructure/REFACTOR.md +0 -532
  32. package/infrastructure/TRANSFORMATION-VISUAL.md +0 -239
@@ -19,6 +19,9 @@
19
19
  */
20
20
 
21
21
  const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
22
+ const VpcResourceResolver = require('./vpc-resolver');
23
+ const { createEmptyDiscoveryResult } = require('../shared/types/discovery-result');
24
+ const { ResourceOwnership } = require('../shared/types/resource-ownership');
22
25
 
23
26
  class VpcBuilder extends InfrastructureBuilder {
24
27
  constructor() {
@@ -97,11 +100,362 @@ class VpcBuilder extends InfrastructureBuilder {
97
100
  }
98
101
 
99
102
  /**
100
- * Build complete VPC infrastructure based on management mode
103
+ * Convert flat discovery result to structured discovery result
104
+ * Provides backwards compatibility for tests using old discovery format
105
+ *
106
+ * @param {Object} flatDiscovery - Flat discovery object
107
+ * @param {Object} appDefinition - App definition (used to detect stack-managed resources)
108
+ */
109
+ convertFlatDiscoveryToStructured(flatDiscovery, appDefinition = {}) {
110
+ const discovery = createEmptyDiscoveryResult();
111
+
112
+ if (!flatDiscovery) {
113
+ return discovery;
114
+ }
115
+
116
+ // Special case: managementMode='managed' + vpcIsolation='isolated' with existing resources
117
+ // These resources are from a previous deployment of this stack, so they're stack-managed
118
+ const isManagedIsolated = appDefinition.managementMode === 'managed' &&
119
+ (appDefinition.vpcIsolation === 'isolated' || !appDefinition.vpcIsolation);
120
+ const hasExistingStackResources = isManagedIsolated && flatDiscovery.defaultVpcId &&
121
+ typeof flatDiscovery.defaultVpcId === 'string';
122
+
123
+ // Check if this came from CloudFormation stack
124
+ if (flatDiscovery.fromCloudFormationStack || hasExistingStackResources) {
125
+ discovery.fromCloudFormation = true;
126
+ discovery.stackName = flatDiscovery.stackName || 'assumed-stack';
127
+
128
+ // Add resources to stackManaged array
129
+ let existingLogicalIds = flatDiscovery.existingLogicalIds || [];
130
+
131
+ // If hasExistingStackResources but no existingLogicalIds provided,
132
+ // infer logical IDs from presence of physical IDs
133
+ if (hasExistingStackResources && existingLogicalIds.length === 0) {
134
+ existingLogicalIds = [];
135
+ if (flatDiscovery.defaultVpcId) existingLogicalIds.push('FriggVPC');
136
+ if (flatDiscovery.privateSubnetId1) existingLogicalIds.push('FriggPrivateSubnet1');
137
+ if (flatDiscovery.privateSubnetId2) existingLogicalIds.push('FriggPrivateSubnet2');
138
+ if (flatDiscovery.publicSubnetId1) existingLogicalIds.push('FriggPublicSubnet');
139
+ if (flatDiscovery.publicSubnetId2) existingLogicalIds.push('FriggPublicSubnet2');
140
+ }
141
+
142
+ existingLogicalIds.forEach(logicalId => {
143
+ // Find the resource type and physical ID
144
+ let resourceType = '';
145
+ let physicalId = '';
146
+
147
+ if (logicalId === 'FriggVPC') {
148
+ resourceType = 'AWS::EC2::VPC';
149
+ physicalId = flatDiscovery.defaultVpcId;
150
+ } else if (logicalId === 'FriggLambdaSecurityGroup') {
151
+ resourceType = 'AWS::EC2::SecurityGroup';
152
+ physicalId = flatDiscovery.defaultSecurityGroupId || flatDiscovery.securityGroupId;
153
+ } else if (logicalId === 'FriggPrivateSubnet1') {
154
+ resourceType = 'AWS::EC2::Subnet';
155
+ physicalId = flatDiscovery.privateSubnetId1;
156
+ } else if (logicalId === 'FriggPrivateSubnet2') {
157
+ resourceType = 'AWS::EC2::Subnet';
158
+ physicalId = flatDiscovery.privateSubnetId2;
159
+ } else if (logicalId === 'FriggNATGateway') {
160
+ resourceType = 'AWS::EC2::NatGateway';
161
+ physicalId = flatDiscovery.existingNatGatewayId;
162
+ } else if (logicalId === 'FriggS3VPCEndpoint') {
163
+ resourceType = 'AWS::EC2::VPCEndpoint';
164
+ physicalId = flatDiscovery.s3VpcEndpointId;
165
+ } else if (logicalId === 'FriggDynamoDBVPCEndpoint') {
166
+ resourceType = 'AWS::EC2::VPCEndpoint';
167
+ physicalId = flatDiscovery.dynamodbVpcEndpointId;
168
+ } else if (logicalId === 'FriggKMSVPCEndpoint') {
169
+ resourceType = 'AWS::EC2::VPCEndpoint';
170
+ physicalId = flatDiscovery.kmsVpcEndpointId;
171
+ } else if (logicalId === 'FriggSecretsManagerVPCEndpoint') {
172
+ resourceType = 'AWS::EC2::VPCEndpoint';
173
+ physicalId = flatDiscovery.secretsManagerVpcEndpointId;
174
+ } else if (logicalId === 'FriggSQSVPCEndpoint') {
175
+ resourceType = 'AWS::EC2::VPCEndpoint';
176
+ physicalId = flatDiscovery.sqsVpcEndpointId;
177
+ }
178
+
179
+ if (physicalId && typeof physicalId === 'string') {
180
+ discovery.stackManaged.push({
181
+ logicalId,
182
+ physicalId,
183
+ resourceType
184
+ });
185
+ }
186
+ });
187
+ } else {
188
+ // Resources discovered from AWS API (not CloudFormation)
189
+ // These go into external array
190
+
191
+ if (flatDiscovery.defaultVpcId && typeof flatDiscovery.defaultVpcId === 'string') {
192
+ discovery.external.push({
193
+ physicalId: flatDiscovery.defaultVpcId,
194
+ resourceType: 'AWS::EC2::VPC',
195
+ source: 'aws-discovery'
196
+ });
197
+ }
198
+
199
+ if (flatDiscovery.defaultSecurityGroupId && typeof flatDiscovery.defaultSecurityGroupId === 'string') {
200
+ discovery.external.push({
201
+ physicalId: flatDiscovery.defaultSecurityGroupId,
202
+ resourceType: 'AWS::EC2::SecurityGroup',
203
+ source: 'aws-discovery'
204
+ });
205
+ }
206
+
207
+ if (flatDiscovery.privateSubnetId1 && typeof flatDiscovery.privateSubnetId1 === 'string') {
208
+ discovery.external.push({
209
+ physicalId: flatDiscovery.privateSubnetId1,
210
+ resourceType: 'AWS::EC2::Subnet',
211
+ source: 'aws-discovery'
212
+ });
213
+ }
214
+
215
+ if (flatDiscovery.privateSubnetId2 && typeof flatDiscovery.privateSubnetId2 === 'string') {
216
+ discovery.external.push({
217
+ physicalId: flatDiscovery.privateSubnetId2,
218
+ resourceType: 'AWS::EC2::Subnet',
219
+ source: 'aws-discovery'
220
+ });
221
+ }
222
+
223
+ // Only add NAT Gateway to external if it's NOT in a private subnet (properly placed)
224
+ // If natGatewayInPrivateSubnet is true, we need a new NAT Gateway
225
+ const natIsProperlyPlaced = flatDiscovery.natGatewayInPrivateSubnet !== true;
226
+
227
+ if (flatDiscovery.natGatewayId && typeof flatDiscovery.natGatewayId === 'string' && natIsProperlyPlaced) {
228
+ discovery.external.push({
229
+ physicalId: flatDiscovery.natGatewayId,
230
+ resourceType: 'AWS::EC2::NatGateway',
231
+ source: 'aws-discovery'
232
+ });
233
+ }
234
+
235
+ if (flatDiscovery.existingNatGatewayId && typeof flatDiscovery.existingNatGatewayId === 'string' && natIsProperlyPlaced) {
236
+ discovery.external.push({
237
+ physicalId: flatDiscovery.existingNatGatewayId,
238
+ resourceType: 'AWS::EC2::NatGateway',
239
+ source: 'aws-discovery'
240
+ });
241
+ }
242
+
243
+ // VPC Endpoints
244
+ if (flatDiscovery.s3VpcEndpointId && typeof flatDiscovery.s3VpcEndpointId === 'string') {
245
+ discovery.external.push({
246
+ physicalId: flatDiscovery.s3VpcEndpointId,
247
+ resourceType: 'AWS::EC2::VPCEndpoint',
248
+ source: 'aws-discovery',
249
+ properties: { ServiceName: 's3' }
250
+ });
251
+ }
252
+
253
+ if (flatDiscovery.dynamodbVpcEndpointId && typeof flatDiscovery.dynamodbVpcEndpointId === 'string') {
254
+ discovery.external.push({
255
+ physicalId: flatDiscovery.dynamodbVpcEndpointId,
256
+ resourceType: 'AWS::EC2::VPCEndpoint',
257
+ source: 'aws-discovery',
258
+ properties: { ServiceName: 'dynamodb' }
259
+ });
260
+ }
261
+
262
+ if (flatDiscovery.kmsVpcEndpointId && typeof flatDiscovery.kmsVpcEndpointId === 'string') {
263
+ discovery.external.push({
264
+ physicalId: flatDiscovery.kmsVpcEndpointId,
265
+ resourceType: 'AWS::EC2::VPCEndpoint',
266
+ source: 'aws-discovery',
267
+ properties: { ServiceName: 'kms' }
268
+ });
269
+ }
270
+
271
+ if (flatDiscovery.secretsManagerVpcEndpointId && typeof flatDiscovery.secretsManagerVpcEndpointId === 'string') {
272
+ discovery.external.push({
273
+ physicalId: flatDiscovery.secretsManagerVpcEndpointId,
274
+ resourceType: 'AWS::EC2::VPCEndpoint',
275
+ source: 'aws-discovery',
276
+ properties: { ServiceName: 'secretsmanager' }
277
+ });
278
+ }
279
+
280
+ if (flatDiscovery.sqsVpcEndpointId && typeof flatDiscovery.sqsVpcEndpointId === 'string') {
281
+ discovery.external.push({
282
+ physicalId: flatDiscovery.sqsVpcEndpointId,
283
+ resourceType: 'AWS::EC2::VPCEndpoint',
284
+ source: 'aws-discovery',
285
+ properties: { ServiceName: 'sqs' }
286
+ });
287
+ }
288
+ }
289
+
290
+ return discovery;
291
+ }
292
+
293
+ /**
294
+ * Translate legacy configuration (management modes) to new ownership-based configuration
295
+ * Provides backwards compatibility for existing app definitions
296
+ */
297
+ translateLegacyConfig(appDefinition, discoveredResources) {
298
+ // If already using new ownership schema, return as-is
299
+ if (appDefinition.vpc?.ownership) {
300
+ return appDefinition;
301
+ }
302
+
303
+ // Clone to avoid mutating original
304
+ const translated = JSON.parse(JSON.stringify(appDefinition));
305
+
306
+ // Initialize ownership and external sections
307
+ if (!translated.vpc.ownership) {
308
+ translated.vpc.ownership = {};
309
+ }
310
+ if (!translated.vpc.external) {
311
+ translated.vpc.external = {};
312
+ }
313
+ if (!translated.vpc.config) {
314
+ translated.vpc.config = {};
315
+ }
316
+
317
+ // Handle top-level managementMode
318
+ const globalMode = appDefinition.managementMode || 'discover';
319
+ const vpcIsolation = appDefinition.vpcIsolation || 'shared';
320
+
321
+ if (globalMode === 'managed') {
322
+ this.warnIgnoredOptions(appDefinition);
323
+
324
+ if (vpcIsolation === 'isolated') {
325
+ // Check if CloudFormation stack already has resources
326
+ const hasStackVpc = discoveredResources?.defaultVpcId && typeof discoveredResources.defaultVpcId === 'string';
327
+
328
+ if (hasStackVpc) {
329
+ // Stack has VPC - reuse it
330
+ translated.vpc.ownership.vpc = 'auto';
331
+ translated.vpc.ownership.securityGroup = 'auto';
332
+ translated.vpc.ownership.subnets = 'auto';
333
+ translated.vpc.config.selfHeal = true;
334
+ console.log(` managementMode='managed' + vpcIsolation='isolated' → stack has VPC, reusing`);
335
+ } else {
336
+ // No stack VPC - create new
337
+ translated.vpc.ownership.vpc = 'stack';
338
+ translated.vpc.ownership.securityGroup = 'stack';
339
+ translated.vpc.ownership.subnets = 'stack';
340
+ translated.vpc.ownership.natGateway = 'stack';
341
+ translated.vpc.config.natGateway = { enable: true };
342
+ console.log(` managementMode='managed' + vpcIsolation='isolated' → no stack VPC, creating new`);
343
+ }
344
+ } else {
345
+ // Shared VPC
346
+ translated.vpc.ownership.vpc = 'auto';
347
+ translated.vpc.ownership.securityGroup = 'auto';
348
+ translated.vpc.ownership.subnets = 'auto';
349
+ translated.vpc.config.selfHeal = true;
350
+ }
351
+ } else if (globalMode === 'existing') {
352
+ translated.vpc.ownership.vpc = 'external';
353
+ translated.vpc.ownership.securityGroup = 'external';
354
+ translated.vpc.ownership.subnets = 'external';
355
+ }
356
+
357
+ // Handle legacy vpc.management modes
358
+ const vpcManagement = appDefinition.vpc?.management;
359
+ if (vpcManagement === 'create-new') {
360
+ translated.vpc.ownership.vpc = 'stack';
361
+ translated.vpc.ownership.securityGroup = 'stack';
362
+ translated.vpc.ownership.subnets = 'stack';
363
+ } else if (vpcManagement === 'use-existing') {
364
+ translated.vpc.ownership.vpc = 'external';
365
+ translated.vpc.external.vpcId = appDefinition.vpc.vpcId;
366
+
367
+ if (appDefinition.vpc.securityGroupIds) {
368
+ translated.vpc.ownership.securityGroup = 'external';
369
+ translated.vpc.external.securityGroupIds = appDefinition.vpc.securityGroupIds;
370
+ }
371
+
372
+ if (appDefinition.vpc.subnets?.ids) {
373
+ translated.vpc.ownership.subnets = 'external';
374
+ translated.vpc.external.subnetIds = appDefinition.vpc.subnets.ids;
375
+ }
376
+ } else if (vpcManagement === 'discover') {
377
+ // Discover mode - let auto-resolution handle it
378
+ translated.vpc.ownership.vpc = 'auto';
379
+ translated.vpc.ownership.securityGroup = 'auto';
380
+ translated.vpc.ownership.subnets = 'auto';
381
+ }
382
+
383
+ // Handle legacy shareAcrossStages
384
+ if (appDefinition.vpc?.shareAcrossStages !== undefined) {
385
+ if (appDefinition.vpc.shareAcrossStages) {
386
+ // Shared VPC - discover and reuse
387
+ translated.vpc.ownership.vpc = 'auto';
388
+ translated.vpc.ownership.subnets = 'auto';
389
+ } else {
390
+ // Isolated VPC - create stage-specific
391
+ translated.vpc.ownership.vpc = 'stack';
392
+ translated.vpc.ownership.subnets = 'stack';
393
+ translated.vpc.ownership.natGateway = 'stack';
394
+ translated.vpc.config.natGateway = { enable: true };
395
+ }
396
+ }
397
+
398
+ // Handle legacy NAT Gateway management
399
+ if (appDefinition.vpc?.natGateway?.management === 'createAndManage') {
400
+ // Use 'auto' to allow discovering and reusing properly placed external NAT Gateways
401
+ // The resolver will check if there's a good external NAT Gateway and reuse it,
402
+ // or create a new one if needed (or if the existing one is misplaced)
403
+ translated.vpc.ownership.natGateway = 'auto';
404
+ translated.vpc.config.natGateway = { enable: true };
405
+ } else if (appDefinition.vpc?.natGateway?.id) {
406
+ translated.vpc.ownership.natGateway = 'external';
407
+ translated.vpc.external.natGatewayId = appDefinition.vpc.natGateway.id;
408
+ }
409
+
410
+ // Handle legacy subnet management
411
+ if (appDefinition.vpc?.subnets?.management === 'create') {
412
+ translated.vpc.ownership.subnets = 'stack';
413
+ } else if (appDefinition.vpc?.subnets?.management === 'use-existing' && appDefinition.vpc.subnets.ids) {
414
+ translated.vpc.ownership.subnets = 'external';
415
+ translated.vpc.external.subnetIds = appDefinition.vpc.subnets.ids;
416
+ }
417
+
418
+ // Preserve other VPC config
419
+ if (appDefinition.vpc?.cidrBlock) {
420
+ translated.vpc.config.cidrBlock = appDefinition.vpc.cidrBlock;
421
+ }
422
+ if (appDefinition.vpc?.enableVPCEndpoints !== undefined) {
423
+ translated.vpc.config.enableVpcEndpoints = appDefinition.vpc.enableVPCEndpoints;
424
+ }
425
+ if (appDefinition.vpc?.selfHeal !== undefined) {
426
+ translated.vpc.config.selfHeal = appDefinition.vpc.selfHeal;
427
+ }
428
+
429
+ return translated;
430
+ }
431
+
432
+ /**
433
+ * Build complete VPC infrastructure using ownership-based architecture
101
434
  */
102
435
  async build(appDefinition, discoveredResources) {
103
436
  console.log(`\n[${this.name}] Building VPC infrastructure...`);
104
437
 
438
+ // Backwards compatibility: Translate old schema to new ownership schema
439
+ appDefinition = this.translateLegacyConfig(appDefinition, discoveredResources);
440
+
441
+ // Get structured discovery result (or convert flat discovery to structured)
442
+ // Pass appDefinition to help detect stack-managed resources in managementMode='managed'
443
+ const discovery = discoveredResources._structured || this.convertFlatDiscoveryToStructured(discoveredResources, appDefinition);
444
+
445
+ // Use VpcResourceResolver to make ownership decisions
446
+ const resolver = new VpcResourceResolver();
447
+ const decisions = resolver.resolveAll(appDefinition, discovery);
448
+
449
+ console.log('\n 📋 Resource Ownership Decisions:');
450
+ console.log(` VPC: ${decisions.vpc.ownership} - ${decisions.vpc.reason}`);
451
+ console.log(` Security Group: ${decisions.securityGroup.ownership} - ${decisions.securityGroup.reason}`);
452
+ console.log(` Subnets: ${decisions.subnets.ownership} - ${decisions.subnets.reason}`);
453
+ console.log(` NAT Gateway: ${decisions.natGateway.ownership || 'disabled'} - ${decisions.natGateway.reason}`);
454
+ console.log(` VPC Endpoints:`);
455
+ console.log(` S3: ${decisions.vpcEndpoints.s3.ownership || 'disabled'} - ${decisions.vpcEndpoints.s3.reason}`);
456
+ console.log(` DynamoDB: ${decisions.vpcEndpoints.dynamodb.ownership || 'disabled'} - ${decisions.vpcEndpoints.dynamodb.reason}`);
457
+
458
+ // Initialize result
105
459
  const result = {
106
460
  resources: {},
107
461
  vpcConfig: {
@@ -110,9 +464,42 @@ class VpcBuilder extends InfrastructureBuilder {
110
464
  },
111
465
  iamStatements: [],
112
466
  outputs: {},
467
+ environment: {},
113
468
  };
114
469
 
115
470
  // Add IAM permissions for VPC-enabled Lambda functions
471
+ this.addVpcIamPermissions(result);
472
+
473
+ // Build VPC based on ownership decision
474
+ this.buildVpcFromDecision(decisions.vpc, appDefinition, result);
475
+
476
+ // Build Security Group based on ownership decision
477
+ this.buildSecurityGroupFromDecision(decisions.securityGroup, appDefinition, result);
478
+
479
+ // Build Subnets based on ownership decision
480
+ this.buildSubnetsFromDecision(decisions.subnets, appDefinition, discoveredResources, result);
481
+
482
+ // Build NAT Gateway based on ownership decision
483
+ this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
484
+
485
+ // Build VPC Endpoints based on ownership decisions
486
+ this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, appDefinition, result);
487
+
488
+ // Set VPC_ENABLED environment variable
489
+ result.environment.VPC_ENABLED = 'true';
490
+
491
+ console.log(`\n[${this.name}] ✅ VPC infrastructure built successfully`);
492
+ console.log(` - VPC ID: ${result.vpcId || 'from discovery'}`);
493
+ console.log(` - Subnets: ${result.vpcConfig.subnetIds.length}`);
494
+ console.log(` - Security Groups: ${result.vpcConfig.securityGroupIds.length}`);
495
+
496
+ return result;
497
+ }
498
+
499
+ /**
500
+ * Add IAM permissions for VPC-enabled Lambda functions
501
+ */
502
+ addVpcIamPermissions(result) {
116
503
  result.iamStatements.push({
117
504
  Effect: 'Allow',
118
505
  Action: [
@@ -124,153 +511,494 @@ class VpcBuilder extends InfrastructureBuilder {
124
511
  ],
125
512
  Resource: '*',
126
513
  });
514
+ }
127
515
 
128
- // Normalize top-level managementMode (simplified API)
129
- const globalMode = appDefinition.managementMode || 'discover';
130
- const vpcIsolation = appDefinition.vpcIsolation || 'shared';
516
+ /**
517
+ * Build VPC based on ownership decision
518
+ *
519
+ * For STACK ownership: ALWAYS add definitions to template.
520
+ * CloudFormation idempotency ensures existing resources aren't recreated.
521
+ */
522
+ buildVpcFromDecision(decision, appDefinition, result) {
523
+ if (decision.ownership === ResourceOwnership.STACK) {
524
+ // For STACK ownership: ALWAYS create definitions (CloudFormation idempotency)
525
+ if (decision.physicalId) {
526
+ console.log(` → Adding VPC definition to template (existing: ${decision.physicalId})`);
527
+ } else {
528
+ console.log(' → Adding VPC definition to template (new)');
529
+ }
131
530
 
132
- // Debug logging
133
- console.log(` 🔍 DEBUG: globalMode = '${globalMode}', vpcIsolation = '${vpcIsolation}'`);
134
- console.log(` 🔍 DEBUG: discoveredResources.defaultVpcId = ${discoveredResources?.defaultVpcId}`);
135
- console.log(` 🔍 DEBUG: discoveredResources keys = ${Object.keys(discoveredResources || {}).join(', ')}`);
531
+ const cidrBlock = appDefinition.vpc?.config?.cidrBlock || appDefinition.vpc?.cidrBlock || '10.0.0.0/16';
136
532
 
137
- let management = appDefinition.vpc.management;
533
+ result.resources.FriggVPC = {
534
+ Type: 'AWS::EC2::VPC',
535
+ Properties: {
536
+ CidrBlock: cidrBlock,
537
+ EnableDnsHostnames: true,
538
+ EnableDnsSupport: true,
539
+ Tags: [
540
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc' },
541
+ { Key: 'ManagedBy', Value: 'Frigg' },
542
+ { Key: 'Service', Value: '${self:service}' },
543
+ { Key: 'Stage', Value: '${self:provider.stage}' },
544
+ ],
545
+ },
546
+ };
138
547
 
139
- if (globalMode === 'managed') {
140
- // Warn about ignored granular options
141
- this.warnIgnoredOptions(appDefinition);
548
+ // Internet Gateway
549
+ result.resources.FriggInternetGateway = {
550
+ Type: 'AWS::EC2::InternetGateway',
551
+ Properties: {
552
+ Tags: [
553
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-igw' },
554
+ { Key: 'ManagedBy', Value: 'Frigg' },
555
+ ],
556
+ },
557
+ };
142
558
 
143
- // Clear granular options to prevent conflicts
144
- delete appDefinition.vpc.management;
145
- if (appDefinition.vpc.subnets) delete appDefinition.vpc.subnets.management;
146
- if (appDefinition.vpc.natGateway) delete appDefinition.vpc.natGateway.management;
147
- delete appDefinition.vpc.shareAcrossStages;
559
+ result.resources.FriggVPCGatewayAttachment = {
560
+ Type: 'AWS::EC2::VPCGatewayAttachment',
561
+ Properties: {
562
+ VpcId: { Ref: 'FriggVPC' },
563
+ InternetGatewayId: { Ref: 'FriggInternetGateway' },
564
+ },
565
+ };
148
566
 
149
- // Set management based on isolation strategy AND existing stack resources
150
- if (vpcIsolation === 'isolated') {
151
- // Check if CloudFormation stack already has a VPC (stage-specific)
152
- // CloudFormation discovery sets 'defaultVpcId' (string) when found in stack
153
- const hasStackVpc = discoveredResources?.defaultVpcId && typeof discoveredResources.defaultVpcId === 'string';
567
+ // Use Ref for stack-managed VPC
568
+ result.vpcId = { Ref: 'FriggVPC' };
569
+ console.log(' ✅ VPC definition added to template');
570
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
571
+ // Use external VPC ID (no definition in template)
572
+ result.vpcId = decision.physicalId;
573
+ console.log(` ✓ Using external VPC: ${decision.physicalId}`);
574
+ }
575
+ }
154
576
 
155
- // Debug logging
156
- console.log(` 🔍 DEBUG: discoveredResources.defaultVpcId = ${discoveredResources?.defaultVpcId} (type: ${typeof discoveredResources?.defaultVpcId})`);
157
- console.log(` 🔍 DEBUG: hasStackVpc = ${hasStackVpc}`);
577
+ /**
578
+ * Build Security Group based on ownership decision
579
+ */
580
+ buildSecurityGroupFromDecision(decision, appDefinition, result) {
581
+ if (decision.ownership === ResourceOwnership.STACK) {
582
+ // Always create security group resource in template
583
+ // CloudFormation handles idempotency if it already exists
584
+ console.log(' → Adding Lambda Security Group to template...');
158
585
 
159
- if (hasStackVpc) {
160
- // Stack has VPC - reuse it (standard flow: stack → orphaned → create)
161
- management = 'discover';
162
- appDefinition.vpc.selfHeal = true;
163
- console.log(` managementMode='managed' + vpcIsolation='isolated' → stack has VPC, reusing`);
164
- } else {
165
- // No stack VPC - create new isolated VPC for this stage
166
- management = 'create-new';
167
- appDefinition.vpc.natGateway = appDefinition.vpc.natGateway || {};
168
- appDefinition.vpc.natGateway.management = 'createAndManage';
169
- console.log(` managementMode='managed' + vpcIsolation='isolated' no stack VPC, creating new`);
586
+ result.resources.FriggLambdaSecurityGroup = {
587
+ Type: 'AWS::EC2::SecurityGroup',
588
+ Properties: {
589
+ GroupDescription: 'Security group for Frigg Lambda functions',
590
+ VpcId: result.vpcId,
591
+ SecurityGroupEgress: [
592
+ { IpProtocol: 'tcp', FromPort: 443, ToPort: 443, CidrIp: '0.0.0.0/0', Description: 'HTTPS outbound' },
593
+ { IpProtocol: 'tcp', FromPort: 80, ToPort: 80, CidrIp: '0.0.0.0/0', Description: 'HTTP outbound' },
594
+ { IpProtocol: 'tcp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS TCP' },
595
+ { IpProtocol: 'udp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS UDP' },
596
+ { IpProtocol: 'tcp', FromPort: 5432, ToPort: 5432, CidrIp: '0.0.0.0/0', Description: 'PostgreSQL' },
597
+ { IpProtocol: 'tcp', FromPort: 27017, ToPort: 27017, CidrIp: '0.0.0.0/0', Description: 'MongoDB' },
598
+ ],
599
+ Tags: [
600
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-sg' },
601
+ { Key: 'ManagedBy', Value: 'Frigg' },
602
+ ],
603
+ },
604
+ };
605
+
606
+ // Use CloudFormation Ref since resource is in template
607
+ result.vpcConfig.securityGroupIds = [{ Ref: 'FriggLambdaSecurityGroup' }];
608
+ console.log(' ✅ Security Group added to template');
609
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
610
+ // Use external security group IDs
611
+ const sgIds = Array.isArray(decision.physicalId) ? decision.physicalId : [decision.physicalId];
612
+ result.vpcConfig.securityGroupIds = sgIds;
613
+ console.log(` ✓ Using external security group(s): ${sgIds.join(', ')}`);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Build Subnets based on ownership decision
619
+ */
620
+ buildSubnetsFromDecision(decision, appDefinition, discoveredResources, result) {
621
+ if (decision.ownership === ResourceOwnership.STACK) {
622
+ // Check if no subnets exist and selfHeal is disabled
623
+ if (!decision.physicalIds || decision.physicalIds.length < 2) {
624
+ const selfHeal = appDefinition.vpc?.config?.selfHeal !== false;
625
+ if (!selfHeal) {
626
+ throw new Error(
627
+ 'No subnets discovered. Enable vpc.selfHeal, set subnets.management to "create", or provide subnet IDs.'
628
+ );
170
629
  }
171
- } else {
172
- management = 'discover';
173
- appDefinition.vpc.selfHeal = true;
174
- console.log(` managementMode='managed' + vpcIsolation='shared' → discovering VPC`);
175
630
  }
176
- } else if (globalMode === 'existing') {
177
- management = 'use-existing';
178
- } else if (!management && appDefinition.vpc.shareAcrossStages !== undefined) {
179
- // Legacy shareAcrossStages support (backwards compatibility)
180
- management = appDefinition.vpc.shareAcrossStages ? 'discover' : 'create-new';
181
- console.log(` VPC Sharing: ${appDefinition.vpc.shareAcrossStages ? 'shared' : 'isolated'} (translated to ${management})`);
182
-
183
- if (!appDefinition.vpc.shareAcrossStages && !appDefinition.vpc.natGateway?.management) {
184
- appDefinition.vpc.natGateway = appDefinition.vpc.natGateway || {};
185
- appDefinition.vpc.natGateway.management = 'createAndManage';
186
- console.log(` NAT Gateway: creating isolated NAT (shareAcrossStages=false)`);
631
+
632
+ // For STACK ownership: ALWAYS add definitions to template
633
+ // CloudFormation idempotency ensures existing resources won't be recreated
634
+ if (decision.physicalIds && decision.physicalIds.length >= 2) {
635
+ console.log(` → Adding subnet definitions to template (existing: ${decision.physicalIds.join(', ')})`);
636
+ } else {
637
+ console.log(' → Adding subnet definitions to template (new)');
187
638
  }
188
- } else {
189
- management = management || 'discover';
639
+
640
+ this.createSubnetsInTemplate(appDefinition, result, discoveredResources);
641
+
642
+ // Use Refs for stack-managed resources
643
+ result.vpcConfig.subnetIds = [
644
+ { Ref: 'FriggPrivateSubnet1' },
645
+ { Ref: 'FriggPrivateSubnet2' }
646
+ ];
647
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
648
+ // Use external subnet IDs directly (no definitions in template)
649
+ result.vpcConfig.subnetIds = decision.physicalIds;
650
+ console.log(` ✓ Using external subnets: ${decision.physicalIds.join(', ')}`);
190
651
  }
652
+ }
653
+
654
+ /**
655
+ * Create subnet resources in CloudFormation template
656
+ */
657
+ createSubnetsInTemplate(appDefinition, result, discoveredResources) {
658
+ // Determine VPC ID for subnets
659
+ const vpcId = result.vpcId;
191
660
 
192
- console.log(` VPC Management Mode: ${management}`);
661
+ // Generate subnet CIDRs
662
+ const cidrs = this.generateSubnetCidrsForNewVpc(vpcId, discoveredResources);
193
663
 
194
- // Handle self-healing if enabled
195
- if (appDefinition.vpc.selfHeal) {
196
- this.performSelfHealing(discoveredResources, appDefinition);
664
+ // Private Subnet 1
665
+ result.resources.FriggPrivateSubnet1 = {
666
+ Type: 'AWS::EC2::Subnet',
667
+ DeletionPolicy: 'Retain',
668
+ Properties: {
669
+ VpcId: vpcId,
670
+ CidrBlock: cidrs.private1,
671
+ AvailabilityZone: { 'Fn::Select': [0, { 'Fn::GetAZs': '' }] },
672
+ Tags: [
673
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-private-1' },
674
+ { Key: 'Type', Value: 'Private' },
675
+ { Key: 'ManagedBy', Value: 'Frigg' },
676
+ ],
677
+ },
678
+ };
679
+
680
+ // Private Subnet 2
681
+ result.resources.FriggPrivateSubnet2 = {
682
+ Type: 'AWS::EC2::Subnet',
683
+ DeletionPolicy: 'Retain',
684
+ Properties: {
685
+ VpcId: vpcId,
686
+ CidrBlock: cidrs.private2,
687
+ AvailabilityZone: { 'Fn::Select': [1, { 'Fn::GetAZs': '' }] },
688
+ Tags: [
689
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-private-2' },
690
+ { Key: 'Type', Value: 'Private' },
691
+ { Key: 'ManagedBy', Value: 'Frigg' },
692
+ ],
693
+ },
694
+ };
695
+
696
+ // Public Subnets (for NAT Gateway)
697
+ result.resources.FriggPublicSubnet = {
698
+ Type: 'AWS::EC2::Subnet',
699
+ Properties: {
700
+ VpcId: vpcId,
701
+ CidrBlock: cidrs.public1,
702
+ MapPublicIpOnLaunch: true,
703
+ AvailabilityZone: { 'Fn::Select': [0, { 'Fn::GetAZs': '' }] },
704
+ Tags: [
705
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-public-1' },
706
+ { Key: 'Type', Value: 'Public' },
707
+ { Key: 'ManagedBy', Value: 'Frigg' },
708
+ ],
709
+ },
710
+ };
711
+
712
+ result.resources.FriggPublicSubnet2 = {
713
+ Type: 'AWS::EC2::Subnet',
714
+ Properties: {
715
+ VpcId: vpcId,
716
+ CidrBlock: cidrs.public2,
717
+ MapPublicIpOnLaunch: true,
718
+ AvailabilityZone: { 'Fn::Select': [1, { 'Fn::GetAZs': '' }] },
719
+ Tags: [
720
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-public-2' },
721
+ { Key: 'Type', Value: 'Public' },
722
+ { Key: 'ManagedBy', Value: 'Frigg' },
723
+ ],
724
+ },
725
+ };
726
+
727
+ result.vpcConfig.subnetIds = [
728
+ { Ref: 'FriggPrivateSubnet1' },
729
+ { Ref: 'FriggPrivateSubnet2' },
730
+ ];
731
+
732
+ // Map to discovered resources for other builders
733
+ discoveredResources.privateSubnetId1 = { Ref: 'FriggPrivateSubnet1' };
734
+ discoveredResources.privateSubnetId2 = { Ref: 'FriggPrivateSubnet2' };
735
+ discoveredResources.publicSubnetId1 = { Ref: 'FriggPublicSubnet' };
736
+ discoveredResources.publicSubnetId2 = { Ref: 'FriggPublicSubnet2' };
737
+
738
+ console.log(' ✅ Subnet resources added to template');
739
+ }
740
+
741
+ /**
742
+ * Generate subnet CIDRs for new VPC or existing VPC
743
+ */
744
+ generateSubnetCidrsForNewVpc(vpcId, discoveredResources) {
745
+ // If VPC is a Ref (new VPC), use Fn::Cidr
746
+ if (typeof vpcId === 'object' && vpcId.Ref === 'FriggVPC') {
747
+ return {
748
+ private1: { 'Fn::Select': [0, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
749
+ private2: { 'Fn::Select': [1, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
750
+ public1: { 'Fn::Select': [2, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
751
+ public2: { 'Fn::Select': [3, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
752
+ };
197
753
  }
198
754
 
199
- // Build VPC based on management mode
200
- switch (management) {
201
- case 'create-new':
202
- await this.buildNewVpc(appDefinition, discoveredResources, result);
203
- break;
204
- case 'use-existing':
205
- await this.useExistingVpc(appDefinition, discoveredResources, result);
206
- break;
207
- case 'discover':
208
- default:
209
- await this.discoverVpc(appDefinition, discoveredResources, result);
210
- break;
755
+ // For existing VPC, find available CIDRs
756
+ const existingCidrs = new Set();
757
+ if (discoveredResources?.subnets) {
758
+ for (const subnet of discoveredResources.subnets) {
759
+ if (subnet.CidrBlock) {
760
+ existingCidrs.add(subnet.CidrBlock);
761
+ }
762
+ }
211
763
  }
212
764
 
213
- // Build subnets - pass normalized management mode for correct CIDR generation
214
- await this.buildSubnets(appDefinition, discoveredResources, result, management);
765
+ const findAvailableCidr = (startOctet, endOctet) => {
766
+ for (let octet = startOctet; octet <= endOctet; octet++) {
767
+ const candidate = `172.31.${octet}.0/24`;
768
+ if (!existingCidrs.has(candidate)) {
769
+ existingCidrs.add(candidate);
770
+ return candidate;
771
+ }
772
+ }
773
+ return `172.31.${startOctet}.0/24`;
774
+ };
215
775
 
216
- // Build NAT Gateway if needed
217
- await this.buildNatGateway(appDefinition, discoveredResources, result);
776
+ return {
777
+ private1: findAvailableCidr(240, 249),
778
+ private2: findAvailableCidr(240, 249),
779
+ public1: findAvailableCidr(250, 255),
780
+ public2: findAvailableCidr(250, 255),
781
+ };
782
+ }
218
783
 
219
- // Build VPC Endpoints if enabled
220
- const vpcManagement = appDefinition.vpc.management || 'discover';
221
- const selfHeal = appDefinition.vpc.selfHeal !== false;
222
- // Check which VPC endpoints already exist
223
- const existingEndpoints = {
224
- s3: discoveredResources.s3VpcEndpointId,
225
- dynamodb: discoveredResources.dynamodbVpcEndpointId,
226
- kms: discoveredResources.kmsVpcEndpointId,
227
- secretsManager: discoveredResources.secretsManagerVpcEndpointId,
228
- sqs: discoveredResources.sqsVpcEndpointId,
784
+ /**
785
+ * Build NAT Gateway based on ownership decision
786
+ */
787
+ buildNatGatewayFromDecision(decision, appDefinition, discoveredResources, result) {
788
+ if (!decision.ownership) {
789
+ console.log(' ⊝ NAT Gateway disabled');
790
+ return;
791
+ }
792
+
793
+ if (decision.ownership === ResourceOwnership.STACK) {
794
+ if (decision.physicalId) {
795
+ // NAT Gateway exists in stack - CloudFormation will handle it
796
+ console.log(` ✓ NAT Gateway in stack: ${decision.physicalId}`);
797
+ // Still need to ensure route tables are set up
798
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, { Ref: 'FriggNATGateway' });
799
+ } else {
800
+ // Create new NAT Gateway
801
+ console.log(' → Creating NAT Gateway in template...');
802
+ this.createNatGatewayInTemplate(appDefinition, discoveredResources, result);
803
+ }
804
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
805
+ // Use external NAT Gateway
806
+ console.log(` ✓ Using external NAT Gateway: ${decision.physicalId}`);
807
+ result.natGatewayId = decision.physicalId;
808
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, decision.physicalId);
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Create NAT Gateway resources in CloudFormation template
814
+ */
815
+ createNatGatewayInTemplate(appDefinition, discoveredResources, result) {
816
+ // Elastic IP for NAT Gateway
817
+ result.resources.FriggNATGatewayEIP = {
818
+ Type: 'AWS::EC2::EIP',
819
+ DeletionPolicy: 'Retain',
820
+ UpdateReplacePolicy: 'Retain',
821
+ Properties: {
822
+ Domain: 'vpc',
823
+ Tags: [
824
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-nat-eip' },
825
+ { Key: 'ManagedBy', Value: 'Frigg' },
826
+ ],
827
+ },
229
828
  };
230
- const allEndpointsExist = existingEndpoints.s3 && existingEndpoints.dynamodb &&
231
- existingEndpoints.kms && existingEndpoints.secretsManager && existingEndpoints.sqs;
232
- const someEndpointsExist = existingEndpoints.s3 || existingEndpoints.dynamodb ||
233
- existingEndpoints.kms || existingEndpoints.secretsManager || existingEndpoints.sqs;
234
-
235
- if (appDefinition.vpc.enableVPCEndpoints !== false) {
236
- // Check if resources came from CloudFormation stack
237
- const fromCfStack = discoveredResources.fromCloudFormationStack === true;
238
- const existingLogicalIds = discoveredResources.existingLogicalIds || [];
239
-
240
- if (fromCfStack && existingLogicalIds.length > 0 && allEndpointsExist) {
241
- console.log(' All VPC endpoints exist in CloudFormation stack - skipping creation');
242
- // Skip VPC endpoint creation entirely
243
- } else if (vpcManagement === 'create-new') {
244
- // Always create in create-new mode
245
- this.buildVpcEndpoints(appDefinition, discoveredResources, result, existingEndpoints);
246
- } else if (vpcManagement === 'discover') {
247
- if (allEndpointsExist) {
248
- console.log(' All VPC endpoints already exist - skipping creation');
249
- } else if (selfHeal) {
250
- if (someEndpointsExist) {
251
- console.log(' Some VPC endpoints found - selfHeal creating missing ones');
252
- } else {
253
- console.log(' No VPC endpoints found - selfHeal creating them');
254
- }
255
- this.buildVpcEndpoints(appDefinition, discoveredResources, result, existingEndpoints);
256
- } else {
257
- console.log(' VPC endpoints not found and selfHeal disabled - skipping');
258
- }
829
+
830
+ // NAT Gateway in public subnet
831
+ result.resources.FriggNATGateway = {
832
+ Type: 'AWS::EC2::NatGateway',
833
+ DeletionPolicy: 'Retain',
834
+ UpdateReplacePolicy: 'Retain',
835
+ Properties: {
836
+ AllocationId: { 'Fn::GetAtt': ['FriggNATGatewayEIP', 'AllocationId'] },
837
+ SubnetId: discoveredResources.publicSubnetId1 || { Ref: 'FriggPublicSubnet' },
838
+ Tags: [
839
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-nat' },
840
+ { Key: 'ManagedBy', Value: 'Frigg' },
841
+ ],
842
+ },
843
+ };
844
+
845
+ // Create public routing
846
+ this.createPublicRouting(appDefinition, discoveredResources, result);
847
+
848
+ // Create NAT routing
849
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, { Ref: 'FriggNATGateway' });
850
+
851
+ console.log(' ✅ NAT Gateway resources added to template');
852
+ }
853
+
854
+ /**
855
+ * Build VPC Endpoints based on ownership decisions
856
+ */
857
+ buildVpcEndpointsFromDecisions(decisions, appDefinition, result) {
858
+ const endpointsToCreate = [];
859
+ const endpointsInStack = [];
860
+ const externalEndpoints = [];
861
+
862
+ // Analyze decisions
863
+ Object.entries(decisions).forEach(([type, decision]) => {
864
+ if (decision.ownership === ResourceOwnership.STACK && !decision.physicalId) {
865
+ endpointsToCreate.push(type);
866
+ } else if (decision.ownership === ResourceOwnership.STACK && decision.physicalId) {
867
+ endpointsInStack.push(type);
868
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
869
+ externalEndpoints.push(type);
259
870
  }
871
+ });
872
+
873
+ if (endpointsInStack.length > 0) {
874
+ console.log(` ✓ VPC Endpoints in stack: ${endpointsInStack.join(', ')}`);
260
875
  }
261
876
 
262
- // Set VPC_ENABLED environment variable so runtime can detect VPC configuration
263
- if (!result.environment) {
264
- result.environment = {};
877
+ if (externalEndpoints.length > 0) {
878
+ console.log(` ✓ External VPC Endpoints: ${externalEndpoints.join(', ')}`);
265
879
  }
266
- result.environment.VPC_ENABLED = 'true';
267
880
 
268
- console.log(`[${this.name}] VPC infrastructure built successfully`);
269
- console.log(` - VPC ID: ${result.vpcId || 'from discovery'}`);
270
- console.log(` - Subnets: ${result.vpcConfig.subnetIds.length}`);
271
- console.log(` - Security Groups: ${result.vpcConfig.securityGroupIds.length}`);
881
+ if (endpointsToCreate.length === 0) {
882
+ if (endpointsInStack.length === 0 && externalEndpoints.length === 0) {
883
+ console.log(' VPC Endpoints disabled');
884
+ }
885
+ return;
886
+ }
272
887
 
273
- return result;
888
+ console.log(` → Creating VPC Endpoints: ${endpointsToCreate.join(', ')}...`);
889
+
890
+ const vpcId = result.vpcId;
891
+
892
+ // Create route table if needed
893
+ if (!result.resources.FriggLambdaRouteTable) {
894
+ result.resources.FriggLambdaRouteTable = {
895
+ Type: 'AWS::EC2::RouteTable',
896
+ Properties: {
897
+ VpcId: vpcId,
898
+ Tags: [
899
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-rt' },
900
+ { Key: 'ManagedBy', Value: 'Frigg' },
901
+ ],
902
+ },
903
+ };
904
+ }
905
+
906
+ // Ensure subnet associations
907
+ this.ensureSubnetAssociations(appDefinition, {}, result);
908
+
909
+ // Create endpoints
910
+ if (endpointsToCreate.includes('s3')) {
911
+ result.resources.FriggS3VPCEndpoint = {
912
+ Type: 'AWS::EC2::VPCEndpoint',
913
+ Properties: {
914
+ VpcId: vpcId,
915
+ ServiceName: 'com.amazonaws.${self:provider.region}.s3',
916
+ VpcEndpointType: 'Gateway',
917
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }],
918
+ },
919
+ };
920
+ }
921
+
922
+ if (endpointsToCreate.includes('dynamodb')) {
923
+ result.resources.FriggDynamoDBVPCEndpoint = {
924
+ Type: 'AWS::EC2::VPCEndpoint',
925
+ Properties: {
926
+ VpcId: vpcId,
927
+ ServiceName: 'com.amazonaws.${self:provider.region}.dynamodb',
928
+ VpcEndpointType: 'Gateway',
929
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }],
930
+ },
931
+ };
932
+ }
933
+
934
+ // Create security group for interface endpoints if needed
935
+ const needsInterfaceEndpoints = endpointsToCreate.some(type => ['kms', 'secretsManager', 'sqs'].includes(type));
936
+ if (needsInterfaceEndpoints) {
937
+ result.resources.FriggVPCEndpointSecurityGroup = {
938
+ Type: 'AWS::EC2::SecurityGroup',
939
+ Properties: {
940
+ GroupDescription: 'Security group for VPC Endpoints',
941
+ VpcId: vpcId,
942
+ SecurityGroupIngress: [
943
+ {
944
+ IpProtocol: 'tcp',
945
+ FromPort: 443,
946
+ ToPort: 443,
947
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
948
+ Description: 'HTTPS from Lambda',
949
+ },
950
+ ],
951
+ Tags: [
952
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc-endpoint-sg' },
953
+ { Key: 'ManagedBy', Value: 'Frigg' },
954
+ ],
955
+ },
956
+ };
957
+ }
958
+
959
+ if (endpointsToCreate.includes('kms')) {
960
+ result.resources.FriggKMSVPCEndpoint = {
961
+ Type: 'AWS::EC2::VPCEndpoint',
962
+ Properties: {
963
+ VpcId: vpcId,
964
+ ServiceName: 'com.amazonaws.${self:provider.region}.kms',
965
+ VpcEndpointType: 'Interface',
966
+ SubnetIds: result.vpcConfig.subnetIds,
967
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
968
+ PrivateDnsEnabled: true,
969
+ },
970
+ };
971
+ }
972
+
973
+ if (endpointsToCreate.includes('secretsManager')) {
974
+ result.resources.FriggSecretsManagerVPCEndpoint = {
975
+ Type: 'AWS::EC2::VPCEndpoint',
976
+ Properties: {
977
+ VpcId: vpcId,
978
+ ServiceName: 'com.amazonaws.${self:provider.region}.secretsmanager',
979
+ VpcEndpointType: 'Interface',
980
+ SubnetIds: result.vpcConfig.subnetIds,
981
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
982
+ PrivateDnsEnabled: true,
983
+ },
984
+ };
985
+ }
986
+
987
+ if (endpointsToCreate.includes('sqs')) {
988
+ result.resources.FriggSQSVPCEndpoint = {
989
+ Type: 'AWS::EC2::VPCEndpoint',
990
+ Properties: {
991
+ VpcId: vpcId,
992
+ ServiceName: 'com.amazonaws.${self:provider.region}.sqs',
993
+ VpcEndpointType: 'Interface',
994
+ SubnetIds: result.vpcConfig.subnetIds,
995
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
996
+ PrivateDnsEnabled: true,
997
+ },
998
+ };
999
+ }
1000
+
1001
+ console.log(` ✅ VPC Endpoint resources added to template`);
274
1002
  }
275
1003
 
276
1004
  /**
@@ -432,22 +1160,14 @@ class VpcBuilder extends InfrastructureBuilder {
432
1160
  if (fromCfStack && existingLogicalIds.length > 0) {
433
1161
  console.log(` ✓ VPC discovered from CloudFormation stack: ${discoveredResources.stackName}`);
434
1162
  console.log(` ✓ Found ${existingLogicalIds.length} existing resources in stack`);
435
- console.log(' ℹ Skipping resource creation - will reuse existing CloudFormation resources');
436
-
437
- // Set security group IDs from discovered resources
438
- if (discoveredResources.securityGroupId) {
439
- result.vpcConfig.securityGroupIds = [discoveredResources.securityGroupId];
440
- }
441
-
442
- // Don't create any new resources - they already exist in the CF stack
443
- return;
1163
+ console.log(' ℹ Adding resources to template for idempotent deployment');
1164
+ } else {
1165
+ // VPC discovered from AWS API (not from CF stack)
1166
+ console.log(' ℹ VPC discovered from AWS API - will create Lambda security group');
444
1167
  }
445
1168
 
446
- // VPC discovered from AWS API (not from CF stack) - create needed resources
447
- console.log(' ℹ VPC discovered from AWS API - will create Lambda security group');
448
-
449
- // Create a Lambda security group in the discovered VPC
450
- // This is needed even in discover mode so other resources can reference it
1169
+ // Always create Lambda security group in template for idempotent deployments
1170
+ // CloudFormation will recognize it already exists and won't recreate it
451
1171
  result.resources.FriggLambdaSecurityGroup = {
452
1172
  Type: 'AWS::EC2::SecurityGroup',
453
1173
  Properties: {
@@ -468,6 +1188,7 @@ class VpcBuilder extends InfrastructureBuilder {
468
1188
  },
469
1189
  };
470
1190
 
1191
+ // Always use Ref since resource is in template
471
1192
  result.vpcConfig.securityGroupIds = [{ Ref: 'FriggLambdaSecurityGroup' }];
472
1193
 
473
1194
  console.log(` ✅ Discovered VPC: ${result.vpcId}`);