Compare commits
24
Commits
No files matched your search
@@ -20,3 +20,10 @@ screenshot_for_fix/
|
||||
|
||||
# Windows CI code signing (scripts/windows-signing-setup.ps1)
|
||||
.code-signing/
|
||||
|
||||
# OpenTofu working state (aws/terraform/*): providers, local state, locks.
|
||||
# The modules' .terraform.lock.hcl files are committed.
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
.terraform.tfstate.lock.info
|
||||
@@ -312,19 +312,43 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
`identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is
|
||||
recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN
|
||||
lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …);
|
||||
`policies.rs` embeds `aws/policies/*.json` (the two tier policies and the permissions boundary)
|
||||
and the table of checks the simulator is asked; `permissions.rs` runs it
|
||||
`policies.rs` embeds `aws/policies/*.json` (the tier policies — the admin tier spans several
|
||||
files, `policy_files` lists them — and the permissions boundary) and the table of checks the
|
||||
simulator is asked; `permissions.rs` runs it
|
||||
(`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the
|
||||
caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests
|
||||
are the drift guard: every allowed action has a check, every check is allowed by a small IAM
|
||||
evaluator, owner-scoped checks are denied for someone else, admin destruction is tag-scoped,
|
||||
the boundary fences hold, every Resource is an ARN and every policy fits a 6,144-character
|
||||
managed policy. **Change a policy JSON, its checks and `aws/README.md` together.** The evaluator
|
||||
evaluator (`policy_eval.rs`, test-only; `tofu_modules/tests.rs` also asks it whether the admin
|
||||
tier allows every call `MODULE_CALLS` lists for the Terraform modules), owner-scoped checks are
|
||||
denied for someone else, admin destruction is tag-scoped, the boundary fences hold, every
|
||||
Resource is an ARN and every policy fits a 6,144-character managed policy. **Change a policy
|
||||
JSON, its checks and `aws/README.md` together.** The evaluator
|
||||
is not IAM: `live_tests.rs` (ignored; `TRIPLEC_AWS_E2E_PROFILE`, `TRIPLEC_AWS_E2E_REGION`,
|
||||
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 — `sha256("<region>:<aws:userid>")`,
|
||||
12 hex, regional because role names are account-global — 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
|
||||
|
||||
Generated
+204
-30
@@ -298,7 +298,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"hex",
|
||||
"http",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -350,6 +350,7 @@ dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -366,32 +367,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-cloudformation"
|
||||
version = "1.139.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b77def03a5fb2442348afdd6ecd0eafbfa117243865cc78e899beb26edf7fb6d"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ecs"
|
||||
version = "1.159.0"
|
||||
@@ -464,6 +439,42 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.159.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d5c431c5db2f1f6f536c2a19425575e28273859c6ab399d9ac957f73678c7db"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-sigv4",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-checksums",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http",
|
||||
"http-body",
|
||||
"lru",
|
||||
"percent-encoding",
|
||||
"regex-lite",
|
||||
"sha2 0.11.1",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-secretsmanager"
|
||||
version = "1.128.0"
|
||||
@@ -591,6 +602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57a4393e6addc3380632887b0998d06ad5f099ab5c478be0a3eeedecf8604161"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -616,12 +628,45 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-checksums"
|
||||
version = "0.65.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2f2d12a431aab3703d55277278bad45f02208569b11506220b95fdc1ef0507"
|
||||
dependencies = [
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
"hex",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"md-5",
|
||||
"pin-project-lite",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-eventstream"
|
||||
version = "0.61.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"crc32fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http"
|
||||
version = "0.64.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8"
|
||||
dependencies = [
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -832,7 +877,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
@@ -1385,6 +1430,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-fast"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -3432,6 +3487,46 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hcl-edit"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b1b3e90b173143b26dbb7495ac79e25c0206389d012881cea7cda9f0f1fce8a"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"hcl-primitives",
|
||||
"pratt",
|
||||
"vecmap-rs",
|
||||
"winnow 1.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hcl-primitives"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c35ea757c1bdfa0bdfe8f65771be944d199a7f379b739c8f6fe2ad2f92d18ecb"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"kstring",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hcl-rs"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9736cfea6e2ed86d991394a6a5106cf768ee97dcb3db9cbea49859a9ecbfd1fb"
|
||||
dependencies = [
|
||||
"hcl-edit",
|
||||
"hcl-primitives",
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"serde",
|
||||
"vecmap-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
@@ -4123,6 +4218,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kstring"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a09b82a7f771ed02dc0dd9b27130a0fa5499fa15ed3027116c1e5e4e591bd9e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -4248,6 +4353,15 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -4308,6 +4422,16 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -5159,6 +5283,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pratt"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17e0a4425d076f0718b820673a38fbf3747080c61017eeb0dd79bc7e472b8bb8"
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -6178,6 +6308,17 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1dc"
|
||||
version = "0.1.4"
|
||||
@@ -6332,12 +6473,24 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -7239,10 +7392,10 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-sdk-cloudformation",
|
||||
"aws-sdk-ecs",
|
||||
"aws-sdk-iam",
|
||||
"aws-sdk-kms",
|
||||
"aws-sdk-s3",
|
||||
"aws-sdk-secretsmanager",
|
||||
"aws-sdk-ssm",
|
||||
"aws-sdk-sts",
|
||||
@@ -7254,11 +7407,14 @@ dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
"fern",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"gix",
|
||||
"hcl-rs",
|
||||
"iana-time-zone",
|
||||
"include_dir",
|
||||
"keyring",
|
||||
"libc",
|
||||
"local-ip-address",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
@@ -7301,7 +7457,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
@@ -7480,6 +7636,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vecmap-rs"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58c1dc449b873236909c7f325adf395071c209047737d831183e95c692413adf"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -8255,6 +8420,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
||||
@@ -28,6 +28,9 @@ dirs = "6"
|
||||
log = "0.4"
|
||||
fern = { version = "0.7", features = ["date-based"] }
|
||||
tar = "0.4"
|
||||
# Unpacks the pinned OpenTofu release archive (aws/tofu.rs); every platform's
|
||||
# archive is a .tar.gz. The version already in the tree.
|
||||
flate2 = "1"
|
||||
include_dir = "0.7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
|
||||
iana-time-zone = "0.1"
|
||||
@@ -64,7 +67,6 @@ aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["defa
|
||||
aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-sts = { version = "1.126.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-iam = { version = "1.136.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-cloudformation = { version = "1.139.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
# Named by src/aws/connection.rs: the credentials-provider trait (its profile
|
||||
# wrapper) and the profile-file set (aws-config's alias for it is deprecated).
|
||||
@@ -74,6 +76,14 @@ aws-runtime = { version = "1.10.4", default-features = false }
|
||||
# The default sleep and time source for the hand-built SdkConfig (no env/profile
|
||||
# loading); the version and feature already in the tree.
|
||||
aws-smithy-async = { version = "1.3.1", default-features = false, features = ["rt-tokio"] }
|
||||
aws-sdk-s3 = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
|
||||
# aws/tofu.rs: `setsid` puts the detached OpenTofu runner in its own session,
|
||||
# and Cancel sends SIGINT to the runner process alone (never a group), which
|
||||
# forwards it to `tofu`. Liveness is the runner's `apply.lock`, not a PID.
|
||||
# Already in the tree through tokio and friends.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media
|
||||
# streams and answers the permission request (see src/linux_media.rs). Same
|
||||
@@ -89,6 +99,8 @@ tempfile = "3"
|
||||
# Only `aws::live_tests` (ignored): it creates a throwaway environment key to
|
||||
# check that Secrets Manager and the permissions boundary see its tags.
|
||||
aws-sdk-kms = { version = "1.131.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
# Only `aws::tofu_modules` tests: they parse the shipped OpenTofu modules.
|
||||
hcl-rs = "0.20"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -65,6 +65,18 @@
|
||||
"allow-aws-connection-sso-login",
|
||||
"allow-check-aws-permissions",
|
||||
"allow-aws-policy-document",
|
||||
"allow-detect-aws-environments",
|
||||
"allow-deploy-aws-environment",
|
||||
"allow-update-aws-environment",
|
||||
"allow-repair-aws-environment",
|
||||
"allow-destroy-incomplete-aws-environment",
|
||||
"allow-unlock-aws-state",
|
||||
"allow-follow-aws-deployment",
|
||||
"allow-cancel-aws-deployment",
|
||||
"allow-push-aws-saved-state",
|
||||
"allow-onboard-aws-user",
|
||||
"allow-deploy-aws-permissions-boundary",
|
||||
"allow-save-aws-admin-bundle",
|
||||
"allow-detect-host-timezone",
|
||||
"allow-export-settings",
|
||||
"allow-preview-settings-import",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","descriptionLine truncated
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
@@ -446,6 +446,12 @@
|
||||
"const": "allow-build-stt-image",
|
||||
"markdownDescription": "Enables the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-cancel-aws-deployment",
|
||||
"markdownDescription": "Enables the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -554,12 +560,36 @@
|
||||
"const": "allow-delete-note",
|
||||
"markdownDescription": "Enables the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-environment",
|
||||
"markdownDescription": "Enables the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-config",
|
||||
"markdownDescription": "Enables the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-environments",
|
||||
"markdownDescription": "Enables the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -590,6 +620,12 @@
|
||||
"const": "allow-export-settings",
|
||||
"markdownDescription": "Enables the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-follow-aws-deployment",
|
||||
"markdownDescription": "Enables the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -806,6 +842,12 @@
|
||||
"const": "allow-migrate-project-to-base",
|
||||
"markdownDescription": "Enables the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-onboard-aws-user",
|
||||
"markdownDescription": "Enables the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +908,12 @@
|
||||
"const": "allow-pull-stt-image",
|
||||
"markdownDescription": "Enables the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-push-aws-saved-state",
|
||||
"markdownDescription": "Enables the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -938,6 +986,12 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-repair-aws-environment",
|
||||
"markdownDescription": "Enables the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -956,6 +1010,12 @@
|
||||
"const": "allow-run-scheduled-task-now",
|
||||
"markdownDescription": "Enables the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-save-aws-admin-bundle",
|
||||
"markdownDescription": "Enables the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1124,12 +1184,24 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-unlock-aws-state",
|
||||
"markdownDescription": "Enables the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-connection",
|
||||
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-environment",
|
||||
"markdownDescription": "Enables the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1304,6 +1376,12 @@
|
||||
"const": "deny-build-stt-image",
|
||||
"markdownDescription": "Denies the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-cancel-aws-deployment",
|
||||
"markdownDescription": "Denies the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1412,12 +1490,36 @@
|
||||
"const": "deny-delete-note",
|
||||
"markdownDescription": "Denies the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-environment",
|
||||
"markdownDescription": "Denies the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-config",
|
||||
"markdownDescription": "Denies the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-environments",
|
||||
"markdownDescription": "Denies the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1448,6 +1550,12 @@
|
||||
"const": "deny-export-settings",
|
||||
"markdownDescription": "Denies the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-follow-aws-deployment",
|
||||
"markdownDescription": "Denies the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1664,6 +1772,12 @@
|
||||
"const": "deny-migrate-project-to-base",
|
||||
"markdownDescription": "Denies the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-onboard-aws-user",
|
||||
"markdownDescription": "Denies the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1724,6 +1838,12 @@
|
||||
"const": "deny-pull-stt-image",
|
||||
"markdownDescription": "Denies the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-push-aws-saved-state",
|
||||
"markdownDescription": "Denies the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1796,6 +1916,12 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-repair-aws-environment",
|
||||
"markdownDescription": "Denies the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1814,6 +1940,12 @@
|
||||
"const": "deny-run-scheduled-task-now",
|
||||
"markdownDescription": "Denies the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-save-aws-admin-bundle",
|
||||
"markdownDescription": "Denies the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1982,12 +2114,24 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-unlock-aws-state",
|
||||
"markdownDescription": "Denies the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-connection",
|
||||
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-environment",
|
||||
"markdownDescription": "Denies the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -446,6 +446,12 @@
|
||||
"const": "allow-build-stt-image",
|
||||
"markdownDescription": "Enables the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-cancel-aws-deployment",
|
||||
"markdownDescription": "Enables the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -554,12 +560,36 @@
|
||||
"const": "allow-delete-note",
|
||||
"markdownDescription": "Enables the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-environment",
|
||||
"markdownDescription": "Enables the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-config",
|
||||
"markdownDescription": "Enables the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-detect-aws-environments",
|
||||
"markdownDescription": "Enables the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -590,6 +620,12 @@
|
||||
"const": "allow-export-settings",
|
||||
"markdownDescription": "Enables the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-follow-aws-deployment",
|
||||
"markdownDescription": "Enables the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -806,6 +842,12 @@
|
||||
"const": "allow-migrate-project-to-base",
|
||||
"markdownDescription": "Enables the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-onboard-aws-user",
|
||||
"markdownDescription": "Enables the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -866,6 +908,12 @@
|
||||
"const": "allow-pull-stt-image",
|
||||
"markdownDescription": "Enables the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-push-aws-saved-state",
|
||||
"markdownDescription": "Enables the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -938,6 +986,12 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-repair-aws-environment",
|
||||
"markdownDescription": "Enables the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -956,6 +1010,12 @@
|
||||
"const": "allow-run-scheduled-task-now",
|
||||
"markdownDescription": "Enables the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-save-aws-admin-bundle",
|
||||
"markdownDescription": "Enables the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1124,12 +1184,24 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-unlock-aws-state",
|
||||
"markdownDescription": "Enables the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-connection",
|
||||
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-update-aws-environment",
|
||||
"markdownDescription": "Enables the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1304,6 +1376,12 @@
|
||||
"const": "deny-build-stt-image",
|
||||
"markdownDescription": "Denies the build_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-cancel-aws-deployment",
|
||||
"markdownDescription": "Denies the cancel_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel_claude_token command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1412,12 +1490,36 @@
|
||||
"const": "deny-delete-note",
|
||||
"markdownDescription": "Denies the delete_note command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-environment",
|
||||
"markdownDescription": "Denies the deploy_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-deploy-aws-permissions-boundary",
|
||||
"markdownDescription": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-destroy-incomplete-aws-environment",
|
||||
"markdownDescription": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_config command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-config",
|
||||
"markdownDescription": "Denies the detect_aws_config command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_aws_environments command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-detect-aws-environments",
|
||||
"markdownDescription": "Denies the detect_aws_environments command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the detect_host_timezone command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1448,6 +1550,12 @@
|
||||
"const": "deny-export-settings",
|
||||
"markdownDescription": "Denies the export_settings command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the follow_aws_deployment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-follow-aws-deployment",
|
||||
"markdownDescription": "Denies the follow_aws_deployment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the forget_marketplace_installs command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1664,6 +1772,12 @@
|
||||
"const": "deny-migrate-project-to-base",
|
||||
"markdownDescription": "Denies the migrate_project_to_base command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the onboard_aws_user command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-onboard-aws-user",
|
||||
"markdownDescription": "Denies the onboard_aws_user command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_browser_view_popout command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1724,6 +1838,12 @@
|
||||
"const": "deny-pull-stt-image",
|
||||
"markdownDescription": "Denies the pull_stt_image command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the push_aws_saved_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-push-aws-saved-state",
|
||||
"markdownDescription": "Denies the push_aws_saved_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the read_container_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1796,6 +1916,12 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the repair_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-repair-aws-environment",
|
||||
"markdownDescription": "Denies the repair_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1814,6 +1940,12 @@
|
||||
"const": "deny-run-scheduled-task-now",
|
||||
"markdownDescription": "Denies the run_scheduled_task_now command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_aws_admin_bundle command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-save-aws-admin-bundle",
|
||||
"markdownDescription": "Denies the save_aws_admin_bundle command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save_note command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1982,12 +2114,24 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unlock_aws_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-unlock-aws-state",
|
||||
"markdownDescription": "Denies the unlock_aws_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_connection command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-connection",
|
||||
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_aws_environment command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-update-aws-environment",
|
||||
"markdownDescription": "Denies the update_aws_environment command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the update_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,11 @@ pub enum AwsError {
|
||||
action: String,
|
||||
message: String,
|
||||
},
|
||||
/// `s3:CreateBucket` said `BucketAlreadyExists`: the state bucket's
|
||||
/// name belongs to another AWS account. Never treated as success.
|
||||
StateBucketTaken {
|
||||
bucket: String,
|
||||
},
|
||||
Other {
|
||||
message: String,
|
||||
},
|
||||
@@ -53,6 +58,11 @@ impl std::fmt::Display for AwsError {
|
||||
AwsError::AccessDenied { action, message } => {
|
||||
write!(f, "Access denied for {action}: {message}")
|
||||
}
|
||||
AwsError::StateBucketTaken { bucket } => write!(
|
||||
f,
|
||||
"The state bucket name \"{bucket}\" is already taken by another AWS account, so \
|
||||
Triple-C can't keep OpenTofu state there"
|
||||
),
|
||||
AwsError::Other { message } => f.write_str(message),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ use super::permissions::{
|
||||
SdkPermissionApi, Summary, Verdict,
|
||||
};
|
||||
use super::policies::{
|
||||
checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME,
|
||||
checks, permissions_boundary_json, policy_files, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME,
|
||||
};
|
||||
use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity};
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
@@ -276,10 +276,12 @@ impl E2e {
|
||||
Tier::WorkspaceUser => "workspace",
|
||||
Tier::EnvironmentAdmin => "admin",
|
||||
};
|
||||
let arn = self
|
||||
.managed_policy(&format!("{suffix}-{name}"), policy_json(tier))
|
||||
.await;
|
||||
self.attach(&role, &arn).await;
|
||||
for (i, (_, json)) in policy_files(tier).iter().enumerate() {
|
||||
let arn = self
|
||||
.managed_policy(&format!("{suffix}-{name}{i}"), json)
|
||||
.await;
|
||||
self.attach(&role, &arn).await;
|
||||
}
|
||||
}
|
||||
role
|
||||
}
|
||||
@@ -287,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
|
||||
@@ -307,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());
|
||||
@@ -555,7 +562,7 @@ async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() {
|
||||
async fn a_role_without_simulate_permission_falls_back_to_probes() {
|
||||
e2e(|e| async move {
|
||||
let mut doc: serde_json::Value =
|
||||
serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap();
|
||||
serde_json::from_str(policy_files(Tier::WorkspaceUser)[0].1).unwrap();
|
||||
doc["Statement"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
@@ -578,7 +585,6 @@ async fn a_role_without_simulate_permission_falls_back_to_probes() {
|
||||
for probed in [
|
||||
"ecs:ListClusters",
|
||||
"ssm:GetParametersByPath",
|
||||
"cloudformation:DescribeStacks",
|
||||
"secretsmanager:ListSecrets",
|
||||
] {
|
||||
assert_eq!(verdict(probed), Verdict::Allowed, "{probed}");
|
||||
@@ -956,3 +962,737 @@ 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| crate::aws::environments::LocalRun {
|
||||
running: tofu::is_running(&wd),
|
||||
failed: tofu::last_run_failed(&wd),
|
||||
errored_state: tofu::has_errored_state(&wd),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
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")
|
||||
}
|
||||
|
||||
/// `ensure_state_bucket` as the environment admin. The bucket is
|
||||
/// marked for deletion only if this call is what creates it: a bucket
|
||||
/// that already exists (a fixed `TRIPLEC_E2E_BUCKET_SUFFIX`) is never
|
||||
/// deleted by the cleanup.
|
||||
async fn ensure_bucket(&self, cfg: &SdkConfig, who: &CallerIdentity) {
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
let head = aws_sdk_s3::Client::new(&self.e.cfg)
|
||||
.head_bucket()
|
||||
.bucket(&self.bucket)
|
||||
.send()
|
||||
.await;
|
||||
match head {
|
||||
Ok(_) => eprintln!("{} already exists; the cleanup leaves it", self.bucket),
|
||||
Err(e)
|
||||
if e.raw_response().map(|r| r.status().as_u16()) == Some(404)
|
||||
|| e.code() == Some("NotFound") =>
|
||||
{
|
||||
// Set before the create, so a create that half-succeeds
|
||||
// is still cleaned up.
|
||||
*self.bucket_created.lock().unwrap() = true;
|
||||
}
|
||||
Err(e) => panic!("HeadBucket {}: {e:?}", self.bucket),
|
||||
}
|
||||
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
|
||||
);
|
||||
match self.e.iam.get_policy().policy_arn(&arn).send().await {
|
||||
Ok(_) => {
|
||||
eprintln!("the account already has the boundary; using it");
|
||||
return;
|
||||
}
|
||||
Err(e) if e.as_service_error().is_some_and(|s| s.is_no_such_entity_exception()) => {}
|
||||
// Anything else is not "absent": never take ownership of a
|
||||
// boundary this run can't see.
|
||||
Err(e) => panic!("iam:GetPolicy on the boundary: {e:?}"),
|
||||
}
|
||||
// Ownership is the module's state: the cleanup removes the
|
||||
// boundary only with `tofu destroy` of that state, so a failed
|
||||
// apply (EntityAlreadyExists in a race) destroys nothing.
|
||||
*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 boundary only through its own module's state), then the
|
||||
/// bucket with every version if this run created 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:?}"),
|
||||
}
|
||||
}
|
||||
// No direct delete: the boundary module's `tofu destroy` above is
|
||||
// the only thing that removes it, because its state proves this
|
||||
// run created it. If that failed, say so loudly.
|
||||
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: WARNING: {arn} still exists after its module's destroy; \
|
||||
remove it by hand if this run created it"
|
||||
);
|
||||
}
|
||||
}
|
||||
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(&t.e.region, &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();
|
||||
// Joined from the state's onboarding_complete output, not just the role.
|
||||
assert_eq!(env.onboarding, Some(OnboardingState::Joined), "{env:#?}");
|
||||
assert_eq!(env.onboarded_users, Some(1), "{env:#?}");
|
||||
let (task_role, exec_role) = user_role_names(&name, &okey);
|
||||
eprintln!("onboarded with region-scoped roles {task_role} / {exec_role}");
|
||||
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);
|
||||
// The emptied onboarding state and the deleted roles: not joined.
|
||||
let found = t.scan(&cfg, &who).await;
|
||||
let env = found.environments.iter().find(|x| x.name == name).unwrap();
|
||||
assert_eq!(env.onboarding, Some(OnboardingState::NotJoined), "{env:#?}");
|
||||
assert_eq!(env.onboarded_users, Some(0), "{env:#?}");
|
||||
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,13 +4,18 @@
|
||||
//!
|
||||
|
||||
mod connection;
|
||||
pub mod environments;
|
||||
mod error;
|
||||
mod identity;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
pub mod permissions;
|
||||
pub mod policies;
|
||||
#[cfg(test)]
|
||||
mod policy_eval;
|
||||
pub mod tofu;
|
||||
pub mod tofu_modules;
|
||||
|
||||
pub use connection::{sdk_config, sdk_config_for_keys};
|
||||
pub use error::AwsError;
|
||||
pub use error::{classify_sdk_error, AwsError};
|
||||
pub use identity::{caller_identity, CallerIdentity};
|
||||
@@ -74,15 +74,13 @@ pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> Sim
|
||||
pub enum Probe {
|
||||
EcsListClusters,
|
||||
SsmGetParametersByPath,
|
||||
CfnDescribeStacks,
|
||||
SecretsListSecrets,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
const ALL: [Probe; 4] = [
|
||||
const ALL: [Probe; 3] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::CfnDescribeStacks,
|
||||
Probe::SecretsListSecrets,
|
||||
];
|
||||
|
||||
@@ -91,7 +89,6 @@ impl Probe {
|
||||
match action.split_once(':')?.0 {
|
||||
"ecs" => Some(Probe::EcsListClusters),
|
||||
"ssm" => Some(Probe::SsmGetParametersByPath),
|
||||
"cloudformation" => Some(Probe::CfnDescribeStacks),
|
||||
"secretsmanager" => Some(Probe::SecretsListSecrets),
|
||||
_ => None,
|
||||
}
|
||||
@@ -101,7 +98,6 @@ impl Probe {
|
||||
match self {
|
||||
Probe::EcsListClusters => "ecs:ListClusters",
|
||||
Probe::SsmGetParametersByPath => "ssm:GetParametersByPath",
|
||||
Probe::CfnDescribeStacks => "cloudformation:DescribeStacks",
|
||||
Probe::SecretsListSecrets => "secretsmanager:ListSecrets",
|
||||
}
|
||||
}
|
||||
@@ -130,7 +126,6 @@ pub struct SdkPermissionApi {
|
||||
iam: aws_sdk_iam::Client,
|
||||
ecs: aws_sdk_ecs::Client,
|
||||
ssm: aws_sdk_ssm::Client,
|
||||
cfn: aws_sdk_cloudformation::Client,
|
||||
secrets: aws_sdk_secretsmanager::Client,
|
||||
}
|
||||
|
||||
@@ -147,7 +142,6 @@ impl SdkPermissionApi {
|
||||
iam: aws_sdk_iam::Client::from_conf(iam),
|
||||
ecs: aws_sdk_ecs::Client::new(cfg),
|
||||
ssm: aws_sdk_ssm::Client::new(cfg),
|
||||
cfn: aws_sdk_cloudformation::Client::new(cfg),
|
||||
secrets: aws_sdk_secretsmanager::Client::new(cfg),
|
||||
}
|
||||
}
|
||||
@@ -213,13 +207,6 @@ impl PermissionApi for SdkPermissionApi {
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
Probe::CfnDescribeStacks => self
|
||||
.cfn
|
||||
.describe_stacks()
|
||||
.send()
|
||||
.await
|
||||
.map(drop)
|
||||
.map_err(|e| classify(&e)),
|
||||
Probe::SecretsListSecrets => self
|
||||
.secrets
|
||||
.list_secrets()
|
||||
@@ -515,7 +502,7 @@ async fn simulate_all<A: PermissionApi>(
|
||||
))
|
||||
}
|
||||
|
||||
/// Per-tier outcomes from the four probes. `denied` is the action whose
|
||||
/// Per-tier outcomes from the three probes. `denied` is the action whose
|
||||
/// denial forced probing; the caller's own-principal checks of it are
|
||||
/// `Denied`.
|
||||
async fn probe_all<A: PermissionApi>(
|
||||
|
||||
@@ -17,10 +17,9 @@ fn identity() -> CallerIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_PROBES: [Probe; 4] = [
|
||||
const ALL_PROBES: [Probe; 3] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::CfnDescribeStacks,
|
||||
Probe::SecretsListSecrets,
|
||||
];
|
||||
|
||||
@@ -214,15 +213,11 @@ fn a_missing_action_reports_insufficient_and_names_it() {
|
||||
#[test]
|
||||
fn an_admin_gap_still_reports_workspace_user() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.allow.remove("cloudformation:CreateStack");
|
||||
api.allow.remove("ec2:CreateVpc");
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert_eq!(
|
||||
verdict(
|
||||
&report,
|
||||
Tier::EnvironmentAdmin,
|
||||
"cloudformation:CreateStack"
|
||||
),
|
||||
verdict(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc"),
|
||||
Verdict::Denied
|
||||
);
|
||||
}
|
||||
@@ -407,10 +402,6 @@ fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() {
|
||||
verdict(&report, ws, "ssm:GetParametersByPath"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "cloudformation:DescribeStacks"),
|
||||
Verdict::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
verdict(&report, ws, "secretsmanager:ListSecrets"),
|
||||
Verdict::Allowed
|
||||
@@ -446,7 +437,7 @@ fn a_denied_get_role_falls_back_to_probes() {
|
||||
m => panic!("expected Probed, got {m:?}"),
|
||||
}
|
||||
assert!(api.simulated().is_empty());
|
||||
assert_eq!(api.probed.lock().unwrap().len(), 4);
|
||||
assert_eq!(api.probed.lock().unwrap().len(), ALL_PROBES.len());
|
||||
// Denied on the caller's own role: direct evidence.
|
||||
assert_eq!(
|
||||
verdict(&report, Tier::WorkspaceUser, "iam:GetRole"),
|
||||
@@ -487,14 +478,10 @@ fn a_probed_run_never_claims_environment_admin() {
|
||||
#[test]
|
||||
fn throttled_simulations_block_the_tier_and_carry_notes() {
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
|
||||
api.simulate_errors = vec![("cloudformation:CreateStack", throttled())];
|
||||
api.simulate_errors = vec![("ec2:CreateVpc", throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
let r = row(
|
||||
&report,
|
||||
Tier::EnvironmentAdmin,
|
||||
"cloudformation:CreateStack",
|
||||
);
|
||||
let r = row(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc");
|
||||
assert_eq!(r.verdict, Verdict::Unverified);
|
||||
assert!(r.note.as_deref().unwrap().contains("Throttling"), "{r:?}");
|
||||
// Allowed rows carry no note.
|
||||
@@ -548,7 +535,7 @@ fn credential_errors_anywhere_are_errors() {
|
||||
// From a probe.
|
||||
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
|
||||
api.get_role_denied = true;
|
||||
api.probes_ok.remove(&Probe::CfnDescribeStacks);
|
||||
api.probes_ok.remove(&Probe::SecretsListSecrets);
|
||||
api.probe_error = Some(AwsError::KeysMissing);
|
||||
assert_eq!(
|
||||
try_run(&api, &identity()).unwrap_err(),
|
||||
@@ -630,3 +617,14 @@ fn check_permissions_is_send() {
|
||||
}
|
||||
let _ = with::<SdkPermissionApi>;
|
||||
}
|
||||
|
||||
/// D8: CloudFormation left the policies, so nothing probes it either.
|
||||
#[test]
|
||||
fn no_probe_is_cloudformation() {
|
||||
assert_eq!(Probe::ALL, ALL_PROBES);
|
||||
for p in Probe::ALL {
|
||||
assert!(!p.action().starts_with("cloudformation:"), "{p:?}");
|
||||
assert_eq!(Probe::for_action(p.action()), Some(p));
|
||||
}
|
||||
assert_eq!(Probe::for_action("cloudformation:DescribeStacks"), None);
|
||||
}
|
||||
+654
-359
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,303 @@
|
||||
//! The test-only IAM evaluator the policy drift guards use (moved here from
|
||||
//! `policies.rs` so the Terraform-module tests can ask it too). It is
|
||||
//! deliberately small: the operators the shipped policies use and nothing
|
||||
//! else; an unknown operator fails the test rather than being ignored. It is
|
||||
//! not IAM -- `live_tests.rs` and the live runs are the proof.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::policies::{render, Check, ContextValue, Placeholders};
|
||||
|
||||
pub(crate) const AT: Placeholders = Placeholders {
|
||||
region: "us-west-2",
|
||||
account: "111122223333",
|
||||
principal: "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Dev_0123456789abcdef",
|
||||
};
|
||||
pub(crate) const ME: &str = "AROAEXAMPLEME:alice@example.com";
|
||||
pub(crate) const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com";
|
||||
|
||||
/// What [`Request::resolve`] turns IAM's `${*}` into: a literal `*` in a
|
||||
/// pattern, which [`glob`] matches only against a `*`.
|
||||
const LITERAL_STAR: char = '\u{e000}';
|
||||
|
||||
/// IAM wildcard match: `*` is any run of characters (including `:` and
|
||||
/// `/`), `?` is exactly one; [`LITERAL_STAR`] is a plain `*`.
|
||||
pub(crate) fn glob(pattern: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let (mut pi, mut ti) = (0, 0);
|
||||
let (mut star, mut mark) = (None, 0);
|
||||
while ti < t.len() {
|
||||
if pi < p.len()
|
||||
&& (p[pi] == '?' || p[pi] == t[ti] || (p[pi] == LITERAL_STAR && t[ti] == '*'))
|
||||
{
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star = Some(pi);
|
||||
mark = ti;
|
||||
pi += 1;
|
||||
} else if let Some(s) = star {
|
||||
pi = s + 1;
|
||||
mark += 1;
|
||||
ti = mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && p[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
pub(crate) fn parse(name: &str, json: &str) -> Value {
|
||||
serde_json::from_str(json).unwrap_or_else(|e| panic!("{name} is not JSON: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn statements_of(doc: &Value) -> Vec<Value> {
|
||||
doc["Statement"]
|
||||
.as_array()
|
||||
.expect("policy has no Statement array")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn effect(stmt: &Value) -> &str {
|
||||
stmt["Effect"].as_str().unwrap_or("<no Effect>")
|
||||
}
|
||||
|
||||
/// `Action` / `Resource` / condition values: a string or an array of them.
|
||||
pub(crate) fn strings(v: &Value) -> Vec<String> {
|
||||
match v {
|
||||
Value::String(s) => vec![s.clone()],
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.map(|x| x.as_str().expect("expected a string").to_string())
|
||||
.collect(),
|
||||
other => panic!("expected a string or array, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn actions(stmt: &Value) -> Vec<String> {
|
||||
strings(&stmt["Action"])
|
||||
}
|
||||
|
||||
pub(crate) fn resources(stmt: &Value) -> Vec<String> {
|
||||
strings(&stmt["Resource"])
|
||||
}
|
||||
|
||||
pub(crate) fn sid(stmt: &Value) -> &str {
|
||||
stmt["Sid"].as_str().unwrap_or("<no Sid>")
|
||||
}
|
||||
|
||||
pub(crate) fn rendered(check: &Check) -> String {
|
||||
render(check.resource, &AT)
|
||||
}
|
||||
|
||||
/// The request: who calls, and whose the resource / request tag is.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Request<'a> {
|
||||
pub check: &'a Check,
|
||||
/// `${aws:userid}` of the caller.
|
||||
pub caller: &'a str,
|
||||
/// What `OwnerId` context entries carry.
|
||||
pub owner: &'a str,
|
||||
}
|
||||
|
||||
impl Request<'_> {
|
||||
pub(crate) fn context_value(&self, key: &str) -> Option<String> {
|
||||
self.context_values(key).into_iter().next()
|
||||
}
|
||||
|
||||
/// All values of a (possibly multi-valued) key.
|
||||
pub(crate) fn context_values(&self, key: &str) -> Vec<String> {
|
||||
self.check
|
||||
.context
|
||||
.iter()
|
||||
.filter(|(k, _)| *k == key)
|
||||
.map(|(_, v)| match v {
|
||||
ContextValue::OwnerId => self.owner.to_string(),
|
||||
ContextValue::Literal(l) => render(l, &AT),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolves `${…}` policy variables the way the simulator does: from
|
||||
/// the request context. An unresolvable variable makes the string
|
||||
/// match nothing (`None`).
|
||||
pub(crate) fn resolve(&self, s: &str) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find("${") {
|
||||
out.push_str(&rest[..start]);
|
||||
let end = rest[start..].find('}').expect("unterminated ${") + start;
|
||||
let name = &rest[start + 2..end];
|
||||
let value = if name == "*" {
|
||||
Some(LITERAL_STAR.to_string())
|
||||
} else if name == "aws:userid" {
|
||||
Some(self.caller.to_string())
|
||||
} else {
|
||||
self.context_value(name)
|
||||
};
|
||||
out.push_str(&value?);
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Statements that name the action and one of whose resources matches
|
||||
/// the rendered resource.
|
||||
pub(crate) fn matching<'s>(stmts: &'s [Value], req: &Request) -> Vec<&'s Value> {
|
||||
let resource = rendered(req.check);
|
||||
stmts
|
||||
.iter()
|
||||
.filter(|s| actions(s).iter().any(|a| glob(a, req.check.action)))
|
||||
.filter(|s| {
|
||||
resources(s)
|
||||
.iter()
|
||||
.filter_map(|r| req.resolve(r))
|
||||
.any(|r| glob(&r, &resource))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A deliberately small IAM condition evaluator: the operators the
|
||||
/// shipped policies use, and nothing else (an unknown operator fails the
|
||||
/// test rather than being ignored).
|
||||
pub(crate) fn conditions_hold(stmt: &Value, req: &Request) -> bool {
|
||||
let Some(cond) = stmt.get("Condition") else {
|
||||
return true;
|
||||
};
|
||||
let cond = cond.as_object().expect("Condition is an object");
|
||||
cond.iter().all(|(op, entries)| {
|
||||
let entries = entries.as_object().expect("condition block is an object");
|
||||
// ForAllValues: every value of the (multi-valued) key matches
|
||||
// one of the policy's values. Vacuously true when the key is
|
||||
// absent, which is why statements pair it with a required key.
|
||||
if let Some(base) = op.strip_prefix("ForAllValues:") {
|
||||
assert_eq!(
|
||||
base,
|
||||
"StringEquals",
|
||||
"{}: unsupported ForAllValues operator",
|
||||
sid(stmt)
|
||||
);
|
||||
return entries.iter().all(|(key, want)| {
|
||||
let allowed: Vec<String> = strings(want)
|
||||
.iter()
|
||||
.filter_map(|p| req.resolve(p))
|
||||
.collect();
|
||||
req.context_values(key).iter().all(|v| allowed.contains(v))
|
||||
});
|
||||
}
|
||||
if op == "Null" {
|
||||
return entries.iter().all(|(key, want)| {
|
||||
let absent = req.context_value(key).is_none();
|
||||
match want.as_str() {
|
||||
Some("true") => absent,
|
||||
Some("false") => !absent,
|
||||
_ => panic!("{}: Null takes \"true\" or \"false\"", sid(stmt)),
|
||||
}
|
||||
});
|
||||
}
|
||||
let (base, if_exists) = match op.strip_suffix("IfExists") {
|
||||
Some(b) => (b, true),
|
||||
None => (op.as_str(), false),
|
||||
};
|
||||
// A negated operator holds when the key is absent, and
|
||||
// otherwise when no value matches.
|
||||
let (matches, negated): (fn(&str, &str) -> bool, bool) = match base {
|
||||
"StringEquals" => (|p, v| p == v, false),
|
||||
"StringLike" | "ArnLike" => (glob, false),
|
||||
"StringNotEquals" => (|p, v| p == v, true),
|
||||
"StringNotLike" => (glob, true),
|
||||
other => panic!("{}: unsupported condition operator {other}", sid(stmt)),
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.all(|(key, want)| match req.context_value(key) {
|
||||
None => if_exists || negated,
|
||||
Some(value) => {
|
||||
let any = strings(want)
|
||||
.iter()
|
||||
.filter_map(|p| req.resolve(p))
|
||||
.any(|p| matches(&p, &value));
|
||||
any != negated
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Allowed by some Allow and by no Deny among `stmts`.
|
||||
pub(crate) fn evaluate_in(stmts: &[Value], req: Request) -> bool {
|
||||
let applies = |s: &&Value| conditions_hold(s, &req);
|
||||
let allowed = matching(stmts, &req)
|
||||
.into_iter()
|
||||
.filter(|s| effect(s) == "Allow")
|
||||
.any(|s| applies(&s));
|
||||
let denied = matching(stmts, &req)
|
||||
.into_iter()
|
||||
.filter(|s| effect(s) == "Deny")
|
||||
.any(|s| applies(&s));
|
||||
allowed && !denied
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ContextValue::Literal;
|
||||
|
||||
#[test]
|
||||
fn glob_behaves_like_iam_wildcards() {
|
||||
assert!(glob("ecs:Describe*", "ecs:DescribeClusters"));
|
||||
assert!(!glob("ecs:Describe*", "ecs:ListClusters"));
|
||||
assert!(glob(
|
||||
"arn:aws:ecs:*:*:task/triple-c-*/*",
|
||||
"arn:aws:ecs:us-west-2:1:task/triple-c-x/abc"
|
||||
));
|
||||
assert!(!glob(
|
||||
"arn:aws:ecs:*:*:task/triple-c-*/*",
|
||||
"arn:aws:ecs:us-west-2:1:task/other/abc"
|
||||
));
|
||||
assert!(glob("a?c", "abc"));
|
||||
assert!(!glob("a?c", "ac"));
|
||||
// `${*}` (resolved to LITERAL_STAR) matches only a real `*`.
|
||||
let literal = format!("arn:aws:ec2:*:*:{LITERAL_STAR}/{LITERAL_STAR}");
|
||||
assert!(glob(&literal, "arn:aws:ec2:us-west-2:1:*/*"));
|
||||
assert!(!glob(&literal, "arn:aws:ec2:us-west-2:1:elastic-ip/eipalloc-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_all_values_is_vacuous_on_an_absent_key() {
|
||||
let stmt: Value = serde_json::json!({
|
||||
"Sid": "T", "Effect": "Allow", "Action": "x:Y", "Resource": "*",
|
||||
"Condition": { "ForAllValues:StringEquals": { "aws:TagKeys": ["a", "b"] } }
|
||||
});
|
||||
let check = |context| Check {
|
||||
action: "x:Y",
|
||||
resource: "*",
|
||||
context,
|
||||
write: true,
|
||||
};
|
||||
let holds = |c: &Check| {
|
||||
conditions_hold(
|
||||
&stmt,
|
||||
&Request {
|
||||
check: c,
|
||||
caller: ME,
|
||||
owner: ME,
|
||||
},
|
||||
)
|
||||
};
|
||||
assert!(holds(&check(&[])));
|
||||
assert!(holds(&check(&[
|
||||
("aws:TagKeys", Literal("a")),
|
||||
("aws:TagKeys", Literal("b"))
|
||||
])));
|
||||
assert!(!holds(&check(&[
|
||||
("aws:TagKeys", Literal("a")),
|
||||
("aws:TagKeys", Literal("c"))
|
||||
])));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,251 @@
|
||||
//! The OpenTofu modules Triple-C ships (`aws/terraform/*` at the repo root)
|
||||
//! and the names the modules and the app agree on.
|
||||
//!
|
||||
//! Every name a module or a phase-4 command uses — environment, parameter,
|
||||
//! owner key, per-user roles, state bucket and keys — is built here and
|
||||
//! nowhere else. The tests parse the shipped HCL and pin it to these
|
||||
//! constants: versions, default tags, the record's fields, no inbound access
|
||||
//! and no endpoint overrides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::models::host_connection::validate_supported_region;
|
||||
|
||||
/// `locals.template_version` (and the `triple-c:template-version` default
|
||||
/// tag) of `aws/terraform/environment`.
|
||||
pub const ENVIRONMENT_MODULE_VERSION: u32 = 1;
|
||||
/// The same for the user-onboarding module.
|
||||
#[allow(dead_code)] // the onboarding module's version; checked against its HCL by a test
|
||||
pub const ONBOARD_MODULE_VERSION: u32 = 1;
|
||||
/// The permissions boundary every module-created role carries (no path).
|
||||
/// Equal to [`super::policies::PERMISSIONS_BOUNDARY_NAME`]; a test checks it.
|
||||
pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModuleKind {
|
||||
Environment,
|
||||
UserOnboard,
|
||||
PermissionsBoundary,
|
||||
}
|
||||
|
||||
macro_rules! module {
|
||||
($dir:literal: $($file:literal),+ $(,)?) => {
|
||||
&[$(($file, include_str!(concat!("../../../../aws/terraform/", $dir, "/", $file)))),+]
|
||||
};
|
||||
}
|
||||
|
||||
const ENVIRONMENT_FILES: &[(&str, &str)] = module!("environment":
|
||||
"versions.tf",
|
||||
"variables.tf",
|
||||
"main.tf",
|
||||
"network.tf",
|
||||
"outputs.tf",
|
||||
".terraform.lock.hcl",
|
||||
);
|
||||
|
||||
const USER_ONBOARD_FILES: &[(&str, &str)] = module!("user-onboard":
|
||||
"versions.tf",
|
||||
"variables.tf",
|
||||
"main.tf",
|
||||
"iam.tf",
|
||||
"efs.tf",
|
||||
"outputs.tf",
|
||||
".terraform.lock.hcl",
|
||||
);
|
||||
|
||||
const PERMISSIONS_BOUNDARY_FILES: &[(&str, &str)] = module!("permissions-boundary":
|
||||
"versions.tf",
|
||||
"variables.tf",
|
||||
"main.tf",
|
||||
"outputs.tf",
|
||||
".terraform.lock.hcl",
|
||||
);
|
||||
|
||||
/// The module's files as (file name relative to the module directory,
|
||||
/// contents), lock file included, for writing into a work or bundle folder.
|
||||
pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] {
|
||||
match kind {
|
||||
ModuleKind::Environment => ENVIRONMENT_FILES,
|
||||
ModuleKind::UserOnboard => USER_ONBOARD_FILES,
|
||||
ModuleKind::PermissionsBoundary => PERMISSIONS_BOUNDARY_FILES,
|
||||
}
|
||||
}
|
||||
|
||||
/// `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--` — the same rule
|
||||
/// as the module's `environment_name` validation.
|
||||
pub fn validate_environment_name(name: &str) -> Result<String, String> {
|
||||
let b = name.as_bytes();
|
||||
let ok = !b.is_empty()
|
||||
&& b.len() <= 20
|
||||
&& b[0].is_ascii_lowercase()
|
||||
&& b.iter()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-')
|
||||
&& !name.ends_with('-')
|
||||
&& !name.contains("--");
|
||||
if ok {
|
||||
Ok(name.to_string())
|
||||
} else {
|
||||
Err(format!(
|
||||
"\"{}\" is not a valid environment name: use 1 to 20 lowercase letters, digits and \
|
||||
single hyphens, starting with a letter and not ending in a hyphen",
|
||||
name.escape_debug()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The SSM parameter holding an environment's record.
|
||||
pub fn environment_parameter_name(env: &str) -> String {
|
||||
format!("/triple-c/environments/{env}")
|
||||
}
|
||||
|
||||
/// The first 12 hex characters of `sha256("<region>:<aws:userid>")`. A
|
||||
/// `UserId` holds a `:`, which IAM role names reject, so per-user names use
|
||||
/// this instead. The region is part of it because role names are
|
||||
/// account-global while environments are regional: the same user in the same
|
||||
/// environment name in two regions gets two sets of roles.
|
||||
pub fn owner_key(region: &str, user_id: &str) -> String {
|
||||
Sha256::digest(format!("{region}:{user_id}").as_bytes())
|
||||
.iter()
|
||||
.take(6)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A user's (task role, execution role) names. At most 47 characters.
|
||||
pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) {
|
||||
(
|
||||
format!("triple-c-{env}-{owner_key}-task"),
|
||||
format!("triple-c-{env}-{owner_key}-exec"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The per-account, per-region state bucket.
|
||||
pub fn state_bucket_name(account: &str, region: &str) -> String {
|
||||
format!("triple-c-tfstate-{account}-{region}")
|
||||
}
|
||||
|
||||
/// One deployment, i.e. one state object in the bucket (D9).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Deployment<'a> {
|
||||
/// The environment module for this environment name.
|
||||
Environment(&'a str),
|
||||
/// A user's onboarding module in an environment.
|
||||
User { env: &'a str, owner_key: &'a str },
|
||||
/// The account's permissions-boundary module.
|
||||
Boundary,
|
||||
}
|
||||
|
||||
/// A deployment's state object in [`state_bucket_name`] (D9); the lock is the
|
||||
/// same key plus `.tflock`.
|
||||
pub fn state_key(d: Deployment) -> String {
|
||||
match d {
|
||||
Deployment::Environment(env) => format!("environments/{env}/terraform.tfstate"),
|
||||
Deployment::User { env, owner_key } => {
|
||||
format!("users/{env}/{owner_key}/terraform.tfstate")
|
||||
}
|
||||
Deployment::Boundary => "boundary/terraform.tfstate".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How an environment's tasks reach the network (D4).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "mode", rename_all = "kebab-case")]
|
||||
pub enum NetworkChoice {
|
||||
/// A new VPC with two public subnets; tasks get a public IP. No NAT, $0.
|
||||
Public,
|
||||
/// A new VPC with private subnets behind one NAT gateway (~$33/month).
|
||||
PrivateNat,
|
||||
/// The user's own VPC and exactly two subnets.
|
||||
Existing {
|
||||
vpc_id: String,
|
||||
subnet_ids: Vec<String>,
|
||||
assign_public_ip: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// `vpc-` / `subnet-` followed by 8 or 17 lowercase hex characters.
|
||||
fn is_aws_id(id: &str, prefix: &str) -> bool {
|
||||
id.strip_prefix(prefix).is_some_and(|hex| {
|
||||
(hex.len() == 8 || hex.len() == 17)
|
||||
&& hex.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
|
||||
})
|
||||
}
|
||||
|
||||
/// The environment module's `terraform.tfvars.json`. Applies the same rules
|
||||
/// as the module's variable validations, so a bad value fails here first.
|
||||
pub fn environment_vars(
|
||||
env: &str,
|
||||
region: &str,
|
||||
network: &NetworkChoice,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let env = validate_environment_name(env)?;
|
||||
let region = validate_supported_region(region)?;
|
||||
let (mode, vpc_id, subnet_ids, assign_public_ip) = match network {
|
||||
NetworkChoice::Public => ("public", String::new(), Vec::new(), false),
|
||||
NetworkChoice::PrivateNat => ("private-nat", String::new(), Vec::new(), false),
|
||||
NetworkChoice::Existing {
|
||||
vpc_id,
|
||||
subnet_ids,
|
||||
assign_public_ip,
|
||||
} => {
|
||||
if !is_aws_id(vpc_id, "vpc-") {
|
||||
return Err(format!("\"{}\" is not a VPC id", vpc_id.escape_debug()));
|
||||
}
|
||||
if subnet_ids.len() != 2 {
|
||||
return Err(format!(
|
||||
"An existing network needs exactly 2 subnets (in different availability \
|
||||
zones), not {}",
|
||||
subnet_ids.len()
|
||||
));
|
||||
}
|
||||
if let Some(bad) = subnet_ids.iter().find(|s| !is_aws_id(s, "subnet-")) {
|
||||
return Err(format!("\"{}\" is not a subnet id", bad.escape_debug()));
|
||||
}
|
||||
if subnet_ids[0] == subnet_ids[1] {
|
||||
return Err("The two subnets must be different".into());
|
||||
}
|
||||
(
|
||||
"existing",
|
||||
vpc_id.clone(),
|
||||
subnet_ids.clone(),
|
||||
*assign_public_ip,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(json!({
|
||||
"environment_name": env,
|
||||
"region": region,
|
||||
"network_mode": mode,
|
||||
"vpc_id": vpc_id,
|
||||
"subnet_ids": subnet_ids,
|
||||
"assign_public_ip": assign_public_ip,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The onboarding module's `terraform.tfvars.json` for the user whose
|
||||
/// `aws:userid` is `owner_id`. Bedrock stays on the workspace user's own
|
||||
/// credentials unless a later setting opts the task role in.
|
||||
pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"environment_name": env,
|
||||
"region": region,
|
||||
"owner_id": owner_id,
|
||||
"owner_key": owner_key(region, owner_id),
|
||||
"bedrock_on_task_role": false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The boundary module's `terraform.tfvars.json`: the shipped boundary
|
||||
/// document itself, so the module holds no second copy.
|
||||
pub fn boundary_vars(region: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"region": region,
|
||||
"policy_json": super::policies::permissions_boundary_json(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,631 @@
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::aws::tofu::Workdir;
|
||||
use crate::aws::environments::{
|
||||
EnvironmentState, Knowable, LockInfo, OnboardingState,
|
||||
};
|
||||
use crate::aws::tofu_modules::{
|
||||
environment_vars, module_files, onboarding_vars, owner_key, state_key, ModuleKind,
|
||||
};
|
||||
|
||||
const ACCOUNT: &str = "123456789012";
|
||||
const REGION: &str = "eu-west-1";
|
||||
const USER_ID: &str = "AROAEXAMPLEID12345678:alice";
|
||||
|
||||
fn info(name: &str, mode: &str, assign_public_ip: bool) -> EnvironmentInfo {
|
||||
EnvironmentInfo {
|
||||
schema: 1,
|
||||
name: name.into(),
|
||||
template_version: 1,
|
||||
cluster_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-{name}"),
|
||||
security_group_id: "sg-0123456789abcdef0".into(),
|
||||
subnet_ids: vec![
|
||||
"subnet-0123456789abcdef0".into(),
|
||||
"subnet-0fedcba9876543210".into(),
|
||||
],
|
||||
assign_public_ip,
|
||||
kms_key_arn: format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234abcd-12ab"),
|
||||
log_group: format!("/triple-c/{name}"),
|
||||
network_mode: mode.into(),
|
||||
tenancy: "strict".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn env(name: &str, state: EnvironmentState, actions: Vec<EnvironmentAction>) -> DetectedEnvironment {
|
||||
DetectedEnvironment {
|
||||
name: name.into(),
|
||||
template_version: None,
|
||||
state,
|
||||
actions,
|
||||
info: None,
|
||||
onboarding: None,
|
||||
onboarding_actions: Vec::new(),
|
||||
onboarded_users: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(boundary: Knowable, environments: Vec<DetectedEnvironment>) -> EnvironmentScan {
|
||||
EnvironmentScan {
|
||||
region: REGION.into(),
|
||||
account: ACCOUNT.into(),
|
||||
owner_id: USER_ID.into(),
|
||||
owner_key: owner_key(REGION, USER_ID),
|
||||
state_bucket: Knowable::Yes,
|
||||
boundary,
|
||||
environments,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(age_secs: u64, local_process: bool) -> LockInfo {
|
||||
LockInfo {
|
||||
who: "someone@host".into(),
|
||||
created: String::new(),
|
||||
operation: "OperationTypeApply".into(),
|
||||
age_secs,
|
||||
local_process,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_our_state_keys_are_accepted() {
|
||||
let ok = owner_key(REGION, USER_ID);
|
||||
let good = [
|
||||
(
|
||||
"environments/dev/terraform.tfstate".to_string(),
|
||||
DeploymentKey::Environment("dev".into()),
|
||||
),
|
||||
(
|
||||
format!("users/dev/{ok}/terraform.tfstate"),
|
||||
DeploymentKey::User { env: "dev".into(), owner_key: ok.clone() },
|
||||
),
|
||||
("boundary/terraform.tfstate".to_string(), DeploymentKey::Boundary),
|
||||
];
|
||||
for (key, want) in good {
|
||||
let parsed = parse_state_key(&key).unwrap();
|
||||
assert_eq!(parsed, want);
|
||||
assert_eq!(state_key(parsed.deployment()), key, "round trip");
|
||||
}
|
||||
let bad = [
|
||||
"",
|
||||
"environments/dev/terraform.tfstate.tflock",
|
||||
"environments/Dev/terraform.tfstate",
|
||||
"environments/dev-/terraform.tfstate",
|
||||
"environments/../terraform.tfstate",
|
||||
"environments/dev/../../boundary/terraform.tfstate",
|
||||
"/environments/dev/terraform.tfstate",
|
||||
"environments//terraform.tfstate",
|
||||
"environments/dev/x/terraform.tfstate",
|
||||
"environments/dev/terraform.tfstate/",
|
||||
"users/dev/terraform.tfstate",
|
||||
"users/dev/ABCDEF012345/terraform.tfstate",
|
||||
"users/dev/abcdef01234/terraform.tfstate",
|
||||
"users/dev/abcdef0123456/terraform.tfstate",
|
||||
"users/dev/abcdef01234g/terraform.tfstate",
|
||||
"boundary/x/terraform.tfstate",
|
||||
"boundary/terraform.tfstate ",
|
||||
"something/else",
|
||||
"environments\\dev\\terraform.tfstate",
|
||||
];
|
||||
for key in bad {
|
||||
assert!(parse_state_key(key).is_err(), "{key:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deploy_refuses_an_existing_name_and_a_missing_boundary() {
|
||||
use EnvironmentAction::*;
|
||||
let existing = vec![
|
||||
env("dev", EnvironmentState::Ready, vec![Join]),
|
||||
env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
|
||||
];
|
||||
let s = scan(Knowable::Yes, existing.clone());
|
||||
assert!(deploy_refusal(&s, "prod").is_ok());
|
||||
let e = deploy_refusal(&s, "dev").unwrap_err();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
assert!(deploy_refusal(&s, "half").is_err(), "state without a record is taken too");
|
||||
assert!(deploy_refusal(&s, "Not Valid").is_err());
|
||||
|
||||
let no_boundary = scan(Knowable::No, existing.clone());
|
||||
let e = deploy_refusal(&no_boundary, "prod").unwrap_err();
|
||||
assert!(e.contains("permissions boundary"), "{e}");
|
||||
// A caller who can't tell is let through (the apply itself would fail).
|
||||
assert!(deploy_refusal(&scan(Knowable::Unknown, existing), "prod").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_reuses_the_recorded_network() {
|
||||
assert_eq!(
|
||||
network_from_record(&info("dev", "public", true), None).unwrap(),
|
||||
NetworkChoice::Public
|
||||
);
|
||||
assert_eq!(
|
||||
network_from_record(&info("dev", "private-nat", false), None).unwrap(),
|
||||
NetworkChoice::PrivateNat
|
||||
);
|
||||
let existing = info("dev", "existing", false);
|
||||
assert_eq!(
|
||||
network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(),
|
||||
NetworkChoice::Existing {
|
||||
vpc_id: "vpc-0123456789abcdef0".into(),
|
||||
subnet_ids: existing.subnet_ids.clone(),
|
||||
assign_public_ip: false,
|
||||
}
|
||||
);
|
||||
// The VPC isn't in the record: without it from the state, refuse rather
|
||||
// than guess.
|
||||
assert!(network_from_record(&existing, None).is_err());
|
||||
assert!(network_from_record(&existing, Some("not-a-vpc")).is_err());
|
||||
assert!(network_from_record(&info("dev", "carrier-pigeon", false), None).is_err());
|
||||
// And it round-trips into the module's variables unchanged.
|
||||
let vars = environment_vars(
|
||||
"dev",
|
||||
REGION,
|
||||
&network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(vars["network_mode"], "existing");
|
||||
assert_eq!(vars["subnet_ids"], json!(existing.subnet_ids));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vpc_is_read_from_the_state_outputs() {
|
||||
let state = json!({
|
||||
"version": 4,
|
||||
"outputs": { "vpc_id": { "value": "vpc-0123456789abcdef0", "type": "string" } },
|
||||
})
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
vpc_id_from_state(state.as_bytes()).as_deref(),
|
||||
Some("vpc-0123456789abcdef0")
|
||||
);
|
||||
assert_eq!(vpc_id_from_state(b"{}"), None);
|
||||
assert_eq!(vpc_id_from_state(b"not json"), None);
|
||||
let hostile = json!({"outputs": {"vpc_id": {"value": "vpc-1\"; rm -rf /"}}}).to_string();
|
||||
assert_eq!(vpc_id_from_state(hostile.as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundles_contain_the_module_backend_vars_and_readme() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ok = owner_key(REGION, USER_ID);
|
||||
let d = Deployment::User { env: "dev", owner_key: &ok };
|
||||
let vars = onboarding_vars("dev", REGION, USER_ID);
|
||||
let written = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap();
|
||||
|
||||
for (name, text) in module_files(ModuleKind::UserOnboard) {
|
||||
assert_eq!(&fs::read_to_string(dir.path().join(name)).unwrap(), text);
|
||||
assert!(written.contains(&name.to_string()));
|
||||
}
|
||||
let bucket = format!("triple-c-tfstate-{ACCOUNT}-{REGION}");
|
||||
let key = state_key(d);
|
||||
let backend = fs::read_to_string(dir.path().join("backend.hcl")).unwrap();
|
||||
assert!(backend.contains(&format!("bucket = \"{bucket}\"")), "{backend}");
|
||||
assert!(backend.contains(&format!("key = \"{key}\"")), "{backend}");
|
||||
assert!(backend.contains("use_lockfile = true"), "{backend}");
|
||||
let tfvars: Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.path().join("terraform.tfvars.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(tfvars, vars);
|
||||
|
||||
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
|
||||
for needle in [
|
||||
bucket.as_str(),
|
||||
key.as_str(),
|
||||
ACCOUNT,
|
||||
REGION,
|
||||
"tofu init -backend-config=backend.hcl",
|
||||
"tofu apply",
|
||||
"terraform providers lock",
|
||||
"registry.opentofu.org",
|
||||
"1.10",
|
||||
"aws s3api create-bucket",
|
||||
] {
|
||||
assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}");
|
||||
}
|
||||
// Outside us-east-1 the bucket needs a location constraint.
|
||||
assert!(readme.contains(&format!("\"LocationConstraint\":\"{REGION}\"")), "{readme}");
|
||||
// The admin policy allows s3:CreateBucket only with the component tag
|
||||
// in the request, so the tag goes on the create itself; and the bucket
|
||||
// is configured as the app configures it, TLS-only policy included.
|
||||
let create = readme
|
||||
.lines()
|
||||
.find(|l| l.contains("aws s3api create-bucket"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
create.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#),
|
||||
"{create}"
|
||||
);
|
||||
for needle in [
|
||||
"put-bucket-versioning",
|
||||
"put-bucket-encryption",
|
||||
"put-public-access-block",
|
||||
"put-bucket-policy",
|
||||
"aws:SecureTransport",
|
||||
"DenyInsecureTransport",
|
||||
] {
|
||||
assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}");
|
||||
}
|
||||
assert!(!readme.contains("put-bucket-tagging"), "a later tag can't satisfy the create");
|
||||
|
||||
// Writing again over our own files is fine...
|
||||
write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap();
|
||||
// ...but an unrelated file (or folder) means the wrong folder.
|
||||
fs::write(dir.path().join("notes.txt"), "mine").unwrap();
|
||||
let e = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap_err();
|
||||
assert!(e.contains("notes.txt"), "{e}");
|
||||
assert_eq!(fs::read_to_string(dir.path().join("notes.txt")).unwrap(), "mine");
|
||||
fs::remove_file(dir.path().join("notes.txt")).unwrap();
|
||||
fs::create_dir(dir.path().join(".terraform")).unwrap();
|
||||
assert!(write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_us_east_1_bundle_creates_its_bucket_without_a_location_constraint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vars = environment_vars("dev", "us-east-1", &NetworkChoice::Public).unwrap();
|
||||
write_bundle(dir.path(), Deployment::Environment("dev"), ACCOUNT, "us-east-1", &vars).unwrap();
|
||||
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
|
||||
assert!(readme.contains("aws s3api create-bucket"), "{readme}");
|
||||
assert!(!readme.contains("LocationConstraint"), "{readme}");
|
||||
assert!(readme.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#), "{readme}");
|
||||
assert!(readme.contains("tofu destroy"), "{readme}");
|
||||
for (name, _) in module_files(ModuleKind::Environment) {
|
||||
assert!(dir.path().join(name).is_file(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_ids_must_look_like_an_aws_user_id() {
|
||||
for ok in ["AIDAEXAMPLEUSERID1234", "AROAEXAMPLEID12345678:alice", "AROA1234567890ABCDEF:a.b@c_d-e+f=g,h"] {
|
||||
assert_eq!(validate_owner_id(ok).unwrap(), ok);
|
||||
}
|
||||
for bad in [
|
||||
"",
|
||||
"123456789012",
|
||||
"aidaexampleuserid1234",
|
||||
"AROAEXAMPLEID12345678:",
|
||||
"AROAEXAMPLEID12345678:a b",
|
||||
"AROAEXAMPLEID12345678:a:b",
|
||||
"AROAEXAMPLEID12345678:\"",
|
||||
&format!("AROAEXAMPLEID12345678:{}", "a".repeat(65)),
|
||||
&"A".repeat(129),
|
||||
] {
|
||||
assert!(validate_owner_id(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_side_rechecks_follow_the_detected_actions() {
|
||||
use EnvironmentAction::*;
|
||||
let s = scan(
|
||||
Knowable::Yes,
|
||||
vec![
|
||||
env("broken", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
|
||||
env(
|
||||
"working",
|
||||
EnvironmentState::Incomplete { lock: Some(lock(9000, false)) },
|
||||
vec![Repair, Unlock],
|
||||
),
|
||||
env("ready", EnvironmentState::Ready, vec![Join]),
|
||||
env("busy", EnvironmentState::Deploying { lock: Some(lock(10, true)) }, vec![Follow]),
|
||||
],
|
||||
);
|
||||
assert!(require_action(&s, "broken", Destroy).is_ok());
|
||||
assert!(require_action(&s, "working", Destroy).is_err(), "never destroy a working env (D6)");
|
||||
assert!(require_action(&s, "ready", Destroy).is_err());
|
||||
assert!(require_action(&s, "missing", Destroy).is_err());
|
||||
assert!(require_action(&s, "working", Repair).is_ok());
|
||||
assert!(require_action(&s, "ready", Update).is_err());
|
||||
|
||||
assert!(unlock_refusal(&s, "working").is_ok());
|
||||
assert!(unlock_refusal(&s, "broken").is_err(), "no lock to remove");
|
||||
assert!(unlock_refusal(&s, "busy").is_err(), "a local runner holds it");
|
||||
let local_stale = scan(
|
||||
Knowable::Yes,
|
||||
vec![env(
|
||||
"x",
|
||||
EnvironmentState::Incomplete { lock: Some(lock(9000, true)) },
|
||||
vec![Unlock],
|
||||
)],
|
||||
);
|
||||
assert!(unlock_refusal(&local_stale, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_needs_a_joinable_environment_and_the_boundary() {
|
||||
use EnvironmentAction::*;
|
||||
let mut ready = env("dev", EnvironmentState::Ready, vec![Join]);
|
||||
ready.onboarding = Some(OnboardingState::NotJoined);
|
||||
let mut busy = env("busy", EnvironmentState::Ready, vec![Join]);
|
||||
busy.onboarding = Some(OnboardingState::InProgress { lock: None });
|
||||
let half = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
|
||||
let s = scan(Knowable::Yes, vec![ready.clone(), busy, half]);
|
||||
assert!(onboard_refusal(&s, "dev").is_ok());
|
||||
assert!(onboard_refusal(&s, "busy").is_err());
|
||||
assert!(onboard_refusal(&s, "half").is_err());
|
||||
assert!(onboard_refusal(&s, "nope").is_err());
|
||||
assert!(onboard_refusal(&scan(Knowable::No, vec![ready]), "dev").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_events_have_the_documented_shape() {
|
||||
let p = DeployProgress {
|
||||
op_id: "op".into(),
|
||||
state_key: "boundary/terraform.tfstate".into(),
|
||||
events: vec![],
|
||||
done: false,
|
||||
cancellable: true,
|
||||
result: None,
|
||||
};
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"op_id": "op", "state_key": "boundary/terraform.tfstate", "events": [], "done": false, "cancellable": true})
|
||||
);
|
||||
let done = DeployProgress {
|
||||
done: true,
|
||||
result: Some(TofuOutcome { ok: false, exit_code: 1, summary: Some("boom".into()) }),
|
||||
..p
|
||||
};
|
||||
let v = serde_json::to_value(&done).unwrap();
|
||||
assert_eq!(v["result"], json!({"ok": false, "exit_code": 1, "summary": "boom"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_running_start_names_the_deployment_and_suggests_follow() {
|
||||
let e = start_error(StartError::AlreadyRunning, "environment \"dev\"");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("already running for environment \"dev\""), "{msg}");
|
||||
assert!(msg.contains("Follow"), "{msg}");
|
||||
let inner = AwsError::KeysMissing;
|
||||
assert_eq!(start_error(StartError::Failed(inner.clone()), "x"), inner);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_follower_emits_nothing_until_its_gate_opens() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
// A finished run: the whole replay and `done` are available at once,
|
||||
// the case where events could otherwise beat the op id to the caller.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lines: String = (0..450).map(|i| format!("line {i}\n")).collect();
|
||||
fs::write(dir.path().join("apply.log"), lines).unwrap();
|
||||
fs::write(dir.path().join("apply.exit"), "0\n").unwrap();
|
||||
let wd = Workdir { dir: dir.path().to_path_buf() };
|
||||
let base = DeployProgress {
|
||||
op_id: "op-1".into(),
|
||||
state_key: "boundary/terraform.tfstate".into(),
|
||||
events: vec![],
|
||||
done: false,
|
||||
cancellable: true,
|
||||
result: None,
|
||||
};
|
||||
let seen: Arc<Mutex<Vec<DeployProgress>>> = Arc::default();
|
||||
let sink = seen.clone();
|
||||
let (gate, rx) = Gate::new();
|
||||
let task = tokio::spawn(follow_and_report(
|
||||
rx,
|
||||
wd,
|
||||
base,
|
||||
Duration::from_millis(10),
|
||||
move |p| sink.lock().unwrap().push(p.clone()),
|
||||
));
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
assert!(seen.lock().unwrap().is_empty(), "emitted before the gate opened");
|
||||
|
||||
gate.open();
|
||||
task.await.unwrap();
|
||||
let seen = seen.lock().unwrap();
|
||||
let batches: Vec<usize> = seen.iter().filter(|p| !p.done).map(|p| p.events.len()).collect();
|
||||
assert_eq!(batches, vec![200, 200, 50], "the whole log, in capped batches");
|
||||
assert!(seen.iter().all(|p| p.op_id == "op-1" && p.state_key == "boundary/terraform.tfstate"));
|
||||
let last = seen.last().unwrap();
|
||||
assert!(last.done && !last.cancellable);
|
||||
assert_eq!(last.result, Some(TofuOutcome { ok: true, exit_code: 0, summary: None }));
|
||||
assert_eq!(seen.iter().filter(|p| p.done).count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dropped_gate_still_releases_the_follower() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("apply.log"), "x\n").unwrap();
|
||||
fs::write(dir.path().join("apply.exit"), "1\n").unwrap();
|
||||
let (gate, rx) = Gate::new();
|
||||
drop(gate);
|
||||
let mut n = 0;
|
||||
follow_and_report(
|
||||
rx,
|
||||
Workdir { dir: dir.path().to_path_buf() },
|
||||
DeployProgress {
|
||||
op_id: "o".into(),
|
||||
state_key: "k".into(),
|
||||
events: vec![],
|
||||
done: false,
|
||||
cancellable: false,
|
||||
result: None,
|
||||
},
|
||||
Duration::from_millis(10),
|
||||
|_| n += 1,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_needs_the_action_no_record_and_no_lock() {
|
||||
use EnvironmentAction::*;
|
||||
let mut stale_with_record = env(
|
||||
"rec",
|
||||
EnvironmentState::Incomplete { lock: Some(lock(9000, false)) },
|
||||
vec![Repair, Destroy, Unlock],
|
||||
);
|
||||
// Stale lock + record + no state: detection offers Destroy; the guard
|
||||
// must still refuse (the record says it may be working).
|
||||
stale_with_record.info = Some(info("rec", "public", true));
|
||||
let mut record_unlocked = env("rec2", EnvironmentState::Incomplete { lock: None }, vec![Destroy]);
|
||||
record_unlocked.info = Some(info("rec2", "public", true));
|
||||
let s = scan(
|
||||
Knowable::Yes,
|
||||
vec![
|
||||
stale_with_record,
|
||||
record_unlocked,
|
||||
env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Destroy, Unlock]),
|
||||
env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
|
||||
env("ready", EnvironmentState::Ready, vec![Join]),
|
||||
],
|
||||
);
|
||||
let e = destroy_refusal(&s, "rec").unwrap_err();
|
||||
assert!(e.contains("valid record"), "{e}");
|
||||
assert!(destroy_refusal(&s, "rec2").unwrap_err().contains("valid record"));
|
||||
assert!(destroy_refusal(&s, "locked").unwrap_err().contains("Unlock it first"));
|
||||
assert!(destroy_refusal(&s, "ready").is_err());
|
||||
assert!(destroy_refusal(&s, "missing").is_err());
|
||||
assert_eq!(destroy_refusal(&s, "half").unwrap().name, "half");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_is_refused_while_users_are_onboarded() {
|
||||
use EnvironmentAction::*;
|
||||
let mut onboarded = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
|
||||
onboarded.onboarded_users = Some(2);
|
||||
let mut unknown = env("blind", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
|
||||
unknown.onboarded_users = None;
|
||||
let s = scan(Knowable::Yes, vec![onboarded, unknown]);
|
||||
let e = destroy_refusal(&s, "half").unwrap_err();
|
||||
assert!(e.contains("remove the onboarded users' stacks first"), "{e}");
|
||||
assert!(e.contains("users/half/"), "{e}");
|
||||
// Without a listing there's no telling, and nothing to destroy from.
|
||||
assert!(destroy_refusal(&s, "blind").unwrap_err().contains("can't list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_your_own_stale_onboarding_lock_can_be_removed() {
|
||||
use EnvironmentAction::*;
|
||||
let mut stale = env("dev", EnvironmentState::Ready, vec![Join]);
|
||||
stale.onboarding = Some(OnboardingState::Incomplete { lock: Some(lock(9000, false)) });
|
||||
stale.onboarding_actions = vec![Unlock];
|
||||
let mut live = env("live", EnvironmentState::Ready, vec![Join]);
|
||||
live.onboarding = Some(OnboardingState::InProgress { lock: Some(lock(10, false)) });
|
||||
let mut none = env("none", EnvironmentState::Ready, vec![Join]);
|
||||
none.onboarding = Some(OnboardingState::Incomplete { lock: None });
|
||||
let s = scan(Knowable::Yes, vec![stale, live, none]);
|
||||
let mine = owner_key(REGION, USER_ID);
|
||||
assert!(onboarding_unlock_refusal(&s, "dev", &mine).is_ok());
|
||||
let e = onboarding_unlock_refusal(&s, "dev", "0123456789ab").unwrap_err();
|
||||
assert!(e.contains("your own"), "{e}");
|
||||
assert!(onboarding_unlock_refusal(&s, "live", &mine).is_err());
|
||||
assert!(onboarding_unlock_refusal(&s, "none", &mine).is_err());
|
||||
assert!(onboarding_unlock_refusal(&s, "missing", &mine).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_bundle_names_an_administrator_without_the_admin_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_bundle(dir.path(), Deployment::Boundary, ACCOUNT, REGION, &boundary_vars(REGION)).unwrap();
|
||||
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
|
||||
assert!(readme.contains("account administrator"), "{readme}");
|
||||
assert!(readme.contains("WITHOUT"), "{readme}");
|
||||
assert!(!readme.contains("that carry the Triple-C environment-admin"), "{readme}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_and_update_are_refused_under_a_held_lock() {
|
||||
use EnvironmentAction::*;
|
||||
let s = scan(
|
||||
Knowable::Yes,
|
||||
vec![
|
||||
env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Unlock]),
|
||||
env("free", EnvironmentState::Incomplete { lock: None }, vec![Repair]),
|
||||
env("old", EnvironmentState::UpdateAvailable, vec![Join, Update]),
|
||||
],
|
||||
);
|
||||
let e = reapply_refusal(&s, "locked", Repair).unwrap_err();
|
||||
assert!(e.contains("Unlock it first"), "{e}");
|
||||
assert!(reapply_refusal(&s, "free", Repair).is_ok());
|
||||
assert!(reapply_refusal(&s, "old", Update).is_ok());
|
||||
assert!(reapply_refusal(&s, "free", Update).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_deletes_only_a_still_stale_lock_and_reads_412_as_changed() {
|
||||
assert!(stale_lock_refusal(0).is_err());
|
||||
assert!(stale_lock_refusal(LIVE_LOCK_SECS - 1).is_err());
|
||||
assert!(stale_lock_refusal(LIVE_LOCK_SECS).is_ok());
|
||||
assert!(is_precondition_failed(Some("PreconditionFailed"), Some(412)));
|
||||
assert!(is_precondition_failed(None, Some(412)));
|
||||
assert!(!is_precondition_failed(Some("AccessDenied"), Some(403)));
|
||||
assert!(!is_precondition_failed(None, Some(404)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_boundary_without_state_here_is_refused() {
|
||||
assert!(boundary_refusal(&Knowable::Yes, Some(false)).unwrap_err().contains("already exists"));
|
||||
assert!(boundary_refusal(&Knowable::Yes, Some(true)).is_ok(), "re-apply our own");
|
||||
assert!(boundary_refusal(&Knowable::Yes, None).is_ok(), "can't tell");
|
||||
assert!(boundary_refusal(&Knowable::No, Some(false)).is_ok());
|
||||
assert!(boundary_refusal(&Knowable::Unknown, Some(false)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_follow_is_refused_while_a_launch_holds_the_folder() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.begin_launch(dir).unwrap();
|
||||
let mut spawned = false;
|
||||
let r = slots.install_follower(dir, "f1", false, || {
|
||||
spawned = true;
|
||||
1
|
||||
});
|
||||
assert_eq!(r.unwrap_err(), SlotBusy::Launching);
|
||||
assert!(!spawned, "nothing is spawned for a refused follow");
|
||||
// The launch's own follower replaces its Launching slot.
|
||||
assert_eq!(slots.install_follower(dir, "l1", true, || 2).unwrap(), None);
|
||||
slots.end_launch(dir); // the guard dropping afterwards
|
||||
assert!(!slots.is_free(dir), "end_launch must not clear a follower");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_is_refused_while_a_follower_or_launch_holds_the_folder() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.install_follower(dir, "f1", false, || 1).unwrap();
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following);
|
||||
slots.finish_follower(dir, "f1");
|
||||
slots.begin_launch(dir).unwrap();
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching);
|
||||
slots.end_launch(dir);
|
||||
assert!(slots.is_free(dir));
|
||||
// Other folders are independent.
|
||||
slots.begin_launch(dir).unwrap();
|
||||
slots.begin_launch(Path::new("/w/env/prod")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finishing_follower_only_removes_its_own_slot() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.install_follower(dir, "old", false, || 1).unwrap();
|
||||
// A re-follow replaces it and hands back the old task to abort.
|
||||
assert_eq!(slots.install_follower(dir, "new", false, || 2).unwrap(), Some(1));
|
||||
slots.finish_follower(dir, "old");
|
||||
assert!(!slots.is_free(dir), "the old follower must not remove the new one's slot");
|
||||
slots.finish_follower(dir, "new");
|
||||
assert!(slots.is_free(dir));
|
||||
// Nor a launch's slot.
|
||||
slots.begin_launch(dir).unwrap();
|
||||
slots.finish_follower(dir, "new");
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ending_a_launch_never_clears_a_following_slot() {
|
||||
let dir = Path::new("/w/env/dev");
|
||||
let mut slots: Slots<u32> = Slots::default();
|
||||
slots.install_follower(dir, "f", false, || 1).unwrap();
|
||||
slots.end_launch(dir);
|
||||
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following);
|
||||
slots.end_launch(Path::new("/w/env/none")); // nothing there: no-op
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::future::Future;
|
||||
use tauri::State;
|
||||
|
||||
use crate::aws::permissions::{check_permissions, PermissionReport, SdkPermissionApi};
|
||||
use crate::aws::policies::{policy_json, Tier};
|
||||
use crate::aws::policies::{policy_files, Tier};
|
||||
use crate::aws::{caller_identity, sdk_config, sdk_config_for_keys, AwsError, CallerIdentity};
|
||||
use crate::models::{
|
||||
validate_connection_name, validate_profile_name, validate_supported_region, AppSettings, AwsAuth,
|
||||
@@ -118,7 +118,7 @@ async fn real_identity(c: Credentials) -> Result<CallerIdentity, AwsError> {
|
||||
caller_identity(&cfg).await
|
||||
}
|
||||
|
||||
fn aws_conn(id: &str, settings: &AppSettings) -> Result<HostConnection, String> {
|
||||
pub(crate) fn aws_conn(id: &str, settings: &AppSettings) -> Result<HostConnection, String> {
|
||||
check_id(id)?;
|
||||
let conn = settings
|
||||
.hosts
|
||||
@@ -281,9 +281,24 @@ pub async fn check_aws_permissions(
|
||||
check_permissions(&api, &identity, ®ion).await
|
||||
}
|
||||
|
||||
/// One policy file of a tier: its file name under `aws/policies/` and its
|
||||
/// JSON, verbatim.
|
||||
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
|
||||
pub struct PolicyFile {
|
||||
pub name: String,
|
||||
pub json: String,
|
||||
}
|
||||
|
||||
/// Every policy file `tier` needs, in order; all of them are required (D7).
|
||||
#[tauri::command]
|
||||
pub fn aws_policy_document(tier: Tier) -> String {
|
||||
policy_json(tier).to_string()
|
||||
pub fn aws_policy_document(tier: Tier) -> Vec<PolicyFile> {
|
||||
policy_files(tier)
|
||||
.iter()
|
||||
.map(|(name, json)| PolicyFile {
|
||||
name: name.to_string(),
|
||||
json: json.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -522,4 +537,21 @@ mod tests {
|
||||
}
|
||||
assert!(validated("Prod", "eu-west-1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_policy_document_is_every_file_of_the_tier() {
|
||||
let admin = aws_policy_document(Tier::EnvironmentAdmin);
|
||||
let names: Vec<&str> = admin.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["environment-admin.json", "environment-admin-network.json"]);
|
||||
for f in &admin {
|
||||
serde_json::from_str::<serde_json::Value>(&f.json).expect("policy JSON");
|
||||
}
|
||||
let ws = aws_policy_document(Tier::WorkspaceUser);
|
||||
assert_eq!(ws.len(), 1);
|
||||
assert_eq!(ws[0].name, "workspace-user.json");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&ws[0]).unwrap()["name"],
|
||||
"workspace-user.json"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod auth_bridge_commands;
|
||||
pub mod auth_token_commands;
|
||||
pub mod aws_commands;
|
||||
pub mod docker_commands;
|
||||
pub mod environment_commands;
|
||||
pub mod file_commands;
|
||||
pub mod file_viewer_commands;
|
||||
pub mod gateway_commands;
|
||||
|
||||
@@ -211,6 +211,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// The detached OpenTofu runner (`aws::tofu`): `Some(exit code)` when this
|
||||
/// process was started as one, `None` for a normal launch. `main` asks
|
||||
/// before anything Tauri or GTK starts, so a runner never opens a window.
|
||||
pub fn tofu_runner_main() -> Option<i32> {
|
||||
aws::tofu::runner_main(std::env::args_os())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
logging::init();
|
||||
|
||||
@@ -603,6 +610,18 @@ pub fn run() {
|
||||
commands::host_commands::aws_connection_sso_login,
|
||||
commands::host_commands::check_aws_permissions,
|
||||
commands::host_commands::aws_policy_document,
|
||||
commands::environment_commands::detect_aws_environments,
|
||||
commands::environment_commands::deploy_aws_environment,
|
||||
commands::environment_commands::update_aws_environment,
|
||||
commands::environment_commands::repair_aws_environment,
|
||||
commands::environment_commands::destroy_incomplete_aws_environment,
|
||||
commands::environment_commands::unlock_aws_state,
|
||||
commands::environment_commands::follow_aws_deployment,
|
||||
commands::environment_commands::cancel_aws_deployment,
|
||||
commands::environment_commands::push_aws_saved_state,
|
||||
commands::environment_commands::onboard_aws_user,
|
||||
commands::environment_commands::deploy_aws_permissions_boundary,
|
||||
commands::environment_commands::save_aws_admin_bundle,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
|
||||
@@ -144,6 +144,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// A detached OpenTofu apply re-executes this binary with `--tofu-runner`
|
||||
// (aws/tofu.rs). It must not get anywhere near Tauri, GTK or a window.
|
||||
if let Some(code) = triple_c_lib::tofu_runner_main() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
// Before *any* `std::env::set_var` — `url_open` hands a child process the
|
||||
// environment this app was started with, and the workaround below is one
|
||||
// of the things that must not leak into it (see triple-c#34). Anything
|
||||
|
||||
@@ -187,11 +187,10 @@ fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
|
||||
/// from the process, so the rules can be tested without a global mutex around
|
||||
/// the environment. Returns changes sorted by variable name so assertions are
|
||||
/// deterministic.
|
||||
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
|
||||
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
|
||||
// tests and the documentation stay in one piece on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn sanitize_child_env(
|
||||
///
|
||||
/// Also used by the OpenTofu runner (`aws::tofu::tofu_env`), which undoes an
|
||||
/// AppImage launcher's changes before starting `tofu`.
|
||||
pub(crate) fn sanitize_child_env(
|
||||
current: &BTreeMap<String, String>,
|
||||
pristine: &BTreeMap<String, String>,
|
||||
appdir: Option<&str>,
|
||||
|
||||
@@ -140,11 +140,18 @@ describe("HostsSettings", () => {
|
||||
expect(screen.getByLabelText("Secret access key")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("check shows Insufficient with the denied actions and the copy-policy buttons", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
it("check shows Insufficient with the denied actions and every policy file to copy", async () => {
|
||||
const adminFiles = [
|
||||
{ name: "environment-admin.json", json: '{"Statement":"admin"}' },
|
||||
{ name: "environment-admin-network.json", json: '{"Statement":"network"}' },
|
||||
];
|
||||
invoke.mockImplementation(async (cmd: string, args?: { tier: string }) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
if (cmd === "aws_policy_document") return '{"Version":"2012-10-17"}';
|
||||
if (cmd === "aws_policy_document")
|
||||
return args?.tier === "environment_admin"
|
||||
? adminFiles
|
||||
: [{ name: "workspace-user.json", json: '{"Version":"2012-10-17"}' }];
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -154,10 +161,22 @@ describe("HostsSettings", () => {
|
||||
expect(await screen.findByText("Insufficient")).toBeInTheDocument();
|
||||
expect(screen.getByText("ecs:RunTask")).toBeInTheDocument();
|
||||
expect(screen.getByText(/SCPs or session policies/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy workspace-user policy" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show workspace-user policy" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Copy workspace-user.json" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"Version":"2012-10-17"}'));
|
||||
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "workspace_user" });
|
||||
expect(screen.getByRole("button", { name: "Copy admin policy" })).toBeInTheDocument();
|
||||
expect(screen.getByText("workspace-user.json copied to clipboard.")).toBeInTheDocument();
|
||||
|
||||
// The admin tier is every one of its files, each shown and copied on its own.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show admin policies" }));
|
||||
expect(await screen.findByText(/Attach all 2 files/)).toBeInTheDocument();
|
||||
for (const f of adminFiles) {
|
||||
expect(screen.getByText(f.json)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: `Copy ${f.name}` }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(f.json));
|
||||
}
|
||||
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "environment_admin" });
|
||||
});
|
||||
|
||||
it("shows a neutral Incomplete badge when the only blockers are failed checks", async () => {
|
||||
@@ -301,14 +320,27 @@ describe("HostsSettings", () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
if (cmd === "aws_policy_document") return "{}";
|
||||
if (cmd === "aws_policy_document") return [{ name: "environment-admin.json", json: "{}" }];
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } });
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Copy admin policy" }));
|
||||
expect(await screen.findByText(/Could not copy the policy: denied/)).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Show admin policies" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Copy environment-admin.json" }));
|
||||
expect(await screen.findByText(/Could not copy environment-admin.json: denied/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a message when the policy files can't be loaded", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "list_aws_profiles") return [];
|
||||
if (cmd === "check_aws_permissions") return report({});
|
||||
throw new Error("boom");
|
||||
});
|
||||
render(<HostsSettings />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Show admin policies" }));
|
||||
expect(await screen.findByText(/Could not load the policy: boom/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -5,47 +5,29 @@ import {
|
||||
addAwsProfileConnection,
|
||||
awsConnectionIdentity,
|
||||
awsConnectionSsoLogin,
|
||||
awsPolicyDocument,
|
||||
checkAwsPermissions,
|
||||
listAwsProfiles,
|
||||
removeHostConnection,
|
||||
updateAwsConnection,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { toAwsError } from "../../lib/aws-error";
|
||||
import { awsErrorMessage, toAwsError } from "../../lib/aws-error";
|
||||
import PolicyFiles from "./hosts/PolicyFiles";
|
||||
import EnvironmentsPanel from "./hosts/EnvironmentsPanel";
|
||||
import { COMMON_REGIONS } from "./hosts/environmentFormat";
|
||||
import type {
|
||||
AwsError,
|
||||
HostConnection,
|
||||
PermissionReport,
|
||||
PermissionSummary,
|
||||
Tier,
|
||||
PolicyFile,
|
||||
} from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||
|
||||
const COMMON_REGIONS = [
|
||||
"us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1",
|
||||
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
|
||||
"ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1",
|
||||
"sa-east-1",
|
||||
];
|
||||
|
||||
const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"];
|
||||
|
||||
/** Human-readable copy for a failed AWS command; never raw JSON. */
|
||||
export function awsErrorMessage(e: AwsError): string {
|
||||
switch (e.kind) {
|
||||
case "sso_expired":
|
||||
return `Your SSO session for profile ${e.profile} has expired.`;
|
||||
case "profile_not_found":
|
||||
return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`;
|
||||
case "keys_missing":
|
||||
return "The access keys for this connection are missing from the keychain. Remove and re-add the connection.";
|
||||
default:
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const SUMMARY_LABEL: Record<PermissionSummary, string> = {
|
||||
environment_admin: "Environment admin",
|
||||
workspace_user: "Workspace user",
|
||||
@@ -121,12 +103,12 @@ function PermissionReportView({ report }: { report: PermissionReport }) {
|
||||
t.results.some((r) => r.verdict === "denied" && OWN_PRINCIPAL_ACTIONS.includes(r.action)),
|
||||
);
|
||||
|
||||
const copy = async (tier: Tier) => {
|
||||
const copy = async (file: PolicyFile) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(await awsPolicyDocument(tier));
|
||||
setCopyNote("Policy copied to clipboard.");
|
||||
await navigator.clipboard.writeText(file.json);
|
||||
setCopyNote(`${file.name} copied to clipboard.`);
|
||||
} catch (e) {
|
||||
setCopyNote(`Could not copy the policy: ${awsErrorMessage(toAwsError(e))}`);
|
||||
setCopyNote(`Could not copy ${file.name}: ${awsErrorMessage(toAwsError(e))}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,14 +163,8 @@ function PermissionReportView({ report }: { report: PermissionReport }) {
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" onClick={() => copy("workspace_user")}>
|
||||
Copy workspace-user policy
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => copy("environment_admin")}>
|
||||
Copy admin policy
|
||||
</Button>
|
||||
</div>
|
||||
<PolicyFiles tier="workspace_user" label="Show workspace-user policy" onCopy={copy} />
|
||||
<PolicyFiles tier="environment_admin" label="Show admin policies" onCopy={copy} />
|
||||
{copyNote && (
|
||||
<p role="status" className="text-xs text-[var(--text-secondary)]">
|
||||
{copyNote}
|
||||
@@ -338,6 +314,7 @@ function ConnectionRow({ host }: { host: HostConnection }) {
|
||||
{busy && !editing && !confirmRemove && <p role="status" className="text-xs text-[var(--text-secondary)]">Working…</p>}
|
||||
{failure && <ErrorLine failure={failure} onSignIn={signIn} signingIn={signingIn} />}
|
||||
{report && <PermissionReportView report={report} />}
|
||||
<EnvironmentsPanel connection={host} tier={report?.summary} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -530,7 +507,7 @@ export default function HostsSettings() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Where project environments run. AWS connections are used to check what your credentials can do.
|
||||
Where project environments run. AWS connections check what your credentials can do, and find, deploy and join environments.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<li className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import AdminBundleModal from "./AdminBundleModal";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
|
||||
}));
|
||||
|
||||
describe("AdminBundleModal", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("saves the bundle and shows the folder and the two commands, with Copy", async () => {
|
||||
invoke.mockResolvedValue("/home/me/triple-c-onboard-dev");
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
render(
|
||||
<AdminBundleModal connectionId="h1" region="eu-west-1" kind="user_onboard" environment="dev" ownerId="AIDAEXAMPLE" onClose={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByRole("dialog", { name: "Onboarding bundle" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/environment admin/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
expect(await screen.findByText("/home/me/triple-c-onboard-dev")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
kind: "user_onboard",
|
||||
environment: "dev",
|
||||
ownerId: "AIDAEXAMPLE",
|
||||
network: null,
|
||||
});
|
||||
const commands = "tofu init -backend-config=backend.hcl\ntofu apply";
|
||||
expect(screen.getByText((_, el) => el?.tagName === "PRE" && el.textContent === commands)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy commands" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(commands));
|
||||
});
|
||||
|
||||
it("shows nothing new when the folder picker is dismissed", async () => {
|
||||
invoke.mockResolvedValue(null);
|
||||
render(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="permissions_boundary" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Copy commands" })).toBeNull();
|
||||
});
|
||||
|
||||
it("an environment bundle asks for the name and network", async () => {
|
||||
invoke.mockResolvedValue("/tmp/env");
|
||||
render(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="environment" onClose={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "team" } });
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
kind: "environment",
|
||||
environment: "team",
|
||||
ownerId: null,
|
||||
network: { mode: "private-nat" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a save failure through the AWS error copy", async () => {
|
||||
invoke.mockRejectedValue({ kind: "sso_expired", profile: "prod-sso" });
|
||||
render(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="permissions_boundary" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
|
||||
expect(await screen.findByText("Your SSO session for profile prod-sso has expired.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { saveAwsAdminBundle } from "../../../lib/tauri-commands";
|
||||
import { awsErrorMessage, toAwsError } from "../../../lib/aws-error";
|
||||
import type { AwsError, ModuleKind, NetworkChoice } from "../../../lib/types";
|
||||
import Button from "../../ui/Button";
|
||||
import Field, { inputClass } from "../../ui/Field";
|
||||
import Modal from "../../ui/Modal";
|
||||
import NetworkFields from "./NetworkFields";
|
||||
import { DEFAULT_NETWORK, environmentNameError, networkFromDraft, type NetworkDraft } from "./environmentFormat";
|
||||
|
||||
const COMMANDS = "tofu init -backend-config=backend.hcl\ntofu apply";
|
||||
|
||||
const TITLE: Record<ModuleKind, string> = {
|
||||
environment: "Environment bundle",
|
||||
user_onboard: "Onboarding bundle",
|
||||
permissions_boundary: "Permissions boundary bundle",
|
||||
};
|
||||
|
||||
/**
|
||||
* An admin request (D5): Triple-C writes the module, `backend.hcl`,
|
||||
* `terraform.tfvars.json` and a README into a folder picked in Rust, and the
|
||||
* admin runs the two commands from there.
|
||||
*/
|
||||
export default function AdminBundleModal({
|
||||
connectionId,
|
||||
region,
|
||||
kind,
|
||||
environment,
|
||||
ownerId,
|
||||
network,
|
||||
reason,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
connectionId: string;
|
||||
region: string;
|
||||
kind: ModuleKind;
|
||||
/** Fixed environment; when absent for an environment bundle, the user picks it here. */
|
||||
environment?: string;
|
||||
ownerId?: string;
|
||||
network?: NetworkChoice;
|
||||
/** Why the bundle is needed (e.g. the refusal that led here). */
|
||||
reason?: string;
|
||||
/** Called with the folder once a bundle was actually written. */
|
||||
onSaved?: (folder: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const askEnvironment = kind === "environment" && !environment;
|
||||
const [name, setName] = useState("main");
|
||||
const [draft, setDraft] = useState<NetworkDraft>(DEFAULT_NETWORK);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [error, setError] = useState<AwsError | null>(null);
|
||||
const [copyNote, setCopyNote] = useState<string | null>(null);
|
||||
|
||||
const nameError = askEnvironment ? environmentNameError(name) : null;
|
||||
const picked = askEnvironment ? networkFromDraft(draft) : null;
|
||||
const ready = !nameError && (!picked || "network" in picked);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const folder = await saveAwsAdminBundle(connectionId, region, kind, {
|
||||
environment: askEnvironment ? name : environment,
|
||||
ownerId,
|
||||
network: picked && "network" in picked ? picked.network : network,
|
||||
});
|
||||
if (folder) {
|
||||
setSaved(folder);
|
||||
onSaved?.(folder);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(toAwsError(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(COMMANDS);
|
||||
setCopyNote("Commands copied.");
|
||||
} catch (e) {
|
||||
setCopyNote(`Could not copy: ${awsErrorMessage(toAwsError(e))}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={TITLE[kind]}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button size="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{reason && (
|
||||
<p role="alert" className="text-[var(--warning)]">
|
||||
Doing this from here was refused: {reason}
|
||||
</p>
|
||||
)}
|
||||
{kind === "environment" && (
|
||||
<p>
|
||||
Deploying an environment needs an environment admin. Triple-C writes the OpenTofu module with its
|
||||
backend settings and variables into a folder; send it to an admin, who deploys it from there.
|
||||
</p>
|
||||
)}
|
||||
{kind === "user_onboard" && (
|
||||
<p>
|
||||
Joining {environment} needs your own task role and file system, which an environment admin creates
|
||||
by applying this bundle. Send them the folder; once it's applied, Detect again shows you as joined.
|
||||
</p>
|
||||
)}
|
||||
{kind === "permissions_boundary" && (
|
||||
<p>
|
||||
Every role Triple-C creates is capped by the account's permissions boundary, and creating it needs an
|
||||
account administrator. Send them this folder.
|
||||
</p>
|
||||
)}
|
||||
<p>The bundle works with OpenTofu or Terraform 1.10 and later. Its README lists every step.</p>
|
||||
{askEnvironment && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Name" hint={nameError ?? undefined}>
|
||||
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
|
||||
</Field>
|
||||
<NetworkFields value={draft} onChange={setDraft} error={picked && "error" in picked ? picked.error : null} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="md" variant="primary" onClick={save} disabled={saving || !ready}>
|
||||
{saving ? "Saving…" : "Save bundle to folder…"}
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{awsErrorMessage(error)}
|
||||
</p>
|
||||
)}
|
||||
{saved && (
|
||||
<div className="space-y-1.5">
|
||||
<p>
|
||||
Saved to <code className="font-mono text-[var(--text-primary)] break-all">{saved}</code>
|
||||
</p>
|
||||
<p>From that folder, run:</p>
|
||||
<pre className="font-mono text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 whitespace-pre-wrap">
|
||||
{COMMANDS}
|
||||
</pre>
|
||||
<Button size="sm" onClick={copy}>
|
||||
Copy commands
|
||||
</Button>
|
||||
{copyNote && <p role="status">{copyNote}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import DeployEnvironmentModal from "./DeployEnvironmentModal";
|
||||
import type { DeployProgress } from "../../../lib/types";
|
||||
import { conn, env, progress, scan, tofuEvent } from "./testkit";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
|
||||
}));
|
||||
|
||||
type Handler = (e: { payload: DeployProgress }) => void;
|
||||
const handlers = new Set<Handler>();
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_name: string, handler: Handler) => {
|
||||
handlers.add(handler);
|
||||
return () => handlers.delete(handler);
|
||||
}),
|
||||
}));
|
||||
function emit(p: DeployProgress) {
|
||||
act(() => {
|
||||
for (const h of [...handlers]) h({ payload: p });
|
||||
});
|
||||
}
|
||||
|
||||
const BOUNDARY_KEY = "boundary/terraform.tfstate";
|
||||
|
||||
function renderModal(over: Partial<Parameters<typeof DeployEnvironmentModal>[0]> = {}) {
|
||||
const props = {
|
||||
connectionId: conn.id,
|
||||
region: "eu-west-1",
|
||||
scan: scan(),
|
||||
onClose: vi.fn(),
|
||||
onDeploy: vi.fn().mockResolvedValue(null),
|
||||
onDetect: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
render(<DeployEnvironmentModal {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("DeployEnvironmentModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
handlers.clear();
|
||||
});
|
||||
|
||||
it("the public network mode is the default and says it costs nothing", async () => {
|
||||
const props = renderModal();
|
||||
expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByText("$0 — tasks get a public IP, no inbound access")).toBeInTheDocument();
|
||||
expect(screen.getByText("First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB).")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" }));
|
||||
expect(screen.getByText("about $33/month for the NAT gateway")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Public" }));
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("main");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deploy" }));
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("main", { mode: "public" });
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("validates the name with the backend's rule", async () => {
|
||||
const props = renderModal({ scan: scan({ environments: [env({ name: "main" })] }) });
|
||||
const deploy = screen.getByRole("button", { name: "Deploy" });
|
||||
expect(screen.getByText(/already exists/)).toBeInTheDocument();
|
||||
expect(deploy).toBeDisabled();
|
||||
for (const bad of ["Dev", "a--b", "dev-", "1dev", "a".repeat(21), ""]) {
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: bad } });
|
||||
expect(deploy).toBeDisabled();
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "team-2" } });
|
||||
expect(deploy).toBeEnabled();
|
||||
fireEvent.click(deploy);
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("team-2", { mode: "public" });
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("an existing network needs a VPC and two distinct subnets", async () => {
|
||||
const props = renderModal();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Existing VPC" }));
|
||||
const deploy = screen.getByRole("button", { name: "Deploy" });
|
||||
expect(deploy).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("VPC ID"), { target: { value: "vpc-0123abcd" } });
|
||||
fireEvent.change(screen.getByLabelText("Subnet ID 1"), { target: { value: "subnet-0123abcd" } });
|
||||
fireEvent.change(screen.getByLabelText("Subnet ID 2"), { target: { value: "subnet-0123abcd" } });
|
||||
expect(deploy).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("Subnet ID 2"), { target: { value: "subnet-4567abcd" } });
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Assign a public IP" }));
|
||||
fireEvent.click(deploy);
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("main", {
|
||||
mode: "existing",
|
||||
vpc_id: "vpc-0123abcd",
|
||||
subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"],
|
||||
assign_public_ip: true,
|
||||
});
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps the dialog open and shows the server's refusal", async () => {
|
||||
const props = renderModal({
|
||||
onDeploy: vi.fn().mockResolvedValue({ kind: "other", message: "A deployment is starting for environment \"main\"; wait for it" }),
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deploy" }));
|
||||
expect(await screen.findByText(/A deployment is starting/)).toBeInTheDocument();
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deploy modal blocks on a missing boundary and offers to create it", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "deploy_aws_permissions_boundary") return "b1";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const props = renderModal({ scan: scan({ boundary: "no" }) });
|
||||
expect(screen.queryByLabelText("Name")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Deploy" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
|
||||
// Running (op id known) is the visible sign the invoke resolved.
|
||||
expect(await screen.findByText("Running")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" });
|
||||
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, events: [tofuEvent("Creating boundary")] }));
|
||||
expect(screen.getByText("Creating boundary")).toBeInTheDocument();
|
||||
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete!" } }));
|
||||
expect(await screen.findByLabelText("Name")).toBeInTheDocument();
|
||||
expect(props.onDetect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the boundary bundle when the boundary run fails, with its summary as the reason", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "deploy_aws_permissions_boundary") return "b1";
|
||||
if (cmd === "save_aws_admin_bundle") return "/tmp/b";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
const props = renderModal({ scan: scan({ boundary: "no" }) });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
|
||||
expect(await screen.findByText("Running")).toBeInTheDocument();
|
||||
const summary = "Error: creating IAM Policy (triple-c-permissions-boundary): AccessDenied: not authorized to perform iam:CreatePolicy";
|
||||
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: false, exit_code: 1, summary } }));
|
||||
expect(props.onDetect).toHaveBeenCalled();
|
||||
// Offered, not opened.
|
||||
expect(screen.queryByRole("dialog", { name: "Permissions boundary bundle" })).toBeNull();
|
||||
expect(screen.queryByLabelText("Name")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Get the boundary bundle for an administrator" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Permissions boundary bundle" });
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent(summary);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" }));
|
||||
expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the boundary bundle when the boundary command itself is denied", async () => {
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "deploy_aws_permissions_boundary") throw { kind: "access_denied", action: "iam:CreatePolicy", message: "denied iam:CreatePolicy" };
|
||||
if (cmd === "save_aws_admin_bundle") return "/tmp/b";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
renderModal({ scan: scan({ boundary: "no" }) });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Permissions boundary bundle" });
|
||||
expect(within(dialog).getByText(/denied iam:CreatePolicy/)).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
kind: "permissions_boundary",
|
||||
environment: null,
|
||||
ownerId: null,
|
||||
network: null,
|
||||
}),
|
||||
);
|
||||
expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("repairs a named environment and warns that a different network replaces it", async () => {
|
||||
const props = renderModal({ repair: "dev" });
|
||||
expect(screen.getByRole("dialog", { name: "Repair dev" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Name")).toBeNull();
|
||||
expect(screen.getByText(/different network than the one it was deployed with replaces/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Repair" }));
|
||||
expect(props.onDeploy).toHaveBeenCalledWith("dev", { mode: "private-nat" });
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState } from "react";
|
||||
import { deployAwsPermissionsBoundary } from "../../../lib/tauri-commands";
|
||||
import { awsErrorMessage } from "../../../lib/aws-error";
|
||||
import type { AwsError, EnvironmentScan, NetworkChoice } from "../../../lib/types";
|
||||
import Button from "../../ui/Button";
|
||||
import Field, { inputClass } from "../../ui/Field";
|
||||
import Modal from "../../ui/Modal";
|
||||
import AdminBundleModal from "./AdminBundleModal";
|
||||
import DeployProgress, { runKey, useDeployRuns } from "./DeployProgress";
|
||||
import NetworkFields from "./NetworkFields";
|
||||
import {
|
||||
BOUNDARY_STATE_KEY,
|
||||
DEFAULT_NETWORK,
|
||||
environmentNameError,
|
||||
networkFromDraft,
|
||||
type NetworkDraft,
|
||||
} from "./environmentFormat";
|
||||
|
||||
/**
|
||||
* Deploy a new environment (name + D4 network), or — with `repair` — pick the
|
||||
* network for an environment that has no record to take it from. A missing
|
||||
* permissions boundary is a first step before the form.
|
||||
*/
|
||||
export default function DeployEnvironmentModal({
|
||||
connectionId,
|
||||
region,
|
||||
scan,
|
||||
repair,
|
||||
onClose,
|
||||
onDeploy,
|
||||
onDetect,
|
||||
}: {
|
||||
connectionId: string;
|
||||
region: string;
|
||||
scan: EnvironmentScan;
|
||||
/** The environment to repair; absent for a new deploy. */
|
||||
repair?: string;
|
||||
onClose: () => void;
|
||||
/** Starts the run; resolves to the command's error, or null once started. */
|
||||
onDeploy: (name: string, network: NetworkChoice) => Promise<AwsError | null>;
|
||||
onDetect: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("main");
|
||||
const [draft, setDraft] = useState<NetworkDraft>(DEFAULT_NETWORK);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<AwsError | null>(null);
|
||||
const [boundaryDone, setBoundaryDone] = useState(false);
|
||||
const [boundaryError, setBoundaryError] = useState<AwsError | null>(null);
|
||||
const [bundle, setBundle] = useState<{ reason?: string } | null>(null);
|
||||
const boundary = useDeployRuns(({ result }) => {
|
||||
if (result?.ok) setBoundaryDone(true);
|
||||
onDetect();
|
||||
});
|
||||
const boundaryRun = boundary.runs[runKey(region, BOUNDARY_STATE_KEY)];
|
||||
const boundaryFailed = boundaryRun?.done && boundaryRun.result && !boundaryRun.result.ok ? boundaryRun.result : null;
|
||||
|
||||
if (bundle) {
|
||||
return (
|
||||
<AdminBundleModal
|
||||
connectionId={connectionId}
|
||||
region={region}
|
||||
kind="permissions_boundary"
|
||||
reason={bundle.reason}
|
||||
onClose={() => setBundle(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const needsBoundary = !repair && scan.boundary === "no" && !boundaryDone;
|
||||
const target = repair ?? name;
|
||||
const nameError = repair
|
||||
? null
|
||||
: environmentNameError(name) ??
|
||||
(scan.environments.some((e) => e.name === name) ? `An environment named ${name} already exists here.` : null);
|
||||
const picked = networkFromDraft(draft);
|
||||
const ready = !nameError && "network" in picked && !submitting;
|
||||
|
||||
const createBoundary = async () => {
|
||||
setBoundaryError(null);
|
||||
const err = await boundary.start(BOUNDARY_STATE_KEY, region, "Permissions boundary", () =>
|
||||
deployAwsPermissionsBoundary(connectionId, region),
|
||||
);
|
||||
if (err?.kind === "access_denied") setBundle({ reason: awsErrorMessage(err) });
|
||||
else if (err) setBoundaryError(err);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!("network" in picked)) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const err = await onDeploy(target, picked.network);
|
||||
setSubmitting(false);
|
||||
if (err) setError(err);
|
||||
else onClose();
|
||||
};
|
||||
|
||||
const verb = repair ? "Repair" : "Deploy";
|
||||
return (
|
||||
<Modal
|
||||
title={repair ? `Repair ${repair}` : "Deploy a new environment"}
|
||||
description={`In ${region}.`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!needsBoundary && (
|
||||
<Button size="md" variant="primary" onClick={submit} disabled={!ready}>
|
||||
{submitting ? `${verb}ing…` : verb}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{needsBoundary ? (
|
||||
<div className="space-y-3 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
<p>
|
||||
This account has no Triple-C permissions boundary yet. Every role an environment creates is capped by
|
||||
it, so it comes first.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={createBoundary}
|
||||
disabled={!!boundaryRun && !boundaryRun.done}
|
||||
>
|
||||
Create the permissions boundary (needs an account administrator)
|
||||
</Button>
|
||||
<Button size="md" onClick={() => setBundle({})}>
|
||||
Save a bundle for an administrator instead
|
||||
</Button>
|
||||
</div>
|
||||
{boundaryError && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{awsErrorMessage(boundaryError)}
|
||||
</p>
|
||||
)}
|
||||
{boundaryRun && <DeployProgress run={boundaryRun} connectionId={connectionId} />}
|
||||
{boundaryFailed && (
|
||||
<div className="space-y-1.5">
|
||||
<p>
|
||||
Creating the boundary failed. If it was refused, an account administrator can create it from a
|
||||
bundle.
|
||||
</p>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() =>
|
||||
setBundle({
|
||||
reason: boundaryFailed.summary ?? `OpenTofu exited with code ${boundaryFailed.exit_code}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
Get the boundary bundle for an administrator
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{repair ? (
|
||||
<p className="text-xs text-[var(--warning)] leading-snug">
|
||||
{repair} has no record, so Triple-C can't tell which network it was deployed with. Choose the same one:
|
||||
a different network than the one it was deployed with replaces the network resources (VPC, subnets,
|
||||
NAT gateway).
|
||||
</p>
|
||||
) : (
|
||||
<Field label="Name" hint={nameError ?? "Lowercase letters, digits and hyphens; up to 20 characters."}>
|
||||
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
|
||||
</Field>
|
||||
)}
|
||||
<NetworkFields value={draft} onChange={setDraft} error={"error" in picked ? picked.error : null} />
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB).
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-[var(--error)] leading-snug">
|
||||
{awsErrorMessage(error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { runKey, useDeployRuns } from "./DeployProgress";
|
||||
import type { DeployProgress } from "../../../lib/types";
|
||||
import { progress, tofuEvent } from "./testkit";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
type Handler = (e: { payload: DeployProgress }) => void;
|
||||
const handlers = new Set<Handler>();
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_name: string, handler: Handler) => {
|
||||
handlers.add(handler);
|
||||
return () => handlers.delete(handler);
|
||||
}),
|
||||
}));
|
||||
function emit(p: DeployProgress) {
|
||||
act(() => {
|
||||
for (const h of [...handlers]) h({ payload: p });
|
||||
});
|
||||
}
|
||||
|
||||
const KEY = "environments/main/terraform.tfstate";
|
||||
|
||||
describe("useDeployRuns", () => {
|
||||
beforeEach(() => handlers.clear());
|
||||
|
||||
it("tracks runs for the same state key in two regions independently", async () => {
|
||||
const onDone = vi.fn();
|
||||
const { result } = renderHook(() => useDeployRuns(onDone));
|
||||
await act(async () => {
|
||||
await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "eu");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.start(KEY, "us-east-1", "Deploy of main", async () => "us");
|
||||
});
|
||||
expect(handlers.size).toBe(2);
|
||||
emit(progress({ op_id: "eu", state_key: KEY, events: [tofuEvent("eu line")] }));
|
||||
emit(progress({ op_id: "us", state_key: KEY, events: [tofuEvent("us line")] }));
|
||||
emit(progress({ op_id: "us", state_key: KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "ok" } }));
|
||||
|
||||
const eu = result.current.runs[runKey("eu-west-1", KEY)];
|
||||
const us = result.current.runs[runKey("us-east-1", KEY)];
|
||||
expect(eu.events.map((e) => e.message)).toEqual(["eu line"]);
|
||||
expect(eu.done).toBe(false);
|
||||
expect(us.events.map((e) => e.message)).toEqual(["us line"]);
|
||||
expect(us.done).toBe(true);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
expect(onDone).toHaveBeenCalledWith({ stateKey: KEY, region: "us-east-1", result: { ok: true, exit_code: 0, summary: "ok" } });
|
||||
// The finished run's listener is gone; the other region's is still there.
|
||||
expect(handlers.size).toBe(1);
|
||||
});
|
||||
|
||||
it("removes its progress listeners on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() => useDeployRuns());
|
||||
await act(async () => {
|
||||
await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "a");
|
||||
await result.current.start("boundary/terraform.tfstate", "eu-west-1", "Boundary", async () => "b");
|
||||
});
|
||||
expect(handlers.size).toBe(2);
|
||||
unmount();
|
||||
expect(handlers.size).toBe(0);
|
||||
});
|
||||
|
||||
it("a rejected start removes its listener and returns the AWS error", async () => {
|
||||
const { result } = renderHook(() => useDeployRuns());
|
||||
let err: unknown;
|
||||
await act(async () => {
|
||||
err = await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => {
|
||||
throw { kind: "other", message: "A deployment is already running" };
|
||||
});
|
||||
});
|
||||
expect(err).toEqual({ kind: "other", message: "A deployment is already running" });
|
||||
expect(handlers.size).toBe(0);
|
||||
expect(result.current.runs).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { cancelAwsDeployment } from "../../../lib/tauri-commands";
|
||||
import { awsErrorMessage, toAwsError } from "../../../lib/aws-error";
|
||||
import {
|
||||
AWS_DEPLOY_PROGRESS_EVENT,
|
||||
type AwsError,
|
||||
type DeployProgress as ProgressPayload,
|
||||
type TofuEvent,
|
||||
type TofuOutcome,
|
||||
} from "../../../lib/types";
|
||||
import Button from "../../ui/Button";
|
||||
import Modal from "../../ui/Modal";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
|
||||
/** One OpenTofu run this view is showing: a start, or a follow of one already running. */
|
||||
export interface DeployRun {
|
||||
stateKey: string;
|
||||
region: string;
|
||||
label: string;
|
||||
/** Null until the starting command resolves. */
|
||||
opId: string | null;
|
||||
events: TofuEvent[];
|
||||
done: boolean;
|
||||
cancellable: boolean;
|
||||
result: TofuOutcome | null;
|
||||
}
|
||||
|
||||
type Subscription = { unlisten: (() => void) | null };
|
||||
|
||||
/** Runs are tracked per region and state key: the same key exists in every region. */
|
||||
export const runKey = (region: string, stateKey: string) => `${region}:${stateKey}`;
|
||||
|
||||
/**
|
||||
* Runs keyed by `runKey(region, stateKey)`. `start` subscribes to `aws-deploy-progress` before
|
||||
* invoking, keeps every event for the state key it started until the op id
|
||||
* is known (the backend may emit just before the invoke resolves), then keeps
|
||||
* only that op's events. Resolves to the command's error, or null once started.
|
||||
*/
|
||||
export function useDeployRuns(
|
||||
onDone?: (run: { stateKey: string; region: string; result: TofuOutcome | null }) => void,
|
||||
) {
|
||||
const [runs, setRuns] = useState<Record<string, DeployRun>>({});
|
||||
const subs = useRef(new Map<string, Subscription>());
|
||||
const mounted = useRef(true);
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
const all = subs.current;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
for (const s of all.values()) s.unlisten?.();
|
||||
all.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (stateKey: string, region: string, label: string, run: () => Promise<string>): Promise<AwsError | null> => {
|
||||
const k = runKey(region, stateKey);
|
||||
subs.current.get(k)?.unlisten?.();
|
||||
const sub: Subscription = { unlisten: null };
|
||||
subs.current.set(k, sub);
|
||||
const current = () => mounted.current && subs.current.get(k) === sub;
|
||||
const stop = () => {
|
||||
sub.unlisten?.();
|
||||
sub.unlisten = null;
|
||||
if (subs.current.get(k) === sub) subs.current.delete(k);
|
||||
};
|
||||
|
||||
let opId: string | null = null;
|
||||
const early: ProgressPayload[] = [];
|
||||
const apply = (p: ProgressPayload) => {
|
||||
setRuns((prev) => {
|
||||
const r = prev[k];
|
||||
if (!r || r.opId !== p.op_id || r.done) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[k]: {
|
||||
...r,
|
||||
events: p.events.length ? [...r.events, ...p.events] : r.events,
|
||||
done: p.done,
|
||||
cancellable: p.done ? false : p.cancellable,
|
||||
result: p.result ?? r.result,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (p.done) {
|
||||
stop();
|
||||
onDoneRef.current?.({ stateKey, region, result: p.result ?? null });
|
||||
}
|
||||
};
|
||||
|
||||
setRuns((prev) => ({
|
||||
...prev,
|
||||
[k]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null },
|
||||
}));
|
||||
|
||||
const unlisten = await listen<ProgressPayload>(AWS_DEPLOY_PROGRESS_EVENT, ({ payload }) => {
|
||||
if (!current() || payload.state_key !== stateKey) return;
|
||||
if (opId === null) early.push(payload);
|
||||
else if (payload.op_id === opId) apply(payload);
|
||||
});
|
||||
if (!current()) {
|
||||
unlisten();
|
||||
return null;
|
||||
}
|
||||
sub.unlisten = unlisten;
|
||||
|
||||
try {
|
||||
opId = await run();
|
||||
} catch (e) {
|
||||
stop();
|
||||
if (mounted.current) {
|
||||
setRuns((prev) => {
|
||||
const { [k]: _gone, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
return toAwsError(e);
|
||||
}
|
||||
if (!current()) return null;
|
||||
const id = opId;
|
||||
setRuns((prev) => (prev[k] ? { ...prev, [k]: { ...prev[k], opId: id } } : prev));
|
||||
for (const p of early.splice(0)) if (p.op_id === id) apply(p);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** `key` is a `runKey`. */
|
||||
const dismiss = useCallback((key: string) => {
|
||||
setRuns((prev) => {
|
||||
if (!prev[key]?.done) return prev;
|
||||
const { [key]: _gone, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { runs, start, dismiss };
|
||||
}
|
||||
|
||||
function EventLine({ e }: { e: TofuEvent }) {
|
||||
const isError = e.level === "error";
|
||||
return (
|
||||
<li className="text-xs leading-snug">
|
||||
{(e.resource || e.action) && (
|
||||
<span className="mr-1.5">
|
||||
{e.resource && <span className="font-mono text-[var(--text-primary)]">{e.resource}</span>}
|
||||
{e.action && <span className="ml-1 text-[var(--text-secondary)]">{e.action}</span>}
|
||||
</span>
|
||||
)}
|
||||
{isError ? (
|
||||
<>
|
||||
<span className="font-semibold text-[var(--error)]">Error: </span>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-[var(--error)]">{e.message}</pre>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--text-secondary)]">{e.message}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** A run's events in order, its final result, and Cancel (after a confirmation). */
|
||||
export default function DeployProgress({
|
||||
run,
|
||||
connectionId,
|
||||
onDismiss,
|
||||
}: {
|
||||
run: DeployRun;
|
||||
connectionId: string;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelError, setCancelError] = useState<AwsError | null>(null);
|
||||
const listRef = useRef<HTMLOListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [run.events.length]);
|
||||
|
||||
const cancel = async () => {
|
||||
setCancelling(true);
|
||||
setCancelError(null);
|
||||
try {
|
||||
await cancelAwsDeployment(connectionId, run.region, run.stateKey);
|
||||
setConfirming(false);
|
||||
} catch (e) {
|
||||
setCancelError(toAwsError(e));
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const status = run.done
|
||||
? null
|
||||
: run.opId === null
|
||||
? { tone: "busy" as const, label: "Starting…" }
|
||||
: { tone: "busy" as const, label: "Running" };
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={`${run.label} progress`}
|
||||
className="space-y-1.5 border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 bg-[var(--bg-primary)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-[var(--text-primary)]">{run.label}</span>
|
||||
{status && <StatusIndicator tone={status.tone} label={status.label} />}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{!run.done && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={!run.cancellable}
|
||||
title={run.cancellable ? undefined : "This run can't be cancelled from here"}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
Cancel deployment
|
||||
</Button>
|
||||
)}
|
||||
{run.done && onDismiss && (
|
||||
<Button size="sm" variant="ghost" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{run.events.length > 0 && (
|
||||
<ol ref={listRef} className="max-h-64 overflow-y-auto space-y-0.5">
|
||||
{run.events.map((e, i) => (
|
||||
<EventLine key={i} e={e} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{run.done && run.result && (
|
||||
<p role="status" className="text-xs">
|
||||
{run.result.ok ? (
|
||||
<StatusIndicator tone="ok" label={`Finished${run.result.summary ? `: ${run.result.summary}` : ""}`} />
|
||||
) : (
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label={`Failed (exit ${run.result.exit_code})${run.result.summary ? `: ${run.result.summary}` : ""}`}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{cancelError && !confirming && (
|
||||
<p role="alert" className="text-xs text-[var(--error)]">
|
||||
{awsErrorMessage(cancelError)}
|
||||
</p>
|
||||
)}
|
||||
{confirming && (
|
||||
<Modal
|
||||
title="Cancel this deployment?"
|
||||
onClose={() => setConfirming(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" onClick={() => setConfirming(false)}>
|
||||
Keep running
|
||||
</Button>
|
||||
<Button size="md" variant="danger" onClick={cancel} disabled={cancelling}>
|
||||
Cancel deployment
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
<p>
|
||||
OpenTofu stops gracefully and saves its state. Anything it already created stays; Repair or Update
|
||||
finishes the job later.
|
||||
</p>
|
||||
{cancelError && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{awsErrorMessage(cancelError)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import EnvironmentsPanel from "./EnvironmentsPanel";
|
||||
import type { DeployProgress, EnvironmentScan } from "../../../lib/types";
|
||||
import { conn, env, progress, scan, tofuEvent } from "./testkit";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
|
||||
}));
|
||||
|
||||
type Handler = (e: { payload: DeployProgress }) => void;
|
||||
const handlers = new Set<Handler>();
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_name: string, handler: Handler) => {
|
||||
handlers.add(handler);
|
||||
return () => handlers.delete(handler);
|
||||
}),
|
||||
}));
|
||||
function emit(p: DeployProgress) {
|
||||
act(() => {
|
||||
for (const h of [...handlers]) h({ payload: p });
|
||||
});
|
||||
}
|
||||
|
||||
/** Mock the backend: `detect` answers detection; `rest` answers everything else. */
|
||||
function backend(detect: () => EnvironmentScan, rest: (cmd: string, args: unknown) => unknown = () => {
|
||||
throw new Error("unexpected");
|
||||
}) {
|
||||
invoke.mockImplementation(async (cmd: string, args: unknown) => {
|
||||
if (cmd === "detect_aws_environments") return detect();
|
||||
return rest(cmd, args);
|
||||
});
|
||||
}
|
||||
|
||||
const detectCalls = () => invoke.mock.calls.filter((c) => c[0] === "detect_aws_environments").length;
|
||||
|
||||
async function open() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Environments" }));
|
||||
await waitFor(() => expect(detectCalls()).toBeGreaterThan(0));
|
||||
}
|
||||
|
||||
describe("EnvironmentsPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
handlers.clear();
|
||||
});
|
||||
|
||||
it("detects in the connection's default region when opened, and again on a region change", async () => {
|
||||
backend(() => scan());
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
expect(detectCalls()).toBe(0);
|
||||
await open();
|
||||
expect(invoke).toHaveBeenCalledWith("detect_aws_environments", { connectionId: "h1", region: "eu-west-1" });
|
||||
fireEvent.change(screen.getByLabelText("Region"), { target: { value: "us-east-1" } });
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("detect_aws_environments", { connectionId: "h1", region: "us-east-1" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Detect again" }));
|
||||
await waitFor(() => expect(detectCalls()).toBe(3));
|
||||
});
|
||||
|
||||
it("shows deploy as primary when nothing is found and the user is an admin", async () => {
|
||||
backend(() => scan());
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const deploy = await screen.findByRole("button", { name: "Deploy a new environment" });
|
||||
expect(deploy.className).toContain("--accent-emphasis");
|
||||
expect(screen.getByText(/No Triple-C environment in eu-west-1/)).toBeInTheDocument();
|
||||
fireEvent.click(deploy);
|
||||
expect(screen.getByRole("dialog", { name: "Deploy a new environment" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deploy is secondary when environments exist", async () => {
|
||||
backend(() => scan({ environments: [env({ onboarding: { state: "joined" }, actions: [] })] }));
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const deploy = await screen.findByRole("button", { name: "Deploy a new environment" });
|
||||
expect(deploy.className).not.toContain("--accent-emphasis");
|
||||
expect(within(screen.getByTestId("env-dev")).getByText("Joined")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers an admin bundle instead of deploy for a workspace user with nothing found", async () => {
|
||||
backend(
|
||||
() => scan(),
|
||||
(cmd) => {
|
||||
if (cmd === "aws_policy_document") return [{ name: "environment-admin.json", json: "{}" }];
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="workspace_user" />);
|
||||
await open();
|
||||
expect(await screen.findByText(/needs an environment admin/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show the environment-admin policies" }));
|
||||
expect(await screen.findByText("environment-admin.json")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "environment_admin" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Request an environment from an admin…" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByRole("button", { name: "Save bundle to folder…" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats a connection with no permission report as not an admin", async () => {
|
||||
backend(() => scan());
|
||||
render(<EnvironmentsPanel connection={conn} />);
|
||||
await open();
|
||||
expect(await screen.findByRole("button", { name: "Request an environment from an admin…" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull();
|
||||
});
|
||||
|
||||
it("an incomplete environment offers repair and destroy, destroy only after typing its name", async () => {
|
||||
backend(
|
||||
() => scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null, template_version: null })] }),
|
||||
(cmd) => {
|
||||
if (cmd === "destroy_incomplete_aws_environment") return "op9";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const row = await screen.findByTestId("env-dev");
|
||||
expect(within(row).getByText("Incomplete")).toBeInTheDocument();
|
||||
expect(within(row).getByRole("button", { name: "Repair dev" })).toBeInTheDocument();
|
||||
|
||||
// Repair without a record asks for the network and warns about a change.
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Repair dev" }));
|
||||
const repair = screen.getByRole("dialog", { name: "Repair dev" });
|
||||
expect(within(repair).getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(within(repair).getByText(/different network than the one it was deployed with replaces/)).toBeInTheDocument();
|
||||
fireEvent.click(within(repair).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Destroy dev" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Destroy dev" });
|
||||
expect(within(dialog).getByText(/deployment is incomplete \(no valid environment record\)/)).toBeInTheDocument();
|
||||
const confirm = within(dialog).getByRole("button", { name: "Destroy" });
|
||||
expect(confirm).toBeDisabled();
|
||||
fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "de" } });
|
||||
expect(confirm).toBeDisabled();
|
||||
fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "dev" } });
|
||||
expect(confirm).toBeEnabled();
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("destroy_incomplete_aws_environment", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
name: "dev",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a stale lock offers unlock with its owner and age", async () => {
|
||||
const lock = { who: "alice@laptop", created: "2026-11-14T07:00:00Z", operation: "OperationTypeApply", age_secs: 3 * 3600 + 300, local_process: false };
|
||||
backend(
|
||||
() => scan({ environments: [env({ state: { state: "incomplete", lock }, actions: ["repair", "unlock"], onboarding: null })] }),
|
||||
(cmd) => {
|
||||
if (cmd === "unlock_aws_state") return undefined;
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const row = await screen.findByTestId("env-dev");
|
||||
expect(within(row).getByText(/Locked by alice@laptop/)).toBeInTheDocument();
|
||||
expect(within(row).getByText(/3 h 5 min ago/)).toBeInTheDocument();
|
||||
expect(within(row).queryByRole("button", { name: "Destroy dev" })).toBeNull();
|
||||
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Unlock dev" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Unlock dev" });
|
||||
expect(within(dialog).getByText(/alice@laptop/)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/3 h 5 min ago/)).toBeInTheDocument();
|
||||
const confirm = within(dialog).getByRole("button", { name: "Unlock" });
|
||||
expect(confirm).toBeDisabled();
|
||||
fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "dev" } });
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("unlock_aws_state", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
stateKey: "environments/dev/terraform.tfstate",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(detectCalls()).toBe(2));
|
||||
});
|
||||
|
||||
it("shows the server's refusal when a lock is still held", async () => {
|
||||
backend(
|
||||
() => scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], onboarding: null })] }),
|
||||
(cmd) => {
|
||||
if (cmd === "repair_aws_environment") throw { kind: "other", message: "The state is locked; Unlock first" };
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
// With a record, Repair runs straight away on the recorded network.
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Repair dev" }));
|
||||
expect(await screen.findByText("The state is locked; Unlock first")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("repair_aws_environment", { connectionId: "h1", region: "eu-west-1", name: "dev", network: null });
|
||||
});
|
||||
|
||||
it("join onboards an admin and shows progress until done, then re-detects", async () => {
|
||||
const key = "users/dev/abc123abc123/terraform.tfstate";
|
||||
let detects = 0;
|
||||
backend(
|
||||
() => {
|
||||
detects += 1;
|
||||
return scan({ environments: [env({ onboarding: detects > 1 ? { state: "joined" } : { state: "not_joined" } })] });
|
||||
},
|
||||
async (cmd) => {
|
||||
if (cmd === "onboard_aws_user") {
|
||||
// The backend can emit before the invoke resolves: an old follower's
|
||||
// line (another op) and this run's first line.
|
||||
emit(progress({ op_id: "old", state_key: key, events: [tofuEvent("stale line")] }));
|
||||
emit(progress({ op_id: "op1", state_key: key, events: [tofuEvent("Planning")], cancellable: false }));
|
||||
return "op1";
|
||||
}
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Join dev" }));
|
||||
expect(await screen.findByText("Planning")).toBeInTheDocument();
|
||||
expect(screen.queryByText("stale line")).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith("onboard_aws_user", { connectionId: "h1", region: "eu-west-1", environment: "dev" });
|
||||
expect(screen.getByRole("button", { name: "Cancel deployment" })).toBeDisabled();
|
||||
|
||||
emit(progress({ op_id: "op1", state_key: key, events: [tofuEvent("Creating...", { resource: "aws_iam_role.task", action: "create" })] }));
|
||||
expect(screen.getByText("aws_iam_role.task")).toBeInTheDocument();
|
||||
emit(progress({ op_id: "other", state_key: "environments/x/terraform.tfstate", events: [tofuEvent("unrelated")] }));
|
||||
expect(screen.queryByText("unrelated")).toBeNull();
|
||||
expect(detectCalls()).toBe(1);
|
||||
|
||||
emit(progress({ op_id: "op1", state_key: key, events: [], done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete! Resources: 4 added" } }));
|
||||
expect(await screen.findByText(/Apply complete! Resources: 4 added/)).toBeInTheDocument();
|
||||
await waitFor(() => expect(detectCalls()).toBe(2));
|
||||
expect(await within(screen.getByTestId("env-dev")).findByText("Joined")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a workspace user's join opens the onboarding bundle and waits for an admin", async () => {
|
||||
backend(
|
||||
() => scan({ environments: [env()] }),
|
||||
(cmd) => {
|
||||
if (cmd === "save_aws_admin_bundle") return "/home/me/onboard-dev";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="workspace_user" />);
|
||||
await open();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Join dev" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
// Opening the bundle is not a request yet.
|
||||
expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" }));
|
||||
expect(await within(dialog).findByText("/home/me/onboard-dev")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
kind: "user_onboard",
|
||||
environment: "dev",
|
||||
ownerId: "AIDAEXAMPLEEXAMPLE01",
|
||||
network: null,
|
||||
}),
|
||||
);
|
||||
expect(invoke.mock.calls.some((c) => c[0] === "onboard_aws_user")).toBe(false);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
|
||||
expect(within(screen.getByTestId("env-dev")).getByText("Waiting for an admin to onboard you")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("follows a deployment running on this machine when opened, and cancels after confirming", async () => {
|
||||
const lock = { who: "me@here", created: "x", operation: "OperationTypeApply", age_secs: 60, local_process: true };
|
||||
backend(
|
||||
() => scan({ environments: [env({ state: { state: "deploying", lock }, actions: ["follow"], onboarding: null })] }),
|
||||
async (cmd) => {
|
||||
if (cmd === "follow_aws_deployment") {
|
||||
emit(progress({ op_id: "f1", events: [tofuEvent("replayed line")] }));
|
||||
return "f1";
|
||||
}
|
||||
if (cmd === "cancel_aws_deployment") return undefined;
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
expect(await screen.findByText("replayed line")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("follow_aws_deployment", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
stateKey: "environments/dev/terraform.tfstate",
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel deployment" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Cancel this deployment?" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel deployment" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("cancel_aws_deployment", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
stateKey: "environments/dev/terraform.tfstate",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a failed run's errors in full", async () => {
|
||||
backend(
|
||||
() => scan({ environments: [env({ state: { state: "update_available" }, actions: ["join", "update"], onboarding: { state: "joined" } })] }),
|
||||
async (cmd) => {
|
||||
if (cmd === "update_aws_environment") return "u1";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Update dev" }));
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith("update_aws_environment", { connectionId: "h1", region: "eu-west-1", name: "dev" }));
|
||||
const long = "Error: creating IAM Role (triple-c-dev-task): AccessDenied: User is not authorized to perform iam:CreateRole\n with aws_iam_role.task";
|
||||
emit(progress({ op_id: "u1", events: [tofuEvent(long, { level: "error", kind: "diagnostic" })] }));
|
||||
emit(progress({ op_id: "u1", events: [], done: true, cancellable: false, result: { ok: false, exit_code: 1, summary: "Apply failed" } }));
|
||||
expect(await screen.findByText(/Failed \(exit 1\): Apply failed/)).toBeInTheDocument();
|
||||
expect(screen.getByText((_, el) => el?.textContent === long && el.tagName === "PRE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a workspace user who closes the bundle without saving is not waiting", async () => {
|
||||
backend(() => scan({ environments: [env()] }));
|
||||
render(<EnvironmentsPanel connection={conn} tier="workspace_user" />);
|
||||
await open();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Join dev" }));
|
||||
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull();
|
||||
expect(within(screen.getByTestId("env-dev")).getByRole("button", { name: "Join dev" })).toHaveTextContent("Join");
|
||||
});
|
||||
|
||||
it("never offers Destroy on a ready environment", async () => {
|
||||
backend(() => scan({ environments: [env({ actions: ["join", "update"] })] }));
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const row = await screen.findByTestId("env-dev");
|
||||
expect(within(row).getByText("Ready")).toBeInTheDocument();
|
||||
expect(within(row).queryByRole("button", { name: "Destroy dev" })).toBeNull();
|
||||
expect(within(row).queryByRole("button", { name: "Unlock dev" })).toBeNull();
|
||||
});
|
||||
|
||||
it("after a region change, the old region's rows can't be acted on, and actions use the new region", async () => {
|
||||
let resolveUs: (s: EnvironmentScan) => void = () => {};
|
||||
invoke.mockImplementation(async (cmd: string, args: { region?: string }) => {
|
||||
if (cmd === "detect_aws_environments") {
|
||||
if (args.region === "us-east-1") return new Promise<EnvironmentScan>((r) => (resolveUs = r));
|
||||
return scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null })] });
|
||||
}
|
||||
if (cmd === "update_aws_environment") return "u1";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
});
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
expect(await screen.findByRole("button", { name: "Destroy dev" })).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText("Region"), { target: { value: "us-east-1" } });
|
||||
// Before the new scan answers, nothing from eu-west-1 can be clicked.
|
||||
expect(screen.queryByTestId("env-dev")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Destroy dev" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull();
|
||||
await act(async () =>
|
||||
resolveUs(scan({ region: "us-east-1", environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] })),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Update dev" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("update_aws_environment", { connectionId: "h1", region: "us-east-1", name: "dev" }),
|
||||
);
|
||||
expect(await screen.findByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables row actions while detection is in flight", async () => {
|
||||
let calls = 0;
|
||||
let release: () => void = () => {};
|
||||
invoke.mockImplementation(async (cmd: string) => {
|
||||
if (cmd !== "detect_aws_environments") throw new Error(`unexpected ${cmd}`);
|
||||
calls += 1;
|
||||
const found = scan({ environments: [env({ actions: ["join", "update"], onboarding: { state: "joined" } })] });
|
||||
if (calls === 1) return found;
|
||||
return new Promise<EnvironmentScan>((r) => (release = () => r(found)));
|
||||
});
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
expect(await screen.findByRole("button", { name: "Update dev" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Detect again" }));
|
||||
expect(screen.getByRole("button", { name: "Update dev" })).toBeDisabled();
|
||||
await act(async () => release());
|
||||
expect(screen.getByRole("button", { name: "Update dev" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("a done that arrives before the invoke resolves is shown and re-detects", async () => {
|
||||
backend(
|
||||
() => scan({ environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] }),
|
||||
async (cmd) => {
|
||||
if (cmd === "update_aws_environment") {
|
||||
emit(progress({ op_id: "u1", events: [tofuEvent("Refreshing state")] }));
|
||||
emit(progress({ op_id: "u1", events: [], done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "No changes." } }));
|
||||
return "u1";
|
||||
}
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Update dev" }));
|
||||
expect(await screen.findByText(/Finished: No changes\./)).toBeInTheDocument();
|
||||
expect(screen.getByText("Refreshing state")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Cancel deployment" })).toBeNull();
|
||||
await waitFor(() => expect(detectCalls()).toBe(2));
|
||||
expect(handlers.size).toBe(0);
|
||||
});
|
||||
|
||||
it("routes a detection failure through the AWS error copy", async () => {
|
||||
backend(() => {
|
||||
throw { kind: "state_bucket_taken", bucket: "triple-c-tfstate-123456789012-eu-west-1" };
|
||||
});
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
expect(
|
||||
await screen.findByText("The state bucket name triple-c-tfstate-123456789012-eu-west-1 is already taken by another AWS account."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it("explains an environment managed elsewhere and an incomplete one", async () => {
|
||||
backend(() =>
|
||||
scan({
|
||||
environments: [
|
||||
env({ name: "shared", state: { state: "externally_managed" }, actions: ["join"] }),
|
||||
env({ name: "half", state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const shared = await screen.findByTestId("env-shared");
|
||||
expect(within(shared).getByText("Managed outside this account's Triple-C state")).toBeInTheDocument();
|
||||
expect(within(shared).getByText(/can't be updated from here/)).toBeInTheDocument();
|
||||
const half = screen.getByTestId("env-half");
|
||||
expect(within(half).getByText(/The last change didn't finish/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Repair and Destroy while a lock is held, saying Unlock first", async () => {
|
||||
const lock = { who: "bob@desk", created: "x", operation: "OperationTypeApply", age_secs: 3 * 3600, local_process: false };
|
||||
backend(() =>
|
||||
scan({ environments: [env({ state: { state: "incomplete", lock }, actions: ["repair", "destroy", "unlock"], info: null, onboarding: null })] }),
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const row = await screen.findByTestId("env-dev");
|
||||
expect(within(row).getByRole("button", { name: "Repair dev" })).toBeDisabled();
|
||||
expect(within(row).getByRole("button", { name: "Destroy dev" })).toBeDisabled();
|
||||
expect(within(row).getByRole("button", { name: "Unlock dev" })).toBeEnabled();
|
||||
expect(within(row).getByText("Unlock first")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("an incomplete onboarding keeps Join and offers its own Unlock", async () => {
|
||||
const lock = { who: "me@old", created: "x", operation: "OperationTypeApply", age_secs: 3 * 3600, local_process: false };
|
||||
backend(
|
||||
() =>
|
||||
scan({
|
||||
environments: [env({ onboarding: { state: "incomplete", lock }, onboarding_actions: ["unlock"] })],
|
||||
}),
|
||||
(cmd) => {
|
||||
if (cmd === "unlock_aws_state") return undefined;
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const row = await screen.findByTestId("env-dev");
|
||||
expect(within(row).getByText("Onboarding incomplete")).toBeInTheDocument();
|
||||
expect(within(row).getByText(/Your last onboarding didn't finish/)).toBeInTheDocument();
|
||||
expect(within(row).getByRole("button", { name: "Join dev" })).toBeDisabled();
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Unlock my onboarding to dev" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Unlock your onboarding to dev" });
|
||||
expect(within(dialog).getByText(/me@old/)).toBeInTheDocument();
|
||||
fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "dev" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("unlock_aws_state", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
stateKey: "users/dev/abc123abc123/terraform.tfstate",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("pushes a saved state back from the environment and the onboarding rows", async () => {
|
||||
backend(
|
||||
() =>
|
||||
scan({
|
||||
environments: [
|
||||
env({ name: "a", state: { state: "incomplete", lock: null }, actions: ["push_state"], info: null, onboarding: null }),
|
||||
env({ name: "b", onboarding: { state: "incomplete", lock: null }, onboarding_actions: ["push_state"] }),
|
||||
],
|
||||
}),
|
||||
(cmd) => {
|
||||
if (cmd === "push_aws_saved_state") return "p1";
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
const a = await screen.findByTestId("env-a");
|
||||
expect(within(a).getByText(/couldn't save its state/)).toBeInTheDocument();
|
||||
fireEvent.click(within(a).getByRole("button", { name: "Push saved state of a" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("push_aws_saved_state", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
stateKey: "environments/a/terraform.tfstate",
|
||||
}),
|
||||
);
|
||||
const b = screen.getByTestId("env-b");
|
||||
// Join would only be refused until the saved state is back.
|
||||
expect(within(b).getByRole("button", { name: "Join b" })).toBeDisabled();
|
||||
fireEvent.click(within(b).getByRole("button", { name: "Push saved state of my onboarding to b" }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith("push_aws_saved_state", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
stateKey: "users/b/abc123abc123/terraform.tfstate",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("follows an onboarding running on this machine", async () => {
|
||||
backend(
|
||||
() =>
|
||||
scan({
|
||||
environments: [env({ onboarding: { state: "in_progress", lock: null }, onboarding_actions: ["follow"] })],
|
||||
}),
|
||||
async (cmd) => {
|
||||
if (cmd === "follow_aws_deployment") {
|
||||
emit(progress({ op_id: "f2", state_key: "users/dev/abc123abc123/terraform.tfstate", events: [tofuEvent("joining line")] }));
|
||||
return "f2";
|
||||
}
|
||||
throw new Error(`unexpected ${cmd}`);
|
||||
},
|
||||
);
|
||||
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
|
||||
await open();
|
||||
expect(await screen.findByText("joining line")).toBeInTheDocument();
|
||||
expect(invoke).toHaveBeenCalledWith("follow_aws_deployment", {
|
||||
connectionId: "h1",
|
||||
region: "eu-west-1",
|
||||
stateKey: "users/dev/abc123abc123/terraform.tfstate",
|
||||
});
|
||||
expect(within(screen.getByTestId("env-dev")).getByRole("button", { name: "Follow my onboarding to dev" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
deployAwsEnvironment,
|
||||
destroyIncompleteAwsEnvironment,
|
||||
detectAwsEnvironments,
|
||||
followAwsDeployment,
|
||||
onboardAwsUser,
|
||||
pushAwsSavedState,
|
||||
repairAwsEnvironment,
|
||||
unlockAwsState,
|
||||
updateAwsEnvironment,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { awsErrorMessage, toAwsError } from "../../../lib/aws-error";
|
||||
import type {
|
||||
AwsError,
|
||||
DetectedEnvironment,
|
||||
EnvironmentScan,
|
||||
HostConnection,
|
||||
LockInfo,
|
||||
ModuleKind,
|
||||
NetworkChoice,
|
||||
PermissionSummary,
|
||||
PolicyFile,
|
||||
} from "../../../lib/types";
|
||||
import Button from "../../ui/Button";
|
||||
import Field, { inputClass, selectClass } from "../../ui/Field";
|
||||
import Modal from "../../ui/Modal";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import AdminBundleModal from "./AdminBundleModal";
|
||||
import DeployEnvironmentModal from "./DeployEnvironmentModal";
|
||||
import DeployProgress, { runKey, useDeployRuns, type DeployRun } from "./DeployProgress";
|
||||
import PolicyFiles from "./PolicyFiles";
|
||||
import {
|
||||
COMMON_REGIONS,
|
||||
environmentStateKey,
|
||||
formatAge,
|
||||
lockLine,
|
||||
onboardingStateKey,
|
||||
stateLock,
|
||||
stateStatus,
|
||||
} from "./environmentFormat";
|
||||
|
||||
/** A typed confirmation: the action runs only once the environment's name is typed. */
|
||||
function TypedConfirmModal({
|
||||
title,
|
||||
name,
|
||||
confirmLabel,
|
||||
children,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
name: string;
|
||||
confirmLabel: string;
|
||||
children: ReactNode;
|
||||
/** Resolves to the command's error, or null when done. */
|
||||
onConfirm: () => Promise<AwsError | null>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<AwsError | null>(null);
|
||||
const confirm = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const err = await onConfirm();
|
||||
setBusy(false);
|
||||
if (err) setError(err);
|
||||
else onClose();
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="danger" onClick={confirm} disabled={typed !== name || busy}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{children}
|
||||
<Field label={`Type ${name} to confirm`}>
|
||||
{(id) => (
|
||||
<input id={id} className={inputClass} autoComplete="off" value={typed} onChange={(e) => setTyped(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{awsErrorMessage(error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type Bundle = { kind: ModuleKind; region: string; environment?: string; ownerId?: string };
|
||||
/** A row-level key: the same environment name can exist in every region. */
|
||||
const rowKey = (region: string, name: string) => `${region}:${name}`;
|
||||
|
||||
/** The environment a run's state key belongs to, if any. */
|
||||
function runEnvironment(stateKey: string): string | null {
|
||||
const m = /^(?:environments|users)\/([^/]+)\//.exec(stateKey);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The environments a connection can reach in a region (spec §2): detection,
|
||||
* each environment's state and actions, and deploy/join for this caller's tier.
|
||||
*/
|
||||
export default function EnvironmentsPanel({
|
||||
connection,
|
||||
tier,
|
||||
}: {
|
||||
connection: HostConnection;
|
||||
/** The connection's last permission report; absent means unknown, treated as not an admin. */
|
||||
tier?: PermissionSummary;
|
||||
}) {
|
||||
const isAdmin = tier === "environment_admin";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [region, setRegion] = useState(connection.kind.default_region);
|
||||
const [scan, setScan] = useState<EnvironmentScan | null>(null);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectError, setDetectError] = useState<AwsError | null>(null);
|
||||
const [rowErrors, setRowErrors] = useState<Record<string, AwsError>>({});
|
||||
const [pending, setPending] = useState<Record<string, boolean>>({});
|
||||
const [waiting, setWaiting] = useState<Set<string>>(new Set());
|
||||
const [deployOpen, setDeployOpen] = useState(false);
|
||||
const [repairOf, setRepairOf] = useState<string | null>(null);
|
||||
const [destroyOf, setDestroyOf] = useState<{ name: string; region: string } | null>(null);
|
||||
/** An Unlock waiting for its typed confirmation: an environment's lock or the caller's onboarding's. */
|
||||
const [unlockOf, setUnlockOf] = useState<{
|
||||
name: string;
|
||||
region: string;
|
||||
stateKey: string;
|
||||
lock: LockInfo | null;
|
||||
onboarding: boolean;
|
||||
} | null>(null);
|
||||
const [bundle, setBundle] = useState<Bundle | null>(null);
|
||||
const [copyNote, setCopyNote] = useState<string | null>(null);
|
||||
const request = useRef(0);
|
||||
const followed = useRef(new Set<string>());
|
||||
|
||||
const detect = useCallback(async () => {
|
||||
const id = ++request.current;
|
||||
setDetecting(true);
|
||||
setDetectError(null);
|
||||
try {
|
||||
const found = await detectAwsEnvironments(connection.id, region);
|
||||
if (id === request.current) setScan(found);
|
||||
} catch (e) {
|
||||
if (id === request.current) {
|
||||
setScan(null);
|
||||
setDetectError(toAwsError(e));
|
||||
}
|
||||
} finally {
|
||||
if (id === request.current) setDetecting(false);
|
||||
}
|
||||
}, [connection.id, region]);
|
||||
|
||||
// A finished run (its result line shown) re-detects.
|
||||
const detectRef = useRef(detect);
|
||||
detectRef.current = detect;
|
||||
const { runs, start, dismiss } = useDeployRuns(() => void detectRef.current());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void detect();
|
||||
}, [open, detect]);
|
||||
|
||||
/** Start a run in `runRegion` (always the region of the scan the action came from). */
|
||||
const launch = useCallback(
|
||||
async (runRegion: string, envName: string, stateKey: string, label: string, run: () => Promise<string>) => {
|
||||
const rk = rowKey(runRegion, envName);
|
||||
setRowErrors(({ [rk]: _gone, ...rest }) => rest);
|
||||
setPending((p) => ({ ...p, [rk]: true }));
|
||||
const err = await start(stateKey, runRegion, label, run);
|
||||
setPending(({ [rk]: _gone, ...rest }) => rest);
|
||||
if (err) setRowErrors((r) => ({ ...r, [rk]: err }));
|
||||
return err;
|
||||
},
|
||||
[start],
|
||||
);
|
||||
|
||||
// Reattach to a deploy (or the caller's onboarding) running on this machine;
|
||||
// a re-follow replays its whole log.
|
||||
useEffect(() => {
|
||||
if (!scan) return;
|
||||
const reattach = (name: string, key: string, label: string) => {
|
||||
const seen = runKey(scan.region, key);
|
||||
if (!runs[seen] && !followed.current.has(seen)) {
|
||||
followed.current.add(seen);
|
||||
void launch(scan.region, name, key, label, () => followAwsDeployment(connection.id, scan.region, key));
|
||||
}
|
||||
};
|
||||
for (const e of scan.environments) {
|
||||
if (e.actions.includes("follow")) reattach(e.name, environmentStateKey(e.name), `Deploy of ${e.name}`);
|
||||
if (e.onboarding_actions.includes("follow")) {
|
||||
reattach(e.name, onboardingStateKey(e.name, scan.owner_key), `Joining ${e.name}`);
|
||||
}
|
||||
}
|
||||
}, [scan, runs, launch, connection.id]);
|
||||
|
||||
const regions = COMMON_REGIONS.includes(connection.kind.default_region)
|
||||
? COMMON_REGIONS
|
||||
: [connection.kind.default_region, ...COMMON_REGIONS];
|
||||
|
||||
const copy = async (file: PolicyFile) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(file.json);
|
||||
setCopyNote(`${file.name} copied to clipboard.`);
|
||||
} catch (e) {
|
||||
setCopyNote(`Could not copy ${file.name}: ${awsErrorMessage(toAwsError(e))}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Rows (and every action on them) belong to the scan they came from. After a
|
||||
// region change the old scan's rows are hidden, and while any detection is in
|
||||
// flight their actions are disabled; actions always target `scan.region`.
|
||||
const current = scan && scan.region === region ? scan : null;
|
||||
const stale = detecting || !current;
|
||||
|
||||
const deploy = (runRegion: string, name: string, network: NetworkChoice) =>
|
||||
launch(runRegion, name, environmentStateKey(name), `Deploy of ${name}`, () =>
|
||||
deployAwsEnvironment(connection.id, runRegion, name, network),
|
||||
);
|
||||
const repair = (runRegion: string, name: string, network?: NetworkChoice) =>
|
||||
launch(runRegion, name, environmentStateKey(name), `Repair of ${name}`, () =>
|
||||
repairAwsEnvironment(connection.id, runRegion, name, network),
|
||||
);
|
||||
|
||||
const join = (found: EnvironmentScan, e: DetectedEnvironment) => {
|
||||
if (isAdmin) {
|
||||
void launch(found.region, e.name, onboardingStateKey(e.name, found.owner_key), `Joining ${e.name}`, () =>
|
||||
onboardAwsUser(connection.id, found.region, e.name),
|
||||
);
|
||||
} else {
|
||||
setBundle({ kind: "user_onboard", region: found.region, environment: e.name, ownerId: found.owner_id });
|
||||
}
|
||||
};
|
||||
|
||||
const rowRuns = (name: string) =>
|
||||
Object.values(runs).filter((r) => r.region === current?.region && runEnvironment(r.stateKey) === name);
|
||||
const shown = new Set(current?.environments.map((e) => e.name) ?? []);
|
||||
const looseRuns = Object.values(runs).filter(
|
||||
(r) => r.region !== current?.region || !shown.has(runEnvironment(r.stateKey) ?? ""),
|
||||
);
|
||||
const progress = (r: DeployRun) => (
|
||||
<DeployProgress
|
||||
key={runKey(r.region, r.stateKey)}
|
||||
run={r}
|
||||
connectionId={connection.id}
|
||||
onDismiss={() => dismiss(runKey(r.region, r.stateKey))}
|
||||
/>
|
||||
);
|
||||
|
||||
const envRow = (found: EnvironmentScan, e: DetectedEnvironment) => {
|
||||
const at = found.region;
|
||||
const status = stateStatus(e.state);
|
||||
const lock = stateLock(e.state);
|
||||
const has = (a: DetectedEnvironment["actions"][number]) => e.actions.includes(a);
|
||||
const mine = (a: DetectedEnvironment["actions"][number]) => e.onboarding_actions.includes(a);
|
||||
const onboarding = e.onboarding?.state;
|
||||
const myKey = onboardingStateKey(e.name, found.owner_key);
|
||||
const myLock =
|
||||
e.onboarding && (e.onboarding.state === "incomplete" || e.onboarding.state === "in_progress")
|
||||
? e.onboarding.lock
|
||||
: null;
|
||||
// Someone else's run holds the state: a re-apply or destroy would only
|
||||
// fail inside its run, so those wait for Unlock.
|
||||
const lockHeld = !!lock && !lock.local_process;
|
||||
// Join re-applies the onboarding: not under a held lock, nor before a
|
||||
// saved state is pushed back.
|
||||
const joinBlocked = (!!myLock && !myLock.local_process) || mine("push_state");
|
||||
const busy = stale || !!pending[rowKey(at, e.name)];
|
||||
const rowError = rowErrors[rowKey(at, e.name)];
|
||||
const isWaiting = waiting.has(rowKey(at, e.name));
|
||||
return (
|
||||
<li
|
||||
key={e.name}
|
||||
data-testid={`env-${e.name}`}
|
||||
className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 space-y-1.5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="min-w-0 space-y-0.5 text-xs">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[13px] font-medium text-[var(--text-primary)]">{e.name}</span>
|
||||
<StatusIndicator tone={status.tone} label={status.label} />
|
||||
{onboarding === "joined" && <StatusIndicator tone="ok" label="Joined" />}
|
||||
{onboarding === "in_progress" && <StatusIndicator tone="busy" label="Onboarding in progress" />}
|
||||
{onboarding === "incomplete" && <StatusIndicator tone="error" label="Onboarding incomplete" />}
|
||||
</div>
|
||||
<div className="text-[var(--text-secondary)]">
|
||||
{e.template_version !== null ? `Module version ${e.template_version}` : "Module version unknown"}
|
||||
{e.info && ` · ${e.info.network_mode} network`}
|
||||
</div>
|
||||
{lock && (
|
||||
<div className="text-[var(--text-secondary)]">
|
||||
{lockLine(lock)}
|
||||
{lock.local_process && " · running on this machine"}
|
||||
</div>
|
||||
)}
|
||||
{e.state.state === "unreadable_record" && (
|
||||
<div className="text-[var(--error)]">The environment's record can't be used: {e.state.reason}</div>
|
||||
)}
|
||||
{e.state.state === "externally_managed" && (
|
||||
<div className="text-[var(--text-secondary)]">
|
||||
Its OpenTofu state isn't in this region's Triple-C state bucket (deployed from a bundle with another
|
||||
bucket, or you can't read it), so it can't be updated from here.
|
||||
</div>
|
||||
)}
|
||||
{e.state.state === "incomplete" && (
|
||||
<div className="text-[var(--text-secondary)]">
|
||||
{lockHeld
|
||||
? "The last change didn't finish: its run stopped while holding the state lock. Unlock first, then Repair."
|
||||
: "The last change didn't finish: the deployment has state but no working environment record."}
|
||||
</div>
|
||||
)}
|
||||
{has("push_state") && (
|
||||
<div className="text-[var(--error)]">
|
||||
The last run on this machine couldn't save its state (OpenTofu kept it in errored.tfstate). Push it back
|
||||
before anything else runs here.
|
||||
</div>
|
||||
)}
|
||||
{onboarding === "incomplete" && (
|
||||
<div className="text-[var(--text-secondary)]">
|
||||
{myLock && !myLock.local_process
|
||||
? `Your last onboarding didn't finish and still holds its state lock (${lockLine(myLock)}). Unlock it, then Join again to finish.`
|
||||
: mine("push_state")
|
||||
? "Your last onboarding didn't finish and couldn't save its state; push the saved state back, then Join again."
|
||||
: "Your last onboarding didn't finish; Join again to finish it."}
|
||||
</div>
|
||||
)}
|
||||
{!isAdmin && isWaiting && onboarding !== "joined" && (
|
||||
<div className="text-[var(--text-secondary)]">Waiting for an admin to onboard you</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{has("join") && onboarding !== "joined" && onboarding !== "in_progress" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={busy || joinBlocked}
|
||||
aria-label={`Join ${e.name}`}
|
||||
onClick={() => join(found, e)}
|
||||
>
|
||||
{!isAdmin && isWaiting ? "Show onboarding bundle" : "Join"}
|
||||
</Button>
|
||||
)}
|
||||
{mine("follow") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
aria-label={`Follow my onboarding to ${e.name}`}
|
||||
onClick={() =>
|
||||
void launch(at, e.name, myKey, `Joining ${e.name}`, () => followAwsDeployment(connection.id, at, myKey))
|
||||
}
|
||||
>
|
||||
Follow
|
||||
</Button>
|
||||
)}
|
||||
{mine("push_state") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
aria-label={`Push saved state of my onboarding to ${e.name}`}
|
||||
onClick={() =>
|
||||
void launch(at, e.name, myKey, `Push of the saved state of joining ${e.name}`, () =>
|
||||
pushAwsSavedState(connection.id, at, myKey),
|
||||
)
|
||||
}
|
||||
>
|
||||
Push saved state
|
||||
</Button>
|
||||
)}
|
||||
{mine("unlock") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
aria-label={`Unlock my onboarding to ${e.name}`}
|
||||
onClick={() => setUnlockOf({ name: e.name, region: at, stateKey: myKey, lock: myLock, onboarding: true })}
|
||||
>
|
||||
Unlock onboarding
|
||||
</Button>
|
||||
)}
|
||||
{has("push_state") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
aria-label={`Push saved state of ${e.name}`}
|
||||
onClick={() => {
|
||||
const key = environmentStateKey(e.name);
|
||||
void launch(at, e.name, key, `Push of the saved state of ${e.name}`, () =>
|
||||
pushAwsSavedState(connection.id, at, key),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Push saved state
|
||||
</Button>
|
||||
)}
|
||||
{has("follow") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
aria-label={`Follow ${e.name}`}
|
||||
onClick={() => {
|
||||
const key = environmentStateKey(e.name);
|
||||
void launch(at, e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, at, key));
|
||||
}}
|
||||
>
|
||||
Follow
|
||||
</Button>
|
||||
)}
|
||||
{has("update") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
aria-label={`Update ${e.name}`}
|
||||
onClick={() =>
|
||||
void launch(at, e.name, environmentStateKey(e.name), `Update of ${e.name}`, () =>
|
||||
updateAwsEnvironment(connection.id, at, e.name),
|
||||
)
|
||||
}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{has("repair") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || lockHeld}
|
||||
aria-label={`Repair ${e.name}`}
|
||||
onClick={() => (e.info ? void repair(at, e.name) : setRepairOf(e.name))}
|
||||
>
|
||||
Repair
|
||||
</Button>
|
||||
)}
|
||||
{has("unlock") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
aria-label={`Unlock ${e.name}`}
|
||||
onClick={() =>
|
||||
setUnlockOf({ name: e.name, region: at, stateKey: environmentStateKey(e.name), lock, onboarding: false })
|
||||
}
|
||||
>
|
||||
Unlock
|
||||
</Button>
|
||||
)}
|
||||
{has("destroy") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={busy || lockHeld}
|
||||
aria-label={`Destroy ${e.name}`}
|
||||
onClick={() => setDestroyOf({ name: e.name, region: at })}
|
||||
>
|
||||
Destroy
|
||||
</Button>
|
||||
)}
|
||||
{lockHeld && (has("repair") || has("destroy")) && (
|
||||
<span className="self-center text-xs text-[var(--text-secondary)]">Unlock first</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rowError && (
|
||||
<p role="alert" className="text-xs text-[var(--error)] leading-snug">
|
||||
{awsErrorMessage(rowError)}
|
||||
</p>
|
||||
)}
|
||||
{rowRuns(e.name).map(progress)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const nothingFound = !!current && current.environments.length === 0;
|
||||
|
||||
return (
|
||||
<div className="pt-1.5 space-y-2">
|
||||
<Button size="sm" variant="ghost" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
|
||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||
Environments
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="space-y-2 pl-1">
|
||||
<div className="flex items-end gap-2 flex-wrap">
|
||||
<Field label="Region">
|
||||
{(id) => (
|
||||
<select id={id} className={selectClass} value={region} onChange={(e) => setRegion(e.target.value)}>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Button size="md" onClick={() => void detect()} disabled={detecting}>
|
||||
Detect again
|
||||
</Button>
|
||||
</div>
|
||||
{detecting && (
|
||||
<p role="status" className="text-xs text-[var(--text-secondary)]">
|
||||
Detecting…
|
||||
</p>
|
||||
)}
|
||||
{detectError && (
|
||||
<p role="alert" className="text-xs text-[var(--error)] leading-snug">
|
||||
{awsErrorMessage(detectError)}
|
||||
</p>
|
||||
)}
|
||||
{current && isAdmin && current.boundary === "no" && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
This account has no Triple-C permissions boundary yet; deploying creates it first.
|
||||
</p>
|
||||
)}
|
||||
{current && current.environments.length > 0 && (
|
||||
<ul className="space-y-1.5">{current.environments.map((e) => envRow(current, e))}</ul>
|
||||
)}
|
||||
{nothingFound && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
No Triple-C environment in {current.region}.
|
||||
{!isAdmin &&
|
||||
" Deploying one needs an environment admin: attach the environment-admin policies to an identity, or send an admin a bundle to deploy."}
|
||||
{!tier && " Run Check to find out whether you can deploy one yourself."}
|
||||
</p>
|
||||
)}
|
||||
{current && isAdmin && (
|
||||
<Button
|
||||
size="md"
|
||||
variant={nothingFound ? "primary" : "secondary"}
|
||||
disabled={stale}
|
||||
onClick={() => setDeployOpen(true)}
|
||||
>
|
||||
Deploy a new environment
|
||||
</Button>
|
||||
)}
|
||||
{nothingFound && !isAdmin && (
|
||||
<div className="space-y-2">
|
||||
<PolicyFiles tier="environment_admin" label="Show the environment-admin policies" onCopy={copy} />
|
||||
<Button size="md" disabled={stale} onClick={() => setBundle({ kind: "environment", region: current.region })}>
|
||||
Request an environment from an admin…
|
||||
</Button>
|
||||
{copyNote && (
|
||||
<p role="status" className="text-xs text-[var(--text-secondary)]">
|
||||
{copyNote}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{looseRuns.map(progress)}
|
||||
</div>
|
||||
)}
|
||||
{current && (deployOpen || repairOf) && (
|
||||
<DeployEnvironmentModal
|
||||
connectionId={connection.id}
|
||||
region={current.region}
|
||||
scan={current}
|
||||
repair={repairOf ?? undefined}
|
||||
onClose={() => {
|
||||
setDeployOpen(false);
|
||||
setRepairOf(null);
|
||||
}}
|
||||
onDeploy={(name, network) =>
|
||||
repairOf ? repair(current.region, name, network) : deploy(current.region, name, network)
|
||||
}
|
||||
onDetect={() => void detect()}
|
||||
/>
|
||||
)}
|
||||
{destroyOf && (
|
||||
<TypedConfirmModal
|
||||
title={`Destroy ${destroyOf.name}`}
|
||||
name={destroyOf.name}
|
||||
confirmLabel="Destroy"
|
||||
onClose={() => setDestroyOf(null)}
|
||||
onConfirm={() =>
|
||||
launch(destroyOf.region, destroyOf.name, environmentStateKey(destroyOf.name), `Destroy of ${destroyOf.name}`, () =>
|
||||
destroyIncompleteAwsEnvironment(connection.id, destroyOf.region, destroyOf.name),
|
||||
)
|
||||
}
|
||||
>
|
||||
<p>
|
||||
This environment's deployment is incomplete (no valid environment record). OpenTofu removes everything
|
||||
it created for {destroyOf.name} in {destroyOf.region}. This can't be undone.
|
||||
</p>
|
||||
</TypedConfirmModal>
|
||||
)}
|
||||
{unlockOf && (
|
||||
<TypedConfirmModal
|
||||
title={unlockOf.onboarding ? `Unlock your onboarding to ${unlockOf.name}` : `Unlock ${unlockOf.name}`}
|
||||
name={unlockOf.name}
|
||||
confirmLabel="Unlock"
|
||||
onClose={() => setUnlockOf(null)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await unlockAwsState(connection.id, unlockOf.region, unlockOf.stateKey);
|
||||
} catch (e) {
|
||||
return toAwsError(e);
|
||||
}
|
||||
void detect();
|
||||
return null;
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const lock = unlockOf.lock;
|
||||
return lock ? (
|
||||
<p>
|
||||
Held by <span className="font-mono text-[var(--text-primary)]">{lock.who || "unknown"}</span> since{" "}
|
||||
{formatAge(lock.age_secs)} ago ({lock.created}
|
||||
{lock.operation && `, ${lock.operation}`}).
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
<p className="text-[var(--warning)]">
|
||||
Unlock only if that deployment has stopped. Removing the lock of a run that is still going lets two runs
|
||||
write the state at once.
|
||||
</p>
|
||||
</TypedConfirmModal>
|
||||
)}
|
||||
{bundle && (
|
||||
<AdminBundleModal
|
||||
connectionId={connection.id}
|
||||
region={bundle.region}
|
||||
kind={bundle.kind}
|
||||
environment={bundle.environment}
|
||||
ownerId={bundle.ownerId}
|
||||
onSaved={() => {
|
||||
if (bundle.kind === "user_onboard" && bundle.environment) {
|
||||
const rk = rowKey(bundle.region, bundle.environment);
|
||||
setWaiting((w) => new Set(w).add(rk));
|
||||
}
|
||||
}}
|
||||
onClose={() => setBundle(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NetworkChoice } from "../../../lib/types";
|
||||
import Field, { SwitchRow, monoInputClass } from "../../ui/Field";
|
||||
import SegmentedControl from "../../ui/SegmentedControl";
|
||||
import Toggle from "../../ui/Toggle";
|
||||
import type { NetworkDraft } from "./environmentFormat";
|
||||
|
||||
/** D4's modes and their cost lines. */
|
||||
const COST: Record<NetworkChoice["mode"], string> = {
|
||||
public: "$0 — tasks get a public IP, no inbound access",
|
||||
"private-nat": "about $33/month for the NAT gateway",
|
||||
existing: "No new network resources — tasks run in your VPC and two subnets in different availability zones",
|
||||
};
|
||||
|
||||
/** The network picker shared by deploy, repair and the environment bundle. */
|
||||
export default function NetworkFields({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
value: NetworkDraft;
|
||||
onChange: (next: NetworkDraft) => void;
|
||||
/** Why the existing-VPC fields aren't usable yet. */
|
||||
error?: string | null;
|
||||
}) {
|
||||
const set = (patch: Partial<NetworkDraft>) => onChange({ ...value, ...patch });
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">Network</div>
|
||||
<SegmentedControl<NetworkChoice["mode"]>
|
||||
label="Network"
|
||||
value={value.mode}
|
||||
onChange={(mode) => set({ mode })}
|
||||
segments={[
|
||||
{ value: "public", label: "Public" },
|
||||
{ value: "private-nat", label: "Private + NAT" },
|
||||
{ value: "existing", label: "Existing VPC" },
|
||||
]}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">{COST[value.mode]}</p>
|
||||
{value.mode === "existing" && (
|
||||
<div className="space-y-2">
|
||||
<Field label="VPC ID">
|
||||
{(id) => (
|
||||
<input id={id} className={monoInputClass} placeholder="vpc-0123456789abcdef0" value={value.vpcId} onChange={(e) => set({ vpcId: e.target.value })} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Subnet ID 1">
|
||||
{(id) => (
|
||||
<input id={id} className={monoInputClass} placeholder="subnet-…" value={value.subnet1} onChange={(e) => set({ subnet1: e.target.value })} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Subnet ID 2">
|
||||
{(id) => (
|
||||
<input id={id} className={monoInputClass} placeholder="subnet-…" value={value.subnet2} onChange={(e) => set({ subnet2: e.target.value })} />
|
||||
)}
|
||||
</Field>
|
||||
<SwitchRow
|
||||
label="Assign a public IP"
|
||||
hint="Needed when the subnets have no NAT route to the internet. Tasks still get no inbound access."
|
||||
control={<Toggle label="Assign a public IP" checked={value.assignPublicIp} onChange={(v) => set({ assignPublicIp: v })} />}
|
||||
/>
|
||||
{error && <p className="text-xs text-[var(--warning)] leading-snug">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { awsPolicyDocument } from "../../../lib/tauri-commands";
|
||||
import { awsErrorMessage, toAwsError } from "../../../lib/aws-error";
|
||||
import type { PolicyFile, Tier } from "../../../lib/types";
|
||||
import Button from "../../ui/Button";
|
||||
|
||||
/** A tier's policy files, loaded on demand: each one shown and copied on its own. */
|
||||
export default function PolicyFiles({
|
||||
tier,
|
||||
label,
|
||||
onCopy,
|
||||
}: {
|
||||
tier: Tier;
|
||||
label: string;
|
||||
onCopy: (file: PolicyFile) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<PolicyFile[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setFiles(await awsPolicyDocument(tier));
|
||||
} catch (e) {
|
||||
setError(`Could not load the policy: ${awsErrorMessage(toAwsError(e))}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!files) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Button size="sm" onClick={load}>
|
||||
{label}
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-[var(--error)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{tier === "workspace_user" ? "Workspace user" : "Environment admin"}:{" "}
|
||||
{files.length === 1
|
||||
? "attach this file as a customer managed policy."
|
||||
: `Attach all ${files.length} files, each as its own customer managed policy.`}
|
||||
</p>
|
||||
{files.map((f) => (
|
||||
<div key={f.name} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[var(--text-primary)]">{f.name}</span>
|
||||
<Button size="sm" aria-label={`Copy ${f.name}`} onClick={() => onCopy(f)}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-[var(--text-secondary)]">Show JSON</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-all text-[var(--text-secondary)]">
|
||||
{f.json}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Pure helpers shared by the environments UI. The name and id rules mirror
|
||||
// `aws/tofu_modules.rs`; the server re-validates everything.
|
||||
import type { EnvironmentState, LockInfo, NetworkChoice } from "../../../lib/types";
|
||||
import type { StatusTone } from "../../ui/StatusIndicator";
|
||||
|
||||
export const COMMON_REGIONS = [
|
||||
"us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1",
|
||||
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
|
||||
"ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1",
|
||||
"sa-east-1",
|
||||
];
|
||||
|
||||
/** `validate_environment_name`: null when valid, else why not. */
|
||||
export function environmentNameError(name: string): string | null {
|
||||
const ok =
|
||||
/^[a-z][a-z0-9-]{0,19}$/.test(name) && !name.endsWith("-") && !name.includes("--");
|
||||
return ok
|
||||
? null
|
||||
: "Use 1 to 20 lowercase letters, digits and single hyphens, starting with a letter and not ending in a hyphen.";
|
||||
}
|
||||
|
||||
/** `is_aws_id`: the prefix, then 8 or 17 lowercase hex characters. */
|
||||
export function isAwsId(id: string, prefix: "vpc-" | "subnet-"): boolean {
|
||||
return new RegExp(`^${prefix}([0-9a-f]{8}|[0-9a-f]{17})$`).test(id);
|
||||
}
|
||||
|
||||
/** The fields behind the network picker, kept as text while the user types. */
|
||||
export interface NetworkDraft {
|
||||
mode: NetworkChoice["mode"];
|
||||
vpcId: string;
|
||||
subnet1: string;
|
||||
subnet2: string;
|
||||
assignPublicIp: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_NETWORK: NetworkDraft = {
|
||||
mode: "public",
|
||||
vpcId: "",
|
||||
subnet1: "",
|
||||
subnet2: "",
|
||||
assignPublicIp: false,
|
||||
};
|
||||
|
||||
/** The draft as a `NetworkChoice`, or why it can't be one yet. */
|
||||
export function networkFromDraft(d: NetworkDraft): { network: NetworkChoice } | { error: string } {
|
||||
if (d.mode === "public") return { network: { mode: "public" } };
|
||||
if (d.mode === "private-nat") return { network: { mode: "private-nat" } };
|
||||
const vpc = d.vpcId.trim();
|
||||
const subnets = [d.subnet1.trim(), d.subnet2.trim()];
|
||||
if (!isAwsId(vpc, "vpc-")) return { error: "Enter a VPC ID (vpc- and 8 or 17 hex characters)." };
|
||||
if (!subnets.every((s) => isAwsId(s, "subnet-"))) {
|
||||
return { error: "Enter two subnet IDs (subnet- and 8 or 17 hex characters), in different availability zones." };
|
||||
}
|
||||
if (subnets[0] === subnets[1]) return { error: "The two subnets must be different." };
|
||||
return { network: { mode: "existing", vpc_id: vpc, subnet_ids: subnets, assign_public_ip: d.assignPublicIp } };
|
||||
}
|
||||
|
||||
export const environmentStateKey = (env: string) => `environments/${env}/terraform.tfstate`;
|
||||
export const onboardingStateKey = (env: string, ownerKey: string) => `users/${env}/${ownerKey}/terraform.tfstate`;
|
||||
export const BOUNDARY_STATE_KEY = "boundary/terraform.tfstate";
|
||||
|
||||
/** "45 s", "12 min", "3 h 5 min", "2 days". */
|
||||
export function formatAge(secs: number): string {
|
||||
if (secs < 60) return `${Math.max(0, Math.floor(secs))} s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 48) return mins % 60 ? `${hours} h ${mins % 60} min` : `${hours} h`;
|
||||
return `${Math.floor(hours / 24)} days`;
|
||||
}
|
||||
|
||||
/** "Locked by <who> · <age> ago (<operation>)". */
|
||||
export function lockLine(lock: LockInfo): string {
|
||||
const op = lock.operation ? ` (${lock.operation})` : "";
|
||||
return `Locked by ${lock.who || "unknown"} · ${formatAge(lock.age_secs)} ago${op}`;
|
||||
}
|
||||
|
||||
export function stateStatus(s: EnvironmentState): { tone: StatusTone; label: string } {
|
||||
switch (s.state) {
|
||||
case "ready":
|
||||
return { tone: "ok", label: "Ready" };
|
||||
case "update_available":
|
||||
return { tone: "ok", label: "Update available" };
|
||||
case "deploying":
|
||||
return { tone: "busy", label: "Deploying" };
|
||||
case "incomplete":
|
||||
return { tone: "error", label: "Incomplete" };
|
||||
case "externally_managed":
|
||||
// Its state isn't in this region's Triple-C bucket (an admin bundle's
|
||||
// own bucket, by hand, or not readable here), so Update isn't offered.
|
||||
return { tone: "ok", label: "Managed outside this account's Triple-C state" };
|
||||
case "unreadable_record":
|
||||
return { tone: "error", label: "Unreadable record" };
|
||||
}
|
||||
}
|
||||
|
||||
export function stateLock(s: EnvironmentState): LockInfo | null {
|
||||
return s.state === "deploying" || s.state === "incomplete" ? s.lock : null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Shared fixtures for the environments UI tests (not a test file itself).
|
||||
import type {
|
||||
DeployProgress,
|
||||
DetectedEnvironment,
|
||||
EnvironmentInfo,
|
||||
EnvironmentScan,
|
||||
HostConnection,
|
||||
TofuEvent,
|
||||
} from "../../../lib/types";
|
||||
|
||||
export const conn: HostConnection = {
|
||||
id: "h1",
|
||||
name: "Prod",
|
||||
kind: { type: "aws", auth: { method: "profile", name: "prod-sso" }, default_region: "eu-west-1" },
|
||||
};
|
||||
|
||||
export function scan(over: Partial<EnvironmentScan> = {}): EnvironmentScan {
|
||||
return {
|
||||
region: "eu-west-1",
|
||||
account: "123456789012",
|
||||
owner_id: "AIDAEXAMPLEEXAMPLE01",
|
||||
owner_key: "abc123abc123",
|
||||
state_bucket: "yes",
|
||||
boundary: "yes",
|
||||
environments: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function info(name = "dev"): EnvironmentInfo {
|
||||
return {
|
||||
schema: 1,
|
||||
name,
|
||||
template_version: 1,
|
||||
cluster_arn: `arn:aws:ecs:eu-west-1:123456789012:cluster/triple-c-${name}`,
|
||||
security_group_id: "sg-0123abcd",
|
||||
subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"],
|
||||
assign_public_ip: true,
|
||||
kms_key_arn: "arn:aws:kms:eu-west-1:123456789012:key/k",
|
||||
log_group: `/triple-c/${name}`,
|
||||
network_mode: "public",
|
||||
tenancy: "strict",
|
||||
};
|
||||
}
|
||||
|
||||
export function env(over: Partial<DetectedEnvironment> = {}): DetectedEnvironment {
|
||||
return {
|
||||
name: "dev",
|
||||
template_version: 1,
|
||||
state: { state: "ready" },
|
||||
actions: ["join"],
|
||||
info: info(over.name ?? "dev"),
|
||||
onboarding: { state: "not_joined" },
|
||||
onboarding_actions: [],
|
||||
onboarded_users: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function tofuEvent(message: string, over: Partial<TofuEvent> = {}): TofuEvent {
|
||||
return { ts: "2026-11-14T10:00:00Z", level: "info", kind: "apply_progress", resource: null, action: null, message, ...over };
|
||||
}
|
||||
|
||||
export function progress(over: Partial<DeployProgress>): DeployProgress {
|
||||
return { op_id: "op1", state_key: "environments/dev/terraform.tfstate", events: [], done: false, cancellable: true, ...over };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const KINDS: Record<AwsError["kind"], true> = {
|
||||
keys_missing: true,
|
||||
invalid_credentials: true,
|
||||
access_denied: true,
|
||||
state_bucket_taken: true,
|
||||
other: true,
|
||||
};
|
||||
|
||||
@@ -20,3 +21,19 @@ export function toAwsError(e: unknown): AwsError {
|
||||
}
|
||||
return { kind: "other", message: typeof e === "string" ? e : e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
|
||||
/** Human-readable copy for a failed AWS command; never raw JSON. */
|
||||
export function awsErrorMessage(e: AwsError): string {
|
||||
switch (e.kind) {
|
||||
case "sso_expired":
|
||||
return `Your SSO session for profile ${e.profile} has expired.`;
|
||||
case "profile_not_found":
|
||||
return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`;
|
||||
case "keys_missing":
|
||||
return "The access keys for this connection are missing from the keychain. Remove and re-add the connection.";
|
||||
case "state_bucket_taken":
|
||||
return `The state bucket name ${e.bucket} is already taken by another AWS account.`;
|
||||
default:
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, Tier } from "./types";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, PolicyFile, Tier, EnvironmentScan, NetworkChoice, ModuleKind } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -76,8 +76,55 @@ export const awsConnectionSsoLogin = (id: string) =>
|
||||
/** `region` omitted: the connection's default region. */
|
||||
export const checkAwsPermissions = (id: string, region?: string) =>
|
||||
invoke<PermissionReport>("check_aws_permissions", { id, region: region ?? null });
|
||||
/** Every policy file `tier` needs, in order: attach all of them. */
|
||||
export const awsPolicyDocument = (tier: Tier) =>
|
||||
invoke<string>("aws_policy_document", { tier });
|
||||
invoke<PolicyFile[]>("aws_policy_document", { tier });
|
||||
|
||||
// AWS environments (OpenTofu). Reject with an `AwsError`. Starting commands (and follow) resolve
|
||||
// to an op id; progress arrives on the `AWS_DEPLOY_PROGRESS_EVENT` event (`DeployProgress`, in
|
||||
// ./types). Contract: subscribe to aws-deploy-progress BEFORE invoking; events carry `op_id` and
|
||||
// `state_key`. The backend holds a run's first event until the command is about to return, so a
|
||||
// listener registered before the invoke sees the whole run, `done` included. A re-follow (e.g.
|
||||
// after a reload) replays the whole log under a new op id.
|
||||
export const detectAwsEnvironments = (connectionId: string, region: string) =>
|
||||
invoke<EnvironmentScan>("detect_aws_environments", { connectionId, region });
|
||||
export const deployAwsEnvironment = (connectionId: string, region: string, name: string, network: NetworkChoice) =>
|
||||
invoke<string>("deploy_aws_environment", { connectionId, region, name, network });
|
||||
export const updateAwsEnvironment = (connectionId: string, region: string, name: string) =>
|
||||
invoke<string>("update_aws_environment", { connectionId, region, name });
|
||||
/** `network` is required only when the environment has no record. */
|
||||
export const repairAwsEnvironment = (connectionId: string, region: string, name: string, network?: NetworkChoice) =>
|
||||
invoke<string>("repair_aws_environment", { connectionId, region, name, network: network ?? null });
|
||||
export const destroyIncompleteAwsEnvironment = (connectionId: string, region: string, name: string) =>
|
||||
invoke<string>("destroy_incomplete_aws_environment", { connectionId, region, name });
|
||||
export const unlockAwsState = (connectionId: string, region: string, stateKey: string) =>
|
||||
invoke<void>("unlock_aws_state", { connectionId, region, stateKey });
|
||||
export const followAwsDeployment = (connectionId: string, region: string, stateKey: string) =>
|
||||
invoke<string>("follow_aws_deployment", { connectionId, region, stateKey });
|
||||
export const cancelAwsDeployment = (connectionId: string, region: string, stateKey: string) =>
|
||||
invoke<void>("cancel_aws_deployment", { connectionId, region, stateKey });
|
||||
/** Push `errored.tfstate` (a failed run's unsaved state) back to the bucket; resolves to an op id like a deploy. */
|
||||
export const pushAwsSavedState = (connectionId: string, region: string, stateKey: string) =>
|
||||
invoke<string>("push_aws_saved_state", { connectionId, region, stateKey });
|
||||
export const onboardAwsUser = (connectionId: string, region: string, environment: string) =>
|
||||
invoke<string>("onboard_aws_user", { connectionId, region, environment });
|
||||
export const deployAwsPermissionsBoundary = (connectionId: string, region: string) =>
|
||||
invoke<string>("deploy_aws_permissions_boundary", { connectionId, region });
|
||||
/** Opens a folder picker in Rust; resolves to the folder written, or null if dismissed. */
|
||||
export const saveAwsAdminBundle = (
|
||||
connectionId: string,
|
||||
region: string,
|
||||
kind: ModuleKind,
|
||||
options: { environment?: string; ownerId?: string; network?: NetworkChoice } = {},
|
||||
) =>
|
||||
invoke<string | null>("save_aws_admin_bundle", {
|
||||
connectionId,
|
||||
region,
|
||||
kind,
|
||||
environment: options.environment ?? null,
|
||||
ownerId: options.ownerId ?? null,
|
||||
network: options.network ?? null,
|
||||
});
|
||||
/** Check a corporate CA path and report what would be installed. Never
|
||||
* rejects for a bad path — the reason comes back in `error`. */
|
||||
export const inspectCaCertPath = (path: string) =>
|
||||
|
||||
@@ -1148,6 +1148,7 @@ export type AwsError =
|
||||
| { kind: "keys_missing" }
|
||||
| { kind: "invalid_credentials"; message: string }
|
||||
| { kind: "access_denied"; action: string; message: string }
|
||||
| { kind: "state_bucket_taken"; bucket: string }
|
||||
| { kind: "other"; message: string };
|
||||
|
||||
export interface CallerIdentity {
|
||||
@@ -1158,6 +1159,12 @@ export interface CallerIdentity {
|
||||
|
||||
export type Tier = "environment_admin" | "workspace_user";
|
||||
|
||||
/** One policy file of a tier (`aws/policies/<name>`); a tier needs all of its files. */
|
||||
export interface PolicyFile {
|
||||
name: string;
|
||||
json: string;
|
||||
}
|
||||
|
||||
export type Verdict = "allowed" | "denied" | "unverified";
|
||||
|
||||
export interface ActionResult {
|
||||
@@ -1184,3 +1191,113 @@ export interface PermissionReport {
|
||||
/** `[workspace_user, environment_admin]`. */
|
||||
tiers: TierResult[];
|
||||
}
|
||||
|
||||
// ── AWS environments (phase 4; `aws::environments`, `commands::environment_commands`) ──
|
||||
|
||||
/** How an environment's tasks reach the network (D4). `public` is the default and costs $0. */
|
||||
export type NetworkChoice =
|
||||
| { mode: "public" }
|
||||
| { mode: "private-nat" }
|
||||
| { mode: "existing"; vpc_id: string; subnet_ids: string[]; assign_public_ip: boolean };
|
||||
|
||||
export type ModuleKind = "environment" | "user_onboard" | "permissions_boundary";
|
||||
|
||||
/** An environment's validated SSM record (schema 1). */
|
||||
export interface EnvironmentInfo {
|
||||
schema: number;
|
||||
name: string;
|
||||
template_version: number;
|
||||
cluster_arn: string;
|
||||
security_group_id: string;
|
||||
subnet_ids: string[];
|
||||
assign_public_ip: boolean;
|
||||
kms_key_arn: string;
|
||||
log_group: string;
|
||||
network_mode: "public" | "private-nat" | "existing";
|
||||
tenancy: string;
|
||||
}
|
||||
|
||||
/** A held state lock. `who`/`created`/`operation` are sanitized untrusted text. */
|
||||
export interface LockInfo {
|
||||
who: string;
|
||||
created: string;
|
||||
operation: string;
|
||||
age_secs: number;
|
||||
local_process: boolean;
|
||||
}
|
||||
|
||||
export type EnvironmentState =
|
||||
| { state: "ready" }
|
||||
| { state: "deploying"; lock: LockInfo | null }
|
||||
| { state: "update_available" }
|
||||
| { state: "incomplete"; lock: LockInfo | null }
|
||||
| { state: "externally_managed" }
|
||||
| { state: "unreadable_record"; reason: string };
|
||||
|
||||
/** `push_state`: push the state a failed run on this machine couldn't save (`errored.tfstate`) back to the bucket. */
|
||||
export type EnvironmentAction = "join" | "update" | "repair" | "destroy" | "follow" | "unlock" | "push_state";
|
||||
|
||||
/** `incomplete`: the last onboarding run didn't finish (Join re-applies and finishes it). */
|
||||
export type OnboardingState =
|
||||
| { state: "joined" }
|
||||
| { state: "not_joined" }
|
||||
| { state: "in_progress"; lock: LockInfo | null }
|
||||
| { state: "incomplete"; lock: LockInfo | null };
|
||||
|
||||
export type Knowable = "yes" | "no" | "unknown";
|
||||
|
||||
export interface DetectedEnvironment {
|
||||
name: string;
|
||||
template_version: number | null;
|
||||
state: EnvironmentState;
|
||||
actions: EnvironmentAction[];
|
||||
info: EnvironmentInfo | null;
|
||||
onboarding: OnboardingState | null;
|
||||
/** What the caller can do with their own onboarding's run: `follow`, `unlock`, `push_state`. */
|
||||
onboarding_actions: EnvironmentAction[];
|
||||
/** Users onboarded to it (non-empty `users/<env>/…` states); null when the bucket can't be listed. */
|
||||
onboarded_users: number | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentScan {
|
||||
region: string;
|
||||
account: string;
|
||||
/** The caller's `aws:userid`. */
|
||||
owner_id: string;
|
||||
owner_key: string;
|
||||
state_bucket: Knowable;
|
||||
boundary: Knowable;
|
||||
environments: DetectedEnvironment[];
|
||||
}
|
||||
|
||||
/** One line of an OpenTofu run's log. */
|
||||
export interface TofuEvent {
|
||||
ts: string;
|
||||
level: string;
|
||||
/** The `-json` message `type`, or `"log"` for plain text. */
|
||||
kind: string;
|
||||
resource: string | null;
|
||||
action: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TofuOutcome {
|
||||
ok: boolean;
|
||||
exit_code: number;
|
||||
summary: string | null;
|
||||
}
|
||||
|
||||
/** The Tauri event a deploy's progress is emitted on (`environment_commands::PROGRESS_EVENT`). */
|
||||
export const AWS_DEPLOY_PROGRESS_EVENT = "aws-deploy-progress";
|
||||
|
||||
/** Payload of the `aws-deploy-progress` event. Subscribe before invoking the command; match on `op_id` (or `state_key`). `result` is set only when `done`. */
|
||||
export interface DeployProgress {
|
||||
op_id: string;
|
||||
/** `environments/<env>/terraform.tfstate`, `users/<env>/<ownerkey>/terraform.tfstate` or `boundary/terraform.tfstate`. */
|
||||
state_key: string;
|
||||
events: TofuEvent[];
|
||||
done: boolean;
|
||||
/** False when Cancel can't reach the run (its PID is unknown, or Windows). */
|
||||
cancellable: boolean;
|
||||
result?: TofuOutcome;
|
||||
}
|
||||
+288
-67
@@ -7,11 +7,14 @@ tier the signed-in identity has under **Settings → Hosts → Check**.
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `policies/workspace-user.json` | The **workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment |
|
||||
| `policies/environment-admin.json` | The **environment admin** tier: deploy, update and delete the environment stack, and onboard users |
|
||||
| `policies/environment-admin.json` | The **environment admin** tier, part 1: apply and destroy the environment and onboarding modules (`terraform/`) with OpenTofu, and keep their state |
|
||||
| `policies/environment-admin-network.json` | The **environment admin** tier, part 2: the network those modules create (VPC, subnets, gateways, routes, security groups) |
|
||||
| `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person |
|
||||
|
||||
An environment admin needs **both** tier files. The admin policy adds environment management on
|
||||
top of everything a workspace user can do. Explicit Deny statements in either file apply to anyone
|
||||
The admin tier is **two** files, because one managed policy holds only 6,144 characters; both are
|
||||
required, and the app's **Copy** lists every file of a tier. An environment admin attaches all three
|
||||
tier files (the workspace user's and both admin files): the admin files add environment management
|
||||
on top of everything a workspace user can do. Explicit Deny statements in any file apply to anyone
|
||||
who has it attached.
|
||||
|
||||
## Workspace user
|
||||
@@ -42,7 +45,7 @@ following:
|
||||
is needed because the AWS-managed `aws/secretsmanager` key, or any customer key whose key
|
||||
policy grants account principals directly, needs no identity-policy Allow; without the Deny
|
||||
such a `CreateSecret` succeeds. Keeping every user secret on the environment key keeps it
|
||||
under the key policy the environment stack controls. (The permissions boundary does *not*
|
||||
under the key policy the environment module controls. (The permissions boundary does *not*
|
||||
stop a task role decrypting an `aws/secretsmanager` secret: that key's policy grants the
|
||||
caller directly.)
|
||||
- Every `TagResource` on a secret must carry `triple-c:owner = ${aws:userid}` in the request,
|
||||
@@ -50,8 +53,10 @@ following:
|
||||
- A secret tagged `triple-c:component` belongs to the environment. A workspace user can never
|
||||
tag, untag, overwrite or delete one; an explicit Deny enforces this.
|
||||
- **Manage their own access points.** They can create and delete EFS access points tagged with
|
||||
their owner ID, and only on the environment's file system, which is the one tagged
|
||||
`triple-c:component`.
|
||||
their owner ID, and create them only on **their own** file system: one tagged
|
||||
`triple-c:component` (their onboarding module tags it `user-onboarding`) *and*
|
||||
`triple-c:owner = ${aws:userid}`. Each user has their own file system (strict tenancy), so
|
||||
another user's file system is refused even though it is Triple-C's.
|
||||
- **Set only the ordinary tag keys.** Every request that tags something may set only
|
||||
`triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to
|
||||
`RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A
|
||||
@@ -71,19 +76,75 @@ following:
|
||||
roles, add those roles' ARNs or path prefixes (for example `arn:aws:iam::*:role/engineers/*`)
|
||||
to `InspectOwnPrincipal` in both tier policies. Everything matched there can be read and
|
||||
simulated by every holder of the policy, so keep it to the sign-in roles.
|
||||
- **Read environment information.** This covers ECS, CloudFormation stacks, the `/triple-c/*` SSM
|
||||
parameters, the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a secret; the
|
||||
app filters by owner for display.
|
||||
- **Read environment information.** This covers ECS, the `/triple-c/*` SSM parameters (the
|
||||
environment records), the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a
|
||||
secret; the app filters by owner for display. A workspace user sees only environments that have
|
||||
a record: the OpenTofu state bucket is admin-only.
|
||||
|
||||
## Environment admin
|
||||
|
||||
The admin tier manages `triple-c-*` CloudFormation stacks and the resources they create:
|
||||
The admin tier applies (and destroys) the OpenTofu modules in `terraform/` with the admin's own
|
||||
credentials: the provider calls AWS as the admin, so the policy must allow every call it makes,
|
||||
and nothing runs with more than the admin holds. It covers:
|
||||
|
||||
- `triple-c-*` IAM roles;
|
||||
- ECS clusters, capacity providers and task definitions;
|
||||
- security groups, EFS file systems and KMS keys;
|
||||
- `/triple-c/*` log groups and `/triple-c/*` SSM parameters;
|
||||
- ECR repositories and pull-through cache rules.
|
||||
- the per-account state bucket `triple-c-tfstate-<account>-<region>` (created by the app, tagged
|
||||
`triple-c:component=state`, then read and written by OpenTofu's S3 backend, lock files included);
|
||||
- `triple-c-*` IAM roles, and reading the `triple-c-permissions-boundary` policy;
|
||||
- ECS clusters (Fargate and Fargate Spot only) and task definitions;
|
||||
- the network: VPCs, subnets, an internet gateway, route tables and routes, an Elastic IP and NAT
|
||||
gateway, security groups and their rules (`environment-admin-network.json`);
|
||||
- EFS file systems, mount targets and file-system policies, and KMS keys and aliases;
|
||||
- `/triple-c/*` log groups and `/triple-c/*` SSM parameters.
|
||||
|
||||
Not in v1, and so not in the policy: ECR (tasks pull from GHCR), custom ECS capacity providers,
|
||||
and passing roles to EC2 (`iam:PassRole` is to `ecs-tasks.amazonaws.com` only). Phase 7's EC2
|
||||
capacity adds them back.
|
||||
|
||||
### What each module resource needs
|
||||
|
||||
`app/src-tauri/src/aws/tofu_modules/tests.rs` (`MODULE_CALLS`) maps every resource type, data
|
||||
source and the S3 backend the modules use to the IAM actions the AWS provider calls to create,
|
||||
read, tag, update and delete it, and a test evaluates each against the admin files. A live apply
|
||||
and destroy of every module as a role holding only the admin files (see "Live verification") is
|
||||
the proof; an action it shows missing is added to the table, the policy, the check table in
|
||||
`policies.rs` and this list together.
|
||||
|
||||
| Resource | Actions (authorized against) |
|
||||
|---|---|
|
||||
| `aws_ecs_cluster` | `ecs:CreateCluster`, `TagResource` (at create), `DescribeClusters`, `ListTagsForResource`, `UpdateCluster`, `UntagResource`, `DeleteCluster` (cluster `triple-c-*`) |
|
||||
| `aws_ecs_cluster_capacity_providers` | `ecs:PutClusterCapacityProviders`, `DescribeClusters` (the cluster; untaggable) |
|
||||
| `aws_security_group` | `ec2:CreateSecurityGroup` (group: request tag; VPC: any, since EC2 shows the VPC no request tag), `CreateTags` (at create), `RevokeSecurityGroupEgress` (AWS's default rule), `Authorize`/`RevokeSecurityGroupIngress`/`Egress` and `UpdateSecurityGroupRuleDescriptions*` (tagged group; new rules: `security-group-rule/*`), `CreateTags`/`DeleteTags`, `DeleteSecurityGroup` (tagged), `Describe*` |
|
||||
| `aws_kms_key` | `kms:CreateKey` (request tag), `TagResource`, `DescribeKey`, `GetKeyPolicy`, `GetKeyRotationStatus`, `ListResourceTags`, `EnableKeyRotation`, `PutKeyPolicy`, `UpdateKeyDescription`, `UntagResource`, `ScheduleKeyDeletion` (tagged key) |
|
||||
| `aws_kms_alias` | `kms:CreateAlias`/`UpdateAlias`/`DeleteAlias` (alias `triple-c-*` and the tagged key; untaggable), `ListAliases` |
|
||||
| `aws_cloudwatch_log_group` | `logs:CreateLogGroup`, `TagResource`, `PutRetentionPolicy`, `ListTagsForResource`, `UntagResource`, `DeleteRetentionPolicy`, `DeleteLogGroup` (`/triple-c/*`), `DescribeLogGroups` (`*`) |
|
||||
| `aws_ssm_parameter` | `ssm:PutParameter`, `AddTagsToResource`, `GetParameter`, `ListTagsForResource`, `RemoveTagsFromResource`, `DeleteParameter` (`/triple-c/*`), `DescribeParameters` (`*`) |
|
||||
| `aws_vpc` | `ec2:CreateVpc` (request tag), `CreateTags` (at create), `ModifyVpcAttribute` (DNS hostnames), `CreateTags`/`DeleteTags`, `DeleteVpc` (tagged), `Describe*` |
|
||||
| `aws_internet_gateway` | `ec2:CreateInternetGateway`, `CreateTags` (at create), `Attach`/`DetachInternetGateway` (tagged gateway and VPC), `DeleteInternetGateway`, `Describe*` |
|
||||
| `aws_subnet` | `ec2:CreateSubnet` (subnet: request tag; VPC: tagged), `CreateTags` (at create), `ModifySubnetAttribute`, `DeleteSubnet` (tagged), `Describe*` |
|
||||
| `aws_route_table` | `ec2:CreateRouteTable` (table: request tag; VPC: tagged), `CreateTags` (at create), `DeleteRouteTable` (tagged), `Describe*` |
|
||||
| `aws_route` | `ec2:CreateRoute`, `ReplaceRoute`, `DeleteRoute` (the tagged route table; untaggable) |
|
||||
| `aws_route_table_association` | `ec2:AssociateRouteTable`, `DisassociateRouteTable` (the tagged route table and subnet; untaggable). Never `ReplaceRouteTableAssociation`: it names no subnet when it swaps a VPC's main route table, so it could re-route a VPC that isn't Triple-C's; the modules never change an association's table |
|
||||
| `aws_eip` | `ec2:AllocateAddress` (request tag), `CreateTags` (at create), `ReleaseAddress` (tagged), `DisassociateAddress` (only the literal `*/*` EC2 names once the NAT gateway holding the association is gone), `Describe*` |
|
||||
| `aws_nat_gateway` | `ec2:CreateNatGateway` (gateway: request tag; its subnet, address and VPC: tagged), `CreateTags` (at create), `DeleteNatGateway` (tagged), `Describe*` |
|
||||
| `aws_efs_file_system` | `elasticfilesystem:CreateFileSystem` (`*`, request tag), `TagResource` (at create), `UpdateFileSystem`, `PutLifecycleConfiguration`, `TagResource`/`UntagResource`, `DeleteFileSystem` (tagged), `Describe*`, `ListTagsForResource` |
|
||||
| `aws_efs_mount_target` | `elasticfilesystem:CreateMountTarget`, `ModifyMountTargetSecurityGroups`, `DeleteMountTarget` (the tagged file system; untaggable), and the interface EFS makes with the caller's permissions: `ec2:CreateNetworkInterface`, `ec2:DeleteNetworkInterface` (only with `aws:CalledViaLast = elasticfilesystem.amazonaws.com`, verified live; in the environment's subnets, which in `existing` mode are the user's own) |
|
||||
| `aws_efs_file_system_policy` | `elasticfilesystem:PutFileSystemPolicy`, `DeleteFileSystemPolicy` (the tagged file system; untaggable), `DescribeFileSystemPolicy` |
|
||||
| `aws_iam_role` | `iam:CreateRole` (with the boundary), `TagRole`, `UntagRole`, `GetRole`, `ListRolePolicies`, `ListAttachedRolePolicies`, `ListInstanceProfilesForRole`, `UpdateRole`, `UpdateRoleDescription`, `DeleteRole` (`triple-c-*`) |
|
||||
| `aws_iam_role_policy` | `iam:PutRolePolicy`, `DeleteRolePolicy` (with the boundary), `GetRolePolicy` (the role; untaggable) |
|
||||
| data sources | `sts:GetCallerIdentity`, `ec2:DescribeAvailabilityZones`, `ec2:DescribeSubnets`, `ssm:GetParameter` |
|
||||
| S3 backend | `s3:ListBucket` (the state bucket), `GetObject`/`PutObject`/`DeleteObject` (`triple-c-tfstate-*/*`, the `.tflock` objects included), in the caller's own account |
|
||||
|
||||
The app also creates the state bucket: `s3:CreateBucket` with a `triple-c:component` request tag
|
||||
(and `s3:TagResource`, which a create with tags also needs, under the same conditions), then `PutBucketVersioning`, `PutEncryptionConfiguration`, `PutBucketPublicAccessBlock`,
|
||||
`PutBucketTagging`, `PutBucketPolicy`, `GetBucket*` and `ListBucket`, all on
|
||||
`triple-c-tfstate-*` only. No bucket can be deleted.
|
||||
|
||||
Every state-bucket statement also requires `s3:ResourceAccount = ${aws:PrincipalAccount}`: the
|
||||
bucket name is predictable, so another account could create `triple-c-tfstate-<your account>-…`
|
||||
first and collect (or feed) your state. With the condition, the admin's calls on a bucket owned by
|
||||
any other account are denied, so the app fails rather than writes state there. (The Service
|
||||
Authorization Reference lists `s3:ResourceAccount` for `CreateBucket` as well as the bucket and
|
||||
object actions; Task 8's live run confirms it.)
|
||||
|
||||
It is fenced in the following ways.
|
||||
|
||||
@@ -96,9 +157,9 @@ the role carries the `triple-c-permissions-boundary` managed policy as its permi
|
||||
- ECS Exec channels;
|
||||
- reading `triple-c/*` secrets;
|
||||
- decrypting only through Secrets Manager, and only with environment (component-tagged) keys;
|
||||
- mounting and writing only environment (component-tagged) file systems; whether that excludes
|
||||
root access depends on access points fixing the POSIX user and root directory, which is only
|
||||
true once phase 6 enforces them (see "Enforced access-point roots" under Phase 6 gaps);
|
||||
- mounting and writing only Triple-C (component-tagged) file systems, which are each user's own
|
||||
(tagged `user-onboarding` by their onboarding module); the file system's own policy admits
|
||||
only its owner's task role, through an access point;
|
||||
- `sts:GetCallerIdentity`;
|
||||
- invoking Bedrock models.
|
||||
- The boundary grants nothing in IAM.
|
||||
@@ -110,13 +171,19 @@ the role carries the `triple-c-permissions-boundary` managed policy as its permi
|
||||
Without these fences an admin could create a role with any permissions and become an account
|
||||
administrator.
|
||||
|
||||
**No CloudFormation service roles.** The admin cannot create or update a `triple-c-*` stack, or
|
||||
create a change set for one, with a service role (`RoleARN`).
|
||||
|
||||
**Destruction is tag-scoped.** Security groups, EFS file systems and KMS keys can be created only
|
||||
with a `triple-c:component` tag. They can be changed or deleted only if they carry that tag.
|
||||
Tagging is allowed only as part of the creating call, or on a resource that is already
|
||||
Triple-C's.
|
||||
**Destruction is tag-scoped.** VPCs, subnets, internet gateways, route tables, Elastic IPs, NAT
|
||||
gateways, security groups, EFS file systems and KMS keys can be created only with a
|
||||
`triple-c:component` tag (the modules' `default_tags` put it on every create request). They can be
|
||||
changed or deleted only if they carry that tag. Subnets, route tables and NAT gateways can be
|
||||
created only *inside* a Triple-C (tagged) VPC, subnet and address, and an internet gateway
|
||||
attached, a route added or a route table associated only on tagged ones, so the admin cannot
|
||||
re-route a VPC that isn't Triple-C's. Tagging is allowed only as part of the creating
|
||||
call (`ec2:CreateAction`, `elasticfilesystem:CreateAction`), or on a resource that is already
|
||||
Triple-C's. Resources that cannot carry a tag are authorized through one that does: routes and
|
||||
route-table associations through the route table (and subnet), mount targets and the file-system
|
||||
policy through the file system, KMS aliases through the key, role policies through the
|
||||
(boundary-carrying) role. Clusters, log groups, parameters, roles and the state bucket are scoped
|
||||
by name (`triple-c-*`, `/triple-c/*`, `triple-c-tfstate-*`).
|
||||
|
||||
**What is not scoped.** The scoping above does not cover every action. These actions apply
|
||||
account-wide:
|
||||
@@ -124,32 +191,131 @@ account-wide:
|
||||
- `ecs:RegisterTaskDefinition` and `ecs:DeregisterTaskDefinition` on `*`. The admin can deregister
|
||||
*any* task definition revision in the account. `DeregisterTaskDefinition` has no resource type
|
||||
(IAM authorizes it against `*`), so it cannot be narrowed.
|
||||
- `ecr:CreatePullThroughCacheRule` on `*`, which also has no resource type. The admin can add
|
||||
pull-through cache rules for any prefix. This cannot delete or overwrite anything.
|
||||
- `elasticfilesystem:CreateFileSystem` on `*` (no resource type), still only with a
|
||||
`triple-c:component` request tag.
|
||||
- `ec2:CreateSecurityGroup` in any VPC (the new group itself must be tagged), and `ec2:Describe*`.
|
||||
- `cloudformation:*` on `triple-c-*` stacks. Whatever a template creates is limited by the actions
|
||||
above, because CloudFormation runs with the caller's own permissions (see the invariants below).
|
||||
- `ec2:CreateSecurityGroup` in **any** VPC, as long as the new group is tagged: an
|
||||
`existing`-network environment creates its tasks group and each user's `-efs` group in the
|
||||
user's own, untagged VPC. It is the only create that may name an untagged parent. The VPC side
|
||||
of the grant has no condition at all: EC2 does not show the VPC the request's tags (a request-tag
|
||||
condition there denied every create, verified live), so the group's own side carries the tag
|
||||
rule.
|
||||
- `ec2:DisassociateAddress` on `arn:aws:ec2:*:*:${*}/${*}`, i.e. the literal resource `*/*` and
|
||||
nothing else (`${*}` is IAM's escaped `*`). Destroying the NAT gateway's address disassociates
|
||||
it first; by then the NAT gateway and its association are gone and EC2 authorizes the call
|
||||
against `*/*` (verified live: denied without this, then `InvalidAssociationID.NotFound`, which
|
||||
the provider ignores). **Expected, not live-verified:** a live association should be
|
||||
authorized against its address and interface ARNs, which `${*}/${*}` does not match, so no real
|
||||
address should be disassociable through this statement. Only the gone-association case was
|
||||
observed; the probe that would settle it is listed under "Still open" below.
|
||||
- `ec2:AuthorizeSecurityGroupIngress`/`Egress` on `security-group-rule/*`: authorizing names the
|
||||
new, untagged rule as well as the group, and the group must still be tagged.
|
||||
- `ec2:CreateNetworkInterface` and `ec2:DeleteNetworkInterface` on `*`, but only when EFS makes
|
||||
the call for the admin (`aws:CalledViaLast = elasticfilesystem.amazonaws.com`): EFS creates and
|
||||
deletes a mount target's interface with the caller's EC2 permissions, in any subnet (an
|
||||
`existing` environment's are untagged). Verified live: EFS sets `aws:CalledViaLast` for both,
|
||||
and mount targets are created and deleted under this statement alone.
|
||||
- Reads on `*`: `ec2:Describe*`, `elasticfilesystem:Describe*`, the KMS key reads and
|
||||
`ListAliases`, `logs:DescribeLogGroups` and `ssm:DescribeParameters`.
|
||||
- `s3:PutBucketPolicy` on the state bucket (only a bucket in the admin's own account, enforced by
|
||||
`s3:ResourceAccount`): an admin could open that bucket to other accounts. The state holds no
|
||||
secrets (the modules create none), but it maps the account's Triple-C resources.
|
||||
|
||||
## Invariants the templates must keep (phase 4)
|
||||
## Environments (OpenTofu)
|
||||
|
||||
The policies rely on the following. Phase 4's templates must establish them, and phase 4's
|
||||
detection should flag any environment that breaks them.
|
||||
An environment is deployed with [OpenTofu](https://opentofu.org) from the modules in `terraform/`.
|
||||
The app runs them itself (it downloads OpenTofu 1.13.9, verified against a pinned SHA-256 before
|
||||
it is unpacked), and an admin can run the very same files by hand from a bundle.
|
||||
|
||||
| Module | Creates | State key |
|
||||
|---|---|---|
|
||||
| `terraform/permissions-boundary` | the account's `triple-c-permissions-boundary` policy (an account administrator applies it; the admin tier cannot) | `boundary/terraform.tfstate` |
|
||||
| `terraform/environment` | what every user shares: ECS cluster (Fargate, Fargate Spot), the tasks security group (no inbound rules), KMS key and alias, log group `/triple-c/<env>`, the network (`network_mode`: `public`, `private-nat`, `existing`) and, last, the record `/triple-c/environments/<env>` | `environments/<env>/terraform.tfstate` |
|
||||
| `terraform/user-onboard` | one user's task and execution roles (with the boundary) and their own EFS file system, its `-efs` security group, two mount targets and a file-system policy that admits only their task role | `users/<env>/<owner key>/terraform.tfstate` |
|
||||
|
||||
The **owner key** is the first 12 hex characters of `sha256("<region>:<aws:userid>")`; it names
|
||||
the user's roles, `triple-c-<env>-<owner key>-task` and `-exec` (a `UserId` holds a `:`, which
|
||||
role names reject). The region is in it because IAM role names are account-wide while
|
||||
environments are regional: the same user in an environment of the same name in two regions gets
|
||||
two sets of roles. The module checks the key against `region` and `owner_id`.
|
||||
|
||||
Every module pins the AWS provider (`hashicorp/aws` 6.73.0, hashes in `.terraform.lock.hcl`),
|
||||
sets `region` explicitly, never overrides an endpoint, and tags everything through `default_tags`
|
||||
(`triple-c:component`, plus the environment, owner and template version). The onboarding module
|
||||
reads the environment only from its record, never from its state.
|
||||
|
||||
**The state bucket** is `triple-c-tfstate-<account>-<region>`, one per account and region. The app
|
||||
creates it on the first deploy (versioning, SSE-S3, all four public-access blocks, a TLS-only
|
||||
bucket policy, tagged `triple-c:component=state`; the tag goes on the create itself, which the
|
||||
admin policy requires). An existing bucket policy is kept: the TLS-only statement is added to it
|
||||
when missing, never put over an organisation's own statements. Every call carries `expected_bucket_owner`, so a
|
||||
bucket of that name in another account is refused, never used. Locking is S3-native
|
||||
(`use_lockfile = true`): a run holds `<state key>.tflock`, and a held lock is what "deploying"
|
||||
means to every other copy of the app. The app removes a stale lock (older than two hours: an
|
||||
environment's, or the caller's own onboarding's) with a conditional delete on the lock's ETag, so a lock taken since is never
|
||||
removed. `tofu destroy` leaves an *empty* state object behind; detection reads an empty state as
|
||||
no deployment at all.
|
||||
|
||||
**How the app runs a deployment.** It writes the module into a work folder
|
||||
(`<app data>/triple-c/aws/work/<bucket>/<state key>/`) with `backend.hcl` and
|
||||
`terraform.tfvars.json`, then starts a detached runner (the app binary re-executed with
|
||||
`--tofu-runner`) that runs `tofu init` and `tofu apply` (or `destroy`) in its own session,
|
||||
logging to `apply.log`. Closing the app does not stop it; the next start reattaches by following the
|
||||
log. Cancel interrupts OpenTofu, which stops gracefully and saves its state. Cancel is not
|
||||
available on Windows yet (a run there goes on to the end). If OpenTofu cannot save the state after
|
||||
a run it keeps it in `errored.tfstate` in the work folder; the run ends with an error saying so,
|
||||
nothing else is started for that deployment until it is pushed back, and **Push saved state** on
|
||||
the row runs `tofu state push errored.tfstate` through the same runner (by hand: `tofu init
|
||||
-backend-config=backend.hcl`, then `tofu state push errored.tfstate`, from the work folder). OpenTofu's environment
|
||||
is built from scratch: no inherited `AWS_*`, `TF_*` or `TOFU_*` variables, the connection's profile
|
||||
name or its keys (in the environment only, never in arguments or files), and the app's own CLI
|
||||
config, so a `~/.tofurc` cannot redirect provider downloads. The app destroys only an incomplete
|
||||
deployment (state without a record, or a failed apply), and never while any user's onboarding
|
||||
state for it remains; a working environment is removed by an admin with `tofu destroy` from a
|
||||
bundle.
|
||||
|
||||
**Onboarding detection.** A user is *Joined* when their task role exists and, where the bucket is
|
||||
readable, their state holds the module's `onboarding_complete` output, which depends on every
|
||||
resource the module creates, so a run that failed part-way never writes it. A partial state, a
|
||||
failed (or errored) run on this machine, or a stale lock reads as *Incomplete*, and Join (an
|
||||
idempotent re-apply) stays offered to finish it; Follow, Unlock and Push saved state work on the
|
||||
user's own onboarding as on an environment.
|
||||
|
||||
**Admin bundles.** When the signed-in identity cannot deploy (or the admin prefers to), the app
|
||||
saves a folder with the module, `backend.hcl`, `terraform.tfvars.json` and a `README.txt` holding
|
||||
the exact commands. By hand, with credentials carrying the admin files (OpenTofu or Terraform 1.10
|
||||
or later); the permissions-boundary bundle instead needs an account administrator **without** the
|
||||
admin files attached, since their Deny on the boundary policy applies to anyone holding them:
|
||||
|
||||
```sh
|
||||
cd <bundle folder>
|
||||
tofu init -backend-config=backend.hcl
|
||||
tofu apply # or: tofu destroy
|
||||
```
|
||||
|
||||
Remove every user's onboarding before destroying their environment. With Terraform, first run
|
||||
`terraform providers lock` for your platforms (the lock file carries OpenTofu's registry hashes).
|
||||
The bundle's `README.txt` also shows how to create the state bucket by hand if the app never has,
|
||||
exactly as the app configures it: `create-bucket` with the `triple-c:component=state` tag in its
|
||||
`--create-bucket-configuration` (the admin policy allows the create only with that tag in the
|
||||
request, so a later `put-bucket-tagging` would not do; this needs an AWS CLI v2 whose
|
||||
`create-bucket` accepts `Tags`), then versioning, SSE-S3, the public-access block and the TLS-only
|
||||
bucket policy.
|
||||
|
||||
## Invariants the modules must keep (phase 4)
|
||||
|
||||
The policies rely on the following. The modules in `terraform/` establish them (their tests pin
|
||||
it), and detection should flag any environment that breaks them.
|
||||
|
||||
1. **Every `triple-c-*` role carries `triple-c-permissions-boundary`.** The admin tier cannot
|
||||
create one without it. A role created some other way, such as by an account administrator or by
|
||||
hand, must still have the boundary. Detection should flag a `triple-c-*` role that has no
|
||||
boundary.
|
||||
2. **`triple-c-*` stacks are never deployed with a CloudFormation service role.** `UpdateStack`
|
||||
and change sets on a stack that already has a service role reuse it without `iam:PassRole`,
|
||||
and that role is not under the boundary. The admin tier denies setting `RoleARN`. Detection
|
||||
should flag a `triple-c-*` stack whose `RoleARN` is set.
|
||||
3. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with
|
||||
2. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with
|
||||
neither an owner tag nor a component tag could be claimed by any workspace user.
|
||||
4. **Every environment KMS key and EFS file system carries `triple-c:component`.** The boundary
|
||||
and the admin tier both rely on this tag.
|
||||
5. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as
|
||||
3. **Every environment KMS key and EFS file system carries `triple-c:component`,** and every
|
||||
user's file system also carries `triple-c:owner`. The boundary, the admin tier and the
|
||||
workspace user's access-point rule rely on these tags.
|
||||
4. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as
|
||||
`KmsKeyId` when it creates a secret. The workspace policy explicitly denies Secrets Manager
|
||||
encryption with any key that lacks `triple-c:component`, including `aws/secretsmanager`.
|
||||
|
||||
@@ -164,19 +330,21 @@ aws iam create-policy --policy-name triple-c-permissions-boundary \
|
||||
```
|
||||
|
||||
Create the policy **without a path**. The admin policy expects exactly
|
||||
`arn:aws:iam::<account>:policy/triple-c-permissions-boundary`. Phase 4 decides how the app offers
|
||||
this step; one option is to list it with the environment stack's prerequisites.
|
||||
`arn:aws:iam::<account>:policy/triple-c-permissions-boundary`. The `terraform/permissions-boundary`
|
||||
module creates the same policy, applied by an account administrator; the admin tier can read it
|
||||
(`iam:GetPolicy`, which detection uses) but never create or change it.
|
||||
|
||||
## Attaching the tier policies
|
||||
|
||||
Create each file as a **customer managed policy** and attach it. In the console, go to
|
||||
**IAM → Policies → Create policy → JSON**, paste the file, and attach the policy to a user, group
|
||||
or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an
|
||||
environment admin needs both tiers (about 11,500), and a user's share 2,048. A managed policy may
|
||||
hold 6,144 characters, not counting whitespace; the workspace policy is about 6,110 (roughly 30
|
||||
characters of headroom, so phase 4 must split `workspace-user.json` into two policies before
|
||||
adding anything to it), and a unit test (`every_policy_fits_a_managed_policy`) fails before it
|
||||
outgrows that. For IAM Identity Center, add the files as inline policies on the permission set
|
||||
environment admin needs every tier file (about 14,500), and a user's share 2,048. A managed policy
|
||||
may hold 6,144 characters, not counting whitespace. Without whitespace, `workspace-user.json` is
|
||||
about 6,040 (roughly 100 characters of headroom), `environment-admin.json` about 5,800 (340)
|
||||
and `environment-admin-network.json` about 3,340; a unit test (`every_policy_fits_a_managed_policy`)
|
||||
fails before any file outgrows the limit. A tier that outgrows its files gains another file, which
|
||||
the app then lists with the others. For IAM Identity Center, add the files as inline policies on the permission set
|
||||
(the "up to 32,768 characters" limit is unverified) or reference the managed policies.
|
||||
|
||||
With the CLI:
|
||||
@@ -193,20 +361,22 @@ aws iam attach-user-policy --user-name <user> \
|
||||
**Phase 4**
|
||||
|
||||
- **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner
|
||||
ID. Each user's onboarding stack must therefore create their execution role as well as their
|
||||
task role. The alternative is a separate statement that allows the environment's shared
|
||||
execution role.
|
||||
- **The admin's full deploy permissions.** Deploying needs the following, added with the same
|
||||
boundary and tag scoping once the templates exist:
|
||||
- tag-on-create permissions for ECS, CloudWatch Logs, SSM and ECR;
|
||||
- autoscaling and instance-profile actions for EC2 capacity;
|
||||
- `cloudformation:ValidateTemplate`;
|
||||
- whatever else the templates create.
|
||||
ID, so each user's onboarding module creates their execution role as well as their task role
|
||||
(done: `terraform/user-onboard/iam.tf`).
|
||||
- **The admin's deploy permissions are verified for `public` and `private-nat`, not `existing`.**
|
||||
A live apply, update and destroy of every module as a role holding only the admin files passed
|
||||
(see "Live verification"). The `existing` network mode (a user's own, untagged VPC and subnets)
|
||||
was not deployed live; its extra grants (`CreateSecurityGroup` in any VPC, mount-target
|
||||
interfaces in untagged subnets) are the same statements the other modes exercised. Which
|
||||
tag-on-create actions are strictly *needed* was not tested one by one: the listed ones are
|
||||
sufficient, and only `s3:TagResource` turned out missing. A trust-policy change on a role
|
||||
(`iam:UpdateAssumeRolePolicy`) is never allowed, so a module version that changes a role's trust
|
||||
must replace the role.
|
||||
- **Admin access to user data.** The admin can retag `triple-c-*` roles (`iam:TagRole`). The
|
||||
boundary also allows every `triple-c/*` secret and every environment file system. As a result,
|
||||
isolating users *from the admin* depends on the onboarding template's per-user policies, not on
|
||||
boundary also allows every `triple-c/*` secret and every Triple-C file system. As a result,
|
||||
isolating users *from the admin* depends on the onboarding module's per-user policies, not on
|
||||
IAM fences.
|
||||
- **The workspace tier's secret Deny also applies to admins.** If the environment stack ever
|
||||
- **The workspace tier's secret Deny also applies to admins.** If the environment module ever
|
||||
manages component-tagged secrets through the admin's credentials, that Deny will block
|
||||
`PutSecretValue`/`DeleteSecret` for them.
|
||||
|
||||
@@ -219,11 +389,10 @@ aws iam attach-user-policy --user-name <user> \
|
||||
|
||||
**Phase 6**
|
||||
|
||||
- **Enforced access-point roots.** A workspace user creates their own access points. The
|
||||
`RootDirectory` and `PosixUser` must be enforced, so that an access point rooted at `/` cannot
|
||||
read other users' data. Two ways to do this: an EFS file-system policy that pins each access
|
||||
point to its owner's task role, or access points created by the onboarding stack rather than by
|
||||
the user.
|
||||
- **Access-point roots.** A workspace user creates their own access points, only on their own file
|
||||
system (strict tenancy: one file system per user, whose policy admits only their task role). An
|
||||
access point rooted at `/` therefore reaches only that user's own data. Pinning `RootDirectory`
|
||||
and `PosixUser` is still worth doing if file systems are ever shared again.
|
||||
|
||||
## Live verification (2026-11-14)
|
||||
|
||||
@@ -268,11 +437,63 @@ ends (keys are scheduled for deletion, 7 days). One test creates the real
|
||||
| Secret ownership | ✓ another owner's or a component-tagged secret cannot be written, deleted or claimed; `triple-c:component` cannot be set. An untagged `triple-c/*` secret *can* be claimed (invariant 3) |
|
||||
| `aws:TagKeys` | ✓ for `CreateSecret` and secret `TagResource`. `RunTask` (including `aws:ecs:*` and propagated tags), `CreateAccessPoint`, ECS/EFS tag-on-create: — need phase 4/5/6 resources |
|
||||
| Boundary and key tags | ✓ a role under the boundary decrypts through Secrets Manager with a component-tagged key and is refused with an untagged customer key, so KMS supplies the key's `aws:ResourceTag` |
|
||||
| `cloudformation:RoleArn` | ✓ present when `RoleARN` is given; the Deny fires. The admin tier cannot pass any role to CloudFormation anyway (`PassRole` is only to ECS tasks and EC2) |
|
||||
| `cloudformation:RoleArn` | ✓ (phase 3) present when `RoleARN` is given; the Deny fired. CloudFormation has since left the policies (phase 4, D8), and the Deny with it |
|
||||
| Task definition / pull-through scoping | ✓ both authorize against `*`; they cannot be narrowed |
|
||||
|
||||
Still open for the phases that create the resources: `RunTask`'s tag keys and `iam:PassRole`'s
|
||||
`iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/
|
||||
`StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule
|
||||
points above. The simulator does not apply session policies, so the first real create is the final
|
||||
check.
|
||||
check. The phase-4 additions (the network, the state bucket, the module table above) are
|
||||
verified below; the owner-scoped `CreateAccessPoint` waits for phase 6's access points.
|
||||
|
||||
### Phase 4: the modules, applied for real (2026-11-14)
|
||||
|
||||
```sh
|
||||
cd app/src-tauri
|
||||
cargo build # the runner is the app binary, re-executed with --tofu-runner
|
||||
TRIPLEC_AWS_E2E_PROFILE=<admin profile of a disposable account> TRIPLEC_AWS_E2E_REGION=us-west-2 \
|
||||
cargo test aws::live_tests::environment -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
`an_environment_admin_can_deploy_join_update_and_remove_an_environment` creates a role holding
|
||||
**only** the admin files (as `triple-c-e2e-<id>-envadmin*` managed policies) and does everything as
|
||||
a session of it, passing its keys to OpenTofu as a keys connection does: `ensure_state_bucket` on a
|
||||
throwaway `triple-c-tfstate-<account>-<region>-<suffix>` bucket (`TRIPLEC_E2E_BUCKET_SUFFIX`, random
|
||||
by default), deploy a `public` environment through the real detached runner, `scan` (Ready, a valid
|
||||
record, joinable), self-onboard (Joined, both roles carry the boundary), update (no changes),
|
||||
destroy the onboarding, then the environment. If the account has no boundary, the account admin
|
||||
applies the boundary module first and destroys it at the end. `the_private_nat_mode_deploys` does
|
||||
the same for `private-nat` (about $0.05: one NAT gateway for a few minutes). A guard destroys
|
||||
whatever is still applied, as the account admin (the boundary only through its module's state, so
|
||||
a boundary the run did not create is never touched), and deletes the bucket with every version if
|
||||
the run created it, on failure too; every denied action is printed by name. The test drives the same functions as the
|
||||
commands (`ensure_tofu`, `materialize`, `child_env`, the runner, `follow`, `scan`, the refusal
|
||||
checks, the conditional lock delete); the Tauri commands' own bookkeeping (launch slots, the
|
||||
progress events) is not exercised.
|
||||
|
||||
| Point | Result |
|
||||
|---|---|
|
||||
| `s3:CreateBucket` with a request tag and `s3:ResourceAccount` | ✓ both conditions are evaluated and pass for the admin's own account. ✗→fixed: a create with tags also authorizes **`s3:TagResource`**, which the policy lacked (added to `CreateTheStateBucket`, same conditions) |
|
||||
| `ec2:CreateSecurityGroup` in a VPC | ✗→fixed: denied on the VPC resource, which EC2 does not show the request's tags, so `CreateSecurityGroupsInAnyVpc` lost its request-tag condition. The group itself still needs the tag |
|
||||
| Tag-on-create (ECS, KMS, Logs, SSM, EFS, EC2, IAM) | ✓ every create passed with the listed actions; none was missing. Not tested one by one whether each is strictly needed |
|
||||
| EFS mount-target interfaces | ✓ EFS sets `aws:CalledViaLast = elasticfilesystem.amazonaws.com`: both mount targets were created and deleted with `MountTargetInterfacesThroughEfs` as the only grant of `ec2:CreateNetworkInterface`/`DeleteNetworkInterface`. Ruling R9's fallback was not needed |
|
||||
| EFS file-system policy naming a just-created role | ✓ no invalid-principal error in any onboarding apply (two applies, each created in 4 s, right after the role; whether the provider retried internally is not visible without its debug log); no module change. If it ever fails, applying the onboarding again completes it |
|
||||
| NAT address on destroy | ✗→fixed: the provider disassociates the address before releasing it, after the NAT gateway (and the association) are gone, and EC2 authorizes that call against the literal `*/*`. `DisassociateGoneAddresses` allows exactly `arn:aws:ec2:*:*:${*}/${*}`; CloudTrail shows the retried call reaching `InvalidAssociationID.NotFound` |
|
||||
| Conditional unlock | ✓ on the versioned state bucket, `DeleteObject` with `If-Match` and a mismatched ETag gets 412 (the app's "lock changed" answer) and leaves the lock; with the current ETag it deletes it. A fresh foreign lock scans as Deploying with its `Who` |
|
||||
| Update | ✓ re-applying with the network read back from the record: "0 added, 0 changed, 0 destroyed" |
|
||||
| After `tofu destroy` | ✗→fixed (app): the state object stays, empty, so a destroyed environment kept scanning as Incomplete (and a removed onboarding as Joined). Detection now reads small state objects (up to 2 KiB, at most 200 per scan) and treats one with no resources as absent; a read that fails counts as a deployment |
|
||||
| Cleanup | ✓ nothing `triple-c` left in us-west-2 (roles, policies, log groups, clusters, VPCs, subnets, gateways, NAT gateways, addresses, security groups, file systems, parameters, aliases, buckets); five environment KMS keys pending deletion (7 days); the boundary, which the account did not have, deleted again |
|
||||
|
||||
The environment-admin role in these tests carries **no permissions boundary**, deliberately: the
|
||||
boundary is the ceiling for the task and execution roles the modules create, not for admins (it
|
||||
allows only what a task does, so an admin under it could administer nothing). The test instead
|
||||
asserts that every role the admin creates carries the boundary.
|
||||
|
||||
Still open:
|
||||
|
||||
- the `existing` network mode against a user's own VPC, and an onboarding by a workspace user
|
||||
from an admin bundle (the same module, applied with admin credentials);
|
||||
- `DisassociateGoneAddresses` cannot touch a **live** association. Probe: allocate an untagged
|
||||
Elastic IP, associate it with a test ENI, and call `ec2:DisassociateAddress` on that association
|
||||
as the environment admin; it must be denied (then release both).
|
||||
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "CreateTaggedVpcs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateVpc"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedSubnets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSubnet"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:subnet/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "AllocateTaggedAddresses",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:AllocateAddress"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:elastic-ip/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateOtherTaggedNetwork",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateInternetGateway",
|
||||
"ec2:CreateRouteTable",
|
||||
"ec2:CreateNatGateway",
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:internet-gateway/*",
|
||||
"arn:aws:ec2:*:*:route-table/*",
|
||||
"arn:aws:ec2:*:*:natgateway/*",
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateOnlyInTripleCParents",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSubnet",
|
||||
"ec2:CreateRouteTable",
|
||||
"ec2:CreateNatGateway"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*",
|
||||
"arn:aws:ec2:*:*:subnet/*",
|
||||
"arn:aws:ec2:*:*:elastic-ip/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateSecurityGroupsInAnyVpc",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "TagNetworkAtCreate",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*",
|
||||
"arn:aws:ec2:*:*:subnet/*",
|
||||
"arn:aws:ec2:*:*:internet-gateway/*",
|
||||
"arn:aws:ec2:*:*:route-table/*",
|
||||
"arn:aws:ec2:*:*:elastic-ip/*",
|
||||
"arn:aws:ec2:*:*:natgateway/*",
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"ec2:CreateAction": [
|
||||
"CreateVpc",
|
||||
"CreateSubnet",
|
||||
"CreateInternetGateway",
|
||||
"CreateRouteTable",
|
||||
"AllocateAddress",
|
||||
"CreateNatGateway",
|
||||
"CreateSecurityGroup"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCNetwork",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:DeleteVpc",
|
||||
"ec2:ModifyVpcAttribute",
|
||||
"ec2:DeleteSubnet",
|
||||
"ec2:ModifySubnetAttribute",
|
||||
"ec2:AttachInternetGateway",
|
||||
"ec2:DetachInternetGateway",
|
||||
"ec2:DeleteInternetGateway",
|
||||
"ec2:DeleteRouteTable",
|
||||
"ec2:CreateRoute",
|
||||
"ec2:ReplaceRoute",
|
||||
"ec2:DeleteRoute",
|
||||
"ec2:AssociateRouteTable",
|
||||
"ec2:DisassociateRouteTable",
|
||||
"ec2:ReleaseAddress",
|
||||
"ec2:DeleteNatGateway",
|
||||
"ec2:DeleteSecurityGroup",
|
||||
"ec2:AuthorizeSecurityGroup*",
|
||||
"ec2:RevokeSecurityGroup*",
|
||||
"ec2:UpdateSecurityGroupRuleDescriptions*",
|
||||
"ec2:CreateTags",
|
||||
"ec2:DeleteTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*",
|
||||
"arn:aws:ec2:*:*:subnet/*",
|
||||
"arn:aws:ec2:*:*:internet-gateway/*",
|
||||
"arn:aws:ec2:*:*:route-table/*",
|
||||
"arn:aws:ec2:*:*:elastic-ip/*",
|
||||
"arn:aws:ec2:*:*:natgateway/*",
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "AuthorizeNewSecurityGroupRules",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:AuthorizeSecurityGroup*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group-rule/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "MountTargetInterfacesThroughEfs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateNetworkInterface",
|
||||
"ec2:DeleteNetworkInterface"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:CalledViaLast": "elasticfilesystem.amazonaws.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DisassociateGoneAddresses",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:DisassociateAddress"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:${*}/${*}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "DescribeEc2",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:Describe*"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+123
-138
@@ -1,16 +1,6 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ManageEnvironmentStacks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudformation:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageBoundedTripleCRoles",
|
||||
"Effect": "Allow",
|
||||
@@ -31,12 +21,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "TagReadAndDeleteTripleCRoles",
|
||||
"Sid": "ManageAndReadTripleCRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:DeleteRole",
|
||||
"iam:TagRole",
|
||||
"iam:GetRole"
|
||||
"iam:UntagRole",
|
||||
"iam:UpdateRole",
|
||||
"iam:UpdateRoleDescription",
|
||||
"iam:GetRole",
|
||||
"iam:GetRolePolicy",
|
||||
"iam:ListRolePolicies",
|
||||
"iam:ListAttachedRolePolicies",
|
||||
"iam:ListInstanceProfilesForRole"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:role/triple-c-*"
|
||||
@@ -53,10 +50,7 @@
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"iam:PassedToService": [
|
||||
"ecs-tasks.amazonaws.com",
|
||||
"ec2.amazonaws.com"
|
||||
]
|
||||
"iam:PassedToService": "ecs-tasks.amazonaws.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -98,23 +92,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "NoServiceRoleForTripleCStacks",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"cloudformation:CreateStack",
|
||||
"cloudformation:UpdateStack",
|
||||
"cloudformation:CreateChangeSet"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"cloudformation:RoleArn": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "InspectOwnPrincipal",
|
||||
"Effect": "Allow",
|
||||
@@ -129,29 +106,33 @@
|
||||
"arn:aws:iam::*:user/*/${aws:username}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadTheBoundary",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:GetPolicy"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::*:policy/triple-c-permissions-boundary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageClusters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:CreateCluster",
|
||||
"ecs:DeleteCluster",
|
||||
"ecs:PutClusterCapacityProviders"
|
||||
"ecs:UpdateCluster",
|
||||
"ecs:PutClusterCapacityProviders",
|
||||
"ecs:DescribeClusters",
|
||||
"ecs:TagResource",
|
||||
"ecs:UntagResource",
|
||||
"ecs:ListTagsForResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:cluster/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageCapacityProviders",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:CreateCapacityProvider",
|
||||
"ecs:DeleteCapacityProvider"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:capacity-provider/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTaskDefinitions",
|
||||
"Effect": "Allow",
|
||||
@@ -163,74 +144,6 @@
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedSecurityGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateSecurityGroupsInVpcs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "TagSecurityGroupsAtCreate",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"ec2:CreateAction": "CreateSecurityGroup"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCSecurityGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:DeleteSecurityGroup",
|
||||
"ec2:AuthorizeSecurityGroup*",
|
||||
"ec2:RevokeSecurityGroup*",
|
||||
"ec2:CreateTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DescribeEc2",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:Describe*"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedFileSystems",
|
||||
"Effect": "Allow",
|
||||
@@ -266,10 +179,15 @@
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"elasticfilesystem:DeleteFileSystem",
|
||||
"elasticfilesystem:UpdateFileSystem",
|
||||
"elasticfilesystem:PutLifecycleConfiguration",
|
||||
"elasticfilesystem:CreateMountTarget",
|
||||
"elasticfilesystem:DeleteMountTarget",
|
||||
"elasticfilesystem:ModifyMountTargetSecurityGroups",
|
||||
"elasticfilesystem:PutFileSystemPolicy",
|
||||
"elasticfilesystem:TagResource"
|
||||
"elasticfilesystem:DeleteFileSystemPolicy",
|
||||
"elasticfilesystem:TagResource",
|
||||
"elasticfilesystem:UntagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:elasticfilesystem:*:*:file-system/*"
|
||||
@@ -301,8 +219,12 @@
|
||||
"Action": [
|
||||
"kms:ScheduleKeyDeletion",
|
||||
"kms:PutKeyPolicy",
|
||||
"kms:EnableKeyRotation",
|
||||
"kms:UpdateKeyDescription",
|
||||
"kms:TagResource",
|
||||
"kms:UntagResource",
|
||||
"kms:CreateAlias",
|
||||
"kms:UpdateAlias",
|
||||
"kms:DeleteAlias"
|
||||
],
|
||||
"Resource": [
|
||||
@@ -319,6 +241,7 @@
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:CreateAlias",
|
||||
"kms:UpdateAlias",
|
||||
"kms:DeleteAlias"
|
||||
],
|
||||
"Resource": [
|
||||
@@ -331,44 +254,106 @@
|
||||
"Action": [
|
||||
"logs:CreateLogGroup",
|
||||
"logs:DeleteLogGroup",
|
||||
"logs:PutRetentionPolicy"
|
||||
"logs:PutRetentionPolicy",
|
||||
"logs:DeleteRetentionPolicy",
|
||||
"logs:TagResource",
|
||||
"logs:UntagResource",
|
||||
"logs:ListTagsForResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:logs:*:*:log-group:/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageRepositories",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:CreateRepository",
|
||||
"ecr:DeleteRepository"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecr:*:*:repository/triple-c-*",
|
||||
"arn:aws:ecr:*:*:repository/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreatePullThroughCacheRules",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:CreatePullThroughCacheRule"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageEnvironmentParameters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssm:PutParameter",
|
||||
"ssm:DeleteParameter"
|
||||
"ssm:DeleteParameter",
|
||||
"ssm:GetParameter*",
|
||||
"ssm:AddTagsToResource",
|
||||
"ssm:RemoveTagsFromResource",
|
||||
"ssm:ListTagsForResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ssm:*:*:parameter/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadWhatTheModulesRefresh",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:DescribeKey",
|
||||
"kms:GetKeyPolicy",
|
||||
"kms:GetKeyRotationStatus",
|
||||
"kms:ListResourceTags",
|
||||
"kms:ListAliases",
|
||||
"logs:DescribeLogGroups",
|
||||
"ssm:DescribeParameters",
|
||||
"elasticfilesystem:Describe*",
|
||||
"elasticfilesystem:ListTagsForResource",
|
||||
"sts:GetCallerIdentity"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTheStateBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:CreateBucket",
|
||||
"s3:TagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::triple-c-tfstate-*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
},
|
||||
"StringEquals": {
|
||||
"s3:ResourceAccount": "${aws:PrincipalAccount}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTheStateBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:PutBucketVersioning",
|
||||
"s3:PutEncryptionConfiguration",
|
||||
"s3:PutBucketPublicAccessBlock",
|
||||
"s3:PutBucketTagging",
|
||||
"s3:PutBucketPolicy",
|
||||
"s3:GetBucket*",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::triple-c-tfstate-*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ResourceAccount": "${aws:PrincipalAccount}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ReadAndWriteState",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::triple-c-tfstate-*/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ResourceAccount": "${aws:PrincipalAccount}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -232,7 +232,8 @@
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}"
|
||||
"aws:RequestTag/triple-c:owner": "${aws:userid}",
|
||||
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
|
||||
},
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
@@ -344,17 +345,6 @@
|
||||
"arn:aws:ssm:*:*:parameter/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadStacks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudformation:DescribeStacks",
|
||||
"cloudformation:ListStacks"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ListSecrets",
|
||||
"Effect": "Allow",
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/aws" {
|
||||
version = "6.73.0"
|
||||
constraints = "6.73.0"
|
||||
hashes = [
|
||||
"h1:1zQPP8XHNYS2m7MnhPd19nR6xjWk3p/e08X+B7GGx7I=",
|
||||
"h1:IdAE6YFUBZvlLDzcKe+khHpceC+cZMJy8DOsuFV1iLs=",
|
||||
"h1:KLd1R0RCfFNyhJ8fBLI7KuCI8VvV8fMZh2+Bt4/5UEk=",
|
||||
"h1:NE8f7fV/bAVTxhVOuQSIRcG0/NTNMrrrZTFJ863U3v8=",
|
||||
"h1:PLSysFV+jYgBuI42kYv4AMGMDusaKj6o4MfKz7+AD2w=",
|
||||
"h1:PirCmCZao9jd4snXgOlO+OrG4HtPKQVyiu37vmoM6Y8=",
|
||||
"h1:ZDDEyufl0zfy5gWdRkfFrDj/K7Dt9oym+FTrKTyzPmY=",
|
||||
"h1:cqILWB/Kxr0AYCi+pJj9A8d5KzsXEOrKxbFHh3VfKsQ=",
|
||||
"h1:iUIwaluxZuApYxl8C5Xpwo5mb/9OkEMn6tAzLKVTxag=",
|
||||
"h1:jjsA3nBL/NmNYeG0bmVhHv1ruFBH2ucdkW9VH1iE5Tw=",
|
||||
"h1:mKUNC7il3hHVi93ZyWogDKWTCwuhorndZf629rHqElQ=",
|
||||
"h1:miKdeCwqPUV/IPN+HCS0ZZquE4ENjb18UhOO612M+AY=",
|
||||
"h1:mrSvOTfc0Uwurvpyj9uEhbGLUqUWA1p3VAAK+qA8OXs=",
|
||||
"h1:pOp3kyT/dPNiMXzc7CtQqFrPhIFR7gqtgX5iepQJczs=",
|
||||
"h1:q5MABxUl6HoEzLYB21WOLdH+EZzioo0n9/8pkKYweF0=",
|
||||
"zh:01a4a08962eb85b35c9912e715df1a5124e5064cd60792b37c82ebe8898489b8",
|
||||
"zh:3efa54bad2af67c07547f6682bfe2b419c9f018c63008dc7d8a1e4691fd35569",
|
||||
"zh:432944bc45558e48a526e522b559dc74b70dc37cfe460420ed48527d36563531",
|
||||
"zh:6e06ddd0fa7f5c9b88c428abb18ae8d8ee9749a773ca52b2ee5010d8ce697eca",
|
||||
"zh:7efbfba3b2daed43b0ee0322b62cfd0c9102b2320cac74878e950b5d02ffc979",
|
||||
"zh:8866298c36a45b7867415119289a8f680f32d54f5553f738880ffcc0561c6907",
|
||||
"zh:9dcde5077f648b6231eef6de019a5dd89ba7ec393767be4f6cbde7e420a5a874",
|
||||
"zh:9fd52591011b1066036a47a4e677cad00d63956579a200cb57c9228f1dfb4a1a",
|
||||
"zh:a61f8e114e5233e63b8d2b034b1ad59a220e532fa34aaaa63eec6bad73a0ca21",
|
||||
"zh:ba50cb9245a579285169565995e20bebb410e7f4d4181dc7de1205877fe04728",
|
||||
"zh:c2eb24eedab492b3e42f78632b0dd6a8d23f3021b080fee2137e91765629a9e1",
|
||||
"zh:cac7953f04c637c303f212b87e96089950bb95856ffbeb2719c5138299a64787",
|
||||
"zh:d7929af6ee61fb8c7d6ec5d3a0ff9cf8554f01d0b470b3787d7e7882e6df3946",
|
||||
"zh:d7f889bcdbf6b5889ca2252ce517241a2723e61927b2826683d01b648ea91f9a",
|
||||
"zh:ff4c4ca563e044561e3c3836f51b24dca034879574ca7e334b27a49b25d1df51",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# A Triple-C environment: the pieces every user's tasks share. Each user's
|
||||
# own roles, EFS file system and secrets come from the onboarding module.
|
||||
|
||||
locals {
|
||||
# ENVIRONMENT_MODULE_VERSION in app/src-tauri/src/aws/tofu_modules.rs.
|
||||
template_version = 1
|
||||
name = "triple-c-${var.environment_name}"
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
# The only place the component tag is set: the admin policy's
|
||||
# destruction rights are scoped to it, so no resource may override it.
|
||||
default_tags {
|
||||
tags = {
|
||||
"triple-c:component" = "environment"
|
||||
"triple-c:environment" = var.environment_name
|
||||
"triple-c:template-version" = tostring(local.template_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
resource "aws_ecs_cluster" "this" {
|
||||
name = local.name
|
||||
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "disabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ecs_cluster_capacity_providers" "this" {
|
||||
cluster_name = aws_ecs_cluster.this.name
|
||||
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
|
||||
|
||||
default_capacity_provider_strategy {
|
||||
capacity_provider = "FARGATE"
|
||||
weight = 1
|
||||
}
|
||||
}
|
||||
|
||||
# Tasks are reached only through SSM, so nothing may connect in.
|
||||
resource "aws_security_group" "tasks" {
|
||||
name = "triple-c-${var.environment_name}-tasks"
|
||||
description = "Triple-C ${var.environment_name} tasks: outbound only"
|
||||
vpc_id = local.vpc_id
|
||||
|
||||
# Empty, not absent: OpenTofu then removes any rule added out of band.
|
||||
ingress = []
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
ipv6_cidr_blocks = ["::/0"]
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
# Empty lists (a new VPC) pass: only an existing network is looked up.
|
||||
precondition {
|
||||
condition = (
|
||||
alltrue([for s in data.aws_subnet.existing : s.vpc_id == var.vpc_id])
|
||||
&& length(distinct([for s in data.aws_subnet.existing : s.availability_zone])) == length(data.aws_subnet.existing)
|
||||
)
|
||||
error_message = "The existing network's two subnets must both be in vpc_id and in different availability zones."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_kms_key" "this" {
|
||||
description = "Triple-C environment ${var.environment_name}"
|
||||
enable_key_rotation = true
|
||||
deletion_window_in_days = 7
|
||||
|
||||
# Delegate to IAM: who may use the key is decided by IAM policies (and the
|
||||
# permissions boundary), not by the key policy.
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "EnableIAMPolicies"
|
||||
Effect = "Allow"
|
||||
Principal = { AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" }
|
||||
Action = "kms:*"
|
||||
Resource = "*"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_kms_alias" "this" {
|
||||
name = "alias/${local.name}"
|
||||
target_key_id = aws_kms_key.this.key_id
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "this" {
|
||||
name = "/triple-c/${var.environment_name}"
|
||||
retention_in_days = var.log_retention_days
|
||||
}
|
||||
|
||||
# The environment record (EnvironmentInfo schema 1). Written last, so its
|
||||
# presence means the environment is complete. The app treats it as untrusted.
|
||||
resource "aws_ssm_parameter" "environment" {
|
||||
name = "/triple-c/environments/${var.environment_name}"
|
||||
type = "String"
|
||||
tier = "Standard"
|
||||
value = jsonencode({
|
||||
schema = 1
|
||||
name = var.environment_name
|
||||
template_version = local.template_version
|
||||
cluster_arn = aws_ecs_cluster.this.arn
|
||||
security_group_id = aws_security_group.tasks.id
|
||||
subnet_ids = local.task_subnet_ids
|
||||
assign_public_ip = local.assign_public_ip
|
||||
kms_key_arn = aws_kms_key.this.arn
|
||||
log_group = aws_cloudwatch_log_group.this.name
|
||||
network_mode = var.network_mode
|
||||
tenancy = "strict"
|
||||
})
|
||||
|
||||
depends_on = [
|
||||
aws_ecs_cluster_capacity_providers.this,
|
||||
aws_kms_alias.this,
|
||||
aws_route.public_internet,
|
||||
aws_route.private_nat,
|
||||
aws_route_table_association.public,
|
||||
aws_route_table_association.private,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# The network, by network_mode:
|
||||
# public new VPC, two public subnets, internet gateway; tasks get a
|
||||
# public IP (the security group still admits nothing).
|
||||
# private-nat the same plus two private subnets behind one NAT gateway;
|
||||
# tasks run in the private subnets without a public IP.
|
||||
# existing the given vpc_id / subnet_ids; nothing is created here.
|
||||
|
||||
locals {
|
||||
create_vpc = var.network_mode != "existing"
|
||||
private_nat = var.network_mode == "private-nat"
|
||||
vpc_cidr = "10.42.0.0/16"
|
||||
|
||||
vpc_id = local.create_vpc ? one(aws_vpc.this[*].id) : var.vpc_id
|
||||
task_subnet_ids = (
|
||||
var.network_mode == "existing" ? var.subnet_ids
|
||||
: local.private_nat ? aws_subnet.private[*].id
|
||||
: aws_subnet.public[*].id
|
||||
)
|
||||
assign_public_ip = (
|
||||
var.network_mode == "existing" ? var.assign_public_ip
|
||||
: var.network_mode == "public"
|
||||
)
|
||||
}
|
||||
|
||||
# An existing network is the user's, taken by id: look the subnets up so the
|
||||
# task security group's precondition (main.tf) can refuse subnets outside
|
||||
# var.vpc_id or both in one availability zone before anything is built --
|
||||
# the same check the onboarding module makes of the record.
|
||||
data "aws_subnet" "existing" {
|
||||
count = var.network_mode == "existing" ? 2 : 0
|
||||
id = var.subnet_ids[count.index]
|
||||
}
|
||||
|
||||
# The subnets' AZs must not move once chosen: a changed availability_zone
|
||||
# replaces the subnet (and the ENIs, NAT gateway, associations and record
|
||||
# hanging off it). So the list is not filtered on `state` -- a temporary
|
||||
# impairment would reorder it -- only on opt-in status, which a region's
|
||||
# default AZs never change; and each subnet ignores later AZ changes.
|
||||
data "aws_availability_zones" "available" {
|
||||
count = local.create_vpc ? 1 : 0
|
||||
|
||||
filter {
|
||||
name = "opt-in-status"
|
||||
values = ["opt-in-not-required"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_vpc" "this" {
|
||||
count = local.create_vpc ? 1 : 0
|
||||
cidr_block = local.vpc_cidr
|
||||
enable_dns_support = true
|
||||
enable_dns_hostnames = true
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_internet_gateway" "this" {
|
||||
count = local.create_vpc ? 1 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
resource "aws_subnet" "public" {
|
||||
count = local.create_vpc ? 2 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
cidr_block = cidrsubnet(local.vpc_cidr, 8, count.index)
|
||||
availability_zone = data.aws_availability_zones.available[0].names[count.index]
|
||||
|
||||
tags = { Name = "${local.name}-public-${count.index}" }
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [availability_zone]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
count = local.create_vpc ? 1 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
|
||||
tags = { Name = "${local.name}-public" }
|
||||
}
|
||||
|
||||
resource "aws_route" "public_internet" {
|
||||
count = local.create_vpc ? 1 : 0
|
||||
route_table_id = aws_route_table.public[0].id
|
||||
destination_cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.this[0].id
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "public" {
|
||||
count = local.create_vpc ? 2 : 0
|
||||
subnet_id = aws_subnet.public[count.index].id
|
||||
route_table_id = aws_route_table.public[0].id
|
||||
}
|
||||
|
||||
resource "aws_eip" "nat" {
|
||||
count = local.private_nat ? 1 : 0
|
||||
domain = "vpc"
|
||||
|
||||
tags = { Name = "${local.name}-nat" }
|
||||
}
|
||||
|
||||
resource "aws_nat_gateway" "this" {
|
||||
count = local.private_nat ? 1 : 0
|
||||
allocation_id = aws_eip.nat[0].id
|
||||
subnet_id = aws_subnet.public[0].id
|
||||
|
||||
tags = { Name = local.name }
|
||||
|
||||
depends_on = [aws_internet_gateway.this]
|
||||
}
|
||||
|
||||
resource "aws_subnet" "private" {
|
||||
count = local.private_nat ? 2 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
cidr_block = cidrsubnet(local.vpc_cidr, 8, 10 + count.index)
|
||||
availability_zone = data.aws_availability_zones.available[0].names[count.index]
|
||||
|
||||
tags = { Name = "${local.name}-private-${count.index}" }
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [availability_zone]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table" "private" {
|
||||
count = local.private_nat ? 1 : 0
|
||||
vpc_id = aws_vpc.this[0].id
|
||||
|
||||
tags = { Name = "${local.name}-private" }
|
||||
}
|
||||
|
||||
resource "aws_route" "private_nat" {
|
||||
count = local.private_nat ? 1 : 0
|
||||
route_table_id = aws_route_table.private[0].id
|
||||
destination_cidr_block = "0.0.0.0/0"
|
||||
nat_gateway_id = aws_nat_gateway.this[0].id
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "private" {
|
||||
count = local.private_nat ? 2 : 0
|
||||
subnet_id = aws_subnet.private[count.index].id
|
||||
route_table_id = aws_route_table.private[0].id
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
output "parameter_name" {
|
||||
description = "The SSM parameter holding the environment record."
|
||||
value = aws_ssm_parameter.environment.name
|
||||
}
|
||||
|
||||
output "cluster_arn" {
|
||||
value = aws_ecs_cluster.this.arn
|
||||
}
|
||||
|
||||
output "security_group_id" {
|
||||
value = aws_security_group.tasks.id
|
||||
}
|
||||
|
||||
output "vpc_id" {
|
||||
value = local.vpc_id
|
||||
}
|
||||
|
||||
output "subnet_ids" {
|
||||
value = local.task_subnet_ids
|
||||
}
|
||||
|
||||
output "assign_public_ip" {
|
||||
value = local.assign_public_ip
|
||||
}
|
||||
|
||||
output "kms_key_arn" {
|
||||
value = aws_kms_key.this.arn
|
||||
}
|
||||
|
||||
output "log_group" {
|
||||
value = aws_cloudwatch_log_group.this.name
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Every rule here mirrors the app's (`app/src-tauri/src/aws/tofu_modules.rs`),
|
||||
# so an admin running a bundle by hand gets the same answers as the app.
|
||||
|
||||
variable "environment_name" {
|
||||
description = "The environment's name: lowercase letters, digits and single hyphens, starting with a letter, at most 20 characters."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
can(regex("^[a-z][a-z0-9-]{0,19}$", var.environment_name))
|
||||
&& !endswith(var.environment_name, "-")
|
||||
&& !strcontains(var.environment_name, "--")
|
||||
)
|
||||
error_message = "environment_name must match ^[a-z][a-z0-9-]{0,19}$, not end in '-' and not contain '--'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "The AWS region to deploy into, e.g. us-west-2."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
can(regex("^[a-z]{2}(-[a-z]+)+-[0-9]+$", var.region))
|
||||
&& length([for p in ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] : p if startswith(var.region, p)]) == 0
|
||||
)
|
||||
error_message = "region must be a commercial AWS region id such as us-west-2 (China and GovCloud are not supported)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "network_mode" {
|
||||
description = "public: a new VPC with two public subnets and no NAT. private-nat: a new VPC with two private subnets behind one NAT gateway (about $33/month). existing: the given vpc_id and subnet_ids."
|
||||
type = string
|
||||
default = "public"
|
||||
|
||||
validation {
|
||||
condition = contains(["public", "private-nat", "existing"], var.network_mode)
|
||||
error_message = "network_mode must be public, private-nat or existing."
|
||||
}
|
||||
}
|
||||
|
||||
variable "vpc_id" {
|
||||
description = "network_mode = existing only: the VPC to run tasks in."
|
||||
type = string
|
||||
default = ""
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.network_mode == "existing"
|
||||
? can(regex("^vpc-([0-9a-f]{8}|[0-9a-f]{17})$", var.vpc_id))
|
||||
: var.vpc_id == ""
|
||||
)
|
||||
error_message = "vpc_id must be a VPC id (vpc-xxxxxxxx) when network_mode = existing, and empty otherwise."
|
||||
}
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "network_mode = existing only: exactly two subnets of vpc_id, in different availability zones."
|
||||
type = list(string)
|
||||
default = []
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.network_mode == "existing"
|
||||
? (
|
||||
length(var.subnet_ids) == 2
|
||||
&& length(distinct(var.subnet_ids)) == 2
|
||||
&& alltrue([for s in var.subnet_ids : can(regex("^subnet-([0-9a-f]{8}|[0-9a-f]{17})$", s))])
|
||||
)
|
||||
: length(var.subnet_ids) == 0
|
||||
)
|
||||
error_message = "subnet_ids must be exactly two distinct subnet ids (subnet-xxxxxxxx) when network_mode = existing, and empty otherwise."
|
||||
}
|
||||
}
|
||||
|
||||
variable "assign_public_ip" {
|
||||
description = "network_mode = existing only: whether tasks get a public IP (needed in a public subnet without NAT)."
|
||||
type = bool
|
||||
default = false
|
||||
|
||||
validation {
|
||||
condition = var.network_mode == "existing" || !var.assign_public_ip
|
||||
error_message = "assign_public_ip applies only to network_mode = existing; the other modes decide it themselves."
|
||||
}
|
||||
}
|
||||
|
||||
variable "log_retention_days" {
|
||||
description = "How long the environment's log group keeps task logs."
|
||||
type = number
|
||||
default = 30
|
||||
|
||||
validation {
|
||||
condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_days)
|
||||
error_message = "log_retention_days must be a CloudWatch Logs retention value (1, 3, 5, 7, 14, 30, 60, 90, ...)."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
terraform {
|
||||
# OpenTofu 1.10+ or Terraform 1.10+: S3-native state locking (`use_lockfile`).
|
||||
required_version = ">= 1.10"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "6.73.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Filled in by the app (or an admin bundle's backend.hcl) with
|
||||
# `-backend-config`: bucket, key, region, use_lockfile = true.
|
||||
backend "s3" {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/aws" {
|
||||
version = "6.73.0"
|
||||
constraints = "6.73.0"
|
||||
hashes = [
|
||||
"h1:1zQPP8XHNYS2m7MnhPd19nR6xjWk3p/e08X+B7GGx7I=",
|
||||
"h1:IdAE6YFUBZvlLDzcKe+khHpceC+cZMJy8DOsuFV1iLs=",
|
||||
"h1:KLd1R0RCfFNyhJ8fBLI7KuCI8VvV8fMZh2+Bt4/5UEk=",
|
||||
"h1:NE8f7fV/bAVTxhVOuQSIRcG0/NTNMrrrZTFJ863U3v8=",
|
||||
"h1:PLSysFV+jYgBuI42kYv4AMGMDusaKj6o4MfKz7+AD2w=",
|
||||
"h1:PirCmCZao9jd4snXgOlO+OrG4HtPKQVyiu37vmoM6Y8=",
|
||||
"h1:ZDDEyufl0zfy5gWdRkfFrDj/K7Dt9oym+FTrKTyzPmY=",
|
||||
"h1:cqILWB/Kxr0AYCi+pJj9A8d5KzsXEOrKxbFHh3VfKsQ=",
|
||||
"h1:iUIwaluxZuApYxl8C5Xpwo5mb/9OkEMn6tAzLKVTxag=",
|
||||
"h1:jjsA3nBL/NmNYeG0bmVhHv1ruFBH2ucdkW9VH1iE5Tw=",
|
||||
"h1:mKUNC7il3hHVi93ZyWogDKWTCwuhorndZf629rHqElQ=",
|
||||
"h1:miKdeCwqPUV/IPN+HCS0ZZquE4ENjb18UhOO612M+AY=",
|
||||
"h1:mrSvOTfc0Uwurvpyj9uEhbGLUqUWA1p3VAAK+qA8OXs=",
|
||||
"h1:pOp3kyT/dPNiMXzc7CtQqFrPhIFR7gqtgX5iepQJczs=",
|
||||
"h1:q5MABxUl6HoEzLYB21WOLdH+EZzioo0n9/8pkKYweF0=",
|
||||
"zh:01a4a08962eb85b35c9912e715df1a5124e5064cd60792b37c82ebe8898489b8",
|
||||
"zh:3efa54bad2af67c07547f6682bfe2b419c9f018c63008dc7d8a1e4691fd35569",
|
||||
"zh:432944bc45558e48a526e522b559dc74b70dc37cfe460420ed48527d36563531",
|
||||
"zh:6e06ddd0fa7f5c9b88c428abb18ae8d8ee9749a773ca52b2ee5010d8ce697eca",
|
||||
"zh:7efbfba3b2daed43b0ee0322b62cfd0c9102b2320cac74878e950b5d02ffc979",
|
||||
"zh:8866298c36a45b7867415119289a8f680f32d54f5553f738880ffcc0561c6907",
|
||||
"zh:9dcde5077f648b6231eef6de019a5dd89ba7ec393767be4f6cbde7e420a5a874",
|
||||
"zh:9fd52591011b1066036a47a4e677cad00d63956579a200cb57c9228f1dfb4a1a",
|
||||
"zh:a61f8e114e5233e63b8d2b034b1ad59a220e532fa34aaaa63eec6bad73a0ca21",
|
||||
"zh:ba50cb9245a579285169565995e20bebb410e7f4d4181dc7de1205877fe04728",
|
||||
"zh:c2eb24eedab492b3e42f78632b0dd6a8d23f3021b080fee2137e91765629a9e1",
|
||||
"zh:cac7953f04c637c303f212b87e96089950bb95856ffbeb2719c5138299a64787",
|
||||
"zh:d7929af6ee61fb8c7d6ec5d3a0ff9cf8554f01d0b470b3787d7e7882e6df3946",
|
||||
"zh:d7f889bcdbf6b5889ca2252ce517241a2723e61927b2826683d01b648ea91f9a",
|
||||
"zh:ff4c4ca563e044561e3c3836f51b24dca034879574ca7e334b27a49b25d1df51",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# The permissions boundary every role a Triple-C module creates must carry:
|
||||
# the most any such role may ever hold. One per account.
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
default_tags {
|
||||
tags = {
|
||||
"triple-c:component" = "boundary"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_policy" "boundary" {
|
||||
# BOUNDARY_POLICY_NAME in app/src-tauri/src/aws/tofu_modules.rs; no path,
|
||||
# so the ARN the other modules build is exact.
|
||||
name = "triple-c-permissions-boundary"
|
||||
description = "Triple-C: the most any Triple-C-created role may hold"
|
||||
policy = var.policy_json
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
output "policy_arn" {
|
||||
value = aws_iam_policy.boundary.arn
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
variable "region" {
|
||||
description = "The region the provider talks to (IAM itself is global), e.g. us-west-2."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
can(regex("^[a-z]{2}(-[a-z]+)+-[0-9]+$", var.region))
|
||||
&& length([for p in ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] : p if startswith(var.region, p)]) == 0
|
||||
)
|
||||
error_message = "region must be a commercial AWS region id such as us-west-2 (China and GovCloud are not supported)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "policy_json" {
|
||||
description = "The boundary document: the app passes aws/policies/permissions-boundary.json verbatim, so this module holds no copy."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = can(jsondecode(var.policy_json).Statement)
|
||||
error_message = "policy_json must be an IAM policy document (JSON with a Statement)."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
terraform {
|
||||
# OpenTofu 1.10+ or Terraform 1.10+: S3-native state locking (`use_lockfile`).
|
||||
required_version = ">= 1.10"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "6.73.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Filled in by the app (or an admin bundle's backend.hcl) with
|
||||
# `-backend-config`: bucket, key, region, use_lockfile = true.
|
||||
backend "s3" {}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/aws" {
|
||||
version = "6.73.0"
|
||||
constraints = "6.73.0"
|
||||
hashes = [
|
||||
"h1:1zQPP8XHNYS2m7MnhPd19nR6xjWk3p/e08X+B7GGx7I=",
|
||||
"h1:IdAE6YFUBZvlLDzcKe+khHpceC+cZMJy8DOsuFV1iLs=",
|
||||
"h1:KLd1R0RCfFNyhJ8fBLI7KuCI8VvV8fMZh2+Bt4/5UEk=",
|
||||
"h1:NE8f7fV/bAVTxhVOuQSIRcG0/NTNMrrrZTFJ863U3v8=",
|
||||
"h1:PLSysFV+jYgBuI42kYv4AMGMDusaKj6o4MfKz7+AD2w=",
|
||||
"h1:PirCmCZao9jd4snXgOlO+OrG4HtPKQVyiu37vmoM6Y8=",
|
||||
"h1:ZDDEyufl0zfy5gWdRkfFrDj/K7Dt9oym+FTrKTyzPmY=",
|
||||
"h1:cqILWB/Kxr0AYCi+pJj9A8d5KzsXEOrKxbFHh3VfKsQ=",
|
||||
"h1:iUIwaluxZuApYxl8C5Xpwo5mb/9OkEMn6tAzLKVTxag=",
|
||||
"h1:jjsA3nBL/NmNYeG0bmVhHv1ruFBH2ucdkW9VH1iE5Tw=",
|
||||
"h1:mKUNC7il3hHVi93ZyWogDKWTCwuhorndZf629rHqElQ=",
|
||||
"h1:miKdeCwqPUV/IPN+HCS0ZZquE4ENjb18UhOO612M+AY=",
|
||||
"h1:mrSvOTfc0Uwurvpyj9uEhbGLUqUWA1p3VAAK+qA8OXs=",
|
||||
"h1:pOp3kyT/dPNiMXzc7CtQqFrPhIFR7gqtgX5iepQJczs=",
|
||||
"h1:q5MABxUl6HoEzLYB21WOLdH+EZzioo0n9/8pkKYweF0=",
|
||||
"zh:01a4a08962eb85b35c9912e715df1a5124e5064cd60792b37c82ebe8898489b8",
|
||||
"zh:3efa54bad2af67c07547f6682bfe2b419c9f018c63008dc7d8a1e4691fd35569",
|
||||
"zh:432944bc45558e48a526e522b559dc74b70dc37cfe460420ed48527d36563531",
|
||||
"zh:6e06ddd0fa7f5c9b88c428abb18ae8d8ee9749a773ca52b2ee5010d8ce697eca",
|
||||
"zh:7efbfba3b2daed43b0ee0322b62cfd0c9102b2320cac74878e950b5d02ffc979",
|
||||
"zh:8866298c36a45b7867415119289a8f680f32d54f5553f738880ffcc0561c6907",
|
||||
"zh:9dcde5077f648b6231eef6de019a5dd89ba7ec393767be4f6cbde7e420a5a874",
|
||||
"zh:9fd52591011b1066036a47a4e677cad00d63956579a200cb57c9228f1dfb4a1a",
|
||||
"zh:a61f8e114e5233e63b8d2b034b1ad59a220e532fa34aaaa63eec6bad73a0ca21",
|
||||
"zh:ba50cb9245a579285169565995e20bebb410e7f4d4181dc7de1205877fe04728",
|
||||
"zh:c2eb24eedab492b3e42f78632b0dd6a8d23f3021b080fee2137e91765629a9e1",
|
||||
"zh:cac7953f04c637c303f212b87e96089950bb95856ffbeb2719c5138299a64787",
|
||||
"zh:d7929af6ee61fb8c7d6ec5d3a0ff9cf8554f01d0b470b3787d7e7882e6df3946",
|
||||
"zh:d7f889bcdbf6b5889ca2252ce517241a2723e61927b2826683d01b648ea91f9a",
|
||||
"zh:ff4c4ca563e044561e3c3836f51b24dca034879574ca7e334b27a49b25d1df51",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# The user's own file system (D2): only their task role may mount it, and
|
||||
# only through an access point, over TLS.
|
||||
|
||||
# The environment's subnets, for the mount-target group's VPC and to check
|
||||
# the two subnets can each hold one mount target.
|
||||
data "aws_subnet" "task" {
|
||||
count = 2
|
||||
id = local.env.subnet_ids[count.index]
|
||||
}
|
||||
|
||||
resource "aws_efs_file_system" "this" {
|
||||
creation_token = local.name
|
||||
encrypted = true
|
||||
performance_mode = "generalPurpose"
|
||||
throughput_mode = "elastic"
|
||||
|
||||
tags = { Name = local.name }
|
||||
}
|
||||
|
||||
# Admits NFS from the environment's task group, which itself gains nothing.
|
||||
# EC2 descriptions allow only a-zA-Z0-9, spaces and ._-:/()#,@[]+=&;{}!$*
|
||||
# (no apostrophes); the provider does not check, CreateSecurityGroup does.
|
||||
resource "aws_security_group" "efs" {
|
||||
name = "${local.name}-efs"
|
||||
description = "Triple-C ${var.environment_name}: NFS to the file system of ${var.owner_key}"
|
||||
vpc_id = data.aws_subnet.task[0].vpc_id
|
||||
|
||||
ingress {
|
||||
description = "NFS from the environment task group"
|
||||
from_port = 2049
|
||||
to_port = 2049
|
||||
protocol = "tcp"
|
||||
security_groups = [local.env.security_group_id]
|
||||
}
|
||||
|
||||
# Mount targets never open connections and groups are stateful, so NFS
|
||||
# replies need no rule. Empty, not absent: OpenTofu then removes any rule
|
||||
# added out of band (an absent egress would keep AWS's allow-all default).
|
||||
egress = []
|
||||
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = (
|
||||
data.aws_subnet.task[0].vpc_id == data.aws_subnet.task[1].vpc_id
|
||||
&& data.aws_subnet.task[0].availability_zone != data.aws_subnet.task[1].availability_zone
|
||||
)
|
||||
error_message = "The environment's two subnets must be in one VPC and in different availability zones."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_efs_mount_target" "this" {
|
||||
count = 2
|
||||
file_system_id = aws_efs_file_system.this.id
|
||||
subnet_id = local.env.subnet_ids[count.index]
|
||||
security_groups = [aws_security_group.efs.id]
|
||||
}
|
||||
|
||||
resource "aws_efs_file_system_policy" "this" {
|
||||
file_system_id = aws_efs_file_system.this.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "DenyInsecureTransport"
|
||||
Effect = "Deny"
|
||||
Principal = { AWS = "*" }
|
||||
Action = "*"
|
||||
Resource = aws_efs_file_system.this.arn
|
||||
Condition = { Bool = { "aws:SecureTransport" = "false" } }
|
||||
},
|
||||
{
|
||||
Sid = "DenyWithoutAccessPoint"
|
||||
Effect = "Deny"
|
||||
Principal = { AWS = "*" }
|
||||
Action = [
|
||||
"elasticfilesystem:ClientMount",
|
||||
"elasticfilesystem:ClientWrite",
|
||||
"elasticfilesystem:ClientRootAccess",
|
||||
]
|
||||
Resource = aws_efs_file_system.this.arn
|
||||
Condition = { Null = { "elasticfilesystem:AccessPointArn" = "true" } }
|
||||
},
|
||||
{
|
||||
# Same-account access needs an identity OR a resource policy, so the
|
||||
# Allow below is not enough by itself: refuse every other principal.
|
||||
Sid = "DenyAllButOwnersTaskRole"
|
||||
Effect = "Deny"
|
||||
Principal = { AWS = "*" }
|
||||
Action = [
|
||||
"elasticfilesystem:ClientMount",
|
||||
"elasticfilesystem:ClientWrite",
|
||||
"elasticfilesystem:ClientRootAccess",
|
||||
]
|
||||
Resource = aws_efs_file_system.this.arn
|
||||
Condition = { ArnNotEquals = { "aws:PrincipalArn" = aws_iam_role.task.arn } }
|
||||
},
|
||||
{
|
||||
Sid = "OwnersTaskRoleOnly"
|
||||
Effect = "Allow"
|
||||
Principal = { AWS = aws_iam_role.task.arn }
|
||||
Action = ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"]
|
||||
Resource = aws_efs_file_system.this.arn
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# The user's two roles. Both carry the permissions boundary and trust only
|
||||
# ECS tasks in this account; the owner tag comes from default_tags.
|
||||
|
||||
# The role the user's container runs as.
|
||||
resource "aws_iam_role" "task" {
|
||||
name = "${local.name}-task"
|
||||
description = "Triple-C ${var.environment_name}: tasks of ${var.owner_id}"
|
||||
permissions_boundary = local.boundary_arn
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
Action = "sts:AssumeRole"
|
||||
Condition = {
|
||||
StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id }
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "task" {
|
||||
name = "triple-c-task"
|
||||
role = aws_iam_role.task.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "EcsExecChannels"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ssmmessages:CreateControlChannel",
|
||||
"ssmmessages:CreateDataChannel",
|
||||
"ssmmessages:OpenControlChannel",
|
||||
"ssmmessages:OpenDataChannel",
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "WriteEnvironmentLogs"
|
||||
Effect = "Allow"
|
||||
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
|
||||
Resource = local.log_group_arn
|
||||
},
|
||||
{
|
||||
Sid = "MountOwnFileSystem"
|
||||
Effect = "Allow"
|
||||
Action = ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"]
|
||||
Resource = aws_efs_file_system.this.arn
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "task_bedrock" {
|
||||
count = var.bedrock_on_task_role ? 1 : 0
|
||||
name = "triple-c-bedrock"
|
||||
role = aws_iam_role.task.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Sid = "InvokeBedrockModels"
|
||||
Effect = "Allow"
|
||||
Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
|
||||
# Cross-region inference profiles invoke models in other regions.
|
||||
Resource = [
|
||||
"arn:${local.partition}:bedrock:*::foundation-model/*",
|
||||
"arn:${local.partition}:bedrock:*:${local.account}:inference-profile/*",
|
||||
"arn:${local.partition}:bedrock:*:${local.account}:application-inference-profile/*",
|
||||
]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
# The role ECS uses to start the user's tasks: pull their secrets, write logs.
|
||||
resource "aws_iam_role" "exec" {
|
||||
name = "${local.name}-exec"
|
||||
description = "Triple-C ${var.environment_name}: task execution for ${var.owner_id}"
|
||||
permissions_boundary = local.boundary_arn
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
Action = "sts:AssumeRole"
|
||||
Condition = {
|
||||
StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id }
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "exec" {
|
||||
name = "triple-c-exec"
|
||||
role = aws_iam_role.exec.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "ReadOwnSecrets"
|
||||
Effect = "Allow"
|
||||
Action = "secretsmanager:GetSecretValue"
|
||||
Resource = "arn:${local.partition}:secretsmanager:${var.region}:${local.account}:secret:triple-c/*"
|
||||
Condition = {
|
||||
StringEquals = { "aws:ResourceTag/triple-c:owner" = var.owner_id }
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "DecryptSecretsThroughSecretsManager"
|
||||
Effect = "Allow"
|
||||
Action = "kms:Decrypt"
|
||||
Resource = local.env.kms_key_arn
|
||||
Condition = {
|
||||
StringEquals = { "kms:ViaService" = "secretsmanager.${var.region}.amazonaws.com" }
|
||||
}
|
||||
},
|
||||
{
|
||||
Sid = "WriteEnvironmentLogs"
|
||||
Effect = "Allow"
|
||||
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
|
||||
Resource = local.log_group_arn
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# One user's place in an environment: their task and execution roles and
|
||||
# their own EFS file system (D2, strict tenancy). The environment itself is
|
||||
# read only from its record, never from its state.
|
||||
|
||||
locals {
|
||||
# ONBOARD_MODULE_VERSION in app/src-tauri/src/aws/tofu_modules.rs.
|
||||
template_version = 1
|
||||
name = "triple-c-${var.environment_name}-${var.owner_key}"
|
||||
|
||||
partition = data.aws_partition.current.partition
|
||||
account = data.aws_caller_identity.current.account_id
|
||||
|
||||
# The environment record (EnvironmentInfo schema 1), checked below.
|
||||
env = jsondecode(data.aws_ssm_parameter.environment.insecure_value)
|
||||
|
||||
boundary_arn = "arn:${local.partition}:iam::${local.account}:policy/triple-c-permissions-boundary"
|
||||
log_group_arn = "arn:${local.partition}:logs:${var.region}:${local.account}:log-group:${local.env.log_group}:*"
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
# The only place the component tag is set: the admin policy's
|
||||
# destruction rights are scoped to it, so no resource may override it.
|
||||
default_tags {
|
||||
tags = {
|
||||
"triple-c:component" = "user-onboarding"
|
||||
"triple-c:environment" = var.environment_name
|
||||
"triple-c:owner" = var.owner_id
|
||||
"triple-c:template-version" = tostring(local.template_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
# The record is a String parameter (not secret), so its non-sensitive value
|
||||
# is read: subnet ids drive `count`, which a sensitive value cannot.
|
||||
data "aws_ssm_parameter" "environment" {
|
||||
name = "/triple-c/environments/${var.environment_name}"
|
||||
with_decryption = false
|
||||
|
||||
# The record is untrusted input that feeds IAM policies and the network:
|
||||
# every field this module uses is format-checked before anything is built.
|
||||
lifecycle {
|
||||
postcondition {
|
||||
condition = try(
|
||||
jsondecode(self.insecure_value).schema == 1
|
||||
&& jsondecode(self.insecure_value).name == var.environment_name
|
||||
&& jsondecode(self.insecure_value).tenancy == "strict"
|
||||
&& length(jsondecode(self.insecure_value).subnet_ids) == 2,
|
||||
false
|
||||
)
|
||||
error_message = "The environment record is missing or not a schema-1 strict-tenancy record with two subnets; deploy (or update) the environment first."
|
||||
}
|
||||
|
||||
postcondition {
|
||||
condition = try(
|
||||
can(regex(
|
||||
"^arn:aws[a-z-]*:kms:${var.region}:${data.aws_caller_identity.current.account_id}:key/[0-9a-f-]+$",
|
||||
jsondecode(self.insecure_value).kms_key_arn
|
||||
))
|
||||
&& jsondecode(self.insecure_value).log_group == "/triple-c/${var.environment_name}"
|
||||
&& can(regex("^sg-[0-9a-f]{8,17}$", jsondecode(self.insecure_value).security_group_id))
|
||||
&& alltrue([for s in jsondecode(self.insecure_value).subnet_ids : can(regex("^subnet-[0-9a-f]{8,17}$", s))]),
|
||||
false
|
||||
)
|
||||
error_message = "The environment record has a malformed kms_key_arn (not a key in this region and account), log_group (not /triple-c/<environment>), security_group_id or subnet id."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
output "task_role_arn" {
|
||||
value = aws_iam_role.task.arn
|
||||
}
|
||||
|
||||
output "execution_role_arn" {
|
||||
value = aws_iam_role.exec.arn
|
||||
}
|
||||
|
||||
output "file_system_id" {
|
||||
value = aws_efs_file_system.this.id
|
||||
}
|
||||
|
||||
output "file_system_arn" {
|
||||
value = aws_efs_file_system.this.arn
|
||||
}
|
||||
|
||||
output "efs_security_group_id" {
|
||||
value = aws_security_group.efs.id
|
||||
}
|
||||
|
||||
# Written only when every resource above was applied: a run that fails
|
||||
# part-way never evaluates it, so the app reads its absence from the state
|
||||
# as "the last onboarding didn't finish" (Incomplete, Join re-applies)
|
||||
# rather than Joined. Keep depends_on listing every resource in the module.
|
||||
output "onboarding_complete" {
|
||||
value = true
|
||||
depends_on = [
|
||||
aws_efs_file_system.this,
|
||||
aws_security_group.efs,
|
||||
aws_efs_mount_target.this,
|
||||
aws_efs_file_system_policy.this,
|
||||
aws_iam_role.task,
|
||||
aws_iam_role_policy.task,
|
||||
aws_iam_role_policy.task_bedrock,
|
||||
aws_iam_role.exec,
|
||||
aws_iam_role_policy.exec,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Every rule here mirrors the app's (`app/src-tauri/src/aws/tofu_modules.rs`),
|
||||
# so an admin running a bundle by hand gets the same answers as the app.
|
||||
|
||||
variable "environment_name" {
|
||||
description = "The environment to join: lowercase letters, digits and single hyphens, starting with a letter, at most 20 characters."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
can(regex("^[a-z][a-z0-9-]{0,19}$", var.environment_name))
|
||||
&& !endswith(var.environment_name, "-")
|
||||
&& !strcontains(var.environment_name, "--")
|
||||
)
|
||||
error_message = "environment_name must match ^[a-z][a-z0-9-]{0,19}$, not end in '-' and not contain '--'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "The environment's AWS region, e.g. us-west-2."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
can(regex("^[a-z]{2}(-[a-z]+)+-[0-9]+$", var.region))
|
||||
&& length([for p in ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] : p if startswith(var.region, p)]) == 0
|
||||
)
|
||||
error_message = "region must be a commercial AWS region id such as us-west-2 (China and GovCloud are not supported)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "owner_id" {
|
||||
description = "The user's aws:userid (e.g. AROA...:alice@example.com). Every resource is tagged triple-c:owner with it."
|
||||
type = string
|
||||
|
||||
# Also a tag value, so only characters both allow.
|
||||
validation {
|
||||
condition = can(regex("^[A-Za-z0-9_.:/=+@-]{1,128}$", var.owner_id))
|
||||
error_message = "owner_id must be an aws:userid: 1 to 128 letters, digits and _.:/=+@- characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "owner_key" {
|
||||
description = "The first 12 hex characters of sha256(\"<region>:<owner_id>\"); names the user's roles, which reject the ':' in owner_id. The region is included because role names are account-wide and environments are regional."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = can(regex("^[0-9a-f]{12}$", var.owner_key)) && var.owner_key == substr(sha256("${var.region}:${var.owner_id}"), 0, 12)
|
||||
error_message = "owner_key must be the first 12 hex characters of sha256(\"<region>:<owner_id>\")."
|
||||
}
|
||||
}
|
||||
|
||||
variable "bedrock_on_task_role" {
|
||||
description = "Whether the task role may invoke Bedrock models (otherwise the workspace user's own credentials are used)."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
terraform {
|
||||
# OpenTofu 1.10+ or Terraform 1.10+: S3-native state locking (`use_lockfile`).
|
||||
required_version = ">= 1.10"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "6.73.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Filled in by the app (or an admin bundle's backend.hcl) with
|
||||
# `-backend-config`: bucket, key, region, use_lockfile = true.
|
||||
backend "s3" {}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
# 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 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:**
|
||||
- **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-<account>-<region>`. 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-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";
|
||||
- "Multi-user accounts" (strict tenancy);
|
||||
- "Shipped AWS artefacts";
|
||||
- "Phase 3: policy verification and rulings".
|
||||
|
||||
**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 (user, 2026-11-14)
|
||||
|
||||
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.
|
||||
|
||||
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-*`.
|
||||
|
||||
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/<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. 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 `<workdir>/apply.log` and its PID in `<workdir>/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/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/<env>`.
|
||||
- **Owner key:** the first 12 hex characters of `sha256(aws:userid)`. A `UserId` contains `:`, which IAM role names reject.
|
||||
- **Per-user roles:** `triple-c-<env>-<ownerkey>-task` and `triple-c-<env>-<ownerkey>-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-<account>-<region>`. 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=<env>`, `triple-c:template-version=<n>`;
|
||||
- onboarding: `triple-c:component=user-onboarding`, `triple-c:environment`, `triple-c:owner=<owner_id>`, `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:<partition>:iam::<account>: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::<account>: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=<name>`.
|
||||
- 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. **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 module and `aws/tofu_modules.rs`
|
||||
|
||||
**Files:**
|
||||
- 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:
|
||||
```rust
|
||||
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<String, String>;
|
||||
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<String>, assign_public_ip: bool } }
|
||||
pub fn environment_vars(env: &str, region: &str, network: &NetworkChoice) -> Result<serde_json::Value, String>; // tfvars JSON; validates vpc-/subnet- ids, exactly 2 subnets
|
||||
```
|
||||
- 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-<env>`, with `aws_ecs_cluster_capacity_providers` for FARGATE and FARGATE_SPOT;
|
||||
- the security group `triple-c-<env>-tasks`, with egress set to all and no ingress;
|
||||
- the KMS key and the alias `alias/triple-c-<env>`;
|
||||
- the log group `/triple-c/<env>`;
|
||||
- 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-<env>-<key>-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 when `bedrock_on_task_role` is set.
|
||||
- **The execution role** `triple-c-<env>-<key>-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.<region>.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-<env>-<key>-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`.
|
||||
- `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 modules; CloudFormation leaves the policies
|
||||
|
||||
**Files:**
|
||||
- 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<PolicyFile>`), `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`'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)]; // replaces policy_json
|
||||
#[tauri::command] pub fn aws_policy_document(tier: Tier) -> Vec<PolicyFile>; // PolicyFile { name: String, json: String }
|
||||
```
|
||||
- `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_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: 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: The OpenTofu runner (`aws/tofu.rs`)
|
||||
|
||||
**Files:**
|
||||
- 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
|
||||
pub const TOFU_VERSION: &str = "<same as Task 1>";
|
||||
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<PathBuf, AwsError>; // <root>/tofu/<ver>/tofu[.exe]; downloads from github.com/opentofu/opentofu/releases/download/v<ver>/<file>, 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<PathBuf, AwsError>; // 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<Workdir, AwsError>; // <root>/work/<bucket>/<state_key without .tfstate>/; 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<u32 /*pid*/, AwsError>; // 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<String>, pub action: Option<String>, pub message: String }
|
||||
pub fn parse_log_line(line: &str) -> Option<TofuEvent>; // 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<TofuOutcome, AwsError>; // from byte 0, each line once, until apply.exit exists
|
||||
pub struct TofuOutcome { pub ok: bool, pub exit_code: i32, pub summary: Option<String> }
|
||||
```
|
||||
- Detaching: the runner is the app binary itself, re-executed with a hidden `--tofu-runner <workdir> <op>` 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:**
|
||||
- `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: 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: `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<String>, 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<EnvironmentInfo, String>;
|
||||
#[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 { lock: Option<LockInfo> }, UpdateAvailable, Incomplete { lock: Option<LockInfo> }, 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, Incomplete }
|
||||
#[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { name, template_version: Option<u32>, state: EnvironmentState, actions: Vec<EnvironmentAction>, info: Option<EnvironmentInfo>, onboarding: Option<OnboardingState> }
|
||||
#[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<DetectedEnvironment> }
|
||||
pub struct StateObjects { pub states: Vec<String> /* keys */, pub locks: Vec<(String, String /*lock json*/, u64 /*age*/)> }
|
||||
pub fn merge(records: Vec<(String, Result<EnvironmentInfo, String>)>, objects: Option<&StateObjects>, running_locally: &dyn Fn(&str) -> bool, current_version: u32) -> Vec<DetectedEnvironment>;
|
||||
#[async_trait] pub trait DetectApi: Send + Sync {
|
||||
async fn environment_records(&self) -> Result<Vec<(String, String)>, AwsError>;
|
||||
async fn state_objects(&self, bucket: &str) -> Result<Option<StateObjects>, AwsError>; // None = bucket missing or denied
|
||||
async fn bucket_exists(&self, bucket: &str) -> Result<Knowable, AwsError>;
|
||||
async fn boundary_exists(&self, account: &str) -> Result<Knowable, AwsError>;
|
||||
async fn role_exists(&self, name: &str) -> Result<Knowable, AwsError>;
|
||||
}
|
||||
pub async fn scan<D: DetectApi>(d: &D, who: &CallerIdentity, region: &str, running_locally: &dyn Fn(&str) -> bool) -> Result<EnvironmentScan, AwsError>;
|
||||
pub async fn ensure_state_bucket(cfg: &SdkConfig, account: &str, region: &str) -> Result<String, AwsError>;
|
||||
```
|
||||
- `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:
|
||||
- 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 reason names the field and never echoes the value.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests:**
|
||||
- `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**, 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): state bucket, and detection of environments, locks and onboarding`.
|
||||
|
||||
### Task 6: Commands, progress events and admin bundles
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src-tauri/src/commands/environment_commands.rs`
|
||||
- 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). The work root is `dirs::data_dir()/triple-c/aws/`. An `op_id` is a UUID.
|
||||
```rust
|
||||
detect_aws_environments(connection_id, region) -> Result<EnvironmentScan, AwsError>
|
||||
deploy_aws_environment(connection_id, region, name, network: NetworkChoice, app) -> Result<String, AwsError> // 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<String, AwsError> // 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<NetworkChoice>, app) -> Result<String, AwsError> // re-apply an Incomplete one; network required if no record
|
||||
destroy_incomplete_aws_environment(connection_id, region, name, app) -> Result<String, AwsError> // 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<String, AwsError>
|
||||
cancel_aws_deployment(connection_id, region, state_key) -> Result<(), AwsError> // tofu::interrupt
|
||||
onboard_aws_user(connection_id, region, environment, app) -> Result<String, AwsError> // self-onboarding with the caller's own owner id
|
||||
deploy_aws_permissions_boundary(connection_id, region, app) -> Result<String, AwsError>
|
||||
save_aws_admin_bundle(connection_id, region, kind: ModuleKind, environment: Option<String>, owner_id: Option<String>, window) -> Result<Option<String>, AwsError> // Rust-side folder dialog; writes D5 bundle; returns the folder path
|
||||
```
|
||||
- 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 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, 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 OpenTofu progress`.
|
||||
|
||||
### Task 7: Environments UI
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/components/settings/hosts/{EnvironmentsPanel,DeployEnvironmentModal,AdminBundleModal,DeployProgress}.tsx`, plus tests for the first three
|
||||
- Modify: `HostsSettings.tsx`, which renders `<EnvironmentsPanel connection={c} tier={lastReport?.summary} />`
|
||||
|
||||
**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: 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 the failing tests:**
|
||||
- `shows deploy as primary when nothing is found and the user is an admin`.
|
||||
- `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`.
|
||||
- `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 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, repair, join`.
|
||||
|
||||
### Task 8: Live verification, docs and spec
|
||||
|
||||
**Files:**
|
||||
- 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 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<rand>` 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 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`.
|
||||
@@ -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,55 @@ 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), and never while any user's onboarding state for the environment remains. 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 owner key is the
|
||||
first 12 hex characters of `sha256("<region>:<aws:userid>")` and also names the user's roles
|
||||
(`triple-c-<env>-<ownerkey>-task|exec`): role names are account-global and environments
|
||||
regional, so the region is part of it (the same user and environment name in two regions must
|
||||
not collide). 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 session, output to
|
||||
`<workdir>/apply.log`, PID in `<workdir>/apply.pid` (liveness is the runner's `apply.lock`). A
|
||||
run that can't save its state leaves `errored.tfstate`; nothing else starts there until it is
|
||||
pushed back (Push saved state: `tofu state push errored.tfstate` through the same runner).
|
||||
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 (not
|
||||
on Windows yet), 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 +132,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 +181,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 +327,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.
|
||||
@@ -283,9 +340,10 @@ updates use the existing "new image available" flow: the next start uses the new
|
||||
| **Hybrid** (default) | task storage | EFS | Settings, sessions, memory, history: yes. Working tree: only what's pushed | Fast builds, fast git | Small EFS footprint |
|
||||
| **EFS** | EFS | EFS | Everything | git, `node_modules` and builds are noticeably slower over NFS | Grows with repo and dependency size |
|
||||
|
||||
- **One EFS file system per environment, one access point per workspace.** Access point root:
|
||||
- **One EFS file system per user (D2), one access point per workspace.** The file system comes
|
||||
from the user's onboarding module and its policy admits only their task role. Access point root:
|
||||
`/triple-c/<owner>/<project-id>`, with POSIX user and group 1000. The access point is created by
|
||||
the user and tagged with the owner (see policies).
|
||||
the user, on their own file system, and tagged with the owner (see policies).
|
||||
- **WIP push is on by default** (`wip_push_on_stop: true`, with a serde default per the
|
||||
`default_full_permissions` pattern) and can be turned off per project.
|
||||
- **Ephemeral and Hybrid have a stop guard.** Before stopping, the app asks the agent for
|
||||
@@ -390,7 +448,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. Each user has their own EFS file system (D2), whose policy admits only that user'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 +480,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 +618,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 +644,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 +728,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 +771,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 |
|
||||
|
||||
Reference in new issue
Block a user