@staticforge/aws 0.0.0-stage → 0.1.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.
@@ -0,0 +1,33 @@
1
+ # Routing at the edge: a viewer-request function maps clean URLs to objects and
2
+ # prefixes them with the active version, which lives in a KeyValueStore. A
3
+ # deploy or rollback is one KVS key update: no distribution change, and since
4
+ # the rewritten URI is the cache key, no invalidation either.
5
+
6
+ resource "aws_cloudfront_key_value_store" "site" {
7
+ name = substr("sforge-${local.id}", 0, 64)
8
+ comment = "staticforge ${local.id}: active version, previews and routes"
9
+ }
10
+
11
+ # Seeded once; deploys and rollbacks change the value with the SDK, so the
12
+ # value is ignored here and never shows up as drift.
13
+ resource "aws_cloudfrontkeyvaluestore_key" "active" {
14
+ key_value_store_arn = aws_cloudfront_key_value_store.site.arn
15
+ key = "active"
16
+ value = "none"
17
+
18
+ lifecycle {
19
+ ignore_changes = [value]
20
+ }
21
+ }
22
+
23
+ resource "aws_cloudfront_function" "router" {
24
+ name = substr("sforge-${local.id}-router", 0, 64)
25
+ comment = "staticforge URL routing and versions"
26
+ runtime = "cloudfront-js-2.0"
27
+ publish = true
28
+ code = replace(file("${path.module}/router.js"), "__SFORGE_CONFIG__", jsonencode({
29
+ trailingSlash = var.trailing_slash
30
+ previewSuffix = var.preview_domain == null ? "" : ".${var.preview_domain}"
31
+ }))
32
+ key_value_store_associations = [aws_cloudfront_key_value_store.site.arn]
33
+ }
@@ -0,0 +1,107 @@
1
+ locals {
2
+ id = "${var.name}-${var.target}"
3
+ # bucket_prefix may be at most 37 characters; a random suffix makes the name unique.
4
+ bucket_prefix = "${trimsuffix(substr("sforge-${local.id}", 0, 36), "-")}-"
5
+ tags = merge(var.tags, { "sforge:site" = var.name, "sforge:target" = var.target })
6
+ }
7
+
8
+ # Private content bucket. Deploys write v/<versionId>/...; there is no website
9
+ # endpoint and no public access: only this distribution can read it (OAC).
10
+ resource "aws_s3_bucket" "site" {
11
+ bucket_prefix = local.bucket_prefix
12
+ force_destroy = var.force_destroy
13
+ tags = local.tags
14
+ }
15
+
16
+ resource "aws_s3_bucket_public_access_block" "site" {
17
+ bucket = aws_s3_bucket.site.id
18
+ block_public_acls = true
19
+ block_public_policy = true
20
+ ignore_public_acls = true
21
+ restrict_public_buckets = true
22
+ }
23
+
24
+ resource "aws_s3_bucket_ownership_controls" "site" {
25
+ bucket = aws_s3_bucket.site.id
26
+ rule {
27
+ object_ownership = "BucketOwnerEnforced"
28
+ }
29
+ }
30
+
31
+ resource "aws_s3_bucket_versioning" "site" {
32
+ bucket = aws_s3_bucket.site.id
33
+ versioning_configuration {
34
+ status = "Enabled"
35
+ }
36
+ }
37
+
38
+ resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
39
+ bucket = aws_s3_bucket.site.id
40
+ rule {
41
+ apply_server_side_encryption_by_default {
42
+ sse_algorithm = "AES256"
43
+ }
44
+ }
45
+ }
46
+
47
+ # Deploy prefixes beyond retainVersions are deleted by sforge; this only
48
+ # cleans up overwritten object versions and failed uploads.
49
+ resource "aws_s3_bucket_lifecycle_configuration" "site" {
50
+ bucket = aws_s3_bucket.site.id
51
+
52
+ rule {
53
+ id = "expire-noncurrent-versions"
54
+ status = "Enabled"
55
+ filter {}
56
+ noncurrent_version_expiration {
57
+ noncurrent_days = 30
58
+ }
59
+ abort_incomplete_multipart_upload {
60
+ days_after_initiation = 1
61
+ }
62
+ }
63
+
64
+ depends_on = [aws_s3_bucket_versioning.site]
65
+ }
66
+
67
+ locals {
68
+ cloudfront_only = {
69
+ StringEquals = { "AWS:SourceArn" = aws_cloudfront_distribution.site.arn }
70
+ }
71
+ }
72
+
73
+ resource "aws_s3_bucket_policy" "site" {
74
+ bucket = aws_s3_bucket.site.id
75
+ policy = jsonencode({
76
+ Version = "2012-10-17"
77
+ Statement = [
78
+ {
79
+ Sid = "CloudFrontRead"
80
+ Effect = "Allow"
81
+ Principal = { Service = "cloudfront.amazonaws.com" }
82
+ Action = "s3:GetObject"
83
+ Resource = "${aws_s3_bucket.site.arn}/*"
84
+ Condition = local.cloudfront_only
85
+ },
86
+ {
87
+ # Lets S3 answer 404 (not 403) for missing objects.
88
+ Sid = "CloudFrontList"
89
+ Effect = "Allow"
90
+ Principal = { Service = "cloudfront.amazonaws.com" }
91
+ Action = "s3:ListBucket"
92
+ Resource = aws_s3_bucket.site.arn
93
+ Condition = local.cloudfront_only
94
+ },
95
+ {
96
+ Sid = "DenyInsecureTransport"
97
+ Effect = "Deny"
98
+ Principal = "*"
99
+ Action = "s3:*"
100
+ Resource = [aws_s3_bucket.site.arn, "${aws_s3_bucket.site.arn}/*"]
101
+ Condition = { Bool = { "aws:SecureTransport" = "false" } }
102
+ },
103
+ ]
104
+ })
105
+
106
+ depends_on = [aws_s3_bucket_public_access_block.site]
107
+ }
@@ -0,0 +1,106 @@
1
+ variable "name" {
2
+ description = "Project name (iac.name), used in resource names and tags."
3
+ type = string
4
+ }
5
+
6
+ variable "target" {
7
+ description = "Target name, e.g. staging."
8
+ type = string
9
+ }
10
+
11
+ variable "trailing_slash" {
12
+ description = "build.trailingSlash: how the router maps extensionless URLs."
13
+ type = string
14
+ default = "always"
15
+ validation {
16
+ condition = contains(["always", "never"], var.trailing_slash)
17
+ error_message = "trailing_slash must be \"always\" or \"never\"."
18
+ }
19
+ }
20
+
21
+ variable "domain" {
22
+ description = "Custom domain. Null serves the site from the *.cloudfront.net domain."
23
+ type = string
24
+ default = null
25
+ }
26
+
27
+ variable "hosted_zone" {
28
+ description = "Route 53 hosted zone for DNS validation and alias records. Null prints the records instead."
29
+ type = string
30
+ default = null
31
+ }
32
+
33
+ variable "preview_domain" {
34
+ description = "Parent domain of PR previews (pr-<n>.<preview_domain>). Needs domain."
35
+ type = string
36
+ default = null
37
+ }
38
+
39
+ variable "wait_for_validation" {
40
+ description = "Wait for the ACM certificate to be validated (false only creates the certificate)."
41
+ type = bool
42
+ default = true
43
+ }
44
+
45
+ variable "price_class" {
46
+ type = string
47
+ default = "PriceClass_100"
48
+ }
49
+
50
+ variable "logging" {
51
+ description = "Write CloudFront standard access logs to a separate bucket."
52
+ type = bool
53
+ default = false
54
+ }
55
+
56
+ variable "waf_web_acl_arn" {
57
+ type = string
58
+ default = null
59
+ }
60
+
61
+ variable "force_destroy" {
62
+ description = "Allow the content bucket to be deleted while it holds objects (set by sforge destroy)."
63
+ type = bool
64
+ default = false
65
+ }
66
+
67
+ variable "security_headers" {
68
+ description = "Security headers sent with every response."
69
+ type = object({
70
+ content_security_policy = optional(string)
71
+ search_content_security_policy = optional(string)
72
+ hsts_max_age = optional(number)
73
+ hsts_include_subdomains = optional(bool, false)
74
+ hsts_preload = optional(bool, false)
75
+ referrer_policy = optional(string, "strict-origin-when-cross-origin")
76
+ frame_options = optional(string, "SAMEORIGIN")
77
+ permissions_policy = optional(string)
78
+ })
79
+ default = {}
80
+ }
81
+
82
+ variable "search_paths" {
83
+ description = "Path patterns of the search page, which gets search_content_security_policy (Pagefind needs WebAssembly)."
84
+ type = list(string)
85
+ default = []
86
+ }
87
+
88
+ variable "ci" {
89
+ description = "GitHub Actions OIDC deploy role. Null creates none."
90
+ type = object({
91
+ repo = string
92
+ branches = list(string)
93
+ environments = list(string)
94
+ pull_requests = bool
95
+ create_oidc_provider = bool
96
+ oidc_provider_arn = optional(string)
97
+ state_bucket = optional(string)
98
+ state_key = optional(string)
99
+ })
100
+ default = null
101
+ }
102
+
103
+ variable "tags" {
104
+ type = map(string)
105
+ default = {}
106
+ }
@@ -0,0 +1,11 @@
1
+ terraform {
2
+ required_version = ">= 1.10.0"
3
+
4
+ required_providers {
5
+ aws = {
6
+ source = "hashicorp/aws"
7
+ version = ">= 6.0, < 7.0"
8
+ configuration_aliases = [aws.us_east_1]
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,104 @@
1
+ # Remote state for every staticforge site in one AWS account and region.
2
+ # State files are stored at <site>/<target>.tfstate and locked with S3 native
3
+ # locking (<key>.tflock objects, no DynamoDB table).
4
+
5
+ resource "aws_s3_bucket" "state" {
6
+ bucket = var.bucket_name
7
+ tags = merge(var.tags, { "sforge:role" = "state" })
8
+
9
+ lifecycle {
10
+ prevent_destroy = true
11
+ }
12
+ }
13
+
14
+ resource "aws_s3_bucket_public_access_block" "state" {
15
+ bucket = aws_s3_bucket.state.id
16
+ block_public_acls = true
17
+ block_public_policy = true
18
+ ignore_public_acls = true
19
+ restrict_public_buckets = true
20
+ }
21
+
22
+ resource "aws_s3_bucket_ownership_controls" "state" {
23
+ bucket = aws_s3_bucket.state.id
24
+ rule {
25
+ object_ownership = "BucketOwnerEnforced"
26
+ }
27
+ }
28
+
29
+ resource "aws_s3_bucket_versioning" "state" {
30
+ bucket = aws_s3_bucket.state.id
31
+ versioning_configuration {
32
+ status = "Enabled"
33
+ }
34
+ }
35
+
36
+ resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
37
+ bucket = aws_s3_bucket.state.id
38
+ rule {
39
+ apply_server_side_encryption_by_default {
40
+ sse_algorithm = var.kms_key_arn == null ? "AES256" : "aws:kms"
41
+ kms_master_key_id = var.kms_key_arn
42
+ }
43
+ bucket_key_enabled = var.kms_key_arn != null
44
+ }
45
+ }
46
+
47
+ resource "aws_s3_bucket_lifecycle_configuration" "state" {
48
+ bucket = aws_s3_bucket.state.id
49
+
50
+ rule {
51
+ id = "expire-old-state-versions"
52
+ status = "Enabled"
53
+ filter {}
54
+ noncurrent_version_expiration {
55
+ noncurrent_days = var.noncurrent_version_days
56
+ }
57
+ abort_incomplete_multipart_upload {
58
+ days_after_initiation = 7
59
+ }
60
+ }
61
+
62
+ depends_on = [aws_s3_bucket_versioning.state]
63
+ }
64
+
65
+ data "aws_iam_policy_document" "state" {
66
+ statement {
67
+ sid = "DenyInsecureTransport"
68
+ effect = "Deny"
69
+ actions = ["s3:*"]
70
+ resources = [aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*"]
71
+ principals {
72
+ type = "*"
73
+ identifiers = ["*"]
74
+ }
75
+ condition {
76
+ test = "Bool"
77
+ variable = "aws:SecureTransport"
78
+ values = ["false"]
79
+ }
80
+ }
81
+
82
+ statement {
83
+ sid = "DenyOutdatedTLS"
84
+ effect = "Deny"
85
+ actions = ["s3:*"]
86
+ resources = [aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*"]
87
+ principals {
88
+ type = "*"
89
+ identifiers = ["*"]
90
+ }
91
+ condition {
92
+ test = "NumericLessThan"
93
+ variable = "s3:TlsVersion"
94
+ values = ["1.2"]
95
+ }
96
+ }
97
+ }
98
+
99
+ resource "aws_s3_bucket_policy" "state" {
100
+ bucket = aws_s3_bucket.state.id
101
+ policy = data.aws_iam_policy_document.state.json
102
+
103
+ depends_on = [aws_s3_bucket_public_access_block.state]
104
+ }
@@ -0,0 +1,11 @@
1
+ output "bucket" {
2
+ value = aws_s3_bucket.state.id
3
+ }
4
+
5
+ output "bucket_arn" {
6
+ value = aws_s3_bucket.state.arn
7
+ }
8
+
9
+ output "region" {
10
+ value = aws_s3_bucket.state.region
11
+ }
@@ -0,0 +1,21 @@
1
+ variable "bucket_name" {
2
+ description = "Name of the state bucket, e.g. sforge-state-<accountId>-<region>."
3
+ type = string
4
+ }
5
+
6
+ variable "kms_key_arn" {
7
+ description = "KMS key for SSE-KMS. Null uses SSE-S3."
8
+ type = string
9
+ default = null
10
+ }
11
+
12
+ variable "noncurrent_version_days" {
13
+ description = "Days to keep previous versions of state files."
14
+ type = number
15
+ default = 90
16
+ }
17
+
18
+ variable "tags" {
19
+ type = map(string)
20
+ default = {}
21
+ }
@@ -0,0 +1,10 @@
1
+ terraform {
2
+ required_version = ">= 1.10.0"
3
+
4
+ required_providers {
5
+ aws = {
6
+ source = "hashicorp/aws"
7
+ version = ">= 6.0, < 7.0"
8
+ }
9
+ }
10
+ }