@friggframework/devtools 2.0.0-next.53 → 2.0.0-next.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/frigg-cli/README.md +13 -14
  2. package/frigg-cli/__tests__/unit/commands/db-setup.test.js +267 -166
  3. package/frigg-cli/__tests__/unit/utils/database-validator.test.js +45 -14
  4. package/frigg-cli/__tests__/unit/utils/error-messages.test.js +44 -3
  5. package/frigg-cli/db-setup-command/index.js +75 -22
  6. package/frigg-cli/deploy-command/index.js +6 -3
  7. package/frigg-cli/utils/database-validator.js +18 -5
  8. package/frigg-cli/utils/error-messages.js +84 -12
  9. package/infrastructure/README.md +28 -0
  10. package/infrastructure/domains/database/migration-builder.js +26 -20
  11. package/infrastructure/domains/database/migration-builder.test.js +27 -0
  12. package/infrastructure/domains/integration/integration-builder.js +17 -13
  13. package/infrastructure/domains/integration/integration-builder.test.js +23 -0
  14. package/infrastructure/domains/networking/vpc-builder.js +240 -18
  15. package/infrastructure/domains/networking/vpc-builder.test.js +711 -13
  16. package/infrastructure/domains/networking/vpc-resolver.js +221 -40
  17. package/infrastructure/domains/networking/vpc-resolver.test.js +318 -18
  18. package/infrastructure/domains/security/kms-builder.js +55 -6
  19. package/infrastructure/domains/security/kms-builder.test.js +19 -1
  20. package/infrastructure/domains/shared/cloudformation-discovery.js +310 -13
  21. package/infrastructure/domains/shared/cloudformation-discovery.test.js +395 -0
  22. package/infrastructure/domains/shared/providers/aws-provider-adapter.js +41 -6
  23. package/infrastructure/domains/shared/providers/aws-provider-adapter.test.js +39 -0
  24. package/infrastructure/domains/shared/resource-discovery.js +17 -5
  25. package/infrastructure/domains/shared/resource-discovery.test.js +36 -0
  26. package/infrastructure/domains/shared/utilities/base-definition-factory.js +30 -20
  27. package/infrastructure/domains/shared/utilities/base-definition-factory.test.js +43 -0
  28. package/infrastructure/infrastructure-composer.js +11 -3
  29. package/infrastructure/scripts/build-prisma-layer.js +153 -78
  30. package/infrastructure/scripts/build-prisma-layer.test.js +27 -11
  31. package/layers/prisma/.build-complete +3 -0
  32. package/package.json +7 -7
@@ -586,5 +586,400 @@ describe('CloudFormationDiscovery', () => {
586
586
  expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms');
587
587
  });
588
588
  });
