@friggframework/devtools 2.0.0--canary.493.f8d621f.0 → 2.0.0--canary.490.a6abe40.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.
@@ -149,7 +149,7 @@ class VpcBuilder extends InfrastructureBuilder {
149
149
  physicalId = flatDiscovery.defaultVpcId;
150
150
  } else if (logicalId === 'FriggLambdaSecurityGroup') {
151
151
  resourceType = 'AWS::EC2::SecurityGroup';
152
- physicalId = flatDiscovery.defaultSecurityGroupId || flatDiscovery.securityGroupId;
152
+ physicalId = flatDiscovery.lambdaSecurityGroupId || flatDiscovery.defaultSecurityGroupId || flatDiscovery.securityGroupId;
153
153
  } else if (logicalId === 'FriggPrivateSubnet1') {
154
154
  resourceType = 'AWS::EC2::Subnet';
155
155
  physicalId = flatDiscovery.privateSubnetId1;
@@ -159,6 +159,9 @@ class VpcBuilder extends InfrastructureBuilder {
159
159
  } else if (logicalId === 'FriggNATGateway') {
160
160
  resourceType = 'AWS::EC2::NatGateway';
161
161
  physicalId = flatDiscovery.existingNatGatewayId;
162
+ } else if (logicalId === 'FriggLambdaRouteTable') {
163
+ resourceType = 'AWS::EC2::RouteTable';
164
+ physicalId = flatDiscovery.routeTableId;
162
165
  } else if (logicalId === 'FriggS3VPCEndpoint') {
163
166
  resourceType = 'AWS::EC2::VPCEndpoint';
164
167
  physicalId = flatDiscovery.s3VpcEndpointId;
@@ -184,6 +187,11 @@ class VpcBuilder extends InfrastructureBuilder {
184
187
  });
185
188
  }
186
189
  });
