feat(aws): environment Terraform module and naming

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 20:50:55 -08:00
1 parent fe7642ee2c
commit 3583bee31e
12 files changed
+1270

No files matched your search

+7
View File
@@ -20,3 +20,10 @@ screenshot_for_fix/
# Windows CI code signing (scripts/windows-signing-setup.ps1)
.code-signing/
# OpenTofu working state (aws/terraform/*): providers, local state, locks.
# The modules' .terraform.lock.hcl files are committed.
.terraform/
*.tfstate
*.tfstate.*
.terraform.tfstate.lock.info
+81
View File
@@ -3432,6 +3432,46 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hcl-edit"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1b3e90b173143b26dbb7495ac79e25c0206389d012881cea7cda9f0f1fce8a"
dependencies = [
"fnv",
"hcl-primitives",
"pratt",
"vecmap-rs",
"winnow 1.0.5",
]
[[package]]
name = "hcl-primitives"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35ea757c1bdfa0bdfe8f65771be944d199a7f379b739c8f6fe2ad2f92d18ecb"
dependencies = [
"itoa",
"kstring",
"ryu",
"serde",
"unicode-ident",
]
[[package]]
name = "hcl-rs"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9736cfea6e2ed86d991394a6a5106cf768ee97dcb3db9cbea49859a9ecbfd1fb"
dependencies = [
"hcl-edit",
"hcl-primitives",
"indexmap 2.13.0",
"itoa",
"serde",
"vecmap-rs",
]
[[package]]
name = "heapless"
version = "0.8.0"
@@ -4123,6 +4163,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "kstring"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a09b82a7f771ed02dc0dd9b27130a0fa5499fa15ed3027116c1e5e4e591bd9e"
dependencies = [
"serde",
"static_assertions",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -5159,6 +5209,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "pratt"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e0a4425d076f0718b820673a38fbf3747080c61017eeb0dd79bc7e472b8bb8"
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -6338,6 +6394,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.8.9"
@@ -7256,6 +7318,7 @@ dependencies = [
"fern",
"futures-util",
"gix",
"hcl-rs",
"iana-time-zone",
"include_dir",
"keyring",
@@ -7480,6 +7543,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vecmap-rs"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58c1dc449b873236909c7f325adf395071c209047737d831183e95c692413adf"
dependencies = [
"serde",
]
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -8255,6 +8327,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.55.0"
+2
View File
@@ -89,6 +89,8 @@ tempfile = "3"
# Only `aws::live_tests` (ignored): it creates a throwaway environment key to
# check that Secrets Manager and the permissions boundary see its tags.
aws-sdk-kms = { version = "1.131.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
# Only `aws::tofu_modules` tests: they parse the shipped OpenTofu modules.
hcl-rs = "0.20"
[build-dependencies]
tauri-build = { version = "2", features = [] }
+1
View File
@@ -10,6 +10,7 @@ mod identity;
mod live_tests;
pub mod permissions;
pub mod policies;
pub mod tofu_modules;
pub use connection::{sdk_config, sdk_config_for_keys};
pub use error::AwsError;
+217
View File
@@ -0,0 +1,217 @@
//! The OpenTofu modules Triple-C ships (`aws/terraform/*` at the repo root)
//! and the names the modules and the app agree on.
//!
//! Every name a module or a phase-4 command uses — environment, parameter,
//! owner key, per-user roles, state bucket and keys — is built here and
//! nowhere else. The tests parse the shipped HCL and pin it to these
//! constants: versions, default tags, the record's fields, no inbound access
//! and no endpoint overrides.
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::models::host_connection::validate_supported_region;
/// `locals.template_version` (and the `triple-c:template-version` default
/// tag) of `aws/terraform/environment`.
#[allow(dead_code)] // used by phase-4 commands
pub const ENVIRONMENT_MODULE_VERSION: u32 = 1;
/// The same for the user-onboarding module.
#[allow(dead_code)] // used by phase-4 commands
pub const ONBOARD_MODULE_VERSION: u32 = 1;
/// The permissions boundary every module-created role carries (no path).
/// Equal to [`super::policies::PERMISSIONS_BOUNDARY_NAME`]; a test checks it.
#[allow(dead_code)] // used by phase-4 commands
pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary";
#[allow(dead_code)] // used by phase-4 commands
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ModuleKind {
Environment,
UserOnboard,
PermissionsBoundary,
}
macro_rules! module {
($dir:literal: $($file:literal),+ $(,)?) => {
&[$(($file, include_str!(concat!("../../../../aws/terraform/", $dir, "/", $file)))),+]
};
}
const ENVIRONMENT_FILES: &[(&str, &str)] = module!("environment":
"versions.tf",
"variables.tf",
"main.tf",
"network.tf",
"outputs.tf",
".terraform.lock.hcl",
);
/// The module's files as (file name relative to the module directory,
/// contents), lock file included, for writing into a work or bundle folder.
/// The onboarding and boundary modules arrive in later phase-4 tasks; until
/// then they have no files.
#[allow(dead_code)] // used by phase-4 commands
pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] {
match kind {
ModuleKind::Environment => ENVIRONMENT_FILES,
ModuleKind::UserOnboard | ModuleKind::PermissionsBoundary => &[],
}
}
/// `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--` — the same rule
/// as the module's `environment_name` validation.
#[allow(dead_code)] // used by phase-4 commands
pub fn validate_environment_name(name: &str) -> Result<String, String> {
let b = name.as_bytes();
let ok = !b.is_empty()
&& b.len() <= 20
&& b[0].is_ascii_lowercase()
&& b.iter()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-')
&& !name.ends_with('-')
&& !name.contains("--");
if ok {
Ok(name.to_string())
} else {
Err(format!(
"\"{}\" is not a valid environment name: use 1 to 20 lowercase letters, digits and \
single hyphens, starting with a letter and not ending in a hyphen",
name.escape_debug()
))
}
}
/// The SSM parameter holding an environment's record.
#[allow(dead_code)] // used by phase-4 commands
pub fn environment_parameter_name(env: &str) -> String {
format!("/triple-c/environments/{env}")
}
/// The first 12 hex characters of `sha256(aws:userid)`. A `UserId` holds a
/// `:`, which IAM role names reject, so per-user names use this instead.
#[allow(dead_code)] // used by phase-4 commands
pub fn owner_key(user_id: &str) -> String {
Sha256::digest(user_id.as_bytes())
.iter()
.take(6)
.map(|b| format!("{b:02x}"))
.collect()
}
/// A user's (task role, execution role) names. At most 47 characters.
#[allow(dead_code)] // used by phase-4 commands
pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) {
(
format!("triple-c-{env}-{owner_key}-task"),
format!("triple-c-{env}-{owner_key}-exec"),
)
}
/// The per-account, per-region state bucket.
#[allow(dead_code)] // used by phase-4 commands
pub fn state_bucket_name(account: &str, region: &str) -> String {
format!("triple-c-tfstate-{account}-{region}")
}
/// A deployment's state object in [`state_bucket_name`] (D9); the lock is the
/// same key plus `.tflock`.
///
/// Panics if `kind` needs a part that is `None` (the environment for
/// `Environment` and `UserOnboard`, the owner key for `UserOnboard`): that is
/// a programming error, not input.
#[allow(dead_code)] // used by phase-4 commands
pub fn state_key(kind: ModuleKind, env: Option<&str>, owner_key: Option<&str>) -> String {
let env = || env.expect("state_key: this module kind needs an environment");
match kind {
ModuleKind::Environment => format!("environments/{}/terraform.tfstate", env()),
ModuleKind::UserOnboard => format!(
"users/{}/{}/terraform.tfstate",
env(),
owner_key.expect("state_key: a user deployment needs an owner key")
),
ModuleKind::PermissionsBoundary => "boundary/terraform.tfstate".to_string(),
}
}
/// How an environment's tasks reach the network (D4).
#[allow(dead_code)] // used by phase-4 commands
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "kebab-case")]
pub enum NetworkChoice {
/// A new VPC with two public subnets; tasks get a public IP. No NAT, $0.
Public,
/// A new VPC with private subnets behind one NAT gateway (~$33/month).
PrivateNat,
/// The user's own VPC and exactly two subnets.
Existing {
vpc_id: String,
subnet_ids: Vec<String>,
assign_public_ip: bool,
},
}
/// `vpc-` / `subnet-` followed by 8 or 17 lowercase hex characters.
fn is_aws_id(id: &str, prefix: &str) -> bool {
id.strip_prefix(prefix).is_some_and(|hex| {
(hex.len() == 8 || hex.len() == 17)
&& hex.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
})
}
/// The environment module's `terraform.tfvars.json`. Applies the same rules
/// as the module's variable validations, so a bad value fails here first.
#[allow(dead_code)] // used by phase-4 commands
pub fn environment_vars(
env: &str,
region: &str,
network: &NetworkChoice,
) -> Result<serde_json::Value, String> {
let env = validate_environment_name(env)?;
let region = validate_supported_region(region)?;
let (mode, vpc_id, subnet_ids, assign_public_ip) = match network {
NetworkChoice::Public => ("public", String::new(), Vec::new(), false),
NetworkChoice::PrivateNat => ("private-nat", String::new(), Vec::new(), false),
NetworkChoice::Existing {
vpc_id,
subnet_ids,
assign_public_ip,
} => {
if !is_aws_id(vpc_id, "vpc-") {
return Err(format!("\"{}\" is not a VPC id", vpc_id.escape_debug()));
}
if subnet_ids.len() != 2 {
return Err(format!(
"An existing network needs exactly 2 subnets (in different availability \
zones), not {}",
subnet_ids.len()
));
}
if let Some(bad) = subnet_ids.iter().find(|s| !is_aws_id(s, "subnet-")) {
return Err(format!("\"{}\" is not a subnet id", bad.escape_debug()));
}
if subnet_ids[0] == subnet_ids[1] {
return Err("The two subnets must be different".into());
}
(
"existing",
vpc_id.clone(),
subnet_ids.clone(),
*assign_public_ip,
)
}
};
Ok(json!({
"environment_name": env,
"region": region,
"network_mode": mode,
"vpc_id": vpc_id,
"subnet_ids": subnet_ids,
"assign_public_ip": assign_public_ip,
}))
}
#[cfg(test)]
mod tests;
+533
View File
@@ -0,0 +1,533 @@
use std::collections::BTreeSet;
use hcl::expr::{Expression, ObjectKey};
use hcl::{Block, Body};
use serde_json::json;
use super::*;
use crate::aws::policies::PERMISSIONS_BOUNDARY_NAME;
const RECORD_FIELDS: [&str; 11] = [
"schema",
"name",
"template_version",
"cluster_arn",
"security_group_id",
"subnet_ids",
"assign_public_ip",
"kms_key_arn",
"log_group",
"network_mode",
"tenancy",
];
const VARIABLES: [&str; 7] = [
"environment_name",
"region",
"network_mode",
"vpc_id",
"subnet_ids",
"assign_public_ip",
"log_retention_days",
];
/// Every `.tf` file of `kind`, parsed and merged into one body.
fn parsed(kind: ModuleKind) -> Body {
let mut structures = Vec::new();
for (name, text) in module_files(kind) {
if name.ends_with(".tf") {
let body = hcl::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"));
structures.extend(body.into_inner());
}
}
assert!(!structures.is_empty(), "{kind:?} has no .tf files");
Body::from(structures)
}
fn tf_text(kind: ModuleKind) -> String {
module_files(kind)
.iter()
.filter(|(name, _)| name.ends_with(".tf"))
.map(|(_, text)| *text)
.collect::<Vec<_>>()
.join("\n")
}
fn label(block: &Block, i: usize) -> Option<&str> {
block.labels.get(i).map(|l| l.as_str())
}
/// Every block in `body`, at any depth.
fn all_blocks(body: &Body) -> Vec<&Block> {
let mut out = Vec::new();
for b in body.blocks() {
out.push(b);
out.extend(all_blocks(&b.body));
}
out
}
fn top_blocks<'a>(body: &'a Body, ident: &'a str) -> impl Iterator<Item = &'a Block> + 'a {
body.blocks()
.filter(move |b| b.identifier.as_str() == ident)
}
fn resources<'a>(body: &'a Body, ty: &'a str) -> Vec<&'a Block> {
top_blocks(body, "resource")
.filter(|b| label(b, 0) == Some(ty))
.collect()
}
fn attr<'a>(body: &'a Body, key: &str) -> Option<&'a Expression> {
body.attributes()
.find(|a| a.key.as_str() == key)
.map(|a| &a.expr)
}
fn key_name(key: &ObjectKey) -> String {
match key {
ObjectKey::Identifier(id) => id.as_str().to_string(),
ObjectKey::Expression(Expression::String(s)) => s.clone(),
other => panic!("unexpected object key {other:?}"),
}
}
fn object_keys(expr: &Expression) -> BTreeSet<String> {
match expr {
Expression::Object(obj) => obj.keys().map(key_name).collect(),
other => panic!("expected an object, got {other:?}"),
}
}
fn object_get<'a>(expr: &'a Expression, key: &str) -> Option<&'a Expression> {
match expr {
Expression::Object(obj) => obj.iter().find(|(k, _)| key_name(k) == key).map(|(_, v)| v),
other => panic!("expected an object, got {other:?}"),
}
}
fn render(expr: &Expression) -> String {
hcl::format::to_string(expr).unwrap()
}
fn aws_provider(body: &Body) -> &Block {
let providers: Vec<_> = top_blocks(body, "provider").collect();
assert_eq!(providers.len(), 1, "exactly one provider block");
assert_eq!(label(providers[0], 0), Some("aws"));
providers[0]
}
fn default_tags(body: &Body) -> &Expression {
let provider = aws_provider(body);
let tags_blocks: Vec<_> = top_blocks(&provider.body, "default_tags").collect();
assert_eq!(tags_blocks.len(), 1, "one default_tags block");
attr(&tags_blocks[0].body, "tags").expect("default_tags.tags")
}
/// The environment record: the `jsonencode` argument of the SSM parameter's value.
fn record(body: &Body) -> (&Block, &Expression) {
let params = resources(body, "aws_ssm_parameter");
assert_eq!(params.len(), 1, "one SSM parameter");
let value = attr(&params[0].body, "value").expect("parameter value");
let Expression::FuncCall(call) = value else {
panic!("the record is not jsonencode(...): {value:?}")
};
assert_eq!(call.name.name.as_str(), "jsonencode");
assert_eq!(call.args.len(), 1);
(params[0], &call.args[0])
}
#[test]
fn environment_names_are_validated() {
for ok in ["dev", "team-a1", "a", "abcdefghij0123456789"] {
assert_eq!(validate_environment_name(ok), Ok(ok.to_string()), "{ok}");
}
for bad in [
"",
"Dev",
"1dev",
"-dev",
"dev-",
"a--b",
"abcdefghij0123456789x",
"dev/x",
"dev_x",
"dév",
"dev ",
] {
assert!(validate_environment_name(bad).is_err(), "{bad:?} accepted");
}
assert_eq!(
environment_parameter_name("dev"),
"/triple-c/environments/dev"
);
}
#[test]
fn owner_keys_are_stable_and_role_safe() {
let a = owner_key("AROAEXAMPLEID:alice@example.com");
assert_eq!(a.len(), 12);
assert!(
a.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')),
"{a}"
);
assert_eq!(a, owner_key("AROAEXAMPLEID:alice@example.com"));
assert_ne!(a, owner_key("AROAEXAMPLEID:bob@example.com"));
// The key is the first 12 hex characters of the SHA-256 of the user ID.
assert_eq!(owner_key("AIDAEXAMPLE"), sha256_prefix("AIDAEXAMPLE"));
let env = "abcdefghij0123456789";
assert_eq!(env.len(), 20);
let (task, exec) = user_role_names(env, &a);
assert_eq!(task, format!("triple-c-{env}-{a}-task"));
assert_eq!(exec, format!("triple-c-{env}-{a}-exec"));
assert!(task.len() <= 64 && exec.len() <= 64, "{task} / {exec}");
for name in [&task, &exec] {
assert!(
name.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"+=,.@_-".contains(&c)),
"{name} is not a valid role name"
);
}
}
fn sha256_prefix(s: &str) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(s.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()[..12]
.to_string()
}
#[test]
fn state_bucket_and_keys_follow_d9() {
assert_eq!(BOUNDARY_POLICY_NAME, PERMISSIONS_BOUNDARY_NAME);
assert_eq!(BOUNDARY_POLICY_NAME, "triple-c-permissions-boundary");
assert_eq!(
state_bucket_name("111122223333", "us-west-2"),
"triple-c-tfstate-111122223333-us-west-2"
);
// The longest commercial region name still fits S3's 63 characters.
let longest = state_bucket_name("111122223333", "ap-southeast-5");
assert!(longest.len() <= 63, "{longest}");
assert_eq!(
state_key(ModuleKind::Environment, Some("dev"), None),
"environments/dev/terraform.tfstate"
);
assert_eq!(
state_key(ModuleKind::UserOnboard, Some("dev"), Some("0123456789ab")),
"users/dev/0123456789ab/terraform.tfstate"
);
assert_eq!(
state_key(ModuleKind::PermissionsBoundary, None, None),
"boundary/terraform.tfstate"
);
}
#[test]
fn the_module_versions_agree() {
assert_eq!(ENVIRONMENT_MODULE_VERSION, 1);
assert_eq!(ONBOARD_MODULE_VERSION, 1);
let body = parsed(ModuleKind::Environment);
let locals: Vec<_> = top_blocks(&body, "locals")
.filter_map(|b| attr(&b.body, "template_version"))
.collect();
assert_eq!(locals.len(), 1, "one locals.template_version");
assert_eq!(
locals[0],
&Expression::Number(ENVIRONMENT_MODULE_VERSION.into())
);
let tag = object_get(default_tags(&body), "triple-c:template-version")
.expect("template-version default tag");
assert!(
render(tag).contains("local.template_version"),
"template-version tag is {}",
render(tag)
);
let (_, rec) = record(&body);
assert!(render(object_get(rec, "template_version").unwrap()).contains("local.template_version"));
}
#[test]
fn default_tags_mark_the_environment_and_nothing_overrides_component() {
let body = parsed(ModuleKind::Environment);
let tags = default_tags(&body);
assert_eq!(
object_keys(tags),
BTreeSet::from([
"triple-c:component".to_string(),
"triple-c:environment".to_string(),
"triple-c:template-version".to_string(),
])
);
assert_eq!(
object_get(tags, "triple-c:component"),
Some(&Expression::String("environment".into()))
);
assert_eq!(
render(object_get(tags, "triple-c:environment").unwrap()),
"var.environment_name"
);
// The provider's default_tags is the only place the component is named,
// and no resource sets tags that could shadow it.
assert_eq!(
tf_text(ModuleKind::Environment)
.matches("triple-c:component")
.count(),
1
);
for block in all_blocks(&body) {
if block.identifier.as_str() == "default_tags" {
continue;
}
for key in ["tags", "tags_all"] {
if let Some(t) = attr(&block.body, key) {
let keys = object_keys(t);
assert!(
keys.iter().all(|k| !k.starts_with("triple-c:")),
"{:?} sets triple-c tags {keys:?}",
block.labels
);
}
}
}
}
#[test]
fn the_security_group_has_no_ingress() {
let body = parsed(ModuleKind::Environment);
for ty in [
"aws_vpc_security_group_ingress_rule",
"aws_security_group_rule",
] {
assert!(resources(&body, ty).is_empty(), "{ty} present");
}
assert!(
all_blocks(&body)
.iter()
.all(|b| b.identifier.as_str() != "ingress"),
"an ingress block is present"
);
let groups = resources(&body, "aws_security_group");
assert_eq!(groups.len(), 1);
let sg = groups[0];
assert_eq!(
render(attr(&sg.body, "name").unwrap()),
"\"triple-c-${var.environment_name}-tasks\""
);
// `ingress = []` makes OpenTofu remove any rule added out of band.
assert_eq!(attr(&sg.body, "ingress"), Some(&Expression::Array(vec![])));
let egress: Vec<_> = top_blocks(&sg.body, "egress").collect();
assert_eq!(egress.len(), 1, "one egress block");
assert_eq!(
attr(&egress[0].body, "protocol"),
Some(&Expression::String("-1".into()))
);
}
#[test]
fn no_provider_endpoint_overrides() {
let body = parsed(ModuleKind::Environment);
let provider = aws_provider(&body);
assert_eq!(
render(attr(&provider.body, "region").unwrap()),
"var.region"
);
assert!(attr(&provider.body, "endpoints").is_none());
assert!(top_blocks(&provider.body, "endpoints").next().is_none());
let text = tf_text(ModuleKind::Environment);
for needle in [
"endpoints",
"endpoint_url",
"skip_credentials_validation",
"assume_role",
] {
assert!(!text.contains(needle), "the module mentions {needle}");
}
// versions.tf: the provider is pinned exactly, and the backend is an
// empty S3 block the app fills in.
let terraform: Vec<_> = top_blocks(&body, "terraform").collect();
assert_eq!(terraform.len(), 1);
let tf = &terraform[0].body;
assert_eq!(
attr(tf, "required_version"),
Some(&Expression::String(">= 1.10".into()))
);
let providers = top_blocks(tf, "required_providers").next().unwrap();
let aws = attr(&providers.body, "aws").unwrap();
assert_eq!(
object_get(aws, "source"),
Some(&Expression::String("hashicorp/aws".into()))
);
let Some(Expression::String(version)) = object_get(aws, "version") else {
panic!("aws provider version is not a string")
};
let parts: Vec<&str> = version.split('.').collect();
assert!(
parts.len() == 3 && parts[0] == "6" && parts.iter().all(|p| p.parse::<u32>().is_ok()),
"aws provider must be pinned to an exact 6.x version, got {version}"
);
let backend: Vec<_> = top_blocks(tf, "backend").collect();
assert_eq!(backend.len(), 1);
assert_eq!(label(backend[0], 0), Some("s3"));
assert!(
backend[0].body.iter().next().is_none(),
"the backend block must be empty"
);
// The lock file ships with the module and pins the same version.
let lock = module_files(ModuleKind::Environment)
.iter()
.find(|(name, _)| *name == ".terraform.lock.hcl")
.expect("lock file shipped")
.1;
assert!(lock.contains("registry.opentofu.org/hashicorp/aws"));
assert!(
lock.contains(&format!("version = \"{version}\"")),
"lock pins {version}"
);
}
#[test]
fn the_record_has_exactly_the_schema_fields() {
let body = parsed(ModuleKind::Environment);
let (param, rec) = record(&body);
assert_eq!(
object_keys(rec),
RECORD_FIELDS.iter().map(|s| s.to_string()).collect()
);
assert_eq!(
object_get(rec, "schema"),
Some(&Expression::Number(1.into()))
);
assert_eq!(
object_get(rec, "tenancy"),
Some(&Expression::String("strict".into()))
);
assert_eq!(
attr(&param.body, "type"),
Some(&Expression::String("String".into()))
);
assert_eq!(
render(attr(&param.body, "name").unwrap()),
"\"/triple-c/environments/${var.environment_name}\""
);
}
#[test]
fn every_variable_is_validated() {
let body = parsed(ModuleKind::Environment);
let vars: Vec<_> = top_blocks(&body, "variable").collect();
let names: BTreeSet<_> = vars.iter().filter_map(|b| label(b, 0)).collect();
assert_eq!(names, VARIABLES.iter().copied().collect());
for v in vars {
assert!(
top_blocks(&v.body, "validation").next().is_some(),
"variable {:?} has no validation",
label(v, 0)
);
}
}
#[test]
fn module_files_ship_the_lock_file() {
let names: Vec<_> = module_files(ModuleKind::Environment)
.iter()
.map(|(n, _)| *n)
.collect();
for want in [
"versions.tf",
"variables.tf",
"main.tf",
"network.tf",
"outputs.tf",
".terraform.lock.hcl",
] {
assert!(names.contains(&want), "{want} missing from {names:?}");
}
}
#[test]
fn environment_vars_cover_every_network_mode() {
let vars = environment_vars("dev", "us-west-2", &NetworkChoice::Public).unwrap();
assert_eq!(
vars,
json!({
"environment_name": "dev",
"region": "us-west-2",
"network_mode": "public",
"vpc_id": "",
"subnet_ids": [],
"assign_public_ip": false,
})
);
let vars = environment_vars("dev", "us-west-2", &NetworkChoice::PrivateNat).unwrap();
assert_eq!(vars["network_mode"], "private-nat");
assert_eq!(vars["subnet_ids"], json!([]));
let existing = |vpc: &str, subnets: &[&str]| NetworkChoice::Existing {
vpc_id: vpc.into(),
subnet_ids: subnets.iter().map(|s| s.to_string()).collect(),
assign_public_ip: true,
};
let two = ["subnet-0123456789abcdef0", "subnet-89abcdef"];
let vars = environment_vars("dev", "eu-central-1", &existing("vpc-0a1b2c3d", &two)).unwrap();
assert_eq!(
vars,
json!({
"environment_name": "dev",
"region": "eu-central-1",
"network_mode": "existing",
"vpc_id": "vpc-0a1b2c3d",
"subnet_ids": two,
"assign_public_ip": true,
})
);
// Every variable the app sets is one the module declares.
for key in vars.as_object().unwrap().keys() {
assert!(VARIABLES.contains(&key.as_str()), "{key}");
}
for bad in [
existing("vpc-0a1b2c3d", &["subnet-0123456789abcdef0"]),
existing(
"vpc-0a1b2c3d",
&[
"subnet-0123456789abcdef0",
"subnet-89abcdef",
"subnet-01234567",
],
),
existing("vpc-0a1b2c3d", &["subnet-89abcdef", "subnet-89abcdef"]),
existing("vpc-;", &two),
existing("vpc-0A1B2C3D", &two),
existing("", &two),
existing("vpc-0a1b2c3d", &["subnet-0123456789abcdef0", "subnet-;"]),
existing("vpc-0a1b2c3d", &["subnet-0123456789abcdef0", "sg-89abcdef"]),
] {
assert!(
environment_vars("dev", "us-west-2", &bad).is_err(),
"{bad:?} accepted"
);
}
assert!(environment_vars("Dev", "us-west-2", &NetworkChoice::Public).is_err());
assert!(environment_vars("dev", "us-west-2;", &NetworkChoice::Public).is_err());
assert!(environment_vars("dev", "cn-north-1", &NetworkChoice::Public).is_err());
// The command layer receives the choice as tagged JSON.
let parsed: NetworkChoice = serde_json::from_value(json!({
"mode": "existing",
"vpc_id": "vpc-0a1b2c3d",
"subnet_ids": two,
"assign_public_ip": false,
}))
.unwrap();
assert!(matches!(parsed, NetworkChoice::Existing { .. }));
let parsed: NetworkChoice = serde_json::from_value(json!({ "mode": "private-nat" })).unwrap();
assert_eq!(parsed, NetworkChoice::PrivateNat);
}
+39
View File
@@ -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.73.0"
constraints = "6.73.0"
hashes = [
"h1:1zQPP8XHNYS2m7MnhPd19nR6xjWk3p/e08X+B7GGx7I=",
"h1:IdAE6YFUBZvlLDzcKe+khHpceC+cZMJy8DOsuFV1iLs=",
"h1:KLd1R0RCfFNyhJ8fBLI7KuCI8VvV8fMZh2+Bt4/5UEk=",
"h1:NE8f7fV/bAVTxhVOuQSIRcG0/NTNMrrrZTFJ863U3v8=",
"h1:PLSysFV+jYgBuI42kYv4AMGMDusaKj6o4MfKz7+AD2w=",
"h1:PirCmCZao9jd4snXgOlO+OrG4HtPKQVyiu37vmoM6Y8=",
"h1:ZDDEyufl0zfy5gWdRkfFrDj/K7Dt9oym+FTrKTyzPmY=",
"h1:cqILWB/Kxr0AYCi+pJj9A8d5KzsXEOrKxbFHh3VfKsQ=",
"h1:iUIwaluxZuApYxl8C5Xpwo5mb/9OkEMn6tAzLKVTxag=",
"h1:jjsA3nBL/NmNYeG0bmVhHv1ruFBH2ucdkW9VH1iE5Tw=",
"h1:mKUNC7il3hHVi93ZyWogDKWTCwuhorndZf629rHqElQ=",
"h1:miKdeCwqPUV/IPN+HCS0ZZquE4ENjb18UhOO612M+AY=",
"h1:mrSvOTfc0Uwurvpyj9uEhbGLUqUWA1p3VAAK+qA8OXs=",
"h1:pOp3kyT/dPNiMXzc7CtQqFrPhIFR7gqtgX5iepQJczs=",
"h1:q5MABxUl6HoEzLYB21WOLdH+EZzioo0n9/8pkKYweF0=",
"zh:01a4a08962eb85b35c9912e715df1a5124e5064cd60792b37c82ebe8898489b8",
"zh:3efa54bad2af67c07547f6682bfe2b419c9f018c63008dc7d8a1e4691fd35569",
"zh:432944bc45558e48a526e522b559dc74b70dc37cfe460420ed48527d36563531",
"zh:6e06ddd0fa7f5c9b88c428abb18ae8d8ee9749a773ca52b2ee5010d8ce697eca",
"zh:7efbfba3b2daed43b0ee0322b62cfd0c9102b2320cac74878e950b5d02ffc979",
"zh:8866298c36a45b7867415119289a8f680f32d54f5553f738880ffcc0561c6907",
"zh:9dcde5077f648b6231eef6de019a5dd89ba7ec393767be4f6cbde7e420a5a874",
"zh:9fd52591011b1066036a47a4e677cad00d63956579a200cb57c9228f1dfb4a1a",
"zh:a61f8e114e5233e63b8d2b034b1ad59a220e532fa34aaaa63eec6bad73a0ca21",
"zh:ba50cb9245a579285169565995e20bebb410e7f4d4181dc7de1205877fe04728",
"zh:c2eb24eedab492b3e42f78632b0dd6a8d23f3021b080fee2137e91765629a9e1",
"zh:cac7953f04c637c303f212b87e96089950bb95856ffbeb2719c5138299a64787",
"zh:d7929af6ee61fb8c7d6ec5d3a0ff9cf8554f01d0b470b3787d7e7882e6df3946",
"zh:d7f889bcdbf6b5889ca2252ce517241a2723e61927b2826683d01b648ea91f9a",
"zh:ff4c4ca563e044561e3c3836f51b24dca034879574ca7e334b27a49b25d1df51",
]
}
+123
View File
@@ -0,0 +1,123 @@
# A Triple-C environment: the pieces every user's tasks share. Each user's
# own roles, EFS file system and secrets come from the onboarding module.
locals {
# ENVIRONMENT_MODULE_VERSION in app/src-tauri/src/aws/tofu_modules.rs.
template_version = 1
name = "triple-c-${var.environment_name}"
}
provider "aws" {
region = var.region
# The only place the component tag is set: the admin policy's
# destruction rights are scoped to it, so no resource may override it.
default_tags {
tags = {
"triple-c:component" = "environment"
"triple-c:environment" = var.environment_name
"triple-c:template-version" = tostring(local.template_version)
}
}
}
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
resource "aws_ecs_cluster" "this" {
name = local.name
setting {
name = "containerInsights"
value = "disabled"
}
}
resource "aws_ecs_cluster_capacity_providers" "this" {
cluster_name = aws_ecs_cluster.this.name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
capacity_provider = "FARGATE"
weight = 1
}
}
# Tasks are reached only through SSM, so nothing may connect in.
resource "aws_security_group" "tasks" {
name = "triple-c-${var.environment_name}-tasks"
description = "Triple-C ${var.environment_name} tasks: outbound only"
vpc_id = local.vpc_id
# Empty, not absent: OpenTofu then removes any rule added out of band.
ingress = []
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
}
resource "aws_kms_key" "this" {
description = "Triple-C environment ${var.environment_name}"
enable_key_rotation = true
deletion_window_in_days = 7
# Delegate to IAM: who may use the key is decided by IAM policies (and the
# permissions boundary), not by the key policy.
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "EnableIAMPolicies"
Effect = "Allow"
Principal = { AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" }
Action = "kms:*"
Resource = "*"
}]
})
}
resource "aws_kms_alias" "this" {
name = "alias/${local.name}"
target_key_id = aws_kms_key.this.key_id
}
resource "aws_cloudwatch_log_group" "this" {
name = "/triple-c/${var.environment_name}"
retention_in_days = var.log_retention_days
}
# The environment record (EnvironmentInfo schema 1). Written last, so its
# presence means the environment is complete. The app treats it as untrusted.
resource "aws_ssm_parameter" "environment" {
name = "/triple-c/environments/${var.environment_name}"
type = "String"
tier = "Standard"
value = jsonencode({
schema = 1
name = var.environment_name
template_version = local.template_version
cluster_arn = aws_ecs_cluster.this.arn
security_group_id = aws_security_group.tasks.id
subnet_ids = local.task_subnet_ids
assign_public_ip = local.assign_public_ip
kms_key_arn = aws_kms_key.this.arn
log_group = aws_cloudwatch_log_group.this.name
network_mode = var.network_mode
tenancy = "strict"
})
depends_on = [
aws_ecs_cluster_capacity_providers.this,
aws_kms_alias.this,
aws_route.public_internet,
aws_route.private_nat,
aws_route_table_association.public,
aws_route_table_association.private,
]
}
+124
View File
@@ -0,0 +1,124 @@
# The network, by network_mode:
# public new VPC, two public subnets, internet gateway; tasks get a
# public IP (the security group still admits nothing).
# private-nat the same plus two private subnets behind one NAT gateway;
# tasks run in the private subnets without a public IP.
# existing the given vpc_id / subnet_ids; nothing is created here.
locals {
create_vpc = var.network_mode != "existing"
private_nat = var.network_mode == "private-nat"
vpc_cidr = "10.42.0.0/16"
vpc_id = local.create_vpc ? one(aws_vpc.this[*].id) : var.vpc_id
task_subnet_ids = (
var.network_mode == "existing" ? var.subnet_ids
: local.private_nat ? aws_subnet.private[*].id
: aws_subnet.public[*].id
)
assign_public_ip = (
var.network_mode == "existing" ? var.assign_public_ip
: var.network_mode == "public"
)
}
data "aws_availability_zones" "available" {
count = local.create_vpc ? 1 : 0
state = "available"
filter {
name = "opt-in-status"
values = ["opt-in-not-required"]
}
}
resource "aws_vpc" "this" {
count = local.create_vpc ? 1 : 0
cidr_block = local.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = local.name }
}
resource "aws_internet_gateway" "this" {
count = local.create_vpc ? 1 : 0
vpc_id = aws_vpc.this[0].id
tags = { Name = local.name }
}
resource "aws_subnet" "public" {
count = local.create_vpc ? 2 : 0
vpc_id = aws_vpc.this[0].id
cidr_block = cidrsubnet(local.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available[0].names[count.index]
tags = { Name = "${local.name}-public-${count.index}" }
}
resource "aws_route_table" "public" {
count = local.create_vpc ? 1 : 0
vpc_id = aws_vpc.this[0].id
tags = { Name = "${local.name}-public" }
}
resource "aws_route" "public_internet" {
count = local.create_vpc ? 1 : 0
route_table_id = aws_route_table.public[0].id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this[0].id
}
resource "aws_route_table_association" "public" {
count = local.create_vpc ? 2 : 0
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public[0].id
}
resource "aws_eip" "nat" {
count = local.private_nat ? 1 : 0
domain = "vpc"
tags = { Name = "${local.name}-nat" }
}
resource "aws_nat_gateway" "this" {
count = local.private_nat ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = { Name = local.name }
depends_on = [aws_internet_gateway.this]
}
resource "aws_subnet" "private" {
count = local.private_nat ? 2 : 0
vpc_id = aws_vpc.this[0].id
cidr_block = cidrsubnet(local.vpc_cidr, 8, 10 + count.index)
availability_zone = data.aws_availability_zones.available[0].names[count.index]
tags = { Name = "${local.name}-private-${count.index}" }
}
resource "aws_route_table" "private" {
count = local.private_nat ? 1 : 0
vpc_id = aws_vpc.this[0].id
tags = { Name = "${local.name}-private" }
}
resource "aws_route" "private_nat" {
count = local.private_nat ? 1 : 0
route_table_id = aws_route_table.private[0].id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[0].id
}
resource "aws_route_table_association" "private" {
count = local.private_nat ? 2 : 0
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[0].id
}
+32
View File
@@ -0,0 +1,32 @@
output "parameter_name" {
description = "The SSM parameter holding the environment record."
value = aws_ssm_parameter.environment.name
}
output "cluster_arn" {
value = aws_ecs_cluster.this.arn
}
output "security_group_id" {
value = aws_security_group.tasks.id
}
output "vpc_id" {
value = local.vpc_id
}
output "subnet_ids" {
value = local.task_subnet_ids
}
output "assign_public_ip" {
value = local.assign_public_ip
}
output "kms_key_arn" {
value = aws_kms_key.this.arn
}
output "log_group" {
value = aws_cloudwatch_log_group.this.name
}
+96
View File
@@ -0,0 +1,96 @@
# Every rule here mirrors the app's (`app/src-tauri/src/aws/tofu_modules.rs`),
# so an admin running a bundle by hand gets the same answers as the app.
variable "environment_name" {
description = "The environment's name: lowercase letters, digits and single hyphens, starting with a letter, at most 20 characters."
type = string
validation {
condition = (
can(regex("^[a-z][a-z0-9-]{0,19}$", var.environment_name))
&& !endswith(var.environment_name, "-")
&& !strcontains(var.environment_name, "--")
)
error_message = "environment_name must match ^[a-z][a-z0-9-]{0,19}$, not end in '-' and not contain '--'."
}
}
variable "region" {
description = "The AWS region to deploy into, e.g. us-west-2."
type = string
validation {
condition = (
can(regex("^[a-z]{2}(-[a-z]+)+-[0-9]+$", var.region))
&& length([for p in ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] : p if startswith(var.region, p)]) == 0
)
error_message = "region must be a commercial AWS region id such as us-west-2 (China and GovCloud are not supported)."
}
}
variable "network_mode" {
description = "public: a new VPC with two public subnets and no NAT. private-nat: a new VPC with two private subnets behind one NAT gateway (about $33/month). existing: the given vpc_id and subnet_ids."
type = string
default = "public"
validation {
condition = contains(["public", "private-nat", "existing"], var.network_mode)
error_message = "network_mode must be public, private-nat or existing."
}
}
variable "vpc_id" {
description = "network_mode = existing only: the VPC to run tasks in."
type = string
default = ""
validation {
condition = (
var.network_mode == "existing"
? can(regex("^vpc-([0-9a-f]{8}|[0-9a-f]{17})$", var.vpc_id))
: var.vpc_id == ""
)
error_message = "vpc_id must be a VPC id (vpc-xxxxxxxx) when network_mode = existing, and empty otherwise."
}
}
variable "subnet_ids" {
description = "network_mode = existing only: exactly two subnets of vpc_id, in different availability zones."
type = list(string)
default = []
validation {
condition = (
var.network_mode == "existing"
? (
length(var.subnet_ids) == 2
&& length(distinct(var.subnet_ids)) == 2
&& alltrue([for s in var.subnet_ids : can(regex("^subnet-([0-9a-f]{8}|[0-9a-f]{17})$", s))])
)
: length(var.subnet_ids) == 0
)
error_message = "subnet_ids must be exactly two distinct subnet ids (subnet-xxxxxxxx) when network_mode = existing, and empty otherwise."
}
}
variable "assign_public_ip" {
description = "network_mode = existing only: whether tasks get a public IP (needed in a public subnet without NAT)."
type = bool
default = false
validation {
condition = var.network_mode == "existing" || !var.assign_public_ip
error_message = "assign_public_ip applies only to network_mode = existing; the other modes decide it themselves."
}
}
variable "log_retention_days" {
description = "How long the environment's log group keeps task logs."
type = number
default = 30
validation {
condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_days)
error_message = "log_retention_days must be a CloudWatch Logs retention value (1, 3, 5, 7, 14, 30, 60, 90, ...)."
}
}
+15
View File
@@ -0,0 +1,15 @@
terraform {
# OpenTofu 1.10+ or Terraform 1.10+: S3-native state locking (`use_lockfile`).
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.73.0"
}
}
# Filled in by the app (or an admin bundle's backend.hcl) with
# `-backend-config`: bucket, key, region, use_lockfile = true.
backend "s3" {}
}