589
+
590
+ describe('External VPC with routing infrastructure pattern', () => {
591
+ it('should discover routing resources when VPC is external', async () => {
592
+ // This tests the external VPC pattern: external VPC/subnets/KMS,
593
+ // but stack creates routing infrastructure (route table, NAT route, VPC endpoints)
594
+ const mockStack = {
595
+ StackName: 'create-frigg-app-production',
596
+ Outputs: [],
597
+ };
598
+
599
+ const mockResources = [
600
+ {
601
+ LogicalResourceId: 'FriggLambdaRouteTable',
602
+ PhysicalResourceId: 'rtb-0b83aca77ccde20a6',
603
+ ResourceType: 'AWS::EC2::RouteTable',
604
+ ResourceStatus: 'UPDATE_COMPLETE',
605
+ },
606
+ {
607
+ LogicalResourceId: 'FriggNATRoute',
608
+ PhysicalResourceId: 'rtb-0b83aca77ccde20a6|0.0.0.0/0',
609
+ ResourceType: 'AWS::EC2::Route',
610
+ ResourceStatus: 'UPDATE_COMPLETE',
611
+ },
612
+ {
613
+ LogicalResourceId: 'FriggSubnet1RouteAssociation',
614
+ PhysicalResourceId: 'rtbassoc-07245da0b447ca469',
615
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
616
+ ResourceStatus: 'CREATE_COMPLETE',
617
+ },
618
+ {
619
+ LogicalResourceId: 'FriggSubnet2RouteAssociation',
620
+ PhysicalResourceId: 'rtbassoc-0806f9783c4ea181f',
621
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
622
+ ResourceStatus: 'CREATE_COMPLETE',
623
+ },
624
+ {
625
+ LogicalResourceId: 'VPCEndpointS3',
626
+ PhysicalResourceId: 'vpce-0352ceac2124c14be',
627
+ ResourceType: 'AWS::EC2::VPCEndpoint',
628
+ ResourceStatus: 'CREATE_COMPLETE',
629
+ },
630
+ {
631
+ LogicalResourceId: 'VPCEndpointDynamoDB',
632
+ PhysicalResourceId: 'vpce-0b06c4f631199ea68',
633
+ ResourceType: 'AWS::EC2::VPCEndpoint',
634
+ ResourceStatus: 'CREATE_COMPLETE',
635
+ },
636
+ ];
637
+
638
+ mockProvider.describeStack.mockResolvedValue(mockStack);
639
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
640
+
641
+ const result = await cfDiscovery.discoverFromStack('create-frigg-app-production');
642
+
643
+ // Verify routing infrastructure was discovered
644
+ expect(result.routeTableId).toBe('rtb-0b83aca77ccde20a6');
645
+ expect(result.privateRouteTableId).toBe('rtb-0b83aca77ccde20a6');
646
+ expect(result.natRoute).toBe('rtb-0b83aca77ccde20a6|0.0.0.0/0');
647
+ expect(result.routeTableAssociations).toEqual([
648
+ 'rtbassoc-07245da0b447ca469',
649
+ 'rtbassoc-0806f9783c4ea181f',
650
+ ]);
651
+
652
+ // Verify VPC endpoints were discovered (both naming conventions)
653
+ expect(result.vpcEndpoints).toBeDefined();
654
+ expect(result.vpcEndpoints.s3).toBe('vpce-0352ceac2124c14be');
655
+ expect(result.vpcEndpoints.dynamodb).toBe('vpce-0b06c4f631199ea68');
656
+ expect(result.s3VpcEndpointId).toBe('vpce-0352ceac2124c14be');
657
+ expect(result.dynamoDbVpcEndpointId).toBe('vpce-0b06c4f631199ea68');
658
+
659
+ // Verify NO VPC/KMS resources (they're external)
660
+ expect(result.defaultVpcId).toBeUndefined();
661
+ expect(result.defaultKmsKeyId).toBeUndefined();
662
+ });
663
+
664
+ it('should work with legacy VPC endpoint naming (FriggS3VPCEndpoint)', async () => {
665
+ const mockStack = {
666
+ StackName: 'test-stack',
667
+ Outputs: [],
668
+ };
669
+
670
+ const mockResources = [
671
+ {
672
+ LogicalResourceId: 'FriggS3VPCEndpoint',
673
+ PhysicalResourceId: 'vpce-legacy-s3',
674
+ ResourceType: 'AWS::EC2::VPCEndpoint',
675
+ ResourceStatus: 'CREATE_COMPLETE',
676
+ },
677
+ {
678
+ LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
679
+ PhysicalResourceId: 'vpce-legacy-ddb',
680
+ ResourceType: 'AWS::EC2::VPCEndpoint',
681
+ ResourceStatus: 'CREATE_COMPLETE',
682
+ },
683
+ ];
684
+
685
+ mockProvider.describeStack.mockResolvedValue(mockStack);
686
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
687
+
688
+ const result = await cfDiscovery.discoverFromStack('test-stack');
689
+
690
+ // Both naming conventions should work
691
+ expect(result.vpcEndpoints.s3).toBe('vpce-legacy-s3');
692
+ expect(result.vpcEndpoints.dynamodb).toBe('vpce-legacy-ddb');
693
+ expect(result.s3VpcEndpointId).toBe('vpce-legacy-s3');
694
+ expect(result.dynamoDbVpcEndpointId).toBe('vpce-legacy-ddb');
695
+ });
696
+
697
+ it('should extract FriggLambdaSecurityGroup from stack', async () => {
698
+ const mockStack = {
699
+ StackName: 'test-stack',
700
+ Outputs: [],
701
+ };
702
+
703
+ const mockResources = [
704
+ {
705
+ LogicalResourceId: 'FriggLambdaSecurityGroup',
706
+ PhysicalResourceId: 'sg-01002240c6a446202',
707
+ ResourceType: 'AWS::EC2::SecurityGroup',
708
+ ResourceStatus: 'UPDATE_COMPLETE',
709
+ },
710
+ {
711
+ LogicalResourceId: 'FriggLambdaRouteTable',
712
+ PhysicalResourceId: 'rtb-08af43bbf0775602d',
713
+ ResourceType: 'AWS::EC2::RouteTable',
714
+ ResourceStatus: 'UPDATE_COMPLETE',
715
+ },
716
+ ];
717
+
718
+ mockProvider.describeStack.mockResolvedValue(mockStack);
719
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
720
+
721
+ const result = await cfDiscovery.discoverFromStack('test-stack');
722
+
723
+ // Lambda security group should be extracted
724
+ expect(result.lambdaSecurityGroupId).toBe('sg-01002240c6a446202');
725
+ expect(result.defaultSecurityGroupId).toBe('sg-01002240c6a446202');
726
+ expect(result.existingLogicalIds).toContain('FriggLambdaSecurityGroup');
727
+ });
728
+
729
+ it('should support FriggPrivateRoute naming for NAT routes', async () => {
730
+ const mockStack = {
731
+ StackName: 'test-stack',
732
+ Outputs: [],
733
+ };
734
+
735
+ const mockResources = [
736
+ {
737
+ LogicalResourceId: 'FriggLambdaRouteTable',
738
+ PhysicalResourceId: 'rtb-123',
739
+ ResourceType: 'AWS::EC2::RouteTable',
740
+ ResourceStatus: 'UPDATE_COMPLETE',
741
+ },
742
+ {
743
+ LogicalResourceId: 'FriggPrivateRoute',
744
+ PhysicalResourceId: 'rtb-123|0.0.0.0/0',
745
+ ResourceType: 'AWS::EC2::Route',
746
+ ResourceStatus: 'UPDATE_COMPLETE',
747
+ },
748
+ ];
749
+
750
+ mockProvider.describeStack.mockResolvedValue(mockStack);
751
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
752
+
753
+ const result = await cfDiscovery.discoverFromStack('test-stack');
754
+
755
+ // Both FriggNATRoute and FriggPrivateRoute should be recognized
756
+ expect(result.natRoute).toBe('rtb-123|0.0.0.0/0');
757
+ expect(result.routeTableId).toBe('rtb-123');
758
+ });
759
+
760
+ it('should extract external references from route table without stackName error', async () => {
761
+ const mockStack = {
762
+ StackName: 'test-stack',
763
+ Outputs: [],
764
+ };
765
+
766
+ const mockResources = [
767
+ {
768
+ LogicalResourceId: 'FriggLambdaRouteTable',
769
+ PhysicalResourceId: 'rtb-real-id',
770
+ ResourceType: 'AWS::EC2::RouteTable',
771
+ ResourceStatus: 'UPDATE_COMPLETE',
772
+ },
773
+ ];
774
+
775
+ mockProvider.describeStack.mockResolvedValue(mockStack);
776
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
777
+
778
+ // Mock EC2 DescribeRouteTables to return route table with VPC info
779
+ mockProvider.getEC2Client = jest.fn().mockReturnValue({
780
+ send: jest.fn().mockResolvedValue({
781
+ RouteTables: [{
782
+ RouteTableId: 'rtb-real-id',
783
+ VpcId: 'vpc-extracted',
784
+ Routes: [
785
+ { NatGatewayId: 'nat-extracted', DestinationCidrBlock: '0.0.0.0/0' }
786
+ ],
787
+ Associations: [
788
+ { SubnetId: 'subnet-1' },
789
+ { SubnetId: 'subnet-2' }
790
+ ]
791
+ }]
792
+ })
793
+ });
794
+
795
+ const result = await cfDiscovery.discoverFromStack('test-stack');
796
+
797
+ // Should extract VPC, NAT, and subnets from route table
798
+ expect(result.defaultVpcId).toBe('vpc-extracted');
799
+ expect(result.existingNatGatewayId).toBe('nat-extracted');
800
+ expect(result.privateSubnetId1).toBe('subnet-1');
801
+ expect(result.privateSubnetId2).toBe('subnet-2');
802
+
803
+ // Should NOT throw 'stackName is not defined' error
804
+ expect(result).toBeDefined();
805
+ });
806
+ });
807
+
808
+ describe('existingLogicalIds tracking', () => {
809
+ it('should track OLD VPC endpoint logical IDs (VPCEndpointS3 pattern) for backwards compatibility', async () => {
810
+ // CRITICAL: Frontify production uses OLD naming convention
811
+ const mockStack = {
812
+ StackName: 'create-frigg-app-production',
813
+ Outputs: []
814
+ };
815
+
816
+ const mockResources = [
817
+ { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' },
818
+ { LogicalResourceId: 'FriggNATRoute', PhysicalResourceId: 'rtb-123|0.0.0.0/0', ResourceType: 'AWS::EC2::Route' },
819
+ { LogicalResourceId: 'FriggSubnet1RouteAssociation', PhysicalResourceId: 'rtbassoc-1', ResourceType: 'AWS::EC2::SubnetRouteTableAssociation' },
820
+ { LogicalResourceId: 'FriggSubnet2RouteAssociation', PhysicalResourceId: 'rtbassoc-2', ResourceType: 'AWS::EC2::SubnetRouteTableAssociation' },
821
+ { LogicalResourceId: 'VPCEndpointS3', PhysicalResourceId: 'vpce-s3-123', ResourceType: 'AWS::EC2::VPCEndpoint' },
822
+ { LogicalResourceId: 'VPCEndpointDynamoDB', PhysicalResourceId: 'vpce-ddb-123', ResourceType: 'AWS::EC2::VPCEndpoint' }
823
+ ];
824
+
825
+ mockProvider.describeStack.mockResolvedValue(mockStack);
826
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
827
+
828
+ const result = await cfDiscovery.discoverFromStack('create-frigg-app-production');
829
+
830
+ // CRITICAL: existingLogicalIds MUST contain old VPC endpoint names
831
+ expect(result.existingLogicalIds).toBeDefined();
832
+ expect(result.existingLogicalIds).toContain('FriggNATRoute');
833
+ expect(result.existingLogicalIds).toContain('FriggSubnet1RouteAssociation');
834
+ expect(result.existingLogicalIds).toContain('FriggSubnet2RouteAssociation');
835
+ expect(result.existingLogicalIds).toContain('VPCEndpointS3'); // OLD naming
836
+ expect(result.existingLogicalIds).toContain('VPCEndpointDynamoDB'); // OLD naming
837
+
838
+ // Should also have the flat discovery properties
839
+ expect(result.routeTableId).toBe('rtb-123');
840
+ expect(result.natRoute).toBe('rtb-123|0.0.0.0/0');
841
+ expect(result.s3VpcEndpointId).toBe('vpce-s3-123');
842
+ expect(result.dynamodbVpcEndpointId).toBe('vpce-ddb-123');
843
+ });
844
+
845
+ it('should track NEW VPC endpoint logical IDs (FriggS3VPCEndpoint pattern) for newer stacks', async () => {
846
+ const mockStack = {
847
+ StackName: 'test-stack',
848
+ Outputs: []
849
+ };
850
+
851
+ const mockResources = [
852
+ { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-456', ResourceType: 'AWS::EC2::RouteTable' },
853
+ { LogicalResourceId: 'FriggPrivateRoute', PhysicalResourceId: 'rtb-456|0.0.0.0/0', ResourceType: 'AWS::EC2::Route' },
854
+ { LogicalResourceId: 'FriggS3VPCEndpoint', PhysicalResourceId: 'vpce-s3-456', ResourceType: 'AWS::EC2::VPCEndpoint' },
855
+ { LogicalResourceId: 'FriggDynamoDBVPCEndpoint', PhysicalResourceId: 'vpce-ddb-456', ResourceType: 'AWS::EC2::VPCEndpoint' },
856
+ { LogicalResourceId: 'FriggKMSVPCEndpoint', PhysicalResourceId: 'vpce-kms-456', ResourceType: 'AWS::EC2::VPCEndpoint' }
857
+ ];
858
+
859
+ mockProvider.describeStack.mockResolvedValue(mockStack);
860
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
861
+
862
+ const result = await cfDiscovery.discoverFromStack('test-stack');
863
+
864
+ // Should track NEW naming pattern in existingLogicalIds
865
+ expect(result.existingLogicalIds).toContain('FriggPrivateRoute');
866
+ expect(result.existingLogicalIds).toContain('FriggS3VPCEndpoint');
867
+ expect(result.existingLogicalIds).toContain('FriggDynamoDBVPCEndpoint');
868
+ expect(result.existingLogicalIds).toContain('FriggKMSVPCEndpoint');
869
+
870
+ // Should NOT contain old naming patterns
871
+ expect(result.existingLogicalIds).not.toContain('FriggNATRoute');
872
+ expect(result.existingLogicalIds).not.toContain('VPCEndpointS3');
873
+ });
874
+ });
875
+
876
+ describe('Subnet extraction from VPC query (OLD reliable approach)', () => {
877
+ it('should extract subnets by querying ALL subnets in VPC then filtering by route table', async () => {
878
+ // Tests the proven method from aws-discovery.js
879
+ // 1. Query ALL subnets in VPC using vpc-id filter (not association filter!)
880
+ // 2. Query route table by ID (RouteTableIds parameter, not Filters!)
881
+ // 3. Extract subnet IDs from route table's Associations array
882
+
883
+ const mockStack = {
884
+ StackName: 'test-stack',
885
+ Outputs: []
886
+ };
887
+
888
+ const mockResources = [
889
+ { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' },
890
+ { LogicalResourceId: 'FriggVPC', PhysicalResourceId: 'vpc-456', ResourceType: 'AWS::EC2::VPC' }
891
+ ];
892
+
893
+ const sendMock = jest.fn();
894
+ sendMock
895
+ .mockResolvedValueOnce({
896
+ RouteTables: [{
897
+ RouteTableId: 'rtb-123',
898
+ VpcId: 'vpc-456',
899
+ Associations: [],
900
+ Routes: [{ NatGatewayId: 'nat-789', DestinationCidrBlock: '0.0.0.0/0' }]
901
+ }]
902
+ })
903
+ .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] })
904
+ .mockResolvedValueOnce({
905
+ Subnets: [
906
+ { SubnetId: 'subnet-aaa', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1a' },
907
+ { SubnetId: 'subnet-bbb', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1b' },
908
+ { SubnetId: 'subnet-ccc', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1c' }
909
+ ]
910
+ })
911
+ .mockResolvedValueOnce({
912
+ RouteTables: [{
913
+ RouteTableId: 'rtb-123',
914
+ Associations: [
915
+ { RouteTableAssociationId: 'rtbassoc-111', SubnetId: 'subnet-aaa' },
916
+ { RouteTableAssociationId: 'rtbassoc-222', SubnetId: 'subnet-bbb' }
917
+ ]
918
+ }]
919
+ });
920
+
921
+ const mockEC2Client = { send: sendMock };
922
+
923
+ mockProvider.describeStack.mockResolvedValue(mockStack);
924
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
925
+ mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
926
+
927
+ const result = await cfDiscovery.discoverFromStack('test-stack');
928
+
929
+ // Should have extracted subnets using VPC query approach
930
+ expect(result.privateSubnetId1).toBe('subnet-aaa');
931
+ expect(result.privateSubnetId2).toBe('subnet-bbb');
932
+ });
933
+
934
+ it('should handle VPC with only 1 associated subnet (use second as fallback)', async () => {
935
+ const mockStack = {
936
+ StackName: 'test-stack',
937
+ Outputs: []
938
+ };
939
+
940
+ const mockResources = [
941
+ { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' },
942
+ { LogicalResourceId: 'FriggVPC', PhysicalResourceId: 'vpc-456', ResourceType: 'AWS::EC2::VPC' }
943
+ ];
944
+
945
+ const sendMock = jest.fn();
946
+ sendMock
947
+ .mockResolvedValueOnce({
948
+ RouteTables: [{
949
+ RouteTableId: 'rtb-123',
950
+ VpcId: 'vpc-456',
951
+ Associations: [],
952
+ Routes: [{ NatGatewayId: 'nat-789', DestinationCidrBlock: '0.0.0.0/0' }]
953
+ }]
954
+ })
955
+ .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] })
956
+ .mockResolvedValueOnce({
957
+ Subnets: [
958
+ { SubnetId: 'subnet-aaa', VpcId: 'vpc-456' },
959
+ { SubnetId: 'subnet-bbb', VpcId: 'vpc-456' }
960
+ ]
961
+ })
962
+ .mockResolvedValueOnce({
963
+ RouteTables: [{
964
+ RouteTableId: 'rtb-123',
965
+ Associations: [
966
+ { RouteTableAssociationId: 'rtbassoc-111', SubnetId: 'subnet-aaa' }
967
+ ]
968
+ }]
969
+ });
970
+
971
+ const mockEC2Client = { send: sendMock };
972
+
973
+ mockProvider.describeStack.mockResolvedValue(mockStack);
974
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
975
+ mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
976
+
977
+ const result = await cfDiscovery.discoverFromStack('test-stack');
978
+
979
+ // Should use first from route table, second from fallback
980
+ expect(result.privateSubnetId1).toBe('subnet-aaa');
981
+ expect(result.privateSubnetId2).toBe('subnet-bbb');
982
+ });
983
+ });
589
984
  });
