@friggframework/devtools 2.0.0-next.45 → 2.0.0-next.46

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 (212) hide show
  1. package/infrastructure/ARCHITECTURE.md +487 -0
  2. package/infrastructure/HEALTH.md +468 -0
  3. package/infrastructure/README.md +51 -0
  4. package/infrastructure/__tests__/postgres-config.test.js +914 -0
  5. package/infrastructure/__tests__/template-generation.test.js +687 -0
  6. package/infrastructure/create-frigg-infrastructure.js +1 -1
  7. package/infrastructure/docs/POSTGRES-CONFIGURATION.md +630 -0
  8. package/infrastructure/{DEPLOYMENT-INSTRUCTIONS.md → docs/deployment-instructions.md} +3 -3
  9. package/infrastructure/{IAM-POLICY-TEMPLATES.md → docs/iam-policy-templates.md} +9 -10
  10. package/infrastructure/domains/database/aurora-builder.js +809 -0
  11. package/infrastructure/domains/database/aurora-builder.test.js +950 -0
  12. package/infrastructure/domains/database/aurora-discovery.js +87 -0
  13. package/infrastructure/domains/database/aurora-discovery.test.js +188 -0
  14. package/infrastructure/domains/database/aurora-resolver.js +210 -0
  15. package/infrastructure/domains/database/aurora-resolver.test.js +347 -0
  16. package/infrastructure/domains/database/migration-builder.js +633 -0
  17. package/infrastructure/domains/database/migration-builder.test.js +294 -0
  18. package/infrastructure/domains/database/migration-resolver.js +163 -0
  19. package/infrastructure/domains/database/migration-resolver.test.js +337 -0
  20. package/infrastructure/domains/health/application/ports/IPropertyReconciler.js +164 -0
  21. package/infrastructure/domains/health/application/ports/IResourceDetector.js +129 -0
  22. package/infrastructure/domains/health/application/ports/IResourceImporter.js +142 -0
  23. package/infrastructure/domains/health/application/ports/IStackRepository.js +131 -0
  24. package/infrastructure/domains/health/application/ports/index.js +26 -0
  25. package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
  26. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  27. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +1130 -0
  28. package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
  29. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +152 -0
  30. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.test.js +343 -0
  31. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +535 -0
  32. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.test.js +376 -0
  33. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +213 -0
  34. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +441 -0
  35. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  36. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  37. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  38. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  39. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  40. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  41. package/infrastructure/domains/health/domain/entities/issue.js +299 -0
  42. package/infrastructure/domains/health/domain/entities/issue.test.js +528 -0
  43. package/infrastructure/domains/health/domain/entities/property-mismatch.js +108 -0
  44. package/infrastructure/domains/health/domain/entities/property-mismatch.test.js +275 -0
  45. package/infrastructure/domains/health/domain/entities/resource.js +159 -0
  46. package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
  47. package/infrastructure/domains/health/domain/entities/stack-health-report.js +306 -0
  48. package/infrastructure/domains/health/domain/entities/stack-health-report.test.js +601 -0
  49. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  50. package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
  51. package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
  52. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +672 -0
  53. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  54. package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
  55. package/infrastructure/domains/health/domain/services/health-score-calculator.js +248 -0
  56. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +504 -0
  57. package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
  58. package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
  59. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +345 -0
  60. package/infrastructure/domains/health/domain/services/mismatch-analyzer.js +234 -0
  61. package/infrastructure/domains/health/domain/services/mismatch-analyzer.test.js +431 -0
  62. package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
  63. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  64. package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
  65. package/infrastructure/domains/health/domain/value-objects/health-score.js +138 -0
  66. package/infrastructure/domains/health/domain/value-objects/health-score.test.js +267 -0
  67. package/infrastructure/domains/health/domain/value-objects/property-mutability.js +161 -0
  68. package/infrastructure/domains/health/domain/value-objects/property-mutability.test.js +198 -0
  69. package/infrastructure/domains/health/domain/value-objects/resource-state.js +167 -0
  70. package/infrastructure/domains/health/domain/value-objects/resource-state.test.js +196 -0
  71. package/infrastructure/domains/health/domain/value-objects/stack-identifier.js +192 -0
  72. package/infrastructure/domains/health/domain/value-objects/stack-identifier.test.js +262 -0
  73. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  74. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  75. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  76. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +784 -0
  77. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +1133 -0
  78. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +565 -0
  79. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +554 -0
  80. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.js +318 -0
  81. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.test.js +398 -0
  82. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +777 -0
  83. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.test.js +580 -0
  84. package/infrastructure/domains/integration/integration-builder.js +397 -0
  85. package/infrastructure/domains/integration/integration-builder.test.js +593 -0
  86. package/infrastructure/domains/integration/integration-resolver.js +170 -0
  87. package/infrastructure/domains/integration/integration-resolver.test.js +369 -0
  88. package/infrastructure/domains/integration/websocket-builder.js +69 -0
  89. package/infrastructure/domains/integration/websocket-builder.test.js +195 -0
  90. package/infrastructure/domains/networking/vpc-builder.js +1829 -0
  91. package/infrastructure/domains/networking/vpc-builder.test.js +1262 -0
  92. package/infrastructure/domains/networking/vpc-discovery.js +177 -0
  93. package/infrastructure/domains/networking/vpc-discovery.test.js +350 -0
  94. package/infrastructure/domains/networking/vpc-resolver.js +324 -0
  95. package/infrastructure/domains/networking/vpc-resolver.test.js +501 -0
  96. package/infrastructure/domains/parameters/ssm-builder.js +79 -0
  97. package/infrastructure/domains/parameters/ssm-builder.test.js +189 -0
  98. package/infrastructure/domains/parameters/ssm-discovery.js +84 -0
  99. package/infrastructure/domains/parameters/ssm-discovery.test.js +210 -0
  100. package/infrastructure/{iam-generator.js → domains/security/iam-generator.js} +2 -2
  101. package/infrastructure/domains/security/kms-builder.js +366 -0
  102. package/infrastructure/domains/security/kms-builder.test.js +374 -0
  103. package/infrastructure/domains/security/kms-discovery.js +80 -0
  104. package/infrastructure/domains/security/kms-discovery.test.js +177 -0
  105. package/infrastructure/domains/security/kms-resolver.js +96 -0
  106. package/infrastructure/domains/security/kms-resolver.test.js +216 -0
  107. package/infrastructure/domains/shared/base-builder.js +112 -0
  108. package/infrastructure/domains/shared/base-resolver.js +186 -0
  109. package/infrastructure/domains/shared/base-resolver.test.js +305 -0
  110. package/infrastructure/domains/shared/builder-orchestrator.js +212 -0
  111. package/infrastructure/domains/shared/builder-orchestrator.test.js +213 -0
  112. package/infrastructure/domains/shared/cloudformation-discovery-v2.js +334 -0
  113. package/infrastructure/domains/shared/cloudformation-discovery.js +375 -0
  114. package/infrastructure/domains/shared/cloudformation-discovery.test.js +590 -0
  115. package/infrastructure/domains/shared/environment-builder.js +119 -0
  116. package/infrastructure/domains/shared/environment-builder.test.js +247 -0
  117. package/infrastructure/domains/shared/providers/aws-provider-adapter.js +544 -0
  118. package/infrastructure/domains/shared/providers/aws-provider-adapter.test.js +377 -0
  119. package/infrastructure/domains/shared/providers/azure-provider-adapter.stub.js +93 -0
  120. package/infrastructure/domains/shared/providers/cloud-provider-adapter.js +136 -0
  121. package/infrastructure/domains/shared/providers/gcp-provider-adapter.stub.js +82 -0
  122. package/infrastructure/domains/shared/providers/provider-factory.js +108 -0
  123. package/infrastructure/domains/shared/providers/provider-factory.test.js +170 -0
  124. package/infrastructure/domains/shared/resource-discovery.js +192 -0
  125. package/infrastructure/domains/shared/resource-discovery.test.js +552 -0
  126. package/infrastructure/domains/shared/types/app-definition.js +205 -0
  127. package/infrastructure/domains/shared/types/discovery-result.js +106 -0
  128. package/infrastructure/domains/shared/types/discovery-result.test.js +258 -0
  129. package/infrastructure/domains/shared/types/index.js +46 -0
  130. package/infrastructure/domains/shared/types/resource-ownership.js +108 -0
  131. package/infrastructure/domains/shared/types/resource-ownership.test.js +101 -0
  132. package/infrastructure/domains/shared/utilities/base-definition-factory.js +380 -0
  133. package/infrastructure/domains/shared/utilities/base-definition-factory.js.bak +338 -0
  134. package/infrastructure/domains/shared/utilities/base-definition-factory.test.js +248 -0
  135. package/infrastructure/domains/shared/utilities/handler-path-resolver.js +134 -0
  136. package/infrastructure/domains/shared/utilities/handler-path-resolver.test.js +268 -0
  137. package/infrastructure/domains/shared/utilities/prisma-layer-manager.js +55 -0
  138. package/infrastructure/domains/shared/utilities/prisma-layer-manager.test.js +138 -0
  139. package/infrastructure/{env-validator.js → domains/shared/validation/env-validator.js} +2 -1
  140. package/infrastructure/domains/shared/validation/env-validator.test.js +173 -0
  141. package/infrastructure/esbuild.config.js +53 -0
  142. package/infrastructure/infrastructure-composer.js +87 -0
  143. package/infrastructure/{serverless-template.test.js → infrastructure-composer.test.js} +115 -24
  144. package/infrastructure/scripts/build-prisma-layer.js +553 -0
  145. package/infrastructure/scripts/build-prisma-layer.test.js +102 -0
  146. package/infrastructure/{build-time-discovery.js → scripts/build-time-discovery.js} +80 -48
  147. package/infrastructure/{build-time-discovery.test.js → scripts/build-time-discovery.test.js} +5 -4
  148. package/layers/prisma/nodejs/package.json +8 -0
  149. package/management-ui/server/utils/cliIntegration.js +1 -1
  150. package/management-ui/server/utils/environment/awsParameterStore.js +29 -18
  151. package/package.json +11 -11
  152. package/frigg-cli/.eslintrc.js +0 -141
  153. package/frigg-cli/__tests__/unit/commands/build.test.js +0 -251
  154. package/frigg-cli/__tests__/unit/commands/db-setup.test.js +0 -548
  155. package/frigg-cli/__tests__/unit/commands/install.test.js +0 -400
  156. package/frigg-cli/__tests__/unit/commands/ui.test.js +0 -346
  157. package/frigg-cli/__tests__/unit/utils/database-validator.test.js +0 -366
  158. package/frigg-cli/__tests__/unit/utils/error-messages.test.js +0 -304
  159. package/frigg-cli/__tests__/unit/utils/prisma-runner.test.js +0 -486
  160. package/frigg-cli/__tests__/utils/mock-factory.js +0 -270
  161. package/frigg-cli/__tests__/utils/prisma-mock.js +0 -194
  162. package/frigg-cli/__tests__/utils/test-fixtures.js +0 -463
  163. package/frigg-cli/__tests__/utils/test-setup.js +0 -287
  164. package/frigg-cli/build-command/index.js +0 -65
  165. package/frigg-cli/db-setup-command/index.js +0 -193
  166. package/frigg-cli/deploy-command/index.js +0 -175
  167. package/frigg-cli/generate-command/__tests__/generate-command.test.js +0 -301
  168. package/frigg-cli/generate-command/azure-generator.js +0 -43
  169. package/frigg-cli/generate-command/gcp-generator.js +0 -47
  170. package/frigg-cli/generate-command/index.js +0 -332
  171. package/frigg-cli/generate-command/terraform-generator.js +0 -555
  172. package/frigg-cli/generate-iam-command.js +0 -118
  173. package/frigg-cli/index.js +0 -75
  174. package/frigg-cli/index.test.js +0 -158
  175. package/frigg-cli/init-command/backend-first-handler.js +0 -756
  176. package/frigg-cli/init-command/index.js +0 -93
  177. package/frigg-cli/init-command/template-handler.js +0 -143
  178. package/frigg-cli/install-command/backend-js.js +0 -33
  179. package/frigg-cli/install-command/commit-changes.js +0 -16
  180. package/frigg-cli/install-command/environment-variables.js +0 -127
  181. package/frigg-cli/install-command/environment-variables.test.js +0 -136
  182. package/frigg-cli/install-command/index.js +0 -54
  183. package/frigg-cli/install-command/install-package.js +0 -13
  184. package/frigg-cli/install-command/integration-file.js +0 -30
  185. package/frigg-cli/install-command/logger.js +0 -12
  186. package/frigg-cli/install-command/template.js +0 -90
  187. package/frigg-cli/install-command/validate-package.js +0 -75
  188. package/frigg-cli/jest.config.js +0 -124
  189. package/frigg-cli/package.json +0 -54
  190. package/frigg-cli/start-command/index.js +0 -149
  191. package/frigg-cli/start-command/start-command.test.js +0 -297
  192. package/frigg-cli/test/init-command.test.js +0 -180
  193. package/frigg-cli/test/npm-registry.test.js +0 -319
  194. package/frigg-cli/ui-command/index.js +0 -154
  195. package/frigg-cli/utils/app-resolver.js +0 -319
  196. package/frigg-cli/utils/backend-path.js +0 -25
  197. package/frigg-cli/utils/database-validator.js +0 -161
  198. package/frigg-cli/utils/error-messages.js +0 -257
  199. package/frigg-cli/utils/npm-registry.js +0 -167
  200. package/frigg-cli/utils/prisma-runner.js +0 -280
  201. package/frigg-cli/utils/process-manager.js +0 -199
  202. package/frigg-cli/utils/repo-detection.js +0 -405
  203. package/infrastructure/aws-discovery.js +0 -1176
  204. package/infrastructure/aws-discovery.test.js +0 -1220
  205. package/infrastructure/serverless-template.js +0 -2094
  206. /package/infrastructure/{WEBSOCKET-CONFIGURATION.md → docs/WEBSOCKET-CONFIGURATION.md} +0 -0
  207. /package/infrastructure/{GENERATE-IAM-DOCS.md → docs/generate-iam-command.md} +0 -0
  208. /package/infrastructure/{iam-generator.test.js → domains/security/iam-generator.test.js} +0 -0
  209. /package/infrastructure/{frigg-deployment-iam-stack.yaml → domains/security/templates/frigg-deployment-iam-stack.yaml} +0 -0
  210. /package/infrastructure/{iam-policy-basic.json → domains/security/templates/iam-policy-basic.json} +0 -0
  211. /package/infrastructure/{iam-policy-full.json → domains/security/templates/iam-policy-full.json} +0 -0
  212. /package/infrastructure/{run-discovery.js → scripts/run-discovery.js} +0 -0
