Add three standalone OpenTofu host topologies and mocked validation
35 files changed,
+1629
−0
.amadan/ci.d/10-validate+3 −0.gitignore+11 −0LICENSE+373 −0examples/single-host/.terraform.lock.hcl+39 −0examples/single-host/backend.tf.example+9 −0examples/single-host/main.tf+37 −0examples/single-host/terraform.tfvars.example+5 −0examples/single-host/tests/topology.tftest.hcl+30 −0examples/single-host/variables.tf+53 −0examples/single-host/versions.tf+9 −0examples/single-region/.terraform.lock.hcl+39 −0examples/single-region/backend.tf.example+9 −0examples/single-region/main.tf+37 −0examples/single-region/terraform.tfvars.example+6 −0examples/single-region/tests/topology.tftest.hcl+30 −0examples/single-region/variables.tf+61 −0examples/single-region/versions.tf+9 −0examples/two-regions/.terraform.lock.hcl+39 −0examples/two-regions/backend.tf.example+9 −0examples/two-regions/main.tf+66 −0examples/two-regions/terraform.tfvars.example+6 −0examples/two-regions/tests/topology.tftest.hcl+44 −0examples/two-regions/variables.tf+69 −0examples/two-regions/versions.tf+9 −0modules/region/.terraform.lock.hcl+39 −0modules/region/cloud-init.sh.tftpl+42 −0modules/region/main.tf+274 −0modules/region/outputs.tf+13 −0modules/region/tests/security.tftest.hcl+52 −0modules/region/variables.tf+53 −0modules/storage/.terraform.lock.hcl+39 −0modules/storage/main.tf+53 −0modules/storage/tests/security.tftest.hcl+24 −0scripts/check+15 −0scripts/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.") |