590
985
 
@@ -20,7 +20,7 @@ let KMSClient, ListKeysCommand, DescribeKeyCommand, ListAliasesCommand;
20
20
  let RDSClient, DescribeDBClustersCommand, DescribeDBInstancesCommand;
21
21
  let SSMClient, GetParameterCommand, GetParametersByPathCommand;
22
22
  let SecretsManagerClient, ListSecretsCommand, GetSecretValueCommand;
23
- let CloudFormationClient, DescribeStacksCommand, ListStackResourcesCommand;
23
+ let CloudFormationClient, DescribeStacksCommand, ListStackResourcesCommand, GetTemplateCommand;
24
24
 
25
25
  /**
26
26
  * Lazy load EC2 SDK
@@ -518,24 +518,59 @@ class AWSProviderAdapter extends CloudProviderAdapter {
518
518
 
519
519
  /**
520
520
  * List CloudFormation stack resources
521
+ * Handles pagination to retrieve all resources (CloudFormation limits to 1 MB per page)
521
522
  *
522
523
  * @param {string} stackName - Name of the CloudFormation stack
523
- * @returns {Promise<Array>} List of stack resources
524
+ * @returns {Promise<Array>} List of all stack resources across all pages
524
525
  */
525
526
  async listStackResources(stackName) {
526
527
  const cf = this.getCloudFormationClient();
528
+ const allResources = [];
529
+ let nextToken = null;
527
530
 
528
531
  try {
529
- const response = await cf.send(new ListStackResourcesCommand({
530
- StackName: stackName,
531
- }));
532
+ do {
533
+ const response = await cf.send(new ListStackResourcesCommand({
534
+ StackName: stackName,
535
+ NextToken: nextToken
536
+ }));
537
+
538
+ if (response.StackResourceSummaries) {
539
+ allResources.push(...response.StackResourceSummaries);
540
+ }
541
+
542
+ nextToken = response.NextToken || null;
543
+ } while (nextToken);
532
544
 
533
- return response.StackResourceSummaries || [];
545
+ return allResources;
534
546
  } catch (error) {
535
547
  console.warn(`Failed to list stack resources for ${stackName}:`, error.message);
536
548
  return [];
537
549
  }
538
550
  }
