From 3583bee31e29f728e12be2dd7eb5c90772083e35 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 20:50:55 -0800 Subject: [PATCH] feat(aws): environment Terraform module and naming Co-Authored-By: Claude Opus 5.5 --- .gitignore | 7 + app/src-tauri/Cargo.lock | 81 +++ app/src-tauri/Cargo.toml | 2 + app/src-tauri/src/aws/mod.rs | 1 + app/src-tauri/src/aws/tofu_modules.rs | 217 +++++++ app/src-tauri/src/aws/tofu_modules/tests.rs | 533 ++++++++++++++++++ aws/terraform/environment/.terraform.lock.hcl | 39 ++ aws/terraform/environment/main.tf | 123 ++++ aws/terraform/environment/network.tf | 124 ++++ aws/terraform/environment/outputs.tf | 32 ++ aws/terraform/environment/variables.tf | 96 ++++ aws/terraform/environment/versions.tf | 15 + 12 files changed, 1270 insertions(+) create mode 100644 app/src-tauri/src/aws/tofu_modules.rs create mode 100644 app/src-tauri/src/aws/tofu_modules/tests.rs create mode 100644 aws/terraform/environment/.terraform.lock.hcl create mode 100644 aws/terraform/environment/main.tf create mode 100644 aws/terraform/environment/network.tf create mode 100644 aws/terraform/environment/outputs.tf create mode 100644 aws/terraform/environment/variables.tf create mode 100644 aws/terraform/environment/versions.tf diff --git a/.gitignore b/.gitignore index acfd1df..7a0af42 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 260bbfd..3b5a9e6 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -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" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8a922ec..c12a00e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -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 = [] } diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 2cc45ab..f51e5ee 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -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; diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs new file mode 100644 index 0000000..bd15950 --- /dev/null +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -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 { + 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, + 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 { + 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; diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs new file mode 100644 index 0000000..0e50436 --- /dev/null +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -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::>() + .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 + '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 { + 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(¶ms[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::()[..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::().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(¶m.body, "type"), + Some(&Expression::String("String".into())) + ); + assert_eq!( + render(attr(¶m.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); +} diff --git a/aws/terraform/environment/.terraform.lock.hcl b/aws/terraform/environment/.terraform.lock.hcl new file mode 100644 index 0000000..55c488f --- /dev/null +++ b/aws/terraform/environment/.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.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", + ] +} diff --git a/aws/terraform/environment/main.tf b/aws/terraform/environment/main.tf new file mode 100644 index 0000000..9cd9d74 --- /dev/null +++ b/aws/terraform/environment/main.tf @@ -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, + ] +} diff --git a/aws/terraform/environment/network.tf b/aws/terraform/environment/network.tf new file mode 100644 index 0000000..67a6a59 --- /dev/null +++ b/aws/terraform/environment/network.tf @@ -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 +} diff --git a/aws/terraform/environment/outputs.tf b/aws/terraform/environment/outputs.tf new file mode 100644 index 0000000..0a7f9c7 --- /dev/null +++ b/aws/terraform/environment/outputs.tf @@ -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 +} diff --git a/aws/terraform/environment/variables.tf b/aws/terraform/environment/variables.tf new file mode 100644 index 0000000..7e50ba7 --- /dev/null +++ b/aws/terraform/environment/variables.tf @@ -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, ...)." + } +} diff --git a/aws/terraform/environment/versions.tf b/aws/terraform/environment/versions.tf new file mode 100644 index 0000000..a3efb1c --- /dev/null +++ b/aws/terraform/environment/versions.tf @@ -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" {} +}