@friggframework/devtools 2.0.0-next.72 → 2.0.0-next.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/infrastructure/domains/networking/vpc-builder.js +1 -1
- package/infrastructure/domains/shared/cloudformation-discovery.js +20 -11
- package/infrastructure/domains/shared/cloudformation-discovery.test.js +450 -115
- package/infrastructure/domains/shared/resource-discovery.js +23 -0
- package/infrastructure/domains/shared/resource-discovery.test.js +194 -25
- package/package.json +7 -7
|
@@ -977,7 +977,7 @@ class VpcBuilder extends InfrastructureBuilder {
|
|
|
977
977
|
}
|
|
978
978
|
|
|
979
979
|
// Ensure subnet associations
|
|
980
|
-
this.ensureSubnetAssociations(appDefinition,
|
|
980
|
+
this.ensureSubnetAssociations(appDefinition, discoveredResources, result);
|
|
981
981
|
|
|
982
982
|
// Create endpoints
|
|
983
983
|
if (endpointsToCreate.includes('s3')) {
|
|
@@ -162,8 +162,8 @@ class CloudFormationDiscovery {
|
|
|
162
162
|
// Extract subnet IDs from route table associations
|
|
163
163
|
const associations = routeTable.Associations || [];
|
|
164
164
|
const subnetAssociations = associations.filter(a => a.SubnetId);
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
discovered.routeTableAssociationCount = subnetAssociations.length;
|
|
166
|
+
|
|
167
167
|
if (subnetAssociations.length >= 1 && !discovered.privateSubnetId1) {
|
|
168
168
|
discovered.privateSubnetId1 = subnetAssociations[0].SubnetId;
|
|
169
169
|
console.log(` ✓ Extracted private subnet 1 from associations: ${subnetAssociations[0].SubnetId}`);
|
|
@@ -562,24 +562,33 @@ class CloudFormationDiscovery {
|
|
|
562
562
|
.map(a => a.SubnetId);
|
|
563
563
|
|
|
564
564
|
console.log(` Route table has ${associatedSubnetIds.length} associated subnets: ${associatedSubnetIds.join(', ')}`);
|
|
565
|
-
|
|
565
|
+
discovered.routeTableAssociationCount = associatedSubnetIds.length;
|
|
566
|
+
|
|
566
567
|
// Use the associated subnets if available
|
|
567
568
|
if (associatedSubnetIds.length >= 2) {
|
|
568
569
|
discovered.privateSubnetId1 = associatedSubnetIds[0];
|
|
569
570
|
discovered.privateSubnetId2 = associatedSubnetIds[1];
|
|
570
571
|
console.log(` ✓ Extracted subnets from route table associations: ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`);
|
|
571
572
|
} else if (associatedSubnetIds.length === 1) {
|
|
572
|
-
// Only 1 associated subnet, use another subnet from VPC as backup
|
|
573
|
+
// Only 1 associated subnet, use another private subnet from VPC as backup
|
|
573
574
|
discovered.privateSubnetId1 = associatedSubnetIds[0];
|
|
574
|
-
|
|
575
|
+
const otherPrivateSubnet = subnetsResponse.Subnets.find(
|
|
576
|
+
s => s.SubnetId !== associatedSubnetIds[0] && !s.MapPublicIpOnLaunch
|
|
577
|
+
);
|
|
578
|
+
discovered.privateSubnetId2 = otherPrivateSubnet?.SubnetId;
|
|
575
579
|
console.log(` ✓ Extracted subnets (1 from route table, 1 fallback): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`);
|
|
576
580
|
} else if (subnetsResponse.Subnets.length >= 2) {
|
|
577
|
-
//
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
581
|
+
// Route table has 0 associations — pick private subnets from VPC
|
|
582
|
+
const privateSubnets = subnetsResponse.Subnets.filter(s => !s.MapPublicIpOnLaunch);
|
|
583
|
+
if (privateSubnets.length >= 2) {
|
|
584
|
+
discovered.privateSubnetId1 = privateSubnets[0].SubnetId;
|
|
585
|
+
discovered.privateSubnetId2 = privateSubnets[1].SubnetId;
|
|
586
|
+
console.log(` ✓ Using private subnets from VPC (route table Associations empty): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`);
|
|
587
|
+
} else {
|
|
588
|
+
discovered.privateSubnetId1 = subnetsResponse.Subnets[0].SubnetId;
|
|
589
|
+
discovered.privateSubnetId2 = subnetsResponse.Subnets[1].SubnetId;
|
|
590
|
+
console.warn(' ⚠️ Could not identify private subnets by MapPublicIpOnLaunch, using first 2 VPC subnets');
|
|
591
|
+
}
|
|
583
592
|
}
|
|
584
593
|
}
|
|
585
594
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for CloudFormation-based Resource Discovery
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Tests discovering resources from existing CloudFormation stacks
|
|
5
5
|
* before falling back to direct AWS API discovery.
|
|
6
6
|
*/
|
|
@@ -28,7 +28,9 @@ describe('CloudFormationDiscovery', () => {
|
|
|
28
28
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
29
29
|
|
|
30
30
|
expect(result).toBeNull();
|
|
31
|
-
expect(mockProvider.describeStack).toHaveBeenCalledWith(
|
|
31
|
+
expect(mockProvider.describeStack).toHaveBeenCalledWith(
|
|
32
|
+
'test-stack'
|
|
33
|
+
);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
36
|
it('should extract VPC resources from stack outputs', async () => {
|
|
@@ -36,7 +38,10 @@ describe('CloudFormationDiscovery', () => {
|
|
|
36
38
|
StackName: 'test-stack',
|
|
37
39
|
Outputs: [
|
|
38
40
|
{ OutputKey: 'VpcId', OutputValue: 'vpc-123' },
|
|
39
|
-
{
|
|
41
|
+
{
|
|
42
|
+
OutputKey: 'PrivateSubnetIds',
|
|
43
|
+
OutputValue: 'subnet-1,subnet-2',
|
|
44
|
+
},
|
|
40
45
|
{ OutputKey: 'PublicSubnetId', OutputValue: 'subnet-3' },
|
|
41
46
|
{ OutputKey: 'SecurityGroupId', OutputValue: 'sg-123' },
|
|
42
47
|
],
|
|
@@ -61,7 +66,10 @@ describe('CloudFormationDiscovery', () => {
|
|
|
61
66
|
const mockStack = {
|
|
62
67
|
StackName: 'test-stack',
|
|
63
68
|
Outputs: [
|
|
64
|
-
{
|
|
69
|
+
{
|
|
70
|
+
OutputKey: 'KMS_KEY_ARN',
|
|
71
|
+
OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc',
|
|
72
|
+
},
|
|
65
73
|
],
|
|
66
74
|
};
|
|
67
75
|
|
|
@@ -80,10 +88,26 @@ describe('CloudFormationDiscovery', () => {
|
|
|
80
88
|
it('should extract VPC subnets from stack resources', async () => {
|
|
81
89
|
const mockStack = { StackName: 'test-stack', Outputs: [] };
|
|
82
90
|
const mockResources = [
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
{
|
|
92
|
+
LogicalResourceId: 'FriggPrivateSubnet1',
|
|
93
|
+
PhysicalResourceId: 'subnet-priv-1',
|
|
94
|
+
ResourceType: 'AWS::EC2::Subnet',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
LogicalResourceId: 'FriggPrivateSubnet2',
|
|
98
|
+
PhysicalResourceId: 'subnet-priv-2',
|
|
99
|
+
ResourceType: 'AWS::EC2::Subnet',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
LogicalResourceId: 'FriggPublicSubnet',
|
|
103
|
+
PhysicalResourceId: 'subnet-pub-1',
|
|
104
|
+
ResourceType: 'AWS::EC2::Subnet',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
LogicalResourceId: 'FriggPublicSubnet2',
|
|
108
|
+
PhysicalResourceId: 'subnet-pub-2',
|
|
109
|
+
ResourceType: 'AWS::EC2::Subnet',
|
|
110
|
+
},
|
|
87
111
|
];
|
|
88
112
|
|
|
89
113
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
@@ -100,12 +124,36 @@ describe('CloudFormationDiscovery', () => {
|
|
|
100
124
|
it('should extract route tables and VPC endpoints from stack resources', async () => {
|
|
101
125
|
const mockStack = { StackName: 'test-stack', Outputs: [] };
|
|
102
126
|
const mockResources = [
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
{
|
|
127
|
+
{
|
|
128
|
+
LogicalResourceId: 'FriggLambdaRouteTable',
|
|
129
|
+
PhysicalResourceId: 'rtb-123',
|
|
130
|
+
ResourceType: 'AWS::EC2::RouteTable',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
LogicalResourceId: 'FriggVPCEndpointSecurityGroup',
|
|
134
|
+
PhysicalResourceId: 'sg-vpce-123',
|
|
135
|
+
ResourceType: 'AWS::EC2::SecurityGroup',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
LogicalResourceId: 'FriggS3VPCEndpoint',
|
|
139
|
+
PhysicalResourceId: 'vpce-s3-123',
|
|
140
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
|
|
144
|
+
PhysicalResourceId: 'vpce-ddb-123',
|
|
145
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
LogicalResourceId: 'FriggKMSVPCEndpoint',
|
|
149
|
+
PhysicalResourceId: 'vpce-kms-123',
|
|
150
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
LogicalResourceId: 'FriggSecretsManagerVPCEndpoint',
|
|
154
|
+
PhysicalResourceId: 'vpce-sm-123',
|
|
155
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
156
|
+
},
|
|
109
157
|
];
|
|
110
158
|
|
|
111
159
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
@@ -224,7 +272,8 @@ describe('CloudFormationDiscovery', () => {
|
|
|
224
272
|
const mockResources = [
|
|
225
273
|
{
|
|
226
274
|
LogicalResourceId: 'DbMigrationQueue',
|
|
227
|
-
PhysicalResourceId:
|
|
275
|
+
PhysicalResourceId:
|
|
276
|
+
'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
|
|
228
277
|
ResourceType: 'AWS::SQS::Queue',
|
|
229
278
|
},
|
|
230
279
|
];
|
|
@@ -237,7 +286,8 @@ describe('CloudFormationDiscovery', () => {
|
|
|
237
286
|
expect(result).toEqual({
|
|
238
287
|
fromCloudFormationStack: true,
|
|
239
288
|
stackName: 'test-stack',
|
|
240
|
-
migrationQueueUrl:
|
|
289
|
+
migrationQueueUrl:
|
|
290
|
+
'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
|
|
241
291
|
existingLogicalIds: ['DbMigrationQueue'],
|
|
242
292
|
});
|
|
243
293
|
});
|
|
@@ -301,7 +351,10 @@ describe('CloudFormationDiscovery', () => {
|
|
|
301
351
|
StackName: 'test-stack',
|
|
302
352
|
Outputs: [
|
|
303
353
|
{ OutputKey: 'VpcId', OutputValue: 'vpc-123' },
|
|
304
|
-
{
|
|
354
|
+
{
|
|
355
|
+
OutputKey: 'KMS_KEY_ARN',
|
|
356
|
+
OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc',
|
|
357
|
+
},
|
|
305
358
|
],
|
|
306
359
|
};
|
|
307
360
|
|
|
@@ -377,7 +430,9 @@ describe('CloudFormationDiscovery', () => {
|
|
|
377
430
|
|
|
378
431
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
379
432
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
380
|
-
mockProvider.getEC2Client = jest
|
|
433
|
+
mockProvider.getEC2Client = jest
|
|
434
|
+
.fn()
|
|
435
|
+
.mockReturnValue(mockEC2Client);
|
|
381
436
|
|
|
382
437
|
// Mock security group query for VPC ID
|
|
383
438
|
mockEC2Client.send.mockResolvedValueOnce({
|
|
@@ -392,7 +447,10 @@ describe('CloudFormationDiscovery', () => {
|
|
|
392
447
|
MapPublicIpOnLaunch: false,
|
|
393
448
|
Tags: [
|
|
394
449
|
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
395
|
-
{
|
|
450
|
+
{
|
|
451
|
+
Key: 'aws:cloudformation:logical-id',
|
|
452
|
+
Value: 'FriggPrivateSubnet1',
|
|
453
|
+
},
|
|
396
454
|
],
|
|
397
455
|
},
|
|
398
456
|
{
|
|
@@ -400,7 +458,10 @@ describe('CloudFormationDiscovery', () => {
|
|
|
400
458
|
MapPublicIpOnLaunch: false,
|
|
401
459
|
Tags: [
|
|
402
460
|
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
403
|
-
{
|
|
461
|
+
{
|
|
462
|
+
Key: 'aws:cloudformation:logical-id',
|
|
463
|
+
Value: 'FriggPrivateSubnet2',
|
|
464
|
+
},
|
|
404
465
|
],
|
|
405
466
|
},
|
|
406
467
|
],
|
|
@@ -433,7 +494,9 @@ describe('CloudFormationDiscovery', () => {
|
|
|
433
494
|
|
|
434
495
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
435
496
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
436
|
-
mockProvider.getEC2Client = jest
|
|
497
|
+
mockProvider.getEC2Client = jest
|
|
498
|
+
.fn()
|
|
499
|
+
.mockReturnValue(mockEC2Client);
|
|
437
500
|
|
|
438
501
|
// Mock security group query for VPC ID
|
|
439
502
|
mockEC2Client.send.mockResolvedValueOnce({
|
|
@@ -441,7 +504,9 @@ describe('CloudFormationDiscovery', () => {
|
|
|
441
504
|
});
|
|
442
505
|
|
|
443
506
|
// Mock subnet query failure
|
|
444
|
-
mockEC2Client.send.mockRejectedValueOnce(
|
|
507
|
+
mockEC2Client.send.mockRejectedValueOnce(
|
|
508
|
+
new Error('EC2 API Error')
|
|
509
|
+
);
|
|
445
510
|
|
|
446
511
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
447
512
|
|
|
@@ -472,9 +537,13 @@ describe('CloudFormationDiscovery', () => {
|
|
|
472
537
|
|
|
473
538
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
474
539
|
|
|
475
|
-
expect(result.defaultKmsKeyId).toBe(
|
|
540
|
+
expect(result.defaultKmsKeyId).toBe(
|
|
541
|
+
'arn:aws:kms:us-east-1:123456789:key/abc-123'
|
|
542
|
+
);
|
|
476
543
|
expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
|
|
477
|
-
expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
|
|
544
|
+
expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
|
|
545
|
+
'alias/test-service-dev-frigg-kms'
|
|
546
|
+
);
|
|
478
547
|
});
|
|
479
548
|
|
|
480
549
|
it('should query AWS API for KMS alias when serviceName and stage are provided', async () => {
|
|
@@ -499,9 +568,13 @@ describe('CloudFormationDiscovery', () => {
|
|
|
499
568
|
|
|
500
569
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
501
570
|
|
|
502
|
-
expect(result.defaultKmsKeyId).toBe(
|
|
571
|
+
expect(result.defaultKmsKeyId).toBe(
|
|
572
|
+
'arn:aws:kms:us-east-1:123456789:key/abc-123'
|
|
573
|
+
);
|
|
503
574
|
expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
|
|
504
|
-
expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
|
|
575
|
+
expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
|
|
576
|
+
'alias/test-service-dev-frigg-kms'
|
|
577
|
+
);
|
|
505
578
|
});
|
|
506
579
|
|
|
507
580
|
it('should handle KMS alias not found gracefully', async () => {
|
|
@@ -515,9 +588,11 @@ describe('CloudFormationDiscovery', () => {
|
|
|
515
588
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
516
589
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
517
590
|
mockProvider.region = 'us-east-1';
|
|
518
|
-
mockProvider.describeKmsKey = jest
|
|
519
|
-
|
|
520
|
-
|
|
591
|
+
mockProvider.describeKmsKey = jest
|
|
592
|
+
.fn()
|
|
593
|
+
.mockRejectedValue(
|
|
594
|
+
new Error('Alias/test-service-dev-frigg-kms is not found')
|
|
595
|
+
);
|
|
521
596
|
|
|
522
597
|
cfDiscovery.serviceName = 'test-service';
|
|
523
598
|
cfDiscovery.stage = 'dev';
|
|
@@ -537,7 +612,8 @@ describe('CloudFormationDiscovery', () => {
|
|
|
537
612
|
const mockResources = [
|
|
538
613
|
{
|
|
539
614
|
LogicalResourceId: 'FriggKMSKey',
|
|
540
|
-
PhysicalResourceId:
|
|
615
|
+
PhysicalResourceId:
|
|
616
|
+
'arn:aws:kms:us-east-1:123456789:key/xyz-789',
|
|
541
617
|
ResourceType: 'AWS::KMS::Key',
|
|
542
618
|
},
|
|
543
619
|
];
|
|
@@ -549,7 +625,9 @@ describe('CloudFormationDiscovery', () => {
|
|
|
549
625
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
550
626
|
|
|
551
627
|
// Should use the key from stack resources, not query for alias
|
|
552
|
-
expect(result.defaultKmsKeyId).toBe(
|
|
628
|
+
expect(result.defaultKmsKeyId).toBe(
|
|
629
|
+
'arn:aws:kms:us-east-1:123456789:key/xyz-789'
|
|
630
|
+
);
|
|
553
631
|
expect(mockProvider.describeKmsKey).not.toHaveBeenCalled();
|
|
554
632
|
});
|
|
555
633
|
|
|
@@ -562,7 +640,8 @@ describe('CloudFormationDiscovery', () => {
|
|
|
562
640
|
const mockResources = [
|
|
563
641
|
{
|
|
564
642
|
LogicalResourceId: 'FriggKMSKey',
|
|
565
|
-
PhysicalResourceId:
|
|
643
|
+
PhysicalResourceId:
|
|
644
|
+
'arn:aws:kms:us-east-1:123456789:key/xyz-789',
|
|
566
645
|
ResourceType: 'AWS::KMS::Key',
|
|
567
646
|
},
|
|
568
647
|
{
|
|
@@ -581,9 +660,13 @@ describe('CloudFormationDiscovery', () => {
|
|
|
581
660
|
|
|
582
661
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
583
662
|
|
|
584
|
-
expect(result.defaultKmsKeyId).toBe(
|
|
663
|
+
expect(result.defaultKmsKeyId).toBe(
|
|
664
|
+
'arn:aws:kms:us-east-1:123456789:key/xyz-789'
|
|
665
|
+
);
|
|
585
666
|
expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
|
|
586
|
-
expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
|
|
667
|
+
expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
|
|
668
|
+
'alias/test-service-dev-frigg-kms'
|
|
669
|
+
);
|
|
587
670
|
});
|
|
588
671
|
});
|
|
589
672
|
|
|
@@ -638,7 +721,9 @@ describe('CloudFormationDiscovery', () => {
|
|
|
638
721
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
639
722
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
640
723
|
|
|
641
|
-
const result = await cfDiscovery.discoverFromStack(
|
|
724
|
+
const result = await cfDiscovery.discoverFromStack(
|
|
725
|
+
'create-frigg-app-production'
|
|
726
|
+
);
|
|
642
727
|
|
|
643
728
|
// Verify routing infrastructure was discovered
|
|
644
729
|
expect(result.routeTableId).toBe('rtb-0b83aca77ccde20a6');
|
|
@@ -723,7 +808,9 @@ describe('CloudFormationDiscovery', () => {
|
|
|
723
808
|
// Lambda security group should be extracted
|
|
724
809
|
expect(result.lambdaSecurityGroupId).toBe('sg-01002240c6a446202');
|
|
725
810
|
expect(result.defaultSecurityGroupId).toBe('sg-01002240c6a446202');
|
|
726
|
-
expect(result.existingLogicalIds).toContain(
|
|
811
|
+
expect(result.existingLogicalIds).toContain(
|
|
812
|
+
'FriggLambdaSecurityGroup'
|
|
813
|
+
);
|
|
727
814
|
});
|
|
728
815
|
|
|
729
816
|
it('should support FriggPrivateRoute naming for NAT routes', async () => {
|
|
@@ -774,22 +861,27 @@ describe('CloudFormationDiscovery', () => {
|
|
|
774
861
|
|
|
775
862
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
776
863
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
777
|
-
|
|
864
|
+
|
|
778
865
|
// Mock EC2 DescribeRouteTables to return route table with VPC info
|
|
779
866
|
mockProvider.getEC2Client = jest.fn().mockReturnValue({
|
|
780
867
|
send: jest.fn().mockResolvedValue({
|
|
781
|
-
RouteTables: [
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
868
|
+
RouteTables: [
|
|
869
|
+
{
|
|
870
|
+
RouteTableId: 'rtb-real-id',
|
|
871
|
+
VpcId: 'vpc-extracted',
|
|
872
|
+
Routes: [
|
|
873
|
+
{
|
|
874
|
+
NatGatewayId: 'nat-extracted',
|
|
875
|
+
DestinationCidrBlock: '0.0.0.0/0',
|
|
876
|
+
},
|
|
877
|
+
],
|
|
878
|
+
Associations: [
|
|
879
|
+
{ SubnetId: 'subnet-1' },
|
|
880
|
+
{ SubnetId: 'subnet-2' },
|
|
881
|
+
],
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
}),
|
|
793
885
|
});
|
|
794
886
|
|
|
795
887
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
@@ -799,7 +891,7 @@ describe('CloudFormationDiscovery', () => {
|
|
|
799
891
|
expect(result.existingNatGatewayId).toBe('nat-extracted');
|
|
800
892
|
expect(result.privateSubnetId1).toBe('subnet-1');
|
|
801
893
|
expect(result.privateSubnetId2).toBe('subnet-2');
|
|
802
|
-
|
|
894
|
+
|
|
803
895
|
// Should NOT throw 'stackName is not defined' error
|
|
804
896
|
expect(result).toBeDefined();
|
|
805
897
|
});
|
|
@@ -810,30 +902,60 @@ describe('CloudFormationDiscovery', () => {
|
|
|
810
902
|
// CRITICAL: Frontify production uses OLD naming convention
|
|
811
903
|
const mockStack = {
|
|
812
904
|
StackName: 'create-frigg-app-production',
|
|
813
|
-
Outputs: []
|
|
905
|
+
Outputs: [],
|
|
814
906
|
};
|
|
815
907
|
|
|
816
908
|
const mockResources = [
|
|
817
|
-
{
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
{
|
|
909
|
+
{
|
|
910
|
+
LogicalResourceId: 'FriggLambdaRouteTable',
|
|
911
|
+
PhysicalResourceId: 'rtb-123',
|
|
912
|
+
ResourceType: 'AWS::EC2::RouteTable',
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
LogicalResourceId: 'FriggNATRoute',
|
|
916
|
+
PhysicalResourceId: 'rtb-123|0.0.0.0/0',
|
|
917
|
+
ResourceType: 'AWS::EC2::Route',
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
LogicalResourceId: 'FriggSubnet1RouteAssociation',
|
|
921
|
+
PhysicalResourceId: 'rtbassoc-1',
|
|
922
|
+
ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
LogicalResourceId: 'FriggSubnet2RouteAssociation',
|
|
926
|
+
PhysicalResourceId: 'rtbassoc-2',
|
|
927
|
+
ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
LogicalResourceId: 'VPCEndpointS3',
|
|
931
|
+
PhysicalResourceId: 'vpce-s3-123',
|
|
932
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
LogicalResourceId: 'VPCEndpointDynamoDB',
|
|
936
|
+
PhysicalResourceId: 'vpce-ddb-123',
|
|
937
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
938
|
+
},
|
|
823
939
|
];
|
|
824
940
|
|
|
825
941
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
826
942
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
827
943
|
|
|
828
|
-
const result = await cfDiscovery.discoverFromStack(
|
|
944
|
+
const result = await cfDiscovery.discoverFromStack(
|
|
945
|
+
'create-frigg-app-production'
|
|
946
|
+
);
|
|
829
947
|
|
|
830
948
|
// CRITICAL: existingLogicalIds MUST contain old VPC endpoint names
|
|
831
949
|
expect(result.existingLogicalIds).toBeDefined();
|
|
832
950
|
expect(result.existingLogicalIds).toContain('FriggNATRoute');
|
|
833
|
-
expect(result.existingLogicalIds).toContain(
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
expect(result.existingLogicalIds).toContain(
|
|
951
|
+
expect(result.existingLogicalIds).toContain(
|
|
952
|
+
'FriggSubnet1RouteAssociation'
|
|
953
|
+
);
|
|
954
|
+
expect(result.existingLogicalIds).toContain(
|
|
955
|
+
'FriggSubnet2RouteAssociation'
|
|
956
|
+
);
|
|
957
|
+
expect(result.existingLogicalIds).toContain('VPCEndpointS3'); // OLD naming
|
|
958
|
+
expect(result.existingLogicalIds).toContain('VPCEndpointDynamoDB'); // OLD naming
|
|
837
959
|
|
|
838
960
|
// Should also have the flat discovery properties
|
|
839
961
|
expect(result.routeTableId).toBe('rtb-123');
|
|
@@ -845,15 +967,35 @@ describe('CloudFormationDiscovery', () => {
|
|
|
845
967
|
it('should track NEW VPC endpoint logical IDs (FriggS3VPCEndpoint pattern) for newer stacks', async () => {
|
|
846
968
|
const mockStack = {
|
|
847
969
|
StackName: 'test-stack',
|
|
848
|
-
Outputs: []
|
|
970
|
+
Outputs: [],
|
|
849
971
|
};
|
|
850
972
|
|
|
851
973
|
const mockResources = [
|
|
852
|
-
{
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
974
|
+
{
|
|
975
|
+
LogicalResourceId: 'FriggLambdaRouteTable',
|
|
976
|
+
PhysicalResourceId: 'rtb-456',
|
|
977
|
+
ResourceType: 'AWS::EC2::RouteTable',
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
LogicalResourceId: 'FriggPrivateRoute',
|
|
981
|
+
PhysicalResourceId: 'rtb-456|0.0.0.0/0',
|
|
982
|
+
ResourceType: 'AWS::EC2::Route',
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
LogicalResourceId: 'FriggS3VPCEndpoint',
|
|
986
|
+
PhysicalResourceId: 'vpce-s3-456',
|
|
987
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
|
|
991
|
+
PhysicalResourceId: 'vpce-ddb-456',
|
|
992
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
LogicalResourceId: 'FriggKMSVPCEndpoint',
|
|
996
|
+
PhysicalResourceId: 'vpce-kms-456',
|
|
997
|
+
ResourceType: 'AWS::EC2::VPCEndpoint',
|
|
998
|
+
},
|
|
857
999
|
];
|
|
858
1000
|
|
|
859
1001
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
@@ -864,9 +1006,11 @@ describe('CloudFormationDiscovery', () => {
|
|
|
864
1006
|
// Should track NEW naming pattern in existingLogicalIds
|
|
865
1007
|
expect(result.existingLogicalIds).toContain('FriggPrivateRoute');
|
|
866
1008
|
expect(result.existingLogicalIds).toContain('FriggS3VPCEndpoint');
|
|
867
|
-
expect(result.existingLogicalIds).toContain(
|
|
1009
|
+
expect(result.existingLogicalIds).toContain(
|
|
1010
|
+
'FriggDynamoDBVPCEndpoint'
|
|
1011
|
+
);
|
|
868
1012
|
expect(result.existingLogicalIds).toContain('FriggKMSVPCEndpoint');
|
|
869
|
-
|
|
1013
|
+
|
|
870
1014
|
// Should NOT contain old naming patterns
|
|
871
1015
|
expect(result.existingLogicalIds).not.toContain('FriggNATRoute');
|
|
872
1016
|
expect(result.existingLogicalIds).not.toContain('VPCEndpointS3');
|
|
@@ -879,50 +1023,89 @@ describe('CloudFormationDiscovery', () => {
|
|
|
879
1023
|
// 1. Query ALL subnets in VPC using vpc-id filter (not association filter!)
|
|
880
1024
|
// 2. Query route table by ID (RouteTableIds parameter, not Filters!)
|
|
881
1025
|
// 3. Extract subnet IDs from route table's Associations array
|
|
882
|
-
|
|
1026
|
+
|
|
883
1027
|
const mockStack = {
|
|
884
1028
|
StackName: 'test-stack',
|
|
885
|
-
Outputs: []
|
|
1029
|
+
Outputs: [],
|
|
886
1030
|
};
|
|
887
1031
|
|
|
888
1032
|
const mockResources = [
|
|
889
|
-
{
|
|
890
|
-
|
|
1033
|
+
{
|
|
1034
|
+
LogicalResourceId: 'FriggLambdaRouteTable',
|
|
1035
|
+
PhysicalResourceId: 'rtb-123',
|
|
1036
|
+
ResourceType: 'AWS::EC2::RouteTable',
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
LogicalResourceId: 'FriggVPC',
|
|
1040
|
+
PhysicalResourceId: 'vpc-456',
|
|
1041
|
+
ResourceType: 'AWS::EC2::VPC',
|
|
1042
|
+
},
|
|
891
1043
|
];
|
|
892
1044
|
|
|
893
1045
|
const sendMock = jest.fn();
|
|
894
1046
|
sendMock
|
|
895
1047
|
.mockResolvedValueOnce({
|
|
896
|
-
RouteTables: [
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1048
|
+
RouteTables: [
|
|
1049
|
+
{
|
|
1050
|
+
RouteTableId: 'rtb-123',
|
|
1051
|
+
VpcId: 'vpc-456',
|
|
1052
|
+
Associations: [],
|
|
1053
|
+
Routes: [
|
|
1054
|
+
{
|
|
1055
|
+
NatGatewayId: 'nat-789',
|
|
1056
|
+
DestinationCidrBlock: '0.0.0.0/0',
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
},
|
|
1060
|
+
],
|
|
1061
|
+
})
|
|
1062
|
+
.mockResolvedValueOnce({
|
|
1063
|
+
SecurityGroups: [{ GroupId: 'sg-default' }],
|
|
902
1064
|
})
|
|
903
|
-
.mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] })
|
|
904
1065
|
.mockResolvedValueOnce({
|
|
905
1066
|
Subnets: [
|
|
906
|
-
{
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1067
|
+
{
|
|
1068
|
+
SubnetId: 'subnet-aaa',
|
|
1069
|
+
VpcId: 'vpc-456',
|
|
1070
|
+
AvailabilityZone: 'us-east-1a',
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
SubnetId: 'subnet-bbb',
|
|
1074
|
+
VpcId: 'vpc-456',
|
|
1075
|
+
AvailabilityZone: 'us-east-1b',
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
SubnetId: 'subnet-ccc',
|
|
1079
|
+
VpcId: 'vpc-456',
|
|
1080
|
+
AvailabilityZone: 'us-east-1c',
|
|
1081
|
+
},
|
|
1082
|
+
],
|
|
910
1083
|
})
|
|
911
1084
|
.mockResolvedValueOnce({
|
|
912
|
-
RouteTables: [
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1085
|
+
RouteTables: [
|
|
1086
|
+
{
|
|
1087
|
+
RouteTableId: 'rtb-123',
|
|
1088
|
+
Associations: [
|
|
1089
|
+
{
|
|
1090
|
+
RouteTableAssociationId: 'rtbassoc-111',
|
|
1091
|
+
SubnetId: 'subnet-aaa',
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
RouteTableAssociationId: 'rtbassoc-222',
|
|
1095
|
+
SubnetId: 'subnet-bbb',
|
|
1096
|
+
},
|
|
1097
|
+
],
|
|
1098
|
+
},
|
|
1099
|
+
],
|
|
919
1100
|
});
|
|
920
|
-
|
|
1101
|
+
|
|
921
1102
|
const mockEC2Client = { send: sendMock };
|
|
922
1103
|
|
|
923
1104
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
924
1105
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
925
|
-
mockProvider.getEC2Client = jest
|
|
1106
|
+
mockProvider.getEC2Client = jest
|
|
1107
|
+
.fn()
|
|
1108
|
+
.mockReturnValue(mockEC2Client);
|
|
926
1109
|
|
|
927
1110
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
928
1111
|
|
|
@@ -931,48 +1114,201 @@ describe('CloudFormationDiscovery', () => {
|
|
|
931
1114
|
expect(result.privateSubnetId2).toBe('subnet-bbb');
|
|
932
1115
|
});
|
|
933
1116
|
|
|
1117
|
+
it('should select only private subnets when route table has 0 associations and VPC has public + private subnets', async () => {
|
|
1118
|
+
// Real-world drift scenario:
|
|
1119
|
+
// - VPC has 3 subnets: 1 public (NAT/IGW) + 2 private (Lambda)
|
|
1120
|
+
// - IGW route table has all 3 associated (default route table)
|
|
1121
|
+
// - Frigg lambda route table has 0 associations (drift)
|
|
1122
|
+
// - Frigg-managed subnet query returns nothing useful
|
|
1123
|
+
// Expected: the fallback path selects only the 2 private subnets (MapPublicIpOnLaunch=false)
|
|
1124
|
+
const mockStack = {
|
|
1125
|
+
StackName: 'test-stack',
|
|
1126
|
+
Outputs: [],
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
const mockResources = [
|
|
1130
|
+
{
|
|
1131
|
+
LogicalResourceId: 'FriggLambdaRouteTable',
|
|
1132
|
+
PhysicalResourceId: 'rtb-lambda',
|
|
1133
|
+
ResourceType: 'AWS::EC2::RouteTable',
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
LogicalResourceId: 'FriggVPC',
|
|
1137
|
+
PhysicalResourceId: 'vpc-456',
|
|
1138
|
+
ResourceType: 'AWS::EC2::VPC',
|
|
1139
|
+
},
|
|
1140
|
+
];
|
|
1141
|
+
|
|
1142
|
+
const sendMock = jest.fn();
|
|
1143
|
+
sendMock
|
|
1144
|
+
// External reference extraction: route table with 0 subnet associations
|
|
1145
|
+
.mockResolvedValueOnce({
|
|
1146
|
+
RouteTables: [
|
|
1147
|
+
{
|
|
1148
|
+
RouteTableId: 'rtb-lambda',
|
|
1149
|
+
VpcId: 'vpc-456',
|
|
1150
|
+
Associations: [
|
|
1151
|
+
{
|
|
1152
|
+
RouteTableAssociationId: 'rtbassoc-main',
|
|
1153
|
+
Main: true,
|
|
1154
|
+
},
|
|
1155
|
+
],
|
|
1156
|
+
Routes: [
|
|
1157
|
+
{
|
|
1158
|
+
NatGatewayId: 'nat-789',
|
|
1159
|
+
DestinationCidrBlock: '0.0.0.0/0',
|
|
1160
|
+
},
|
|
1161
|
+
],
|
|
1162
|
+
},
|
|
1163
|
+
],
|
|
1164
|
+
})
|
|
1165
|
+
// DescribeSecurityGroupsCommand — default SG
|
|
1166
|
+
.mockResolvedValueOnce({
|
|
1167
|
+
SecurityGroups: [{ GroupId: 'sg-default' }],
|
|
1168
|
+
})
|
|
1169
|
+
// Frigg-managed subnet query returns nothing, forcing the fallback path to run
|
|
1170
|
+
.mockResolvedValueOnce({
|
|
1171
|
+
Subnets: [],
|
|
1172
|
+
})
|
|
1173
|
+
// Fallback VPC-wide subnet query — 3 subnets (1 public, 2 private)
|
|
1174
|
+
.mockResolvedValueOnce({
|
|
1175
|
+
Subnets: [
|
|
1176
|
+
{
|
|
1177
|
+
SubnetId: 'subnet-public',
|
|
1178
|
+
VpcId: 'vpc-456',
|
|
1179
|
+
MapPublicIpOnLaunch: true,
|
|
1180
|
+
AvailabilityZone: 'us-east-1a',
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
SubnetId: 'subnet-priv-1',
|
|
1184
|
+
VpcId: 'vpc-456',
|
|
1185
|
+
MapPublicIpOnLaunch: false,
|
|
1186
|
+
AvailabilityZone: 'us-east-1b',
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
SubnetId: 'subnet-priv-2',
|
|
1190
|
+
VpcId: 'vpc-456',
|
|
1191
|
+
MapPublicIpOnLaunch: false,
|
|
1192
|
+
AvailabilityZone: 'us-east-1c',
|
|
1193
|
+
},
|
|
1194
|
+
],
|
|
1195
|
+
})
|
|
1196
|
+
// Fallback route table query — lambda route table still has 0 subnet associations
|
|
1197
|
+
.mockResolvedValueOnce({
|
|
1198
|
+
RouteTables: [
|
|
1199
|
+
{
|
|
1200
|
+
RouteTableId: 'rtb-lambda',
|
|
1201
|
+
Associations: [
|
|
1202
|
+
{
|
|
1203
|
+
RouteTableAssociationId: 'rtbassoc-main',
|
|
1204
|
+
Main: true,
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
},
|
|
1208
|
+
],
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
1212
|
+
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
1213
|
+
mockProvider.getEC2Client = jest
|
|
1214
|
+
.fn()
|
|
1215
|
+
.mockReturnValue({ send: sendMock });
|
|
1216
|
+
|
|
1217
|
+
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
1218
|
+
|
|
1219
|
+
// Should select ONLY the 2 private subnets
|
|
1220
|
+
expect(result.privateSubnetId1).toBe('subnet-priv-1');
|
|
1221
|
+
expect(result.privateSubnetId2).toBe('subnet-priv-2');
|
|
1222
|
+
|
|
1223
|
+
// Should NOT select the public subnet
|
|
1224
|
+
expect(result.privateSubnetId1).not.toBe('subnet-public');
|
|
1225
|
+
expect(result.privateSubnetId2).not.toBe('subnet-public');
|
|
1226
|
+
|
|
1227
|
+
// Should record 0 associations for self-heal to detect
|
|
1228
|
+
expect(result.routeTableAssociationCount).toBe(0);
|
|
1229
|
+
|
|
1230
|
+
// Verify the test actually exercised the fallback path:
|
|
1231
|
+
// 1) route table query for external refs
|
|
1232
|
+
// 2) default security group query
|
|
1233
|
+
// 3) Frigg-managed subnet query (empty)
|
|
1234
|
+
// 4) all-subnets-in-VPC fallback query
|
|
1235
|
+
// 5) route table query for association extraction
|
|
1236
|
+
expect(sendMock).toHaveBeenCalledTimes(5);
|
|
1237
|
+
expect(sendMock.mock.calls[2][0].input.Filters).toEqual([
|
|
1238
|
+
{ Name: 'vpc-id', Values: ['vpc-456'] },
|
|
1239
|
+
{ Name: 'tag:ManagedBy', Values: ['Frigg'] },
|
|
1240
|
+
]);
|
|
1241
|
+
expect(sendMock.mock.calls[3][0].input.Filters).toEqual([
|
|
1242
|
+
{ Name: 'vpc-id', Values: ['vpc-456'] },
|
|
1243
|
+
]);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
934
1246
|
it('should handle VPC with only 1 associated subnet (use second as fallback)', async () => {
|
|
935
1247
|
const mockStack = {
|
|
936
1248
|
StackName: 'test-stack',
|
|
937
|
-
Outputs: []
|
|
1249
|
+
Outputs: [],
|
|
938
1250
|
};
|
|
939
1251
|
|
|
940
1252
|
const mockResources = [
|
|
941
|
-
{
|
|
942
|
-
|
|
1253
|
+
{
|
|
1254
|
+
LogicalResourceId: 'FriggLambdaRouteTable',
|
|
1255
|
+
PhysicalResourceId: 'rtb-123',
|
|
1256
|
+
ResourceType: 'AWS::EC2::RouteTable',
|
|
1257
|
+
},
|
|
1258
|
+
{
|
|
1259
|
+
LogicalResourceId: 'FriggVPC',
|
|
1260
|
+
PhysicalResourceId: 'vpc-456',
|
|
1261
|
+
ResourceType: 'AWS::EC2::VPC',
|
|
1262
|
+
},
|
|
943
1263
|
];
|
|
944
1264
|
|
|
945
1265
|
const sendMock = jest.fn();
|
|
946
1266
|
sendMock
|
|
947
1267
|
.mockResolvedValueOnce({
|
|
948
|
-
RouteTables: [
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1268
|
+
RouteTables: [
|
|
1269
|
+
{
|
|
1270
|
+
RouteTableId: 'rtb-123',
|
|
1271
|
+
VpcId: 'vpc-456',
|
|
1272
|
+
Associations: [],
|
|
1273
|
+
Routes: [
|
|
1274
|
+
{
|
|
1275
|
+
NatGatewayId: 'nat-789',
|
|
1276
|
+
DestinationCidrBlock: '0.0.0.0/0',
|
|
1277
|
+
},
|
|
1278
|
+
],
|
|
1279
|
+
},
|
|
1280
|
+
],
|
|
1281
|
+
})
|
|
1282
|
+
.mockResolvedValueOnce({
|
|
1283
|
+
SecurityGroups: [{ GroupId: 'sg-default' }],
|
|
954
1284
|
})
|
|
955
|
-
.mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] })
|
|
956
1285
|
.mockResolvedValueOnce({
|
|
957
1286
|
Subnets: [
|
|
958
1287
|
{ SubnetId: 'subnet-aaa', VpcId: 'vpc-456' },
|
|
959
|
-
{ SubnetId: 'subnet-bbb', VpcId: 'vpc-456' }
|
|
960
|
-
]
|
|
1288
|
+
{ SubnetId: 'subnet-bbb', VpcId: 'vpc-456' },
|
|
1289
|
+
],
|
|
961
1290
|
})
|
|
962
1291
|
.mockResolvedValueOnce({
|
|
963
|
-
RouteTables: [
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1292
|
+
RouteTables: [
|
|
1293
|
+
{
|
|
1294
|
+
RouteTableId: 'rtb-123',
|
|
1295
|
+
Associations: [
|
|
1296
|
+
{
|
|
1297
|
+
RouteTableAssociationId: 'rtbassoc-111',
|
|
1298
|
+
SubnetId: 'subnet-aaa',
|
|
1299
|
+
},
|
|
1300
|
+
],
|
|
1301
|
+
},
|
|
1302
|
+
],
|
|
969
1303
|
});
|
|
970
|
-
|
|
1304
|
+
|
|
971
1305
|
const mockEC2Client = { send: sendMock };
|
|
972
1306
|
|
|
973
1307
|
mockProvider.describeStack.mockResolvedValue(mockStack);
|
|
974
1308
|
mockProvider.listStackResources.mockResolvedValue(mockResources);
|
|
975
|
-
mockProvider.getEC2Client = jest
|
|
1309
|
+
mockProvider.getEC2Client = jest
|
|
1310
|
+
.fn()
|
|
1311
|
+
.mockReturnValue(mockEC2Client);
|
|
976
1312
|
|
|
977
1313
|
const result = await cfDiscovery.discoverFromStack('test-stack');
|
|
978
1314
|
|
|
@@ -982,4 +1318,3 @@ describe('CloudFormationDiscovery', () => {
|
|
|
982
1318
|
});
|
|
983
1319
|
});
|
|
984
1320
|
});
|
|
985
|
-
|
|
@@ -118,6 +118,29 @@ async function gatherDiscoveredResources(appDefinition) {
|
|
|
118
118
|
appDefinition.vpcIsolation === 'isolated';
|
|
119
119
|
|
|
120
120
|
if (stackResources && hasSomeUsefulData) {
|
|
121
|
+
// Self-heal: if route table exists but has 0 subnet associations, fix via EC2 API
|
|
122
|
+
if (appDefinition.vpc?.selfHeal &&
|
|
123
|
+
stackResources.routeTableId &&
|
|
124
|
+
stackResources.routeTableAssociationCount === 0 &&
|
|
125
|
+
stackResources.privateSubnetId1 && stackResources.privateSubnetId2) {
|
|
126
|
+
|
|
127
|
+
console.log(' ⚠️ Route table has 0 subnet associations - self-healing...');
|
|
128
|
+
const { AssociateRouteTableCommand } = require('@aws-sdk/client-ec2');
|
|
129
|
+
const ec2 = provider.getEC2Client();
|
|
130
|
+
|
|
131
|
+
for (const subnetId of [stackResources.privateSubnetId1, stackResources.privateSubnetId2]) {
|
|
132
|
+
try {
|
|
133
|
+
const response = await ec2.send(new AssociateRouteTableCommand({
|
|
134
|
+
RouteTableId: stackResources.routeTableId,
|
|
135
|
+
SubnetId: subnetId,
|
|
136
|
+
}));
|
|
137
|
+
console.log(` ✓ Self-healed: associated ${subnetId} → ${stackResources.routeTableId} (${response.AssociationId})`);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.warn(` ⚠️ Self-heal failed for ${subnetId}: ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
console.log(' ✓ Discovered resources from existing CloudFormation stack');
|
|
122
145
|
console.log('✅ Cloud resource discovery completed successfully!');
|
|
123
146
|
return stackResources;
|
|
@@ -12,12 +12,18 @@ jest.mock('../networking/vpc-discovery');
|
|
|
12
12
|
jest.mock('../security/kms-discovery');
|
|
13
13
|
jest.mock('../database/aurora-discovery');
|
|
14
14
|
jest.mock('../parameters/ssm-discovery');
|
|
15
|
+
jest.mock('./cloudformation-discovery');
|
|
16
|
+
jest.mock('@aws-sdk/client-ec2', () => ({
|
|
17
|
+
AssociateRouteTableCommand: jest.fn().mockImplementation((params) => params),
|
|
18
|
+
}));
|
|
15
19
|
|
|
16
20
|
const { CloudProviderFactory } = require('./providers/provider-factory');
|
|
17
21
|
const { VpcDiscovery } = require('../networking/vpc-discovery');
|
|
18
22
|
const { KmsDiscovery } = require('../security/kms-discovery');
|
|
19
23
|
const { AuroraDiscovery } = require('../database/aurora-discovery');
|
|
20
24
|
const { SsmDiscovery } = require('../parameters/ssm-discovery');
|
|
25
|
+
const { CloudFormationDiscovery } = require('./cloudformation-discovery');
|
|
26
|
+
const { AssociateRouteTableCommand } = require('@aws-sdk/client-ec2');
|
|
21
27
|
|
|
22
28
|
describe('Resource Discovery', () => {
|
|
23
29
|
let mockProvider;
|
|
@@ -31,6 +37,7 @@ describe('Resource Discovery', () => {
|
|
|
31
37
|
delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
|
|
32
38
|
delete process.env.CLOUD_PROVIDER;
|
|
33
39
|
delete process.env.AWS_REGION;
|
|
40
|
+
delete process.env.SLS_STAGE;
|
|
34
41
|
|
|
35
42
|
// Create mock provider
|
|
36
43
|
mockProvider = {
|
|
@@ -64,6 +71,9 @@ describe('Resource Discovery', () => {
|
|
|
64
71
|
};
|
|
65
72
|
|
|
66
73
|
// Mock factory and discovery constructors
|
|
74
|
+
CloudFormationDiscovery.mockImplementation(() => ({
|
|
75
|
+
discoverFromStack: jest.fn().mockResolvedValue(null),
|
|
76
|
+
}));
|
|
67
77
|
CloudProviderFactory.create = jest.fn().mockReturnValue(mockProvider);
|
|
68
78
|
VpcDiscovery.mockImplementation(() => mockVpcDiscovery);
|
|
69
79
|
KmsDiscovery.mockImplementation(() => mockKmsDiscovery);
|
|
@@ -377,8 +387,6 @@ describe('Resource Discovery', () => {
|
|
|
377
387
|
});
|
|
378
388
|
|
|
379
389
|
it('should default stage to dev', async () => {
|
|
380
|
-
delete process.env.SLS_STAGE;
|
|
381
|
-
|
|
382
390
|
const appDefinition = {
|
|
383
391
|
vpc: { enable: true },
|
|
384
392
|
};
|
|
@@ -411,8 +419,6 @@ describe('Resource Discovery', () => {
|
|
|
411
419
|
stage: 'qa',
|
|
412
420
|
})
|
|
413
421
|
);
|
|
414
|
-
|
|
415
|
-
delete process.env.SLS_STAGE;
|
|
416
422
|
});
|
|
417
423
|
|
|
418
424
|
it('should recognize routing infrastructure as useful data', async () => {
|
|
@@ -423,8 +429,7 @@ describe('Resource Discovery', () => {
|
|
|
423
429
|
|
|
424
430
|
process.env.SLS_STAGE = 'production';
|
|
425
431
|
|
|
426
|
-
|
|
427
|
-
const mockCloudFormationDiscovery = {
|
|
432
|
+
CloudFormationDiscovery.mockImplementation(() => ({
|
|
428
433
|
discoverFromStack: jest.fn().mockResolvedValue({
|
|
429
434
|
fromCloudFormationStack: true,
|
|
430
435
|
routeTableId: 'rtb-123',
|
|
@@ -434,20 +439,13 @@ describe('Resource Discovery', () => {
|
|
|
434
439
|
dynamodb: 'vpce-ddb'
|
|
435
440
|
},
|
|
436
441
|
existingLogicalIds: ['FriggLambdaRouteTable', 'FriggNATRoute']
|
|
437
|
-
// NO defaultVpcId, NO defaultKmsKeyId, NO auroraClusterId
|
|
438
442
|
})
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
const { CloudFormationDiscovery } = require('./cloudformation-discovery');
|
|
442
|
-
CloudFormationDiscovery.mockImplementation(() => mockCloudFormationDiscovery);
|
|
443
|
+
}));
|
|
443
444
|
|
|
444
445
|
const result = await gatherDiscoveredResources(appDefinition);
|
|
445
446
|
|
|
446
|
-
// Should use CloudFormation data without falling back to AWS API
|
|
447
447
|
expect(result.routeTableId).toBe('rtb-123');
|
|
448
448
|
expect(result.vpcEndpoints.s3).toBe('vpce-s3');
|
|
449
|
-
|
|
450
|
-
// Should NOT call AWS API discovery
|
|
451
449
|
expect(mockVpcDiscovery.discover).not.toHaveBeenCalled();
|
|
452
450
|
});
|
|
453
451
|
|
|
@@ -468,11 +466,8 @@ describe('Resource Discovery', () => {
|
|
|
468
466
|
|
|
469
467
|
describe('Isolated Mode Discovery', () => {
|
|
470
468
|
beforeEach(() => {
|
|
471
|
-
// Mock CloudFormation discovery
|
|
472
|
-
jest.mock('./cloudformation-discovery');
|
|
473
|
-
const { CloudFormationDiscovery } = require('./cloudformation-discovery');
|
|
474
469
|
CloudFormationDiscovery.mockImplementation(() => ({
|
|
475
|
-
discoverFromStack: jest.fn().mockResolvedValue({}),
|
|
470
|
+
discoverFromStack: jest.fn().mockResolvedValue({}),
|
|
476
471
|
}));
|
|
477
472
|
});
|
|
478
473
|
|
|
@@ -506,12 +501,6 @@ describe('Resource Discovery', () => {
|
|
|
506
501
|
});
|
|
507
502
|
|
|
508
503
|
it('should return empty if no KMS found in isolated mode (fresh infrastructure)', async () => {
|
|
509
|
-
const { CloudFormationDiscovery } = require('./cloudformation-discovery');
|
|
510
|
-
|
|
511
|
-
// Mock that CF stack exists but we still want fresh resources
|
|
512
|
-
CloudFormationDiscovery.mockImplementation(() => ({
|
|
513
|
-
discoverFromStack: jest.fn().mockResolvedValue({}), // Stack exists but empty
|
|
514
|
-
}));
|
|
515
504
|
|
|
516
505
|
const appDefinition = {
|
|
517
506
|
name: 'test-app',
|
|
@@ -584,5 +573,185 @@ describe('Resource Discovery', () => {
|
|
|
584
573
|
});
|
|
585
574
|
});
|
|
586
575
|
});
|
|
587
|
-
});
|
|
588
576
|
|
|
577
|
+
describe('VPC Self-Heal', () => {
|
|
578
|
+
let mockEc2Send;
|
|
579
|
+
|
|
580
|
+
beforeEach(() => {
|
|
581
|
+
AssociateRouteTableCommand.mockClear();
|
|
582
|
+
mockEc2Send = jest.fn().mockResolvedValue({ AssociationId: 'rtbassoc-new-123' });
|
|
583
|
+
|
|
584
|
+
CloudFormationDiscovery.mockImplementation(() => ({
|
|
585
|
+
discoverFromStack: jest.fn().mockResolvedValue({
|
|
586
|
+
fromCloudFormationStack: true,
|
|
587
|
+
routeTableId: 'rtb-123',
|
|
588
|
+
routeTableAssociationCount: 0,
|
|
589
|
+
privateSubnetId1: 'subnet-priv-1',
|
|
590
|
+
privateSubnetId2: 'subnet-priv-2',
|
|
591
|
+
defaultVpcId: 'vpc-123',
|
|
592
|
+
}),
|
|
593
|
+
}));
|
|
594
|
+
|
|
595
|
+
mockProvider.getEC2Client = jest.fn().mockReturnValue({
|
|
596
|
+
send: mockEc2Send,
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('should self-heal when route table has 0 associations and selfHeal is enabled', async () => {
|
|
601
|
+
const appDefinition = {
|
|
602
|
+
name: 'test-app',
|
|
603
|
+
vpc: { enable: true, selfHeal: true },
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
process.env.SLS_STAGE = 'production';
|
|
607
|
+
|
|
608
|
+
const result = await gatherDiscoveredResources(appDefinition);
|
|
609
|
+
|
|
610
|
+
expect(mockEc2Send).toHaveBeenCalledTimes(2);
|
|
611
|
+
expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
|
|
612
|
+
RouteTableId: 'rtb-123',
|
|
613
|
+
SubnetId: 'subnet-priv-1',
|
|
614
|
+
});
|
|
615
|
+
expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
|
|
616
|
+
RouteTableId: 'rtb-123',
|
|
617
|
+
SubnetId: 'subnet-priv-2',
|
|
618
|
+
});
|
|
619
|
+
expect(result.routeTableId).toBe('rtb-123');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('should only associate private subnets with lambda route table (not public subnet)', async () => {
|
|
623
|
+
// Simulates CF discovery output for: 3 subnets in VPC (1 public, 2 private),
|
|
624
|
+
// IGW route table has all 3, Frigg lambda route table has 0 associations.
|
|
625
|
+
// CF discovery already filtered by !MapPublicIpOnLaunch, so only private IDs arrive here.
|
|
626
|
+
CloudFormationDiscovery.mockImplementation(() => ({
|
|
627
|
+
discoverFromStack: jest.fn().mockResolvedValue({
|
|
628
|
+
fromCloudFormationStack: true,
|
|
629
|
+
routeTableId: 'rtb-lambda',
|
|
630
|
+
routeTableAssociationCount: 0,
|
|
631
|
+
privateSubnetId1: 'subnet-priv-1',
|
|
632
|
+
privateSubnetId2: 'subnet-priv-2',
|
|
633
|
+
defaultVpcId: 'vpc-123',
|
|
634
|
+
}),
|
|
635
|
+
}));
|
|
636
|
+
|
|
637
|
+
const appDefinition = {
|
|
638
|
+
name: 'test-app',
|
|
639
|
+
vpc: { enable: true, selfHeal: true },
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
process.env.SLS_STAGE = 'production';
|
|
643
|
+
|
|
644
|
+
await gatherDiscoveredResources(appDefinition);
|
|
645
|
+
|
|
646
|
+
// Self-heal should associate ONLY the 2 private subnets
|
|
647
|
+
expect(mockEc2Send).toHaveBeenCalledTimes(2);
|
|
648
|
+
expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
|
|
649
|
+
RouteTableId: 'rtb-lambda',
|
|
650
|
+
SubnetId: 'subnet-priv-1',
|
|
651
|
+
});
|
|
652
|
+
expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
|
|
653
|
+
RouteTableId: 'rtb-lambda',
|
|
654
|
+
SubnetId: 'subnet-priv-2',
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Public subnet (subnet-public) should never appear in any AssociateRouteTableCommand call
|
|
658
|
+
const allCalls = AssociateRouteTableCommand.mock.calls.map(c => c[0].SubnetId);
|
|
659
|
+
expect(allCalls).not.toContain('subnet-public');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('should not self-heal when selfHeal is disabled', async () => {
|
|
663
|
+
const appDefinition = {
|
|
664
|
+
name: 'test-app',
|
|
665
|
+
vpc: { enable: true, selfHeal: false },
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
process.env.SLS_STAGE = 'production';
|
|
669
|
+
|
|
670
|
+
await gatherDiscoveredResources(appDefinition);
|
|
671
|
+
|
|
672
|
+
expect(mockEc2Send).not.toHaveBeenCalled();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('should not self-heal when routeTableAssociationCount is not 0', async () => {
|
|
676
|
+
CloudFormationDiscovery.mockImplementation(() => ({
|
|
677
|
+
discoverFromStack: jest.fn().mockResolvedValue({
|
|
678
|
+
fromCloudFormationStack: true,
|
|
679
|
+
routeTableId: 'rtb-123',
|
|
680
|
+
routeTableAssociationCount: 2,
|
|
681
|
+
privateSubnetId1: 'subnet-priv-1',
|
|
682
|
+
privateSubnetId2: 'subnet-priv-2',
|
|
683
|
+
defaultVpcId: 'vpc-123',
|
|
684
|
+
}),
|
|
685
|
+
}));
|
|
686
|
+
|
|
687
|
+
const appDefinition = {
|
|
688
|
+
name: 'test-app',
|
|
689
|
+
vpc: { enable: true, selfHeal: true },
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
process.env.SLS_STAGE = 'production';
|
|
693
|
+
|
|
694
|
+
await gatherDiscoveredResources(appDefinition);
|
|
695
|
+
|
|
696
|
+
expect(mockEc2Send).not.toHaveBeenCalled();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('should continue associating subnet 2 if subnet 1 fails', async () => {
|
|
700
|
+
mockEc2Send
|
|
701
|
+
.mockRejectedValueOnce(new Error('Resource.AlreadyAssociated'))
|
|
702
|
+
.mockResolvedValueOnce({ AssociationId: 'rtbassoc-456' });
|
|
703
|
+
|
|
704
|
+
const appDefinition = {
|
|
705
|
+
name: 'test-app',
|
|
706
|
+
vpc: { enable: true, selfHeal: true },
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
process.env.SLS_STAGE = 'production';
|
|
710
|
+
|
|
711
|
+
const result = await gatherDiscoveredResources(appDefinition);
|
|
712
|
+
|
|
713
|
+
expect(mockEc2Send).toHaveBeenCalledTimes(2);
|
|
714
|
+
expect(result.routeTableId).toBe('rtb-123');
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('should handle both subnet associations failing gracefully', async () => {
|
|
718
|
+
mockEc2Send.mockRejectedValue(new Error('AccessDenied'));
|
|
719
|
+
|
|
720
|
+
const appDefinition = {
|
|
721
|
+
name: 'test-app',
|
|
722
|
+
vpc: { enable: true, selfHeal: true },
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
process.env.SLS_STAGE = 'production';
|
|
726
|
+
|
|
727
|
+
const result = await gatherDiscoveredResources(appDefinition);
|
|
728
|
+
|
|
729
|
+
expect(mockEc2Send).toHaveBeenCalledTimes(2);
|
|
730
|
+
expect(result.routeTableId).toBe('rtb-123');
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('should not self-heal when privateSubnetId2 is missing', async () => {
|
|
734
|
+
CloudFormationDiscovery.mockImplementation(() => ({
|
|
735
|
+
discoverFromStack: jest.fn().mockResolvedValue({
|
|
736
|
+
fromCloudFormationStack: true,
|
|
737
|
+
routeTableId: 'rtb-123',
|
|
738
|
+
routeTableAssociationCount: 0,
|
|
739
|
+
privateSubnetId1: 'subnet-priv-1',
|
|
740
|
+
// privateSubnetId2 missing
|
|
741
|
+
defaultVpcId: 'vpc-123',
|
|
742
|
+
}),
|
|
743
|
+
}));
|
|
744
|
+
|
|
745
|
+
const appDefinition = {
|
|
746
|
+
name: 'test-app',
|
|
747
|
+
vpc: { enable: true, selfHeal: true },
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
process.env.SLS_STAGE = 'production';
|
|
751
|
+
|
|
752
|
+
await gatherDiscoveredResources(appDefinition);
|
|
753
|
+
|
|
754
|
+
expect(mockEc2Send).not.toHaveBeenCalled();
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/devtools",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0-next.
|
|
4
|
+
"version": "2.0.0-next.74",
|
|
5
5
|
"bin": {
|
|
6
6
|
"frigg": "./frigg-cli/index.js"
|
|
7
7
|
},
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"@babel/eslint-parser": "^7.18.9",
|
|
26
26
|
"@babel/parser": "^7.25.3",
|
|
27
27
|
"@babel/traverse": "^7.25.3",
|
|
28
|
-
"@friggframework/core": "2.0.0-next.
|
|
29
|
-
"@friggframework/schemas": "2.0.0-next.
|
|
30
|
-
"@friggframework/test": "2.0.0-next.
|
|
28
|
+
"@friggframework/core": "2.0.0-next.74",
|
|
29
|
+
"@friggframework/schemas": "2.0.0-next.74",
|
|
30
|
+
"@friggframework/test": "2.0.0-next.74",
|
|
31
31
|
"@hapi/boom": "^10.0.1",
|
|
32
32
|
"@inquirer/prompts": "^5.3.8",
|
|
33
33
|
"axios": "^1.7.2",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"validate-npm-package-name": "^5.0.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
59
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
58
|
+
"@friggframework/eslint-config": "2.0.0-next.74",
|
|
59
|
+
"@friggframework/prettier-config": "2.0.0-next.74",
|
|
60
60
|
"aws-sdk-client-mock": "^4.1.0",
|
|
61
61
|
"aws-sdk-client-mock-jest": "^4.1.0",
|
|
62
62
|
"jest": "^30.1.3",
|
|
@@ -88,5 +88,5 @@
|
|
|
88
88
|
"publishConfig": {
|
|
89
89
|
"access": "public"
|
|
90
90
|
},
|
|
91
|
-
"gitHead": "
|
|
91
|
+
"gitHead": "8f935e0715e46299f5bc720d2ce8551da5d5705a"
|
|
92
92
|
}
|