# region-remote-host: what an off-cloud CARLOS host (a Raspberry Pi today, # tito's hq box later) needs from AWS — a region data bucket plus a scoped # IAM principal, since there is no EC2 instance profile to lean on off-cloud. # # Residency is enforced the same way as region-host: the principal reaches # ITS OWN region's data bucket and nothing else. A wrong-region host fails # loudly at the IAM layer, same property as every cloud host. The deployment # bucket is grandfathered-optional (var.deployment_bucket_arn, default "") # — a fleet-API box needs no platform-bucket grant at all; see the # aws_iam_user_policy.host_deployment_bucket comment below. # # The data bucket follows the deployment-buckets convention for this class # of bucket (infra/modules/README.md, infra/modules/deployment-buckets/ # main.tf): boxes are disposable, this bucket is not, hence versioning, # AES256 SSE, a full public-access block, and prevent_destroy. terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = ">= 6.0" } } } resource "aws_s3_bucket" "data" { bucket = var.data_bucket_name lifecycle { prevent_destroy = true } } resource "aws_s3_bucket_versioning" "data" { bucket = aws_s3_bucket.data.id versioning_configuration { status = "Enabled" } } # Old versions in the data bucket expire (platform #336). The bucket is # versioned and litestream rewrites and compacts its files constantly, so # without this every replaced or deleted object lived on as a noncurrent # version forever: on 2026-10-24 one flagship data bucket held 14.5 GB of # current objects and was billed for 1,657 GB. A noncurrent version expires # data_noncurrent_days after it is replaced (7 by default, the restore # window), delete markers with nothing behind them go, and abandoned # multipart uploads go after 7 days. Current objects never expire. resource "aws_s3_bucket_lifecycle_configuration" "data" { bucket = aws_s3_bucket.data.id rule { id = "expire-noncurrent" status = "Enabled" filter {} noncurrent_version_expiration { noncurrent_days = var.data_noncurrent_days } expiration { expired_object_delete_marker = true } abort_incomplete_multipart_upload { days_after_initiation = 7 } } depends_on = [aws_s3_bucket_versioning.data] } resource "aws_s3_bucket_public_access_block" "data" { bucket = aws_s3_bucket.data.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_server_side_encryption_configuration" "data" { bucket = aws_s3_bucket.data.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } # The off-cloud host principal. No instance profile exists off EC2; this # user + access key is the C6 decision-6 shape (IAM Roles Anywhere is the # later hardening — the policy below is what either principal would carry). resource "aws_iam_user" "host" { name = "carlos-remote-host-${var.region_code}" } resource "aws_iam_access_key" "host" { user = aws_iam_user.host.name } # Scope: THIS region's data bucket, always — wrong-region-fails-at-IAM, same # property as every cloud host. # # The action list is not independently chosen: region-host's # aws_iam_role_policy.host_buckets (infra/modules/region-host/main.tf) is # the authority for what this principal shape may do to S3, and this # policy copies it verbatim — object actions (GetObject/PutObject/ # DeleteObject) scoped to the object ARNs, list/location actions # (ListBucket/GetBucketLocation) scoped to the bucket ARNs. resource "aws_iam_user_policy" "host" { name = "carlos-remote-host-${var.region_code}" user = aws_iam_user.host.name policy = jsonencode({ Version = "2012-10-17" # Sibling staging (instance-move spec, issue #331) — same push-only # moves/* shape as region-host's host_buckets policy; keep the two in # step (this module's README already carries that rule for the S3 # action list). Statement = concat([ { Sid = "OwnRegionDataObjects" Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] Resource = ["${aws_s3_bucket.data.arn}/*"] }, { Sid = "OwnRegionDataList" Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] Resource = [aws_s3_bucket.data.arn] }, # Host lifecycle claim, Plan 2 (spec C3, §6.2, §6.5): exact-version # list, read and delete on this box's OWN data bucket. Sibling-region # moves/* grants are unchanged: the destination region retires its # own staging (spec N13). { Sid = "PullDoorObjectVersions" Effect = "Allow" Action = ["s3:GetObjectVersion"] Resource = ["${aws_s3_bucket.data.arn}/*"] }, { Effect = "Allow" Action = ["s3:DeleteObjectVersion"] Resource = ["${aws_s3_bucket.data.arn}/*"] }, { Sid = "PullDoorVersionList" Effect = "Allow" Action = ["s3:ListBucketVersions"] Resource = [aws_s3_bucket.data.arn] }, ], [for arn in var.sibling_staging_arns : { Effect = "Allow" Action = ["s3:PutObject", "s3:DeleteObject"] Resource = "${arn}/moves/*" }], [for arn in var.sibling_staging_arns : { Effect = "Allow" Action = ["s3:ListBucket"] Resource = arn Condition = { StringLike = { "s3:prefix" = "moves/*" } } }] ) }) } # Deployment-bucket grant: GRANDFATHERED, direct-S3 boxes only. A fleet-API # box (issue #73) never needs this — it reaches the platform's deployment # bucket through the console's edge, over the same tunnel token it dials # with, so var.deployment_bucket_arn is left "" and this whole statement # (and the IAM policy resource carrying it) is omitted. count, not a dynamic # block, matches the same optional-statement pattern used by # aws_iam_role_policy.host_route53 in the region-host module. resource "aws_iam_user_policy" "host_deployment_bucket" { count = var.deployment_bucket_arn != "" ? 1 : 0 name = "carlos-remote-host-${var.region_code}-deployment-bucket" user = aws_iam_user.host.name policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "DeploymentBucketObjects" Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] Resource = ["${var.deployment_bucket_arn}/*"] }, { Sid = "DeploymentBucketList" Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] Resource = [var.deployment_bucket_arn] }, ] }) }