diff --git a/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md b/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md index bb952cd..fc3dea0 100644 --- a/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md +++ b/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md @@ -1,448 +1,492 @@ -# AWS Environment + Onboarding Stacks (Phase 4) Implementation Plan +# AWS Environment + Onboarding (Phase 4, OpenTofu) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** From Settings → Hosts, a user picks a region on an AWS connection. They see the Triple-C environments there, including half-deployed and outdated ones. They can deploy a new environment from the CloudFormation template that ships with the app, update or repair one, and join one. Joining onboards them automatically when they are allowed to, and otherwise produces an exact onboarding request for an admin. Strict tenancy is the only mode. +**Goal:** From Settings → Hosts, a user picks a region on an AWS connection and sees the Triple-C environments there, including incomplete and outdated ones. The user can: +- deploy a new environment from the Terraform module that ships with the app; +- update or repair an existing environment; +- join an environment. Joining onboards the user automatically if they're allowed to. Otherwise it produces an exact onboarding bundle for an admin. + +Strict tenancy is the only mode. **Architecture:** -- Three CloudFormation templates ship in `aws/`, compiled in with `include_str!`: - - the **environment** template; - - the per-user **onboarding** template; - - a one-off **permissions boundary** template. -- `aws/templates.rs` owns names, versions and stack parameters. -- `aws/stacks.rs` creates, updates, deletes and watches stacks behind a mockable trait. -- `aws/environments.rs` detects environments by merging the SSM parameters with the tagged stacks, and also reports the boundary and the caller's onboarding state. -- Commands live in a new `commands/environment_commands.rs`. Stack progress streams to the UI as Tauri events. -- The UI is an Environments panel on each AWS connection in `HostsSettings`. -- Static tests keep the templates and the environment-admin policy in step. An ignored live test deploys everything as a role that holds *only* the admin policies, then tears it down. +- **Modules.** Three Terraform modules (HCL, OpenTofu-compatible) ship in `aws/terraform/`: the environment, per-user onboarding, and the permissions boundary. They are compiled into the app with `include_str!`. +- **Running OpenTofu.** The app downloads a pinned OpenTofu release on first use and checks its SHA-256. It materializes a module into a working directory with a generated S3 backend and `terraform.tfvars.json`, then runs `tofu init` and `tofu apply -json`. The apply runs **detached**, so it survives the app closing. The app follows its log file and turns the JSON lines into progress events. +- **State.** State lives in one bucket per account and region, `triple-c-tfstate--`. The bucket is versioned and encrypted, blocks public access, and uses S3-native locking. The app creates it before the first deploy. +- **Detection.** The app merges the SSM environment records with the state objects and locks in that bucket. +- **Commands and UI.** The Tauri commands are in `commands/environment_commands.rs`. The UI is an Environments panel on each AWS connection. +- **Proof.** Static HCL tests keep the modules and the admin policies in step. An ignored live test deploys everything as a role that holds *only* the admin policies. **Tech Stack:** -- Rust and Tauri v2, with the existing `aws-sdk-cloudformation`, `aws-sdk-iam`, `aws-sdk-ssm` and `sha2`. No new crates. In particular, no `aws-sdk-ec2` (compile time): existing-VPC mode takes typed IDs. +- Rust and Tauri v2, with the existing `aws-sdk-ssm`, `aws-sdk-iam`, `sha2`, `tar` and `reqwest`, if present (otherwise the HTTP client the app already uses). +- **New dependencies:** + - `aws-sdk-s3`, with `default-features = false` and the same features as the other SDK crates. + - `zip` (deflate only), for the Windows release. + - `flate2`, if it is not already a direct dependency. + - `hcl-rs` as a **dev-dependency**, for the static module tests. +- OpenTofu itself is downloaded at runtime and is never bundled. The version is pinned in source (`TOFU_VERSION`), along with one SHA-256 per platform taken from that release's `SHA256SUMS`. The AWS provider is pinned in `versions.tf`, and the module's committed `.terraform.lock.hcl` carries hashes for every shipped platform. - React, TypeScript and vitest. **Spec:** `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`. Relevant sections: - "User flow" §2; - "Connectivity" (network); - "Storage"; -- "Secrets and uploads"; - "Multi-user accounts" (strict tenancy); - "Shipped AWS artefacts"; - "Phase 3: policy verification and rulings". -**Branch:** `feat/aws-environments` off `main` (3bc0e9f). +**Branch:** `feat/aws-environments`. A superseded CloudFormation start is parked in `git stash` ("phase4 task1 CFN WIP"). Drop that stash once Task 1 lands. -## Decisions this plan makes +## Decisions (user, 2026-11-14) -These go in the spec in Task 8. Each is a deviation or a resolved gap, so the reviewer should read them first. +The user chose: +- **Terraform over CloudFormation;** +- the **app runs it** itself; +- **OpenTofu**, which is MPL-2.0 and safe for the app to download and run; +- **state in an S3 bucket** in the account. -- **D1. Templates are JSON**: `aws/triple-c-environment.json`, `aws/triple-c-user-onboard.json` and `aws/triple-c-permissions-boundary.json`. - - CloudFormation takes JSON natively, and the app already depends on `serde_json`. - - There is no YAML crate in the tree, and the drift tests must parse the templates. -- **D2. Strict tenancy gives each user their own EFS file system**, created by their onboarding stack. The spec had one file system per environment. - - In the single-file-system design, the file system policy would have to bind each access point to its owner's task role. EFS can't express that: `ClientMount` sees the access point's ARN, not its tags, and access point IDs are random. - - With one file system per user, its policy allows only that user's task role. An empty file system costs nothing. - - The environment keeps the shared parts: 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, which the spec allows ("the default when the environment has no ECR mirror"). An ECR mirror is later work. -- **D4. Network modes** (template parameter `NetworkMode`): - - `public` (**default**): creates a VPC with two public subnets. Tasks get a public IP behind a security group with **no inbound rules**. There is no NAT gateway, so it costs $0. - - `private-nat`: creates a VPC with two public and two private subnets and one NAT gateway. Tasks run in the private subnets. The UI says this costs about $33/month plus data. - - `existing`: `VpcId`, `SubnetA`, `SubnetB` (in different AZs) and `AssignPublicIp`. - - The spec's "private subnets" becomes one option, not the only one. The no-inbound rule holds in every mode. -- **D5. An admin request is a template file plus a CLI command, not a console link.** CloudFormation quick-create links need the template at an S3 URL, and Triple-C has no public bucket. - - The request offers to save the template, using a Rust-side save dialog like `pick_save_path`. - - It shows the exact parameters and tags. - - It gives a copyable `aws cloudformation deploy …` command. -- **D6. The app deletes only failed or half-deployed stacks.** Those are `ROLLBACK_COMPLETE`, `ROLLBACK_FAILED`, `CREATE_FAILED` and `DELETE_FAILED`. Deleting a working environment or onboarding stack stays in the console for v1, which is the irreversible case. -- **D7. The admin tier is two policy files**, `environment-admin.json` and `environment-admin-network.json`. Both are required for the admin tier. The VPC actions don't fit in one 6,144-character managed policy (the admin file is already about 5,530). `aws_policy_document` returns every file for a tier. -- **D8. The workspace-user policy is unchanged.** Detection uses only reads it already grants: - - `ssm:GetParameter*` on `/triple-c/*`, which covers `GetParametersByPath` on `/triple-c/environments`; - - `cloudformation:DescribeStacks` and `ListStacks`; +The plan adds the decisions below. Task 8 writes all of them into the spec, replacing the CloudFormation text. + +- **D2. Strict tenancy gives each user their own EFS file system**, created by their onboarding module. + - Why: 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. + - The file system policy allows 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 spec allows this ("the default when the environment has no ECR mirror"). +- **D4. Network modes** (the `network_mode` variable): + - `public` (**default**): a new VPC with two public subnets. Tasks get a public IP, and the security group has **no inbound rules**. There is no NAT. It costs $0. + - `private-nat`: a new VPC with two public and two private subnets and one NAT gateway. The UI says this costs about $33/month. + - `existing`: the user supplies `vpc_id`, `subnet_ids` (exactly 2, in different AZs) and `assign_public_ip`. +- **D5. An admin request is a folder bundle.** The app writes it into a folder the user picks through a Rust-side folder dialog. It contains: + - the module's files; + - `backend.hcl`; + - `terraform.tfvars.json`; + - a `README.txt` with the exact commands: `tofu init -backend-config=backend.hcl` followed by `tofu apply`. Terraform 1.10+ works too. +- **D6. The app destroys only incomplete deployments.** That means state exists but there's no environment record, or the last apply failed. A working environment is destroyed by an admin running `tofu destroy` from a bundle, which is the irreversible case. +- **D7. The admin tier spans several policy files.** It is `environment-admin.json` and `environment-admin-network.json`, plus a third file if the live test needs one. All of them are required for the tier. `aws_policy_document` returns every file for a tier. +- **D8. CloudFormation leaves the policies**, so the `cloudformation:*` actions, checks and probes are removed. The workspace-user policy loses `ReadStacks` and gains nothing. Detection for workspace users reads only: + - `ssm:GetParameter*` on `/triple-c/*`; - `iam:GetRole` on `role/triple-c-*`. - It stays about 30 characters under the size limit. The split the phase-3 notes called for moves to phase 5, which adds actions. + Bucket reads (the state objects and locks) are admin-only. A workspace user sees only environments that have a record. +- **D9. One state key per deployment**, inside the per-account, per-region bucket: + - `environments//terraform.tfstate`; + - `users///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. Its JSON (`Who`, `Created`, `Operation`) is shown. +- **D10. Applies are detached.** `tofu apply` runs in its own process group, with stdout and stderr going to `/apply.log` and its PID in `/apply.pid`. Closing the app doesn't interrupt it. On the next run, the app sees the live PID or the held lock and reattaches by following the log. The app sends an interrupt only if the user presses Cancel. OpenTofu then stops gracefully and saves state. ## Global Constraints -- Names. These are pure functions in `aws/templates.rs`, and nothing else builds these strings. - - **Environment name**: `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--`. - - **Environment stack**: `triple-c-`. **Parameter**: `/triple-c/environments/`. - - **Owner key**: the first 12 hex characters of `sha256(aws:userid)`. A `UserId` contains `:`, which IAM role names reject. - - **Onboarding stack**: `triple-c--user-`. - - **Per-user roles**: `triple-c---task` and `triple-c---exec`. The longest is 47 characters, under the 64 limit. - - **Boundary stack**: `triple-c-permissions-boundary`. It creates the managed policy `triple-c-permissions-boundary`, with no path. -- Tags. - - Every taggable resource in the environment template carries `triple-c:component=environment` and `triple-c:environment=`. - - In the onboarding template, every taggable resource carries `triple-c:component=user-onboarding`, `triple-c:environment=` and `triple-c:owner=`. - - Stacks are tagged the same way, plus `triple-c:template-version=`. - - Workspace users never set `triple-c:component`; phase 3 depends on that. -- Every IAM role a template creates has `PermissionsBoundary` set to `arn:aws:iam::${AWS::AccountId}:policy/triple-c-permissions-boundary`. Trust is `ecs-tasks.amazonaws.com` with `aws:SourceAccount = ${AWS::AccountId}`. -- No stack uses a service role (`RoleARN`). The admin policy denies one, so CloudFormation acts with the caller's own credentials. -- The security group has **no** `SecurityGroupIngress`, inline or as a separate resource. -- The KMS key policy delegates to IAM (`arn:aws:iam::${AWS::AccountId}:root`, `kms:*`). The key has rotation on and `PendingWindowInDays: 7`. -- The environment parameter's value is JSON, `EnvironmentInfo` schema 1 (Task 1 lists the fields). It is **untrusted input**. Anyone holding `ssm:PutParameter` on `/triple-c/*` can write it, so the app validates every field before use and caps the value at 4 KiB. -- Template versions are `ENVIRONMENT_TEMPLATE_VERSION: u32 = 1` and `ONBOARD_TEMPLATE_VERSION: u32 = 1`. Each is written in three places, and a test checks they agree: the template's `Metadata.TripleC.TemplateVersion`, the stack tag, and (for the environment) the parameter JSON. -- `TemplateBody` is limited to 51,200 bytes, and a test checks it. -- AWS calls use `aws::connection::sdk_config`, never `aws_config::defaults()`. -- Every new command needs `#[tauri::command]`, a `generate_handler!` entry and an `allow-*` grant in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass. -- When a policy JSON changes, its checks in `policies.rs` and `aws/README.md` change in the same commit. Every policy fits in 6,144 characters with whitespace removed. -- Live AWS spending is capped by the user at $10 for this work, and every resource is cleaned up. Live tests use the scratchpad `awsx` wrapper, never the container's own `AWS_*` env. +- **Names.** These are pure functions in `aws/tofu_modules.rs`, and nothing else builds these strings. + - **Environment name:** `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--`. + - **Parameter:** `/triple-c/environments/`. + - **Owner key:** the first 12 hex characters of `sha256(aws:userid)`. A `UserId` contains `:`, which IAM role names reject. + - **Per-user roles:** `triple-c---task` and `triple-c---exec`. The longest is 47 characters, under the 64-character limit. + - **Boundary policy:** `triple-c-permissions-boundary`, with no path. + - **State bucket:** `triple-c-tfstate--`. The longest region gives 46 characters, under the 63-character limit. +- **Tags.** + - Each module's provider block sets `default_tags`: + - environment: `triple-c:component=environment`, `triple-c:environment=`, `triple-c:template-version=`; + - onboarding: `triple-c:component=user-onboarding`, `triple-c:environment`, `triple-c:owner=`, `triple-c:template-version`; + - boundary: `triple-c:component=boundary`. + - No resource overrides `triple-c:component`. + - The state bucket is tagged `triple-c:component=state`. + - Workspace users never set `triple-c:component`; phase 3 relies on that. +- **IAM roles.** Every role a module creates has `permissions_boundary` set to `arn::iam:::policy/triple-c-permissions-boundary`. It trusts `ecs-tasks.amazonaws.com`, with `aws:SourceAccount` set to the account. +- **No inbound access.** The environment security group has **no** ingress, inline or as a separate `aws_vpc_security_group_ingress_rule` or `aws_security_group_rule`. +- **KMS key.** The key policy delegates to IAM (`arn:aws:iam:::root`, `kms:*`). Rotation is on and `deletion_window_in_days = 7`. +- **The environment record** is an SSM `String` parameter holding JSON, `EnvironmentInfo` schema 1 (fields listed in Task 1). It is written by the module with `jsonencode`. It is **untrusted input**: the app validates every field and caps the value at 4 KiB. +- **Versions.** `ENVIRONMENT_MODULE_VERSION: u32 = 1` and `ONBOARD_MODULE_VERSION: u32 = 1`. Each appears in the Rust constant, the module's `locals.template_version` and the `template-version` default tag, and a test checks they agree. +- **The OpenTofu child's environment.** + - Every inherited `AWS_*` variable is removed, along with `TF_*` and `TOFU_*` except the ones the app sets. + - A profile connection gets `AWS_PROFILE=`. + - A keys connection gets `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` **in the child's environment only**. They never go in argv, files or logs. + - The app sets `TF_PLUGIN_CACHE_DIR`, `TF_IN_AUTOMATION=1`, `TF_INPUT=0` and `CHECKPOINT_DISABLE=1`. + - The provider block sets `region` explicitly. No `endpoints` override is allowed in any module. +- **Downloads.** The OpenTofu archive is verified against the pinned SHA-256 *before* extraction. On a mismatch the app deletes it and returns an error; it never runs an unverified binary. The provider is pinned by `.terraform.lock.hcl`, so `tofu init` verifies it. +- **Commands.** Every new command needs `#[tauri::command]`, a `generate_handler!` entry and an `allow-*` grant in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass. +- **Policies.** When a policy JSON changes, its checks in `policies.rs` and `aws/README.md` change in the same commit. Every policy fits in 6,144 characters with whitespace removed. +- **Live AWS testing.** The user caps spending at $10, and every resource is cleaned up. Use the scratchpad `awsx` wrapper, never the container's own `AWS_*` environment. ## Review Focus -1. **A half-deployed stack**: `CREATE_IN_PROGRESS`, `ROLLBACK_COMPLETE`, `UPDATE_ROLLBACK_COMPLETE` or `DELETE_FAILED`. - - Show it with its state. - - Offer Delete only for the D6 states. - - Show an in-progress one with live progress, not as "Failed". - - `UPDATE_ROLLBACK_COMPLETE` is usable, so offer Update. - - Test: `half_deployed_stacks_get_the_right_actions` (Task 5). -2. **A record and its stack disagree.** - - A parameter with no stack is shown as "Stack missing" and is not joinable. - - A tagged stack with no parameter is shown as "Record missing — update to repair". - - Test: `a_parameter_without_a_stack_and_a_stack_without_a_parameter_are_both_listed` (Task 5). -3. **Update when nothing changed.** CloudFormation's `ValidationError` "No updates are to be performed." means "Already up to date", not a failure. Test: `an_update_with_no_changes_is_already_up_to_date` (Task 4). -4. **A hostile or garbled environment record**: bad JSON, 64 KiB of text, an ARN in another account or region, or a name that doesn't match its path. It is reported as "Unreadable environment record" and never used to build a request. Test: `untrusted_environment_records_are_rejected` (Task 5). -5. **The app closes mid-deploy.** The stack carries on. On the next detection, the in-progress stack is shown, and its progress can be followed from its existing events without starting a second deploy. Test: `watching_a_stack_already_in_progress_reports_its_existing_events_once` (Task 4). +1. **An apply that was cut off.** State and the lock are present, with no live process and no record. Expected: + - The environment shows as "Incomplete — last change was interrupted". + - It offers Repair (re-apply) and Destroy. + - It offers Unlock only when the lock is more than 2 hours old and isn't held by a live local process. + + Test: `interrupted_deployments_get_the_right_actions` (Task 5). +2. **A record and its state disagree.** + - A record with no state (deployed by hand elsewhere, or with the state deleted) shows as "Managed outside this account's Triple-C state". It is joinable, but has no Update or Destroy. + - State with no record is incomplete, as in item 1. + + Test: `a_record_without_state_and_state_without_a_record_are_both_listed` (Task 5). +3. **A hostile or garbled environment record**: bad JSON, 64 KiB, an ARN in another account or region, or a name that doesn't match its path. It is reported as "Unreadable environment record" and never used to build a request. Test: `untrusted_environment_records_are_rejected` (Task 5). +4. **The app closes mid-apply.** OpenTofu carries on. On the next detection, the deployment shows as "Deploying", and its progress replays from `apply.log` once, without starting a second apply. Test: `following_an_existing_log_reports_each_event_once` (Task 4). +5. **A tampered or truncated OpenTofu download.** Expected: deleted, an error, never executed, and a retry downloads again. Test: `a_download_with_the_wrong_hash_is_deleted_and_refused` (Task 4). --- -### Task 1: Environment template and `aws/templates.rs` +### Task 1: Environment module and `aws/tofu_modules.rs` **Files:** -- Create: `aws/triple-c-environment.json` -- Create: `app/src-tauri/src/aws/templates.rs`, with tests in `app/src-tauri/src/aws/templates/tests.rs` +- Create: `aws/terraform/environment/{versions.tf,variables.tf,main.tf,network.tf,outputs.tf,.terraform.lock.hcl}` +- Create: `app/src-tauri/src/aws/tofu_modules.rs`, with tests in `aws/tofu_modules/tests.rs` - Modify: `app/src-tauri/src/aws/mod.rs` +- Modify: `app/src-tauri/Cargo.toml`, adding `hcl-rs` under `[dev-dependencies]` **Interfaces:** -- Produces, in `aws::templates`: - ```rust - pub const ENVIRONMENT_TEMPLATE_VERSION: u32 = 1; - pub const ONBOARD_TEMPLATE_VERSION: u32 = 1; - pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary"; // re-export of policies::PERMISSIONS_BOUNDARY_NAME - pub const BOUNDARY_STACK_NAME: &str = "triple-c-permissions-boundary"; - #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] - pub enum TemplateKind { Environment, UserOnboard, PermissionsBoundary } - pub fn template_body(kind: TemplateKind) -> &'static str; - pub fn template_file_name(kind: TemplateKind) -> &'static str; // "triple-c-environment.json", … - pub fn validate_environment_name(name: &str) -> Result; - pub fn environment_stack_name(env: &str) -> String; - pub fn environment_parameter_name(env: &str) -> String; - pub fn owner_key(user_id: &str) -> String; // 12 lowercase hex - pub fn onboarding_stack_name(env: &str, owner_key: &str) -> String; - pub fn user_role_names(env: &str, owner_key: &str) -> (String, String); // (task, exec) - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "mode", rename_all = "kebab-case")] - pub enum NetworkChoice { Public, PrivateNat, Existing { vpc_id: String, subnet_a: String, subnet_b: String, assign_public_ip: bool } } - pub fn environment_parameters(env: &str, network: &NetworkChoice) -> Result, String>; // validates vpc-/subnet- ids - pub fn stack_tags(kind: TemplateKind, env: Option<&str>, owner_id: Option<&str>) -> Vec<(String, String)>; - ``` -- Produces the `EnvironmentInfo` JSON shape. Task 5 parses it, and the template writes it with `Fn::Sub`: - `{"schema":1,"name","template_version","cluster_arn","security_group_id","subnet_ids":[2],"assign_public_ip":bool,"kms_key_arn","log_group","network_mode","tenancy":"strict"}`. -- Template parameters: `EnvironmentName`, `NetworkMode` (`public|private-nat|existing`), `VpcId`, `SubnetA`, `SubnetB`, `AssignPublicIp` (`true|false`) and `LogRetentionDays` (default 30). -- Exports, which Task 2's onboarding template imports: `triple-c--ClusterArn`, `-SecurityGroupId`, `-SubnetA`, `-SubnetB`, `-KmsKeyArn` and `-LogGroupName`. -- Resources, always created: the ECS cluster (`triple-c-`, `FARGATE` and `FARGATE_SPOT` capacity providers), the security group (egress all, no ingress), the KMS key and its alias `alias/triple-c-`, the log group `/triple-c/`, and the SSM parameter. -- Resources, conditional on network mode: the VPC, IGW, subnets, route tables, and the NAT gateway and EIP. - -- [ ] **Step 1: Write the failing tests** in `templates/tests.rs`: - - `environment_names_are_validated`: accepts `dev` and `team-a1`. Rejects `""`, `Dev`, `1dev`, `dev-`, `a--b`, a 21-character name, and `dev/x`. - - `owner_keys_are_stable_and_role_safe`: `owner_key("AROAEXAMPLE:jdoe@example.com")` is 12 characters from `[0-9a-f]`, is the same on every call, and differs from `owner_key("AROAEXAMPLE:jdoe2@example.com")`. `user_role_names("abcdefghijklmnopqrst", &key)` are each at most 64 characters. - - `the_environment_template_parses_and_fits`: `template_body(Environment)` parses as JSON and is under 51,200 bytes. `Metadata.TripleC.TemplateVersion == ENVIRONMENT_TEMPLATE_VERSION`. - - `the_security_group_has_no_ingress`: there is no `AWS::EC2::SecurityGroupIngress` resource and no `SecurityGroupIngress` property. - - `every_taggable_environment_resource_is_tagged_component_environment`: every resource with a `Tags` property has `triple-c:component=environment` and `triple-c:environment` (`Ref EnvironmentName`). The ECS cluster, KMS key, log group, security group, VPC pieces and the SSM parameter all count; resource types that can't take tags are excluded by an explicit list in the test. - - `the_environment_record_renders_to_valid_json`: substitute fake values into the parameter's `Fn::Sub` string, with the network-mode conditions resolved each way. The result parses into the field list above, with `schema == 1` and `template_version == 1`. - - `exports_are_named_for_the_environment`: all six export names are `Fn::Sub` of `triple-c-${EnvironmentName}-`. - - `environment_parameters_cover_every_network_mode`: `Existing` with `vpc-0abc`/`subnet-1`/`subnet-2` gives all six parameters. A bad ID (`vpc-;`) is an error. `Public` sets `NetworkMode=public` and leaves the existing-VPC parameters at their template defaults. -- [ ] **Step 2: Run** `cargo test aws::templates`. Expected: FAIL (module missing). -- [ ] **Step 3: Write `aws/triple-c-environment.json` and implement `templates.rs`.** - - Use the long-form intrinsics; JSON has no short form. - - The record's JSON booleans come from `Fn::If` returning the literal text `true`/`false` inside the `Fn::Sub`. - - `subnet_ids` comes from conditions selecting the created or the existing subnets. -- [ ] **Step 4: Run** `cargo test aws::templates`. Expected: PASS. -- [ ] **Step 5: Validate the template against CloudFormation.** Run `awsx aws cloudformation validate-template --template-body file://aws/triple-c-environment.json --region us-west-2`. Expected: no error. This is free and creates nothing. -- [ ] **Step 6: Commit** with the message `feat(aws): environment CloudFormation template and stack naming`. - -### Task 2: Onboarding and boundary templates - -**Files:** -- Create: `aws/triple-c-user-onboard.json` -- Create: `aws/triple-c-permissions-boundary.json` -- Modify: `app/src-tauri/src/aws/templates.rs`, `templates/tests.rs` - -**Interfaces:** -- Consumes: Task 1's names, exports and tags. - Produces: ```rust - pub fn onboarding_parameters(env: &str, owner_id: &str) -> Vec<(String, String)>; // EnvironmentName, OwnerId, OwnerKey (= owner_key(owner_id)), BedrockOnTaskRole=false + pub const ENVIRONMENT_MODULE_VERSION: u32 = 1; + pub const ONBOARD_MODULE_VERSION: u32 = 1; + pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary"; // = policies::PERMISSIONS_BOUNDARY_NAME + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] + pub enum ModuleKind { Environment, UserOnboard, PermissionsBoundary } + pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)]; // (relative file name, contents), include_str!, incl. .terraform.lock.hcl + pub fn validate_environment_name(name: &str) -> Result; + pub fn environment_parameter_name(env: &str) -> String; + pub fn owner_key(user_id: &str) -> String; + pub fn user_role_names(env: &str, owner_key: &str) -> (String, String); // (task, exec) + pub fn state_bucket_name(account: &str, region: &str) -> String; + pub fn state_key(kind: ModuleKind, env: Option<&str>, owner_key: Option<&str>) -> String; // D9 + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "mode", rename_all = "kebab-case")] + pub enum NetworkChoice { Public, PrivateNat, Existing { vpc_id: String, subnet_ids: Vec, assign_public_ip: bool } } + pub fn environment_vars(env: &str, region: &str, network: &NetworkChoice) -> Result; // tfvars JSON; validates vpc-/subnet- ids, exactly 2 subnets ``` -- The onboarding template's parameters are `EnvironmentName`, `OwnerId`, `OwnerKey` and `BedrockOnTaskRole` (`false` by default). -- It creates the following resources. - - **Task role** `triple-c---task`. It may use: +- Variables: `environment_name`, `region`, `network_mode`, `vpc_id` (default `""`), `subnet_ids` (default `[]`), `assign_public_ip` (default `false`) and `log_retention_days` (default 30). Each has a `validation` block that matches the Rust rules. +- Resources: + - the ECS cluster `triple-c-`, with `aws_ecs_cluster_capacity_providers` for FARGATE and FARGATE_SPOT; + - the security group `triple-c--tasks`, with egress set to all and no ingress; + - the KMS key and the alias `alias/triple-c-`; + - the log group `/triple-c/`; + - the network resources, according to `network_mode` (`count` or `for_each`); + - the SSM parameter. +- The record is the SSM parameter value, `jsonencode({...})` with these fields: `schema` (1), `name`, `template_version`, `cluster_arn`, `security_group_id`, `subnet_ids` (exactly 2), `assign_public_ip`, `kms_key_arn`, `log_group`, `network_mode`, and `tenancy` (always `"strict"`). Task 2's onboarding module reads it, and Task 5 parses it. +- `versions.tf` pins `required_version >= 1.10` and `hashicorp/aws` to an exact `6.x` version. Choose the current one, and write it in the report. It also declares an **empty** `backend "s3" {}`, which the app fills in with `-backend-config`. + +- [ ] **Step 1: Install OpenTofu locally for development** (sudo is available), using the same pinned version Task 4 will use. Choose the latest stable 1.x release and record it in the report. +- [ ] **Step 2: Write the failing tests** in `tofu_modules/tests.rs`. Parse the module with `hcl-rs`. + - `environment_names_are_validated`: accepts `dev` and `team-a1`. Rejects `""`, `Dev`, `1dev`, `dev-`, `a--b`, a 21-character name and `dev/x`. + - `owner_keys_are_stable_and_role_safe`: the key is 12 characters in `[0-9a-f]` and stable. Different user IDs give different keys. Both role names are at most 64 characters for a 20-character environment name. + - `state_bucket_and_keys_follow_d9`. + - `the_module_versions_agree`: `locals.template_version` equals the constant, and `default_tags` carries it. + - `default_tags_mark_the_environment_and_nothing_overrides_component`. + - `the_security_group_has_no_ingress`: there are no `ingress` blocks, and no ingress-rule resources. + - `no_provider_endpoint_overrides`. + - `the_record_has_exactly_the_schema_fields`: the `jsonencode` object's keys equal the field list above. + - `environment_vars_cover_every_network_mode`: `existing` with 1 or 3 subnets, or `vpc-;`, is an error. +- [ ] **Step 3: Run** `cargo test aws::tofu_modules`. Expected: FAIL. +- [ ] **Step 4: Write the module and implement the module.** + - Generate `.terraform.lock.hcl` with `tofu providers lock -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_amd64 -platform=darwin_arm64 -platform=windows_amd64`. + - Run `tofu fmt -check` and `tofu init -backend=false && tofu validate` in the module directory. + - Don't commit `.terraform/`. Add it to `.gitignore`. +- [ ] **Step 5: Run** the tests and `tofu validate`. Expected: PASS and "Success!". +- [ ] **Step 6: Commit** with the message `feat(aws): environment Terraform module and naming`. + +### Task 2: Onboarding and boundary modules + +**Files:** +- Create: `aws/terraform/user-onboard/*` and `aws/terraform/permissions-boundary/*`, with the same file set as Task 1 +- Modify: `aws/tofu_modules.rs`, `tofu_modules/tests.rs` + +**Interfaces:** +- Produces: + ```rust + pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::Value; // environment_name, region, owner_id, owner_key, bedrock_on_task_role=false + pub fn boundary_vars(region: &str) -> serde_json::Value; // region, policy_json = policies::permissions_boundary_json() + ``` +- **Onboarding module.** It reads the environment through `data "aws_ssm_parameter"` and `jsondecode`, and creates: + - **The task role** `triple-c---task`. It allows: - `ssmmessages:CreateControlChannel/CreateDataChannel/OpenControlChannel/OpenDataChannel` on `*`; - `logs:CreateLogStream/PutLogEvents` on the environment's log group; - `elasticfilesystem:ClientMount/ClientWrite` on *this user's* file system; - - Bedrock invoke, only under the `BedrockOnTaskRole` condition. - - **Execution role** `triple-c---exec`. It may use: - - `secretsmanager:GetSecretValue` on `triple-c/*` where `aws:ResourceTag/triple-c:owner` equals the `OwnerId` parameter; - - `kms:Decrypt` on the environment key via `secretsmanager.${AWS::Region}.amazonaws.com`; - - `logs:CreateLogStream/PutLogEvents` on the environment's log group. - - **The user's EFS file system.** It is encrypted, `PerformanceMode: generalPurpose`, `ThroughputMode: elastic`, and has two mount targets (the environment's subnets and security group). - - **That file system's policy:** - - Deny `aws:SecureTransport=false`. - - Deny any request where `elasticfilesystem:AccessPointArn` is null. - - Allow `ClientMount` and `ClientWrite` only to the task role's ARN. - - **The security group rule.** EFS needs inbound NFS (2049) **from the environment security group to the mount targets**. This must not open ingress on the tasks. So the onboarding stack creates its own mount-target security group `triple-c---efs`, with ingress 2049 only from the environment security group, and the environment group stays ingress-free. -- The boundary template is one `AWS::IAM::ManagedPolicy` named `triple-c-permissions-boundary`. Its `PolicyDocument` equals `aws/policies/permissions-boundary.json`. + - Bedrock invoke, only when `bedrock_on_task_role` is set. + - **The execution role** `triple-c---exec`. It allows: + - `secretsmanager:GetSecretValue` on `triple-c/*`, where `aws:ResourceTag/triple-c:owner` equals `var.owner_id`; + - `kms:Decrypt` on the environment key, via `secretsmanager..amazonaws.com`; + - the same log permissions as the task role. + - **The user's EFS file system**: encrypted, `generalPurpose`, elastic throughput, with a mount target in each of the environment's two subnets. + - **The file system policy:** + - Deny when `aws:SecureTransport=false`. + - Deny when `elasticfilesystem:AccessPointArn` is null. + - Allow `ClientMount` and `ClientWrite` to the task role's ARN only. + - **The mount-target security group** `triple-c---efs`. Its only ingress is TCP 2049 from the environment security group. The environment group itself never gains ingress. +- **Boundary module.** One `aws_iam_policy` named `triple-c-permissions-boundary`, whose `policy` is `var.policy_json`. The app passes the shipped JSON, so there is no second copy to drift. - [ ] **Step 1: Write the failing tests:** - - `every_onboarding_role_carries_the_boundary_and_owner_tag`: every `AWS::IAM::Role` has `PermissionsBoundary` equal to the `Fn::Sub` of the boundary ARN, and the tag `triple-c:owner = Ref OwnerId`. `RoleName` is `Fn::Sub` of `triple-c-${EnvironmentName}-${OwnerKey}-task` or `-exec`. - - `role_trust_is_ecs_tasks_in_this_account_only`: the trust principal is `ecs-tasks.amazonaws.com` with a `StringEquals aws:SourceAccount` condition. - - `the_users_file_system_admits_only_their_task_role_through_an_access_point`: the file system policy has the two Denies and one Allow whose principal is `Fn::GetAtt TaskRole.Arn`. - - `the_environment_security_group_gains_no_ingress`: the only ingress anywhere in the onboarding template is on the `-efs` group, port 2049, sourced from `Fn::ImportValue triple-c-${EnvironmentName}-SecurityGroupId`. - - `exec_role_reads_only_its_owners_secrets`: the `GetSecretValue` statement is conditioned on `aws:ResourceTag/triple-c:owner` = `Ref OwnerId`. - - `the_boundary_template_matches_the_shipped_boundary`: parse both. The template's `PolicyDocument` equals `permissions-boundary.json` as JSON values, and `ManagedPolicyName == BOUNDARY_POLICY_NAME`. - - `every_onboarding_role_stays_inside_the_boundary`: every action each role is granted is allowed by `permissions-boundary.json`, under the Task 3 evaluator. **Write this test in Task 3** once the evaluator is shared, and leave it as a TODO here. -- [ ] **Step 2: Run** `cargo test aws::templates`. Expected: FAIL. -- [ ] **Step 3: Write both templates and `onboarding_parameters`.** -- [ ] **Step 4: Run the tests** and `awsx aws cloudformation validate-template` on both files. Expected: PASS, and both valid. -- [ ] **Step 5: Commit** with the message `feat(aws): per-user onboarding and permissions-boundary templates`. + - `every_onboarding_role_carries_the_boundary_and_owner_tag`. + - `role_trust_is_ecs_tasks_in_this_account_only`. + - `the_users_file_system_admits_only_their_task_role_through_an_access_point`. + - `the_environment_security_group_gains_no_ingress`: the only ingress is the `-efs` group's port 2049 rule, sourced from the record's `security_group_id`. + - `exec_role_reads_only_its_owners_secrets`. + - `boundary_vars_carry_the_shipped_boundary`: `policy_json` equals `permissions-boundary.json`. + - `every_onboarding_role_stays_inside_the_boundary`: **write this in Task 3**, using the shared evaluator. +- [ ] **Step 2: Run** `cargo test aws::tofu_modules`. Expected: FAIL. +- [ ] **Step 3: Write both modules**, plus their lock files, and run `tofu fmt`, `tofu init -backend=false` and `tofu validate`. +- [ ] **Step 4: Run** the tests. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): onboarding and permissions-boundary Terraform modules`. -### Task 3: The admin policies cover the templates +### Task 3: The admin policies cover the modules; CloudFormation leaves the policies **Files:** -- Create: `aws/policies/environment-admin-network.json` -- Modify: `aws/policies/environment-admin.json` -- Modify: `app/src-tauri/src/aws/policies.rs` -- Modify: `app/src-tauri/src/aws/permissions.rs` (one tier is now several files) -- Modify: `app/src-tauri/src/commands/host_commands.rs` (`aws_policy_document`) -- Modify: `app/src/lib/tauri-commands.ts`, `app/src/lib/types.ts`, `components/settings/HostsSettings.tsx` (show and copy each file) +- Create: `aws/policies/environment-admin-network.json`, plus a third admin file only if the size limit forces it +- Modify: `aws/policies/environment-admin.json`, `aws/policies/workspace-user.json` (remove `ReadStacks`) +- Modify: `app/src-tauri/src/aws/policies.rs`, `permissions.rs`, `permissions/tests.rs` (drop the CFN checks and probe, and evaluate every file of a tier together) +- Modify: `commands/host_commands.rs` (`aws_policy_document -> Vec`), `app/src/lib/{types,tauri-commands}.ts`, `components/settings/HostsSettings.tsx` (show and copy each file) +- Modify: `app/src-tauri/Cargo.toml`: remove `aws-sdk-cloudformation` if nothing else uses it - Modify: `aws/README.md` -- Move: the test-only IAM evaluator from `policies.rs` `mod tests` into `app/src-tauri/src/aws/policy_eval.rs` (`#[cfg(test)]`), so the template tests can use it. Its behaviour is unchanged. +- Move: the test-only IAM evaluator from `policies.rs`'s `mod tests` to `aws/policy_eval.rs` (`#[cfg(test)]`). Its behaviour doesn't change. **Interfaces:** - Produces: ```rust - pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)]; // (file name, json); admin = [environment-admin.json, environment-admin-network.json] + pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)]; // replaces policy_json #[tauri::command] pub fn aws_policy_document(tier: Tier) -> Vec; // PolicyFile { name: String, json: String } ``` - `policy_json` is removed. Its callers use `policy_files`, and the simulator evaluates all of a tier's files together. -- A table in `templates/tests.rs` maps each template resource type to the IAM actions CloudFormation calls to create, tag, read, update and delete it. For example, `AWS::KMS::Key` maps to `kms:CreateKey`, `kms:TagResource`, `kms:DescribeKey`, `kms:GetKeyPolicy`, `kms:PutKeyPolicy`, `kms:EnableKeyRotation`, `kms:GetKeyRotationStatus`, `kms:ListResourceTags` and `kms:ScheduleKeyDeletion`. - - The table is the hypothesis. The Task 8 live test is the proof, and any action it finds missing goes into both the table and the policy. +- `tofu_modules/tests.rs` gets a table mapping each **Terraform resource type** used by the modules to the IAM actions the AWS provider calls to create, read (refresh), tag, update and delete it. For example, `aws_kms_key` maps to `kms:CreateKey`, `TagResource`, `DescribeKey`, `GetKeyPolicy`, `GetKeyRotationStatus`, `ListResourceTags`, `EnableKeyRotation`, `PutKeyPolicy` and `ScheduleKeyDeletion`. + - The table is the hypothesis. Task 8's live run is the proof, and any action it shows missing is added to the table, the policy, the check table and the README together. +- **State bucket permissions (admin):** + - `s3:CreateBucket` with `aws:RequestTag/triple-c:component`; + - `s3:PutBucketVersioning`, `PutEncryptionConfiguration`, `PutBucketPublicAccessBlock`, `PutBucketTagging`, `PutBucketPolicy`, `GetBucket*` and `ListBucket` on `arn:aws:s3:::triple-c-tfstate-*`; + - `s3:GetObject`, `PutObject` and `DeleteObject` on `arn:aws:s3:::triple-c-tfstate-*/*`. +- **Other admin additions:** + - `iam:GetPolicy` on the boundary ARN, which detection uses; + - the `iam:` read actions the provider uses on `role/triple-c-*`; + - the KMS, EFS, logs and SSM reads and tagging. +- **Network creation** goes in `environment-admin-network.json`: + - creates are tag-conditioned on `aws:RequestTag/triple-c:component`; + - mutations and deletes are conditioned on `aws:ResourceTag/triple-c:component`; + - `ec2:CreateTags` is allowed only under `ec2:CreateAction` for those creates. - [ ] **Step 1: Write the failing tests:** - - `the_admin_tier_can_deploy_every_template_resource`: for each resource in the environment and onboarding templates, every mapped action is allowed by the admin tier's files. Use the resource's tags (`triple-c:component` present) and resource names from Task 1's naming functions. - - `every_onboarding_role_stays_inside_the_boundary` (from Task 2). - - The existing `every_policy_fits_a_managed_policy` covers the new file. The existing check-table drift tests now require a check for every new admin action. - - `the_admin_still_cannot_touch_untagged_resources`: `ec2:DeleteVpc`, `ec2:DeleteSubnet` and `ec2:DeleteNatGateway` on a resource without `triple-c:component` are denied. + - `the_admin_tier_can_apply_every_module_resource`: every action mapped for every resource in the environment and onboarding modules is allowed by the admin tier's files. The test uses the `triple-c:component` tag and the names from Task 1. + - `every_onboarding_role_stays_inside_the_boundary`. + - `the_admin_still_cannot_touch_untagged_resources`: `ec2:DeleteVpc`, `DeleteSubnet`, `DeleteNatGateway` and `s3:DeleteBucket` on a resource without `triple-c:component`, or outside `triple-c-tfstate-*`, are denied. + - `no_policy_mentions_cloudformation`. + - The existing size and check-drift tests cover the new files. - [ ] **Step 2: Run** `cargo test aws::`. Expected: FAIL. -- [ ] **Step 3: Add the actions.** - - VPC, subnet, IGW, route, route-table, EIP and NAT go in `environment-admin-network.json`. - - Creation requires the `aws:RequestTag/triple-c:component` tag. - - Mutation and deletion require `aws:ResourceTag/triple-c:component`. - - `ec2:CreateTags` is allowed only with `ec2:CreateAction` set to one of those create calls. - - Everything else goes in `environment-admin.json`. That covers the extra KMS, ECS, logs, EFS and SSM read and tag actions, plus `iam:GetPolicy` on the boundary ARN, which detection uses. - - Add a check-table row for each new action, and update the README tier sections. -- [ ] **Step 4: Run** `cargo test aws::` and `npx vitest run src/components/settings`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): admin policies cover the environment and onboarding stacks`. +- [ ] **Step 3: Implement it.** +- [ ] **Step 4: Run** `cargo test aws::`, then `npx vitest run src/components/settings`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): admin policies cover the Terraform modules; drop CloudFormation`. -### Task 4: Stack operations (`aws/stacks.rs`) +### Task 4: The OpenTofu runner (`aws/tofu.rs`) **Files:** -- Create: `app/src-tauri/src/aws/stacks.rs`, with tests in `aws/stacks/tests.rs` -- Modify: `app/src-tauri/src/aws/error.rs` if a new variant is needed (`AlreadyExists { name }`) +- Create: `app/src-tauri/src/aws/tofu.rs`, with tests in `aws/tofu/tests.rs` +- Modify: `Cargo.toml` (`zip`, and `flate2` if needed) **Interfaces:** - Produces: ```rust - #[async_trait] pub trait StackApi: Send + Sync { - async fn create(&self, name: &str, body: &str, params: &[(String, String)], tags: &[(String, String)]) -> Result; // stack id; CAPABILITY_NAMED_IAM; OnFailure=ROLLBACK - async fn update(&self, name: &str, body: &str, keep_params: &[String], tags: &[(String, String)]) -> Result; // UsePreviousValue for keep_params - async fn delete(&self, name: &str) -> Result<(), AwsError>; - async fn describe(&self, name: &str) -> Result, AwsError>; // None when it does not exist - async fn events(&self, name: &str) -> Result, AwsError>; // newest first, as the API returns - async fn list_tagged(&self, key: &str, value: &str) -> Result, AwsError>; // DescribeStacks paged, filtered by tag client-side - } - pub struct SdkStackApi { … } impl SdkStackApi { pub fn new(cfg: &SdkConfig) -> Self } - pub enum UpdateOutcome { Started, AlreadyUpToDate } - #[derive(Serialize, Clone, Debug, PartialEq)] pub struct StackSummary { name, id, status: String, status_reason: Option, tags: Vec<(String,String)>, template_version: Option } // from tag triple-c:template-version - #[derive(Serialize, Clone, Debug, PartialEq)] pub struct StackEvent { id, timestamp: String, logical_id, resource_type, status, reason: Option } - pub fn is_terminal(status: &str) -> bool; // *_COMPLETE or *_FAILED, except *_IN_PROGRESS - pub fn is_success(status: &str) -> bool; // CREATE_COMPLETE, UPDATE_COMPLETE, DELETE_COMPLETE, IMPORT_COMPLETE - pub fn is_deletable_failure(status: &str) -> bool; // D6 list - pub async fn watch(api: &A, name: &str, poll: Duration, deadline: Duration, on_events: impl FnMut(&[StackEvent])) -> Result; + pub const TOFU_VERSION: &str = ""; + pub fn release_asset(os: &str, arch: &str) -> Option<(String /*file name*/, &'static str /*sha256*/)>; // linux/darwin amd64+arm64 (.tar.gz), windows amd64 (.zip) + pub async fn ensure_tofu(root: &Path) -> Result; // /tofu//tofu[.exe]; downloads from github.com/opentofu/opentofu/releases/download/v/, verifies, extracts, chmod 755; reuses an existing verified binary (re-hash on each call, cheap) + pub fn verify_and_extract(archive: &Path, expected_sha256: &str, dest: &Path) -> Result; // deletes archive on mismatch + pub struct Workdir { pub dir: PathBuf } + pub fn materialize(root: &Path, kind: ModuleKind, state_key: &str, bucket: &str, region: &str, vars: &serde_json::Value) -> Result; // /work///; writes module_files, backend.hcl (bucket, key, region, encrypt=true, use_lockfile=true), terraform.tfvars.json (0600) + pub fn child_env(conn: &HostConnection, keys: Option<&AwsKeys>, plugin_cache: &Path) -> Vec<(String, String)>; // the Global Constraints env, nothing inherited from AWS_/TF_/TOFU_ + pub enum TofuOp { Apply, Destroy } + pub fn start_detached(tofu: &Path, wd: &Workdir, op: TofuOp, env: &[(String, String)]) -> Result; // runs `init -input=false -backend-config=backend.hcl` then `apply|destroy -auto-approve -json -input=false`, as one detached shell-free sequence (a tiny runner: spawn init, wait, spawn apply) writing apply.log, apply.pid, apply.exit + pub fn is_running(wd: &Workdir) -> bool; // apply.pid alive and apply.exit absent + pub fn interrupt(wd: &Workdir) -> Result<(), AwsError>; // SIGINT (Windows: CTRL_BREAK to the process group) + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct TofuEvent { pub ts: String, pub level: String, pub kind: String /* "@type" */, pub resource: Option, pub action: Option, pub message: String } + pub fn parse_log_line(line: &str) -> Option; // tofu -json lines; non-JSON lines (init output) become kind "log" + pub async fn follow(wd: &Workdir, poll: Duration, on_events: impl FnMut(&[TofuEvent])) -> Result; // from byte 0, each line once, until apply.exit exists + pub struct TofuOutcome { pub ok: bool, pub exit_code: i32, pub summary: Option } ``` - - `watch` reports each event once, in chronological order. Events that already existed when it started are reported in the first batch. It returns when `describe` is terminal (or `None` after a delete), and returns `Err(Timeout)` after `deadline`. - - The real poll interval is 3 s and the deadline is 60 min. Tests pass `Duration::ZERO` and a fake clock is not needed. +- Detaching: the runner is the app binary itself, re-executed with a hidden `--tofu-runner ` argument, so no shell is involved. + - The runner runs init and then apply, writes the exit code, and exits. + - On Unix it is spawned with `setsid` (`pre_exec` with `libc::setsid`). On Windows it uses `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`. + - Changing `main.rs` to handle the flag before Tauri starts is part of this task. + - The runner receives `child_env` by inheriting the environment the app gives it at spawn time. **Credentials never go on argv.** -- [ ] **Step 1: Write the failing tests** with a scripted fake `StackApi`: - - `an_update_with_no_changes_is_already_up_to_date`: the SDK `ValidationError` with the message `No updates are to be performed.` maps to `UpdateOutcome::AlreadyUpToDate`. Test the mapping function `update_error_outcome(&str)` directly. - - `creating_a_stack_that_exists_is_already_exists`: `AlreadyExistsException` maps to `AwsError::AlreadyExists { name }`. - - `watching_a_stack_already_in_progress_reports_its_existing_events_once`: polls return events [e1], then [e1, e2], then [e1, e2, e3] with a terminal status. The callback sees e1, then e2, then e3, each exactly once and in time order. - - `watch_ends_on_delete_complete_or_a_vanished_stack`. - - `statuses_are_classified`: `ROLLBACK_COMPLETE` is terminal, not a success, and deletable. `UPDATE_ROLLBACK_COMPLETE` is terminal, not a success, and not deletable. `CREATE_IN_PROGRESS` is not terminal. -- [ ] **Step 2: Run** `cargo test aws::stacks`. Expected: FAIL. -- [ ] **Step 3: Implement** `stacks.rs`, using `aws-sdk-cloudformation`. Classify errors through `classify_sdk_error`. -- [ ] **Step 4: Run** `cargo test aws::stacks`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): CloudFormation stack operations and progress watching`. +- [ ] **Step 1: Write the failing tests:** + - `a_download_with_the_wrong_hash_is_deleted_and_refused`: write a fake archive, call `verify_and_extract` with another hash, and expect `Err`, with the archive gone and nothing extracted. + - `the_child_environment_carries_nothing_inherited`: set `AWS_ENDPOINT_URL`, `AWS_ACCESS_KEY_ID`, `TF_LOG` and `TOFU_X` in the test's view of the env (pass the parent env in as a parameter rather than mutating the process env). Expect none of them in `child_env`, except the connection's own keys or profile. Expect the four `TF_*` and `CHECKPOINT_*` values the app sets. + - `materialize_writes_backend_and_vars_privately`: `backend.hcl` holds the bucket, key, region, `use_lockfile = true` and `encrypt = true`. `terraform.tfvars.json` has mode `0600` on Unix. The module files are byte-identical. + - `following_an_existing_log_reports_each_event_once`: write 3 JSON lines, then 2 more, then `apply.exit`. The callback sees 5 events, in order, once each, and the outcome is `ok`. + - `apply_errored_and_diagnostics_surface_their_message`: parse a real `-json` `diagnostic` line (severity error) and an `apply_errored` line. + - `release_assets_exist_for_every_shipped_platform`. +- [ ] **Step 2: Run** `cargo test aws::tofu`. Expected: FAIL. +- [ ] **Step 3: Implement it.** Take the hashes from the release's `SHA256SUMS` file; verify that file's signature if practical, and say in the report what you did. +- [ ] **Step 4: Run** the tests, then do a local smoke test. With `ensure_tofu` into a temp root, `materialize` the boundary module with a dummy bucket, and run only `tofu init -backend=false` plus `validate` through the same binary. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): pinned OpenTofu download and a detached apply runner`. -### Task 5: Detection (`aws/environments.rs`) +### Task 5: The state bucket and detection (`aws/environments.rs`) **Files:** - Create: `app/src-tauri/src/aws/environments.rs`, with tests in `aws/environments/tests.rs` +- Modify: `Cargo.toml` (`aws-sdk-s3`) **Interfaces:** -- Consumes: `templates::*`, `stacks::{StackApi, StackSummary, is_deletable_failure}`, and `identity::CallerIdentity`. +- Consumes: `tofu_modules::*`, `tofu::is_running`/`Workdir` (for the local-process check), `identity::CallerIdentity`. - Produces: ```rust #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct EnvironmentInfo { schema: u32, name, template_version: u32, cluster_arn, security_group_id, subnet_ids: Vec, assign_public_ip: bool, kms_key_arn, log_group, network_mode, tenancy } pub fn parse_environment_record(path_name: &str, value: &str, account: &str, region: &str) -> Result; + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct LockInfo { who: String, created: String, operation: String, age_secs: u64, local_process: bool } #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] - pub enum EnvironmentState { Ready, Deploying, UpdateAvailable, Failed { reason: Option }, StackMissing, RecordMissing, UnreadableRecord { reason: String } } - #[derive(Serialize, Clone, Debug, PartialEq)] pub enum EnvironmentAction { Join, Update, Delete, Watch } // snake_case + pub enum EnvironmentState { Ready, Deploying { lock: Option }, UpdateAvailable, Incomplete { lock: Option }, ExternallyManaged, UnreadableRecord { reason: String } } + #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum EnvironmentAction { Join, Update, Repair, Destroy, Follow, Unlock } #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] - pub enum OnboardingState { Joined, NotJoined, InProgress, Failed { reason: Option } } - #[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { name, stack_status: Option, template_version: Option, state: EnvironmentState, actions: Vec, info: Option, onboarding: Option } - #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum BoundaryState { Present, Missing, Unknown } - #[derive(Serialize, Clone, Debug)] pub struct EnvironmentScan { region, account, owner_id, owner_key, boundary: BoundaryState, environments: Vec } - pub fn merge(records: Vec<(String, Result)>, stacks: Vec, current_version: u32) -> Vec; // sorted by name; onboarding None - #[async_trait] pub trait DetectApi: Send + Sync { async fn environment_records(&self) -> Result, AwsError>; async fn boundary_exists(&self, account: &str) -> Result, AwsError>; async fn role_exists(&self, name: &str) -> Result, AwsError>; } // None = denied - pub async fn scan(d: &D, s: &S, who: &CallerIdentity, region: &str) -> Result; + pub enum OnboardingState { Joined, NotJoined, InProgress, Incomplete } + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { name, template_version: Option, state: EnvironmentState, actions: Vec, info: Option, onboarding: Option } + #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum Knowable { Yes, No, Unknown } + #[derive(Serialize, Clone, Debug)] pub struct EnvironmentScan { region, account, owner_id, owner_key, state_bucket: Knowable, boundary: Knowable, environments: Vec } + pub struct StateObjects { pub states: Vec /* keys */, pub locks: Vec<(String, String /*lock json*/, u64 /*age*/)> } + pub fn merge(records: Vec<(String, Result)>, objects: Option<&StateObjects>, running_locally: &dyn Fn(&str) -> bool, current_version: u32) -> Vec; + #[async_trait] pub trait DetectApi: Send + Sync { + async fn environment_records(&self) -> Result, AwsError>; + async fn state_objects(&self, bucket: &str) -> Result, AwsError>; // None = bucket missing or denied + async fn bucket_exists(&self, bucket: &str) -> Result; + async fn boundary_exists(&self, account: &str) -> Result; + async fn role_exists(&self, name: &str) -> Result; + } + pub async fn scan(d: &D, who: &CallerIdentity, region: &str, running_locally: &dyn Fn(&str) -> bool) -> Result; + pub async fn ensure_state_bucket(cfg: &SdkConfig, account: &str, region: &str) -> Result; ``` -- Rules: - - The `Join` action is offered only for `Ready` and `UpdateAvailable`. - - `UpdateAvailable` means the stack's `template_version` is below the current version. - - `UPDATE_ROLLBACK_COMPLETE` is `Ready`, with a note that the last update rolled back. - - Onboarding is resolved only for joinable environments: - 1. `describe(onboarding_stack_name)`. - 2. If the stack doesn't exist, `role_exists(task role)`. This catches an admin who deployed it under another stack name. - 3. If that is denied, `NotJoined`. - - The boundary state comes from `iam:GetPolicy`. If that is denied, the state is `Unknown`. +- `ensure_state_bucket` creates the bucket with the `triple-c:component=state` tag. `LocationConstraint` is omitted in `us-east-1`. It then sets versioning, SSE-S3 (AES256) encryption, all four public-access blocks, and a bucket policy denying non-TLS requests. It is idempotent: `BucketAlreadyOwnedByYou` counts as success, and `BucketAlreadyExists` from another owner is an error that names the bucket. +- Rules for `merge`: + - **Ready**: a record with state at the current version. + - **UpdateAvailable**: a record with state at an older version. + - **Deploying**: the lock is held and either a local process is running or the lock is less than 2 hours old. The action is Follow when the process is local. + - **Incomplete**: state with no record and no live lock, or a stale lock. Actions are Repair and Destroy, plus Unlock when the lock is stale and not local. + - **ExternallyManaged**: a record with no state (or no bucket access). The only action is Join. + - Join is offered for Ready, UpdateAvailable and ExternallyManaged. + - Onboarding is resolved for joinable environments. Check the user's state key first, then `role_exists` on the task role. If that is denied, the state is `NotJoined`. - `parse_environment_record` rejects: - - a value over 4 KiB, or one that isn't JSON; - - `schema != 1`; - - a `name` different from the path's last segment; - - any ARN not in `arn:aws::::` for this region and account; - - `subnet_ids` that isn't exactly 2 `subnet-[0-9a-f]+` IDs, or a security group that isn't `sg-[0-9a-f]+`; - - `tenancy != "strict"`. + - more than 4 KiB, or not JSON; + - `schema` other than 1; + - a name that doesn't match the path; + - any ARN not in this account and region; + - anything other than exactly 2 `subnet-[0-9a-f]+` IDs; + - a security group that isn't `sg-[0-9a-f]+`; + - `tenancy` other than `strict`. - The rejection reason names the field and never echoes the value. + The reason names the field and never echoes the value. - [ ] **Step 1: Write the failing tests:** - - `untrusted_environment_records_are_rejected`: a table of the rejections above, plus one valid record that parses. - - `a_parameter_without_a_stack_and_a_stack_without_a_parameter_are_both_listed`: gives `StackMissing` with no Join, and `RecordMissing` with Update. - - `half_deployed_stacks_get_the_right_actions`: - - `CREATE_IN_PROGRESS` → `Deploying` [Watch]; - - `ROLLBACK_COMPLETE` → `Failed` [Delete]; - - `DELETE_FAILED` → `Failed` [Delete]; - - `UPDATE_ROLLBACK_COMPLETE` → `Ready` [Join, Update]; - - `UPDATE_IN_PROGRESS` → `Deploying` [Watch]. - - `an_older_template_offers_update`: version 0 against current version 1 gives `UpdateAvailable` [Join, Update]. - - `onboarding_is_found_by_stack_then_by_role`: the stack present at `CREATE_COMPLETE` gives `Joined`. No stack but the role present gives `Joined`. No stack and no role gives `NotJoined`. Stack at `ROLLBACK_COMPLETE` gives `Failed`. - - `a_denied_boundary_lookup_is_unknown_not_missing`. + - `untrusted_environment_records_are_rejected`: a table of those cases, plus one valid record. + - `a_record_without_state_and_state_without_a_record_are_both_listed`. + - `interrupted_deployments_get_the_right_actions`: + - a stale lock with no local process gives `Incomplete` with [Repair, Destroy, Unlock]; + - a fresh lock gives `Deploying` with no actions; + - a local process gives `Deploying` with [Follow]. + - `an_older_module_offers_update`. + - `onboarding_is_found_by_state_then_by_role`. + - `denied_lookups_are_unknown_not_missing`. - [ ] **Step 2: Run** `cargo test aws::environments`. Expected: FAIL. -- [ ] **Step 3: Implement** it, with `SdkDetectApi`: `GetParametersByPath` on `/triple-c/environments` (non-recursive, paged), and `iam:GetPolicy`/`GetRole`. An `AccessDenied` maps to `None`. +- [ ] **Step 3: Implement it**, including the SDK `DetectApi`: SSM `GetParametersByPath`, S3 `ListObjectsV2` with the prefixes `environments/`, `users/` and `boundary/` plus `GetObject` on `*.tflock`, and IAM `GetPolicy`/`GetRole`. Access denied maps to `Unknown` or `None`. - [ ] **Step 4: Run** the tests. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): detect environments, the boundary and onboarding`. +- [ ] **Step 5: Commit** with the message `feat(aws): state bucket, and detection of environments, locks and onboarding`. -### Task 6: Commands, progress events and admin requests +### Task 6: Commands, progress events and admin bundles **Files:** - Create: `app/src-tauri/src/commands/environment_commands.rs` -- Modify: `commands/mod.rs`, `lib.rs` (`generate_handler!`), `capabilities/default.json` -- Modify: `app/src/lib/types.ts`, `app/src/lib/tauri-commands.ts` +- Modify: `commands/mod.rs`, `lib.rs`, `capabilities/default.json`, `app/src/lib/{types,tauri-commands}.ts` **Interfaces:** -- Produces these commands. Each takes `connection_id` and `region` (validated with `validate_supported_region`). An `op_id` is a new UUID. +- Produces these commands. Each takes `connection_id` and `region` (validated). The work root is `dirs::data_dir()/triple-c/aws/`. An `op_id` is a UUID. ```rust detect_aws_environments(connection_id, region) -> Result - deploy_aws_environment(connection_id, region, name, network: NetworkChoice, app) -> Result // validates name; refuses if boundary Missing - update_aws_environment(connection_id, region, name, app) -> Result // keep_params = all template params - delete_failed_aws_stack(connection_id, region, stack_name, app) -> Result // only if describe() status is_deletable_failure and stack name is one of ours - watch_aws_stack(connection_id, region, stack_name, app) -> Result // attach to an in-progress stack - onboard_aws_user(connection_id, region, environment, app) -> Result // self-onboarding with the caller's own owner id + deploy_aws_environment(connection_id, region, name, network: NetworkChoice, app) -> Result // ensure_tofu, ensure_state_bucket, refuse if boundary == No, refuse if a record or state for `name` exists + update_aws_environment(connection_id, region, name, app) -> Result // re-apply with vars read back from the record (network from record; never change network on update) + repair_aws_environment(connection_id, region, name, network: Option, app) -> Result // re-apply an Incomplete one; network required if no record + destroy_incomplete_aws_environment(connection_id, region, name, app) -> Result // only when detection says Incomplete + unlock_aws_state(connection_id, region, state_key) -> Result<(), AwsError> // only for a stale, non-local lock (re-checked server-side), deletes the .tflock object + follow_aws_deployment(connection_id, region, state_key, app) -> Result + cancel_aws_deployment(connection_id, region, state_key) -> Result<(), AwsError> // tofu::interrupt + onboard_aws_user(connection_id, region, environment, app) -> Result // self-onboarding with the caller's own owner id deploy_aws_permissions_boundary(connection_id, region, app) -> Result - aws_admin_request(connection_id, region, kind: TemplateKind, environment: Option, owner_id: Option) -> Result - save_aws_template(kind: TemplateKind, window) -> Result, String> // Rust-side save dialog (pattern: file_commands::pick_save_path); writes the embedded body + save_aws_admin_bundle(connection_id, region, kind: ModuleKind, environment: Option, owner_id: Option, window) -> Result, AwsError> // Rust-side folder dialog; writes D5 bundle; returns the folder path ``` - - `AdminRequest { stack_name, template_file_name, parameters: Vec<(String,String)>, tags: Vec<(String,String)>, capabilities: Vec, cli_command: String }`. - - `cli_command` is `aws cloudformation deploy --region --stack-name --template-file --capabilities CAPABILITY_NAMED_IAM --parameter-overrides K=V … --tags K=V …`, with every value shell-quoted by `commands::terminal_commands::shell_quote_arg`. Make that function `pub(crate)`; don't copy it. - - Every mutating command spawns `stacks::watch` and returns at once. - - Progress is emitted on the Tauri event `aws-stack-progress` as `{ op_id, stack_name, events: StackEvent[], done: bool, result?: { ok: bool, status, message? } }`. - - "Already up to date" is emitted as `done` with `ok: true` and `status: "NO_CHANGES"`. -- Stack names passed in from the frontend are checked to have the form `triple-c-*`, and to be one this app's naming functions can produce. The frontend can't name an arbitrary stack. + - Progress is emitted on the Tauri event `aws-deploy-progress` as `{ op_id, state_key, events: TofuEvent[], done: bool, result?: { ok, exit_code, summary? } }`. + - State keys passed in from the frontend must equal a key `state_key()` can produce. Anything else is refused. + - The bundle's `README.txt` gives the exact commands for this account, region and bucket. Its vars come from the same `*_vars` functions the app applies with. The bucket must exist first, so the README says to deploy from the app once or to create the bucket by hand, and gives the CLI for that. -- [ ] **Step 1: Write failing unit tests** in the module, against the pure helpers: - - `admin_requests_are_exact_and_quoted`: an onboarding request for env `dev` and owner `AROAX:o'brien@example.com` contains `OwnerId='AROAX:o'"'"'brien@example.com'`, or whatever the helper's quoting is (assert that it round-trips through `sh -c 'printf %s …'`). Its stack name is `triple-c-dev-user-`. - - `only_our_stacks_can_be_deleted_or_watched`: `triple-c-dev` and `triple-c-dev-user-0123456789ab` are accepted. `other-stack`, `triple-c-` and `triple-c-dev; x` are rejected. - - `deploy_refuses_when_the_boundary_is_missing`, using a fake that reports `Missing`. +- [ ] **Step 1: Write the failing tests** against the pure helpers: + - `bundles_contain_the_module_backend_vars_and_readme`: `write_bundle(dir, …)` writes every `module_files` entry, plus `backend.hcl`, `terraform.tfvars.json` and a README naming the bucket and key. + - `only_our_state_keys_are_accepted`. + - `deploy_refuses_an_existing_name_and_a_missing_boundary`. + - `update_reuses_the_recorded_network`. - [ ] **Step 2: Run** `cargo test environment_commands`. Expected: FAIL. -- [ ] **Step 3: Implement** the commands, register them and grant them. Add the TS types and wrappers: `EnvironmentScan`, `DetectedEnvironment`, `NetworkChoice`, `AdminRequest`, `StackProgress`, `TemplateKind`. +- [ ] **Step 3: Implement** the commands, register them, grant them, and add the TS types and wrappers. - [ ] **Step 4: Run** `cargo test`, `cargo check` and `npx vitest run src/test/capabilities.test.ts`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): environment commands with streamed stack progress`. +- [ ] **Step 5: Commit** with the message `feat(aws): environment commands with streamed OpenTofu progress`. ### Task 7: Environments UI **Files:** -- Create: `app/src/components/settings/hosts/EnvironmentsPanel.tsx`, `DeployEnvironmentModal.tsx`, `AdminRequestModal.tsx`, `StackProgress.tsx`, plus a `*.test.tsx` for each of the first three -- Modify: `app/src/components/settings/HostsSettings.tsx`, which renders `` under each connection's permission check +- Create: `app/src/components/settings/hosts/{EnvironmentsPanel,DeployEnvironmentModal,AdminBundleModal,DeployProgress}.tsx`, plus tests for the first three +- Modify: `HostsSettings.tsx`, which renders `` -**Interfaces:** -- Consumes: the Task 6 wrappers and the `aws-stack-progress` event. - -**Behaviour** (spec §2's table, with phase-3 conventions: `StatusIndicator`, `Modal`, `Button`, `Field`, design tokens): -- **Region select.** Defaults to the connection's `default_region`. Detection runs when the panel opens and when the region changes. A "Detect again" button reruns it. -- **One row per environment**, showing its state, the template version and actions: - - **Join**: - - If `onboarding` is `Joined`, it shows "Joined" instead. - - Otherwise, an environment admin gets `onboardAwsUser` with progress. - - Anyone else gets the onboarding `AdminRequestModal`. The row then reads "Waiting for an admin to onboard you", and the request is regenerated each time the panel opens. - - **Update**: runs `updateAwsEnvironment` with progress. "Already up to date" is a success. - - **Delete**: a typed confirmation of the stack name, then `deleteFailedAwsStack`. - - **Watch**: `watchAwsStack`. -- **"Deploy a new environment."** - - It is the primary action when nothing is found and the tier is admin. It is secondary when environments exist. - - If the tier is not admin and nothing is found, show the explanation, the policy files from `awsPolicyDocument("environment_admin")`, and the environment `AdminRequestModal`. +**Behaviour.** This follows spec §2's table. It uses `StatusIndicator`, `Modal`, `Button`, `Field`, `SegmentedControl` and the design tokens. +- **Detection.** A region select defaults to `default_region`. Detection runs when the panel opens and whenever the region changes. There is also a "Detect again" button. +- **Environment rows.** Each row shows the state (Lock owner and age are shown when present), the version, and its actions: + - Join; + - Update; + - Repair, which asks for the network if there is no record; + - Destroy, which requires typing the name; + - Follow; + - Unlock, which shows who holds the lock and since when, and needs a typed confirmation; + - Cancel, during a local deploy. +- **Joining.** + - When the user is already onboarded, the row reads "Joined". + - For an admin, Join runs `onboardAwsUser` and shows progress. + - Anyone else gets the `AdminBundleModal` for onboarding, and the row reads "Waiting for an admin to onboard you". +- **Deploying.** + - "Deploy a new environment" is the primary action when nothing is found and the user is an admin. It is secondary when environments exist. + - A non-admin with nothing found sees an explanation, the policy files (`awsPolicyDocument("environment_admin")`), and the environment `AdminBundleModal`. - **`DeployEnvironmentModal`:** - - Name, defaulting to `main`, validated with the same rule as Rust (mirror the regex in TS; Rust is the boundary). - - Network, as a `SegmentedControl` with the D4 modes. Each mode states its cost: `public` "$0, tasks get a public IP, no inbound access"; `private-nat` "about $33/month for the NAT gateway"; `existing` shows three ID fields and the public-IP toggle. - - If the boundary is `Missing`, the modal first shows a step: "Create the permissions boundary (needs an account administrator)". That step offers `deployAwsPermissionsBoundary`. If that fails with access denied, it shows the boundary `AdminRequestModal`. - - If the boundary is `Unknown`, deploying is allowed, with a note that it fails if the boundary is missing. -- **`StackProgress`** shows events oldest first, with resource, status and reason. Failure reasons are shown in full. When done, it shows a result line and triggers re-detection. -- **`AdminRequestModal`:** "Save template…" (`saveAwsTemplate`), the parameters and tags table, and the CLI command with a Copy button. + - Name: defaults to `main`, and is validated with the Rust rule mirrored in TS. + - Network: the D4 modes, each with its cost line. `public` reads "$0 — tasks get a public IP, no inbound access". `private-nat` reads "about $33/month for the NAT gateway". `existing` has a VPC ID, two subnet IDs and a public-IP toggle. + - If `boundary` is `no`, the modal first offers a step: "Create the permissions boundary (needs an account administrator)". If that is denied, it opens the boundary bundle. + - A first-run note says "First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB)." +- **`DeployProgress`.** It lists events in order: resource, action and message. Errors are shown in full. A final result line triggers re-detection. Cancel calls `cancelAwsDeployment` after a confirmation. +- **`AdminBundleModal`.** It explains the bundle and has a "Save bundle to folder…" button (`saveAwsAdminBundle`). After saving, it shows the saved path and the two commands, with a Copy button. -- [ ] **Step 1: Write failing component tests**, mocking `tauri-commands`: +- [ ] **Step 1: Write the failing tests:** - `shows deploy as primary when nothing is found and the user is an admin`. - - `offers an admin request instead of deploy for a workspace user with nothing found`. - - `a failed stack offers delete only after typing its name`. + - `offers an admin bundle instead of deploy for a workspace user with nothing found`. + - `an incomplete environment offers repair and destroy, destroy only after typing its name`. + - `a stale lock offers unlock with its owner and age`. - `join onboards an admin and shows progress until done, then re-detects`. - - `join for a workspace user opens the onboarding request with the exact command`. - `deploy modal blocks on a missing boundary and offers to create it`. - `the public network mode is the default and says it costs nothing`. - [ ] **Step 2: Run** `npx vitest run src/components/settings/hosts`. Expected: FAIL. -- [ ] **Step 3: Implement** the components. +- [ ] **Step 3: Implement it.** - [ ] **Step 4: Run** `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): environments panel — detect, deploy, update, join`. +- [ ] **Step 5: Commit** with the message `feat(aws): environments panel — detect, deploy, update, repair, join`. ### Task 8: Live verification, docs and spec **Files:** -- Modify: `app/src-tauri/src/aws/live_tests.rs`, adding the `environment` tests below -- Modify: `aws/README.md` (an "Environment and onboarding stacks" section, plus results in "Live verification"), `CLAUDE.md` (the `aws/` paragraph: templates, stacks, environments, D2), and the spec (phase 4 done, decisions D1–D8, the EFS isolation change, "To verify" updates) +- Modify: `app/src-tauri/src/aws/live_tests.rs`, adding the `environment` tests +- Modify: `aws/README.md`. Add an "Environments (OpenTofu)" section covering the modules, the state bucket, bundles and running them by hand. Add the results to "Live verification". +- Modify: `CLAUDE.md`'s `aws/` paragraph, covering `tofu_modules`, `tofu`, `environments`, D2 and D10. +- Modify: the spec. Phase 4 is done. Replace the CloudFormation design text (§2 "Deploy uses the CloudFormation template…", stack detection, "Shipped AWS artefacts") with the OpenTofu design and decisions D2–D10, and update "To verify". **Live tests** (ignored; they read `TRIPLEC_AWS_E2E_PROFILE` and `TRIPLEC_AWS_E2E_REGION`; run with `awsx cargo test aws::live_tests::environment -- --ignored --test-threads=1`): - `an_environment_admin_can_deploy_join_update_and_remove_an_environment`: - 1. As the admin credentials, ensure the boundary exists. If it doesn't, deploy the boundary stack and record that it was created, so it can be removed at the end. - 2. Create the role `triple-c-e2e-envadmin`, trusted by the caller's account. Attach the admin tier's two policies as `triple-c-e2e-*` managed policies, and give it the boundary. - 3. Assume that role. Every following step runs as it, *only*. - 4. Deploy `triple-c-e2e` in `public` mode, and watch it to `CREATE_COMPLETE`. - 5. `scan` finds it `Ready` with a valid record. - 6. Self-onboard, and see `Joined`. - 7. Update, and get `AlreadyUpToDate`. - 8. Delete the onboarding stack, then the environment stack. - 9. Finally, as admin, delete the e2e role and policies, and the boundary if this test created it. - - Cleanup runs on failure too, through a guard. - - Every access-denied error is printed with its action, so a missing policy action shows up as a precise failure. -- `the_private_nat_mode_deploys` (separately ignored): an admin deploy in `private-nat` mode, then `CREATE_COMPLETE`, then delete. This costs about $0.05. + 1. As admin, make sure the boundary exists. If it doesn't, apply the boundary module and remember to remove it at the end. + 2. Create the role `triple-c-e2e-envadmin` with the admin tier's policies attached as `triple-c-e2e-*` managed policies, and with the boundary set. + 3. Assume that role. Every step from here runs as it **only**. Pass its temporary keys to the child as a keys connection. + 4. `ensure_state_bucket` on a test-unique bucket. Allow a `TRIPLEC_E2E_BUCKET_SUFFIX` so the test doesn't touch a real one. + 5. Deploy `e2e` in `public` mode through the real runner and follow it to `ok`. + 6. `scan` finds it Ready with a valid record. + 7. Self-onboard and see `Joined`. + 8. Update. The apply shows "No changes". + 9. Destroy the onboarding, then the environment. + 10. As admin, delete the role, the policies, the bucket (every version), and the boundary if this test created it. + - A guard runs cleanup on failure too. + - Every denied action is printed with its name. +- `the_private_nat_mode_deploys` (ignored separately): deploy in `private-nat` mode, check it reaches `ok`, then destroy. It costs about $0.05. - [ ] **Step 1: Write the tests.** -- [ ] **Step 2: Run them** with the e2e environment and fix any denied action. Each fix goes in the Task 3 policy files, the resource-action table, the check table and the README together, and is re-run until green. Record each action IAM required beyond the table in the README's "Live verification". -- [ ] **Step 3: Confirm cleanup.** List `triple-c-*` stacks, roles, policies and log groups, `triple-c` VPCs, EIPs and EFS file systems, and `alias/triple-c-*` KMS aliases in the region. Expected: none left, with KMS keys only pending deletion. Record the spend. -- [ ] **Step 4: Write the docs and spec updates.** Then run the full `cargo test`, `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `test(aws): live environment lifecycle as an environment admin; docs`. +- [ ] **Step 2: Run them and fix any denied action.** Each fix goes into the Task 3 policies, the resource-action table, the check table and the README together. Re-run until green, and record what IAM needed beyond the table. +- [ ] **Step 3: Confirm cleanup.** Look for leftover `triple-c-*` roles, policies and log groups, any `triple-c` VPCs, EIPs, EFS file systems, `triple-c-tfstate-*` buckets, and `alias/triple-c-*` aliases. Expected: none, apart from KMS keys pending deletion. Record the spend. +- [ ] **Step 4: Write the docs and the spec**, then run the full `cargo test`, `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `test(aws): live environment lifecycle with OpenTofu as an environment admin; docs`.