Harden host recovery and regional storage isolation
Prompt
Yes, use the full workflow
examples/single-host/main.tf+2 −2examples/single-host/tests/topology.tftest.hcl+1 −0examples/single-region/main.tf+2 −2examples/single-region/tests/topology.tftest.hcl+1 −0examples/two-regions/main.tf+4 −5examples/two-regions/tests/topology.tftest.hcl+22 −0modules/region/cloud-init.sh.tftpl+2 −0modules/region/main.tf+45 −5modules/region/outputs.tf+12 −0modules/region/tests/security.tftest.hcl+15 −0modules/region/variables.tf+32 −0modules/storage/main.tf+67 −2modules/storage/tests/security.tftest.hcl+22 −0
CI passed — run details
CI log
=== validate === Initializing provider plugins... - Reusing previous version of hashicorp/aws from the dependency lock file - Using previously-installed hashicorp/aws v6.70.0 OpenTofu has been successfully initialized! You may now begin working with OpenTofu. Try running "tofu plan" to see any changes that are required for your infrastructure. All OpenTofu commands should now work. If you ever set or change modules or backend configuration for OpenTofu, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. Success! The configuration is valid. tests/security.tftest.hcl... pass run "storage_security"... pass Warning: Value derived from a deprecated source on tests/security.tftest.hcl line 17, in run "storage_security": 17: condition = length(aws_s3_bucket_lifecycle_configuration.this.rule) == 1 && one(aws_s3_bucket_lifecycle_configuration.this.rule).abort_incomplete_multipart_upload[0].days_after_initiation == 7 This value is derived from aws_s3_bucket_lifecycle_configuration.this.rule[0].prefix, which is deprecated with the following message: Specify a prefix using 'filter' instead run "control_cleanup"... pass Success! 2 passed, 0 failed. Initializing provider plugins... - Reusing previous version of hashicorp/aws from the dependency lock file - Using previously-installed hashicorp/aws v6.70.0 OpenTofu has been successfully initialized! You may now begin working with OpenTofu. Try running "tofu plan" to see any changes that are required for your infrastructure. All OpenTofu commands should now work. If you ever set or change modules or backend configuration for OpenTofu, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. Success! The configuration is valid. tests/security.tftest.hcl... pass run "host_security"... pass run "reject_module_injection"... pass Success! 2 passed, 0 failed. Initializing modules... Initializing provider plugins... - Reusing previous version of hashicorp/aws from the dependency lock file - Using previously-installed hashicorp/aws v6.70.0 OpenTofu has been successfully initialized! You may now begin working with OpenTofu. Try running "tofu plan" to see any changes that are required for your infrastructure. All OpenTofu commands should now work. If you ever set or change modules or backend configuration for OpenTofu, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. Success! The configuration is valid. tests/topology.tftest.hcl... pass run "topology"... pass run "reject_invalid_public_key"... pass run "reject_shell_in_domain"... pass Success! 3 passed, 0 failed. Initializing modules... Initializing provider plugins... - Reusing previous version of hashicorp/aws from the dependency lock file - Using previously-installed hashicorp/aws v6.70.0 OpenTofu has been successfully initialized! You may now begin working with OpenTofu. Try running "tofu plan" to see any changes that are required for your infrastructure. All OpenTofu commands should now work. If you ever set or change modules or backend configuration for OpenTofu, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. Success! The configuration is valid. tests/topology.tftest.hcl... pass run "topology"... pass run "reject_invalid_public_key"... pass run "reject_shell_in_domain"... pass Success! 3 passed, 0 failed. Initializing modules... Initializing provider plugins... - terraform.io/builtin/terraform is built in to OpenTofu - Reusing previous version of hashicorp/aws from the dependency lock file - Using previously-installed hashicorp/aws v6.70.0 OpenTofu has been successfully initialized! You may now begin working with OpenTofu. Try running "tofu plan" to see any changes that are required for your infrastructure. All OpenTofu commands should now work. If you ever set or change modules or backend configuration for OpenTofu, rerun this command to reinitialize your working directory. If you forget, other commands will detect it and remind you to do so if necessary. Success! The configuration is valid. tests/topology.tftest.hcl... pass run "topology"... pass run "reject_invalid_public_key"... pass run "reject_shell_in_domain"... pass run "reject_same_region"... pass Success! 4 passed, 0 failed. Bootstrap syntax, verification and startup order passed.
Intent
Continue and finish your original read-only review of the OpenTofu starter. Ignore unrelated messages. Your last output only acknowledged an unrelated message and did not give the review. Give the concrete findings now, based on your inspection; inspect more only if essential. No cloud operations, no writes.
Review the fixes now in the working tree relative to b3b24aa. Removed module depends_on and attached storage readiness to outputs; protected EC2 termination, retained root disks, added recovery alarms; storage cleanup ported from v0.28.15; added module input validation; AZs filtered/intersect instance offerings and existing subnet zone ignored; host metrics enabled; bootstrap resets umask before units. Strengthened cross-region mocks and IAM/TLS tests. All 14 test runs pass. Guide draft writing/readme/humane.md now explicitly states logs go to primary control bucket, console/sealed config/always-on Litestream remain setup work (not a full platform install), no live AWS apply tested. Confirm prior blockers resolved or name any remaining concrete defects. Do not expand scope to implementing missing console distribution. Read-only, no AWS calls. Keep response concise.
Final verification only: storage outputs now have all six readiness dependencies, instance lifecycle checks the existing subnet zone against instance type offerings, and humane.md uses YOUR_HOST_REGION plus explains retained volume cleanup. Inspect those exact fixes in /home/paulca/.cache/carlos-opentofu/worktree. State approve or remaining blocking issues. Do not edit files or respond to unrelated messages.
diff --git a/examples/single-host/main.tf b/examples/single-host/main.tf| index 1773f5b..3da39b8 100644 |
| --- a/examples/single-host/main.tf |
| +++ b/examples/single-host/main.tf |
| @@ -5,7 +5,8 @@ provider "aws" { |
| default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } } |
| } |
| module "deployment" { |
| - source = "../../modules/storage" |
| + control_plane = true |
| + source = "../../modules/storage" |
| name = "${var.name}-${var.aws_account_id}-platform" |
| } |
| @@ -30,7 +31,6 @@ module "region" { |
| apps_domain = var.apps_domain |
| instance_type = var.instance_type |
| root_volume_gib = var.root_volume_gib |
| - depends_on = [module.deployment, module.data] |
| } |
| output "hosts" { value = module.region.hosts } |
| output "deployment_bucket" { value = module.deployment.name } |
diff --git a/examples/single-host/tests/topology.tftest.hcl b/examples/single-host/tests/topology.tftest.hcl| index 897e5e8..c9960d1 100644 |
| --- a/examples/single-host/tests/topology.tftest.hcl |
| +++ b/examples/single-host/tests/topology.tftest.hcl |
| @@ -1,4 +1,5 @@ |
| mock_provider "aws" { |
| + mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_region" { defaults = { region = "eu-west-1" } } |
| mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } } |
diff --git a/examples/single-region/main.tf b/examples/single-region/main.tf| index 4c4ba2d..027c196 100644 |
| --- a/examples/single-region/main.tf |
| +++ b/examples/single-region/main.tf |
| @@ -5,7 +5,8 @@ provider "aws" { |
| default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } } |
| } |
| module "deployment" { |
| - source = "../../modules/storage" |
| + control_plane = true |
| + source = "../../modules/storage" |
| name = "${var.name}-${var.aws_account_id}-platform" |
| } |
| @@ -30,7 +31,6 @@ module "region" { |
| apps_domain = var.apps_domain |
| instance_type = var.instance_type |
| root_volume_gib = var.root_volume_gib |
| - depends_on = [module.deployment, module.data] |
| } |
| output "hosts" { value = module.region.hosts } |
| output "deployment_bucket" { value = module.deployment.name } |
diff --git a/examples/single-region/tests/topology.tftest.hcl b/examples/single-region/tests/topology.tftest.hcl| index 1943449..9cca6c3 100644 |
| --- a/examples/single-region/tests/topology.tftest.hcl |
| +++ b/examples/single-region/tests/topology.tftest.hcl |
| @@ -1,4 +1,5 @@ |
| mock_provider "aws" { |
| + mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_region" { defaults = { region = "eu-west-1" } } |
| mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } } |
diff --git a/examples/two-regions/main.tf b/examples/two-regions/main.tf| index 15ac50e..3197efa 100644 |
| --- a/examples/two-regions/main.tf |
| +++ b/examples/two-regions/main.tf |
| @@ -11,9 +11,10 @@ provider "aws" { |
| default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } } |
| } |
| module "deployment" { |
| - source = "../../modules/storage" |
| - providers = { aws = aws.primary } |
| - name = "${var.name}-${var.aws_account_id}-platform" |
| + control_plane = true |
| + source = "../../modules/storage" |
| + providers = { aws = aws.primary } |
| + name = "${var.name}-${var.aws_account_id}-platform" |
| } |
| module "primary_data" { |
| source = "../../modules/storage" |
| @@ -41,7 +42,6 @@ module "primary" { |
| apps_domain = var.apps_domain |
| instance_type = var.instance_type |
| root_volume_gib = var.root_volume_gib |
| - depends_on = [module.deployment, module.primary_data] |
| } |
| module "secondary" { |
| source = "../../modules/region" |
| @@ -59,7 +59,6 @@ module "secondary" { |
| apps_domain = var.apps_domain |
| instance_type = var.instance_type |
| root_volume_gib = var.root_volume_gib |
| - depends_on = [module.deployment, module.secondary_data] |
| } |
| output "hosts" { value = { primary = module.primary.hosts, secondary = module.secondary.hosts } } |
| output "deployment_bucket" { value = module.deployment.name } |
diff --git a/examples/two-regions/tests/topology.tftest.hcl b/examples/two-regions/tests/topology.tftest.hcl| index 39643b5..238043b 100644 |
| --- a/examples/two-regions/tests/topology.tftest.hcl |
| +++ b/examples/two-regions/tests/topology.tftest.hcl |
| @@ -1,4 +1,17 @@ |
| +override_resource { |
| + target = module.deployment.aws_s3_bucket.this |
| + values = { id = "test-control", arn = "arn:aws:s3:::test-control" } |
| +} |
| +override_resource { |
| + target = module.primary_data.aws_s3_bucket.this |
| + values = { id = "test-primary", arn = "arn:aws:s3:::test-primary" } |
| +} |
| +override_resource { |
| + target = module.secondary_data.aws_s3_bucket.this |
| + values = { id = "test-secondary", arn = "arn:aws:s3:::test-secondary" } |
| +} |
| mock_provider "aws" { |
| + mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } } |
| alias = "primary" |
| mock_data "aws_region" { defaults = { region = "eu-west-1" } } |
| mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } } |
| @@ -7,6 +20,7 @@ mock_provider "aws" { |
| mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } } |
| } |
| mock_provider "aws" { |
| + mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-2a", "eu-west-2b"] } } |
| alias = "secondary" |
| mock_data "aws_region" { defaults = { region = "eu-west-2" } } |
| mock_data "aws_availability_zones" { defaults = { names = ["eu-west-2a", "eu-west-2b"] } } |
| @@ -21,6 +35,14 @@ variables { |
| release_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" |
| } |
| run "topology" { |
| + assert { |
| + condition = toset(module.secondary.storage.object_resources) == toset(["arn:aws:s3:::test-control/*", "arn:aws:s3:::test-secondary/*"]) |
| + error_message = "The secondary region must not access the primary region's data." |
| + } |
| + assert { |
| + condition = module.secondary.storage.data_bucket == "test-secondary" && module.secondary.storage.data_region == "eu-west-2" && module.secondary.storage.deployment_region == "eu-west-1" && module.secondary.storage.deployment_bucket == "test-control" |
| + error_message = "Secondary-region storage and control-plane routing are crossed." |
| + } |
| command = plan |
| assert { |
| condition = module.primary.host_count == 1 && module.secondary.host_count == 1 |
diff --git a/modules/region/cloud-init.sh.tftpl b/modules/region/cloud-init.sh.tftpl| index 9851121..0b4ea06 100644 |
| --- a/modules/region/cloud-init.sh.tftpl |
| +++ b/modules/region/cloud-init.sh.tftpl |
| @@ -34,9 +34,11 @@ CARLOS_HIBERNATE_REGION=${local_region} |
| CARLOS_HIBERNATE_PREFIX=instances |
| PLATFORM_APPS_DOMAIN=${apps_domain} |
| CARLOS_SYSTEM_CHANNEL=stable |
| +CARLOS_HOST_METRICS=cloudwatch |
| ENVEOF |
| chown root:root /etc/carlos/host.env |
| chmod 0600 /etc/carlos/host.env |
| +umask 022 |
| /opt/carlos/carlos bootstrap |
| systemctl start carlos-edge |
diff --git a/modules/region/main.tf b/modules/region/main.tf| index 1f8dae5..bee24ea 100644 |
| --- a/modules/region/main.tf |
| +++ b/modules/region/main.tf |
| @@ -13,6 +13,10 @@ data "aws_region" "current" {} |
| data "aws_availability_zones" "available" { |
| state = "available" |
| + filter { |
| + name = "opt-in-status" |
| + values = ["opt-in-not-required"] |
| + } |
| } |
| data "aws_ami" "debian_arm64" { |
| @@ -37,11 +41,26 @@ data "aws_ami" "debian_arm64" { |
| } |
| } |
| +data "aws_ec2_instance_type_offerings" "hosts" { |
| + location_type = "availability-zone" |
| + filter { |
| + name = "instance-type" |
| + values = [var.instance_type] |
| + } |
| +} |
| + |
| locals { |
| - host_keys = [for i in range(var.host_count) : "host-${i}"] |
| + available_zones = sort(tolist(setintersection(toset(data.aws_availability_zones.available.names), toset(data.aws_ec2_instance_type_offerings.hosts.locations)))) |
| + host_keys = [for i in range(var.host_count) : "host-${i}"] |
| } |
| resource "aws_vpc" "this" { |
| + lifecycle { |
| + precondition { |
| + condition = length(local.available_zones) >= min(var.host_count, 2) |
| + error_message = "The chosen instance type needs one available zone for one host, or at least two zones for multiple hosts." |
| + } |
| + } |
| cidr_block = var.vpc_cidr |
| enable_dns_support = true |
| enable_dns_hostnames = true |
| @@ -73,11 +92,12 @@ resource "aws_route_table" "public" { |
| } |
| resource "aws_subnet" "public" { |
| + lifecycle { ignore_changes = [availability_zone] } |
| count = var.host_count |
| vpc_id = aws_vpc.this.id |
| cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) |
| - availability_zone = data.aws_availability_zones.available.names[count.index % length(data.aws_availability_zones.available.names)] |
| + availability_zone = try(local.available_zones[count.index % max(1, length(local.available_zones))], null) |
| map_public_ip_on_launch = true |
| tags = { |
| @@ -221,6 +241,7 @@ resource "aws_instance" "host" { |
| vpc_security_group_ids = [aws_security_group.host.id] |
| iam_instance_profile = aws_iam_instance_profile.host.name |
| associate_public_ip_address = true |
| + disable_api_termination = true |
| metadata_options { |
| http_endpoint = "enabled" |
| @@ -229,9 +250,10 @@ resource "aws_instance" "host" { |
| } |
| root_block_device { |
| - volume_type = "gp3" |
| - volume_size = var.root_volume_gib |
| - encrypted = true |
| + volume_type = "gp3" |
| + volume_size = var.root_volume_gib |
| + encrypted = true |
| + delete_on_termination = false |
| } |
| user_data = templatefile("${path.module}/cloud-init.sh.tftpl", { |
| @@ -259,6 +281,10 @@ resource "aws_instance" "host" { |
| lifecycle { |
| prevent_destroy = true |
| ignore_changes = [ami, user_data] |
| + precondition { |
| + condition = contains(data.aws_ec2_instance_type_offerings.hosts.locations, aws_subnet.public[tonumber(trimprefix(each.key, "host-"))].availability_zone) |
| + error_message = "The chosen instance type is unavailable in this host's existing availability zone." |
| + } |
| } |
| } |
| @@ -272,3 +298,17 @@ resource "aws_eip" "host" { |
| Name = "${var.name}-${each.key}" |
| } |
| } |
| + |
| +resource "aws_cloudwatch_metric_alarm" "recover" { |
| + for_each = aws_instance.host |
| + alarm_name = "${var.name}-${each.key}-recover" |
| + namespace = "AWS/EC2" |
| + metric_name = "StatusCheckFailed_System" |
| + statistic = "Maximum" |
| + period = 60 |
| + evaluation_periods = 2 |
| + threshold = 1 |
| + comparison_operator = "GreaterThanOrEqualToThreshold" |
| + alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"] |
| + dimensions = { InstanceId = each.value.id } |
| +} |
diff --git a/modules/region/outputs.tf b/modules/region/outputs.tf| index b9e70ca..89c702d 100644 |
| --- a/modules/region/outputs.tf |
| +++ b/modules/region/outputs.tf |
| @@ -11,3 +11,15 @@ output "hosts" { |
| output "host_count" { |
| value = length(aws_instance.host) |
| } |
| + |
| +output "storage" { |
| + value = { |
| + deployment_bucket = var.deployment_bucket_name |
| + deployment_region = var.deployment_region |
| + data_bucket = var.data_bucket_name |
| + data_region = data.aws_region.current.region |
| + object_resources = jsondecode(aws_iam_role_policy.buckets.policy).Statement[0].Resource |
| + } |
| +} |
| + |
| +output "role_arn" { value = aws_iam_role.host.arn } |
diff --git a/modules/region/tests/security.tftest.hcl b/modules/region/tests/security.tftest.hcl| index fb54d38..21be837 100644 |
| --- a/modules/region/tests/security.tftest.hcl |
| +++ b/modules/region/tests/security.tftest.hcl |
| @@ -1,4 +1,5 @@ |
| mock_provider "aws" { |
| + mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_region" { defaults = { region = "eu-west-1" } } |
| mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } } |
| @@ -20,6 +21,14 @@ variables { |
| apps_domain = "apps.example.com" |
| } |
| run "host_security" { |
| + assert { |
| + condition = alltrue([for h in aws_instance.host : h.disable_api_termination && !h.root_block_device[0].delete_on_termination]) && length(aws_cloudwatch_metric_alarm.recover) == 2 |
| + error_message = "Protect hosts from accidental termination and retain their disks." |
| + } |
| + assert { |
| + condition = toset(jsondecode(aws_iam_role_policy.buckets.policy).Statement[1].Resource) == toset(["arn:aws:s3:::test-platform", "arn:aws:s3:::test-data"]) |
| + error_message = "Bucket listing must stay within this deployment and region." |
| + } |
| command = plan |
| assert { |
| condition = alltrue([for h in aws_instance.host : h.metadata_options[0].http_tokens == "required" && h.root_block_device[0].encrypted]) |
| @@ -50,3 +59,9 @@ run "host_security" { |
| error_message = "Bootstrap must check downloads and must not carry a private signing key." |
| } |
| } |
| + |
| +run "reject_module_injection" { |
| + command = plan |
| + variables { data_bucket_name = "test-data\nENVEOF\necho bad" } |
| + expect_failures = [var.data_bucket_name] |
| +} |
diff --git a/modules/region/variables.tf b/modules/region/variables.tf| index 9305ae6..860f32e 100644 |
| --- a/modules/region/variables.tf |
| +++ b/modules/region/variables.tf |
| @@ -1,8 +1,16 @@ |
| variable "name" { |
| + validation { |
| + condition = can(regex("^[a-z][a-z0-9-]{1,45}[a-z0-9]$", var.name)) |
| + error_message = "Expected a lowercase resource name." |
| + } |
| type = string |
| } |
| variable "host_count" { |
| + validation { |
| + condition = var.host_count >= 1 && var.host_count <= 6 && floor(var.host_count) == var.host_count |
| + error_message = "Use 1–6 hosts." |
| + } |
| type = number |
| } |
| @@ -11,6 +19,10 @@ variable "vpc_cidr" { |
| } |
| variable "deployment_bucket_name" { |
| + validation { |
| + condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.deployment_bucket_name)) |
| + error_message = "Expected an S3 bucket name." |
| + } |
| type = string |
| } |
| @@ -19,10 +31,18 @@ variable "deployment_bucket_arn" { |
| } |
| variable "deployment_region" { |
| + validation { |
| + condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.deployment_region)) |
| + error_message = "Expected an AWS region name." |
| + } |
| type = string |
| } |
| variable "data_bucket_name" { |
| + validation { |
| + condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.data_bucket_name)) |
| + error_message = "Expected an S3 bucket name." |
| + } |
| type = string |
| } |
| @@ -31,14 +51,26 @@ variable "data_bucket_arn" { |
| } |
| variable "placement_region" { |
| + validation { |
| + condition = can(regex("^[a-z][a-z0-9-]{0,31}$", var.placement_region)) |
| + error_message = "Expected a lowercase placement region." |
| + } |
| type = string |
| } |
| variable "release_public_key" { |
| + validation { |
| + condition = can(regex("^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$", var.release_public_key)) |
| + error_message = "Expected a base64 32-byte Ed25519 public key." |
| + } |
| type = string |
| } |
| variable "apps_domain" { |
| + validation { |
| + condition = length(var.apps_domain) <= 253 && can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$", var.apps_domain)) |
| + error_message = "Expected a lowercase DNS domain without a scheme or path." |
| + } |
| type = string |
| } |
diff --git a/modules/storage/main.tf b/modules/storage/main.tf| index a6c64c3..abacf1f 100644 |
| --- a/modules/storage/main.tf |
| +++ b/modules/storage/main.tf |
| @@ -49,5 +49,70 @@ resource "aws_s3_bucket_policy" "this" { |
| }) |
| depends_on = [aws_s3_bucket_public_access_block.this] |
| } |
| -output "name" { value = aws_s3_bucket.this.id } |
| -output "arn" { value = aws_s3_bucket.this.arn } |
| +output "name" { |
| + value = aws_s3_bucket.this.id |
| + depends_on = [ |
| + aws_s3_bucket_versioning.this, |
| + aws_s3_bucket_server_side_encryption_configuration.this, |
| + aws_s3_bucket_public_access_block.this, |
| + aws_s3_bucket_ownership_controls.this, |
| + aws_s3_bucket_policy.this, |
| + aws_s3_bucket_lifecycle_configuration.this, |
| + ] |
| +} |
| +output "arn" { |
| + value = aws_s3_bucket.this.arn |
| + depends_on = [ |
| + aws_s3_bucket_versioning.this, |
| + aws_s3_bucket_server_side_encryption_configuration.this, |
| + aws_s3_bucket_public_access_block.this, |
| + aws_s3_bucket_ownership_controls.this, |
| + aws_s3_bucket_policy.this, |
| + aws_s3_bucket_lifecycle_configuration.this, |
| + ] |
| +} |
| + |
| +variable "control_plane" { |
| + type = bool |
| + default = false |
| +} |
| +resource "aws_s3_bucket_lifecycle_configuration" "this" { |
| + bucket = aws_s3_bucket.this.id |
| + rule { |
| + id = "abandoned-uploads" |
| + status = "Enabled" |
| + filter {} |
| + abort_incomplete_multipart_upload { days_after_initiation = 7 } |
| + } |
| + dynamic "rule" { |
| + for_each = var.control_plane ? [1] : [] |
| + content { |
| + id = "geoip-noncurrent" |
| + status = "Enabled" |
| + filter { prefix = "control/geoip/" } |
| + noncurrent_version_expiration { noncurrent_days = 1 } |
| + abort_incomplete_multipart_upload { days_after_initiation = 1 } |
| + } |
| + } |
| + dynamic "rule" { |
| + for_each = var.control_plane ? [1] : [] |
| + content { |
| + id = "db-reads-expire" |
| + status = "Enabled" |
| + filter { prefix = "db-reads/" } |
| + expiration { days = 1 } |
| + noncurrent_version_expiration { noncurrent_days = 1 } |
| + abort_incomplete_multipart_upload { days_after_initiation = 1 } |
| + } |
| + } |
| + dynamic "rule" { |
| + for_each = var.control_plane ? [1] : [] |
| + content { |
| + id = "db-reads-delete-markers" |
| + status = "Enabled" |
| + filter { prefix = "db-reads/" } |
| + expiration { expired_object_delete_marker = true } |
| + } |
| + } |
| + depends_on = [aws_s3_bucket_versioning.this] |
| +} |
diff --git a/modules/storage/tests/security.tftest.hcl b/modules/storage/tests/security.tftest.hcl| index b241e4c..f6a0caa 100644 |
| --- a/modules/storage/tests/security.tftest.hcl |
| +++ b/modules/storage/tests/security.tftest.hcl |
| @@ -1,4 +1,5 @@ |
| mock_provider "aws" { |
| + mock_data "aws_ec2_instance_type_offerings" { defaults = { locations = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_region" { defaults = { region = "eu-west-1" } } |
| mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b"] } } |
| mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } } |
| @@ -8,6 +9,14 @@ mock_provider "aws" { |
| variables { name = "test-bucket" } |
| run "storage_security" { |
| + assert { |
| + condition = jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Effect == "Deny" && jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Condition.Bool["aws:SecureTransport"] == "false" && jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Principal == "*" |
| + error_message = "Require TLS for all bucket access." |
| + } |
| + assert { |
| + condition = length(aws_s3_bucket_lifecycle_configuration.this.rule) == 1 && one(aws_s3_bucket_lifecycle_configuration.this.rule).abort_incomplete_multipart_upload[0].days_after_initiation == 7 |
| + error_message = "Data buckets must retain database versions and clean abandoned uploads only." |
| + } |
| command = plan |
| assert { |
| condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled" && !aws_s3_bucket.this.force_destroy |
| @@ -22,3 +31,16 @@ run "storage_security" { |
| error_message = "Encrypt stored objects." |
| } |
| } |
| + |
| +run "control_cleanup" { |
| + command = plan |
| + variables { control_plane = true } |
| + assert { |
| + condition = length(aws_s3_bucket_lifecycle_configuration.this.rule) == 4 |
| + error_message = "Control buckets need the Platform cleanup rules." |
| + } |
| + assert { |
| + condition = alltrue([for r in aws_s3_bucket_lifecycle_configuration.this.rule : r.expiration[0].days == 1 && r.noncurrent_version_expiration[0].noncurrent_days == 1 if r.id == "db-reads-expire"]) |
| + error_message = "Forwarded database reads must expire current and old versions." |
| + } |
| +} |