test(aws): live environment lifecycle with OpenTofu as an environment admin; docs

The environment live tests apply the real modules through the real
detached runner (the built app binary) as a role holding only the admin
files: state bucket, public deploy, scan, self-onboard, update with no
changes, destroy; the conditional lock delete (412 on a stale ETag); and
a private-nat deploy. A guard destroys what is left as the account admin
and deletes the throwaway bucket. CLAUDE.md and the spec describe the
OpenTofu design (D2-D10) in place of CloudFormation; phase 4 is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 23:31:49 -08:00
1 parent 5f38e0d0d2
commit ba88323feb
5 files changed
+874 -51

No files matched your search

+21 -1
View File
@@ -327,7 +327,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored
--test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles,
secrets and keys, and cleans them up. Re-run it after any policy change; the results are in
`aws/README.md` ("Live verification").
`aws/README.md` ("Live verification"). Its `environment` tests apply the real modules as a role
holding only the admin files (`cargo build` first: the runner they start is the built app
binary), in a throwaway state bucket.
**Environments are OpenTofu modules** (`aws/terraform/{environment,user-onboard,permissions-boundary}`;
spec D2–D10). `tofu_modules.rs` embeds them and is the **only** place the shared names are built
(environment, `/triple-c/environments/<env>` record, owner key, per-user roles, state bucket
`triple-c-tfstate-<account>-<region>`, state keys); its tests pin the HCL to those names, the
versions and tags, and `MODULE_CALLS` (every provider call) to the admin policy.
`tofu.rs` downloads the pinned OpenTofu (SHA-256 checked **before** extraction), writes a work
folder (`materialize`), builds tofu's environment from scratch (`child_env`: no inherited
`AWS_*`/`TF_*`, keys only in the environment) and runs `init` + `apply|destroy` **detached
(D10)**: the app binary re-executed with `--tofu-runner` (`main.rs` checks that first), in its
own session, logging to `apply.log`; liveness is the runner's `apply.lock`, never a PID; closing
the app never stops a run and the next start re-follows the log. `environments.rs` is detection
(records + state objects + locks; records are untrusted and capped; an empty state, which
`tofu destroy` leaves behind, is no deployment) and `ensure_state_bucket`;
`commands/environment_commands.rs` sequences ensure_tofu → ensure_state_bucket → materialize →
start → follow. **D2: strict tenancy gives each user their own EFS file system** (its policy
admits only their task role) from their onboarding module; the environment keeps only the shared
pieces. The app destroys only incomplete deployments (D6); a working environment is removed with
`tofu destroy` from an admin bundle (D5).
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
+708 -1
View File
@@ -289,6 +289,11 @@ impl E2e {
/// An SDK config for a session of `role`, once IAM lets it be assumed
/// and its policies have propagated.
async fn assume(&self, role: &Role) -> SdkConfig {
sdk_config_for_keys(&self.assume_keys(role).await, &self.region)
}
/// A session of `role` as keys (see [`E2e::assume`]).
async fn assume_keys(&self, role: &Role) -> AwsKeys {
let mut last = None;
for _ in 0..20 {
match self
@@ -309,7 +314,7 @@ impl E2e {
// New sessions see a just-attached policy after a few
// seconds; denials in that window would be noise.
tokio::time::sleep(Duration::from_secs(10)).await;
return sdk_config_for_keys(&keys, &self.region);
return keys;
}
Err(e) => {
last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string());
@@ -957,3 +962,705 @@ async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() {
})
.await;
}
/// Phase 4: OpenTofu environments, deployed for real by a role that holds
/// only the environment-admin policies.
///
/// ```sh
/// cd app/src-tauri
/// cargo build # the runner is the app binary re-executed (see below)
/// TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \
/// cargo test aws::live_tests::environment -- --ignored --test-threads=1
/// ```
///
/// What runs the way the app runs it: `ensure_tofu` (the pinned download),
/// `ensure_state_bucket`'s configuration, `materialize`, `child_env` (a keys
/// connection for the environment admin, a profile connection for the
/// account admin), the detached runner — the **built app binary**
/// (`target/debug/triple-c`, or `TRIPLEC_E2E_APP_BIN`) re-executed with
/// `--tofu-runner`, because the test harness is not the app and can't
/// re-execute itself as one — `follow`, `scan`, the refusal checks the
/// commands make, and the conditional lock delete. What doesn't: the Tauri
/// commands themselves (they need an `AppHandle` and saved settings), so the
/// slot/follower bookkeeping and the event stream are not exercised here.
///
/// Extra variables:
/// - `TRIPLEC_E2E_BUCKET_SUFFIX`: the state bucket is
/// `triple-c-tfstate-<account>-<region>-<suffix>` (default: random), so the
/// account's real one is never touched;
/// - `TRIPLEC_E2E_TOFU_ROOT`: where OpenTofu, the provider cache and the work
/// folders go (default: `<tmp>/triple-c-e2e-tofu`, kept between runs).
mod environment {
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use aws_config::SdkConfig;
use super::{e2e, ok, E2e, Role};
use crate::aws::environments::{
ensure_named_state_bucket, is_empty_state, scan_in_bucket, EnvironmentAction, EnvironmentState, Knowable,
OnboardingState, SdkDetectApi,
};
use crate::aws::policies::{policy_files, Tier, PERMISSIONS_BOUNDARY_NAME};
use crate::aws::tofu::{
self, child_env, ensure_tofu, follow, materialize, start_detached_with, TofuOp,
TofuOutcome, Workdir,
};
use crate::aws::tofu_modules::{
boundary_vars, environment_vars, onboarding_vars, owner_key, state_bucket_name, state_key,
user_role_names, Deployment, ModuleKind, NetworkChoice,
};
use crate::aws::{caller_identity, sdk_config_for_keys, CallerIdentity};
use crate::commands::environment_commands::{
delete_lock_if_unchanged, deploy_refusal, network_from_record, onboard_refusal,
validate_owner_id, LOCK_CHANGED,
};
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use crate::storage::secure::AwsKeys;
/// The environment admin: a role holding the admin tier's policy files
/// as managed policies, and nothing else.
struct Admin {
role: Role,
}
struct Env {
e: Arc<E2e>,
root: PathBuf,
tofu: PathBuf,
app_bin: PathBuf,
bucket: String,
/// Deployments applied (work folder, still to destroy), newest last.
applied: std::sync::Mutex<Vec<(Workdir, bool)>>,
/// The test created the boundary (through its module).
boundary_created: std::sync::Mutex<bool>,
bucket_created: std::sync::Mutex<bool>,
}
fn app_bin() -> PathBuf {
if let Ok(p) = std::env::var("TRIPLEC_E2E_APP_BIN") {
return PathBuf::from(p);
}
// target/debug/deps/<test binary> -> target/debug/triple-c
let exe = std::env::current_exe().expect("current exe");
let dir = exe.parent().and_then(|d| d.parent()).expect("target dir");
dir.join(format!("triple-c{}", std::env::consts::EXE_SUFFIX))
}
fn keys_connection(region: &str) -> HostConnection {
HostConnection {
id: "e2e-envadmin".into(),
name: "e2e-envadmin".into(),
kind: HostKind::Aws {
auth: AwsAuth::Keys {
keyring_ref: "unused".into(),
},
default_region: region.to_string(),
},
}
}
fn profile_connection(region: &str) -> HostConnection {
HostConnection {
id: "e2e".into(),
name: "e2e".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile {
name: super::var("TRIPLEC_AWS_E2E_PROFILE"),
},
default_region: region.to_string(),
},
}
}
/// `not authorized to perform: <action>` (and the KMS/S3 phrasings) in a
/// log message.
fn denied_actions(message: &str) -> Vec<String> {
let mut out = Vec::new();
for marker in ["not authorized to perform: ", "is not authorized to perform "] {
let mut rest = message;
while let Some(i) = rest.find(marker) {
rest = &rest[i + marker.len()..];
let action: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '*')
.collect();
if !action.is_empty() && !out.contains(&action) {
out.push(action);
}
}
}
if out.is_empty()
&& (message.contains("AccessDenied") || message.contains("UnauthorizedOperation"))
{
out.push("(unnamed: see the message above)".into());
}
out
}
impl Env {
async fn new(e: Arc<E2e>) -> Arc<Env> {
let root = std::env::var("TRIPLEC_E2E_TOFU_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("triple-c-e2e-tofu"));
std::fs::create_dir_all(&root).unwrap();
let app_bin = app_bin();
assert!(
app_bin.is_file(),
"{} is missing: run `cargo build` first (the runner is the app binary)",
app_bin.display()
);
let tofu = ensure_tofu(&root).await.expect("ensure_tofu");
let suffix = std::env::var("TRIPLEC_E2E_BUCKET_SUFFIX").unwrap_or_else(|_| {
format!("e2e{}", &uuid::Uuid::new_v4().simple().to_string()[..8])
});
let bucket = format!("{}-{suffix}", state_bucket_name(&e.account, &e.region));
assert!(bucket.len() <= 63, "{bucket} is too long for a bucket name");
Arc::new(Env {
e,
root,
tofu,
app_bin,
bucket,
applied: Default::default(),
boundary_created: Default::default(),
bucket_created: Default::default(),
})
}
/// The environment admin role (no boundary on the role itself: the
/// boundary allows only what tasks do, so an admin under it could
/// administer nothing).
async fn admin(&self) -> Admin {
let role = self.e.role("envadmin", None, &[]).await;
for (i, (file, json)) in policy_files(Tier::EnvironmentAdmin).iter().enumerate() {
let arn = self.e.managed_policy(&format!("envadmin{i}"), json).await;
eprintln!("envadmin: {file} -> {arn}");
self.e.attach(&role, &arn).await;
}
Admin { role }
}
/// A fresh session of the admin role (role chaining caps one at an
/// hour, so each phase takes its own).
async fn session(&self, admin: &Admin) -> (AwsKeys, SdkConfig, CallerIdentity) {
let keys = self.e.assume_keys(&admin.role).await;
let cfg = sdk_config_for_keys(&keys, &self.e.region);
let who = caller_identity(&cfg).await.expect("envadmin identity");
(keys, cfg, who)
}
async fn scan(&self, cfg: &SdkConfig, who: &CallerIdentity) -> crate::aws::environments::EnvironmentScan {
let api = SdkDetectApi::new(cfg, &who.account);
let (root, bucket) = (self.root.clone(), self.bucket.clone());
let running = move |d: Deployment| {
tofu::workdir_for(&root, d, &bucket)
.map(|wd| tofu::is_running(&wd))
.unwrap_or(false)
};
scan_in_bucket(&api, who, &self.e.region, &self.bucket, &running)
.await
.expect("scan")
}
/// Write the module and run `op` through the detached runner, printing
/// the log and every denied action.
async fn run(
&self,
kind: ModuleKind,
d: Deployment<'_>,
vars: &serde_json::Value,
op: TofuOp,
env: Vec<(String, String)>,
) -> (Workdir, TofuOutcome, Vec<String>) {
let wd = materialize(&self.root, kind, d, &self.bucket, &self.e.region, vars)
.expect("materialize");
if op == TofuOp::Apply {
let mut applied = self.applied.lock().unwrap();
if !applied.iter().any(|(w, _)| *w == wd) {
applied.push((wd.clone(), true));
}
}
let label = state_key(d);
eprintln!("=== tofu {op:?} {label} ({})", wd.dir.display());
let (bin, tofu_bin, start_wd) = (self.app_bin.clone(), self.tofu.clone(), wd.clone());
let started = tokio::task::spawn_blocking(move || {
start_detached_with(&bin, Duration::from_secs(30), &tofu_bin, &start_wd, op, &env)
})
.await
.unwrap()
.unwrap_or_else(|e| panic!("start {label}: {e}"));
assert!(started.pid.is_some(), "the runner wrote no PID");
let mut denied = Vec::new();
let outcome = follow(&wd, Duration::from_millis(500), |events| {
for ev in events {
let resource = ev.resource.as_deref().unwrap_or("");
let short: String = ev.message.chars().take(300).collect();
eprintln!(" [{}] {} {} {}", ev.level, ev.kind, resource, short);
if ev.level == "error" {
eprintln!(" ERROR DETAIL: {}", ev.message);
for a in denied_actions(&ev.message) {
eprintln!(" DENIED: {a} ({resource})");
if !denied.contains(&a) {
denied.push(a);
}
}
}
}
})
.await
.expect("follow");
eprintln!("=== {label}: {outcome:?}");
if op == TofuOp::Destroy && outcome.ok {
for (w, pending) in self.applied.lock().unwrap().iter_mut() {
if *w == wd {
*pending = false;
}
}
}
(wd, outcome, denied)
}
fn keys_env(&self, keys: &AwsKeys) -> Vec<(String, String)> {
child_env(
&keys_connection(&self.e.region),
Some(keys),
&self.root,
std::env::vars(),
)
.expect("child env")
}
fn admin_env(&self) -> Vec<(String, String)> {
child_env(&profile_connection(&self.e.region), None, &self.root, std::env::vars())
.expect("child env")
}
async fn ensure_bucket(&self, cfg: &SdkConfig, who: &CallerIdentity) {
*self.bucket_created.lock().unwrap() = true;
let b = ensure_named_state_bucket(cfg, &who.account, &self.e.region, &self.bucket)
.await
.unwrap_or_else(|e| panic!("ensure_state_bucket as the environment admin: {e}"));
assert_eq!(b, self.bucket);
}
/// The boundary, through its module as the account admin, if the
/// account has none.
async fn ensure_boundary(&self) {
let arn = format!(
"arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}",
self.e.account
);
if self.e.iam.get_policy().policy_arn(&arn).send().await.is_ok() {
eprintln!("the account already has the boundary; using it");
return;
}
*self.boundary_created.lock().unwrap() = true;
let (_, outcome, denied) = self
.run(
ModuleKind::PermissionsBoundary,
Deployment::Boundary,
&boundary_vars(&self.e.region),
TofuOp::Apply,
self.admin_env(),
)
.await;
assert!(outcome.ok, "boundary apply: {outcome:?} denied {denied:?}");
}
/// As the account admin: destroy whatever is still applied (newest
/// first), the bucket with every version, and the boundary if this
/// test made it. Logs rather than fails, so one error strands nothing
/// else.
async fn cleanup(&self) {
let pending: Vec<Workdir> = self
.applied
.lock()
.unwrap()
.iter()
.rev()
.filter(|(_, p)| *p)
.map(|(w, _)| w.clone())
.collect();
for wd in pending {
eprintln!("cleanup: destroying {} as the account admin", wd.dir.display());
let env = self.admin_env();
let (bin, tofu_bin, w) = (self.app_bin.clone(), self.tofu.clone(), wd.clone());
let started = tokio::task::spawn_blocking(move || {
start_detached_with(&bin, Duration::from_secs(30), &tofu_bin, &w, TofuOp::Destroy, &env)
})
.await;
match started {
Ok(Ok(_)) => {
let outcome = follow(&wd, Duration::from_millis(500), |events| {
for ev in events.iter().filter(|e| e.level == "error") {
eprintln!(" cleanup error: {}", ev.message);
}
})
.await;
eprintln!("cleanup: destroy {}: {outcome:?}", wd.dir.display());
}
other => eprintln!("cleanup: could not start destroy: {other:?}"),
}
}
if *self.boundary_created.lock().unwrap() {
let arn = format!(
"arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}",
self.e.account
);
if self.e.iam.get_policy().policy_arn(&arn).send().await.is_ok() {
eprintln!("cleanup: deleting the boundary directly");
if let Ok(out) = self.e.iam.list_entities_for_policy().policy_arn(&arn).send().await {
for r in out.policy_roles() {
if let Some(name) = r.role_name() {
let _ = self
.e
.iam
.delete_role_permissions_boundary()
.role_name(name)
.send()
.await;
}
}
}
if let Err(e) = self.e.iam.delete_policy().policy_arn(&arn).send().await {
eprintln!("cleanup: boundary: {e:?}");
}
}
}
if *self.bucket_created.lock().unwrap() {
self.delete_bucket().await;
}
}
async fn delete_bucket(&self) {
let s3 = aws_sdk_s3::Client::new(&self.e.cfg);
loop {
let page = match s3.list_object_versions().bucket(&self.bucket).send().await {
Ok(p) => p,
Err(e) => {
use aws_sdk_s3::error::ProvideErrorMetadata;
if e.code() != Some("NoSuchBucket") {
eprintln!("cleanup: list versions of {}: {e:?}", self.bucket);
}
return;
}
};
let mut ids = Vec::new();
for v in page.versions() {
ids.push((v.key().unwrap_or_default().to_string(), v.version_id().map(str::to_string)));
}
for m in page.delete_markers() {
ids.push((m.key().unwrap_or_default().to_string(), m.version_id().map(str::to_string)));
}
if ids.is_empty() {
break;
}
for (key, version) in ids {
if let Err(e) = s3
.delete_object()
.bucket(&self.bucket)
.key(&key)
.set_version_id(version)
.send()
.await
{
eprintln!("cleanup: delete {key}: {e:?}");
return;
}
}
}
match s3.delete_bucket().bucket(&self.bucket).send().await {
Ok(_) => eprintln!("cleanup: deleted bucket {}", self.bucket),
Err(e) => eprintln!("cleanup: delete bucket {}: {e:?}", self.bucket),
}
}
}
/// `e2e` plus the environment fixture's own cleanup, which runs first
/// (it needs the account admin, and the roles it destroys hold the
/// boundary).
async fn env_e2e<F, Fut>(body: F)
where
F: FnOnce(Arc<Env>) -> Fut,
Fut: std::future::Future<Output = ()>,
{
use futures_util::FutureExt;
e2e(|e| async move {
let env = Env::new(e).await;
let result = std::panic::AssertUnwindSafe(body(env.clone()))
.catch_unwind()
.await;
env.cleanup().await;
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
})
.await;
}
fn assert_ok(what: &str, outcome: &TofuOutcome, denied: &[String]) {
assert!(
outcome.ok,
"{what} failed: {:?}; denied actions: {denied:?}",
outcome.summary
);
assert!(denied.is_empty(), "{what} reported denied actions: {denied:?}");
}
#[tokio::test]
#[ignore]
async fn an_environment_admin_can_deploy_join_update_and_remove_an_environment() {
env_e2e(|t| async move {
let admin = t.admin().await;
let (keys, cfg, who) = t.session(&admin).await;
assert!(who.arn.contains(":assumed-role/"), "{}", who.arn);
// The state bucket, created and configured by the environment
// admin (s3:CreateBucket with the request tag, in-account).
t.ensure_bucket(&cfg, &who).await;
// Idempotent.
t.ensure_bucket(&cfg, &who).await;
t.ensure_boundary().await;
let name = format!("e2e{}", &uuid::Uuid::new_v4().simple().to_string()[..6]);
let found = t.scan(&cfg, &who).await;
assert_eq!(found.state_bucket, Knowable::Yes);
assert_eq!(found.boundary, Knowable::Yes);
deploy_refusal(&found, &name).expect("deploy is offered");
// Deploy, public network, as the environment admin only.
let vars = environment_vars(&name, &t.e.region, &NetworkChoice::Public).unwrap();
let (_, outcome, denied) = t
.run(
ModuleKind::Environment,
Deployment::Environment(&name),
&vars,
TofuOp::Apply,
t.keys_env(&keys),
)
.await;
assert_ok("deploy", &outcome, &denied);
// Detected Ready with a valid record, joinable, not joined yet.
let found = t.scan(&cfg, &who).await;
let env = found
.environments
.iter()
.find(|x| x.name == name)
.unwrap_or_else(|| panic!("{name} not detected: {found:#?}"));
assert_eq!(env.state, EnvironmentState::Ready, "{env:#?}");
let info = env.info.clone().expect("a valid record");
assert_eq!(info.network_mode, "public");
assert!(info.assign_public_ip);
assert_eq!(info.subnet_ids.len(), 2);
assert!(env.actions.contains(&EnvironmentAction::Join), "{env:#?}");
assert_eq!(env.onboarding, Some(OnboardingState::NotJoined));
// A stale lock's conditional delete: a mismatched ETag is refused
// (412), the current one removes it.
{
let s3 = aws_sdk_s3::Client::new(&cfg);
let lock_key = format!("{}.tflock", state_key(Deployment::Environment(&name)));
let put = ok(
"put a fake lock",
s3.put_object()
.bucket(&t.bucket)
.key(&lock_key)
.body(r#"{"ID":"e2e","Operation":"OperationTypeApply","Who":"e2e@test","Created":"2026-01-01T00:00:00Z"}"#.as_bytes().to_vec().into())
.expected_bucket_owner(&who.account)
.send()
.await,
);
let etag = put.e_tag().expect("etag").to_string();
let head = ok(
"head the lock",
s3.head_object().bucket(&t.bucket).key(&lock_key).send().await,
);
assert_eq!(head.e_tag(), Some(etag.as_str()));
let found = t.scan(&cfg, &who).await;
let env = found.environments.iter().find(|x| x.name == name).unwrap();
assert!(
matches!(&env.state, EnvironmentState::Deploying { lock: Some(l) } if l.who == "e2e@test"),
"a fresh foreign lock reads as Deploying: {env:#?}"
);
let wrong = delete_lock_if_unchanged(
&s3,
&t.bucket,
&who.account,
&lock_key,
"\"00000000000000000000000000000000\"",
)
.await
.expect_err("a mismatched ETag must not delete the lock");
assert!(wrong.to_string().contains(LOCK_CHANGED), "{wrong}");
ok(
"the lock survives a mismatched delete",
s3.head_object().bucket(&t.bucket).key(&lock_key).send().await,
);
delete_lock_if_unchanged(&s3, &t.bucket, &who.account, &lock_key, &etag)
.await
.unwrap_or_else(|e| panic!("conditional delete with the current ETag: {e}"));
let gone = s3.head_object().bucket(&t.bucket).key(&lock_key).send().await;
assert!(gone.is_err(), "the lock is still there after the delete");
eprintln!("conditional unlock: 412 on a mismatched ETag, deleted with the current one");
}
// Self-onboarding as the environment admin.
let (keys, cfg, who) = t.session(&admin).await;
let found = t.scan(&cfg, &who).await;
onboard_refusal(&found, &name).expect("join is offered");
let owner = validate_owner_id(&who.user_id).expect("owner id");
let okey = owner_key(&owner);
let (_, outcome, denied) = t
.run(
ModuleKind::UserOnboard,
Deployment::User { env: &name, owner_key: &okey },
&onboarding_vars(&name, &t.e.region, &owner),
TofuOp::Apply,
t.keys_env(&keys),
)
.await;
assert_ok("onboard", &outcome, &denied);
let found = t.scan(&cfg, &who).await;
let env = found.environments.iter().find(|x| x.name == name).unwrap();
assert_eq!(env.onboarding, Some(OnboardingState::Joined), "{env:#?}");
let (task_role, exec_role) = user_role_names(&name, &okey);
for r in [&task_role, &exec_role] {
let role = ok("get the onboarded role", t.e.iam.get_role().role_name(r).send().await);
let boundary = role
.role()
.and_then(|r| r.permissions_boundary())
.and_then(|b| b.permissions_boundary_arn())
.unwrap_or_default()
.to_string();
assert!(boundary.ends_with(&format!(":policy/{PERMISSIONS_BOUNDARY_NAME}")), "{r}: {boundary}");
}
// Update: the recorded network, the same module version: nothing
// to change.
let network = network_from_record(&info, None).expect("network from record");
let vars = environment_vars(&name, &t.e.region, &network).unwrap();
let (_, outcome, denied) = t
.run(
ModuleKind::Environment,
Deployment::Environment(&name),
&vars,
TofuOp::Apply,
t.keys_env(&keys),
)
.await;
assert_ok("update", &outcome, &denied);
let summary = outcome.summary.clone().unwrap_or_default();
assert!(
summary.contains("0 added, 0 changed, 0 destroyed"),
"the update changed something: {summary}"
);
// Remove: the onboarding, then the environment (an admin's
// `tofu destroy` from a bundle; the same module and state).
let (keys, _, _) = t.session(&admin).await;
let (_, outcome, denied) = t
.run(
ModuleKind::UserOnboard,
Deployment::User { env: &name, owner_key: &okey },
&onboarding_vars(&name, &t.e.region, &owner),
TofuOp::Destroy,
t.keys_env(&keys),
)
.await;
assert_ok("destroy the onboarding", &outcome, &denied);
let (_, outcome, denied) = t
.run(
ModuleKind::Environment,
Deployment::Environment(&name),
&vars,
TofuOp::Destroy,
t.keys_env(&keys),
)
.await;
assert_ok("destroy the environment", &outcome, &denied);
// `tofu destroy` leaves an empty state object, which detection
// must read as no deployment at all.
let state = ok(
"read the destroyed state",
aws_sdk_s3::Client::new(&cfg)
.get_object()
.bucket(&t.bucket)
.key(state_key(Deployment::Environment(&name)))
.send()
.await,
);
let bytes = state.body.collect().await.unwrap().into_bytes();
assert!(
is_empty_state(&String::from_utf8_lossy(&bytes)),
"the destroyed state is not empty: {}",
String::from_utf8_lossy(&bytes)
);
let found = t.scan(&cfg, &who).await;
assert!(
found.environments.iter().all(|x| x.name != name),
"{name} still detected after destroy: {found:#?}"
);
// The boundary, by its module, if this test made it.
if *t.boundary_created.lock().unwrap() {
let (_, outcome, denied) = t
.run(
ModuleKind::PermissionsBoundary,
Deployment::Boundary,
&boundary_vars(&t.e.region),
TofuOp::Destroy,
t.admin_env(),
)
.await;
assert_ok("destroy the boundary", &outcome, &denied);
}
})
.await;
}
/// About $0.05: one NAT gateway and its address for a few minutes.
#[tokio::test]
#[ignore]
async fn the_private_nat_mode_deploys() {
env_e2e(|t| async move {
let admin = t.admin().await;
let (keys, cfg, who) = t.session(&admin).await;
t.ensure_bucket(&cfg, &who).await;
let name = format!("e2e{}", &uuid::Uuid::new_v4().simple().to_string()[..6]);
let vars = environment_vars(&name, &t.e.region, &NetworkChoice::PrivateNat).unwrap();
let (_, outcome, denied) = t
.run(
ModuleKind::Environment,
Deployment::Environment(&name),
&vars,
TofuOp::Apply,
t.keys_env(&keys),
)
.await;
assert_ok("deploy private-nat", &outcome, &denied);
let found = t.scan(&cfg, &who).await;
let env = found.environments.iter().find(|x| x.name == name).unwrap();
assert_eq!(env.state, EnvironmentState::Ready, "{env:#?}");
let info = env.info.as_ref().unwrap();
assert_eq!(info.network_mode, "private-nat");
assert!(!info.assign_public_ip);
let (keys, _, _) = t.session(&admin).await;
let (_, outcome, denied) = t
.run(
ModuleKind::Environment,
Deployment::Environment(&name),
&vars,
TofuOp::Destroy,
t.keys_env(&keys),
)
.await;
assert_ok("destroy private-nat", &outcome, &denied);
})
.await;
}
}
+4 -1
View File
@@ -706,7 +706,10 @@ pub fn start_detached(
/// After the runner takes its lock, how long to wait for its PID file.
const PID_GRACE: Duration = Duration::from_secs(2);
fn start_detached_with(
/// [`start_detached`] with the runner program and start timeout given: the
/// unit tests pass a fake runner, the live tests the built app binary (the
/// test harness is not the app, so it can't re-execute itself as one).
pub(crate) fn start_detached_with(
program: &Path,
timeout: Duration,
tofu: &Path,
@@ -1095,24 +1095,38 @@ pub async fn unlock_aws_state(
.map(|t| now.saturating_sub(t.secs()).max(0) as u64)
.ok_or_else(|| other("The state lock's age is unknown, so it can't be removed safely"))?;
stale_lock_refusal(age).map_err(other)?;
delete_lock_if_unchanged(&s3, &ctx.bucket(), &ctx.who.account, &lock_key, &etag).await?;
log::info!("Removed the stale state lock {lock_key}");
Ok(())
}
/// The lock was replaced since it was read: its ETag no longer matches.
pub(crate) const LOCK_CHANGED: &str = "The state lock changed since it was checked; detect again";
/// Delete `lock_key` only if its ETag is still `etag` (S3 conditional
/// delete, `If-Match`); [`LOCK_CHANGED`] when it is not.
pub(crate) async fn delete_lock_if_unchanged(
s3: &aws_sdk_s3::Client,
bucket: &str,
account: &str,
lock_key: &str,
etag: &str,
) -> Result<(), AwsError> {
if let Err(e) = s3
.delete_object()
.bucket(ctx.bucket())
.key(&lock_key)
.if_match(&etag)
.expected_bucket_owner(&ctx.who.account)
.bucket(bucket)
.key(lock_key)
.if_match(etag)
.expected_bucket_owner(account)
.send()
.await
{
let status = e.raw_response().map(|r| r.status().as_u16());
if is_precondition_failed(e.code(), status) {
return Err(other(
"The state lock changed since it was checked; detect again",
));
return Err(other(LOCK_CHANGED));
}
return Err(crate::aws::classify_sdk_error("s3:DeleteObject", &e));
}
log::info!("Removed the stale state lock {lock_key}");
Ok(())
}
@@ -1,7 +1,7 @@
# Triple-C on AWS (ECS on EC2 / Fargate) — design
Date: 2026-11-13
Status: **draft for review** (user decisions recorded 2026-11-13). The user set the direction (local or AWS, login like the AWS CLI,
Status: **draft for review** (user decisions recorded 2026-11-13 and 2026-11-14; phase 4 done). The user set the direction (local or AWS, login like the AWS CLI,
permission check, hosting options, secrets pushed automatically, shared accounts). Everything else
here is a proposal.
@@ -31,6 +31,48 @@ The AWS flow, in the user's words:
| Tenancy | **Strict from v1, and the default.** Most people will set this up in their own accounts. Strict costs a single user nothing extra (they onboard themselves automatically), and it means the setup is already safe the day a second person joins |
| Connection model | **C, hybrid.** A connection is a login (profile or keys) plus a **default region**. Environments are detected per region when needed; each project records `(connection, region, environment)`. See [Connection model](#connection-model) |
### Decisions (user, 2026-11-14): environments are OpenTofu
The user chose **Terraform over CloudFormation**, the **app runs it** itself, with **OpenTofu**
(MPL-2.0, safe for the app to download and run), and **state in an S3 bucket** in the account.
Phase 4 added these decisions:
- **D2. Strict tenancy gives each user their own EFS file system**, created by their onboarding
module. EFS can't bind an access point to its owner's task role (`ClientMount` sees the access
point's ARN, not its tags, and access-point IDs are random), so the file-system policy admits
only that user's task role. An empty file system costs nothing. The environment keeps the shared
pieces: cluster, security group, KMS key, log group and network.
- **D3. No ECR in v1.** Tasks pull `ghcr.io/shadowdao/triple-c-sandbox` straight from GHCR (the
default when an environment has no ECR mirror).
- **D4. Network modes** (`network_mode`): `public` (**default**: a new VPC, two public subnets,
tasks get a public IP, the security group has no inbound rules, no NAT, $0); `private-nat` (a new
VPC with two public and two private subnets and one NAT gateway; the UI says about $33/month);
`existing` (the user's `vpc_id`, exactly two `subnet_ids` in different AZs, and
`assign_public_ip`).
- **D5. An admin request is a folder bundle**, written into a folder picked through a Rust-side
dialog: the module's files, `backend.hcl`, `terraform.tfvars.json` and a `README.txt` with the
exact commands (`tofu init -backend-config=backend.hcl`, then `tofu apply`; Terraform 1.10+
works too).
- **D6. The app destroys only incomplete deployments** (state without an environment record, or
a failed apply). A working environment is destroyed by an admin with `tofu destroy` from a
bundle, which is the irreversible case.
- **D7. The admin tier spans several policy files** (`environment-admin.json` and
`environment-admin-network.json`), all required; `aws_policy_document` returns every file of a
tier.
- **D8. CloudFormation left the policies**, with its actions, checks and probes. Detection for a
workspace user reads only `ssm:GetParameter*` on `/triple-c/*` and `iam:GetRole` on
`role/triple-c-*`; the bucket (state objects and locks) is admin-only, so a workspace user sees
only environments that have a record.
- **D9. One state key per deployment**, in the per-account, per-region bucket
`triple-c-tfstate-<account>-<region>`: `environments/<env>/terraform.tfstate`,
`users/<env>/<ownerkey>/terraform.tfstate`, `boundary/terraform.tfstate`. The lock is the
matching `.tflock` object (S3-native locking, `use_lockfile = true`); a held lock is a deploy in
progress, and its `Who`/`Created`/`Operation` are shown.
- **D10. Applies are detached.** `tofu apply` runs in its own process group, output to
`<workdir>/apply.log`, PID in `<workdir>/apply.pid`. Closing the app doesn't interrupt it; on the
next run the app sees the live runner or the held lock and reattaches by following the log. It
interrupts only when the user presses Cancel, and OpenTofu then stops gracefully and saves state.
### Non-goals (v1)
- Kubernetes/EKS, other clouds, or remote Docker hosts. The host abstraction below allows them
@@ -83,40 +125,48 @@ users don't need the AWS CLI. Only SSO login needs it, until the device flow lan
Triple-C looks for existing Triple-C infrastructure in the region in two ways, so it finds an
environment even if one of them is missing:
- the SSM parameters `/triple-c/environments/*`, written by the environment stack, whose JSON
value lists the environment's ARNs and IDs;
- CloudFormation stacks tagged `triple-c:component=environment`. This catches a stack whose
parameter was deleted, or one that is half-deployed (`*_IN_PROGRESS`, `*_FAILED`). Those are
shown with their state and a "Repair or delete" action instead of being silently ignored.
- the SSM parameters `/triple-c/environments/*`, written last by the environment module, whose
JSON value (`EnvironmentInfo`, schema 1) lists the environment's ARNs and IDs. The record is
untrusted input: every field is validated and the value capped at 4 KiB;
- the OpenTofu state objects and `.tflock` locks in the state bucket (D9; admins only, D8). This
catches a deployment whose record is missing (a failed or interrupted apply) or one in progress.
A state without a record, a failed apply or a stale lock (over two hours old) is shown as
**Incomplete** with Repair, Unlock or, without a record, Destroy (D6), instead of being silently
ignored; a fresh lock is **Deploying** (Follow when it is this machine's run). An empty state
(what `tofu destroy` leaves behind) counts as no deployment.
Detection also reads the template version each stack was deployed from, so an environment deployed
by an older Triple-C shows **"Update available"**.
The record carries the module version it was deployed from, so an environment deployed by an older
Triple-C shows **"Update available"**.
What the user sees depends on what was found and what they're allowed to do:
| Found | Can deploy (stack permissions) | Offered |
| Found | Can deploy (admin tier) | Offered |
|---|---|---|
| Nothing | Yes | **Deploy a new environment** (the default path for a personal account) |
| Nothing | No | Explanation, plus the policy JSON and a pre-filled CloudFormation console link to send to whoever owns the account |
| Nothing | No | Explanation, plus the policy JSON and an admin bundle (D5) to send to whoever owns the account |
| One or more | Yes | **Join** `<name>` (default) or **Deploy a new one** (for example a separate region or cost boundary) |
| One or more | No | **Join** `<name>` |
**Deploy** uses the **CloudFormation template that ships with the app**
(`aws/triple-c-environment.yaml`). A CloudFormation stack is auditable, an admin can deploy it
without Triple-C, and deleting the stack removes everything. The app never creates environment
resources one API call at a time. Deploy progress streams from the stack events into the same
progress view used for local image pulls.
**Deploy** applies the **OpenTofu module that ships with the app** (`aws/terraform/environment`)
with the caller's own credentials. The app downloads a pinned OpenTofu (checked against its SHA-256
before it is unpacked), creates the state bucket if needed, writes a work folder and starts a
detached `tofu init` + `tofu apply` (D10); progress streams from OpenTofu's JSON log into the
deploy view. An admin can apply the same files without Triple-C from a bundle (D5), and
`tofu destroy` removes everything the module created. The app never creates environment resources
one API call at a time.
**Joining in strict tenancy** (the default) also needs the user's **onboarding stack**: their own
task role, EFS access-point policy entry and, if they use EC2, a dedicated capacity provider. The
app checks whether the caller already has one:
**Joining in strict tenancy** (the default) also needs the user's **onboarding**
(`aws/terraform/user-onboard`): their own task and execution roles (with the permissions
boundary) and their own EFS file system (D2), and later, if they use EC2, a dedicated capacity
provider. The app checks whether the caller already has one (their state object, or their task
role):
- **The caller can deploy it** (always true in their own account): Triple-C deploys
`triple-c-user-onboard.yaml` for them as part of joining. For the person who just deployed the
- **The caller can deploy it** (an environment admin; always true in their own account): Triple-C
applies the onboarding module for them as part of joining. For the person who just deployed the
environment, this happens in the same flow, so a single user never sees "onboarding" as a step.
- **The caller can't**: Triple-C produces an onboarding request for whoever runs the environment —
a pre-filled CloudFormation console link plus the exact parameters — and the user's "Join" stays
pending until the stack exists. The app re-checks whenever the user opens the dialog.
an admin bundle (D5) with the user's `aws:userid` filled in — and the user's "Join" stays
pending until their roles exist. The app re-checks whenever the user opens the dialog.
### 3. Permission check
@@ -124,7 +174,7 @@ There are three tiers, and the check reports which one the user is in:
| Tier | Can do |
|---|---|
| **Environment admin** | Deploy or update the environment stack, onboard users (strict tenancy) |
| **Environment admin** | Deploy or update an environment (the OpenTofu modules), onboard users (strict tenancy) |
| **Workspace user** | Create, start, stop, connect to and delete **their own** workspaces in an existing environment |
| **Insufficient** | Lists exactly which actions are missing, and offers the IAM policy JSON to send to an admin |
@@ -270,7 +320,7 @@ other.
- ECR pull-through cache of `ghcr.io/shadowdao/triple-c-sandbox` (GHCR as an upstream needs a
GitHub token stored in Secrets Manager), or
- the environment stack's ECR repo, filled by an admin or CI with `crane copy`.
- an environment's ECR repo (not in v1, D3), filled by an admin or CI with `crane copy`.
Pulling straight from GHCR over NAT also works, and is the default when the environment has no ECR mirror. Image
updates use the existing "new image available" flow: the next start uses the new digest.
@@ -390,7 +440,7 @@ before release.**
| Mode | For | Task role | EC2 options | Isolation |
|---|---|---|---|---|
| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding stack (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. EFS file system policy allows each access point only to its owner's task role |
| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding module (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. EFS file system policy allows each access point only to its owner's task role |
| **Standard** (opt-in, later) | Large teams that want fewer IAM roles | Shared, minimal (logs, SSM messages) | **Fargate only**, or EC2 with **no** privileged mode, Docker socket or `NET_ADMIN` | Containers are unprivileged and can't mount NFS, can't read secrets, and can't reach each other over the network |
There is no separate "personal" mode. A solo user on strict tenancy is simply the only onboarded
@@ -422,7 +472,7 @@ fails because of a quota, the app says so and doesn't retry.
| Start time | About 30–90 s (image pull every time; SOCI lazy-loading **to evaluate**) | Fast when the image is cached on a warm instance |
| Docker access / privileged / VPN | No | Yes, on dedicated capacity |
| GPU | No | Yes |
| Ops | None | ASG + capacity provider, defined in the environment stack. The app only picks a capacity provider |
| Ops | None | ASG + capacity provider, defined in the environment module. The app only picks a capacity provider |
| Cost when idle | 0 (task stopped) | Instances keep running unless the ASG scales in |
| Max size | 16 vCPU / 120 GB | Instance type |
@@ -560,19 +610,23 @@ Existing `projects.json` files deserialize unchanged (`host_id` missing → Loca
```
aws/
triple-c-environment.yaml # CloudFormation: cluster, capacity providers, SG, EFS + policy,
# KMS key, ECR repo / pull-through rule, log group, exec+task roles,
# SSM environment parameter. Params: VPC/subnets (or create),
# tenancy mode, limits, Bedrock-on-task-role.
triple-c-user-onboard.yaml # strict tenancy: per-user task role + capacity provider
README.md # the policies, the modules, running a bundle by hand, live results
terraform/ # OpenTofu modules (hashicorp/aws pinned, .terraform.lock.hcl)
permissions-boundary/ # the account's triple-c-permissions-boundary policy
environment/ # cluster, tasks SG (no ingress), KMS key + alias, log group,
# network (public | private-nat | existing), SSM record (last)
user-onboard/ # per user: task + exec roles (bounded), own EFS file system,
# its SG, two mount targets, file-system policy (D2)
policies/
environment-admin.json
workspace-user.json
permissions-boundary.json # every triple-c-* role carries it (phase 3)
task-execution-role.json
task-role-standard.json
environment-admin.json # the admin tier, part 1 (D7)
environment-admin-network.json # the admin tier, part 2: the network
permissions-boundary.json # every triple-c-* role carries it
```
State lives in `triple-c-tfstate-<account>-<region>` (versioned, SSE-S3, public access blocked,
TLS only, tagged `triple-c:component=state`), one object per deployment (D9).
## Phases
1. **Host abstraction refactor.** `ProjectHost` trait, `DockerHost`, every call site moved. No
@@ -582,13 +636,15 @@ aws/
3. **AWS connection + permission check.** Settings UI, SDK wiring, `GetCallerIdentity`, the tiered
simulation and fallback probes, policy files. **Done 2026-11-14**; the policies were verified
against real IAM (see "Phase 3: policy verification and rulings").
4. **Environment + onboarding stacks.** CloudFormation templates for both, detection (SSM
parameters + tagged stacks, including half-deployed and outdated ones), join or deploy,
self-onboarding or an admin request, **strict tenancy**.
4. **Environment + onboarding modules.** OpenTofu modules for both and the boundary, run by the
app (detached) or by an admin from a bundle; detection (SSM records + state objects and locks,
including incomplete, in-progress and outdated ones), join or deploy, self-onboarding or an
admin request, **strict tenancy**. **Done 2026-11-14**; deployed, joined, updated and destroyed
for real as a role holding only the admin policies (see "Phase 4: live verification").
5. **Fargate workspaces.** Ephemeral and Hybrid storage, Secrets Manager delivery, git bootstrap,
start/stop/status, stop guard with WIP push on by default, idle auto-stop, logs.
6. **EFS mode, Copy-at-connect, setup scripts, cost estimate.**
7. **ECS on EC2.** Per-user capacity providers in the onboarding stack, privileged/Docker/VPN on
7. **ECS on EC2.** Per-user capacity providers in the onboarding module, privileged/Docker/VPN on
dedicated capacity.
8. **Standard tenancy** (opt-in), only if demand appears.
@@ -664,6 +720,29 @@ Left for the phases with real ECS/EFS/EC2 resources: `RunTask`'s `aws:TagKeys` (
`aws:ecs:*` or propagated tags), `iam:PassRole` with `iam:ResourceTag` on a real run, the owner
tag on a real task for stop/exec/session, session ownership for an SSO-started session, EFS
`CreateAction`/`CreateAccessPoint`/`ClientMount`, and `security-group-rule` authorization.
(Phase 4 has since created security groups and rules for real; see below.)
### Phase 4: live verification (2026-11-14)
`app/src-tauri/src/aws/live_tests.rs` (`environment`) applies the modules for real, in us-west-2,
as a role holding **only** the admin files: state bucket, a `public` environment through the real
detached runner, detection, self-onboarding, an update ("No changes"), destroy; and a
`private-nat` environment. What reality changed (details in `aws/README.md`):
- `s3:CreateBucket` with tags also needs `s3:TagResource`. Its `aws:RequestTag` and
`s3:ResourceAccount` conditions are evaluated and pass.
- `ec2:CreateSecurityGroup` is authorized against the VPC without the request's tags, so the VPC
side of the grant is unconditioned (the group still needs the tag).
- Destroying the NAT gateway's address disassociates it after the association is gone; EC2
authorizes that against the literal `*/*`, which is all the policy grants (`${*}/${*}`).
- `tofu destroy` leaves an empty state object; detection treats it as no deployment (otherwise a
destroyed environment read as Incomplete for ever).
- Confirmed as designed: EFS sets `aws:CalledViaLast` for mount-target interfaces; the EFS
file-system policy accepted the just-created task role; conditional `DeleteObject` (`If-Match`)
on the versioned bucket returns 412 on a stale ETag.
Still to verify: the `existing` network mode against a user's own VPC, and Fargate tasks in the
deployed environment (phase 5).
## Connection model
@@ -684,7 +763,7 @@ The options:
| Several regions or environments, same login | Duplicate connections with the same credentials. Keys are stored twice; SSO shares a token cache, so it's only clutter | One connection | One connection |
| Permission check | Once per connection, cached | Per environment, run lazily the first time a project uses it | Per environment, run lazily |
| Detection cost | One region | Every region the user might use (`DescribeRegions` + a scan, slow) or a hand-kept list | Default region straight away; other regions only when the user picks one |
| Risk of deploying to the wrong place | Low: the place is in the connection's name | Higher: an easy region-picker mistake leaves a stack in a region nobody looks at | Low: default region preselected, others must be chosen deliberately |
| Risk of deploying to the wrong place | Low: the place is in the connection's name | Higher: an easy region-picker mistake leaves an environment in a region nobody looks at | Low: default region preselected, others must be chosen deliberately |
| Moving a project to another region or environment | Re-point the project to another connection | Change a field | Change a field |
| Settings export/import | Each connection is self-contained | Projects reference environments that may not exist on the importing machine | Same as B |
| Data model / UI | Smallest | Largest (environment picker and per-environment status everywhere) | Medium |