carlos / opentofu Public

Add three standalone OpenTofu host topologies and mocked validation

codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 27df16b15ec80ac94e9c246d99d228bfd2f3c5c0 parent 692f605
35 files changed, +1629 −0
  • .amadan/ci.d/10-validate +3 −0
  • .gitignore +11 −0
  • LICENSE +373 −0
  • examples/single-host/.terraform.lock.hcl +39 −0
  • examples/single-host/backend.tf.example +9 −0
  • examples/single-host/main.tf +37 −0
  • examples/single-host/terraform.tfvars.example +5 −0
  • examples/single-host/tests/topology.tftest.hcl +30 −0
  • examples/single-host/variables.tf +53 −0
  • examples/single-host/versions.tf +9 −0
  • examples/single-region/.terraform.lock.hcl +39 −0
  • examples/single-region/backend.tf.example +9 −0
  • examples/single-region/main.tf +37 −0
  • examples/single-region/terraform.tfvars.example +6 −0
  • examples/single-region/tests/topology.tftest.hcl +30 −0
  • examples/single-region/variables.tf +61 −0
  • examples/single-region/versions.tf +9 −0
  • examples/two-regions/.terraform.lock.hcl +39 −0
  • examples/two-regions/backend.tf.example +9 −0
  • examples/two-regions/main.tf +66 −0
  • examples/two-regions/terraform.tfvars.example +6 −0
  • examples/two-regions/tests/topology.tftest.hcl +44 −0
  • examples/two-regions/variables.tf +69 −0
  • examples/two-regions/versions.tf +9 −0
  • modules/region/.terraform.lock.hcl +39 −0
  • modules/region/cloud-init.sh.tftpl +42 −0
  • modules/region/main.tf +274 −0
  • modules/region/outputs.tf +13 −0
  • modules/region/tests/security.tftest.hcl +52 −0
  • modules/region/variables.tf +53 −0
  • modules/storage/.terraform.lock.hcl +39 −0
  • modules/storage/main.tf +53 −0
  • modules/storage/tests/security.tftest.hcl +24 −0
  • scripts/check +15 −0
  • scripts/check-bootstrap.py +23 −0