190
+
191
+ // Also check for external resources extracted via CloudFormation queries
192
+ // (e.g., VPC ID from security group query, subnets from route table associations)
193
+ // These are NOT in the stack but were discovered through stack resources
194
+ this._addExternalResourcesFromCloudFormationQueries(flatDiscovery, discovery, existingLogicalIds);
187
195
  } else {
188
196
  // Resources discovered from AWS API (not CloudFormation)
189
197
  // These go into external array
@@ -290,6 +298,58 @@ class VpcBuilder extends InfrastructureBuilder {
290
298
  return discovery;
291
299
  }
292
300
 
301
+ /**
302
+ * Add external resources that were discovered via CloudFormation queries
303
+ * (e.g., VPC ID extracted from security group, subnets from route table associations)
304
+ *
305
+ * @private
306
+ */
307
+ _addExternalResourcesFromCloudFormationQueries(flatDiscovery, discovery, existingLogicalIds) {
308
+ // VPC ID extracted from SG or route table (NOT a stack resource)
309
+ if (flatDiscovery.defaultVpcId &&
310
+ typeof flatDiscovery.defaultVpcId === 'string' &&
311
+ !existingLogicalIds.includes('FriggVPC')) {
312
+ discovery.external.push({
313
+ physicalId: flatDiscovery.defaultVpcId,
314
+ resourceType: 'AWS::EC2::VPC',
315
+ source: 'cloudformation-query'
316
+ });
317
+ }
318
+
319
+ // Subnets extracted from route table associations (NOT stack resources)
320
+ if (flatDiscovery.privateSubnetId1 &&
321
+ typeof flatDiscovery.privateSubnetId1 === 'string' &&
322
+ !existingLogicalIds.includes('FriggPrivateSubnet1')) {
323
+ discovery.external.push({
324
+ physicalId: flatDiscovery.privateSubnetId1,
325
+ resourceType: 'AWS::EC2::Subnet',
326
+ source: 'cloudformation-query'
327
+ });
328
+ }
329
+
330
+ if (flatDiscovery.privateSubnetId2 &&
331
+ typeof flatDiscovery.privateSubnetId2 === 'string' &&
332
+ !existingLogicalIds.includes('FriggPrivateSubnet2')) {
333
+ discovery.external.push({
334
+ physicalId: flatDiscovery.privateSubnetId2,
335
+ resourceType: 'AWS::EC2::Subnet',
336
+ source: 'cloudformation-query'
337
+ });
338
+ }
339
+
340
+ // NAT Gateway extracted from route table routes
341
+ if (flatDiscovery.existingNatGatewayId &&
342
+ typeof flatDiscovery.existingNatGatewayId === 'string' &&
343
+ !existingLogicalIds.includes('FriggNATGateway') &&
344
+ !existingLogicalIds.includes('FriggNatGateway')) {
345
+ discovery.external.push({
346
+ physicalId: flatDiscovery.existingNatGatewayId,
347
+ resourceType: 'AWS::EC2::NatGateway',
348
+ source: 'cloudformation-query'
349
+ });
350
+ }
351
+ }
352
+
293
353
  /**
294
354
  * Translate legacy configuration (management modes) to new ownership-based configuration
295
355
  * Provides backwards compatibility for existing app definitions
@@ -580,7 +640,6 @@ class VpcBuilder extends InfrastructureBuilder {
580
640
  buildSecurityGroupFromDecision(decision, appDefinition, result) {
581
641
  if (decision.ownership === ResourceOwnership.STACK) {
582
642
  // Always create security group resource in template
583
- // CloudFormation handles idempotency if it already exists
584
643
  console.log(' → Adding Lambda Security Group to template...');
585
644
 
586
645
  result.resources.FriggLambdaSecurityGroup = {
@@ -630,7 +689,6 @@ class VpcBuilder extends InfrastructureBuilder {
630
689
  }
631
690
 
632
691
  // For STACK ownership: ALWAYS add definitions to template
633
- // CloudFormation idempotency ensures existing resources won't be recreated
634
692
  if (decision.physicalIds && decision.physicalIds.length >= 2) {
635
693
  console.log(` → Adding subnet definitions to template (existing: ${decision.physicalIds.join(', ')})`);
636
694
  } else {
@@ -872,6 +930,8 @@ class VpcBuilder extends InfrastructureBuilder {
872
930
 
873
931
  if (endpointsInStack.length > 0) {
874
932
  console.log(` ✓ VPC Endpoints in stack: ${endpointsInStack.join(', ')}`);
933
+ // CRITICAL: Must add stack-managed endpoints back to template or CloudFormation will DELETE them!
934
+ this._addStackManagedEndpointsToTemplate(decisions, result);
875
935
  }
876
936
 
877
937
  if (externalEndpoints.length > 0) {
@@ -1052,6 +1112,101 @@ class VpcBuilder extends InfrastructureBuilder {
1052
1112
  return healingReport;
1053
1113
  }
1054
1114
 
1115
+ /**
1116
+ * Add stack-managed VPC endpoints back to template
1117
+ *
1118
+ * CRITICAL: CloudFormation will DELETE resources that exist in the previous template
1119
+ * but are missing from the new template. We must re-add discovered stack-managed
1120
+ * endpoints to prevent CloudFormation from deleting them.
1121
+ *
1122
+ * @private
1123
+ */
1124
+ _addStackManagedEndpointsToTemplate(decisions, result) {
1125
+ const vpcId = result.vpcId;
1126
+ const logicalIdMap = {
1127
+ s3: 'FriggS3VPCEndpoint',
1128
+ dynamodb: 'FriggDynamoDBVPCEndpoint',
1129
+ kms: 'FriggKMSVPCEndpoint',
1130
+ secretsManager: 'FriggSecretsManagerVPCEndpoint',
1131
+ sqs: 'FriggSQSVPCEndpoint'
1132
+ };
1133
+
1134
+ Object.entries(decisions).forEach(([type, decision]) => {
1135
+ if (decision.ownership === ResourceOwnership.STACK && decision.physicalId) {
1136
+ const logicalId = logicalIdMap[type];
1137
+
1138
+ // Determine endpoint type and properties based on service
1139
+ if (type === 's3') {
1140
+ result.resources[logicalId] = {
1141
+ Type: 'AWS::EC2::VPCEndpoint',
1142
+ Properties: {
1143
+ VpcId: vpcId,
1144
+ ServiceName: 'com.amazonaws.${self:provider.region}.s3',
1145
+ VpcEndpointType: 'Gateway',
1146
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }]
1147
+ }
1148
+ };
1149
+ } else if (type === 'dynamodb') {
1150
+ result.resources[logicalId] = {
1151
+ Type: 'AWS::EC2::VPCEndpoint',
1152
+ Properties: {
1153
+ VpcId: vpcId,
1154
+ ServiceName: 'com.amazonaws.${self:provider.region}.dynamodb',
1155
+ VpcEndpointType: 'Gateway',
1156
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }]
1157
+ }
1158
+ };
1159
+ } else {
1160
+ // Interface endpoints (KMS, Secrets Manager, SQS)
1161
+ const serviceMap = {
1162
+ kms: 'kms',
1163
+ secretsManager: 'secretsmanager',
1164
+ sqs: 'sqs'
1165
+ };
1166
+
1167
+ result.resources[logicalId] = {
1168
+ Type: 'AWS::EC2::VPCEndpoint',
1169
+ Properties: {
1170
+ VpcId: vpcId,
1171
+ ServiceName: `com.amazonaws.\${self:provider.region}.${serviceMap[type]}`,
1172
+ VpcEndpointType: 'Interface',
1173
+ SubnetIds: result.vpcConfig.subnetIds,
1174
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
1175
+ PrivateDnsEnabled: true
1176
+ }
1177
+ };
1178
+ }
1179
+ }
1180
+ });
1181
+
1182
+ // If any interface endpoints exist, ensure security group is in template
1183
+ const hasInterfaceEndpoints = ['kms', 'secretsManager', 'sqs'].some(
1184
+ type => decisions[type]?.ownership === ResourceOwnership.STACK && decisions[type]?.physicalId
1185
+ );
1186
+
1187
+ if (hasInterfaceEndpoints && !result.resources.FriggVPCEndpointSecurityGroup) {
1188
+ result.resources.FriggVPCEndpointSecurityGroup = {
1189
+ Type: 'AWS::EC2::SecurityGroup',
1190
+ Properties: {
1191
+ GroupDescription: 'Security group for VPC Endpoints',
1192
+ VpcId: vpcId,
1193
+ SecurityGroupIngress: [
1194
+ {
1195
+ IpProtocol: 'tcp',
1196
+ FromPort: 443,
1197
+ ToPort: 443,
1198
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' }
1199
+ }
1200
+ ],
1201
+ Tags: [
1202
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc-endpoint-sg' },
1203
+ { Key: 'ManagedBy', Value: 'Frigg' }
1204
+ ]
1205
+ }
1206
+ };
1207
+ }
1208
+ }
1209
+
1055
1210
  /**
1056
1211
  * Build new VPC from scratch
1057
1212
  */
