@friggframework/devtools 2.0.0--canary.493.f8d621f.0 → 2.0.0--canary.490.71c435d.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
@@ -872,6 +932,8 @@ class VpcBuilder extends InfrastructureBuilder {
872
932
 
873
933
  if (endpointsInStack.length > 0) {
874
934
  console.log(` ✓ VPC Endpoints in stack: ${endpointsInStack.join(', ')}`);
935
+ // CRITICAL: Must add stack-managed endpoints back to template or CloudFormation will DELETE them!
936
+ this._addStackManagedEndpointsToTemplate(decisions, result);
875
937
  }
876
938
 
877
939
  if (externalEndpoints.length > 0) {
@@ -1052,6 +1114,101 @@ class VpcBuilder extends InfrastructureBuilder {
1052
1114
  return healingReport;
1053
1115
  }
1054
1116
 
1117
+ /**
1118
+ * Add stack-managed VPC endpoints back to template
1119
+ *
1120
+ * CRITICAL: CloudFormation will DELETE resources that exist in the previous template
1121
+ * but are missing from the new template. We must re-add discovered stack-managed
1122
+ * endpoints to prevent CloudFormation from deleting them.
1123
+ *
1124
+ * @private
1125
+ */
1126
+ _addStackManagedEndpointsToTemplate(decisions, result) {
1127
+ const vpcId = result.vpcId;
1128
+ const logicalIdMap = {
1129
+ s3: 'FriggS3VPCEndpoint',
1130
+ dynamodb: 'FriggDynamoDBVPCEndpoint',
1131
+ kms: 'FriggKMSVPCEndpoint',
1132
+ secretsManager: 'FriggSecretsManagerVPCEndpoint',
1133
+ sqs: 'FriggSQSVPCEndpoint'
1134
+ };
1135
+
1136
+ Object.entries(decisions).forEach(([type, decision]) => {
1137
+ if (decision.ownership === ResourceOwnership.STACK && decision.physicalId) {
1138
+ const logicalId = logicalIdMap[type];
1139
+
1140
+ // Determine endpoint type and properties based on service
1141
+ if (type === 's3') {
1142
+ result.resources[logicalId] = {
1143
+ Type: 'AWS::EC2::VPCEndpoint',
1144
+ Properties: {
1145
+ VpcId: vpcId,
1146
+ ServiceName: 'com.amazonaws.${self:provider.region}.s3',
1147
+ VpcEndpointType: 'Gateway',
1148
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }]
1149
+ }
1150
+ };
1151
+ } else if (type === 'dynamodb') {
1152
+ result.resources[logicalId] = {
1153
+ Type: 'AWS::EC2::VPCEndpoint',
1154
+ Properties: {
1155
+ VpcId: vpcId,
1156
+ ServiceName: 'com.amazonaws.${self:provider.region}.dynamodb',
1157
+ VpcEndpointType: 'Gateway',
1158
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }]
1159
+ }
1160
+ };
1161
+ } else {
1162
+ // Interface endpoints (KMS, Secrets Manager, SQS)
1163
+ const serviceMap = {
1164
+ kms: 'kms',
1165
+ secretsManager: 'secretsmanager',
1166
+ sqs: 'sqs'
1167
+ };
1168
+
1169
+ result.resources[logicalId] = {
1170
+ Type: 'AWS::EC2::VPCEndpoint',
1171
+ Properties: {
1172
+ VpcId: vpcId,
1173
+ ServiceName: `com.amazonaws.\${self:provider.region}.${serviceMap[type]}`,
1174
+ VpcEndpointType: 'Interface',
1175
+ SubnetIds: result.vpcConfig.subnetIds,
1176
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
1177
+ PrivateDnsEnabled: true
1178
+ }
1179
+ };
1180
+ }
1181
+ }
1182
+ });
1183
+
1184
+ // If any interface endpoints exist, ensure security group is in template
1185
+ const hasInterfaceEndpoints = ['kms', 'secretsManager', 'sqs'].some(
1186
+ type => decisions[type]?.ownership === ResourceOwnership.STACK && decisions[type]?.physicalId
1187
+ );
1188
+
1189
+ if (hasInterfaceEndpoints && !result.resources.FriggVPCEndpointSecurityGroup) {
1190
+ result.resources.FriggVPCEndpointSecurityGroup = {
1191
+ Type: 'AWS::EC2::SecurityGroup',
1192
+ Properties: {
1193
+ GroupDescription: 'Security group for VPC Endpoints',
1194
+ VpcId: vpcId,
1195
+ SecurityGroupIngress: [
1196
+ {
1197
+ IpProtocol: 'tcp',
1198
+ FromPort: 443,
1199
+ ToPort: 443,
1200
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' }
1201
+ }
1202
+ ],
1203
+ Tags: [
1204
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc-endpoint-sg' },
1205
+ { Key: 'ManagedBy', Value: 'Frigg' }
1206
+ ]
1207
+ }
1208
+ };
1209
+ }
1210
+ }
1211
+
1055
1212
  /**
1056
1213
  * Build new VPC from scratch
1057
1214
  */
