@aws/nx-plugin 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +1 -1
  2. package/src/infra/app/generator.js +4 -1
  3. package/src/infra/app/generator.js.map +1 -1
  4. package/src/mcp-server/tools/create-workspace-command.js +4 -0
  5. package/src/mcp-server/tools/create-workspace-command.js.map +1 -1
  6. package/src/mcp-server/tools/general-guidance.js +3 -1
  7. package/src/mcp-server/tools/general-guidance.js.map +1 -1
  8. package/src/preset/__snapshots__/generator.spec.ts.snap +9 -0
  9. package/src/preset/generator.d.ts +1 -1
  10. package/src/preset/generator.js +4 -1
  11. package/src/preset/generator.js.map +1 -1
  12. package/src/preset/schema.d.ts +3 -0
  13. package/src/preset/schema.json +9 -1
  14. package/src/py/fast-api/__snapshots__/generator.spec.ts.snap +60 -0
  15. package/src/py/fast-api/generator.js +5 -3
  16. package/src/py/fast-api/generator.js.map +1 -1
  17. package/src/py/fast-api/react/__snapshots__/generator.spec.ts.snap +1 -0
  18. package/src/py/fast-api/schema.d.ts +3 -1
  19. package/src/py/fast-api/schema.json +4 -4
  20. package/src/py/lambda-function/__snapshots__/generator.spec.ts.snap +310 -0
  21. package/src/py/lambda-function/generator.js +17 -25
  22. package/src/py/lambda-function/generator.js.map +1 -1
  23. package/src/py/lambda-function/schema.d.ts +3 -0
  24. package/src/py/lambda-function/schema.json +8 -0
  25. package/src/py/mcp-server/generator.js +4 -2
  26. package/src/py/mcp-server/generator.js.map +1 -1
  27. package/src/py/mcp-server/schema.d.ts +4 -1
  28. package/src/py/mcp-server/schema.json +4 -4
  29. package/src/py/strands-agent/generator.js +4 -2
  30. package/src/py/strands-agent/generator.js.map +1 -1
  31. package/src/py/strands-agent/schema.d.ts +3 -1
  32. package/src/py/strands-agent/schema.json +4 -4
  33. package/src/terraform/project/generator.js +2 -1
  34. package/src/terraform/project/generator.js.map +1 -1
  35. package/src/trpc/backend/__snapshots__/generator.spec.ts.snap +60 -0
  36. package/src/trpc/backend/generator.js +4 -2
  37. package/src/trpc/backend/generator.js.map +1 -1
  38. package/src/trpc/backend/schema.d.ts +2 -1
  39. package/src/trpc/backend/schema.json +4 -4
  40. package/src/trpc/react/__snapshots__/generator.spec.ts.snap +1 -0
  41. package/src/ts/lambda-function/__snapshots__/generator.spec.ts.snap +310 -0
  42. package/src/ts/lambda-function/generator.js +17 -27
  43. package/src/ts/lambda-function/generator.js.map +1 -1
  44. package/src/ts/lambda-function/schema.d.ts +3 -0
  45. package/src/ts/lambda-function/schema.json +8 -0
  46. package/src/ts/mcp-server/generator.js +4 -2
  47. package/src/ts/mcp-server/generator.js.map +1 -1
  48. package/src/ts/mcp-server/schema.d.ts +3 -1
  49. package/src/ts/mcp-server/schema.json +4 -4
  50. package/src/ts/nx-generator/__snapshots__/generator.spec.ts.snap +27 -1
  51. package/src/ts/nx-generator/files/nx-plugin-for-aws/docs/__nameKebabCase__.mdx.template +31 -0
  52. package/src/ts/nx-generator/files/nx-plugin-for-aws/generator/generator.spec.ts.template +1 -1
  53. package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +756 -0
  54. package/src/ts/react-website/app/generator.js +44 -43
  55. package/src/ts/react-website/app/generator.js.map +1 -1
  56. package/src/ts/react-website/app/schema.d.ts +2 -0
  57. package/src/ts/react-website/app/schema.json +8 -0
  58. package/src/ts/react-website/cognito-auth/__snapshots__/generator.spec.ts.snap +259 -0
  59. package/src/ts/react-website/cognito-auth/generator.js +12 -13
  60. package/src/ts/react-website/cognito-auth/generator.js.map +1 -1
  61. package/src/ts/react-website/cognito-auth/schema.d.ts +4 -0
  62. package/src/ts/react-website/cognito-auth/schema.json +8 -0
  63. package/src/ts/react-website/runtime-config/__snapshots__/generator.spec.ts.snap +0 -40
  64. package/src/ts/react-website/runtime-config/generator.js +0 -2
  65. package/src/ts/react-website/runtime-config/generator.js.map +1 -1
  66. package/src/utils/agent-core-constructs/agent-core-constructs.d.ts +4 -2
  67. package/src/utils/agent-core-constructs/agent-core-constructs.js.map +1 -1
  68. package/src/utils/api-constructs/api-constructs.d.ts +2 -1
  69. package/src/utils/api-constructs/api-constructs.js.map +1 -1
  70. package/src/utils/api-constructs/files/terraform/app/apis/http/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +10 -0
  71. package/src/utils/api-constructs/files/terraform/app/apis/rest/__apiNameKebabCase__/__apiNameKebabCase__.tf.template +10 -0
  72. package/src/utils/config/index.d.ts +6 -0
  73. package/src/utils/config/index.js.map +1 -1
  74. package/src/utils/files/terraform/src/core/runtime-config/entry/entry.tf.template +119 -0
  75. package/src/utils/files/terraform/src/core/runtime-config/read/read.tf.template +28 -0
  76. package/src/utils/files/terraform/src/metrics/metrics.tf.template +7 -1
  77. package/src/{py/lambda-function/files/common/constructs/src/app/lambda-functions/__constructFunctionKebabCase__.ts.template → utils/function-constructs/files/cdk/app/lambda-functions/__functionNameKebabCase__.ts.template} +5 -5
  78. package/src/utils/function-constructs/files/terraform/app/lambda-functions/__functionNameKebabCase__/__functionNameKebabCase__.tf.template +150 -0
  79. package/src/utils/function-constructs/function-constructs.d.ts +20 -0
  80. package/src/utils/function-constructs/function-constructs.js +57 -0
  81. package/src/utils/function-constructs/function-constructs.js.map +1 -0
  82. package/src/utils/iac.d.ts +21 -0
  83. package/src/utils/iac.js +25 -0
  84. package/src/utils/iac.js.map +1 -0
  85. package/src/utils/identity-constructs/files/terraform/core/user-identity/add-callback-url/add-callback-url.tf.template +123 -0
  86. package/src/utils/identity-constructs/files/terraform/core/user-identity/identity/identity.tf.template +421 -0
  87. package/src/utils/identity-constructs/files/terraform/core/user-identity/main.tf.template +47 -0
  88. package/src/utils/identity-constructs/identity-constructs.d.ts +16 -0
  89. package/src/utils/identity-constructs/identity-constructs.js +84 -0
  90. package/src/utils/identity-constructs/identity-constructs.js.map +1 -0
  91. package/src/utils/metrics.js +1 -1
  92. package/src/utils/metrics.js.map +1 -1
  93. package/src/utils/shared-constructs.d.ts +3 -2
  94. package/src/utils/shared-constructs.js +27 -3
  95. package/src/utils/shared-constructs.js.map +1 -1
  96. package/src/utils/website-constructs/files/terraform/app/static-websites/__websiteNameKebabCase__/__websiteNameKebabCase__.tf.template +42 -0
  97. package/src/utils/website-constructs/files/terraform/core/static-website/static-website.tf.template +709 -0
  98. package/src/utils/website-constructs/website-constructs.d.ts +19 -0
  99. package/src/utils/website-constructs/website-constructs.js +61 -0
  100. package/src/utils/website-constructs/website-constructs.js.map +1 -0
  101. package/src/ts/lambda-function/files/common/constructs/src/app/lambda-functions/__constructFunctionNameKebabCase__.ts.template +0 -24
  102. /package/src/{ts/react-website/cognito-auth/files/common/constructs/src → utils/identity-constructs/files/cdk}/core/user-identity.ts.template +0 -0
  103. /package/src/{ts/react-website/app/files/common/constructs/src → utils/website-constructs/files/cdk}/app/static-websites/__websiteNameKebabCase__.ts.template +0 -0
  104. /package/src/{ts/react-website/app/files/common/constructs/src → utils/website-constructs/files/cdk}/core/static-website.ts.template +0 -0