@@ -0,0 +1,1829 @@
1
+ /**
2
+ * VPC Infrastructure Builder
3
+ *
4
+ * Domain Layer - Hexagonal Architecture
5
+ *
6
+ * Responsible for building VPC infrastructure including:
7
+ * - VPC creation or discovery
8
+ * - Subnet management (public/private)
9
+ * - Security groups for Lambda functions
10
+ * - NAT Gateways for private subnet internet access
11
+ * - VPC Endpoints (S3, DynamoDB, KMS, Secrets Manager)
12
+ * - Route tables and routing configuration
13
+ * - Self-healing VPC misconfigurations
14
+ *
15
+ * Supports three management modes:
16
+ * 1. create-new: Creates complete VPC infrastructure from scratch
17
+ * 2. use-existing: Uses explicitly provided VPC/subnet IDs
18
+ * 3. discover (default): Discovers and uses existing AWS resources
19
+ */
20
+
21
+ const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
22
+ const VpcResourceResolver = require('./vpc-resolver');
23
+ const { createEmptyDiscoveryResult } = require('../shared/types/discovery-result');
24
+ const { ResourceOwnership } = require('../shared/types/resource-ownership');
25
+
26
+ class VpcBuilder extends InfrastructureBuilder {
27
+ constructor() {
28
+ super();
29
+ this.name = 'VpcBuilder';
30
+ }
31
+
32
+ shouldExecute(appDefinition) {
33
+ // Skip VPC in local mode (when FRIGG_SKIP_AWS_DISCOVERY is set)
34
+ // VPC is an AWS-specific service that should only be created in production
35
+ if (process.env.FRIGG_SKIP_AWS_DISCOVERY === 'true') {
36
+ return false;
37
+ }
38
+
39
+ return appDefinition.vpc?.enable === true;
40
+ }
41
+
42
+ validate(appDefinition) {
43
+ const result = new ValidationResult();
44
+
45
+ if (!appDefinition.vpc) {
46
+ result.addError('VPC configuration is missing');
47
+ return result;
48
+ }
49
+
50
+ const vpc = appDefinition.vpc;
51
+
52
+ // Validate management mode
53
+ const validModes = ['discover', 'create-new', 'use-existing'];
54
+ const management = vpc.management || 'discover';
55
+ if (!validModes.includes(management)) {
56
+ result.addError(`Invalid vpc.management: "${management}". Must be one of: ${validModes.join(', ')}`);
57
+ }
58
+
59
+ // Validate use-existing mode requirements
60
+ if (management === 'use-existing') {
61
+ if (!vpc.vpcId) {
62
+ result.addError('vpc.vpcId is required when management="use-existing"');
63
+ }
64
+ if (!vpc.securityGroupIds || vpc.securityGroupIds.length === 0) {
65
+ result.addWarning('vpc.securityGroupIds not provided - will attempt discovery');
66
+ }
67
+ }
68
+
69
+ // Validate CIDR block format
70
+ if (vpc.cidrBlock) {
71
+ const cidrPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$/;
72
+ if (!cidrPattern.test(vpc.cidrBlock)) {
73
+ result.addError(`Invalid CIDR block format: ${vpc.cidrBlock}`);
74
+ }
75
+ }
76
+
77
+ // Validate subnet configuration
78
+ if (vpc.subnets?.management === 'use-existing') {
79
+ if (!vpc.subnets.ids || vpc.subnets.ids.length < 2) {
80
+ result.addError('At least 2 subnet IDs required when subnets.management="use-existing"');
81
+ }
82
+ }
83
+
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Warn about ignored options when managementMode='managed'
89
+ */
90
+ warnIgnoredOptions(appDefinition) {
91
+ const ignoredOptions = [];
92
+ if (appDefinition.vpc?.management) ignoredOptions.push('vpc.management');
93
+ if (appDefinition.vpc?.subnets?.management) ignoredOptions.push('vpc.subnets.management');
94
+ if (appDefinition.vpc?.natGateway?.management) ignoredOptions.push('vpc.natGateway.management');
95
+ if (appDefinition.vpc?.shareAcrossStages !== undefined) ignoredOptions.push('vpc.shareAcrossStages');
96
+
97
+ if (ignoredOptions.length > 0) {
98
+ console.log(` ⚠️ managementMode='managed' ignoring: ${ignoredOptions.join(', ')}`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Convert flat discovery result to structured discovery result
104
+ * Provides backwards compatibility for tests using old discovery format
105
+ *
106
+ * @param {Object} flatDiscovery - Flat discovery object
107
+ * @param {Object} appDefinition - App definition (used to detect stack-managed resources)
108
+ */
109
+ convertFlatDiscoveryToStructured(flatDiscovery, appDefinition = {}) {
110
+ const discovery = createEmptyDiscoveryResult();
111
+
112
+ if (!flatDiscovery) {
113
+ return discovery;
114
+ }
115
+
116
+ // Special case: managementMode='managed' + vpcIsolation='isolated' with existing resources
117
+ // These resources are from a previous deployment of this stack, so they're stack-managed
118
+ const isManagedIsolated = appDefinition.managementMode === 'managed' &&
119
+ (appDefinition.vpcIsolation === 'isolated' || !appDefinition.vpcIsolation);
120
+ const hasExistingStackResources = isManagedIsolated && flatDiscovery.defaultVpcId &&
121
+ typeof flatDiscovery.defaultVpcId === 'string';
122
+
123
+ // Check if this came from CloudFormation stack
124
+ if (flatDiscovery.fromCloudFormationStack || hasExistingStackResources) {
125
+ discovery.fromCloudFormation = true;
126
+ discovery.stackName = flatDiscovery.stackName || 'assumed-stack';
127
+
128
+ // Add resources to stackManaged array
129
+ let existingLogicalIds = flatDiscovery.existingLogicalIds || [];
130
+
131
+ // If hasExistingStackResources but no existingLogicalIds provided,
132
+ // infer logical IDs from presence of physical IDs
133
+ if (hasExistingStackResources && existingLogicalIds.length === 0) {
134
+ existingLogicalIds = [];
135
+ if (flatDiscovery.defaultVpcId) existingLogicalIds.push('FriggVPC');
136
+ if (flatDiscovery.privateSubnetId1) existingLogicalIds.push('FriggPrivateSubnet1');
137
+ if (flatDiscovery.privateSubnetId2) existingLogicalIds.push('FriggPrivateSubnet2');
138
+ if (flatDiscovery.publicSubnetId1) existingLogicalIds.push('FriggPublicSubnet');
139
+ if (flatDiscovery.publicSubnetId2) existingLogicalIds.push('FriggPublicSubnet2');
140
+ }
141
+
142
+ existingLogicalIds.forEach(logicalId => {
143
+ // Find the resource type and physical ID
144
+ let resourceType = '';
145
+ let physicalId = '';
146
+
147
+ if (logicalId === 'FriggVPC') {
148
+ resourceType = 'AWS::EC2::VPC';
149
+ physicalId = flatDiscovery.defaultVpcId;
150
+ } else if (logicalId === 'FriggLambdaSecurityGroup') {
151
+ resourceType = 'AWS::EC2::SecurityGroup';
152
+ physicalId = flatDiscovery.defaultSecurityGroupId || flatDiscovery.securityGroupId;
153
+ } else if (logicalId === 'FriggPrivateSubnet1') {
154
+ resourceType = 'AWS::EC2::Subnet';
155
+ physicalId = flatDiscovery.privateSubnetId1;
156
+ } else if (logicalId === 'FriggPrivateSubnet2') {
157
+ resourceType = 'AWS::EC2::Subnet';
158
+ physicalId = flatDiscovery.privateSubnetId2;
159
+ } else if (logicalId === 'FriggNATGateway') {
160
+ resourceType = 'AWS::EC2::NatGateway';
161
+ physicalId = flatDiscovery.existingNatGatewayId;
162
+ } else if (logicalId === 'FriggS3VPCEndpoint') {
163
+ resourceType = 'AWS::EC2::VPCEndpoint';
164
+ physicalId = flatDiscovery.s3VpcEndpointId;
165
+ } else if (logicalId === 'FriggDynamoDBVPCEndpoint') {
166
+ resourceType = 'AWS::EC2::VPCEndpoint';
167
+ physicalId = flatDiscovery.dynamodbVpcEndpointId;
168
+ } else if (logicalId === 'FriggKMSVPCEndpoint') {
169
+ resourceType = 'AWS::EC2::VPCEndpoint';
170
+ physicalId = flatDiscovery.kmsVpcEndpointId;
171
+ } else if (logicalId === 'FriggSecretsManagerVPCEndpoint') {
172
+ resourceType = 'AWS::EC2::VPCEndpoint';
173
+ physicalId = flatDiscovery.secretsManagerVpcEndpointId;
174
+ } else if (logicalId === 'FriggSQSVPCEndpoint') {
175
+ resourceType = 'AWS::EC2::VPCEndpoint';
176
+ physicalId = flatDiscovery.sqsVpcEndpointId;
177
+ }
178
+
179
+ if (physicalId && typeof physicalId === 'string') {
180
+ discovery.stackManaged.push({
181
+ logicalId,
182
+ physicalId,
183
+ resourceType
184
+ });
185
+ }
186
+ });
187
+ } else {
188
+ // Resources discovered from AWS API (not CloudFormation)
189
+ // These go into external array
190
+
191
+ if (flatDiscovery.defaultVpcId && typeof flatDiscovery.defaultVpcId === 'string') {
192
+ discovery.external.push({
193
+ physicalId: flatDiscovery.defaultVpcId,
194
+ resourceType: 'AWS::EC2::VPC',
195
+ source: 'aws-discovery'
196
+ });
197
+ }
198
+
199
+ if (flatDiscovery.defaultSecurityGroupId && typeof flatDiscovery.defaultSecurityGroupId === 'string') {
200
+ discovery.external.push({
201
+ physicalId: flatDiscovery.defaultSecurityGroupId,
202
+ resourceType: 'AWS::EC2::SecurityGroup',
203
+ source: 'aws-discovery'
204
+ });
205
+ }
206
+
207
+ if (flatDiscovery.privateSubnetId1 && typeof flatDiscovery.privateSubnetId1 === 'string') {
208
+ discovery.external.push({
209
+ physicalId: flatDiscovery.privateSubnetId1,
210
+ resourceType: 'AWS::EC2::Subnet',
211
+ source: 'aws-discovery'
212
+ });
213
+ }
214
+
215
+ if (flatDiscovery.privateSubnetId2 && typeof flatDiscovery.privateSubnetId2 === 'string') {
216
+ discovery.external.push({
217
+ physicalId: flatDiscovery.privateSubnetId2,
218
+ resourceType: 'AWS::EC2::Subnet',
219
+ source: 'aws-discovery'
220
+ });
221
+ }
222
+
223
+ // Only add NAT Gateway to external if it's NOT in a private subnet (properly placed)
224
+ // If natGatewayInPrivateSubnet is true, we need a new NAT Gateway
225
+ const natIsProperlyPlaced = flatDiscovery.natGatewayInPrivateSubnet !== true;
226
+
227
+ if (flatDiscovery.natGatewayId && typeof flatDiscovery.natGatewayId === 'string' && natIsProperlyPlaced) {
228
+ discovery.external.push({
229
+ physicalId: flatDiscovery.natGatewayId,
230
+ resourceType: 'AWS::EC2::NatGateway',
231
+ source: 'aws-discovery'
232
+ });
233
+ }
234
+
235
+ if (flatDiscovery.existingNatGatewayId && typeof flatDiscovery.existingNatGatewayId === 'string' && natIsProperlyPlaced) {
236
+ discovery.external.push({
237
+ physicalId: flatDiscovery.existingNatGatewayId,
238
+ resourceType: 'AWS::EC2::NatGateway',
239
+ source: 'aws-discovery'
240
+ });
241
+ }
242
+
243
+ // VPC Endpoints
244
+ if (flatDiscovery.s3VpcEndpointId && typeof flatDiscovery.s3VpcEndpointId === 'string') {
245
+ discovery.external.push({
246
+ physicalId: flatDiscovery.s3VpcEndpointId,
247
+ resourceType: 'AWS::EC2::VPCEndpoint',
248
+ source: 'aws-discovery',
249
+ properties: { ServiceName: 's3' }
250
+ });
251
+ }
252
+
253
+ if (flatDiscovery.dynamodbVpcEndpointId && typeof flatDiscovery.dynamodbVpcEndpointId === 'string') {
254
+ discovery.external.push({
255
+ physicalId: flatDiscovery.dynamodbVpcEndpointId,
256
+ resourceType: 'AWS::EC2::VPCEndpoint',
257
+ source: 'aws-discovery',
258
+ properties: { ServiceName: 'dynamodb' }
259
+ });
260
+ }
261
+
262
+ if (flatDiscovery.kmsVpcEndpointId && typeof flatDiscovery.kmsVpcEndpointId === 'string') {
263
+ discovery.external.push({
264
+ physicalId: flatDiscovery.kmsVpcEndpointId,
265
+ resourceType: 'AWS::EC2::VPCEndpoint',
266
+ source: 'aws-discovery',
267
+ properties: { ServiceName: 'kms' }
268
+ });
269
+ }
270
+
271
+ if (flatDiscovery.secretsManagerVpcEndpointId && typeof flatDiscovery.secretsManagerVpcEndpointId === 'string') {
272
+ discovery.external.push({
273
+ physicalId: flatDiscovery.secretsManagerVpcEndpointId,
274
+ resourceType: 'AWS::EC2::VPCEndpoint',
275
+ source: 'aws-discovery',
276
+ properties: { ServiceName: 'secretsmanager' }
277
+ });
278
+ }
279
+
280
+ if (flatDiscovery.sqsVpcEndpointId && typeof flatDiscovery.sqsVpcEndpointId === 'string') {
281
+ discovery.external.push({
282
+ physicalId: flatDiscovery.sqsVpcEndpointId,
283
+ resourceType: 'AWS::EC2::VPCEndpoint',
284
+ source: 'aws-discovery',
285
+ properties: { ServiceName: 'sqs' }
286
+ });
287
+ }
288
+ }
289
+
290
+ return discovery;
291
+ }
292
+
293
+ /**
294
+ * Translate legacy configuration (management modes) to new ownership-based configuration
295
+ * Provides backwards compatibility for existing app definitions
296
+ */
297
+ translateLegacyConfig(appDefinition, discoveredResources) {
298
+ // If already using new ownership schema, return as-is
299
+ if (appDefinition.vpc?.ownership) {
300
+ return appDefinition;
301
+ }
302
+
303
+ // Clone to avoid mutating original
304
+ const translated = JSON.parse(JSON.stringify(appDefinition));
305
+
306
+ // Initialize ownership and external sections
307
+ if (!translated.vpc.ownership) {
308
+ translated.vpc.ownership = {};
309
+ }
310
+ if (!translated.vpc.external) {
311
+ translated.vpc.external = {};
312
+ }
313
+ if (!translated.vpc.config) {
314
+ translated.vpc.config = {};
315
+ }
316
+
317
+ // Handle top-level managementMode
318
+ const globalMode = appDefinition.managementMode || 'discover';
319
+ const vpcIsolation = appDefinition.vpcIsolation || 'shared';
320
+
321
+ if (globalMode === 'managed') {
322
+ this.warnIgnoredOptions(appDefinition);
323
+
324
+ if (vpcIsolation === 'isolated') {
325
+ // Check if CloudFormation stack already has resources
326
+ const hasStackVpc = discoveredResources?.defaultVpcId && typeof discoveredResources.defaultVpcId === 'string';
327
+
328
+ if (hasStackVpc) {
329
+ // Stack has VPC - reuse it
330
+ translated.vpc.ownership.vpc = 'auto';
331
+ translated.vpc.ownership.securityGroup = 'auto';
332
+ translated.vpc.ownership.subnets = 'auto';
333
+ translated.vpc.config.selfHeal = true;
334
+ console.log(` managementMode='managed' + vpcIsolation='isolated' → stack has VPC, reusing`);
335
+ } else {
336
+ // No stack VPC - create new
337
+ translated.vpc.ownership.vpc = 'stack';
338
+ translated.vpc.ownership.securityGroup = 'stack';
339
+ translated.vpc.ownership.subnets = 'stack';
340
+ translated.vpc.ownership.natGateway = 'stack';
341
+ translated.vpc.config.natGateway = { enable: true };
342
+ console.log(` managementMode='managed' + vpcIsolation='isolated' → no stack VPC, creating new`);
343
+ }
344
+ } else {
345
+ // Shared VPC
346
+ translated.vpc.ownership.vpc = 'auto';
347
+ translated.vpc.ownership.securityGroup = 'auto';
348
+ translated.vpc.ownership.subnets = 'auto';
349
+ translated.vpc.config.selfHeal = true;
350
+ }
351
+ } else if (globalMode === 'existing') {
352
+ translated.vpc.ownership.vpc = 'external';
353
+ translated.vpc.ownership.securityGroup = 'external';
354
+ translated.vpc.ownership.subnets = 'external';
355
+ }
356
+
357
+ // Handle legacy vpc.management modes
358
+ const vpcManagement = appDefinition.vpc?.management;
359
+ if (vpcManagement === 'create-new') {
360
+ translated.vpc.ownership.vpc = 'stack';
361
+ translated.vpc.ownership.securityGroup = 'stack';
362
+ translated.vpc.ownership.subnets = 'stack';
363
+ } else if (vpcManagement === 'use-existing') {
364
+ translated.vpc.ownership.vpc = 'external';
365
+ translated.vpc.external.vpcId = appDefinition.vpc.vpcId;
366
+
367
+ if (appDefinition.vpc.securityGroupIds) {
368
+ translated.vpc.ownership.securityGroup = 'external';
369
+ translated.vpc.external.securityGroupIds = appDefinition.vpc.securityGroupIds;
370
+ }
371
+
372
+ if (appDefinition.vpc.subnets?.ids) {
373
+ translated.vpc.ownership.subnets = 'external';
374
+ translated.vpc.external.subnetIds = appDefinition.vpc.subnets.ids;
375
+ }
376
+ } else if (vpcManagement === 'discover') {
377
+ // Discover mode - let auto-resolution handle it
378
+ translated.vpc.ownership.vpc = 'auto';
379
+ translated.vpc.ownership.securityGroup = 'auto';
380
+ translated.vpc.ownership.subnets = 'auto';
381
+ }
382
+
383
+ // Handle legacy shareAcrossStages
384
+ if (appDefinition.vpc?.shareAcrossStages !== undefined) {
385
+ if (appDefinition.vpc.shareAcrossStages) {
386
+ // Shared VPC - discover and reuse
387
+ translated.vpc.ownership.vpc = 'auto';
388
+ translated.vpc.ownership.subnets = 'auto';
389
+ } else {
390
+ // Isolated VPC - create stage-specific
391
+ translated.vpc.ownership.vpc = 'stack';
392
+ translated.vpc.ownership.subnets = 'stack';
393
+ translated.vpc.ownership.natGateway = 'stack';
394
+ translated.vpc.config.natGateway = { enable: true };
395
+ }
396
+ }
397
+
398
+ // Handle legacy NAT Gateway management
399
+ if (appDefinition.vpc?.natGateway?.management === 'createAndManage') {
400
+ // Use 'auto' to allow discovering and reusing properly placed external NAT Gateways
401
+ // The resolver will check if there's a good external NAT Gateway and reuse it,
402
+ // or create a new one if needed (or if the existing one is misplaced)
403
+ translated.vpc.ownership.natGateway = 'auto';
404
+ translated.vpc.config.natGateway = { enable: true };
405
+ } else if (appDefinition.vpc?.natGateway?.id) {
406
+ translated.vpc.ownership.natGateway = 'external';
407
+ translated.vpc.external.natGatewayId = appDefinition.vpc.natGateway.id;
408
+ }
409
+
410
+ // Handle legacy subnet management
411
+ if (appDefinition.vpc?.subnets?.management === 'create') {
412
+ translated.vpc.ownership.subnets = 'stack';
413
+ } else if (appDefinition.vpc?.subnets?.management === 'use-existing' && appDefinition.vpc.subnets.ids) {
414
+ translated.vpc.ownership.subnets = 'external';
415
+ translated.vpc.external.subnetIds = appDefinition.vpc.subnets.ids;
416
+ }
417
+
418
+ // Preserve other VPC config
419
+ if (appDefinition.vpc?.cidrBlock) {
420
+ translated.vpc.config.cidrBlock = appDefinition.vpc.cidrBlock;
421
+ }
422
+ if (appDefinition.vpc?.enableVPCEndpoints !== undefined) {
423
+ translated.vpc.config.enableVpcEndpoints = appDefinition.vpc.enableVPCEndpoints;
424
+ }
425
+ if (appDefinition.vpc?.selfHeal !== undefined) {
426
+ translated.vpc.config.selfHeal = appDefinition.vpc.selfHeal;
427
+ }
428
+
429
+ return translated;
430
+ }
431
+
432
+ /**
433
+ * Build complete VPC infrastructure using ownership-based architecture
434
+ */
435
+ async build(appDefinition, discoveredResources) {
436
+ console.log(`\n[${this.name}] Building VPC infrastructure...`);
437
+
438
+ // Backwards compatibility: Translate old schema to new ownership schema
439
+ appDefinition = this.translateLegacyConfig(appDefinition, discoveredResources);
440
+
441
+ // Get structured discovery result (or convert flat discovery to structured)
442
+ // Pass appDefinition to help detect stack-managed resources in managementMode='managed'
443
+ const discovery = discoveredResources._structured || this.convertFlatDiscoveryToStructured(discoveredResources, appDefinition);
444
+
445
+ // Use VpcResourceResolver to make ownership decisions
446
+ const resolver = new VpcResourceResolver();
447
+ const decisions = resolver.resolveAll(appDefinition, discovery);
448
+
449
+ console.log('\n 📋 Resource Ownership Decisions:');
450
+ console.log(` VPC: ${decisions.vpc.ownership} - ${decisions.vpc.reason}`);
451
+ console.log(` Security Group: ${decisions.securityGroup.ownership} - ${decisions.securityGroup.reason}`);
452
+ console.log(` Subnets: ${decisions.subnets.ownership} - ${decisions.subnets.reason}`);
453
+ console.log(` NAT Gateway: ${decisions.natGateway.ownership || 'disabled'} - ${decisions.natGateway.reason}`);
454
+ console.log(` VPC Endpoints:`);
455
+ console.log(` S3: ${decisions.vpcEndpoints.s3.ownership || 'disabled'} - ${decisions.vpcEndpoints.s3.reason}`);
456
+ console.log(` DynamoDB: ${decisions.vpcEndpoints.dynamodb.ownership || 'disabled'} - ${decisions.vpcEndpoints.dynamodb.reason}`);
457
+
458
+ // Initialize result
459
+ const result = {
460
+ resources: {},
461
+ vpcConfig: {
462
+ securityGroupIds: [],
463
+ subnetIds: [],
464
+ },
465
+ iamStatements: [],
466
+ outputs: {},
467
+ environment: {},
468
+ };
469
+
470
+ // Add IAM permissions for VPC-enabled Lambda functions
471
+ this.addVpcIamPermissions(result);
472
+
473
+ // Build VPC based on ownership decision
474
+ this.buildVpcFromDecision(decisions.vpc, appDefinition, result);
475
+
476
+ // Build Security Group based on ownership decision
477
+ this.buildSecurityGroupFromDecision(decisions.securityGroup, appDefinition, result);
478
+
479
+ // Build Subnets based on ownership decision
480
+ this.buildSubnetsFromDecision(decisions.subnets, appDefinition, discoveredResources, result);
481
+
482
+ // Build NAT Gateway based on ownership decision
483
+ this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
484
+
485
+ // Build VPC Endpoints based on ownership decisions
486
+ this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, appDefinition, result);
487
+
488
+ // Set VPC_ENABLED environment variable
489
+ result.environment.VPC_ENABLED = 'true';
490
+
491
+ console.log(`\n[${this.name}] ✅ VPC infrastructure built successfully`);
492
+ console.log(` - VPC ID: ${result.vpcId || 'from discovery'}`);
493
+ console.log(` - Subnets: ${result.vpcConfig.subnetIds.length}`);
494
+ console.log(` - Security Groups: ${result.vpcConfig.securityGroupIds.length}`);
495
+
496
+ return result;
497
+ }
498
+
499
+ /**
500
+ * Add IAM permissions for VPC-enabled Lambda functions
501
+ */
502
+ addVpcIamPermissions(result) {
503
+ result.iamStatements.push({
504
+ Effect: 'Allow',
505
+ Action: [
506
+ 'ec2:CreateNetworkInterface',
507
+ 'ec2:DescribeNetworkInterfaces',
508
+ 'ec2:DeleteNetworkInterface',
509
+ 'ec2:AttachNetworkInterface',
510
+ 'ec2:DetachNetworkInterface',
511
+ ],
512
+ Resource: '*',
513
+ });
514
+ }
515
+
516
+ /**
517
+ * Build VPC based on ownership decision
518
+ *
519
+ * For STACK ownership: ALWAYS add definitions to template.
520
+ * CloudFormation idempotency ensures existing resources aren't recreated.
521
+ */
522
+ buildVpcFromDecision(decision, appDefinition, result) {
523
+ if (decision.ownership === ResourceOwnership.STACK) {
524
+ // For STACK ownership: ALWAYS create definitions (CloudFormation idempotency)
525
+ if (decision.physicalId) {
526
+ console.log(` → Adding VPC definition to template (existing: ${decision.physicalId})`);
527
+ } else {
528
+ console.log(' → Adding VPC definition to template (new)');
529
+ }
530
+
531
+ const cidrBlock = appDefinition.vpc?.config?.cidrBlock || appDefinition.vpc?.cidrBlock || '10.0.0.0/16';
532
+
533
+ result.resources.FriggVPC = {
534
+ Type: 'AWS::EC2::VPC',
535
+ Properties: {
536
+ CidrBlock: cidrBlock,
537
+ EnableDnsHostnames: true,
538
+ EnableDnsSupport: true,
539
+ Tags: [
540
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc' },
541
+ { Key: 'ManagedBy', Value: 'Frigg' },
542
+ { Key: 'Service', Value: '${self:service}' },
543
+ { Key: 'Stage', Value: '${self:provider.stage}' },
544
+ ],
545
+ },
546
+ };
547
+
548
+ // Internet Gateway
549
+ result.resources.FriggInternetGateway = {
550
+ Type: 'AWS::EC2::InternetGateway',
551
+ Properties: {
552
+ Tags: [
553
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-igw' },
554
+ { Key: 'ManagedBy', Value: 'Frigg' },
555
+ ],
556
+ },
557
+ };
558
+
559
+ result.resources.FriggVPCGatewayAttachment = {
560
+ Type: 'AWS::EC2::VPCGatewayAttachment',
561
+ Properties: {
562
+ VpcId: { Ref: 'FriggVPC' },
563
+ InternetGatewayId: { Ref: 'FriggInternetGateway' },
564
+ },
565
+ };
566
+
567
+ // Use Ref for stack-managed VPC
568
+ result.vpcId = { Ref: 'FriggVPC' };
569
+ console.log(' ✅ VPC definition added to template');
570
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
571
+ // Use external VPC ID (no definition in template)
572
+ result.vpcId = decision.physicalId;
573
+ console.log(` ✓ Using external VPC: ${decision.physicalId}`);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Build Security Group based on ownership decision
579
+ */
580
+ buildSecurityGroupFromDecision(decision, appDefinition, result) {
581
+ if (decision.ownership === ResourceOwnership.STACK) {
582
+ // Always create security group resource in template
583
+ // CloudFormation handles idempotency if it already exists
584
+ console.log(' → Adding Lambda Security Group to template...');
585
+
586
+ result.resources.FriggLambdaSecurityGroup = {
587
+ Type: 'AWS::EC2::SecurityGroup',
588
+ Properties: {
589
+ GroupDescription: 'Security group for Frigg Lambda functions',
590
+ VpcId: result.vpcId,
591
+ SecurityGroupEgress: [
592
+ { IpProtocol: 'tcp', FromPort: 443, ToPort: 443, CidrIp: '0.0.0.0/0', Description: 'HTTPS outbound' },
593
+ { IpProtocol: 'tcp', FromPort: 80, ToPort: 80, CidrIp: '0.0.0.0/0', Description: 'HTTP outbound' },
594
+ { IpProtocol: 'tcp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS TCP' },
595
+ { IpProtocol: 'udp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS UDP' },
596
+ { IpProtocol: 'tcp', FromPort: 5432, ToPort: 5432, CidrIp: '0.0.0.0/0', Description: 'PostgreSQL' },
597
+ { IpProtocol: 'tcp', FromPort: 27017, ToPort: 27017, CidrIp: '0.0.0.0/0', Description: 'MongoDB' },
598
+ ],
599
+ Tags: [
600
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-sg' },
601
+ { Key: 'ManagedBy', Value: 'Frigg' },
602
+ ],
603
+ },
604
+ };
605
+
606
+ // Use CloudFormation Ref since resource is in template
607
+ result.vpcConfig.securityGroupIds = [{ Ref: 'FriggLambdaSecurityGroup' }];
608
+ console.log(' ✅ Security Group added to template');
609
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
610
+ // Use external security group IDs
611
+ const sgIds = Array.isArray(decision.physicalId) ? decision.physicalId : [decision.physicalId];
612
+ result.vpcConfig.securityGroupIds = sgIds;
613
+ console.log(` ✓ Using external security group(s): ${sgIds.join(', ')}`);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Build Subnets based on ownership decision
619
+ */
620
+ buildSubnetsFromDecision(decision, appDefinition, discoveredResources, result) {
621
+ if (decision.ownership === ResourceOwnership.STACK) {
622
+ // Check if no subnets exist and selfHeal is disabled
623
+ if (!decision.physicalIds || decision.physicalIds.length < 2) {
624
+ const selfHeal = appDefinition.vpc?.config?.selfHeal !== false;
625
+ if (!selfHeal) {
626
+ throw new Error(
627
+ 'No subnets discovered. Enable vpc.selfHeal, set subnets.management to "create", or provide subnet IDs.'
628
+ );
629
+ }
630
+ }
631
+
632
+ // For STACK ownership: ALWAYS add definitions to template
633
+ // CloudFormation idempotency ensures existing resources won't be recreated
634
+ if (decision.physicalIds && decision.physicalIds.length >= 2) {
635
+ console.log(` → Adding subnet definitions to template (existing: ${decision.physicalIds.join(', ')})`);
636
+ } else {
637
+ console.log(' → Adding subnet definitions to template (new)');
638
+ }
639
+
640
+ this.createSubnetsInTemplate(appDefinition, result, discoveredResources);
641
+
642
+ // Use Refs for stack-managed resources
643
+ result.vpcConfig.subnetIds = [
644
+ { Ref: 'FriggPrivateSubnet1' },
645
+ { Ref: 'FriggPrivateSubnet2' }
646
+ ];
647
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
648
+ // Use external subnet IDs directly (no definitions in template)
649
+ result.vpcConfig.subnetIds = decision.physicalIds;
650
+ console.log(` ✓ Using external subnets: ${decision.physicalIds.join(', ')}`);
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Create subnet resources in CloudFormation template
656
+ */
657
+ createSubnetsInTemplate(appDefinition, result, discoveredResources) {
658
+ // Determine VPC ID for subnets
659
+ const vpcId = result.vpcId;
660
+
661
+ // Generate subnet CIDRs
662
+ const cidrs = this.generateSubnetCidrsForNewVpc(vpcId, discoveredResources);
663
+
664
+ // Private Subnet 1
665
+ result.resources.FriggPrivateSubnet1 = {
666
+ Type: 'AWS::EC2::Subnet',
667
+ DeletionPolicy: 'Retain',
668
+ Properties: {
669
+ VpcId: vpcId,
670
+ CidrBlock: cidrs.private1,
671
+ AvailabilityZone: { 'Fn::Select': [0, { 'Fn::GetAZs': '' }] },
672
+ Tags: [
673
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-private-1' },
674
+ { Key: 'Type', Value: 'Private' },
675
+ { Key: 'ManagedBy', Value: 'Frigg' },
676
+ ],
677
+ },
678
+ };
679
+
680
+ // Private Subnet 2
681
+ result.resources.FriggPrivateSubnet2 = {
682
+ Type: 'AWS::EC2::Subnet',
683
+ DeletionPolicy: 'Retain',
684
+ Properties: {
685
+ VpcId: vpcId,
686
+ CidrBlock: cidrs.private2,
687
+ AvailabilityZone: { 'Fn::Select': [1, { 'Fn::GetAZs': '' }] },
688
+ Tags: [
689
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-private-2' },
690
+ { Key: 'Type', Value: 'Private' },
691
+ { Key: 'ManagedBy', Value: 'Frigg' },
692
+ ],
693
+ },
694
+ };
695
+
696
+ // Public Subnets (for NAT Gateway)
697
+ result.resources.FriggPublicSubnet = {
698
+ Type: 'AWS::EC2::Subnet',
699
+ Properties: {
700
+ VpcId: vpcId,
701
+ CidrBlock: cidrs.public1,
702
+ MapPublicIpOnLaunch: true,
703
+ AvailabilityZone: { 'Fn::Select': [0, { 'Fn::GetAZs': '' }] },
704
+ Tags: [
705
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-public-1' },
706
+ { Key: 'Type', Value: 'Public' },
707
+ { Key: 'ManagedBy', Value: 'Frigg' },
708
+ ],
709
+ },
710
+ };
711
+
712
+ result.resources.FriggPublicSubnet2 = {
713
+ Type: 'AWS::EC2::Subnet',
714
+ Properties: {
715
+ VpcId: vpcId,
716
+ CidrBlock: cidrs.public2,
717
+ MapPublicIpOnLaunch: true,
718
+ AvailabilityZone: { 'Fn::Select': [1, { 'Fn::GetAZs': '' }] },
719
+ Tags: [
720
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-public-2' },
721
+ { Key: 'Type', Value: 'Public' },
722
+ { Key: 'ManagedBy', Value: 'Frigg' },
723
+ ],
724
+ },
725
+ };
726
+
727
+ result.vpcConfig.subnetIds = [
728
+ { Ref: 'FriggPrivateSubnet1' },
729
+ { Ref: 'FriggPrivateSubnet2' },
730
+ ];
731
+
732
+ // Map to discovered resources for other builders
733
+ discoveredResources.privateSubnetId1 = { Ref: 'FriggPrivateSubnet1' };
734
+ discoveredResources.privateSubnetId2 = { Ref: 'FriggPrivateSubnet2' };
735
+ discoveredResources.publicSubnetId1 = { Ref: 'FriggPublicSubnet' };
736
+ discoveredResources.publicSubnetId2 = { Ref: 'FriggPublicSubnet2' };
737
+
738
+ console.log(' ✅ Subnet resources added to template');
739
+ }
740
+
741
+ /**
742
+ * Generate subnet CIDRs for new VPC or existing VPC
743
+ */
744
+ generateSubnetCidrsForNewVpc(vpcId, discoveredResources) {
745
+ // If VPC is a Ref (new VPC), use Fn::Cidr
746
+ if (typeof vpcId === 'object' && vpcId.Ref === 'FriggVPC') {
747
+ return {
748
+ private1: { 'Fn::Select': [0, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
749
+ private2: { 'Fn::Select': [1, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
750
+ public1: { 'Fn::Select': [2, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
751
+ public2: { 'Fn::Select': [3, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
752
+ };
753
+ }
754
+
755
+ // For existing VPC, find available CIDRs
756
+ const existingCidrs = new Set();
757
+ if (discoveredResources?.subnets) {
758
+ for (const subnet of discoveredResources.subnets) {
759
+ if (subnet.CidrBlock) {
760
+ existingCidrs.add(subnet.CidrBlock);
761
+ }
762
+ }
763
+ }
764
+
765
+ const findAvailableCidr = (startOctet, endOctet) => {
766
+ for (let octet = startOctet; octet <= endOctet; octet++) {
767
+ const candidate = `172.31.${octet}.0/24`;
768
+ if (!existingCidrs.has(candidate)) {
769
+ existingCidrs.add(candidate);
770
+ return candidate;
771
+ }
772
+ }
773
+ return `172.31.${startOctet}.0/24`;
774
+ };
775
+
776
+ return {
777
+ private1: findAvailableCidr(240, 249),
778
+ private2: findAvailableCidr(240, 249),
779
+ public1: findAvailableCidr(250, 255),
780
+ public2: findAvailableCidr(250, 255),
781
+ };
782
+ }
783
+
784
+ /**
785
+ * Build NAT Gateway based on ownership decision
786
+ */
787
+ buildNatGatewayFromDecision(decision, appDefinition, discoveredResources, result) {
788
+ if (!decision.ownership) {
789
+ console.log(' ⊝ NAT Gateway disabled');
790
+ return;
791
+ }
792
+
793
+ if (decision.ownership === ResourceOwnership.STACK) {
794
+ if (decision.physicalId) {
795
+ // NAT Gateway exists in stack - CloudFormation will handle it
796
+ console.log(` ✓ NAT Gateway in stack: ${decision.physicalId}`);
797
+ // Still need to ensure route tables are set up
798
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, { Ref: 'FriggNATGateway' });
799
+ } else {
800
+ // Create new NAT Gateway
801
+ console.log(' → Creating NAT Gateway in template...');
802
+ this.createNatGatewayInTemplate(appDefinition, discoveredResources, result);
803
+ }
804
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
805
+ // Use external NAT Gateway
806
+ console.log(` ✓ Using external NAT Gateway: ${decision.physicalId}`);
807
+ result.natGatewayId = decision.physicalId;
808
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, decision.physicalId);
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Create NAT Gateway resources in CloudFormation template
814
+ */
815
+ createNatGatewayInTemplate(appDefinition, discoveredResources, result) {
816
+ // Elastic IP for NAT Gateway
817
+ result.resources.FriggNATGatewayEIP = {
818
+ Type: 'AWS::EC2::EIP',
819
+ DeletionPolicy: 'Retain',
820
+ UpdateReplacePolicy: 'Retain',
821
+ Properties: {
822
+ Domain: 'vpc',
823
+ Tags: [
824
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-nat-eip' },
825
+ { Key: 'ManagedBy', Value: 'Frigg' },
826
+ ],
827
+ },
828
+ };
829
+
830
+ // NAT Gateway in public subnet
831
+ result.resources.FriggNATGateway = {
832
+ Type: 'AWS::EC2::NatGateway',
833
+ DeletionPolicy: 'Retain',
834
+ UpdateReplacePolicy: 'Retain',
835
+ Properties: {
836
+ AllocationId: { 'Fn::GetAtt': ['FriggNATGatewayEIP', 'AllocationId'] },
837
+ SubnetId: discoveredResources.publicSubnetId1 || { Ref: 'FriggPublicSubnet' },
838
+ Tags: [
839
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-nat' },
840
+ { Key: 'ManagedBy', Value: 'Frigg' },
841
+ ],
842
+ },
843
+ };
844
+
845
+ // Create public routing
846
+ this.createPublicRouting(appDefinition, discoveredResources, result);
847
+
848
+ // Create NAT routing
849
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, { Ref: 'FriggNATGateway' });
850
+
851
+ console.log(' ✅ NAT Gateway resources added to template');
852
+ }
853
+
854
+ /**
855
+ * Build VPC Endpoints based on ownership decisions
856
+ */
857
+ buildVpcEndpointsFromDecisions(decisions, appDefinition, result) {
858
+ const endpointsToCreate = [];
859
+ const endpointsInStack = [];
860
+ const externalEndpoints = [];
861
+
862
+ // Analyze decisions
863
+ Object.entries(decisions).forEach(([type, decision]) => {
864
+ if (decision.ownership === ResourceOwnership.STACK && !decision.physicalId) {
865
+ endpointsToCreate.push(type);
866
+ } else if (decision.ownership === ResourceOwnership.STACK && decision.physicalId) {
867
+ endpointsInStack.push(type);
868
+ } else if (decision.ownership === ResourceOwnership.EXTERNAL) {
869
+ externalEndpoints.push(type);
870
+ }
871
+ });
872
+
873
+ if (endpointsInStack.length > 0) {
874
+ console.log(` ✓ VPC Endpoints in stack: ${endpointsInStack.join(', ')}`);
875
+ }
876
+
877
+ if (externalEndpoints.length > 0) {
878
+ console.log(` ✓ External VPC Endpoints: ${externalEndpoints.join(', ')}`);
879
+ }
880
+
881
+ if (endpointsToCreate.length === 0) {
882
+ if (endpointsInStack.length === 0 && externalEndpoints.length === 0) {
883
+ console.log(' ⊝ VPC Endpoints disabled');
884
+ }
885
+ return;
886
+ }
887
+
888
+ console.log(` → Creating VPC Endpoints: ${endpointsToCreate.join(', ')}...`);
889
+
890
+ const vpcId = result.vpcId;
891
+
892
+ // Create route table if needed
893
+ if (!result.resources.FriggLambdaRouteTable) {
894
+ result.resources.FriggLambdaRouteTable = {
895
+ Type: 'AWS::EC2::RouteTable',
896
+ Properties: {
897
+ VpcId: vpcId,
898
+ Tags: [
899
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-rt' },
900
+ { Key: 'ManagedBy', Value: 'Frigg' },
901
+ ],
902
+ },
903
+ };
904
+ }
905
+
906
+ // Ensure subnet associations
907
+ this.ensureSubnetAssociations(appDefinition, {}, result);
908
+
909
+ // Create endpoints
910
+ if (endpointsToCreate.includes('s3')) {
911
+ result.resources.FriggS3VPCEndpoint = {
912
+ Type: 'AWS::EC2::VPCEndpoint',
913
+ Properties: {
914
+ VpcId: vpcId,
915
+ ServiceName: 'com.amazonaws.${self:provider.region}.s3',
916
+ VpcEndpointType: 'Gateway',
917
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }],
918
+ },
919
+ };
920
+ }
921
+
922
+ if (endpointsToCreate.includes('dynamodb')) {
923
+ result.resources.FriggDynamoDBVPCEndpoint = {
924
+ Type: 'AWS::EC2::VPCEndpoint',
925
+ Properties: {
926
+ VpcId: vpcId,
927
+ ServiceName: 'com.amazonaws.${self:provider.region}.dynamodb',
928
+ VpcEndpointType: 'Gateway',
929
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }],
930
+ },
931
+ };
932
+ }
933
+
934
+ // Create security group for interface endpoints if needed
935
+ const needsInterfaceEndpoints = endpointsToCreate.some(type => ['kms', 'secretsManager', 'sqs'].includes(type));
936
+ if (needsInterfaceEndpoints) {
937
+ result.resources.FriggVPCEndpointSecurityGroup = {
938
+ Type: 'AWS::EC2::SecurityGroup',
939
+ Properties: {
940
+ GroupDescription: 'Security group for VPC Endpoints',
941
+ VpcId: vpcId,
942
+ SecurityGroupIngress: [
943
+ {
944
+ IpProtocol: 'tcp',
945
+ FromPort: 443,
946
+ ToPort: 443,
947
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
948
+ Description: 'HTTPS from Lambda',
949
+ },
950
+ ],
951
+ Tags: [
952
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc-endpoint-sg' },
953
+ { Key: 'ManagedBy', Value: 'Frigg' },
954
+ ],
955
+ },
956
+ };
957
+ }
958
+
959
+ if (endpointsToCreate.includes('kms')) {
960
+ result.resources.FriggKMSVPCEndpoint = {
961
+ Type: 'AWS::EC2::VPCEndpoint',
962
+ Properties: {
963
+ VpcId: vpcId,
964
+ ServiceName: 'com.amazonaws.${self:provider.region}.kms',
965
+ VpcEndpointType: 'Interface',
966
+ SubnetIds: result.vpcConfig.subnetIds,
967
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
968
+ PrivateDnsEnabled: true,
969
+ },
970
+ };
971
+ }
972
+
973
+ if (endpointsToCreate.includes('secretsManager')) {
974
+ result.resources.FriggSecretsManagerVPCEndpoint = {
975
+ Type: 'AWS::EC2::VPCEndpoint',
976
+ Properties: {
977
+ VpcId: vpcId,
978
+ ServiceName: 'com.amazonaws.${self:provider.region}.secretsmanager',
979
+ VpcEndpointType: 'Interface',
980
+ SubnetIds: result.vpcConfig.subnetIds,
981
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
982
+ PrivateDnsEnabled: true,
983
+ },
984
+ };
985
+ }
986
+
987
+ if (endpointsToCreate.includes('sqs')) {
988
+ result.resources.FriggSQSVPCEndpoint = {
989
+ Type: 'AWS::EC2::VPCEndpoint',
990
+ Properties: {
991
+ VpcId: vpcId,
992
+ ServiceName: 'com.amazonaws.${self:provider.region}.sqs',
993
+ VpcEndpointType: 'Interface',
994
+ SubnetIds: result.vpcConfig.subnetIds,
995
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
996
+ PrivateDnsEnabled: true,
997
+ },
998
+ };
999
+ }
1000
+
1001
+ console.log(` ✅ VPC Endpoint resources added to template`);
1002
+ }
1003
+
1004
+ /**
1005
+ * Perform self-healing checks and fixes
1006
+ */
1007
+ performSelfHealing(discoveredResources, appDefinition) {
1008
+ console.log('🔧 VPC Self-healing mode enabled - checking for misconfigurations...');
1009
+
1010
+ const healingReport = {
1011
+ healed: [],
1012
+ warnings: [],
1013
+ errors: [],
1014
+ };
1015
+
1016
+ // Check for NAT Gateway in private subnet
1017
+ if (discoveredResources.natGatewayInPrivateSubnet) {
1018
+ healingReport.warnings.push(
1019
+ `NAT Gateway ${discoveredResources.natGatewayInPrivateSubnet} is in a private subnet`
1020
+ );
1021
+ healingReport.healed.push(
1022
+ 'Will create new NAT Gateway in public subnet'
1023
+ );
1024
+ discoveredResources.needsNewNatGateway = true;
1025
+ }
1026
+
1027
+ // Check for orphaned Elastic IPs
1028
+ if (discoveredResources.orphanedElasticIps?.length > 0) {
1029
+ healingReport.warnings.push(
1030
+ `Found ${discoveredResources.orphanedElasticIps.length} orphaned Elastic IPs`
1031
+ );
1032
+ }
1033
+
1034
+ // Check for subnet routing issues
1035
+ if (discoveredResources.privateSubnetsWithWrongRoutes?.length > 0) {
1036
+ healingReport.warnings.push(
1037
+ `Found ${discoveredResources.privateSubnetsWithWrongRoutes.length} subnets with wrong routes`
1038
+ );
1039
+ healingReport.healed.push('Will create correct route tables');
1040
+ }
1041
+
1042
+ // Log healing report
1043
+ if (healingReport.healed.length > 0) {
1044
+ console.log(' ✅ Self-healing actions:');
1045
+ healingReport.healed.forEach(action => console.log(` - ${action}`));
1046
+ }
1047
+ if (healingReport.warnings.length > 0) {
1048
+ console.log(' ⚠️ Issues detected:');
1049
+ healingReport.warnings.forEach(warning => console.log(` - ${warning}`));
1050
+ }
1051
+
1052
+ return healingReport;
1053
+ }
1054
+
1055
+ /**
1056
+ * Build new VPC from scratch
1057
+ */
1058
+ async buildNewVpc(appDefinition, discoveredResources, result) {
1059
+ console.log(' Creating new VPC infrastructure...');
1060
+
1061
+ const cidrBlock = appDefinition.vpc.cidrBlock || '10.0.0.0/16';
1062
+
1063
+ // Main VPC
1064
+ result.resources.FriggVPC = {
1065
+ Type: 'AWS::EC2::VPC',
1066
+ Properties: {
1067
+ CidrBlock: cidrBlock,
1068
+ EnableDnsHostnames: true,
1069
+ EnableDnsSupport: true,
1070
+ Tags: [
1071
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc' },
1072
+ { Key: 'ManagedBy', Value: 'Frigg' },
1073
+ { Key: 'Service', Value: '${self:service}' },
1074
+ { Key: 'Stage', Value: '${self:provider.stage}' },
1075
+ ],
1076
+ },
1077
+ };
1078
+
1079
+ // Internet Gateway
1080
+ result.resources.FriggInternetGateway = {
1081
+ Type: 'AWS::EC2::InternetGateway',
1082
+ Properties: {
1083
+ Tags: [
1084
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-igw' },
1085
+ { Key: 'ManagedBy', Value: 'Frigg' },
1086
+ ],
1087
+ },
1088
+ };
1089
+
1090
+ result.resources.FriggVPCGatewayAttachment = {
1091
+ Type: 'AWS::EC2::VPCGatewayAttachment',
1092
+ Properties: {
1093
+ VpcId: { Ref: 'FriggVPC' },
1094
+ InternetGatewayId: { Ref: 'FriggInternetGateway' },
1095
+ },
1096
+ };
1097
+
1098
+ // Lambda Security Group
1099
+ result.resources.FriggLambdaSecurityGroup = {
1100
+ Type: 'AWS::EC2::SecurityGroup',
1101
+ Properties: {
1102
+ GroupDescription: 'Security group for Frigg Lambda functions',
1103
+ VpcId: { Ref: 'FriggVPC' },
1104
+ SecurityGroupEgress: [
1105
+ { IpProtocol: 'tcp', FromPort: 443, ToPort: 443, CidrIp: '0.0.0.0/0', Description: 'HTTPS outbound' },
1106
+ { IpProtocol: 'tcp', FromPort: 80, ToPort: 80, CidrIp: '0.0.0.0/0', Description: 'HTTP outbound' },
1107
+ { IpProtocol: 'tcp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS TCP' },
1108
+ { IpProtocol: 'udp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS UDP' },
1109
+ { IpProtocol: 'tcp', FromPort: 5432, ToPort: 5432, CidrIp: '0.0.0.0/0', Description: 'PostgreSQL' },
1110
+ { IpProtocol: 'tcp', FromPort: 27017, ToPort: 27017, CidrIp: '0.0.0.0/0', Description: 'MongoDB' },
1111
+ ],
1112
+ Tags: [
1113
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-sg' },
1114
+ { Key: 'ManagedBy', Value: 'Frigg' },
1115
+ ],
1116
+ },
1117
+ };
1118
+
1119
+ result.vpcId = { Ref: 'FriggVPC' };
1120
+ result.vpcConfig.securityGroupIds = [{ Ref: 'FriggLambdaSecurityGroup' }];
1121
+
1122
+ console.log(' ✅ New VPC infrastructure resources created');
1123
+ }
1124
+
1125
+ /**
1126
+ * Use existing VPC (explicitly provided)
1127
+ */
1128
+ async useExistingVpc(appDefinition, discoveredResources, result) {
1129
+ console.log(' Using existing VPC...');
1130
+
1131
+ if (!appDefinition.vpc.vpcId) {
1132
+ throw new Error('vpc.vpcId is required when management="use-existing"');
1133
+ }
1134
+
1135
+ result.vpcId = appDefinition.vpc.vpcId;
1136
+ result.vpcConfig.securityGroupIds = appDefinition.vpc.securityGroupIds ||
1137
+ (discoveredResources.defaultSecurityGroupId ? [discoveredResources.defaultSecurityGroupId] : []);
1138
+
1139
+ console.log(` ✅ Using VPC: ${result.vpcId}`);
1140
+ }
1141
+
1142
+ /**
1143
+ * Discover existing VPC from AWS
1144
+ */
1145
+ async discoverVpc(appDefinition, discoveredResources, result) {
1146
+ console.log(' Discovering existing VPC...');
1147
+
1148
+ if (!discoveredResources.defaultVpcId) {
1149
+ throw new Error(
1150
+ 'VPC discovery failed: No VPC found. Set vpc.management to "create-new" or provide vpc.vpcId with "use-existing".'
1151
+ );
1152
+ }
1153
+
1154
+ result.vpcId = discoveredResources.defaultVpcId;
1155
+
1156
+ // Check if resources came from CloudFormation stack
1157
+ const fromCfStack = discoveredResources.fromCloudFormationStack === true;
1158
+ const existingLogicalIds = discoveredResources.existingLogicalIds || [];
1159
+
1160
+ if (fromCfStack && existingLogicalIds.length > 0) {
1161
+ console.log(` ✓ VPC discovered from CloudFormation stack: ${discoveredResources.stackName}`);
1162
+ console.log(` ✓ Found ${existingLogicalIds.length} existing resources in stack`);
1163
+ console.log(' ℹ Adding resources to template for idempotent deployment');
1164
+ } else {
1165
+ // VPC discovered from AWS API (not from CF stack)
1166
+ console.log(' ℹ VPC discovered from AWS API - will create Lambda security group');
1167
+ }
1168
+
1169
+ // Always create Lambda security group in template for idempotent deployments
1170
+ // CloudFormation will recognize it already exists and won't recreate it
1171
+ result.resources.FriggLambdaSecurityGroup = {
1172
+ Type: 'AWS::EC2::SecurityGroup',
1173
+ Properties: {
1174
+ GroupDescription: 'Security group for Frigg Lambda functions',
1175
+ VpcId: result.vpcId,
1176
+ SecurityGroupEgress: [
1177
+ { IpProtocol: 'tcp', FromPort: 443, ToPort: 443, CidrIp: '0.0.0.0/0', Description: 'HTTPS outbound' },
1178
+ { IpProtocol: 'tcp', FromPort: 80, ToPort: 80, CidrIp: '0.0.0.0/0', Description: 'HTTP outbound' },
1179
+ { IpProtocol: 'tcp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS TCP' },
1180
+ { IpProtocol: 'udp', FromPort: 53, ToPort: 53, CidrIp: '0.0.0.0/0', Description: 'DNS UDP' },
1181
+ { IpProtocol: 'tcp', FromPort: 5432, ToPort: 5432, CidrIp: '0.0.0.0/0', Description: 'PostgreSQL' },
1182
+ { IpProtocol: 'tcp', FromPort: 27017, ToPort: 27017, CidrIp: '0.0.0.0/0', Description: 'MongoDB' },
1183
+ ],
1184
+ Tags: [
1185
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-sg' },
1186
+ { Key: 'ManagedBy', Value: 'Frigg' },
1187
+ ],
1188
+ },
1189
+ };
1190
+
1191
+ // Always use Ref since resource is in template
1192
+ result.vpcConfig.securityGroupIds = [{ Ref: 'FriggLambdaSecurityGroup' }];
1193
+
1194
+ console.log(` ✅ Discovered VPC: ${result.vpcId}`);
1195
+ }
1196
+
1197
+ /**
1198
+ * Build subnet infrastructure
1199
+ * @param {Object} vpcManagement - Normalized VPC management mode (passed from build() to ensure consistency)
1200
+ */
1201
+ async buildSubnets(appDefinition, discoveredResources, result, vpcManagement) {
1202
+ // Default subnet management depends on context:
1203
+ // - Stack-managed subnets discovered: discover (reuse existing)
1204
+ // - use-existing mode with subnet IDs provided: use-existing
1205
+ // - create-new mode: create
1206
+ // - discover mode without stack subnets: create (for stage isolation)
1207
+ let defaultSubnetManagement = 'create';
1208
+
1209
+ // Check if stack-managed subnets were discovered from CloudFormation
1210
+ // Only reuse if they're actual subnet IDs (strings), not CloudFormation Refs (objects)
1211
+ const hasStackManagedSubnets =
1212
+ discoveredResources?.privateSubnetId1 &&
1213
+ discoveredResources?.privateSubnetId2 &&
1214
+ typeof discoveredResources.privateSubnetId1 === 'string' &&
1215
+ typeof discoveredResources.privateSubnetId2 === 'string';
1216
+
1217
+ if (hasStackManagedSubnets) {
1218
+ defaultSubnetManagement = 'discover';
1219
+ } else if (vpcManagement === 'use-existing' && appDefinition.vpc.subnets?.ids?.length >= 2) {
1220
+ defaultSubnetManagement = 'use-existing';
1221
+ }
1222
+
1223
+ const subnetManagement = appDefinition.vpc.subnets?.management || defaultSubnetManagement;
1224
+
1225
+ console.log(` Subnet Management Mode: ${subnetManagement} (default: ${defaultSubnetManagement}, explicit: ${appDefinition.vpc.subnets?.management})`);
1226
+
1227
+ switch (subnetManagement) {
1228
+ case 'create':
1229
+ this.createSubnets(appDefinition, discoveredResources, result, vpcManagement);
1230
+ break;
1231
+ case 'use-existing':
1232
+ this.useExistingSubnets(appDefinition, result);
1233
+ break;
1234
+ case 'discover':
1235
+ default:
1236
+ this.discoverSubnets(appDefinition, discoveredResources, result);
1237
+ break;
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * Create new subnets
1243
+ */
1244
+ createSubnets(appDefinition, discoveredResources, result, vpcManagement) {
1245
+ console.log(' Creating new subnets...');
1246
+
1247
+ const subnetVpcId = vpcManagement === 'create-new' ? { Ref: 'FriggVPC' } : result.vpcId;
1248
+
1249
+ // Generate CIDRs - pass discovered resources to avoid conflicts
1250
+ const cidrs = this.generateSubnetCidrs(vpcManagement, discoveredResources);
1251
+
1252
+ // Private Subnet 1
1253
+ result.resources.FriggPrivateSubnet1 = {
1254
+ Type: 'AWS::EC2::Subnet',
1255
+ DeletionPolicy: 'Retain',
1256
+ Properties: {
1257
+ VpcId: subnetVpcId,
1258
+ CidrBlock: cidrs.private1,
1259
+ AvailabilityZone: { 'Fn::Select': [0, { 'Fn::GetAZs': '' }] },
1260
+ Tags: [
1261
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-private-1' },
1262
+ { Key: 'Type', Value: 'Private' },
1263
+ { Key: 'ManagedBy', Value: 'Frigg' },
1264
+ ],
1265
+ },
1266
+ };
1267
+
1268
+ // Private Subnet 2
1269
+ result.resources.FriggPrivateSubnet2 = {
1270
+ Type: 'AWS::EC2::Subnet',
1271
+ DeletionPolicy: 'Retain',
1272
+ Properties: {
1273
+ VpcId: subnetVpcId,
1274
+ CidrBlock: cidrs.private2,
1275
+ AvailabilityZone: { 'Fn::Select': [1, { 'Fn::GetAZs': '' }] },
1276
+ Tags: [
1277
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-private-2' },
1278
+ { Key: 'Type', Value: 'Private' },
1279
+ { Key: 'ManagedBy', Value: 'Frigg' },
1280
+ ],
1281
+ },
1282
+ };
1283
+
1284
+ // Public Subnets (for NAT Gateway and Aurora if publicly accessible)
1285
+ result.resources.FriggPublicSubnet = {
1286
+ Type: 'AWS::EC2::Subnet',
1287
+ Properties: {
1288
+ VpcId: subnetVpcId,
1289
+ CidrBlock: cidrs.public1,
1290
+ MapPublicIpOnLaunch: true,
1291
+ AvailabilityZone: { 'Fn::Select': [0, { 'Fn::GetAZs': '' }] },
1292
+ Tags: [
1293
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-public-1' },
1294
+ { Key: 'Type', Value: 'Public' },
1295
+ { Key: 'ManagedBy', Value: 'Frigg' },
1296
+ ],
1297
+ },
1298
+ };
1299
+
1300
+ result.resources.FriggPublicSubnet2 = {
1301
+ Type: 'AWS::EC2::Subnet',
1302
+ Properties: {
1303
+ VpcId: subnetVpcId,
1304
+ CidrBlock: cidrs.public2,
1305
+ MapPublicIpOnLaunch: true,
1306
+ AvailabilityZone: { 'Fn::Select': [1, { 'Fn::GetAZs': '' }] },
1307
+ Tags: [
1308
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-public-2' },
1309
+ { Key: 'Type', Value: 'Public' },
1310
+ { Key: 'ManagedBy', Value: 'Frigg' },
1311
+ ],
1312
+ },
1313
+ };
1314
+
1315
+ result.vpcConfig.subnetIds = [
1316
+ { Ref: 'FriggPrivateSubnet1' },
1317
+ { Ref: 'FriggPrivateSubnet2' },
1318
+ ];
1319
+
1320
+ // Map to discovered resources for other builders (Aurora, etc.)
1321
+ discoveredResources.privateSubnetId1 = { Ref: 'FriggPrivateSubnet1' };
1322
+ discoveredResources.privateSubnetId2 = { Ref: 'FriggPrivateSubnet2' };
1323
+ discoveredResources.publicSubnetId1 = { Ref: 'FriggPublicSubnet' };
1324
+ discoveredResources.publicSubnetId2 = { Ref: 'FriggPublicSubnet2' };
1325
+
1326
+ console.log(' ✅ Subnets created');
1327
+ }
1328
+
1329
+ /**
1330
+ * Use existing subnets
1331
+ */
1332
+ useExistingSubnets(appDefinition, result) {
1333
+ console.log(' Using existing subnets...');
1334
+
1335
+ if (!appDefinition.vpc.subnets?.ids || appDefinition.vpc.subnets.ids.length < 2) {
1336
+ throw new Error(
1337
+ 'At least 2 subnet IDs required when subnets.management="use-existing"'
1338
+ );
1339
+ }
1340
+
1341
+ result.vpcConfig.subnetIds = appDefinition.vpc.subnets.ids;
1342
+ console.log(` ✅ Using ${result.vpcConfig.subnetIds.length} existing subnets`);
1343
+ }
1344
+
1345
+ /**
1346
+ * Discover existing subnets from AWS
1347
+ */
1348
+ discoverSubnets(appDefinition, discoveredResources, result) {
1349
+ console.log(' Discovering subnets...');
1350
+
1351
+ // Use explicitly provided subnet IDs first
1352
+ if (appDefinition.vpc.subnets?.ids?.length >= 2) {
1353
+ result.vpcConfig.subnetIds = appDefinition.vpc.subnets.ids;
1354
+ console.log(` ✅ Using ${result.vpcConfig.subnetIds.length} provided subnets`);
1355
+ return;
1356
+ }
1357
+
1358
+ // User explicitly set subnets.management: 'discover', so use discovered subnets
1359
+ // NOTE: This may cause route table conflicts if multiple stages share subnets
1360
+ // Default behavior is now to create stage-specific subnets (subnets.management: 'create')
1361
+ if (discoveredResources.privateSubnetId1 && discoveredResources.privateSubnetId2) {
1362
+ result.vpcConfig.subnetIds = [
1363
+ discoveredResources.privateSubnetId1,
1364
+ discoveredResources.privateSubnetId2,
1365
+ ];
1366
+ console.log(' ✅ Using discovered subnets (backwards compatibility mode)');
1367
+ return;
1368
+ }
1369
+
1370
+ // No subnets found - create if self-heal enabled
1371
+ if (appDefinition.vpc.selfHeal) {
1372
+ console.log(' ⚠️ No subnets found - self-heal will create them');
1373
+ this.createSubnets(appDefinition, discoveredResources, result, 'discover');
1374
+ } else {
1375
+ throw new Error(
1376
+ 'No subnets discovered. Enable vpc.selfHeal, set subnets.management to "create", or provide subnet IDs.'
1377
+ );
1378
+ }
1379
+ }
1380
+
1381
+ /**
1382
+ * Generate subnet CIDR blocks
1383
+ * Finds available CIDRs that don't conflict with existing subnets
1384
+ */
1385
+ generateSubnetCidrs(vpcManagement, discoveredResources) {
1386
+ if (vpcManagement === 'create-new') {
1387
+ // Use CloudFormation Fn::Cidr for dynamic generation
1388
+ return {
1389
+ private1: { 'Fn::Select': [0, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
1390
+ private2: { 'Fn::Select': [1, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
1391
+ public1: { 'Fn::Select': [2, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
1392
+ public2: { 'Fn::Select': [3, { 'Fn::Cidr': ['10.0.0.0/16', 4, 8] }] },
1393
+ };
1394
+ } else {
1395
+ // Find available CIDRs for existing VPC by checking existing subnets
1396
+ const existingCidrs = new Set();
1397
+
1398
+ // Collect all existing subnet CIDRs
1399
+ if (discoveredResources?.subnets) {
1400
+ for (const subnet of discoveredResources.subnets) {
1401
+ if (subnet.CidrBlock) {
1402
+ existingCidrs.add(subnet.CidrBlock);
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ console.log(` Found ${existingCidrs.size} existing subnet CIDRs in VPC`);
1408
+
1409
+ // Generate candidates in the default VPC range (172.31.0.0/16)
1410
+ // Private subnets: 240-249, Public subnets: 250-255
1411
+ const findAvailableCidr = (startOctet, endOctet) => {
1412
+ for (let octet = startOctet; octet <= endOctet; octet++) {
1413
+ const candidate = `172.31.${octet}.0/24`;
1414
+ if (!existingCidrs.has(candidate)) {
1415
+ existingCidrs.add(candidate); // Mark as used immediately
1416
+ return candidate;
1417
+ }
1418
+ }
1419
+ // Fallback if range exhausted
1420
+ return `172.31.${startOctet}.0/24`;
1421
+ };
1422
+
1423
+ const privateRange = { start: 240, end: 249 };
1424
+ const publicRange = { start: 250, end: 255 };
1425
+
1426
+ const cidrs = {
1427
+ private1: findAvailableCidr(privateRange.start, privateRange.end),
1428
+ private2: findAvailableCidr(privateRange.start, privateRange.end),
1429
+ public1: findAvailableCidr(publicRange.start, publicRange.end),
1430
+ public2: findAvailableCidr(publicRange.start, publicRange.end),
1431
+ };
1432
+
1433
+ console.log(` Using available CIDRs: ${Object.values(cidrs).join(', ')}`);
1434
+
1435
+ return cidrs;
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Build NAT Gateway for private subnet internet access
1441
+ */
1442
+ async buildNatGateway(appDefinition, discoveredResources, result) {
1443
+ const natManagement = appDefinition.vpc.natGateway?.management || 'discover';
1444
+
1445
+ console.log(` NAT Gateway Management: ${natManagement}`);
1446
+
1447
+ // Check if resources came from CloudFormation stack
1448
+ const fromCfStack = discoveredResources.fromCloudFormationStack === true;
1449
+ const existingLogicalIds = discoveredResources.existingLogicalIds || [];
1450
+
1451
+ if (fromCfStack && existingLogicalIds.length > 0) {
1452
+ console.log(' Skipping NAT Gateway - will reuse from CloudFormation stack');
1453
+ return;
1454
+ }
1455
+
1456
+ // Check if we should create NAT Gateway
1457
+ const needsNatGateway = natManagement === 'createAndManage' ||
1458
+ discoveredResources.needsNewNatGateway === true;
1459
+
1460
+ if (!needsNatGateway && natManagement === 'discover') {
1461
+ console.log(' Skipping NAT Gateway (discovery mode)');
1462
+ return;
1463
+ }
1464
+
1465
+ // Check if we should reuse existing
1466
+ if (appDefinition.vpc.natGateway?.id) {
1467
+ console.log(` Using existing NAT Gateway: ${appDefinition.vpc.natGateway.id}`);
1468
+ result.natGatewayId = appDefinition.vpc.natGateway.id;
1469
+ return;
1470
+ }
1471
+
1472
+ if (discoveredResources.existingNatGatewayId && !discoveredResources.natGatewayInPrivateSubnet) {
1473
+ console.log(` Reusing discovered NAT Gateway: ${discoveredResources.existingNatGatewayId}`);
1474
+ result.natGatewayId = discoveredResources.existingNatGatewayId;
1475
+
1476
+ // Still need to create route table and associations for discovered NAT
1477
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, discoveredResources.existingNatGatewayId);
1478
+ return;
1479
+ }
1480
+
1481
+ // Create new NAT Gateway
1482
+ console.log(' Creating new NAT Gateway...');
1483
+
1484
+ // Elastic IP for NAT Gateway
1485
+ result.resources.FriggNATGatewayEIP = {
1486
+ Type: 'AWS::EC2::EIP',
1487
+ DeletionPolicy: 'Retain',
1488
+ UpdateReplacePolicy: 'Retain',
1489
+ Properties: {
1490
+ Domain: 'vpc',
1491
+ Tags: [
1492
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-nat-eip' },
1493
+ { Key: 'ManagedBy', Value: 'Frigg' },
1494
+ ],
1495
+ },
1496
+ };
1497
+
1498
+ // NAT Gateway in public subnet
1499
+ result.resources.FriggNATGateway = {
1500
+ Type: 'AWS::EC2::NatGateway',
1501
+ DeletionPolicy: 'Retain',
1502
+ UpdateReplacePolicy: 'Retain',
1503
+ Properties: {
1504
+ AllocationId: { 'Fn::GetAtt': ['FriggNATGatewayEIP', 'AllocationId'] },
1505
+ SubnetId: discoveredResources.publicSubnetId1 || { Ref: 'FriggPublicSubnet' },
1506
+ Tags: [
1507
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-nat' },
1508
+ { Key: 'ManagedBy', Value: 'Frigg' },
1509
+ ],
1510
+ },
1511
+ };
1512
+
1513
+ // Create public routing (public subnets → Internet Gateway)
1514
+ this.createPublicRouting(appDefinition, discoveredResources, result);
1515
+
1516
+ // Create routing for the new NAT Gateway (private subnets → NAT → IGW)
1517
+ this.createNatGatewayRouting(appDefinition, discoveredResources, result, { Ref: 'FriggNATGateway' });
1518
+
1519
+ console.log(' ✅ NAT Gateway infrastructure created');
1520
+ }
1521
+
1522
+ /**
1523
+ * Create public route table with Internet Gateway route
1524
+ * Required for NAT Gateway to have internet access
1525
+ */
1526
+ createPublicRouting(appDefinition, discoveredResources, result) {
1527
+ // Public route table with Internet Gateway route
1528
+ result.resources.FriggPublicRouteTable = {
1529
+ Type: 'AWS::EC2::RouteTable',
1530
+ Properties: {
1531
+ VpcId: result.vpcId,
1532
+ Tags: [
1533
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-public-rt' },
1534
+ { Key: 'ManagedBy', Value: 'Frigg' },
1535
+ ],
1536
+ },
1537
+ };
1538
+
1539
+ // Route to Internet Gateway
1540
+ result.resources.FriggPublicRoute = {
1541
+ Type: 'AWS::EC2::Route',
1542
+ DependsOn: 'FriggVPCGatewayAttachment',
1543
+ Properties: {
1544
+ RouteTableId: { Ref: 'FriggPublicRouteTable' },
1545
+ DestinationCidrBlock: '0.0.0.0/0',
1546
+ GatewayId: { Ref: 'FriggInternetGateway' },
1547
+ },
1548
+ };
1549
+
1550
+ // Use discovered public subnets or created ones
1551
+ const publicSubnet1 = discoveredResources.publicSubnetId1 || { Ref: 'FriggPublicSubnet' };
1552
+ const publicSubnet2 = discoveredResources.publicSubnetId2 || { Ref: 'FriggPublicSubnet2' };
1553
+
1554
+ // Associate public subnets with public route table
1555
+ result.resources.FriggPublicSubnet1RouteTableAssociation = {
1556
+ Type: 'AWS::EC2::SubnetRouteTableAssociation',
1557
+ Properties: {
1558
+ SubnetId: publicSubnet1,
1559
+ RouteTableId: { Ref: 'FriggPublicRouteTable' },
1560
+ },
1561
+ };
1562
+
1563
+ result.resources.FriggPublicSubnet2RouteTableAssociation = {
1564
+ Type: 'AWS::EC2::SubnetRouteTableAssociation',
1565
+ Properties: {
1566
+ SubnetId: publicSubnet2,
1567
+ RouteTableId: { Ref: 'FriggPublicRouteTable' },
1568
+ },
1569
+ };
1570
+ }
1571
+
1572
+ /**
1573
+ * Create route table and associations for NAT Gateway
1574
+ */
1575
+ createNatGatewayRouting(appDefinition, discoveredResources, result, natGatewayId) {
1576
+ // Private route table with NAT Gateway route
1577
+ if (!result.resources.FriggLambdaRouteTable) {
1578
+ result.resources.FriggLambdaRouteTable = {
1579
+ Type: 'AWS::EC2::RouteTable',
1580
+ Properties: {
1581
+ VpcId: result.vpcId,
1582
+ Tags: [
1583
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-rt' },
1584
+ { Key: 'ManagedBy', Value: 'Frigg' },
1585
+ ],
1586
+ },
1587
+ };
1588
+ }
1589
+
1590
+ result.resources.FriggPrivateRoute = {
1591
+ Type: 'AWS::EC2::Route',
1592
+ Properties: {
1593
+ RouteTableId: { Ref: 'FriggLambdaRouteTable' },
1594
+ DestinationCidrBlock: '0.0.0.0/0',
1595
+ NatGatewayId: natGatewayId,
1596
+ },
1597
+ };
1598
+
1599
+ // Associate route table with private subnets
1600
+ // Use discovered subnet IDs or CloudFormation references
1601
+ const subnet1Id = discoveredResources.privateSubnetId1 || { Ref: 'FriggPrivateSubnet1' };
1602
+ const subnet2Id = discoveredResources.privateSubnetId2 || { Ref: 'FriggPrivateSubnet2' };
1603
+
1604
+ result.resources.FriggPrivateSubnet1RouteTableAssociation = {
1605
+ Type: 'AWS::EC2::SubnetRouteTableAssociation',
1606
+ Properties: {
1607
+ SubnetId: subnet1Id,
1608
+ RouteTableId: { Ref: 'FriggLambdaRouteTable' },
1609
+ },
1610
+ };
1611
+
1612
+ result.resources.FriggPrivateSubnet2RouteTableAssociation = {
1613
+ Type: 'AWS::EC2::SubnetRouteTableAssociation',
1614
+ Properties: {
1615
+ SubnetId: subnet2Id,
1616
+ RouteTableId: { Ref: 'FriggLambdaRouteTable' },
1617
+ },
1618
+ };
1619
+
1620
+ console.log(' ✅ Route table and subnet associations created');
1621
+ }
1622
+
1623
+ /**
1624
+ * Ensure subnet associations with route table
1625
+ * Called to heal missing associations when route table exists but associations don't
1626
+ */
1627
+ ensureSubnetAssociations(appDefinition, discoveredResources, result) {
1628
+ // Skip if associations already created (by NAT Gateway routing)
1629
+ if (result.resources.FriggPrivateSubnet1RouteTableAssociation) {
1630
+ return; // Already handled by NAT Gateway routing
1631
+ }
1632
+
1633
+ const routeTableId = discoveredResources.routeTableId || { Ref: 'FriggLambdaRouteTable' };
1634
+ const subnet1Id = discoveredResources.privateSubnetId1 || { Ref: 'FriggPrivateSubnet1' };
1635
+ const subnet2Id = discoveredResources.privateSubnetId2 || { Ref: 'FriggPrivateSubnet2' };
1636
+
1637
+ result.resources.FriggPrivateSubnet1RouteTableAssociation = {
1638
+ Type: 'AWS::EC2::SubnetRouteTableAssociation',
1639
+ Properties: {
1640
+ SubnetId: subnet1Id,
1641
+ RouteTableId: routeTableId,
1642
+ },
1643
+ };
1644
+
1645
+ result.resources.FriggPrivateSubnet2RouteTableAssociation = {
1646
+ Type: 'AWS::EC2::SubnetRouteTableAssociation',
1647
+ Properties: {
1648
+ SubnetId: subnet2Id,
1649
+ RouteTableId: routeTableId,
1650
+ },
1651
+ };
1652
+
1653
+ console.log(' ✓ Ensured subnet associations with route table');
1654
+ }
1655
+
1656
+ /**
1657
+ * Build VPC Endpoints for AWS services
1658
+ */
1659
+ buildVpcEndpoints(appDefinition, discoveredResources, result, existingEndpoints = {}) {
1660
+ // Check if endpoints are from CloudFormation stack (string IDs)
1661
+ // Stack-managed resources should be reused, not recreated
1662
+ const stackManagedEndpoints = {
1663
+ s3: discoveredResources.s3VpcEndpointId && typeof discoveredResources.s3VpcEndpointId === 'string',
1664
+ dynamodb: discoveredResources.dynamoDbVpcEndpointId && typeof discoveredResources.dynamoDbVpcEndpointId === 'string',
1665
+ kms: discoveredResources.kmsVpcEndpointId && typeof discoveredResources.kmsVpcEndpointId === 'string',
1666
+ secretsManager: discoveredResources.secretsManagerVpcEndpointId && typeof discoveredResources.secretsManagerVpcEndpointId === 'string',
1667
+ sqs: discoveredResources.sqsVpcEndpointId && typeof discoveredResources.sqsVpcEndpointId === 'string',
1668
+ };
1669
+
1670
+ // Build list of what needs creation (not stack-managed, not existing elsewhere)
1671
+ const missing = [];
1672
+ if (!stackManagedEndpoints.s3 && !existingEndpoints.s3) missing.push('S3');
1673
+ if (!stackManagedEndpoints.dynamodb && !existingEndpoints.dynamodb) missing.push('DynamoDB');
1674
+ if (!stackManagedEndpoints.kms && !existingEndpoints.kms && appDefinition.encryption?.fieldLevelEncryptionMethod === 'kms') missing.push('KMS');
1675
+ if (!stackManagedEndpoints.secretsManager && !existingEndpoints.secretsManager) missing.push('Secrets Manager');
1676
+ // SQS endpoint needed for job queues and async processing
1677
+ if (!stackManagedEndpoints.sqs && !existingEndpoints.sqs) missing.push('SQS');
1678
+
1679
+ // Log reused stack-managed endpoints
1680
+ const reused = [];
1681
+ if (stackManagedEndpoints.s3) reused.push('S3');
1682
+ if (stackManagedEndpoints.dynamodb) reused.push('DynamoDB');
1683
+ if (stackManagedEndpoints.kms) reused.push('KMS');
1684
+ if (stackManagedEndpoints.secretsManager) reused.push('Secrets Manager');
1685
+ if (stackManagedEndpoints.sqs) reused.push('SQS');
1686
+
1687
+ if (reused.length > 0) {
1688
+ console.log(` ✓ Reusing stack-managed VPC endpoints: ${reused.join(', ')}`);
1689
+ }
1690
+
1691
+ if (missing.length > 0) {
1692
+ console.log(` Creating missing VPC Endpoints: ${missing.join(', ')}...`);
1693
+ } else if (reused.length === 0) {
1694
+ console.log(' All required VPC Endpoints already exist - skipping creation');
1695
+ return;
1696
+ } else {
1697
+ // All endpoints are stack-managed, no creation needed
1698
+ return;
1699
+ }
1700
+
1701
+ const vpcId = result.vpcId || discoveredResources.defaultVpcId;
1702
+
1703
+ // Create route table for VPC endpoints if it doesn't exist
1704
+ // VPC endpoints (S3, DynamoDB) need to reference a route table
1705
+ if (!result.resources.FriggLambdaRouteTable) {
1706
+ result.resources.FriggLambdaRouteTable = {
1707
+ Type: 'AWS::EC2::RouteTable',
1708
+ Properties: {
1709
+ VpcId: vpcId,
1710
+ Tags: [
1711
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-lambda-rt' },
1712
+ { Key: 'ManagedBy', Value: 'Frigg' },
1713
+ ],
1714
+ },
1715
+ };
1716
+ }
1717
+
1718
+ // Ensure subnet associations exist (healing for VPC endpoints without NAT Gateway)
1719
+ if (result.resources.FriggLambdaRouteTable || discoveredResources.routeTableId) {
1720
+ this.ensureSubnetAssociations(appDefinition, discoveredResources, result);
1721
+ }
1722
+
1723
+ // S3 Gateway Endpoint (only if not stack-managed and missing)
1724
+ if (!stackManagedEndpoints.s3 && !existingEndpoints.s3) {
1725
+ result.resources.FriggS3VPCEndpoint = {
1726
+ Type: 'AWS::EC2::VPCEndpoint',
1727
+ Properties: {
1728
+ VpcId: vpcId,
1729
+ ServiceName: 'com.amazonaws.${self:provider.region}.s3',
1730
+ VpcEndpointType: 'Gateway',
1731
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }],
1732
+ },
1733
+ };
1734
+ }
1735
+
1736
+ // DynamoDB Gateway Endpoint (only if not stack-managed and missing)
1737
+ if (!stackManagedEndpoints.dynamodb && !existingEndpoints.dynamodb) {
1738
+ result.resources.FriggDynamoDBVPCEndpoint = {
1739
+ Type: 'AWS::EC2::VPCEndpoint',
1740
+ Properties: {
1741
+ VpcId: vpcId,
1742
+ ServiceName: 'com.amazonaws.${self:provider.region}.dynamodb',
1743
+ VpcEndpointType: 'Gateway',
1744
+ RouteTableIds: [{ Ref: 'FriggLambdaRouteTable' }],
1745
+ },
1746
+ };
1747
+ }
1748
+
1749
+ // VPC Endpoint Security Group (only if KMS, Secrets Manager, or SQS are not stack-managed and missing)
1750
+ const needsSecurityGroup =
1751
+ (!stackManagedEndpoints.kms && !existingEndpoints.kms && appDefinition.encryption?.fieldLevelEncryptionMethod === 'kms') ||
1752
+ (!stackManagedEndpoints.secretsManager && !existingEndpoints.secretsManager) ||
1753
+ (!stackManagedEndpoints.sqs && !existingEndpoints.sqs);
1754
+
1755
+ if (needsSecurityGroup) {
1756
+ result.resources.FriggVPCEndpointSecurityGroup = {
1757
+ Type: 'AWS::EC2::SecurityGroup',
1758
+ Properties: {
1759
+ GroupDescription: 'Security group for VPC Endpoints',
1760
+ VpcId: vpcId,
1761
+ SecurityGroupIngress: [
1762
+ {
1763
+ IpProtocol: 'tcp',
1764
+ FromPort: 443,
1765
+ ToPort: 443,
1766
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
1767
+ Description: 'HTTPS from Lambda',
1768
+ },
1769
+ ],
1770
+ Tags: [
1771
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-vpc-endpoint-sg' },
1772
+ { Key: 'ManagedBy', Value: 'Frigg' },
1773
+ ],
1774
+ },
1775
+ };
1776
+ }
1777
+
1778
+ // KMS Interface Endpoint (only if not stack-managed, missing, AND KMS encryption is enabled)
1779
+ if (!stackManagedEndpoints.kms && !existingEndpoints.kms && appDefinition.encryption?.fieldLevelEncryptionMethod === 'kms') {
1780
+ result.resources.FriggKMSVPCEndpoint = {
1781
+ Type: 'AWS::EC2::VPCEndpoint',
1782
+ Properties: {
1783
+ VpcId: vpcId,
1784
+ ServiceName: 'com.amazonaws.${self:provider.region}.kms',
1785
+ VpcEndpointType: 'Interface',
1786
+ SubnetIds: result.vpcConfig.subnetIds,
1787
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
1788
+ PrivateDnsEnabled: true,
1789
+ },
1790
+ };
1791
+ }
1792
+
1793
+ // Secrets Manager Interface Endpoint (only if not stack-managed and missing)
1794
+ if (!stackManagedEndpoints.secretsManager && !existingEndpoints.secretsManager) {
1795
+ result.resources.FriggSecretsManagerVPCEndpoint = {
1796
+ Type: 'AWS::EC2::VPCEndpoint',
1797
+ Properties: {
1798
+ VpcId: vpcId,
1799
+ ServiceName: 'com.amazonaws.${self:provider.region}.secretsmanager',
1800
+ VpcEndpointType: 'Interface',
1801
+ SubnetIds: result.vpcConfig.subnetIds,
1802
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
1803
+ PrivateDnsEnabled: true,
1804
+ },
1805
+ };
1806
+ }
1807
+
1808
+ // SQS Interface Endpoint (only if not stack-managed and missing)
1809
+ // Used for job queues and async processing (not just database migrations)
1810
+ if (!stackManagedEndpoints.sqs && !existingEndpoints.sqs) {
1811
+ result.resources.FriggSQSVPCEndpoint = {
1812
+ Type: 'AWS::EC2::VPCEndpoint',
1813
+ Properties: {
1814
+ VpcId: vpcId,
1815
+ ServiceName: 'com.amazonaws.${self:provider.region}.sqs',
1816
+ VpcEndpointType: 'Interface',
1817
+ SubnetIds: result.vpcConfig.subnetIds,
1818
+ SecurityGroupIds: [{ Ref: 'FriggVPCEndpointSecurityGroup' }],
1819
+ PrivateDnsEnabled: true,
1820
+ },
1821
+ };
1822
+ }
1823
+
1824
+ console.log(` ✅ Created ${missing.length} VPC endpoint(s): ${missing.join(', ')}`);
1825
+ }
1826
+ }
1827
+
1828
+ module.exports = { VpcBuilder };
1829
+