@@ -1661,7 +1816,7 @@ class VpcBuilder extends InfrastructureBuilder {
1661
1816
  // Stack-managed resources should be reused, not recreated
1662
1817
  const stackManagedEndpoints = {
1663
1818
  s3: discoveredResources.s3VpcEndpointId && typeof discoveredResources.s3VpcEndpointId === 'string',
1664
- dynamodb: discoveredResources.dynamoDbVpcEndpointId && typeof discoveredResources.dynamoDbVpcEndpointId === 'string',
1819
+ dynamodb: discoveredResources.dynamodbVpcEndpointId && typeof discoveredResources.dynamodbVpcEndpointId === 'string',
1665
1820
  kms: discoveredResources.kmsVpcEndpointId && typeof discoveredResources.kmsVpcEndpointId === 'string',
1666
1821
  secretsManager: discoveredResources.secretsManagerVpcEndpointId && typeof discoveredResources.secretsManagerVpcEndpointId === 'string',
1667
1822
  sqs: discoveredResources.sqsVpcEndpointId && typeof discoveredResources.sqsVpcEndpointId === 'string',
@@ -398,19 +398,93 @@ describe('VpcBuilder', () => {
398
398
  expect(result.resources.FriggS3VPCEndpoint.Properties.VpcId).toBe('vpc-123');
399
399
  });
400
400
 
401
- it('should reuse stack-managed VPC endpoints without creating CloudFormation resources', async () => {
401
+ it('should add stack-managed security group back to template to prevent deletion', async () => {
402
402
  const appDefinition = {
403
- vpc: { enable: true, enableVPCEndpoints: true },
403
+ vpc: { enable: true },
404
+ };
405
+
406
+ const discoveredResources = {
407
+ fromCloudFormationStack: true,
408
+ stackName: 'test-stack',
409
+ existingLogicalIds: ['FriggLambdaSecurityGroup'],
410
+ defaultVpcId: 'vpc-123',
411
+ privateSubnetId1: 'subnet-1',
412
+ privateSubnetId2: 'subnet-2',
413
+ lambdaSecurityGroupId: 'sg-existing-stack', // Existing in stack
414
+ };
415
+
416
+ const result = await vpcBuilder.build(appDefinition, discoveredResources);
417
+
418
+ // CRITICAL: Must RE-ADD stack-managed SG to template or CloudFormation will DELETE it
419
+ expect(result.resources.FriggLambdaSecurityGroup).toBeDefined();
420
+ expect(result.resources.FriggLambdaSecurityGroup.Type).toBe('AWS::EC2::SecurityGroup');
421
+ expect(result.resources.FriggLambdaSecurityGroup.Properties.VpcId).toBe('vpc-123');
422
+ expect(result.resources.FriggLambdaSecurityGroup.Properties.GroupDescription).toBeDefined();
423
+
424
+ // Should use Ref in Lambda config (not recreating)
425
+ expect(result.vpcConfig.securityGroupIds).toContainEqual({ Ref: 'FriggLambdaSecurityGroup' });
426
+ });
427
+
428
+ it('should add stack-managed subnets back to template to prevent deletion', async () => {
429
+ const appDefinition = {
430
+ vpc: { enable: true },
431
+ };
432
+
433
+ const discoveredResources = {
434
+ fromCloudFormationStack: true,
435
+ stackName: 'test-stack',
436
+ existingLogicalIds: ['FriggPrivateSubnet1', 'FriggPrivateSubnet2'],
437
+ defaultVpcId: 'vpc-123',
438
+ // Subnets exist in stack with specific IDs
439
+ privateSubnetId1: 'subnet-existing-1',
440
+ privateSubnetId2: 'subnet-existing-2',
441
+ };
442
+
443
+ const result = await vpcBuilder.build(appDefinition, discoveredResources);
444
+
445
+ // CRITICAL: Must RE-ADD stack-managed subnets to template or CloudFormation will DELETE them
446
+ expect(result.resources.FriggPrivateSubnet1).toBeDefined();
447
+ expect(result.resources.FriggPrivateSubnet1.Type).toBe('AWS::EC2::Subnet');
448
+ expect(result.resources.FriggPrivateSubnet1.Properties.VpcId).toBe('vpc-123');
449
+
450
+ expect(result.resources.FriggPrivateSubnet2).toBeDefined();
451
+ expect(result.resources.FriggPrivateSubnet2.Type).toBe('AWS::EC2::Subnet');
452
+ expect(result.resources.FriggPrivateSubnet2.Properties.VpcId).toBe('vpc-123');
453
+
454
+ // Should use Refs (not external IDs)
455
+ expect(result.vpcConfig.subnetIds).toEqual([
456
+ { Ref: 'FriggPrivateSubnet1' },
457
+ { Ref: 'FriggPrivateSubnet2' }
458
+ ]);
459
+ });
460
+
461
+ it('should add stack-managed VPC endpoints back to template to prevent deletion', async () => {
462
+ const appDefinition = {
463
+ vpc: { enable: true },
404
464
  encryption: { fieldLevelEncryptionMethod: 'kms' },
405
- database: { postgres: { enable: true } },
406
465
  };
466
+
467
+ // Structured discovery from CloudFormation
407
468
  const discoveredResources = {
469
+ fromCloudFormationStack: true,
470
+ stackName: 'test-stack',
471
+ existingLogicalIds: [
472
+ 'FriggLambdaSecurityGroup',
473
+ 'FriggLambdaRouteTable',
474
+ 'FriggS3VPCEndpoint',
475
+ 'FriggDynamoDBVPCEndpoint',
476
+ 'FriggKMSVPCEndpoint',
477
+ 'FriggSecretsManagerVPCEndpoint',
478
+ 'FriggSQSVPCEndpoint'
479
+ ],
408
480
  defaultVpcId: 'vpc-123',
409
481
  privateSubnetId1: 'subnet-1',
410
482
  privateSubnetId2: 'subnet-2',
411
- // VPC endpoints from CloudFormation stack (string IDs)
483
+ routeTableId: 'rtb-123',
484
+ lambdaSecurityGroupId: 'sg-123',
485
+ // VPC endpoints discovered in stack
412
486
  s3VpcEndpointId: 'vpce-s3-stack',
413
- dynamoDbVpcEndpointId: 'vpce-ddb-stack',
487
+ dynamodbVpcEndpointId: 'vpce-ddb-stack',
414
488
  kmsVpcEndpointId: 'vpce-kms-stack',
415
489
  secretsManagerVpcEndpointId: 'vpce-sm-stack',
416
490
  sqsVpcEndpointId: 'vpce-sqs-stack',
@@ -418,15 +492,30 @@ describe('VpcBuilder', () => {
418
492
 
419
493
  const result = await vpcBuilder.build(appDefinition, discoveredResources);
420
494
 
421
- // Should NOT create CloudFormation resources (reuse stack endpoints)
422
- expect(result.resources.FriggS3VPCEndpoint).toBeUndefined();
423
- expect(result.resources.FriggDynamoDBVPCEndpoint).toBeUndefined();
424
- expect(result.resources.FriggKMSVPCEndpoint).toBeUndefined();
425
- expect(result.resources.FriggSecretsManagerVPCEndpoint).toBeUndefined();
426
- expect(result.resources.FriggSQSVPCEndpoint).toBeUndefined();
495
+ // CRITICAL: Must RE-ADD stack-managed endpoints to template or CloudFormation will DELETE them
496
+ expect(result.resources.FriggS3VPCEndpoint).toBeDefined();
497
+ expect(result.resources.FriggS3VPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
498
+ expect(result.resources.FriggS3VPCEndpoint.Properties.VpcEndpointType).toBe('Gateway');
499
+
500
+ expect(result.resources.FriggDynamoDBVPCEndpoint).toBeDefined();
501
+ expect(result.resources.FriggDynamoDBVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
502
+ expect(result.resources.FriggDynamoDBVPCEndpoint.Properties.VpcEndpointType).toBe('Gateway');
503
+
504
+ expect(result.resources.FriggKMSVPCEndpoint).toBeDefined();
505
+ expect(result.resources.FriggKMSVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
506
+ expect(result.resources.FriggKMSVPCEndpoint.Properties.VpcEndpointType).toBe('Interface');
507
+
508
+ expect(result.resources.FriggSecretsManagerVPCEndpoint).toBeDefined();
509
+ expect(result.resources.FriggSecretsManagerVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
510
+ expect(result.resources.FriggSecretsManagerVPCEndpoint.Properties.VpcEndpointType).toBe('Interface');
511
+
512
+ expect(result.resources.FriggSQSVPCEndpoint).toBeDefined();
513
+ expect(result.resources.FriggSQSVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
514
+ expect(result.resources.FriggSQSVPCEndpoint.Properties.VpcEndpointType).toBe('Interface');
427
515
 
428
- // Should still NOT create VPC Endpoint Security Group
429
- expect(result.resources.FriggVPCEndpointSecurityGroup).toBeUndefined();
516
+ // Should create VPC Endpoint Security Group for interface endpoints
517
+ expect(result.resources.FriggVPCEndpointSecurityGroup).toBeDefined();
518
+ expect(result.resources.FriggVPCEndpointSecurityGroup.Type).toBe('AWS::EC2::SecurityGroup');
430
519
  });
431
520
 
432
521
  it('should create VPC endpoints when discovered from AWS but not stack', async () => {
@@ -1258,5 +1347,145 @@ describe('VpcBuilder', () => {
1258
1347
  expect(result.outputs.PrivateSubnet2Id).toBeDefined();
1259
1348
  });
1260
1349
  });
1350
+
1351
+ describe('convertFlatDiscoveryToStructured - CloudFormation query results', () => {
1352
+ it('should add VPC from CloudFormation query to external array', () => {
1353
+ const flatDiscovery = {
1354
+ fromCloudFormationStack: true,
1355
+ stackName: 'test-stack',
1356
+ existingLogicalIds: ['FriggLambdaRouteTable', 'FriggLambdaSecurityGroup'],
1357
+ // VPC ID was extracted from security group query (NOT a stack resource)
1358
+ defaultVpcId: 'vpc-extracted-from-sg',
1359
+ lambdaSecurityGroupId: 'sg-123',
1360
+ routeTableId: 'rtb-123'
1361
+ };
1362
+
1363
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1364
+
1365
+ // VPC should be in external array (discovered via query, not in stack)
1366
+ const vpcExternal = result.external.find(r => r.resourceType === 'AWS::EC2::VPC');
1367
+ expect(vpcExternal).toBeDefined();
1368
+ expect(vpcExternal.physicalId).toBe('vpc-extracted-from-sg');
1369
+ expect(vpcExternal.source).toBe('cloudformation-query');
1370
+
1371
+ // Security group SHOULD be in stackManaged (is in stack)
1372
+ const sgStack = result.stackManaged.find(r => r.logicalId === 'FriggLambdaSecurityGroup');
1373
+ expect(sgStack).toBeDefined();
1374
+ expect(sgStack.physicalId).toBe('sg-123');
1375
+ });
1376
+
1377
+ it('should add subnets from route table associations to external array', () => {
1378
+ const flatDiscovery = {
1379
+ fromCloudFormationStack: true,
1380
+ stackName: 'test-stack',
1381
+ existingLogicalIds: ['FriggLambdaRouteTable'],
1382
+ routeTableId: 'rtb-123',
1383
+ // Subnets extracted from route table associations (NOT stack resources)
1384
+ privateSubnetId1: 'subnet-1',
1385
+ privateSubnetId2: 'subnet-2'
1386
+ };
1387
+
1388
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1389
+
1390
+ // Subnets should be in external array
1391
+ const subnet1 = result.external.find(r => r.physicalId === 'subnet-1');
1392
+ const subnet2 = result.external.find(r => r.physicalId === 'subnet-2');
1393
+
1394
+ expect(subnet1).toBeDefined();
1395
+ expect(subnet1.resourceType).toBe('AWS::EC2::Subnet');
1396
+ expect(subnet1.source).toBe('cloudformation-query');
1397
+
1398
+ expect(subnet2).toBeDefined();
1399
+ expect(subnet2.resourceType).toBe('AWS::EC2::Subnet');
1400
+ expect(subnet2.source).toBe('cloudformation-query');
1401
+ });
1402
+
1403
+ it('should add NAT Gateway from route table queries to external array', () => {
1404
+ const flatDiscovery = {
1405
+ fromCloudFormationStack: true,
1406
+ stackName: 'test-stack',
1407
+ existingLogicalIds: ['FriggLambdaRouteTable', 'FriggPrivateRoute'],
1408
+ routeTableId: 'rtb-123',
1409
+ // NAT Gateway extracted from route table routes (NOT a stack resource)
1410
+ existingNatGatewayId: 'nat-extracted'
1411
+ };
1412
+
1413
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1414
+
1415
+ // NAT should be in external array
1416
+ const natExternal = result.external.find(r => r.resourceType === 'AWS::EC2::NatGateway');
1417
+ expect(natExternal).toBeDefined();
1418
+ expect(natExternal.physicalId).toBe('nat-extracted');
1419
+ expect(natExternal.source).toBe('cloudformation-query');
1420
+ });
1421
+
1422
+ it('should NOT add resources to external if they are in stack', () => {
1423
+ const flatDiscovery = {
1424
+ fromCloudFormationStack: true,
1425
+ stackName: 'test-stack',
1426
+ existingLogicalIds: ['FriggVPC', 'FriggPrivateSubnet1'],
1427
+ // These ARE in the stack
1428
+ defaultVpcId: 'vpc-in-stack',
1429
+ privateSubnetId1: 'subnet-in-stack'
1430
+ };
1431
+
1432
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1433
+
1434
+ // Should be in stackManaged, NOT external
1435
+ expect(result.stackManaged.some(r => r.logicalId === 'FriggVPC')).toBe(true);
1436
+ expect(result.stackManaged.some(r => r.logicalId === 'FriggPrivateSubnet1')).toBe(true);
1437
+
1438
+ // Should NOT be in external
1439
+ expect(result.external.some(r => r.physicalId === 'vpc-in-stack')).toBe(false);
1440
+ expect(result.external.some(r => r.physicalId === 'subnet-in-stack')).toBe(false);
1441
+ });
1442
+
1443
+ it('should handle Frontify pattern: stack resources + queried external references', () => {
1444
+ const flatDiscovery = {
1445
+ fromCloudFormationStack: true,
1446
+ stackName: 'create-frigg-app-production',
1447
+ existingLogicalIds: [
1448
+ 'FriggLambdaSecurityGroup',
1449
+ 'FriggLambdaRouteTable',
1450
+ 'FriggPrivateRoute',
1451
+ 'FriggPrivateSubnet1RouteTableAssociation',
1452
+ 'FriggPrivateSubnet2RouteTableAssociation',
1453
+ 'FriggS3VPCEndpoint',
1454
+ 'FriggDynamoDBVPCEndpoint',
1455
+ 'FriggKMSVPCEndpoint'
1456
+ ],
1457
+ // Stack resources
1458
+ lambdaSecurityGroupId: 'sg-01002240c6a446202',
1459
+ routeTableId: 'rtb-08af43bbf0775602d',
1460
+ s3VpcEndpointId: 'vpce-s3',
1461
+ // External resources (discovered via queries)
1462
+ defaultVpcId: 'vpc-0cd17c0e06cb28b28',
1463
+ privateSubnetId1: 'subnet-034f6562dbbc16348',
1464
+ privateSubnetId2: 'subnet-0b8be2b82aeb5cdec',
1465
+ existingNatGatewayId: 'nat-022660c36a47e2d79'
1466
+ };
1467
+
1468
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1469
+
1470
+ // Stack resources should be in stackManaged
1471
+ expect(result.stackManaged).toEqual(
1472
+ expect.arrayContaining([
1473
+ expect.objectContaining({ logicalId: 'FriggLambdaSecurityGroup', physicalId: 'sg-01002240c6a446202' }),
1474
+ expect.objectContaining({ logicalId: 'FriggLambdaRouteTable', physicalId: 'rtb-08af43bbf0775602d' }),
1475
+ expect.objectContaining({ logicalId: 'FriggS3VPCEndpoint' })
1476
+ ])
1477
+ );
1478
+
1479
+ // External resources should be in external array
1480
+ expect(result.external).toEqual(
1481
+ expect.arrayContaining([
1482
+ expect.objectContaining({ physicalId: 'vpc-0cd17c0e06cb28b28', resourceType: 'AWS::EC2::VPC', source: 'cloudformation-query' }),
1483
+ expect.objectContaining({ physicalId: 'subnet-034f6562dbbc16348', resourceType: 'AWS::EC2::Subnet', source: 'cloudformation-query' }),
1484
+ expect.objectContaining({ physicalId: 'subnet-0b8be2b82aeb5cdec', resourceType: 'AWS::EC2::Subnet', source: 'cloudformation-query' }),
1485
+ expect.objectContaining({ physicalId: 'nat-022660c36a47e2d79', resourceType: 'AWS::EC2::NatGateway', source: 'cloudformation-query' })
1486
+ ])
1487
+ );
1488
+ });
1489
+ });
1261
1490
  });
1262
1491
 
@@ -23,6 +23,7 @@ class VpcResourceResolver extends BaseResourceResolver {
23
23
  */
24
24
  resolveVpc(appDefinition, discovery) {
25
25
  const userIntent = appDefinition.vpc?.ownership?.vpc || 'auto';
26
+ const vpcManagement = appDefinition.vpc?.management; // Legacy config
26
27
 
27
28
  // Explicit external
28
29
  if (userIntent === 'external') {
@@ -43,20 +44,35 @@ class VpcResourceResolver extends BaseResourceResolver {
43
44
  }
44
45
 
45
46
  // Auto-decide
46
- return this.resolveResourceOwnership(
47
+ const decision = this.resolveResourceOwnership(
47
48
  'auto',
48
49
  'FriggVPC',
49
50
  'AWS::EC2::VPC',
50
51
  discovery
51
52
  );
53
+
54
+ // CRITICAL: If auto-resolution wants to create a VPC but management mode is 'discover' (or undefined),
55
+ // throw an error instead. Creating a VPC is expensive and should be explicit.
56
+ if (decision.ownership === ResourceOwnership.STACK &&
57
+ !decision.physicalId &&
58
+ vpcManagement !== 'create-new' &&
59
+ userIntent === 'auto') {
60
+ throw new Error(
61
+ 'VPC discovery failed: No VPC found. ' +
62
+ 'Either set vpc.management to "create-new" or provide vpc.vpcId with vpc.management "use-existing".'
63
+ );
64
+ }
65
+
66
+ return decision;
52
67
  }
53
68
 
54
69
  /**
55
70
  * Resolve Security Group ownership
56
71
  *
57
- * Special logic: We ALWAYS create our own FriggLambdaSecurityGroup with specific
58
- * rules unless the user explicitly provides external SG IDs. The discovered
59
- * defaultSecurityGroupId is the VPC's default SG, but we need our own Lambda SG.
72
+ * Logic:
73
+ * - If FriggLambdaSecurityGroup exists in stack STACK (keep it)
74
+ * - If default SG discovered from VPC EXTERNAL (use it)
75
+ * - Otherwise → STACK (create FriggLambdaSecurityGroup)
60
76
  *
61
77
  * @param {Object} appDefinition - App definition
62
78
  * @param {Object} discovery - Discovery result
@@ -65,7 +81,7 @@ class VpcResourceResolver extends BaseResourceResolver {
65
81
  resolveSecurityGroup(appDefinition, discovery) {
66
82
  const userIntent = appDefinition.vpc?.ownership?.securityGroup || 'auto';
67
83
 
68
- // Explicit external - only use external SGs if user explicitly provides them
84
+ // Explicit external - use provided SG IDs
69
85
  if (userIntent === 'external') {
70
86
  this.requireExternalIds(
71
87
  appDefinition.vpc?.external?.securityGroupIds,
@@ -77,21 +93,52 @@ class VpcResourceResolver extends BaseResourceResolver {
77
93
  );
78
94
  }
79
95
 
80
- // For stack or auto: check if FriggLambdaSecurityGroup exists in stack
81
- // If it does, reuse it. If not, create it. Never use discovered default SG.
96
+ // Explicit stack - always create FriggLambdaSecurityGroup
97
+ if (userIntent === 'stack') {
98
+ const inStack = this.findInStack('FriggLambdaSecurityGroup', discovery);
99
+ return this.createStackDecision(
100
+ inStack?.physicalId,
101
+ inStack
102
+ ? 'Found FriggLambdaSecurityGroup in CloudFormation stack'
103
+ : 'User specified ownership=stack - will create FriggLambdaSecurityGroup'
104
+ );
105
+ }
106
+
107
+ // Auto mode: Check stack first, then check for discovered default SG
82
108
  const inStack = this.findInStack('FriggLambdaSecurityGroup', discovery);
83
109
 
84
110
  if (inStack) {
85
111
  return this.createStackDecision(
86
112
  inStack.physicalId,
87
- 'Found FriggLambdaSecurityGroup in CloudFormation stack'
113
+ 'Found FriggLambdaSecurityGroup in CloudFormation stack - must keep in template'
114
+ );
115
+ }
116
+
117
+ // Also check flat discovery for lambdaSecurityGroupId (from CloudFormation extraction)
118
+ const structured = discovery._structured || discovery;
119
+ const lambdaSgId = structured.lambdaSecurityGroupId || discovery.lambdaSecurityGroupId;
120
+
121
+ if (lambdaSgId) {
122
+ return this.createStackDecision(
123
+ lambdaSgId,
124
+ 'Found FriggLambdaSecurityGroup in CloudFormation stack - must keep in template'
125
+ );
126
+ }
127
+
128
+ // Check for discovered default security group (from external VPC pattern)
129
+ const defaultSgId = structured.defaultSecurityGroupId || discovery.defaultSecurityGroupId;
130
+
131
+ if (defaultSgId) {
132
+ return this.createExternalDecision(
133
+ [defaultSgId],
134
+ 'Found default security group via discovery - will reuse (matches canary behavior)'
88
135
  );
89
136
  }
90
137
 
91
- // Create new FriggLambdaSecurityGroup in stack
138
+ // No SG found anywhere - create new FriggLambdaSecurityGroup
92
139
  return this.createStackDecision(
93
140
  null,
94
- 'No existing FriggLambdaSecurityGroup - will create in stack'
141
+ 'No security group found - will create FriggLambdaSecurityGroup in stack'
95
142
  );
96
143
  }
97
144
 
@@ -109,6 +109,46 @@ describe('VpcResourceResolver', () => {
109
109
  expect(decision.physicalId).toBeUndefined();
110
110
  expect(decision.reason).toContain('No existing resource found');
111
111
  });
112
+
113
+ it('should throw error when auto mode finds no VPC and management is not create-new', () => {
114
+ const appDefinition = {
115
+ vpc: {
116
+ enable: true,
117
+ // No management specified - defaults to discover
118
+ // No ownership specified - defaults to auto
119
+ }
120
+ };
121
+ const discovery = {
122
+ stackManaged: [],
123
+ external: [],
124
+ fromCloudFormation: false
125
+ };
126
+
127
+ // Should throw error instead of trying to create VPC
128
+ expect(() => resolver.resolveVpc(appDefinition, discovery)).toThrow(
129
+ 'VPC discovery failed: No VPC found'
130
+ );
131
+ });
132
+
133
+ it('should allow creating VPC when management is create-new', () => {
134
+ const appDefinition = {
135
+ vpc: {
136
+ enable: true,
137
+ management: 'create-new'
138
+ }
139
+ };
140
+ const discovery = {
141
+ stackManaged: [],
142
+ external: [],
143
+ fromCloudFormation: false
144
+ };
145
+
146
+ // Should NOT throw - create-new explicitly allows VPC creation
147
+ const decision = resolver.resolveVpc(appDefinition, discovery);
148
+
149
+ expect(decision.ownership).toBe(ResourceOwnership.STACK);
150
+ expect(decision.physicalId).toBeNull();
151
+ });
112
152
  });
113
153
 
114
154
  describe('resolveSecurityGroup', () => {