diff --git a/.amadan/ci.d/10-validate b/.amadan/ci.d/10-validate
new file mode 100755
index 0000000..53e1350
--- /dev/null
+++ b/.amadan/ci.d/10-validate
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+set -euo pipefail
+exec ./scripts/check
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fc146f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+.terraform/
+*.tfstate
+*.tfstate.*
+*.tfvars
+*.tfvars.json
+*.tfplan
+crash.log
+writing/
+.claude/
+*.env
+*.key
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..14e2f77
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/examples/single-host/.terraform.lock.hcl b/examples/single-host/.terraform.lock.hcl
new file mode 100644
index 0000000..ad3f496
--- /dev/null
+++ b/examples/single-host/.terraform.lock.hcl
@@ -0,0 +1,39 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.70.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:0ppSZxnkr2mXy+OT2fE6FDY8B84U5l77bpAIDyIzxq0=",
+ "h1:8B3+vezC72MHmr3u5F5UWjCG4OrpjfKMLnPDyxjvrSo=",
+ "h1:GMjGoPo8GZ5iafPgiBK2TVZ6uiwaDrPdIb8hrNGceus=",
+ "h1:GOgheaNYt+Q3RnZEl0aIQjp/fYLEZHlWkXvmLk+12EU=",
+ "h1:JtwMUJT134sVjOaDFY9FJh1Mpdd5rgTEfeP3+PvezxA=",
+ "h1:K2u8xN6vPcBA24fwoyh4glebpIXprMzf9Q+omP0ditA=",
+ "h1:Oua4xmevWH4nSpBpLNo3SYHFSMorAZtYYI0cndQPHDA=",
+ "h1:RCdEQN76x2oY6wsOWxMuD1/aL++bSEWnVbHAB+vAZa4=",
+ "h1:WPSxowy+7ffZgsKexf9VNGXL/SfoAEiB8oSfQ/BWr+s=",
+ "h1:YwOjwe/o7JFnVz9H/xyAEEgHMVCYFtuX0/97iYAZmsc=",
+ "h1:e3kiK7jos0/EN/Dc6U+vbKBU53knKU6ILErBv0zdGec=",
+ "h1:f/fWBs1fWquPmk03fimTUDc4d871DeMA29TTa8dyjXA=",
+ "h1:iSpEapMJUOzrewKXUa0FJICuC2cKmy6H1PNSHH4L1Hc=",
+ "h1:kMw0FvF0bvj1L7fCSPlg6BApfzkBbTITDBtplJ1Vtek=",
+ "h1:lPMlFm5WfsjVFaDOzMYxnfwcDqfRuHeesiIXUca9oJQ=",
+ "zh:0e28c73ffe0c518db0f1dbcf6260f95e4a423267ec1888d08bc0f7cb7c02fec1",
+ "zh:2b4c26d4570d03cc2ebbe43a74034286ee66c85f0bcf5cf52a4c8652f3bccaa2",
+ "zh:3957610bbc3e6271f37260ba4bea151a4e3e0b7884a9f0fc448ee277a3ab730b",
+ "zh:4160ed56e45fabf4c44d44dc8c66d90fa53d657920e2385fcbe46e8c9d43964d",
+ "zh:43feb0d865e14ecfbe2066a3ec49becd5673f9102efdf4f2dcee08ae7effaf72",
+ "zh:495fb8da6f917d9e860ca945a0daa63bed01c3df4edf2c86bfbd32a90c3a5b05",
+ "zh:49b0eae54f54f39f38cc06661d13e4be94c49f03fc8d5fbb559f7a131d09a3ab",
+ "zh:57acc6485c95e28fc485b28ee198bf88e8bf5128fab8dbca7078fc04704fe50a",
+ "zh:7155dcc1ebf0b91741d28c3d42702b2cf3caafd61301ff8f8cc7a7d3b928d0a3",
+ "zh:7df06c80fd4fc811a931bff684d880eb52c740703c59850a4ca41050bfd922f7",
+ "zh:9fc7ba5238666273af242908b95ac4524b65a6f7e6f1f8f8348db0a0d9d20d49",
+ "zh:a79639c84900d0b8475218a5fa777f7f2fbb3a2def192ab01a041387dd76a074",
+ "zh:b5b81330ceb68f36e20ba0a0a73aa123e47689eaddf35cebba2c5796d8d83808",
+ "zh:f1efc1ef4d5e483833cb8aded5109aa93b6b9d95e098d8479037304f92e776a9",
+ "zh:f225a0a1a7dfb770ea5bb75c6a81a0efca175f8e461f16424f5acff841b3ddaa",
+ ]
+}
diff --git a/examples/single-host/backend.tf.example b/examples/single-host/backend.tf.example
new file mode 100644
index 0000000..07058a0
--- /dev/null
+++ b/examples/single-host/backend.tf.example
@@ -0,0 +1,9 @@
+terraform {
+ backend "s3" {
+ bucket = "REPLACE_WITH_EXISTING_STATE_BUCKET"
+ key = "carlos/single-host/terraform.tfstate"
+ region = "eu-west-1"
+ encrypt = true
+ use_lockfile = true
+ }
+}
diff --git a/examples/single-host/main.tf b/examples/single-host/main.tf
new file mode 100644
index 0000000..1773f5b
--- /dev/null
+++ b/examples/single-host/main.tf
@@ -0,0 +1,37 @@
+provider "aws" {
+
+ region = var.region
+ allowed_account_ids = [var.aws_account_id]
+ default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } }
+}
+module "deployment" {
+ source = "../../modules/storage"
+
+ name = "${var.name}-${var.aws_account_id}-platform"
+}
+module "data" {
+ source = "../../modules/storage"
+
+ name = "${var.name}-${var.aws_account_id}-${var.region}-data"
+}
+module "region" {
+ source = "../../modules/region"
+
+ name = "${var.name}-${var.region}"
+ host_count = 1
+ vpc_cidr = "10.40.0.0/16"
+ deployment_bucket_name = module.deployment.name
+ deployment_bucket_arn = module.deployment.arn
+ deployment_region = var.region
+ data_bucket_name = module.data.name
+ data_bucket_arn = module.data.arn
+ placement_region = "primary"
+ release_public_key = var.release_public_key
+ 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 }
+output "data_bucket" { value = module.data.name }
diff --git a/examples/single-host/terraform.tfvars.example b/examples/single-host/terraform.tfvars.example
new file mode 100644
index 0000000..3c43075
--- /dev/null
+++ b/examples/single-host/terraform.tfvars.example
@@ -0,0 +1,5 @@
+aws_account_id = "123456789012"
+name = "carlos"
+apps_domain = "apps.example.com"
+release_public_key = "REPLACE_WITH_CARLOS_RELEASE_PUBKEY"
+region = "eu-west-1"
diff --git a/examples/single-host/tests/topology.tftest.hcl b/examples/single-host/tests/topology.tftest.hcl
new file mode 100644
index 0000000..897e5e8
--- /dev/null
+++ b/examples/single-host/tests/topology.tftest.hcl
@@ -0,0 +1,30 @@
+mock_provider "aws" {
+ 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" } }
+ mock_resource "aws_s3_bucket" { defaults = { arn = "arn:aws:s3:::mock-test-bucket", id = "mock-test-bucket" } }
+ mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } }
+}
+variables {
+ aws_account_id = "123456789012"
+ name = "test-carlos"
+ apps_domain = "apps.example.com"
+ release_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+}
+run "topology" {
+ command = plan
+ assert {
+ condition = module.region.host_count == 1
+ error_message = "Unexpected host count."
+ }
+}
+run "reject_invalid_public_key" {
+ command = plan
+ variables { release_public_key = "not-a-public-key" }
+ expect_failures = [var.release_public_key]
+}
+run "reject_shell_in_domain" {
+ command = plan
+ variables { apps_domain = "apps.example.com\nENVEOF\necho bad" }
+ expect_failures = [var.apps_domain]
+}
diff --git a/examples/single-host/variables.tf b/examples/single-host/variables.tf
new file mode 100644
index 0000000..6cb2977
--- /dev/null
+++ b/examples/single-host/variables.tf
@@ -0,0 +1,53 @@
+variable "aws_account_id" {
+ type = string
+ validation {
+ condition = can(regex("^[0-9]{12}$", var.aws_account_id))
+ error_message = "Expected a 12-digit AWS account ID."
+ }
+}
+variable "name" {
+ type = string
+ default = "carlos"
+ validation {
+ condition = can(regex("^[a-z][a-z0-9-]{1,18}[a-z0-9]$", var.name))
+ error_message = "Use 3–20 lowercase letters, digits or hyphens, starting with a letter and ending with a letter or digit."
+ }
+}
+variable "release_public_key" {
+ type = string
+ validation {
+ condition = can(regex("^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$", var.release_public_key))
+ error_message = "Expected a base64 32-byte Ed25519 public key."
+ }
+}
+variable "apps_domain" {
+ type = string
+ 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."
+ }
+}
+variable "instance_type" {
+ type = string
+ default = "t4g.small"
+ validation {
+ condition = can(regex("^(t4g|m[6-9]g|c[6-9]g|r[6-9]g)\\.", var.instance_type))
+ error_message = "Choose an ARM64 Graviton instance type."
+ }
+}
+variable "root_volume_gib" {
+ type = number
+ default = 20
+ validation {
+ condition = var.root_volume_gib >= 20 && floor(var.root_volume_gib) == var.root_volume_gib
+ error_message = "Use a whole number of GiB, at least 20."
+ }
+}
+variable "region" {
+ type = string
+ default = "eu-west-1"
+ validation {
+ condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.region))
+ error_message = "Expected an AWS commercial region name."
+ }
+}
diff --git a/examples/single-host/versions.tf b/examples/single-host/versions.tf
new file mode 100644
index 0000000..8ede02f
--- /dev/null
+++ b/examples/single-host/versions.tf
@@ -0,0 +1,9 @@
+terraform {
+ required_version = ">= 1.11.0, < 2.0.0"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
diff --git a/examples/single-region/.terraform.lock.hcl b/examples/single-region/.terraform.lock.hcl
new file mode 100644
index 0000000..ad3f496
--- /dev/null
+++ b/examples/single-region/.terraform.lock.hcl
@@ -0,0 +1,39 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.70.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:0ppSZxnkr2mXy+OT2fE6FDY8B84U5l77bpAIDyIzxq0=",
+ "h1:8B3+vezC72MHmr3u5F5UWjCG4OrpjfKMLnPDyxjvrSo=",
+ "h1:GMjGoPo8GZ5iafPgiBK2TVZ6uiwaDrPdIb8hrNGceus=",
+ "h1:GOgheaNYt+Q3RnZEl0aIQjp/fYLEZHlWkXvmLk+12EU=",
+ "h1:JtwMUJT134sVjOaDFY9FJh1Mpdd5rgTEfeP3+PvezxA=",
+ "h1:K2u8xN6vPcBA24fwoyh4glebpIXprMzf9Q+omP0ditA=",
+ "h1:Oua4xmevWH4nSpBpLNo3SYHFSMorAZtYYI0cndQPHDA=",
+ "h1:RCdEQN76x2oY6wsOWxMuD1/aL++bSEWnVbHAB+vAZa4=",
+ "h1:WPSxowy+7ffZgsKexf9VNGXL/SfoAEiB8oSfQ/BWr+s=",
+ "h1:YwOjwe/o7JFnVz9H/xyAEEgHMVCYFtuX0/97iYAZmsc=",
+ "h1:e3kiK7jos0/EN/Dc6U+vbKBU53knKU6ILErBv0zdGec=",
+ "h1:f/fWBs1fWquPmk03fimTUDc4d871DeMA29TTa8dyjXA=",
+ "h1:iSpEapMJUOzrewKXUa0FJICuC2cKmy6H1PNSHH4L1Hc=",
+ "h1:kMw0FvF0bvj1L7fCSPlg6BApfzkBbTITDBtplJ1Vtek=",
+ "h1:lPMlFm5WfsjVFaDOzMYxnfwcDqfRuHeesiIXUca9oJQ=",
+ "zh:0e28c73ffe0c518db0f1dbcf6260f95e4a423267ec1888d08bc0f7cb7c02fec1",
+ "zh:2b4c26d4570d03cc2ebbe43a74034286ee66c85f0bcf5cf52a4c8652f3bccaa2",
+ "zh:3957610bbc3e6271f37260ba4bea151a4e3e0b7884a9f0fc448ee277a3ab730b",
+ "zh:4160ed56e45fabf4c44d44dc8c66d90fa53d657920e2385fcbe46e8c9d43964d",
+ "zh:43feb0d865e14ecfbe2066a3ec49becd5673f9102efdf4f2dcee08ae7effaf72",
+ "zh:495fb8da6f917d9e860ca945a0daa63bed01c3df4edf2c86bfbd32a90c3a5b05",
+ "zh:49b0eae54f54f39f38cc06661d13e4be94c49f03fc8d5fbb559f7a131d09a3ab",
+ "zh:57acc6485c95e28fc485b28ee198bf88e8bf5128fab8dbca7078fc04704fe50a",
+ "zh:7155dcc1ebf0b91741d28c3d42702b2cf3caafd61301ff8f8cc7a7d3b928d0a3",
+ "zh:7df06c80fd4fc811a931bff684d880eb52c740703c59850a4ca41050bfd922f7",
+ "zh:9fc7ba5238666273af242908b95ac4524b65a6f7e6f1f8f8348db0a0d9d20d49",
+ "zh:a79639c84900d0b8475218a5fa777f7f2fbb3a2def192ab01a041387dd76a074",
+ "zh:b5b81330ceb68f36e20ba0a0a73aa123e47689eaddf35cebba2c5796d8d83808",
+ "zh:f1efc1ef4d5e483833cb8aded5109aa93b6b9d95e098d8479037304f92e776a9",
+ "zh:f225a0a1a7dfb770ea5bb75c6a81a0efca175f8e461f16424f5acff841b3ddaa",
+ ]
+}
diff --git a/examples/single-region/backend.tf.example b/examples/single-region/backend.tf.example
new file mode 100644
index 0000000..cc98bb2
--- /dev/null
+++ b/examples/single-region/backend.tf.example
@@ -0,0 +1,9 @@
+terraform {
+ backend "s3" {
+ bucket = "REPLACE_WITH_EXISTING_STATE_BUCKET"
+ key = "carlos/single-region/terraform.tfstate"
+ region = "eu-west-1"
+ encrypt = true
+ use_lockfile = true
+ }
+}
diff --git a/examples/single-region/main.tf b/examples/single-region/main.tf
new file mode 100644
index 0000000..4c4ba2d
--- /dev/null
+++ b/examples/single-region/main.tf
@@ -0,0 +1,37 @@
+provider "aws" {
+
+ region = var.region
+ allowed_account_ids = [var.aws_account_id]
+ default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } }
+}
+module "deployment" {
+ source = "../../modules/storage"
+
+ name = "${var.name}-${var.aws_account_id}-platform"
+}
+module "data" {
+ source = "../../modules/storage"
+
+ name = "${var.name}-${var.aws_account_id}-${var.region}-data"
+}
+module "region" {
+ source = "../../modules/region"
+
+ name = "${var.name}-${var.region}"
+ host_count = var.host_count
+ vpc_cidr = "10.40.0.0/16"
+ deployment_bucket_name = module.deployment.name
+ deployment_bucket_arn = module.deployment.arn
+ deployment_region = var.region
+ data_bucket_name = module.data.name
+ data_bucket_arn = module.data.arn
+ placement_region = "primary"
+ release_public_key = var.release_public_key
+ 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 }
+output "data_bucket" { value = module.data.name }
diff --git a/examples/single-region/terraform.tfvars.example b/examples/single-region/terraform.tfvars.example
new file mode 100644
index 0000000..513684a
--- /dev/null
+++ b/examples/single-region/terraform.tfvars.example
@@ -0,0 +1,6 @@
+aws_account_id = "123456789012"
+name = "carlos"
+apps_domain = "apps.example.com"
+release_public_key = "REPLACE_WITH_CARLOS_RELEASE_PUBKEY"
+region = "eu-west-1"
+host_count = 2
diff --git a/examples/single-region/tests/topology.tftest.hcl b/examples/single-region/tests/topology.tftest.hcl
new file mode 100644
index 0000000..1943449
--- /dev/null
+++ b/examples/single-region/tests/topology.tftest.hcl
@@ -0,0 +1,30 @@
+mock_provider "aws" {
+ 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" } }
+ mock_resource "aws_s3_bucket" { defaults = { arn = "arn:aws:s3:::mock-test-bucket", id = "mock-test-bucket" } }
+ mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } }
+}
+variables {
+ aws_account_id = "123456789012"
+ name = "test-carlos"
+ apps_domain = "apps.example.com"
+ release_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+}
+run "topology" {
+ command = plan
+ assert {
+ condition = module.region.host_count == 2
+ error_message = "Unexpected host count."
+ }
+}
+run "reject_invalid_public_key" {
+ command = plan
+ variables { release_public_key = "not-a-public-key" }
+ expect_failures = [var.release_public_key]
+}
+run "reject_shell_in_domain" {
+ command = plan
+ variables { apps_domain = "apps.example.com\nENVEOF\necho bad" }
+ expect_failures = [var.apps_domain]
+}
diff --git a/examples/single-region/variables.tf b/examples/single-region/variables.tf
new file mode 100644
index 0000000..730fa7c
--- /dev/null
+++ b/examples/single-region/variables.tf
@@ -0,0 +1,61 @@
+variable "aws_account_id" {
+ type = string
+ validation {
+ condition = can(regex("^[0-9]{12}$", var.aws_account_id))
+ error_message = "Expected a 12-digit AWS account ID."
+ }
+}
+variable "name" {
+ type = string
+ default = "carlos"
+ validation {
+ condition = can(regex("^[a-z][a-z0-9-]{1,18}[a-z0-9]$", var.name))
+ error_message = "Use 3–20 lowercase letters, digits or hyphens, starting with a letter and ending with a letter or digit."
+ }
+}
+variable "release_public_key" {
+ type = string
+ validation {
+ condition = can(regex("^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$", var.release_public_key))
+ error_message = "Expected a base64 32-byte Ed25519 public key."
+ }
+}
+variable "apps_domain" {
+ type = string
+ 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."
+ }
+}
+variable "instance_type" {
+ type = string
+ default = "t4g.small"
+ validation {
+ condition = can(regex("^(t4g|m[6-9]g|c[6-9]g|r[6-9]g)\\.", var.instance_type))
+ error_message = "Choose an ARM64 Graviton instance type."
+ }
+}
+variable "root_volume_gib" {
+ type = number
+ default = 20
+ validation {
+ condition = var.root_volume_gib >= 20 && floor(var.root_volume_gib) == var.root_volume_gib
+ error_message = "Use a whole number of GiB, at least 20."
+ }
+}
+variable "region" {
+ type = string
+ default = "eu-west-1"
+ validation {
+ condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.region))
+ error_message = "Expected an AWS commercial region name."
+ }
+}
+variable "host_count" {
+ type = number
+ default = 2
+ validation {
+ condition = var.host_count >= 2 && var.host_count <= 6 && floor(var.host_count) == var.host_count
+ error_message = "Use 2–6 hosts in this example."
+ }
+}
diff --git a/examples/single-region/versions.tf b/examples/single-region/versions.tf
new file mode 100644
index 0000000..8ede02f
--- /dev/null
+++ b/examples/single-region/versions.tf
@@ -0,0 +1,9 @@
+terraform {
+ required_version = ">= 1.11.0, < 2.0.0"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
diff --git a/examples/two-regions/.terraform.lock.hcl b/examples/two-regions/.terraform.lock.hcl
new file mode 100644
index 0000000..ad3f496
--- /dev/null
+++ b/examples/two-regions/.terraform.lock.hcl
@@ -0,0 +1,39 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.70.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:0ppSZxnkr2mXy+OT2fE6FDY8B84U5l77bpAIDyIzxq0=",
+ "h1:8B3+vezC72MHmr3u5F5UWjCG4OrpjfKMLnPDyxjvrSo=",
+ "h1:GMjGoPo8GZ5iafPgiBK2TVZ6uiwaDrPdIb8hrNGceus=",
+ "h1:GOgheaNYt+Q3RnZEl0aIQjp/fYLEZHlWkXvmLk+12EU=",
+ "h1:JtwMUJT134sVjOaDFY9FJh1Mpdd5rgTEfeP3+PvezxA=",
+ "h1:K2u8xN6vPcBA24fwoyh4glebpIXprMzf9Q+omP0ditA=",
+ "h1:Oua4xmevWH4nSpBpLNo3SYHFSMorAZtYYI0cndQPHDA=",
+ "h1:RCdEQN76x2oY6wsOWxMuD1/aL++bSEWnVbHAB+vAZa4=",
+ "h1:WPSxowy+7ffZgsKexf9VNGXL/SfoAEiB8oSfQ/BWr+s=",
+ "h1:YwOjwe/o7JFnVz9H/xyAEEgHMVCYFtuX0/97iYAZmsc=",
+ "h1:e3kiK7jos0/EN/Dc6U+vbKBU53knKU6ILErBv0zdGec=",
+ "h1:f/fWBs1fWquPmk03fimTUDc4d871DeMA29TTa8dyjXA=",
+ "h1:iSpEapMJUOzrewKXUa0FJICuC2cKmy6H1PNSHH4L1Hc=",
+ "h1:kMw0FvF0bvj1L7fCSPlg6BApfzkBbTITDBtplJ1Vtek=",
+ "h1:lPMlFm5WfsjVFaDOzMYxnfwcDqfRuHeesiIXUca9oJQ=",
+ "zh:0e28c73ffe0c518db0f1dbcf6260f95e4a423267ec1888d08bc0f7cb7c02fec1",
+ "zh:2b4c26d4570d03cc2ebbe43a74034286ee66c85f0bcf5cf52a4c8652f3bccaa2",
+ "zh:3957610bbc3e6271f37260ba4bea151a4e3e0b7884a9f0fc448ee277a3ab730b",
+ "zh:4160ed56e45fabf4c44d44dc8c66d90fa53d657920e2385fcbe46e8c9d43964d",
+ "zh:43feb0d865e14ecfbe2066a3ec49becd5673f9102efdf4f2dcee08ae7effaf72",
+ "zh:495fb8da6f917d9e860ca945a0daa63bed01c3df4edf2c86bfbd32a90c3a5b05",
+ "zh:49b0eae54f54f39f38cc06661d13e4be94c49f03fc8d5fbb559f7a131d09a3ab",
+ "zh:57acc6485c95e28fc485b28ee198bf88e8bf5128fab8dbca7078fc04704fe50a",
+ "zh:7155dcc1ebf0b91741d28c3d42702b2cf3caafd61301ff8f8cc7a7d3b928d0a3",
+ "zh:7df06c80fd4fc811a931bff684d880eb52c740703c59850a4ca41050bfd922f7",
+ "zh:9fc7ba5238666273af242908b95ac4524b65a6f7e6f1f8f8348db0a0d9d20d49",
+ "zh:a79639c84900d0b8475218a5fa777f7f2fbb3a2def192ab01a041387dd76a074",
+ "zh:b5b81330ceb68f36e20ba0a0a73aa123e47689eaddf35cebba2c5796d8d83808",
+ "zh:f1efc1ef4d5e483833cb8aded5109aa93b6b9d95e098d8479037304f92e776a9",
+ "zh:f225a0a1a7dfb770ea5bb75c6a81a0efca175f8e461f16424f5acff841b3ddaa",
+ ]
+}
diff --git a/examples/two-regions/backend.tf.example b/examples/two-regions/backend.tf.example
new file mode 100644
index 0000000..df05b54
--- /dev/null
+++ b/examples/two-regions/backend.tf.example
@@ -0,0 +1,9 @@
+terraform {
+ backend "s3" {
+ bucket = "REPLACE_WITH_EXISTING_STATE_BUCKET"
+ key = "carlos/two-regions/terraform.tfstate"
+ region = "eu-west-1"
+ encrypt = true
+ use_lockfile = true
+ }
+}
diff --git a/examples/two-regions/main.tf b/examples/two-regions/main.tf
new file mode 100644
index 0000000..15ac50e
--- /dev/null
+++ b/examples/two-regions/main.tf
@@ -0,0 +1,66 @@
+provider "aws" {
+ alias = "primary"
+ region = var.primary_region
+ allowed_account_ids = [var.aws_account_id]
+ default_tags { tags = { Project = var.name, ManagedBy = "OpenTofu" } }
+}
+provider "aws" {
+ alias = "secondary"
+ region = var.secondary_region
+ allowed_account_ids = [var.aws_account_id]
+ 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"
+}
+module "primary_data" {
+ source = "../../modules/storage"
+ providers = { aws = aws.primary }
+ name = "${var.name}-${var.aws_account_id}-${var.primary_region}-data"
+}
+module "secondary_data" {
+ source = "../../modules/storage"
+ providers = { aws = aws.secondary }
+ name = "${var.name}-${var.aws_account_id}-${var.secondary_region}-data"
+}
+module "primary" {
+ source = "../../modules/region"
+ providers = { aws = aws.primary }
+ name = "${var.name}-${var.primary_region}"
+ host_count = 1
+ vpc_cidr = "10.40.0.0/16"
+ deployment_bucket_name = module.deployment.name
+ deployment_bucket_arn = module.deployment.arn
+ deployment_region = var.primary_region
+ data_bucket_name = module.primary_data.name
+ data_bucket_arn = module.primary_data.arn
+ placement_region = "primary"
+ release_public_key = var.release_public_key
+ 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"
+ providers = { aws = aws.secondary }
+ name = "${var.name}-${var.secondary_region}"
+ host_count = 1
+ vpc_cidr = "10.41.0.0/16"
+ deployment_bucket_name = module.deployment.name
+ deployment_bucket_arn = module.deployment.arn
+ deployment_region = var.primary_region
+ data_bucket_name = module.secondary_data.name
+ data_bucket_arn = module.secondary_data.arn
+ placement_region = "secondary"
+ release_public_key = var.release_public_key
+ 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 }
+output "data_buckets" { value = { primary = module.primary_data.name, secondary = module.secondary_data.name } }
diff --git a/examples/two-regions/terraform.tfvars.example b/examples/two-regions/terraform.tfvars.example
new file mode 100644
index 0000000..388cc8b
--- /dev/null
+++ b/examples/two-regions/terraform.tfvars.example
@@ -0,0 +1,6 @@
+aws_account_id = "123456789012"
+name = "carlos"
+apps_domain = "apps.example.com"
+release_public_key = "REPLACE_WITH_CARLOS_RELEASE_PUBKEY"
+primary_region = "eu-west-1"
+secondary_region = "eu-west-2"
diff --git a/examples/two-regions/tests/topology.tftest.hcl b/examples/two-regions/tests/topology.tftest.hcl
new file mode 100644
index 0000000..39643b5
--- /dev/null
+++ b/examples/two-regions/tests/topology.tftest.hcl
@@ -0,0 +1,44 @@
+mock_provider "aws" {
+ alias = "primary"
+ 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" } }
+ mock_resource "aws_s3_bucket" { defaults = { arn = "arn:aws:s3:::mock-test-bucket", id = "mock-test-bucket" } }
+ mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } }
+}
+mock_provider "aws" {
+ alias = "secondary"
+ mock_data "aws_region" { defaults = { region = "eu-west-2" } }
+ mock_data "aws_availability_zones" { defaults = { names = ["eu-west-2a", "eu-west-2b"] } }
+ mock_data "aws_ami" { defaults = { id = "ami-0123456789abcdef0" } }
+ mock_resource "aws_s3_bucket" { defaults = { arn = "arn:aws:s3:::mock-test-bucket", id = "mock-test-bucket" } }
+ mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } }
+}
+variables {
+ aws_account_id = "123456789012"
+ name = "test-carlos"
+ apps_domain = "apps.example.com"
+ release_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+}
+run "topology" {
+ command = plan
+ assert {
+ condition = module.primary.host_count == 1 && module.secondary.host_count == 1
+ error_message = "Unexpected host count."
+ }
+}
+run "reject_invalid_public_key" {
+ command = plan
+ variables { release_public_key = "not-a-public-key" }
+ expect_failures = [var.release_public_key]
+}
+run "reject_shell_in_domain" {
+ command = plan
+ variables { apps_domain = "apps.example.com\nENVEOF\necho bad" }
+ expect_failures = [var.apps_domain]
+}
+run "reject_same_region" {
+ command = plan
+ variables { secondary_region = "eu-west-1" }
+ expect_failures = [terraform_data.topology]
+}
diff --git a/examples/two-regions/variables.tf b/examples/two-regions/variables.tf
new file mode 100644
index 0000000..b7cfdd8
--- /dev/null
+++ b/examples/two-regions/variables.tf
@@ -0,0 +1,69 @@
+variable "aws_account_id" {
+ type = string
+ validation {
+ condition = can(regex("^[0-9]{12}$", var.aws_account_id))
+ error_message = "Expected a 12-digit AWS account ID."
+ }
+}
+variable "name" {
+ type = string
+ default = "carlos"
+ validation {
+ condition = can(regex("^[a-z][a-z0-9-]{1,18}[a-z0-9]$", var.name))
+ error_message = "Use 3–20 lowercase letters, digits or hyphens, starting with a letter and ending with a letter or digit."
+ }
+}
+variable "release_public_key" {
+ type = string
+ validation {
+ condition = can(regex("^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$", var.release_public_key))
+ error_message = "Expected a base64 32-byte Ed25519 public key."
+ }
+}
+variable "apps_domain" {
+ type = string
+ 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."
+ }
+}
+variable "instance_type" {
+ type = string
+ default = "t4g.small"
+ validation {
+ condition = can(regex("^(t4g|m[6-9]g|c[6-9]g|r[6-9]g)\\.", var.instance_type))
+ error_message = "Choose an ARM64 Graviton instance type."
+ }
+}
+variable "root_volume_gib" {
+ type = number
+ default = 20
+ validation {
+ condition = var.root_volume_gib >= 20 && floor(var.root_volume_gib) == var.root_volume_gib
+ error_message = "Use a whole number of GiB, at least 20."
+ }
+}
+variable "primary_region" {
+ type = string
+ default = "eu-west-1"
+ validation {
+ condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.primary_region))
+ error_message = "Expected an AWS commercial region name."
+ }
+}
+variable "secondary_region" {
+ type = string
+ default = "eu-west-2"
+ validation {
+ condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.secondary_region))
+ error_message = "Expected an AWS commercial region name."
+ }
+}
+resource "terraform_data" "topology" {
+ lifecycle {
+ precondition {
+ condition = var.primary_region != var.secondary_region
+ error_message = "Choose two different AWS regions."
+ }
+ }
+}
diff --git a/examples/two-regions/versions.tf b/examples/two-regions/versions.tf
new file mode 100644
index 0000000..8ede02f
--- /dev/null
+++ b/examples/two-regions/versions.tf
@@ -0,0 +1,9 @@
+terraform {
+ required_version = ">= 1.11.0, < 2.0.0"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
diff --git a/modules/region/.terraform.lock.hcl b/modules/region/.terraform.lock.hcl
new file mode 100644
index 0000000..ad3f496
--- /dev/null
+++ b/modules/region/.terraform.lock.hcl
@@ -0,0 +1,39 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.70.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:0ppSZxnkr2mXy+OT2fE6FDY8B84U5l77bpAIDyIzxq0=",
+ "h1:8B3+vezC72MHmr3u5F5UWjCG4OrpjfKMLnPDyxjvrSo=",
+ "h1:GMjGoPo8GZ5iafPgiBK2TVZ6uiwaDrPdIb8hrNGceus=",
+ "h1:GOgheaNYt+Q3RnZEl0aIQjp/fYLEZHlWkXvmLk+12EU=",
+ "h1:JtwMUJT134sVjOaDFY9FJh1Mpdd5rgTEfeP3+PvezxA=",
+ "h1:K2u8xN6vPcBA24fwoyh4glebpIXprMzf9Q+omP0ditA=",
+ "h1:Oua4xmevWH4nSpBpLNo3SYHFSMorAZtYYI0cndQPHDA=",
+ "h1:RCdEQN76x2oY6wsOWxMuD1/aL++bSEWnVbHAB+vAZa4=",
+ "h1:WPSxowy+7ffZgsKexf9VNGXL/SfoAEiB8oSfQ/BWr+s=",
+ "h1:YwOjwe/o7JFnVz9H/xyAEEgHMVCYFtuX0/97iYAZmsc=",
+ "h1:e3kiK7jos0/EN/Dc6U+vbKBU53knKU6ILErBv0zdGec=",
+ "h1:f/fWBs1fWquPmk03fimTUDc4d871DeMA29TTa8dyjXA=",
+ "h1:iSpEapMJUOzrewKXUa0FJICuC2cKmy6H1PNSHH4L1Hc=",
+ "h1:kMw0FvF0bvj1L7fCSPlg6BApfzkBbTITDBtplJ1Vtek=",
+ "h1:lPMlFm5WfsjVFaDOzMYxnfwcDqfRuHeesiIXUca9oJQ=",
+ "zh:0e28c73ffe0c518db0f1dbcf6260f95e4a423267ec1888d08bc0f7cb7c02fec1",
+ "zh:2b4c26d4570d03cc2ebbe43a74034286ee66c85f0bcf5cf52a4c8652f3bccaa2",
+ "zh:3957610bbc3e6271f37260ba4bea151a4e3e0b7884a9f0fc448ee277a3ab730b",
+ "zh:4160ed56e45fabf4c44d44dc8c66d90fa53d657920e2385fcbe46e8c9d43964d",
+ "zh:43feb0d865e14ecfbe2066a3ec49becd5673f9102efdf4f2dcee08ae7effaf72",
+ "zh:495fb8da6f917d9e860ca945a0daa63bed01c3df4edf2c86bfbd32a90c3a5b05",
+ "zh:49b0eae54f54f39f38cc06661d13e4be94c49f03fc8d5fbb559f7a131d09a3ab",
+ "zh:57acc6485c95e28fc485b28ee198bf88e8bf5128fab8dbca7078fc04704fe50a",
+ "zh:7155dcc1ebf0b91741d28c3d42702b2cf3caafd61301ff8f8cc7a7d3b928d0a3",
+ "zh:7df06c80fd4fc811a931bff684d880eb52c740703c59850a4ca41050bfd922f7",
+ "zh:9fc7ba5238666273af242908b95ac4524b65a6f7e6f1f8f8348db0a0d9d20d49",
+ "zh:a79639c84900d0b8475218a5fa777f7f2fbb3a2def192ab01a041387dd76a074",
+ "zh:b5b81330ceb68f36e20ba0a0a73aa123e47689eaddf35cebba2c5796d8d83808",
+ "zh:f1efc1ef4d5e483833cb8aded5109aa93b6b9d95e098d8479037304f92e776a9",
+ "zh:f225a0a1a7dfb770ea5bb75c6a81a0efca175f8e461f16424f5acff841b3ddaa",
+ ]
+}
diff --git a/modules/region/cloud-init.sh.tftpl b/modules/region/cloud-init.sh.tftpl
new file mode 100644
index 0000000..9851121
--- /dev/null
+++ b/modules/region/cloud-init.sh.tftpl
@@ -0,0 +1,42 @@
+#!/bin/bash
+set -euo pipefail
+umask 077
+
+export DEBIAN_FRONTEND=noninteractive
+apt-get update
+apt-get install -y curl ca-certificates
+
+stage="$(mktemp -d /var/lib/carlos-stage.XXXXXX)"
+trap 'rm -rf "$stage"' EXIT
+
+curl --retry 5 --retry-delay 3 -fsSL -o "$stage/amazon-ssm-agent.deb" \
+ "https://s3.${local_region}.amazonaws.com/amazon-ssm-${local_region}/3.3.5538.0/debian_arm64/amazon-ssm-agent.deb"
+echo "614e40869848a37427101f9965d2a93d463c19eb8ec652800a4f767fc664d3e2 $stage/amazon-ssm-agent.deb" | sha256sum -c -
+dpkg -i "$stage/amazon-ssm-agent.deb"
+systemctl enable --now amazon-ssm-agent
+
+curl --retry 5 --retry-delay 3 -fsSL -o "$stage/carlos" \
+ "https://github.com/carlosframework/releases/releases/download/v0.28.15/carlos-linux-arm64"
+echo "6587576f1aefae5887cae522770b0238e205f0b56b5ffe94de436ac52190e0dd $stage/carlos" | sha256sum -c -
+
+install -d -m 0755 /opt/carlos
+install -m 0755 "$stage/carlos" /opt/carlos/carlos
+
+install -d -m 0755 /etc/carlos
+cat > /etc/carlos/host.env <<'ENVEOF'
+CARLOS_DEPLOYMENT_BUCKET=${deployment_bucket_name}
+AWS_REGION=${deployment_region}
+AWS_DEFAULT_REGION=${deployment_region}
+CARLOS_RELEASE_PUBKEY=${release_public_key}
+CARLOS_REGION=${placement_region}
+CARLOS_HIBERNATE_BUCKET=${data_bucket_name}
+CARLOS_HIBERNATE_REGION=${local_region}
+CARLOS_HIBERNATE_PREFIX=instances
+PLATFORM_APPS_DOMAIN=${apps_domain}
+CARLOS_SYSTEM_CHANNEL=stable
+ENVEOF
+chown root:root /etc/carlos/host.env
+chmod 0600 /etc/carlos/host.env
+
+/opt/carlos/carlos bootstrap
+systemctl start carlos-edge
diff --git a/modules/region/main.tf b/modules/region/main.tf
new file mode 100644
index 0000000..1f8dae5
--- /dev/null
+++ b/modules/region/main.tf
@@ -0,0 +1,274 @@
+terraform {
+ required_version = ">= 1.11, < 2.0"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
+
+data "aws_region" "current" {}
+
+data "aws_availability_zones" "available" {
+ state = "available"
+}
+
+data "aws_ami" "debian_arm64" {
+ most_recent = true
+ owners = ["136693071363"]
+
+ filter {
+ name = "name"
+ values = ["debian-13-arm64-*"]
+ }
+ filter {
+ name = "virtualization-type"
+ values = ["hvm"]
+ }
+ filter {
+ name = "root-device-type"
+ values = ["ebs"]
+ }
+ filter {
+ name = "architecture"
+ values = ["arm64"]
+ }
+}
+
+locals {
+ host_keys = [for i in range(var.host_count) : "host-${i}"]
+}
+
+resource "aws_vpc" "this" {
+ cidr_block = var.vpc_cidr
+ enable_dns_support = true
+ enable_dns_hostnames = true
+
+ tags = {
+ Name = var.name
+ }
+}
+
+resource "aws_internet_gateway" "this" {
+ vpc_id = aws_vpc.this.id
+
+ tags = {
+ Name = var.name
+ }
+}
+
+resource "aws_route_table" "public" {
+ vpc_id = aws_vpc.this.id
+
+ route {
+ cidr_block = "0.0.0.0/0"
+ gateway_id = aws_internet_gateway.this.id
+ }
+
+ tags = {
+ Name = "${var.name}-public"
+ }
+}
+
+resource "aws_subnet" "public" {
+ 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)]
+ map_public_ip_on_launch = true
+
+ tags = {
+ Name = "${var.name}-public-${count.index}"
+ }
+}
+
+resource "aws_route_table_association" "public" {
+ count = var.host_count
+
+ subnet_id = aws_subnet.public[count.index].id
+ route_table_id = aws_route_table.public.id
+}
+
+resource "aws_security_group" "host" {
+ name = "${var.name}-host"
+ description = "HTTP and HTTPS ingress, all egress"
+ vpc_id = aws_vpc.this.id
+
+ ingress {
+ description = "HTTP"
+ from_port = 80
+ to_port = 80
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ ingress {
+ description = "HTTPS"
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ tags = {
+ Name = "${var.name}-host"
+ }
+}
+
+resource "aws_iam_role" "host" {
+ name = "${var.name}-host"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = { Service = "ec2.amazonaws.com" }
+ Action = "sts:AssumeRole"
+ }]
+ })
+}
+
+resource "aws_iam_role_policy" "buckets" {
+ name = "buckets"
+ role = aws_iam_role.host.id
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "ObjectAccess"
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
+ Resource = [
+ "${var.deployment_bucket_arn}/*",
+ "${var.data_bucket_arn}/*",
+ ]
+ },
+ {
+ Sid = "BucketAccess"
+ Effect = "Allow"
+ Action = ["s3:ListBucket", "s3:GetBucketLocation"]
+ Resource = [var.deployment_bucket_arn, var.data_bucket_arn]
+ },
+ ]
+ })
+}
+
+resource "aws_iam_role_policy" "metrics" {
+ name = "metrics"
+ role = aws_iam_role.host.id
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Sid = "PutHostMetrics"
+ Effect = "Allow"
+ Action = "cloudwatch:PutMetricData"
+ Resource = "*"
+ Condition = {
+ StringEquals = { "cloudwatch:namespace" = "Carlos/Host" }
+ }
+ }]
+ })
+}
+
+resource "aws_iam_role_policy" "deny_parameters" {
+ name = "deny-ssm-parameters"
+ role = aws_iam_role.host.id
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Sid = "DenyParameterReads"
+ Effect = "Deny"
+ Action = [
+ "ssm:GetParameter",
+ "ssm:GetParameters",
+ "ssm:GetParameterHistory",
+ "ssm:GetParametersByPath",
+ ]
+ Resource = "*"
+ }]
+ })
+}
+
+resource "aws_iam_role_policy_attachment" "ssm_core" {
+ role = aws_iam_role.host.name
+ policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "host" {
+ name = "${var.name}-host"
+ role = aws_iam_role.host.name
+}
+
+resource "aws_instance" "host" {
+ for_each = toset(local.host_keys)
+
+ ami = data.aws_ami.debian_arm64.id
+ instance_type = var.instance_type
+ subnet_id = aws_subnet.public[tonumber(trimprefix(each.key, "host-"))].id
+ vpc_security_group_ids = [aws_security_group.host.id]
+ iam_instance_profile = aws_iam_instance_profile.host.name
+ associate_public_ip_address = true
+
+ metadata_options {
+ http_endpoint = "enabled"
+ http_tokens = "required"
+ http_put_response_hop_limit = 1
+ }
+
+ root_block_device {
+ volume_type = "gp3"
+ volume_size = var.root_volume_gib
+ encrypted = true
+ }
+
+ user_data = templatefile("${path.module}/cloud-init.sh.tftpl", {
+ local_region = data.aws_region.current.region
+ deployment_bucket_name = var.deployment_bucket_name
+ deployment_region = var.deployment_region
+ data_bucket_name = var.data_bucket_name
+ placement_region = var.placement_region
+ release_public_key = var.release_public_key
+ apps_domain = var.apps_domain
+ })
+
+ tags = {
+ Name = "${var.name}-${each.key}"
+ }
+
+ depends_on = [
+ aws_route_table_association.public,
+ aws_iam_role_policy.buckets,
+ aws_iam_role_policy.metrics,
+ aws_iam_role_policy.deny_parameters,
+ aws_iam_role_policy_attachment.ssm_core,
+ ]
+
+ lifecycle {
+ prevent_destroy = true
+ ignore_changes = [ami, user_data]
+ }
+}
+
+resource "aws_eip" "host" {
+ for_each = aws_instance.host
+
+ domain = "vpc"
+ instance = each.value.id
+
+ tags = {
+ Name = "${var.name}-${each.key}"
+ }
+}
diff --git a/modules/region/outputs.tf b/modules/region/outputs.tf
new file mode 100644
index 0000000..b9e70ca
--- /dev/null
+++ b/modules/region/outputs.tf
@@ -0,0 +1,13 @@
+output "hosts" {
+ value = {
+ for key, instance in aws_instance.host : key => {
+ instance_id = instance.id
+ public_ip = aws_eip.host[key].public_ip
+ availability_zone = instance.availability_zone
+ }
+ }
+}
+
+output "host_count" {
+ value = length(aws_instance.host)
+}
diff --git a/modules/region/tests/security.tftest.hcl b/modules/region/tests/security.tftest.hcl
new file mode 100644
index 0000000..fb54d38
--- /dev/null
+++ b/modules/region/tests/security.tftest.hcl
@@ -0,0 +1,52 @@
+mock_provider "aws" {
+ 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" } }
+ mock_resource "aws_s3_bucket" { defaults = { arn = "arn:aws:s3:::mock-test-bucket", id = "mock-test-bucket" } }
+ mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } }
+}
+
+variables {
+ name = "test-carlos-eu-west-1"
+ host_count = 2
+ vpc_cidr = "10.40.0.0/16"
+ deployment_bucket_name = "test-platform"
+ deployment_bucket_arn = "arn:aws:s3:::test-platform"
+ deployment_region = "eu-west-2"
+ data_bucket_name = "test-data"
+ data_bucket_arn = "arn:aws:s3:::test-data"
+ placement_region = "primary"
+ release_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+ apps_domain = "apps.example.com"
+}
+run "host_security" {
+ command = plan
+ assert {
+ condition = alltrue([for h in aws_instance.host : h.metadata_options[0].http_tokens == "required" && h.root_block_device[0].encrypted])
+ error_message = "Hosts require IMDSv2 and encrypted disks."
+ }
+ assert {
+ condition = toset([for r in aws_security_group.host.ingress : r.from_port]) == toset([80, 443]) && alltrue([for r in aws_security_group.host.ingress : r.protocol == "tcp" && r.from_port == r.to_port])
+ error_message = "Only HTTP and HTTPS ingress is allowed."
+ }
+ assert {
+ condition = jsondecode(aws_iam_role_policy.deny_parameters.policy).Statement[0].Effect == "Deny" && jsondecode(aws_iam_role_policy.deny_parameters.policy).Statement[0].Resource == "*" && toset(jsondecode(aws_iam_role_policy.deny_parameters.policy).Statement[0].Action) == toset(["ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath"])
+ error_message = "SSM must not grant hosts access to operator parameters."
+ }
+ assert {
+ condition = toset(jsondecode(aws_iam_role_policy.buckets.policy).Statement[0].Resource) == toset(["arn:aws:s3:::test-platform/*", "arn:aws:s3:::test-data/*"])
+ error_message = "Bucket writes must stay within control and local data buckets."
+ }
+ assert {
+ condition = aws_subnet.public[0].availability_zone != aws_subnet.public[1].availability_zone
+ error_message = "Hosts must spread across available zones."
+ }
+ assert {
+ condition = strcontains(aws_instance.host["host-0"].user_data, "AWS_REGION=eu-west-2") && strcontains(aws_instance.host["host-0"].user_data, "CARLOS_HIBERNATE_REGION=eu-west-1")
+ error_message = "Control and data bucket regions must remain distinct."
+ }
+ assert {
+ condition = strcontains(aws_instance.host["host-0"].user_data, "sha256sum -c -") && !strcontains(aws_instance.host["host-0"].user_data, "CARLOS_RELEASE_KEY=")
+ error_message = "Bootstrap must check downloads and must not carry a private signing key."
+ }
+}
diff --git a/modules/region/variables.tf b/modules/region/variables.tf
new file mode 100644
index 0000000..9305ae6
--- /dev/null
+++ b/modules/region/variables.tf
@@ -0,0 +1,53 @@
+variable "name" {
+ type = string
+}
+
+variable "host_count" {
+ type = number
+}
+
+variable "vpc_cidr" {
+ type = string
+}
+
+variable "deployment_bucket_name" {
+ type = string
+}
+
+variable "deployment_bucket_arn" {
+ type = string
+}
+
+variable "deployment_region" {
+ type = string
+}
+
+variable "data_bucket_name" {
+ type = string
+}
+
+variable "data_bucket_arn" {
+ type = string
+}
+
+variable "placement_region" {
+ type = string
+}
+
+variable "release_public_key" {
+ type = string
+}
+
+variable "apps_domain" {
+ type = string
+}
+
+variable "instance_type" {
+ type = string
+ default = "t4g.small"
+}
+
+variable "root_volume_gib" {
+ type = number
+ default = 20
+}
diff --git a/modules/storage/.terraform.lock.hcl b/modules/storage/.terraform.lock.hcl
new file mode 100644
index 0000000..ad3f496
--- /dev/null
+++ b/modules/storage/.terraform.lock.hcl
@@ -0,0 +1,39 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.70.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:0ppSZxnkr2mXy+OT2fE6FDY8B84U5l77bpAIDyIzxq0=",
+ "h1:8B3+vezC72MHmr3u5F5UWjCG4OrpjfKMLnPDyxjvrSo=",
+ "h1:GMjGoPo8GZ5iafPgiBK2TVZ6uiwaDrPdIb8hrNGceus=",
+ "h1:GOgheaNYt+Q3RnZEl0aIQjp/fYLEZHlWkXvmLk+12EU=",
+ "h1:JtwMUJT134sVjOaDFY9FJh1Mpdd5rgTEfeP3+PvezxA=",
+ "h1:K2u8xN6vPcBA24fwoyh4glebpIXprMzf9Q+omP0ditA=",
+ "h1:Oua4xmevWH4nSpBpLNo3SYHFSMorAZtYYI0cndQPHDA=",
+ "h1:RCdEQN76x2oY6wsOWxMuD1/aL++bSEWnVbHAB+vAZa4=",
+ "h1:WPSxowy+7ffZgsKexf9VNGXL/SfoAEiB8oSfQ/BWr+s=",
+ "h1:YwOjwe/o7JFnVz9H/xyAEEgHMVCYFtuX0/97iYAZmsc=",
+ "h1:e3kiK7jos0/EN/Dc6U+vbKBU53knKU6ILErBv0zdGec=",
+ "h1:f/fWBs1fWquPmk03fimTUDc4d871DeMA29TTa8dyjXA=",
+ "h1:iSpEapMJUOzrewKXUa0FJICuC2cKmy6H1PNSHH4L1Hc=",
+ "h1:kMw0FvF0bvj1L7fCSPlg6BApfzkBbTITDBtplJ1Vtek=",
+ "h1:lPMlFm5WfsjVFaDOzMYxnfwcDqfRuHeesiIXUca9oJQ=",
+ "zh:0e28c73ffe0c518db0f1dbcf6260f95e4a423267ec1888d08bc0f7cb7c02fec1",
+ "zh:2b4c26d4570d03cc2ebbe43a74034286ee66c85f0bcf5cf52a4c8652f3bccaa2",
+ "zh:3957610bbc3e6271f37260ba4bea151a4e3e0b7884a9f0fc448ee277a3ab730b",
+ "zh:4160ed56e45fabf4c44d44dc8c66d90fa53d657920e2385fcbe46e8c9d43964d",
+ "zh:43feb0d865e14ecfbe2066a3ec49becd5673f9102efdf4f2dcee08ae7effaf72",
+ "zh:495fb8da6f917d9e860ca945a0daa63bed01c3df4edf2c86bfbd32a90c3a5b05",
+ "zh:49b0eae54f54f39f38cc06661d13e4be94c49f03fc8d5fbb559f7a131d09a3ab",
+ "zh:57acc6485c95e28fc485b28ee198bf88e8bf5128fab8dbca7078fc04704fe50a",
+ "zh:7155dcc1ebf0b91741d28c3d42702b2cf3caafd61301ff8f8cc7a7d3b928d0a3",
+ "zh:7df06c80fd4fc811a931bff684d880eb52c740703c59850a4ca41050bfd922f7",
+ "zh:9fc7ba5238666273af242908b95ac4524b65a6f7e6f1f8f8348db0a0d9d20d49",
+ "zh:a79639c84900d0b8475218a5fa777f7f2fbb3a2def192ab01a041387dd76a074",
+ "zh:b5b81330ceb68f36e20ba0a0a73aa123e47689eaddf35cebba2c5796d8d83808",
+ "zh:f1efc1ef4d5e483833cb8aded5109aa93b6b9d95e098d8479037304f92e776a9",
+ "zh:f225a0a1a7dfb770ea5bb75c6a81a0efca175f8e461f16424f5acff841b3ddaa",
+ ]
+}
diff --git a/modules/storage/main.tf b/modules/storage/main.tf
new file mode 100644
index 0000000..a6c64c3
--- /dev/null
+++ b/modules/storage/main.tf
@@ -0,0 +1,53 @@
+terraform {
+ required_version = ">= 1.11.0, < 2.0.0"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
+
+variable "name" { type = string }
+resource "aws_s3_bucket" "this" {
+ bucket = var.name
+ force_destroy = false
+ lifecycle { prevent_destroy = true }
+}
+resource "aws_s3_bucket_versioning" "this" {
+ bucket = aws_s3_bucket.this.id
+ versioning_configuration { status = "Enabled" }
+}
+resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
+ bucket = aws_s3_bucket.this.id
+ rule {
+ apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
+ }
+}
+resource "aws_s3_bucket_public_access_block" "this" {
+ bucket = aws_s3_bucket.this.id
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+resource "aws_s3_bucket_ownership_controls" "this" {
+ bucket = aws_s3_bucket.this.id
+ rule { object_ownership = "BucketOwnerEnforced" }
+}
+resource "aws_s3_bucket_policy" "this" {
+ bucket = aws_s3_bucket.this.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Deny"
+ Principal = "*"
+ Action = "s3:*"
+ Resource = [aws_s3_bucket.this.arn, "${aws_s3_bucket.this.arn}/*"]
+ Condition = { Bool = { "aws:SecureTransport" = "false" } }
+ }]
+ })
+ 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 }
diff --git a/modules/storage/tests/security.tftest.hcl b/modules/storage/tests/security.tftest.hcl
new file mode 100644
index 0000000..b241e4c
--- /dev/null
+++ b/modules/storage/tests/security.tftest.hcl
@@ -0,0 +1,24 @@
+mock_provider "aws" {
+ 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" } }
+ mock_resource "aws_s3_bucket" { defaults = { arn = "arn:aws:s3:::mock-test-bucket", id = "mock-test-bucket" } }
+ mock_resource "aws_iam_role" { defaults = { arn = "arn:aws:iam::123456789012:role/test" } }
+}
+
+variables { name = "test-bucket" }
+run "storage_security" {
+ command = plan
+ assert {
+ condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled" && !aws_s3_bucket.this.force_destroy
+ error_message = "Retain object versions and refuse non-empty bucket destruction."
+ }
+ assert {
+ condition = aws_s3_bucket_public_access_block.this.block_public_acls && aws_s3_bucket_public_access_block.this.block_public_policy && aws_s3_bucket_public_access_block.this.ignore_public_acls && aws_s3_bucket_public_access_block.this.restrict_public_buckets
+ error_message = "All S3 public access controls must remain enabled."
+ }
+ assert {
+ condition = one(aws_s3_bucket_server_side_encryption_configuration.this.rule).apply_server_side_encryption_by_default[0].sse_algorithm == "AES256"
+ error_message = "Encrypt stored objects."
+ }
+}
diff --git a/scripts/check b/scripts/check
new file mode 100755
index 0000000..856c2e3
--- /dev/null
+++ b/scripts/check
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+export AWS_EC2_METADATA_DISABLED=true
+export AWS_ACCESS_KEY_ID=mock
+export AWS_SECRET_ACCESS_KEY=mock
+unset AWS_SESSION_TOKEN AWS_SECURITY_TOKEN AWS_PROFILE AWS_DEFAULT_PROFILE
+
+tofu fmt -check -recursive
+for module in modules/storage modules/region examples/single-host examples/single-region examples/two-regions; do
+ tofu -chdir="$module" init -backend=false -input=false -lockfile=readonly -no-color
+ tofu -chdir="$module" validate -no-color
+ tofu -chdir="$module" test -no-color
+done
+python3 scripts/check-bootstrap.py
diff --git a/scripts/check-bootstrap.py b/scripts/check-bootstrap.py
new file mode 100644
index 0000000..66eb0b9
--- /dev/null
+++ b/scripts/check-bootstrap.py
@@ -0,0 +1,23 @@
+from pathlib import Path
+import re
+import subprocess
+
+script = Path("modules/region/cloud-init.sh.tftpl").read_text()
+values = {
+ "local_region": "eu-west-2",
+ "deployment_region": "eu-west-1",
+ "deployment_bucket_name": "test-platform",
+ "data_bucket_name": "test-data",
+ "placement_region": "secondary",
+ "release_public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "apps_domain": "apps.example.com",
+}
+rendered = re.sub(r"\$\{([^}]+)\}", lambda match: values[match[1]], script)
+subprocess.run(["bash", "-n"], input=rendered, text=True, check=True)
+assert rendered.index("sha256sum -c -") < rendered.index("dpkg -i")
+assert rendered.rindex("sha256sum -c -") < rendered.index('install -m 0755 "$stage/carlos"')
+assert rendered.index("CARLOS_HIBERNATE_REGION=") < rendered.index("/opt/carlos/carlos bootstrap")
+assert rendered.index("/opt/carlos/carlos bootstrap") < rendered.index("systemctl start carlos-edge")
+assert "latest/" not in rendered
+assert "CARLOS_RELEASE_KEY=" not in rendered
+print("Bootstrap syntax, verification and startup order passed.")