@@ -0,0 +1,709 @@
1
+ terraform {
2
+ required_providers {
3
+ aws = {
4
+ source = "hashicorp/aws"
5
+ version = "~> 6.0"
6
+ configuration_aliases = [aws.us_east_1]
7
+ }
8
+ random = {
9
+ source = "hashicorp/random"
10
+ version = "~> 3.1"
11
+ }
12
+ }
13
+ }
14
+
15
+ # Variables
16
+ variable "website_name" {
17
+ description = "Name of the website"
18
+ type = string
19
+ }
20
+
21
+ variable "website_file_path" {
22
+ description = "Path to the website files"
23
+ type = string
24
+ }
25
+
26
+
27
+ # Data sources
28
+ data "aws_caller_identity" "current" {}
29
+ data "aws_region" "current" {}
30
+
31
+
32
+ # KMS Key for encryption
33
+ resource "aws_kms_key" "website_key" {
34
+ description = "KMS key for ${var.website_name} website encryption"
35
+ deletion_window_in_days = 7
36
+ enable_key_rotation = true
37
+
38
+ policy = jsonencode({
39
+ Version = "2012-10-17"
40
+ Statement = [
41
+ {
42
+ Sid = "Enable IAM User Permissions"
43
+ Effect = "Allow"
44
+ Principal = {
45
+ AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
46
+ }
47
+ Action = "kms:*"
48
+ Resource = "*"
49
+ },
50
+ {
51
+ Sid = "Allow S3 Service"
52
+ Effect = "Allow"
53
+ Principal = {
54
+ Service = "s3.amazonaws.com"
55
+ }
56
+ Action = [
57
+ "kms:Decrypt",
58
+ "kms:GenerateDataKey"
59
+ ]
60
+ Resource = "*"
61
+ },
62
+ {
63
+ Sid = "AllowCloudFrontServicePrincipalSSE-KMS"
64
+ Effect = "Allow"
65
+ Principal = {
66
+ Service = "cloudfront.amazonaws.com"
67
+ }
68
+ Action = [
69
+ "kms:Decrypt",
70
+ "kms:Encrypt",
71
+ "kms:GenerateDataKey*"
72
+ ]
73
+ Resource = "*"
74
+ Condition = {
75
+ StringEquals = {
76
+ "AWS:SourceArn" = aws_cloudfront_distribution.website.arn
77
+ }
78
+ }
79
+ }
80
+ ]
81
+ })
82
+
83
+ tags = {
84
+ Name = "${lower(var.website_name)}-website-key-${random_id.unique_suffix.hex}"
85
+ }
86
+ }
87
+
88
+ resource "aws_kms_alias" "website_key_alias" {
89
+ name = "alias/${lower(var.website_name)}-website-key-${random_id.unique_suffix.hex}"
90
+ target_key_id = aws_kms_key.website_key.key_id
91
+ }
92
+
93
+ # Access Logs Bucket
94
+ resource "aws_s3_bucket" "access_logs" {
95
+ #checkov:skip=CKV2_AWS_61:Lifecycle configuration not required for access logs bucket
96
+ #checkov:skip=CKV_AWS_144:Cross-region replication not required for access logs
97
+ #checkov:skip=CKV2_AWS_62:Event notifications not required for access logs bucket
98
+ #checkov:skip=CKV_AWS_21:Versioning disabled for access logs to reduce storage costs
99
+ bucket = "${lower(var.website_name)}-access-logs-${random_id.bucket_suffix.hex}"
100
+ force_destroy = true
101
+
102
+ tags = {
103
+ Name = "${lower(var.website_name)}-access-logs"
104
+ }
105
+ }
106
+
107
+ resource "random_id" "unique_suffix" {
108
+ byte_length = 4
109
+ }
110
+
111
+ resource "random_id" "bucket_suffix" {
112
+ byte_length = 8
113
+ }
114
+
115
+ resource "aws_s3_bucket_versioning" "access_logs_versioning" {
116
+ bucket = aws_s3_bucket.access_logs.id
117
+ versioning_configuration {
118
+ status = "Disabled"
119
+ }
120
+ }
121
+
122
+ resource "aws_s3_bucket_server_side_encryption_configuration" "access_logs_encryption" {
123
+ bucket = aws_s3_bucket.access_logs.id
124
+
125
+ rule {
126
+ apply_server_side_encryption_by_default {
127
+ kms_master_key_id = aws_kms_key.website_key.arn
128
+ sse_algorithm = "aws:kms"
129
+ }
130
+ }
131
+ }
132
+
133
+ resource "aws_s3_bucket_public_access_block" "access_logs_pab" {
134
+ bucket = aws_s3_bucket.access_logs.id
135
+
136
+ block_public_acls = true
137
+ block_public_policy = true
138
+ ignore_public_acls = true
139
+ restrict_public_buckets = true
140
+ }
141
+
142
+ resource "aws_s3_bucket_policy" "access_logs_ssl_policy" {
143
+ bucket = aws_s3_bucket.access_logs.id
144
+
145
+ policy = jsonencode({
146
+ Version = "2012-10-17"
147
+ Statement = [
148
+ {
149
+ Sid = "DenyInsecureConnections"
150
+ Effect = "Deny"
151
+ Principal = "*"
152
+ Action = "s3:*"
153
+ Resource = [
154
+ aws_s3_bucket.access_logs.arn,
155
+ "${aws_s3_bucket.access_logs.arn}/*"
156
+ ]
157
+ Condition = {
158
+ Bool = {
159
+ "aws:SecureTransport" = "false"
160
+ }
161
+ }
162
+ }
163
+ ]
164
+ })
165
+ }
166
+
167
+ # Website Bucket
168
+ resource "aws_s3_bucket" "website" {
169
+ #checkov:skip=CKV2_AWS_61:Lifecycle configuration not required for static website content
170
+ #checkov:skip=CKV_AWS_144:Cross-region replication not required for static website
171
+ #checkov:skip=CKV2_AWS_62:Event notifications not required for static website bucket
172
+ bucket = "${lower(var.website_name)}-website-${random_id.bucket_suffix.hex}"
173
+ force_destroy = true
174
+
175
+ tags = {
176
+ Name = "${lower(var.website_name)}-website"
177
+ }
178
+ }
179
+
180
+ resource "aws_s3_bucket_versioning" "website_versioning" {
181
+ bucket = aws_s3_bucket.website.id
182
+ versioning_configuration {
183
+ status = "Enabled"
184
+ }
185
+ }
186
+
187
+ resource "aws_s3_bucket_server_side_encryption_configuration" "website_encryption" {
188
+ bucket = aws_s3_bucket.website.id
189
+
190
+ rule {
191
+ apply_server_side_encryption_by_default {
192
+ kms_master_key_id = aws_kms_key.website_key.arn
193
+ sse_algorithm = "aws:kms"
194
+ }
195
+ }
196
+ }
197
+
198
+ resource "aws_s3_bucket_public_access_block" "website_pab" {
199
+ bucket = aws_s3_bucket.website.id
200
+
201
+ block_public_acls = true
202
+ block_public_policy = true
203
+ ignore_public_acls = true
204
+ restrict_public_buckets = true
205
+ }
206
+
207
+ resource "aws_s3_bucket_ownership_controls" "website_ownership" {
208
+ bucket = aws_s3_bucket.website.id
209
+
210
+ rule {
211
+ object_ownership = "BucketOwnerEnforced"
212
+ }
213
+ }
214
+
215
+ resource "aws_s3_bucket_logging" "website_logging" {
216
+ bucket = aws_s3_bucket.website.id
217
+
218
+ target_bucket = aws_s3_bucket.access_logs.id
219
+ target_prefix = "website-access-logs/"
220
+ }
221
+
222
+
223
+ # Distribution Log Bucket
224
+ resource "aws_s3_bucket" "distribution_logs" {
225
+ #checkov:skip=CKV2_AWS_61:Lifecycle configuration not required for CloudFront logs
226
+ #checkov:skip=CKV_AWS_144:Cross-region replication not required for CloudFront logs
227
+ #checkov:skip=CKV2_AWS_62:Event notifications not required for CloudFront logs bucket
228
+ #checkov:skip=CKV_AWS_21:Versioning not required for CloudFront access logs
229
+ bucket = "${lower(var.website_name)}-distribution-logs-${random_id.bucket_suffix.hex}"
230
+ force_destroy = true
231
+
232
+ tags = {
233
+ Name = "${lower(var.website_name)}-distribution-logs"
234
+ }
235
+ }
236
+
237
+ resource "aws_s3_bucket_server_side_encryption_configuration" "distribution_logs_encryption" {
238
+ bucket = aws_s3_bucket.distribution_logs.id
239
+
240
+ rule {
241
+ apply_server_side_encryption_by_default {
242
+ kms_master_key_id = aws_kms_key.website_key.arn
243
+ sse_algorithm = "aws:kms"
244
+ }
245
+ }
246
+ }
247
+
248
+ resource "aws_s3_bucket_public_access_block" "distribution_logs_pab" {
249
+ bucket = aws_s3_bucket.distribution_logs.id
250
+
251
+ block_public_acls = true
252
+ block_public_policy = true
253
+ ignore_public_acls = true
254
+ restrict_public_buckets = true
255
+ }
256
+
257
+ resource "aws_s3_bucket_ownership_controls" "distribution_logs_ownership" {
258
+ #checkov:skip=CKV2_AWS_65:BucketOwnerPreferred required for CloudFront logging compatibility
259
+ bucket = aws_s3_bucket.distribution_logs.id
260
+
261
+ rule {
262
+ object_ownership = "BucketOwnerPreferred"
263
+ }
264
+ }
265
+
266
+
267
+ resource "aws_s3_bucket_logging" "distribution_logs_logging" {
268
+ bucket = aws_s3_bucket.distribution_logs.id
269
+
270
+ target_bucket = aws_s3_bucket.access_logs.id
271
+ target_prefix = "distribution-access-logs/"
272
+ }
273
+
274
+ resource "aws_s3_bucket_policy" "distribution_logs_ssl_policy" {
275
+ bucket = aws_s3_bucket.distribution_logs.id
276
+
277
+ policy = jsonencode({
278
+ Version = "2012-10-17"
279
+ Statement = [
280
+ {
281
+ Sid = "DenyInsecureConnections"
282
+ Effect = "Deny"
283
+ Principal = "*"
284
+ Action = "s3:*"
285
+ Resource = [
286
+ aws_s3_bucket.distribution_logs.arn,
287
+ "${aws_s3_bucket.distribution_logs.arn}/*"
288
+ ]
289
+ Condition = {
290
+ Bool = {
291
+ "aws:SecureTransport" = "false"
292
+ }
293
+ }
294
+ }
295
+ ]
296
+ })
297
+ }
298
+
299
+ # WAF Web ACL (must be in us-east-1 for CloudFront)
300
+ resource "aws_wafv2_web_acl" "cloudfront_waf" {
301
+ #checkov:skip=CKV2_AWS_31:WAF logging disabled
302
+ provider = aws.us_east_1
303
+ name = "${lower(var.website_name)}-cloudfront-waf-${random_id.unique_suffix.hex}"
304
+ scope = "CLOUDFRONT"
305
+
306
+ default_action {
307
+ allow {}
308
+ }
309
+
310
+ rule {
311
+ name = "CRSRule"
312
+ priority = 0
313
+
314
+ override_action {
315
+ none {}
316
+ }
317
+
318
+ statement {
319
+ managed_rule_group_statement {
320
+ name = "AWSManagedRulesCommonRuleSet"
321
+ vendor_name = "AWS"
322
+ }
323
+ }
324
+
325
+ visibility_config {
326
+ cloudwatch_metrics_enabled = true
327
+ metric_name = "MetricForWebACLCDK-CRS"
328
+ sampled_requests_enabled = true
329
+ }
330
+ }
331
+
332
+ rule {
333
+ name = "KnownBadInputsRule"
334
+ priority = 1
335
+
336
+ override_action {
337
+ none {}
338
+ }
339
+
340
+ statement {
341
+ managed_rule_group_statement {
342
+ name = "AWSManagedRulesKnownBadInputsRuleSet"
343
+ vendor_name = "AWS"
344
+ }
345
+ }
346
+
347
+ visibility_config {
348
+ cloudwatch_metrics_enabled = true
349
+ metric_name = "MetricForWebACLCDK-KnownBadInputs"
350
+ sampled_requests_enabled = true
351
+ }
352
+ }
353
+
354
+ visibility_config {
355
+ cloudwatch_metrics_enabled = true
356
+ metric_name = "${lower(var.website_name)}-waf"
357
+ sampled_requests_enabled = true
358
+ }
359
+
360
+ tags = {
361
+ Name = "${lower(var.website_name)}-cloudfront-waf-${random_id.unique_suffix.hex}"
362
+ }
363
+
364
+ lifecycle {
365
+ create_before_destroy = true
366
+ }
367
+ }
368
+
369
+
370
+ # Origin Access Control
371
+ resource "aws_cloudfront_origin_access_control" "website_oac" {
372
+ name = "${lower(var.website_name)}-oac-${random_id.unique_suffix.hex}"
373
+ description = "Origin Access Control for ${lower(var.website_name)}"
374
+ origin_access_control_origin_type = "s3"
375
+ signing_behavior = "always"
376
+ signing_protocol = "sigv4"
377
+ }
378
+
379
+ # CloudFront Distribution
380
+ resource "aws_cloudfront_distribution" "website" {
381
+ #checkov:skip=CKV_AWS_174:Using CloudFront default certificate which does not support TLS v1.2
382
+ #checkov:skip=CKV_AWS_310:Origin failover not required for single S3 origin static website
383
+ #checkov:skip=CKV_AWS_374:Geo restrictions not required for global web application
384
+ #checkov:skip=CKV2_AWS_42:Custom SSL certificate not required for development - using CloudFront default
385
+ #checkov:skip=CKV2_AWS_32:Response headers policy not required for basic static website
386
+ #checkov:skip=CKV2_AWS_47:WAF includes AWSManagedRulesKnownBadInputsRuleSet which provides Log4j protection
387
+ origin {
388
+ domain_name = aws_s3_bucket.website.bucket_regional_domain_name
389
+ origin_access_control_id = aws_cloudfront_origin_access_control.website_oac.id
390
+ origin_id = "S3-${aws_s3_bucket.website.bucket}"
391
+ }
392
+
393
+ enabled = true
394
+ is_ipv6_enabled = true
395
+ default_root_object = "index.html"
396
+ web_acl_id = aws_wafv2_web_acl.cloudfront_waf.arn
397
+
398
+ logging_config {
399
+ include_cookies = false
400
+ bucket = aws_s3_bucket.distribution_logs.bucket_regional_domain_name
401
+ }
402
+
403
+ default_cache_behavior {
404
+ allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
405
+ cached_methods = ["GET", "HEAD"]
406
+ target_origin_id = "S3-${aws_s3_bucket.website.bucket}"
407
+
408
+ forwarded_values {
409
+ query_string = false
410
+ cookies {
411
+ forward = "none"
412
+ }
413
+ }
414
+
415
+ viewer_protocol_policy = "redirect-to-https"
416
+ min_ttl = 0
417
+ default_ttl = 3600
418
+ max_ttl = 86400
419
+ compress = true
420
+ }
421
+
422
+ # Custom error responses for SPA routing
423
+ custom_error_response {
424
+ error_code = 404
425
+ response_code = 200
426
+ response_page_path = "/index.html"
427
+ }
428
+
429
+ custom_error_response {
430
+ error_code = 403
431
+ response_code = 200
432
+ response_page_path = "/index.html"
433
+ }
434
+
435
+ restrictions {
436
+ geo_restriction {
437
+ restriction_type = "none"
438
+ }
439
+ }
440
+
441
+ viewer_certificate {
442
+ cloudfront_default_certificate = true
443
+ }
444
+
445
+ tags = {
446
+ Name = "${lower(var.website_name)}-distribution-${random_id.unique_suffix.hex}"
447
+ }
448
+
449
+ lifecycle {
450
+ replace_triggered_by = [
451
+ aws_wafv2_web_acl.cloudfront_waf
452
+ ]
453
+ }
454
+ }
455
+
456
+ # S3 Bucket Policy for CloudFront OAC
457
+ resource "aws_s3_bucket_policy" "website_cloudfront_policy" {
458
+ bucket = aws_s3_bucket.website.id
459
+
460
+ policy = jsonencode({
461
+ Version = "2012-10-17"
462
+ Statement = [
463
+ {
464
+ Sid = "AllowCloudFrontServicePrincipal"
465
+ Effect = "Allow"
466
+ Principal = {
467
+ Service = "cloudfront.amazonaws.com"
468
+ }
469
+ Action = "s3:GetObject"
470
+ Resource = "${aws_s3_bucket.website.arn}/*"
471
+ Condition = {
472
+ StringEquals = {
473
+ "AWS:SourceArn" = aws_cloudfront_distribution.website.arn
474
+ }
475
+ }
476
+ },
477
+ {
478
+ Sid = "DenyInsecureConnections"
479
+ Effect = "Deny"
480
+ Principal = "*"
481
+ Action = "s3:*"
482
+ Resource = [
483
+ aws_s3_bucket.website.arn,
484
+ "${aws_s3_bucket.website.arn}/*"
485
+ ]
486
+ Condition = {
487
+ Bool = {
488
+ "aws:SecureTransport" = "false"
489
+ }
490
+ }
491
+ }
492
+ ]
493
+ })
494
+
495
+ depends_on = [aws_cloudfront_distribution.website]
496
+ }
497
+
498
+ # Read runtime config using the reader module
499
+ module "runtime_config_reader" {
500
+ source = "../runtime-config/read"
501
+ }
502
+
503
+ # Upload website files to S3
504
+ resource "null_resource" "upload_website_files" {
505
+ triggers = {
506
+ # Trigger on any change to the website directory
507
+ website_path = var.website_file_path
508
+ # Trigger if any file in the directory changes using directory hash
509
+ directory_hash = sha256(join("", [for f in fileset(var.website_file_path, "**") : filesha256("${var.website_file_path}/${f}")]))
510
+ }
511
+
512
+ provisioner "local-exec" {
513
+ command = <<-EOT
514
+ cd "${path.root}"
515
+ uv run --with boto3 python3 -c "
516
+ import os
517
+ import sys
518
+ import boto3
519
+ import mimetypes
520
+ from pathlib import Path
521
+ from botocore.exceptions import ClientError, NoCredentialsError
522
+
523
+ def sync_to_s3(local_path, bucket_name):
524
+ try:
525
+ s3_client = boto3.client('s3')
526
+
527
+ # Check if local directory exists
528
+ if not os.path.isdir(local_path):
529
+ print(f'Error: Website directory not found at {local_path}')
530
+ sys.exit(1)
531
+
532
+ # Get existing objects in bucket (for deletion)
533
+ try:
534
+ existing_objects = set()
535
+ paginator = s3_client.get_paginator('list_objects_v2')
536
+ for page in paginator.paginate(Bucket=bucket_name):
537
+ if 'Contents' in page:
538
+ for obj in page['Contents']:
539
+ existing_objects.add(obj['Key'])
540
+ except ClientError as e:
541
+ if e.response['Error']['Code'] != 'NoSuchBucket':
542
+ raise
543
+ existing_objects = set()
544
+
545
+ # Upload files
546
+ uploaded_objects = set()
547
+ local_path_obj = Path(local_path)
548
+
549
+ for file_path in local_path_obj.rglob('*'):
550
+ if file_path.is_file():
551
+ # Skip runtime-config.json as it's handled separately
552
+ if file_path.name == 'runtime-config.json':
553
+ continue
554
+
555
+ # Calculate S3 key (relative path from local_path)
556
+ relative_path = file_path.relative_to(local_path_obj)
557
+ s3_key = str(relative_path).replace('\\\\', '/')
558
+ uploaded_objects.add(s3_key)
559
+
560
+ # Determine content type
561
+ content_type, _ = mimetypes.guess_type(str(file_path))
562
+ if content_type is None:
563
+ content_type = 'binary/octet-stream'
564
+
565
+ # Upload file
566
+ try:
567
+ s3_client.upload_file(
568
+ str(file_path),
569
+ bucket_name,
570
+ s3_key,
571
+ ExtraArgs={'ContentType': content_type}
572
+ )
573
+ print(f'Uploaded: {s3_key}')
574
+ except ClientError as e:
575
+ print(f'Error uploading {s3_key}: {e}')
576
+ sys.exit(1)
577
+
578
+ # Delete objects that no longer exist locally (excluding runtime-config.json)
579
+ objects_to_delete = existing_objects - uploaded_objects - {'runtime-config.json'}
580
+ if objects_to_delete:
581
+ delete_objects = [{'Key': key} for key in objects_to_delete]
582
+ try:
583
+ s3_client.delete_objects(
584
+ Bucket=bucket_name,
585
+ Delete={'Objects': delete_objects}
586
+ )
587
+ for obj in delete_objects:
588
+ print(f'Deleted: {obj[\"Key\"]}')
589
+ except ClientError as e:
590
+ print(f'Error deleting objects: {e}')
591
+ sys.exit(1)
592
+
593
+ print(f'Website files synced to s3://{bucket_name}/')
594
+
595
+ except NoCredentialsError:
596
+ print('Error: AWS credentials not found')
597
+ sys.exit(1)
598
+ except Exception as e:
599
+ print(f'Error: {e}')
600
+ sys.exit(1)
601
+
602
+ # Execute sync
603
+ sync_to_s3('${var.website_file_path}', '${aws_s3_bucket.website.bucket}')
604
+ "
605
+ EOT
606
+ }
607
+
608
+ depends_on = [aws_s3_bucket_policy.website_cloudfront_policy]
609
+ }
610
+
611
+ # Upload runtime config file
612
+ resource "aws_s3_object" "runtime_config" {
613
+ bucket = aws_s3_bucket.website.id
614
+ key = "runtime-config.json"
615
+ content = module.runtime_config_reader.config_json
616
+ content_type = "application/json"
617
+ etag = md5(module.runtime_config_reader.config_json)
618
+
619
+ depends_on = [null_resource.upload_website_files]
620
+ }
621
+
622
+ # Invalidate CloudFront cache after uploads
623
+ resource "null_resource" "cloudfront_invalidation" {
624
+ triggers = {
625
+ # Trigger when files or runtime config change
626
+ files_trigger = null_resource.upload_website_files.id
627
+ config_trigger = aws_s3_object.runtime_config.etag
628
+ }
629
+
630
+ provisioner "local-exec" {
631
+ command = <<-EOT
632
+ uv run --with boto3 python3 -c "
633
+ import boto3
634
+ import sys
635
+ from botocore.exceptions import ClientError, NoCredentialsError
636
+
637
+ def create_invalidation(distribution_id):
638
+ try:
639
+ cloudfront_client = boto3.client('cloudfront')
640
+
641
+ # Create invalidation for all paths
642
+ response = cloudfront_client.create_invalidation(
643
+ DistributionId=distribution_id,
644
+ InvalidationBatch={
645
+ 'Paths': {
646
+ 'Quantity': 1,
647
+ 'Items': ['/*']
648
+ },
649
+ 'CallerReference': f'terraform-invalidation-{distribution_id}-{hash(distribution_id) % 1000000}'
650
+ }
651
+ )
652
+
653
+ invalidation_id = response['Invalidation']['Id']
654
+ print(f'CloudFront cache invalidation created: {invalidation_id}')
655
+ print(f'Distribution: ${aws_cloudfront_distribution.website.id}')
656
+ print(f'Status: {response[\"Invalidation\"][\"Status\"]}')
657
+
658
+ except NoCredentialsError:
659
+ print('Error: AWS credentials not found')
660
+ sys.exit(1)
661
+ except ClientError as e:
662
+ print(f'Error creating CloudFront invalidation: {e}')
663
+ sys.exit(1)
664
+ except Exception as e:
665
+ print(f'Error: {e}')
666
+ sys.exit(1)
667
+
668
+ # Execute invalidation
669
+ create_invalidation('${aws_cloudfront_distribution.website.id}')
670
+ "
671
+ EOT
672
+ }
673
+
674
+ depends_on = [
675
+ null_resource.upload_website_files,
676
+ aws_s3_object.runtime_config
677
+ ]
678
+ }
679
+
680
+ # Outputs
681
+ output "website_bucket_name" {
682
+ description = "Name of the S3 bucket hosting the website"
683
+ value = aws_s3_bucket.website.bucket
684
+ }
685
+
686
+ output "website_bucket_arn" {
687
+ description = "ARN of the S3 bucket hosting the website"
688
+ value = aws_s3_bucket.website.arn
689
+ }
690
+
691
+ output "cloudfront_distribution_id" {
692
+ description = "ID of the CloudFront distribution"
693
+ value = aws_cloudfront_distribution.website.id
694
+ }
695
+
696
+ output "cloudfront_distribution_arn" {
697
+ description = "ARN of the CloudFront distribution"
698
+ value = aws_cloudfront_distribution.website.arn
699
+ }
700
+
701
+ output "cloudfront_domain_name" {
702
+ description = "Domain name of the CloudFront distribution"
703
+ value = aws_cloudfront_distribution.website.domain_name
704
+ }
705
+
706
+ output "waf_web_acl_arn" {
707
+ description = "ARN of the WAF Web ACL"
708
+ value = aws_wafv2_web_acl.cloudfront_waf.arn
709
+ }