@friggframework/devtools 2.0.0-next.11 → 2.0.0-next.110

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