feat(aws): environment Terraform module and naming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe7642ee2c
commit
3583bee31e
12 files changed
+1270
No files matched your search
@@ -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
|
||||
Generated
+81
@@ -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"
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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(¶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::<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(¶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);
|
||||
}
|
||||
+39
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ...)."
|
||||
}
|
||||
}
|
||||
@@ -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" {}
|
||||
}
|
||||
Reference in new issue
Block a user