@@ -1661,7 +1818,7 @@ class VpcBuilder extends InfrastructureBuilder {
1661
1818
  // Stack-managed resources should be reused, not recreated
1662
1819
  const stackManagedEndpoints = {
1663
1820
  s3: discoveredResources.s3VpcEndpointId && typeof discoveredResources.s3VpcEndpointId === 'string',
1664
- dynamodb: discoveredResources.dynamoDbVpcEndpointId && typeof discoveredResources.dynamoDbVpcEndpointId === 'string',
1821
+ dynamodb: discoveredResources.dynamodbVpcEndpointId && typeof discoveredResources.dynamodbVpcEndpointId === 'string',
1665
1822
  kms: discoveredResources.kmsVpcEndpointId && typeof discoveredResources.kmsVpcEndpointId === 'string',
1666
1823
  secretsManager: discoveredResources.secretsManagerVpcEndpointId && typeof discoveredResources.secretsManagerVpcEndpointId === 'string',
1667
1824
  sqs: discoveredResources.sqsVpcEndpointId && typeof discoveredResources.sqsVpcEndpointId === 'string',
@@ -398,19 +398,33 @@ 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 VPC endpoints back to template to prevent deletion', async () => {
402
402
  const appDefinition = {
403
- vpc: { enable: true, enableVPCEndpoints: true },
403
+ vpc: { enable: true },
404
404
  encryption: { fieldLevelEncryptionMethod: 'kms' },
405
- database: { postgres: { enable: true } },
406
405
  };
406
+
407
+ // Structured discovery from CloudFormation
407
408
  const discoveredResources = {
409
+ fromCloudFormationStack: true,
410
+ stackName: 'test-stack',
411
+ existingLogicalIds: [
412
+ 'FriggLambdaSecurityGroup',
413
+ 'FriggLambdaRouteTable',
414
+ 'FriggS3VPCEndpoint',
415
+ 'FriggDynamoDBVPCEndpoint',
416
+ 'FriggKMSVPCEndpoint',
417
+ 'FriggSecretsManagerVPCEndpoint',
418
+ 'FriggSQSVPCEndpoint'
419
+ ],
408
420
  defaultVpcId: 'vpc-123',
409
421
  privateSubnetId1: 'subnet-1',
410
422
  privateSubnetId2: 'subnet-2',
411
- // VPC endpoints from CloudFormation stack (string IDs)
423
+ routeTableId: 'rtb-123',
424
+ lambdaSecurityGroupId: 'sg-123',
425
+ // VPC endpoints discovered in stack
412
426
  s3VpcEndpointId: 'vpce-s3-stack',
413
- dynamoDbVpcEndpointId: 'vpce-ddb-stack',
427
+ dynamodbVpcEndpointId: 'vpce-ddb-stack',
414
428
  kmsVpcEndpointId: 'vpce-kms-stack',
415
429
  secretsManagerVpcEndpointId: 'vpce-sm-stack',
416
430
  sqsVpcEndpointId: 'vpce-sqs-stack',
@@ -418,15 +432,30 @@ describe('VpcBuilder', () => {
418
432
 
419
433
  const result = await vpcBuilder.build(appDefinition, discoveredResources);
420
434
 
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();
435
+ // CRITICAL: Must RE-ADD stack-managed endpoints to template or CloudFormation will DELETE them
436
+ expect(result.resources.FriggS3VPCEndpoint).toBeDefined();
437
+ expect(result.resources.FriggS3VPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
438
+ expect(result.resources.FriggS3VPCEndpoint.Properties.VpcEndpointType).toBe('Gateway');
439
+
440
+ expect(result.resources.FriggDynamoDBVPCEndpoint).toBeDefined();
441
+ expect(result.resources.FriggDynamoDBVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
442
+ expect(result.resources.FriggDynamoDBVPCEndpoint.Properties.VpcEndpointType).toBe('Gateway');
443
+
444
+ expect(result.resources.FriggKMSVPCEndpoint).toBeDefined();
445
+ expect(result.resources.FriggKMSVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
446
+ expect(result.resources.FriggKMSVPCEndpoint.Properties.VpcEndpointType).toBe('Interface');
447
+
448
+ expect(result.resources.FriggSecretsManagerVPCEndpoint).toBeDefined();
449
+ expect(result.resources.FriggSecretsManagerVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
450
+ expect(result.resources.FriggSecretsManagerVPCEndpoint.Properties.VpcEndpointType).toBe('Interface');
451
+
452
+ expect(result.resources.FriggSQSVPCEndpoint).toBeDefined();
453
+ expect(result.resources.FriggSQSVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint');
454
+ expect(result.resources.FriggSQSVPCEndpoint.Properties.VpcEndpointType).toBe('Interface');
427
455
 
428
- // Should still NOT create VPC Endpoint Security Group
429
- expect(result.resources.FriggVPCEndpointSecurityGroup).toBeUndefined();
456
+ // Should create VPC Endpoint Security Group for interface endpoints
457
+ expect(result.resources.FriggVPCEndpointSecurityGroup).toBeDefined();
458
+ expect(result.resources.FriggVPCEndpointSecurityGroup.Type).toBe('AWS::EC2::SecurityGroup');
430
459
  });
431
460
 
432
461
  it('should create VPC endpoints when discovered from AWS but not stack', async () => {
@@ -1258,5 +1287,145 @@ describe('VpcBuilder', () => {
1258
1287
  expect(result.outputs.PrivateSubnet2Id).toBeDefined();
1259
1288
  });
1260
1289
  });
1290
+
1291
+ describe('convertFlatDiscoveryToStructured - CloudFormation query results', () => {
1292
+ it('should add VPC from CloudFormation query to external array', () => {
1293
+ const flatDiscovery = {
1294
+ fromCloudFormationStack: true,
1295
+ stackName: 'test-stack',
1296
+ existingLogicalIds: ['FriggLambdaRouteTable', 'FriggLambdaSecurityGroup'],
1297
+ // VPC ID was extracted from security group query (NOT a stack resource)
1298
+ defaultVpcId: 'vpc-extracted-from-sg',
1299
+ lambdaSecurityGroupId: 'sg-123',
1300
+ routeTableId: 'rtb-123'
1301
+ };
1302
+
1303
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1304
+
1305
+ // VPC should be in external array (discovered via query, not in stack)
1306
+ const vpcExternal = result.external.find(r => r.resourceType === 'AWS::EC2::VPC');
1307
+ expect(vpcExternal).toBeDefined();
1308
+ expect(vpcExternal.physicalId).toBe('vpc-extracted-from-sg');
1309
+ expect(vpcExternal.source).toBe('cloudformation-query');
1310
+
1311
+ // Security group SHOULD be in stackManaged (is in stack)
1312
+ const sgStack = result.stackManaged.find(r => r.logicalId === 'FriggLambdaSecurityGroup');
1313
+ expect(sgStack).toBeDefined();
1314
+ expect(sgStack.physicalId).toBe('sg-123');
1315
+ });
1316
+
1317
+ it('should add subnets from route table associations to external array', () => {
1318
+ const flatDiscovery = {
1319
+ fromCloudFormationStack: true,
1320
+ stackName: 'test-stack',
1321
+ existingLogicalIds: ['FriggLambdaRouteTable'],
1322
+ routeTableId: 'rtb-123',
1323
+ // Subnets extracted from route table associations (NOT stack resources)
1324
+ privateSubnetId1: 'subnet-1',
1325
+ privateSubnetId2: 'subnet-2'
1326
+ };
1327
+
1328
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1329
+
1330
+ // Subnets should be in external array
1331
+ const subnet1 = result.external.find(r => r.physicalId === 'subnet-1');
1332
+ const subnet2 = result.external.find(r => r.physicalId === 'subnet-2');
1333
+
1334
+ expect(subnet1).toBeDefined();
1335
+ expect(subnet1.resourceType).toBe('AWS::EC2::Subnet');
1336
+ expect(subnet1.source).toBe('cloudformation-query');
1337
+
1338
+ expect(subnet2).toBeDefined();
1339
+ expect(subnet2.resourceType).toBe('AWS::EC2::Subnet');
1340
+ expect(subnet2.source).toBe('cloudformation-query');
1341
+ });
1342
+
1343
+ it('should add NAT Gateway from route table queries to external array', () => {
1344
+ const flatDiscovery = {
1345
+ fromCloudFormationStack: true,
1346
+ stackName: 'test-stack',
1347
+ existingLogicalIds: ['FriggLambdaRouteTable', 'FriggPrivateRoute'],
1348
+ routeTableId: 'rtb-123',
1349
+ // NAT Gateway extracted from route table routes (NOT a stack resource)
1350
+ existingNatGatewayId: 'nat-extracted'
1351
+ };
1352
+
1353
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1354
+
1355
+ // NAT should be in external array
1356
+ const natExternal = result.external.find(r => r.resourceType === 'AWS::EC2::NatGateway');
1357
+ expect(natExternal).toBeDefined();
1358
+ expect(natExternal.physicalId).toBe('nat-extracted');
1359
+ expect(natExternal.source).toBe('cloudformation-query');
1360
+ });
1361
+
1362
+ it('should NOT add resources to external if they are in stack', () => {
1363
+ const flatDiscovery = {
1364
+ fromCloudFormationStack: true,
1365
+ stackName: 'test-stack',
1366
+ existingLogicalIds: ['FriggVPC', 'FriggPrivateSubnet1'],
1367
+ // These ARE in the stack
1368
+ defaultVpcId: 'vpc-in-stack',
1369
+ privateSubnetId1: 'subnet-in-stack'
1370
+ };
1371
+
1372
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1373
+
1374
+ // Should be in stackManaged, NOT external
1375
+ expect(result.stackManaged.some(r => r.logicalId === 'FriggVPC')).toBe(true);
1376
+ expect(result.stackManaged.some(r => r.logicalId === 'FriggPrivateSubnet1')).toBe(true);
1377
+
1378
+ // Should NOT be in external
1379
+ expect(result.external.some(r => r.physicalId === 'vpc-in-stack')).toBe(false);
1380
+ expect(result.external.some(r => r.physicalId === 'subnet-in-stack')).toBe(false);
1381
+ });
1382
+
1383
+ it('should handle Frontify pattern: stack resources + queried external references', () => {
1384
+ const flatDiscovery = {
1385
+ fromCloudFormationStack: true,
1386
+ stackName: 'create-frigg-app-production',
1387
+ existingLogicalIds: [
1388
+ 'FriggLambdaSecurityGroup',
1389
+ 'FriggLambdaRouteTable',
1390
+ 'FriggPrivateRoute',
1391
+ 'FriggPrivateSubnet1RouteTableAssociation',
1392
+ 'FriggPrivateSubnet2RouteTableAssociation',
1393
+ 'FriggS3VPCEndpoint',
1394
+ 'FriggDynamoDBVPCEndpoint',
1395
+ 'FriggKMSVPCEndpoint'
1396
+ ],
1397
+ // Stack resources
1398
+ lambdaSecurityGroupId: 'sg-01002240c6a446202',
1399
+ routeTableId: 'rtb-08af43bbf0775602d',
1400
+ s3VpcEndpointId: 'vpce-s3',
1401
+ // External resources (discovered via queries)
1402
+ defaultVpcId: 'vpc-0cd17c0e06cb28b28',
1403
+ privateSubnetId1: 'subnet-034f6562dbbc16348',
1404
+ privateSubnetId2: 'subnet-0b8be2b82aeb5cdec',
1405
+ existingNatGatewayId: 'nat-022660c36a47e2d79'
1406
+ };
1407
+
1408
+ const result = vpcBuilder.convertFlatDiscoveryToStructured(flatDiscovery);
1409
+
1410
+ // Stack resources should be in stackManaged
1411
+ expect(result.stackManaged).toEqual(
1412
+ expect.arrayContaining([
1413
+ expect.objectContaining({ logicalId: 'FriggLambdaSecurityGroup', physicalId: 'sg-01002240c6a446202' }),
1414
+ expect.objectContaining({ logicalId: 'FriggLambdaRouteTable', physicalId: 'rtb-08af43bbf0775602d' }),
1415
+ expect.objectContaining({ logicalId: 'FriggS3VPCEndpoint' })
1416
+ ])
1417
+ );
1418
+
1419
+ // External resources should be in external array
1420
+ expect(result.external).toEqual(
1421
+ expect.arrayContaining([
1422
+ expect.objectContaining({ physicalId: 'vpc-0cd17c0e06cb28b28', resourceType: 'AWS::EC2::VPC', source: 'cloudformation-query' }),
1423
+ expect.objectContaining({ physicalId: 'subnet-034f6562dbbc16348', resourceType: 'AWS::EC2::Subnet', source: 'cloudformation-query' }),
1424
+ expect.objectContaining({ physicalId: 'subnet-0b8be2b82aeb5cdec', resourceType: 'AWS::EC2::Subnet', source: 'cloudformation-query' }),
1425
+ expect.objectContaining({ physicalId: 'nat-022660c36a47e2d79', resourceType: 'AWS::EC2::NatGateway', source: 'cloudformation-query' })
1426
+ ])
1427
+ );
1428
+ });
1429
+ });
1261
1430
  });
1262
1431
 
@@ -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', () => {