551
+
552
+ /**
553
+ * Describe a specific stack resource to get its full details including properties
554
+ * @param {string} stackName - Stack name
555
+ * @param {string} logicalResourceId - Logical resource ID
556
+ * @returns {Promise<Object>} Resource details
557
+ */
558
+ async describeStackResource(stackName, logicalResourceId) {
559
+ const cf = this.getCloudFormationClient();
560
+
561
+ try {
562
+ const { DescribeStackResourceCommand } = require('@aws-sdk/client-cloudformation');
563
+ const response = await cf.send(new DescribeStackResourceCommand({
564
+ StackName: stackName,
565
+ LogicalResourceId: logicalResourceId,
566
+ }));
567
+
568
+ return response.StackResourceDetail || null;
569
+ } catch (error) {
570
+ console.warn(`Failed to describe stack resource ${logicalResourceId}:`, error.message);
571
+ return null;
572
+ }
573
+ }
539
574
  }
540
575
 
541
576
  module.exports = {
@@ -14,6 +14,45 @@ jest.mock('@aws-sdk/client-ssm');
14
14
  jest.mock('@aws-sdk/client-secrets-manager');
15
15
 
16
16
  describe('AWSProviderAdapter', () => {
17
+ describe('listStackResources', () => {
18
+ it.skip('should handle pagination and return all resources across multiple pages', async () => {
19
+ const mockCfClient = {
20
+ send: jest.fn()
21
+ .mockResolvedValueOnce({
22
+ StackResourceSummaries: [
23
+ { LogicalResourceId: 'Resource1', PhysicalResourceId: 'res-1', ResourceType: 'AWS::EC2::VPC' },
24
+ { LogicalResourceId: 'Resource2', PhysicalResourceId: 'res-2', ResourceType: 'AWS::EC2::Subnet' }
25
+ ],
26
+ NextToken: 'token-page-2' // More pages available
27
+ })
28
+ .mockResolvedValueOnce({
29
+ StackResourceSummaries: [
30
+ { LogicalResourceId: 'VPCEndpointS3', PhysicalResourceId: 'vpce-s3', ResourceType: 'AWS::EC2::VPCEndpoint' },
31
+ { LogicalResourceId: 'VPCEndpointDynamoDB', PhysicalResourceId: 'vpce-ddb', ResourceType: 'AWS::EC2::VPCEndpoint' }
32
+ ],
33
+ NextToken: null // Last page
34
+ })
35
+ };
36
+
37
+ const provider = new AWSProviderAdapter('us-east-1');
38
+ provider.getCloudFormationClient = jest.fn().mockReturnValue(mockCfClient);
39
+
40
+ const resources = await provider.listStackResources('test-stack');
41
+
42
+ // Should have ALL resources from ALL pages
43
+ expect(resources).toHaveLength(4);
44
+ expect(resources.map(r => r.LogicalResourceId)).toEqual([
45
+ 'Resource1',
46
+ 'Resource2',
47
+ 'VPCEndpointS3',
48
+ 'VPCEndpointDynamoDB'
49
+ ]);
50
+
51
+ // Should have called CloudFormation twice (once for each page)
52
+ expect(mockCfClient.send).toHaveBeenCalledTimes(2);
53
+ });
54
+ });
55
+
17
56
  let provider;
18
57
 
19
58
  beforeEach(() => {
@@ -95,11 +95,23 @@ async function gatherDiscoveredResources(appDefinition) {
95
95
  const cfDiscovery = new CloudFormationDiscovery(provider, { serviceName, stage });
96
96
  const stackResources = await cfDiscovery.discoverFromStack(stackName);
97
97
 
98
- // Validate CF discovery results - only use if contains useful data
99
- const hasVpcData = stackResources?.defaultVpcId;
100
- const hasKmsData = stackResources?.defaultKmsKeyId;
101
- const hasAuroraData = stackResources?.auroraClusterId;
102
- const hasSomeUsefulData = hasVpcData || hasKmsData || hasAuroraData;
98
+ // Validate CF discovery results - check for ANY useful infrastructure
99
+ const hasVpcData = stackResources?.defaultVpcId; // VPC resource in stack
100
+ const hasKmsData = stackResources?.defaultKmsKeyId; // KMS resource in stack
101
+ const hasAuroraData = stackResources?.auroraClusterId; // Aurora in stack
102
+
103
+ // Check for routing infrastructure (proves VPC config exists even with external VPC)
104
+ const hasRoutingInfra = stackResources?.routeTableId || // FriggLambdaRouteTable
105
+ stackResources?.natRoute || // FriggNATRoute
106
+ stackResources?.vpcEndpoints?.s3 || // VPC endpoints
107
+ stackResources?.vpcEndpoints?.dynamodb;
108
+
109
+ // Stack is useful if it has EITHER actual resources OR routing infrastructure
110
+ const hasSomeUsefulData = hasVpcData || hasKmsData || hasAuroraData || hasRoutingInfra;
111
+
112
+ if (hasRoutingInfra && !hasVpcData) {
113
+ console.log(' ✓ Found VPC routing infrastructure in stack (external VPC pattern)');
114
+ }
103
115
 
104
116
  // Check if we're in isolated mode (each stage gets its own VPC/Aurora)
105
117
  const isIsolatedMode = appDefinition.managementMode === 'managed' &&
@@ -415,6 +415,42 @@ describe('Resource Discovery', () => {
415
415
  delete process.env.SLS_STAGE;
416
416
  });
417
417
 
418
+ it('should recognize routing infrastructure as useful data', async () => {
419
+ const appDefinition = {
420
+ name: 'test-app',
421
+ vpc: { enable: true },
422
+ };
423
+
424
+ process.env.SLS_STAGE = 'production';
425
+
426
+ // Mock CloudFormation discovery to return routing infrastructure but no VPC resource
427
+ const mockCloudFormationDiscovery = {
428
+ discoverFromStack: jest.fn().mockResolvedValue({
429
+ fromCloudFormationStack: true,
430
+ routeTableId: 'rtb-123',
431
+ natRoute: 'rtb-123|0.0.0.0/0',
432
+ vpcEndpoints: {
433
+ s3: 'vpce-s3',
434
+ dynamodb: 'vpce-ddb'
435
+ },
436
+ existingLogicalIds: ['FriggLambdaRouteTable', 'FriggNATRoute']
437
+ // NO defaultVpcId, NO defaultKmsKeyId, NO auroraClusterId
438
+ })
439
+ };
440
+
441
+ const { CloudFormationDiscovery } = require('./cloudformation-discovery');
442
+ CloudFormationDiscovery.mockImplementation(() => mockCloudFormationDiscovery);
443
+
444
+ const result = await gatherDiscoveredResources(appDefinition);
445
+
446
+ // Should use CloudFormation data without falling back to AWS API
447
+ expect(result.routeTableId).toBe('rtb-123');
448
+ expect(result.vpcEndpoints.s3).toBe('vpce-s3');
449
+
450
+ // Should NOT call AWS API discovery
451
+ expect(mockVpcDiscovery.discover).not.toHaveBeenCalled();
452
+ });
453
+
418
454
  it('should include secrets in SSM discovery by default', async () => {
419
455
  const appDefinition = {
420
456
  ssm: { enable: true },