diff --git a/AGENTS.md b/AGENTS.md index ecd7253..3e5bb7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -774,6 +774,50 @@ resources on water park, against 247 and 43 through the symlink. Nothing in the answer mentions the scratch path (a terraform entity carries `attrs.file` relative to its root, and no `sourceLoc`). +### A choudoufu member's overlay is ownership; attribute drift is the plan (#404) + +**Read a green choudoufu card as "this is ours", never as "nothing changed".** +The overlay is composed from `live-ls -json` and `live-plan -json`, and those +two documents answer one question — bound, unowned, omitted, or invisible to +the listing. Neither of them ever compares an attribute VALUE. A resource whose +tag, path or mutability was changed out of band still carries its markers, so +it is still bound, and it is still green. + +The other half of the terminal's answer — "would a plan change anything?" — is +a **separate, opt-in read**: `choudoufu plan -input=false -out=` then +`choudoufu show -json `, whose `resource_changes[].change.{actions,before, +after}` name the attributes and both their values. (`choudoufu plan -json` is +NOT this: on 0.16.0 it prints choudoufu's own ownership document, the same +shape as `live-plan -json`, with no attribute values in it at all. The +measurement is in src/choudoufu-plan.ts's header.) The plan file is written to +a scratch directory of behold's own and removed after — never inside the served +member, which would be a write into someone's source. + +It is opt-in because it costs differently: the ownership read is answered out +of the tagging index and is flat in the estate's size, while `plan` refreshes +every resource — one provider read per card, 301 of them on `terralith-4`. So: + +- **Never on an ordinary overlay read.** `GET /api/overlay?plan=1` and + `GET /api/diff?plan=1` are the only things that spawn one, and the palette's + **"Re-check live with plan (attribute drift)"** row is the only thing in the + SPA that sends the flag. The cheap **"Re-check live (refresh drift)"** row is + deliberately left alone — it is `POST /api/refresh`, a primary-only re-observe + that captures a lanes frame and never composes the estate. +- **Cached under the member's source stamp**, the same key half `memberIr` + uses. A read that did NOT ask is served that stored answer, so a reload after + a refresh keeps showing the drift without paying for a second pass; a read + that DID ask always re-plans, because "re-check" means re-check. +- **The ownership verdict stays the card's colour.** A bound card that drifted + is still painted bound and still counted `bound`; it gains `attrs._planDrift` + and wears a dashed `--degraded` edge plus a `~ n attributes` corner glyph + (web/app.js `markDriftedCards`, the same post-render stamp as the carve and + operator marks). A card that is not bound is never marked — an unowned + object's planned `create` is an ownership fact the overlay already paints, + and `planDrift` drops creates for that reason. +- **`/api/overlay`'s meta carries `drift`**: `{read: false}` or `{read: true, + drifted: n}`. The two are different answers — "nobody looked" versus "looked, + nothing drifted" — and the legend prints a count only for the second. + ### A choudoufu estate's own references (#393) `choudoufu live-check -json` states the roster and `references[]`, and that diff --git a/README.md b/README.md index 4652962..965fcd0 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,13 @@ There's a whole catalog — `behold demo --list` names the rest (`behold demo k8s` stands the same loop up on a throwaway k3d cluster: runtime Pods, field ownership; `behold demo argo-estate` needs nothing at all — a three-project Argo CD estate, declared only, so it runs where Docker doesn't; `behold demo -carve` is the Terraform peel walkthrough, below). Every loaded demo lands in -the panel's recents, so switching between them is the Scope tab. +choudoufu-estate` is four tag-owned OpenTofu estates served composed, where a +card's colour is **ownership** — bound, unowned, pending — and not "nothing +changed": a resource whose attributes drifted out of band still carries its +markers and is still green, so attribute drift is a separate read you ask for +with ⌘K → "Re-check live with plan"; `behold demo carve` is the Terraform peel +walkthrough, below). Every loaded demo lands in the panel's recents, so +switching between them is the Scope tab. Working in a checkout rather than an npm install? There is a second catalog, `workbench.json`, which is this checkout's and is not shipped — it is diff --git a/demos.json b/demos.json index 426fdce..bbf7576 100644 --- a/demos.json +++ b/demos.json @@ -81,7 +81,7 @@ }, { "name": "choudoufu-estate", - "description": "A tag-owned estate: four choudoufu estates (an OpenTofu fork whose ownership is two AWS tags) served composed \u2014 the terralith applied to a scratch floci, three team estates whose declared resources are the monolith's (every card `owned by` it, dashed to its card), a cross-estate data-source edge, and a move plan whose `live-mv` lines you run by hand while behold previews with -dry-run and reads the receipt. Docker + choudoufu (0.16.0+, or a build from main) on PATH; provider download on first init. scripts/choudoufu-down.sh removes the container.", + "description": "A tag-owned estate: four choudoufu estates (an OpenTofu fork whose ownership is two AWS tags) served composed \u2014 the terralith applied to a scratch floci, three team estates whose declared resources are the monolith's (every card `owned by` it, dashed to its card), a cross-estate data-source edge, and a move plan whose `live-mv` lines you run by hand while behold previews with -dry-run and reads the receipt. A card's colour is ownership, not \"nothing changed\": attribute drift is a separate plan read, opt-in through \u2318K \u2192 \"Re-check live with plan\". Docker + choudoufu (0.16.0+, or a build from main) on PATH; provider download on first init. scripts/choudoufu-down.sh removes the container.", "source": "bundled", "dir": "example-choudoufu-estate", "requires": [ diff --git a/smoke/stub.mjs b/smoke/stub.mjs index 516fab2..c9a0f12 100644 --- a/smoke/stub.mjs +++ b/smoke/stub.mjs @@ -513,6 +513,15 @@ const CHOUDOUFU_IR = { edges: [], }; const CHOUDOUFU_VOCABULARY = { of: "choudoufu", labels: { good: "bound", warn: "unowned", accent: "pending", neutral: "not observed", runtime: "runtime child" } }; +// #404: the attribute drift a plan would revert, on the BOUND card — the case +// the ownership half cannot see, because both markers are intact on either +// side of the change. Served only for `?plan=1`, so the stub reproduces the +// opt-in: an ordinary overlay read gets `{read: false}` and no mark at all. +const CHOUDOUFU_PLAN_DRIFT = { actions: ["update"], attributes: ["tags"] }; +const CHOUDOUFU_PLAN_CHANGES = [ + { path: "tags", oldValue: { drifted: "out-of-band", "tofu-estate": "terralith-4" }, newValue: { "tofu-estate": "terralith-4" } }, + { path: "image_tag_mutability", oldValue: "IMMUTABLE", newValue: "MUTABLE" }, +]; const CHOUDOUFU_PROJECT = { projectDir: "/estates/terralith-4", recents: [], @@ -673,6 +682,13 @@ export function startStub(port, { carve = false, nonChant = false, choudoufu = f // layer without a project on disk. `server.layout` lets the test read and // seed it as if it were the file. const layout = new Map(); + // #404: the server's plan cache, modelled. `?plan=1` reads a plan and stores + // it under the member's source stamp; every read AFTER that one is served + // from the store without re-planning, which is why the SPA's `/api/diff` + // fetch carries no `plan=1` of its own and still gets the attributes. A stub + // that only answered the flagged request would make the client look broken + // where the server is doing exactly what it was asked to. + let choudoufuPlanned = false; const carvePosts = []; /** #284 item 2: what the pending gate card's Approve button actually sent — * so the smoke asserts the wire contract (the EXISTING op-signal route), not @@ -823,16 +839,46 @@ export function startStub(port, { carve = false, nonChant = false, choudoufu = f }; if (path === "/api/project") return json(CHOUDOUFU_PROJECT); if (path === "/api/graph" || path === "/api/overlay") { + // #404: the plan read is opt-in, so the mark and the count exist only + // on the request that asked for them. + if (url.searchParams.get("plan") === "1") choudoufuPlanned = true; + const planned = choudoufuPlanned; + const ir = { + ...CHOUDOUFU_IR, + nodes: CHOUDOUFU_IR.nodes.map((n) => + planned && n.id === "terralith-4/aws_ecs_cluster.main" ? { ...n, attrs: { ...n.attrs, _planDrift: CHOUDOUFU_PLAN_DRIFT } } : n, + ), + }; return json({ - ir: CHOUDOUFU_IR, + ir, svg: CHOUDOUFU_SVG, - meta: { projectDir: CHOUDOUFU_PROJECT.projectDir, env: "live", tier: null, target: null, estate: 1, mode: "overlay", vocabulary: CHOUDOUFU_VOCABULARY }, + meta: { + projectDir: CHOUDOUFU_PROJECT.projectDir, + env: "live", + tier: null, + target: null, + estate: 1, + mode: "overlay", + vocabulary: CHOUDOUFU_VOCABULARY, + drift: planned ? { read: true, drifted: 1 } : { read: false }, + }, }); } if (path === "/api/diff") { + if (url.searchParams.get("plan") === "1") choudoufuPlanned = true; + const planned = choudoufuPlanned; return json({ env: "live", nodes: { + "terralith-4/aws_ecs_cluster.main": { + observed: { type: "aws_ecs_cluster", physicalId: "terralith-4-main", ownership: "owned", attributes: { bound: CHOUDOUFU_IR.nodes[0].attrs.bound } }, + // The ownership verdict is unchanged by the drift — `healthy` + // either way, which is the whole of #404's second bullet. + diff: planned ? { category: "planned", changes: CHOUDOUFU_PLAN_CHANGES } : null, + health: "healthy", + healthDetail: CHOUDOUFU_IR.nodes[0].attrs.bound, + fieldDrift: null, + }, "terralith-4/aws_cloudwatch_log_group.extra": { observed: { type: "aws_cloudwatch_log_group", diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index 6358d76..84bbbfa 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -1198,6 +1198,44 @@ try { await chdfPage.waitForTimeout(100); check("the copy button confirms", (await copy.innerText()).includes("copied")); + // ---- #404: attribute drift, opt-in, as a SECOND signal ---------------- + // Before the plan is read the estate looks exactly as #404 reported it: + // every card bound and green while a plan would change one of them. + check("no card is marked drifted before the plan is read", (await chdfPage.locator("#graph [data-node-id].plan-drifted").count()) === 0); + check("the statusbar says nothing about drift", !meta.includes("drifted")); + check("the legend says the question was not asked, and prints no count", legend.includes("attribute drift not read") && !/drifted \(/.test(legend)); + + // The palette's second row reads the plan. + await chdfPage.keyboard.press("Meta+k"); + await chdfPage.waitForTimeout(200); + await chdfPage.keyboard.type("Re-check live with plan"); + await chdfPage.waitForTimeout(200); + await chdfPage.keyboard.press("Enter"); + await chdfPage.waitForSelector("#graph [data-node-id].plan-drifted", { timeout: 20000 }); + + const marked = chdfPage.locator('#graph [data-node-id="terralith-4/aws_ecs_cluster.main"]'); + check("the drifted card wears the mark", (await marked.locator('[data-plan-drift="1"]').count()) === 1); + // textContent, not innerText: the mark is an SVG , not an HTMLElement. + check("…as a count, so a long attribute name cannot run off the card", (await marked.locator('[data-plan-drift="1"]').textContent()).trim() === "~ 1 attribute"); + // The whole of #404's second bullet: ownership is still the card's colour. + check("…and the card is still painted bound, not demoted", (await chdfPage.locator("#graph [data-node-id].plan-drifted").count()) === 1); + const meta2 = await chdfPage.locator("#meta").innerText(); + check("the statusbar counts the drift beside the ownership counts", meta2.includes("1 bound") && meta2.includes("1 drifted")); + + await chdfPage.click('#panel-tabs button[data-tab="model"]'); + await chdfPage.waitForTimeout(100); + const legend2 = await chdfPage.locator("#tab-model").innerText(); + check("the legend gains a drifted row with its count", /drifted \(the plan would change it\)/.test(legend2) && !legend2.includes("attribute drift not read")); + + // The pane names the attributes, before → after. + await chdfPage.click('[data-node-id="terralith-4/aws_ecs_cluster.main"]'); + await chdfPage.waitForTimeout(400); + const driftPane = await chdfPage.locator("#inspect-body").innerText(); + check("the inspect pane says what the plan would do", driftPane.includes("the plan would change this")); + check("…names the changed attributes", driftPane.includes("tags") && driftPane.includes("image_tag_mutability")); + check("…and prints a scalar as before → after", driftPane.includes('"IMMUTABLE" → "MUTABLE"')); + check("…while the card's health stays healthy", driftPane.includes("healthy")); + check("no console errors on a choudoufu estate", chdfErrors.length === 0); if (chdfErrors.length) console.error("choudoufu page errors:", chdfErrors); await chdfPage.screenshot({ path: join(SHOTS, "10-choudoufu-vocabulary.png") }); diff --git a/src/__fixtures__/choudoufu-plan-iam-ecr-clean.json b/src/__fixtures__/choudoufu-plan-iam-ecr-clean.json new file mode 100644 index 0000000..24d2203 --- /dev/null +++ b/src/__fixtures__/choudoufu-plan-iam-ecr-clean.json @@ -0,0 +1 @@ +{"format_version":"1.2","terraform_version":"1.13.0-dev","planned_values":{"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"sensitive_values":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"identity_schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPAI30Z0WCR1SF1DE8D"},"sensitive_values":{}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"identity_schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"sensitive_values":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"identity_schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"identity_schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"identity_schema_version":0}]}},"resource_changes":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"after":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"after_unknown":{},"before_sensitive":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"after_sensitive":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"}}},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPAI30Z0WCR1SF1DE8D"},"after":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPAI30Z0WCR1SF1DE8D"},"after_unknown":{},"before_sensitive":{},"after_sensitive":{}}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"after":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"}}},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"after":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"after_unknown":{},"before_sensitive":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"after_sensitive":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"}}},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"after":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"after_identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"}}},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"after":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"}}}],"prior_state":{"format_version":"1.0","terraform_version":"1.13.0","values":{"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"sensitive_values":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"identity_schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPAI30Z0WCR1SF1DE8D"},"sensitive_values":{}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"sensitive_values":{"tags":{},"tags_all":{}},"depends_on":["aws_iam_role.iam-ecr"],"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"identity_schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"sensitive_values":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"identity_schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"identity_schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"identity_schema_version":0}]}}},"configuration":{"provider_config":{"aws":{"name":"aws","full_name":"registry.opentofu.org/hashicorp/aws","version_constraint":"6.59.0","expressions":{"s3_use_path_style":{"constant_value":true},"skip_credentials_validation":{"constant_value":true},"skip_metadata_api_check":{"constant_value":true}}}},"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_config_key":"aws","expressions":{"image_scanning_configuration":[{"scan_on_push":{"constant_value":true}}],"image_tag_mutability":{"constant_value":"MUTABLE"},"name":{"constant_value":"tofu-iam-ecr-cohort-ecr-repository"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-group"},"path":{"constant_value":"/users/"}},"schema_version":0},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-instance-profile"},"role":{"references":["aws_iam_role.iam-ecr.name","aws_iam_role.iam-ecr"]},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_config_key":"aws","expressions":{"assume_role_policy":{},"name":{"constant_value":"tofu-iam-ecr-cohort-iam-role"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_config_key":"aws","expressions":{"aws_service_name":{"constant_value":"elasticbeanstalk.amazonaws.com"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-user"},"path":{"constant_value":"/system/"},"tags":{"references":["local.estate_tag"]}},"schema_version":0}]}},"timestamp":"2026-09-10T21:06:25Z","errored":false} diff --git a/src/__fixtures__/choudoufu-plan-iam-ecr-create.json b/src/__fixtures__/choudoufu-plan-iam-ecr-create.json new file mode 100644 index 0000000..5f4ea52 --- /dev/null +++ b/src/__fixtures__/choudoufu-plan-iam-ecr-create.json @@ -0,0 +1 @@ +{"format_version":"1.2","terraform_version":"1.13.0-dev","planned_values":{"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"sensitive_values":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"identity_schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"name":"tofu-iam-ecr-cohort-iam-group","path":"/users/"},"sensitive_values":{}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"identity_schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"sensitive_values":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"identity_schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"identity_schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"identity_schema_version":0}]}},"resource_changes":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"after":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"after_unknown":{},"before_sensitive":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"after_sensitive":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"}}},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"name":"tofu-iam-ecr-cohort-iam-group","path":"/users/"},"after_unknown":{"arn":true,"id":true,"unique_id":true},"before_sensitive":false,"after_sensitive":{}}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"after":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"}}},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"after":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"after_unknown":{},"before_sensitive":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"after_sensitive":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"}}},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"after":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"after_identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"}}},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"after":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"}}}],"prior_state":{"format_version":"1.0","terraform_version":"1.13.0","values":{"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"sensitive_values":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"identity_schema_version":0},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"sensitive_values":{"tags":{},"tags_all":{}},"depends_on":["aws_iam_role.iam-ecr"],"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"identity_schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"sensitive_values":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"identity_schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"identity_schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"identity_schema_version":0}]}}},"configuration":{"provider_config":{"aws":{"name":"aws","full_name":"registry.opentofu.org/hashicorp/aws","version_constraint":"6.59.0","expressions":{"s3_use_path_style":{"constant_value":true},"skip_credentials_validation":{"constant_value":true},"skip_metadata_api_check":{"constant_value":true}}}},"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_config_key":"aws","expressions":{"image_scanning_configuration":[{"scan_on_push":{"constant_value":true}}],"image_tag_mutability":{"constant_value":"MUTABLE"},"name":{"constant_value":"tofu-iam-ecr-cohort-ecr-repository"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-group"},"path":{"constant_value":"/users/"}},"schema_version":0},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-instance-profile"},"role":{"references":["aws_iam_role.iam-ecr.name","aws_iam_role.iam-ecr"]},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_config_key":"aws","expressions":{"assume_role_policy":{},"name":{"constant_value":"tofu-iam-ecr-cohort-iam-role"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_config_key":"aws","expressions":{"aws_service_name":{"constant_value":"elasticbeanstalk.amazonaws.com"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-user"},"path":{"constant_value":"/system/"},"tags":{"references":["local.estate_tag"]}},"schema_version":0}]}},"timestamp":"2026-09-10T21:06:47Z","errored":false} diff --git a/src/__fixtures__/choudoufu-plan-iam-ecr-drift.json b/src/__fixtures__/choudoufu-plan-iam-ecr-drift.json new file mode 100644 index 0000000..a744565 --- /dev/null +++ b/src/__fixtures__/choudoufu-plan-iam-ecr-drift.json @@ -0,0 +1 @@ +{"format_version":"1.2","terraform_version":"1.13.0-dev","planned_values":{"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"sensitive_values":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"identity_schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPA3OJ70UAPO5B4E7UQ"},"sensitive_values":{}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"identity_schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"sensitive_values":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"identity_schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"identity_schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"identity_schema_version":0}]}},"resource_changes":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["update"],"before":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"IMMUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"after":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"MUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"after_unknown":{},"before_sensitive":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"after_sensitive":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"}}},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPA3OJ70UAPO5B4E7UQ"},"after":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPA3OJ70UAPO5B4E7UQ"},"after_unknown":{},"before_sensitive":{},"after_sensitive":{}}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"after":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"}}},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["update"],"before":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"drifted":"out-of-band","tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"drifted":"out-of-band","tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"after":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"after_unknown":{},"before_sensitive":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"after_sensitive":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"}}},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"after":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"after_identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"}}},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","change":{"actions":["no-op"],"before":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"after":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"after_unknown":{},"before_sensitive":{"tags":{},"tags_all":{}},"after_sensitive":{"tags":{},"tags_all":{}},"before_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"after_identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"}}}],"prior_state":{"format_version":"1.0","terraform_version":"1.13.0","values":{"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:ecr:us-east-1:000000000000:repository/tofu-iam-ecr-cohort-ecr-repository","encryption_configuration":[{"encryption_type":"AES256","kms_key":""}],"force_delete":null,"id":"tofu-iam-ecr-cohort-ecr-repository","image_scanning_configuration":[{"scan_on_push":true}],"image_tag_mutability":"IMMUTABLE","image_tag_mutability_exclusion_filter":[],"name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1","registry_id":"000000000000","repository_url":"000000000000.dkr.ecr.us-east-1.localhost:5100/tofu-iam-ecr-cohort-ecr-repository","tags":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_ecr_repository.app","tofu-estate":"iam-ecr-cohort"},"timeouts":null},"sensitive_values":{"encryption_configuration":[{}],"image_scanning_configuration":[{}],"image_tag_mutability_exclusion_filter":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-ecr-repository","region":"us-east-1"},"identity_schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:group/users/tofu-iam-ecr-cohort-iam-group","id":"tofu-iam-ecr-cohort-iam-group","name":"tofu-iam-ecr-cohort-iam-group","path":"/users/","unique_id":"AGPA3OJ70UAPO5B4E7UQ"},"sensitive_values":{}},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:instance-profile/tofu-iam-ecr-cohort-iam-instance-profile","create_date":"2026-09-10T21:03:40Z","id":"tofu-iam-ecr-cohort-iam-instance-profile","name":"tofu-iam-ecr-cohort-iam-instance-profile","name_prefix":"","path":"/","role":"tofu-iam-ecr-cohort-iam-role","tags":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_instance_profile.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIPAK9PANADEL1HUN9ZD"},"sensitive_values":{"tags":{},"tags_all":{}},"depends_on":["aws_iam_role.iam-ecr"],"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-instance-profile"},"identity_schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/tofu-iam-ecr-cohort-iam-role","assume_role_policy":"{\"Statement\":[{\"Action\":\"sts:AssumeRole\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"}}],\"Version\":\"2012-10-17\"}","create_date":"2026-09-10T21:03:40Z","description":"","force_detach_policies":false,"id":"tofu-iam-ecr-cohort-iam-role","inline_policy":[],"managed_policy_arns":[],"max_session_duration":3600,"name":"tofu-iam-ecr-cohort-iam-role","name_prefix":"","path":"/","permissions_boundary":"","tags":{"drifted":"out-of-band","tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"tags_all":{"drifted":"out-of-band","tofu-address":"aws_iam_role.iam-ecr","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROADR3LVAFFNEFSJTXX"},"sensitive_values":{"inline_policy":[],"managed_policy_arns":[],"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-role"},"identity_schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","aws_service_name":"elasticbeanstalk.amazonaws.com","create_date":"2026-09-10T21:03:40Z","custom_suffix":"","description":"","id":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk","name":"AWSServiceRoleForElasticbeanstalk","path":"/aws-service-role/elasticbeanstalk.amazonaws.com/","tags":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_service_linked_role.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AROATPJU1AWS34V7EPX3"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"arn":"arn:aws:iam::000000000000:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticbeanstalk"},"identity_schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_name":"registry.opentofu.org/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:iam::000000000000:user/system/tofu-iam-ecr-cohort-iam-user","force_destroy":false,"id":"tofu-iam-ecr-cohort-iam-user","name":"tofu-iam-ecr-cohort-iam-user","path":"/system/","permissions_boundary":"","tags":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"tags_all":{"tofu-address":"aws_iam_user.app","tofu-estate":"iam-ecr-cohort"},"unique_id":"AIDAIY8TGPU5O4597U4Y"},"sensitive_values":{"tags":{},"tags_all":{}},"identity":{"account_id":"000000000000","name":"tofu-iam-ecr-cohort-iam-user"},"identity_schema_version":0}]}}},"configuration":{"provider_config":{"aws":{"name":"aws","full_name":"registry.opentofu.org/hashicorp/aws","version_constraint":"6.59.0","expressions":{"s3_use_path_style":{"constant_value":true},"skip_credentials_validation":{"constant_value":true},"skip_metadata_api_check":{"constant_value":true}}}},"root_module":{"resources":[{"address":"aws_ecr_repository.app","mode":"managed","type":"aws_ecr_repository","name":"app","provider_config_key":"aws","expressions":{"image_scanning_configuration":[{"scan_on_push":{"constant_value":true}}],"image_tag_mutability":{"constant_value":"MUTABLE"},"name":{"constant_value":"tofu-iam-ecr-cohort-ecr-repository"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_group.app","mode":"managed","type":"aws_iam_group","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-group"},"path":{"constant_value":"/users/"}},"schema_version":0},{"address":"aws_iam_instance_profile.app","mode":"managed","type":"aws_iam_instance_profile","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-instance-profile"},"role":{"references":["aws_iam_role.iam-ecr.name","aws_iam_role.iam-ecr"]},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_role.iam-ecr","mode":"managed","type":"aws_iam_role","name":"iam-ecr","provider_config_key":"aws","expressions":{"assume_role_policy":{},"name":{"constant_value":"tofu-iam-ecr-cohort-iam-role"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_service_linked_role.app","mode":"managed","type":"aws_iam_service_linked_role","name":"app","provider_config_key":"aws","expressions":{"aws_service_name":{"constant_value":"elasticbeanstalk.amazonaws.com"},"tags":{"references":["local.estate_tag"]}},"schema_version":0},{"address":"aws_iam_user.app","mode":"managed","type":"aws_iam_user","name":"app","provider_config_key":"aws","expressions":{"name":{"constant_value":"tofu-iam-ecr-cohort-iam-user"},"path":{"constant_value":"/system/"},"tags":{"references":["local.estate_tag"]}},"schema_version":0}]}},"timestamp":"2026-09-10T21:17:49Z","errored":false} diff --git a/src/choudoufu-live.test.ts b/src/choudoufu-live.test.ts index b1d9255..3b0d3e0 100644 --- a/src/choudoufu-live.test.ts +++ b/src/choudoufu-live.test.ts @@ -280,6 +280,43 @@ describe("choudoufuDiffNodes — the pane's live state (#370)", () => { // Unprefixed on a single-member serve. expect(Object.keys(choudoufuDiffNodes(doc, listing, plan("choudoufu-live-plan-monolith-clean.json")))).toContain("aws_iam_role.team_a"); }); + + // #404: the attribute half, when a plan was read for this member. + it("carries the plan's changed attributes as the pane's `diff`, before → after", () => { + const doc = check("choudoufu-live-check-monolith.json"); + const listing = ls("choudoufu-live-ls-monolith.json"); + const clean = plan("choudoufu-live-plan-monolith-clean.json"); + const drift = new Map([ + ["aws_iam_role.team_a", { actions: ["update"], changes: [{ path: "tags", oldValue: { extra: "1" }, newValue: {} }] }], + ]); + const nodes = choudoufuDiffNodes(doc, listing, clean, "mono", drift); + expect(nodes["mono/aws_iam_role.team_a"]).toMatchObject({ + // Still BOUND: attribute drift is a second signal, never a demotion — + // the health the pane prints is the ownership verdict, unchanged. + health: "healthy", + diff: { category: "planned", changes: [{ path: "tags", oldValue: { extra: "1" }, newValue: {} }] }, + // The k8s managed-fields question, which a Terraform plan never answers. + fieldDrift: null, + }); + // An address the plan did not change keeps the null it always had. + expect(nodes["mono/aws_iam_policy.team_a"].diff).toBeNull(); + // And with no plan read at all, nothing anywhere carries a diff. + const none = choudoufuDiffNodes(doc, listing, clean, "mono"); + expect(Object.values(none).every((n) => n.diff === null)).toBe(true); + }); + + it("never shows plan drift on a card that is not bound", () => { + const doc = check("choudoufu-live-check-monolith.json"); + doc.instances.push({ address: "aws_cloudwatch_log_group.extra", type: "aws_cloudwatch_log_group", rung: "tag-governable" }); + const unowned = plan("choudoufu-live-plan-monolith-unowned-and-renamed.json"); + const drift = new Map([["aws_cloudwatch_log_group.extra", { actions: ["update"], changes: [{ path: "retention_in_days", oldValue: 1, newValue: 7 }] }]]); + const nodes = choudoufuDiffNodes(doc, ls("choudoufu-live-ls-monolith.json"), unowned, "mono", drift); + // UNOWNED is the answer this card owes a reader; an attribute diff beside + // it would be behold arguing with its own overlay (`paintPlanDrift` skips + // the same card, so the two can never disagree). + expect(nodes["mono/aws_cloudwatch_log_group.extra"].health).toBe("degraded"); + expect(nodes["mono/aws_cloudwatch_log_group.extra"].diff).toBeNull(); + }); }); describe("projectChoudoufuLogical — the estate box (#370)", () => { diff --git a/src/choudoufu-live.ts b/src/choudoufu-live.ts index 4506745..bd6f0d9 100644 --- a/src/choudoufu-live.ts +++ b/src/choudoufu-live.ts @@ -375,6 +375,51 @@ export function paintChoudoufu(ir: GraphIR, ls: LiveLsDocument | undefined, plan return ir; } +// --------------------------------------------------------------------------- +// The second signal: attribute drift (#404). +// --------------------------------------------------------------------------- + +/** + * What a card wears when the plan would change a resource the estate owns. + * `_`-prefixed, so it is a signal the SPA reads and not an inspect row. + */ +export interface PlanDriftMark { + /** The plan's own verb(s) for this address: `["update"]`, or a replace. */ + actions: string[]; + /** The changed attribute NAMES, for the card's glyph and its tooltip. The + * values live in `/api/diff`, which is where a pane can afford them. */ + attributes: string[]; +} + +/** + * Mark the bound cards a plan would change. Mutates and returns `ir`. + * + * The ownership verdict stays the card's colour (#404's second bullet): this + * only ADDS `_planDrift`, and never touches `_status`. A resource that is not + * bound is skipped on purpose — an unowned object's planned create is an + * ownership fact the overlay already paints, not an attribute that moved, and + * `planDrift` drops creates for the same reason. + * + * Keyed by the COMPOSED id (`/
`) when a member name is given, + * because this runs over the composed estate IR at the route, not inside the + * member's own read — the plan is opt-in per request and has no business in + * `via.read`, which also answers `/api/graph`. + */ +export function paintPlanDrift(ir: GraphIR, drift: Map, member?: string): number { + if (!drift.size) return 0; + let marked = 0; + for (const n of ir.nodes) { + if (n.lexicon !== CHOUDOUFU_LEXICON || n.attrs._status !== "good") continue; + const address = member && n.id.startsWith(`${member}/`) ? n.id.slice(member.length + 1) : n.id; + const d = drift.get(address); + if (!d) continue; + const mark: PlanDriftMark = { actions: d.actions, attributes: d.changes.map((c) => c.path) }; + n.attrs = { ...n.attrs, _planDrift: mark }; + marked++; + } + return marked; +} + // --------------------------------------------------------------------------- // The read. // --------------------------------------------------------------------------- @@ -486,9 +531,26 @@ export interface DiffNode { * a member name is given, so the pane's lookup by node id lands. `health` is * the pane's own vocabulary: `healthy` for a bound instance, `degraded` for * an unowned object, `unknown` otherwise; `healthDetail` is the verdict's own - * sentence. `diff` is null — choudoufu has no snapshot to drift from; the - * plan IS the drift, and it is on the card. */ -export function choudoufuDiffNodes(check: LiveCheckDocument, ls: LiveLsDocument, plan: LivePlanDocument, member?: string): Record { + * sentence. + * + * `diff` is null unless a plan was read for this member (#404, `plan=1`). + * choudoufu has no snapshot to drift from, so there is no `driftedSinceSnapshot` + * to report — but a plan document names the attributes that differ between the + * live object and the configuration, and that is what the pane's `diff` + * section renders: one row per attribute, `before → after`. The category is + * `planned` rather than chant's `drifted`, because "drifted since snapshot" is + * a claim about a baseline that does not exist here — see web/app.js's + * DIFF_LABEL. + * + * `fieldDrift` stays null: it is the k8s managed-fields question (which + * MANAGER owns a path), and a Terraform plan answers nothing about it. */ +export function choudoufuDiffNodes( + check: LiveCheckDocument, + ls: LiveLsDocument, + plan: LivePlanDocument, + member?: string, + drift?: Map, +): Record { const key = (address: string): string => (member ? `${member}/${address}` : address); const out: Record = {}; const seen = new Set(); @@ -516,7 +578,9 @@ export function choudoufuDiffNodes(check: LiveCheckDocument, ls: LiveLsDocument, attributes: { ...v.attrs, ...(i.rung ? { rung: i.rung } : {}) }, } : null, - diff: null, + // #404: only for a BOUND instance — the same gate `paintPlanDrift` + // applies, so the card's mark and the pane's rows can never disagree. + diff: v?._status === "good" && drift?.has(i.address) ? { category: "planned", changes: drift.get(i.address)!.changes } : null, health, ...(detail ? { healthDetail: detail } : {}), fieldDrift: null, diff --git a/src/choudoufu-plan.test.ts b/src/choudoufu-plan.test.ts new file mode 100644 index 0000000..b7da345 --- /dev/null +++ b/src/choudoufu-plan.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { GraphIR } from "@intentius/chant"; +import { paintPlanDrift } from "./choudoufu-live.ts"; +import { KNOWN_AFTER_APPLY, SENSITIVE, parsePlanShow, planArgv, planDrift, readChoudoufuPlan, type PlanShowDocument } from "./choudoufu-plan.ts"; + +// Fixture provenance (#404). Printed by choudoufu v0.16.0 (based on OpenTofu +// v1.13.0-dev) against a scratch floci, from behold's own workbench entry +// `choudoufu-cohort-iam-ecr` — `node bin/behold.js demo choudoufu-cohort-iam-ecr +// /tmp/beh404 --port 4820`, which renders choudoufu's `iam-ecr` cohort with +// tools/estate-gen (6 resources, a sidecar-declared estate), boots the floci +// `behold-wb-choudoufu-cohort-iam-ecr` on 127.0.0.1:4653 and applies it clean. +// Nothing hand-edited. Every document is `choudoufu show -json` of a plan file +// written by `choudoufu plan -input=false -out=…` in the target, with +// AWS_ENDPOINT_URL=http://127.0.0.1:4653 and the dummy test credentials. +// +// The three, and the out-of-band change that explains each: +// +// 1. choudoufu-plan-iam-ecr-clean.json — the estate exactly as applied. +// All 6 `resource_changes` are `no-op`; the terminal says "No changes." +// 2. choudoufu-plan-iam-ecr-drift.json — TWO attributes changed out of band, +// one a map and one a scalar, so both of the pane's rendering paths are +// covered by a real document: +// aws iam tag-role --role-name tofu-iam-ecr-cohort-iam-role \ +// --tags Key=drifted,Value=out-of-band +// aws ecr put-image-tag-mutability \ +// --repository-name tofu-iam-ecr-cohort-ecr-repository \ +// --image-tag-mutability IMMUTABLE +// `aws_iam_role.iam-ecr` is `update` on `tags`/`tags_all` (the estate's two +// ownership markers survive on both sides — the drift is the third key); +// `aws_ecr_repository.app` is `update` on `image_tag_mutability`, +// "IMMUTABLE" -> "MUTABLE". The terminal prints "Plan: 0 to add, 2 to +// change, 0 to destroy." +// 3. choudoufu-plan-iam-ecr-create.json — the group deleted out of band +// (`aws iam delete-group --group-name tofu-iam-ecr-cohort-iam-group`), so +// the plan proposes `create` for `aws_iam_group.app` with `before: null`. +// Recorded because a create is exactly what must NOT read as attribute +// drift (#404); the estate was restored with `choudoufu apply` afterwards. +const HERE = dirname(fileURLToPath(import.meta.url)); +const raw = (name: string): string => readFileSync(join(HERE, "__fixtures__", name), "utf8"); +const ok = { code: 0, stderr: "" }; +const doc = (name: string): PlanShowDocument => { + const p = parsePlanShow({ ...ok, stdout: raw(name) }, "/est"); + if (!p.ok) throw new Error(p.refusal.error); + return p.doc; +}; + +const CLEAN = "choudoufu-plan-iam-ecr-clean.json"; +const DRIFT = "choudoufu-plan-iam-ecr-drift.json"; +const CREATE = "choudoufu-plan-iam-ecr-create.json"; + +describe("the plan document (#404)", () => { + it("reads a real `choudoufu show -json` — format 1.2, one entry per declared resource", () => { + const d = doc(DRIFT); + expect(d.format_version).toBe("1.2"); + expect(d.resource_changes).toHaveLength(6); + }); + + it("refuses what is not a plan document, and says why", () => { + expect(parsePlanShow({ ...ok, stdout: "" }, "/est")).toMatchObject({ ok: false, refusal: { code: "choudoufu-live-check" } }); + expect(parsePlanShow({ ...ok, stdout: "not json" }, "/est")).toMatchObject({ ok: false, refusal: { error: expect.stringContaining("not JSON") } }); + expect(parsePlanShow({ ...ok, stdout: '{"resource_changes":[]}' }, "/est")).toMatchObject({ ok: false, refusal: { error: expect.stringContaining("format_version") } }); + // A missing binary is its own remedy, not a credentials problem. + expect(parsePlanShow({ code: 127, stdout: "", stderr: "" }, "/est")).toMatchObject({ ok: false, refusal: { remedy: expect.stringContaining("PATH") } }); + }); +}); + +describe("planDrift — what counts as attribute drift (#404)", () => { + it("names the changed attributes of an updated resource, with before and after", () => { + const drift = planDrift(doc(DRIFT)); + expect([...drift.keys()].sort()).toEqual(["aws_ecr_repository.app", "aws_iam_role.iam-ecr"]); + + // The scalar: the account holds IMMUTABLE, the configuration says MUTABLE. + // `oldValue`/`newValue` are the keys the pane's `diff` rows already read, + // and it prints them `before → after`. + const ecr = drift.get("aws_ecr_repository.app")!; + expect(ecr.actions).toEqual(["update"]); + expect(ecr.changes).toEqual([{ path: "image_tag_mutability", oldValue: "IMMUTABLE", newValue: "MUTABLE" }]); + + // The map: the out-of-band `drifted` key is the whole change — both + // ownership markers are identical on either side, which is exactly why + // the ownership half never noticed and the card stayed green. + const role = drift.get("aws_iam_role.iam-ecr")!; + expect(role.changes.map((c) => c.path)).toEqual(["tags", "tags_all"]); + const tags = role.changes.find((c) => c.path === "tags")!; + expect(tags.oldValue).toEqual({ drifted: "out-of-band", "tofu-address": "aws_iam_role.iam-ecr", "tofu-estate": "iam-ecr-cohort" }); + expect(tags.newValue).toEqual({ "tofu-address": "aws_iam_role.iam-ecr", "tofu-estate": "iam-ecr-cohort" }); + }); + + it("reports nothing for a clean estate — a no-op plan is not drift", () => { + const d = doc(CLEAN); + expect(d.resource_changes!.every((r) => r.change.actions.every((a) => a === "no-op"))).toBe(true); + expect(planDrift(d).size).toBe(0); + }); + + it("does NOT count a planned create — an absent resource has no attribute that drifted", () => { + const d = doc(CREATE); + const create = d.resource_changes!.find((r) => r.address === "aws_iam_group.app")!; + expect(create.change.actions).toEqual(["create"]); + expect(create.change.before).toBeNull(); + // The document HAS a change; `planDrift` still reports none, because the + // ownership overlay is what answers "this is not there" (#404). + expect(planDrift(d).size).toBe(0); + }); + + it("does not count a planned delete either — a removal is not a value that moved", () => { + const drift = planDrift({ + format_version: "1.2", + resource_changes: [{ address: "aws_iam_user.gone", change: { actions: ["delete"], before: { name: "gone" }, after: null } }], + }); + expect(drift.size).toBe(0); + }); + + it("counts a replace, where both sides exist and the values really differ", () => { + const drift = planDrift({ + format_version: "1.2", + resource_changes: [{ address: "aws_iam_user.a", change: { actions: ["delete", "create"], before: { path: "/old/" }, after: { path: "/new/" } } }], + }); + expect(drift.get("aws_iam_user.a")!.changes).toEqual([{ path: "path", oldValue: "/old/", newValue: "/new/" }]); + }); + + it("redacts a value the provider marked sensitive, and still names the attribute", () => { + const drift = planDrift({ + format_version: "1.2", + resource_changes: [ + { + address: "aws_db_instance.a", + change: { actions: ["update"], before: { password: "hunter2" }, after: { password: "correct-horse" }, before_sensitive: { password: true }, after_sensitive: { password: true } }, + }, + ], + }); + // The NAME is the useful part and is safe; the values are not behold's to + // print into a pane because a plan happened to read them. + expect(drift.get("aws_db_instance.a")!.changes).toEqual([{ path: "password", oldValue: SENSITIVE, newValue: SENSITIVE }]); + }); + + it("says `(known after apply)` where the plan cannot know the value yet", () => { + const drift = planDrift({ + format_version: "1.2", + resource_changes: [{ address: "aws_iam_role.a", change: { actions: ["update"], before: { arn: "arn:old" }, after: {}, after_unknown: { arn: true } } }], + }); + expect(drift.get("aws_iam_role.a")!.changes).toEqual([{ path: "arn", oldValue: "arn:old", newValue: KNOWN_AFTER_APPLY }]); + }); + + it("compares deeply — an identical nested value is not a change", () => { + const drift = planDrift({ + format_version: "1.2", + resource_changes: [{ address: "a.b", change: { actions: ["update"], before: { tags: { a: "1" }, list: [1, 2] }, after: { tags: { a: "1" }, list: [1, 3] } } }], + }); + expect(drift.get("a.b")!.changes.map((c) => c.path)).toEqual(["list"]); + }); +}); + +describe("readChoudoufuPlan — the two spawns (#404)", () => { + const captured = (name: string) => ({ ...ok, stdout: raw(name) }); + + it("runs `plan -input=false -out=` then `show -json `, both in the member's directory", async () => { + const calls: { args: string[]; cwd: string }[] = []; + const drift = await readChoudoufuPlan("/est/mono", async (args, cwd) => { + calls.push({ args, cwd }); + return args[0] === "plan" ? { ...ok, stdout: "" } : captured(DRIFT); + }); + expect(calls.map((c) => c.cwd)).toEqual(["/est/mono", "/est/mono"]); + expect(calls[0].args.slice(0, 2)).toEqual(["plan", "-input=false"]); + expect(calls[1].args.slice(0, 2)).toEqual(["show", "-json"]); + // The plan file is the SAME path on both spawns, and it is behold's own + // scratch — never a path inside the served member, which would be a write + // into someone's source (AGENTS.md, "Invariant"). + const out = calls[0].args[2].replace(/^-out=/, ""); + expect(calls[1].args[2]).toBe(out); + expect(out.startsWith("/est/mono")).toBe(false); + expect(drift.size).toBe(2); + }); + + it("pins the argv it documents", () => { + expect(planArgv("/tmp/x/plan.tfplan")).toEqual({ + plan: ["plan", "-input=false", "-out=/tmp/x/plan.tfplan"], + show: ["show", "-json", "/tmp/x/plan.tfplan"], + }); + }); + + it("throws when the plan spawn fails, with choudoufu's own line", async () => { + await expect( + readChoudoufuPlan("/est/mono", async (args) => (args[0] === "plan" ? { code: 1, stdout: "", stderr: "Error: no valid credential sources" } : captured(DRIFT))), + ).rejects.toThrow(/no valid credential sources/); + }); + + it("removes its scratch directory even when the read throws", async () => { + const outs: string[] = []; + await expect( + readChoudoufuPlan("/est/mono", async (args) => { + if (args[0] === "plan") outs.push(args[2].replace(/^-out=/, "")); + return { code: 1, stdout: "", stderr: "Error: boom" }; + }), + ).rejects.toThrow(); + const { existsSync } = await import("node:fs"); + expect(outs).toHaveLength(1); + expect(existsSync(dirname(outs[0]))).toBe(false); + }); +}); + +describe("paintPlanDrift — the second signal on the card (#404)", () => { + const ir = (): GraphIR => + ({ + nodes: [ + { id: "mono/aws_iam_role.iam-ecr", kind: "aws_iam_role", lexicon: "choudoufu", ownership: "owned", attrs: { _status: "good" } }, + { id: "mono/aws_iam_group.app", kind: "aws_iam_group", lexicon: "choudoufu", attrs: { _status: "warn" } }, + { id: "mono/aws_iam_user.app", kind: "aws_iam_user", lexicon: "choudoufu", attrs: { _status: "good" } }, + { id: "mono/x", kind: "AWS::S3::Bucket", lexicon: "cfn", attrs: { _status: "good" } }, + ], + edges: [], + groups: {}, + }) as unknown as GraphIR; + + const drift = () => planDrift(doc(DRIFT)); + + it("marks a bound card and leaves its ownership colour alone", () => { + const g = ir(); + // The fixture's addresses, under this test's member name. + const d = new Map([["aws_iam_role.iam-ecr", drift().get("aws_iam_role.iam-ecr")!]]); + expect(paintPlanDrift(g, d, "mono")).toBe(1); + const node = g.nodes.find((n) => n.id === "mono/aws_iam_role.iam-ecr")!; + // The whole point of #404's second bullet: bound stays bound. + expect(node.attrs._status).toBe("good"); + expect(node.attrs._planDrift).toEqual({ actions: ["update"], attributes: ["tags", "tags_all"] }); + }); + + it("never marks a card that is not bound — an unowned object's plan entry is an ownership fact", () => { + const g = ir(); + const d = new Map([["aws_iam_group.app", { actions: ["update"], changes: [{ path: "path" }] }]]); + expect(paintPlanDrift(g, d, "mono")).toBe(0); + expect(g.nodes.find((n) => n.id === "mono/aws_iam_group.app")!.attrs._planDrift).toBeUndefined(); + }); + + it("touches no other lexicon's cards, and marks nothing on an empty change set", () => { + const g = ir(); + expect(paintPlanDrift(g, new Map())).toBe(0); + expect(paintPlanDrift(g, new Map([["x", { actions: ["update"], changes: [{ path: "a" }] }]]), "mono")).toBe(0); + expect(g.nodes.every((n) => n.attrs._planDrift === undefined)).toBe(true); + }); + + it("joins on the raw address when the estate serves one unnamed member", () => { + const g = ir(); + g.nodes[0].id = "aws_iam_role.iam-ecr"; + expect(paintPlanDrift(g, new Map([["aws_iam_role.iam-ecr", drift().get("aws_iam_role.iam-ecr")!]]))).toBe(1); + }); +}); diff --git a/src/choudoufu-plan.ts b/src/choudoufu-plan.ts new file mode 100644 index 0000000..b8f72e2 --- /dev/null +++ b/src/choudoufu-plan.ts @@ -0,0 +1,307 @@ +/** + * The choudoufu member's attribute drift (#404): what a plan would change on a + * resource the estate already owns. + * + * #370's live half answers OWNERSHIP — `live-ls -json` and `live-plan -json` + * say which declared instances are bound, unowned, omitted or invisible, and + * neither of them ever compares an attribute VALUE. So a resource whose tag was + * retagged out of band stays green: its markers are intact, and ownership is + * all the two documents were asked about. #404 is the other half of the + * question a person asks the terminal — "would a plan change anything?" + * + * ## The source, and why this one + * + * Two candidates were measured against a real floci estate (the workbench's + * `choudoufu-cohort-iam-ecr`, 6 resources, applied clean, one IAM role tag + * added out of band with `aws iam tag-role`), choudoufu v0.16.0: + * + * - `choudoufu plan -json` — NOT OpenTofu's `planned_change`/`resource_drift` + * UI stream. On 0.16.0 the flag prints choudoufu's OWN ownership document: + * a single pretty-printed object of `{estate, choudoufu_version, + * upstream_version, bound, omissions, unowned, adoptable, swept, + * diagnostics}` — the same shape `live-plan -json` prints, and the shape + * src/choudoufu-live.ts already reads. It carries no attribute values at + * all: with the out-of-band tag in place, the string `drifted` (the tag's + * own key) appeared nowhere in the document. It cannot answer #404. + * - `choudoufu plan -out=` then `choudoufu show -json ` — the + * stock OpenTofu plan document, `format_version` 1.2, with + * `resource_changes[].change.{actions,before,after}` carrying the full + * attribute maps on both sides. On the same estate it named the changed + * attribute and both its values. + * + * So the second one, and the exact argv is pinned here (both spawns in the + * member's directory, through `captureChoudoufu` so a test seam covers them): + * + * choudoufu plan -input=false -out=/plan.tfplan + * choudoufu show -json /plan.tfplan + * + * `` is a fresh `mkdtemp` under the OS temp directory and is removed on + * the way out — NEVER a path inside the served member. behold's write surface + * is `.behold/layout.json` and nothing else (AGENTS.md, "Invariant"), and a + * plan file dropped in someone's estate would be a write into their source. + * `-input=false` so a spawn can never sit waiting on a prompt nobody can see. + * + * ## What counts as attribute drift + * + * A change is attribute drift only when the plan has BOTH a `before` and an + * `after` object for the resource — an `update`, or a replace + * (`["delete","create"]`), where two states of the same live object can + * actually be compared. A planned `create` has `before: null`: there is no + * live object whose attributes could have drifted, and #404 says so directly — + * an unowned resource's planned create is not attribute drift, it is the + * ownership verdict the overlay already paints. A planned `delete` has + * `after: null` and is a removal, not a value that moved. `no-op` is the clean + * case. + * + * Values marked sensitive by the provider (`before_sensitive` / + * `after_sensitive`) are reported as changed by NAME and their values are + * replaced with `(sensitive)`. behold shows truth, but it has no business + * printing a secret into an inspect pane because a plan happened to read one. + * An attribute the plan cannot know until apply (`after_unknown`) reports its + * after side as `(known after apply)`, which is OpenTofu's own phrase. + * + * Provider-computed twins (`tags` and `tags_all`) are both reported. They are + * both in the document and both in the terminal's own output; a skip-list of + * "internal" attribute names would differ per provider and guessing it is + * worse than showing what the plan says. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { captureChoudoufu, ChoudoufuReadError, stripAnsiLines, type Captured, type ChoudoufuRefusal } from "./choudoufu-member.ts"; +import type { Runner } from "./choudoufu-live.ts"; + +// --------------------------------------------------------------------------- +// The document. +// --------------------------------------------------------------------------- + +/** One entry of the plan document's `resource_changes[]`, shallowly — the + * fields read here. */ +export interface PlanResourceChange { + address: string; + mode?: string; + type?: string; + name?: string; + change: { + actions: string[]; + before?: unknown; + after?: unknown; + after_unknown?: unknown; + before_sensitive?: unknown; + after_sensitive?: unknown; + }; +} + +/** `choudoufu show -json `, shallowly. */ +export interface PlanShowDocument { + format_version: string; + terraform_version?: string; + resource_changes?: PlanResourceChange[] | null; + errored?: boolean; +} + +export type PlanShowParse = { ok: true; doc: PlanShowDocument } | { ok: false; refusal: ChoudoufuRefusal }; + +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + +/** `ChoudoufuRefusal.code` is the one discriminator the SPA's precondition + * card and doctor render, and it stays `choudoufu-live-check` for every + * choudoufu refusal — src/choudoufu-moves.ts does the same for a move. The + * plan read is not a new kind of precondition failure (it is the same missing + * binary and the same missing credentials), so it does not earn a new code. */ +const refuse = (error: string, remedy: string): { ok: false; refusal: ChoudoufuRefusal } => ({ ok: false, refusal: { error, code: "choudoufu-live-check", remedy } }); + +const CREDS = + "Give behold the ambient read-only AWS credentials the estate's `plan` needs (AWS_PROFILE, or AWS_ENDPOINT_URL for an emulator), then re-check live with plan."; + +/** What a failed spawn said, as one line — `said` in src/choudoufu-live.ts. */ +function said(run: Captured, fallback: string): string { + const lines = stripAnsiLines(run.stderr); + return lines.find((l) => /^Error:/.test(l)) ?? lines[0] ?? fallback; +} + +/** Validate `choudoufu show -json` output. Shallow, like the live half's + * parsers: `format_version` and `resource_changes` are what is read. */ +export function parsePlanShow(run: Captured, dir: string): PlanShowParse { + if (run.code === 127) return refuse("choudoufu is not on PATH.", "Install choudoufu and put it on PATH."); + const text = run.stdout.trim(); + if (!text) return refuse(`choudoufu show could not read the plan for ${dir}: ${said(run, `exit ${run.code}`)}`, CREDS); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return refuse(`choudoufu show printed something that is not JSON for ${dir}: ${said(run, "unparseable stdout")}`, CREDS); + } + if (!isRecord(json) || typeof json.format_version !== "string") { + return refuse("That is not a plan document — no `format_version`.", CREDS); + } + if (json.resource_changes !== undefined && json.resource_changes !== null && !Array.isArray(json.resource_changes)) { + return refuse("That plan document's `resource_changes` is not a list.", CREDS); + } + return { ok: true, doc: json as unknown as PlanShowDocument }; +} + +// --------------------------------------------------------------------------- +// The change set. +// --------------------------------------------------------------------------- + +/** One attribute the plan would change, in the shape the inspect pane's `diff` + * section already renders (`{path, oldValue, newValue}`, web/app.js + * `renderDiff` — the pair prints as `before → after`). */ +export interface PlanAttributeChange { + path: string; + oldValue: unknown; + newValue: unknown; +} + +/** The plan's verdict for one address: what it would do, and which attributes + * differ. Only addresses with a non-empty `changes` reach the paint. */ +export interface PlanResourceDrift { + address: string; + actions: string[]; + changes: PlanAttributeChange[]; +} + +/** OpenTofu's own phrase for a value the plan cannot know yet. */ +export const KNOWN_AFTER_APPLY = "(known after apply)"; +/** What stands in for a value the provider marked sensitive. */ +export const SENSITIVE = "(sensitive)"; + +/** `after_unknown` / `*_sensitive` are parallel trees of booleans. Only the + * top-level attribute flag is read: an attribute is redacted or unknown as a + * whole, which is what the pane prints one row per. */ +const flagged = (tree: unknown, key: string): boolean => { + if (tree === true) return true; + if (!isRecord(tree)) return false; + const v = tree[key]; + return v === true || (isRecord(v) && Object.values(v).some((x) => x === true)) || (Array.isArray(v) && v.some((x) => x === true)); +}; + +/** Deep structural equality, enough for plan attribute values (JSON scalars, + * objects and arrays — no cycles, no class instances). */ +function same(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((x, i) => same(x, b[i])); + } + if (isRecord(a) && isRecord(b)) { + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + return ka.every((k) => Object.hasOwn(b, k) && same(a[k], b[k])); + } + return false; +} + +/** + * The attribute drift in a plan document, by address. + * + * Only an entry whose change has BOTH sides is considered — see the module + * header. An entry that has both sides but no differing attribute (a `no-op`) + * yields no drift and is left out entirely, so "in the map" means "the plan + * would change this". + */ +export function planDrift(doc: PlanShowDocument): Map { + const out = new Map(); + for (const rc of doc.resource_changes ?? []) { + if (!rc || typeof rc.address !== "string" || !isRecord(rc.change)) continue; + const { actions, before, after } = rc.change; + if (!Array.isArray(actions) || actions.every((a) => a === "no-op")) continue; + // A create has no `before` and a delete has no `after`: neither is an + // attribute that moved. #404's own example of what must NOT count. + if (!isRecord(before) || !isRecord(after)) continue; + const changes: PlanAttributeChange[] = []; + for (const key of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) { + const unknownAfter = flagged(rc.change.after_unknown, key); + if (!unknownAfter && same(before[key], after[key])) continue; + changes.push({ + path: key, + oldValue: flagged(rc.change.before_sensitive, key) ? SENSITIVE : before[key], + newValue: unknownAfter ? KNOWN_AFTER_APPLY : flagged(rc.change.after_sensitive, key) ? SENSITIVE : after[key], + }); + } + if (changes.length) out.set(rc.address, { address: rc.address, actions, changes }); + } + return out; +} + +// --------------------------------------------------------------------------- +// The read, and its cache. +// --------------------------------------------------------------------------- + +/** The two spawns, pinned so a test can assert them (see the module header for + * why this pair and not `plan -json`). */ +export const planArgv = (out: string): { plan: string[]; show: string[] } => ({ + plan: ["plan", "-input=false", `-out=${out}`], + show: ["show", "-json", out], +}); + +/** + * Read one member's attribute drift: `plan -out` into a scratch directory of + * behold's own, then `show -json` of what it wrote, then remove it. + * + * Throws `ChoudoufuReadError` on a refusal, the way `readChoudoufuLive` does — + * the caller turns that into "the plan was not read" rather than blanking the + * overlay, because the ownership half is still perfectly good. + */ +export async function readChoudoufuPlan(dir: string, run: Runner = captureChoudoufu): Promise> { + const scratch = mkdtempSync(join(tmpdir(), "behold-choudoufu-plan-")); + const file = join(scratch, "plan.tfplan"); + const argv = planArgv(file); + try { + const planRun = await run(argv.plan, dir); + if (planRun.code === 127) throw new ChoudoufuReadError({ error: "choudoufu is not on PATH.", code: "choudoufu-live-check", remedy: "Install choudoufu and put it on PATH." }, dir); + if (planRun.code !== 0) { + throw new ChoudoufuReadError({ error: `choudoufu plan could not plan ${dir}: ${said(planRun, `exit ${planRun.code}`)}`, code: "choudoufu-live-check", remedy: CREDS }, dir); + } + const parsed = parsePlanShow(await run(argv.show, dir), dir); + if (!parsed.ok) throw new ChoudoufuReadError(parsed.refusal, dir); + return planDrift(parsed.doc); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +/** + * The plan read is expensive — `plan` refreshes every resource in the estate, + * which is a full pass over the account (#404's third bullet). So it is cached + * per member under the member's SOURCE stamp, the same key half + * `memberIr` uses (src/member-ir.ts): an edit to the configuration makes the + * stored change set a statement about source that no longer exists, and a + * `plan=1` read replaces it anyway. + * + * What the stamp deliberately does NOT cover is the account. The cloud moves + * without touching a byte of source, which is the whole subject here — so a + * cached entry is never served to a request that ASKED for a plan (`plan=1` + * always re-reads, because "Re-check live" means re-check). It is served to + * the reads that did not ask: an ordinary overlay reload after a refresh shows + * the drift the refresh found instead of silently dropping it, and pays no + * spawn to do it. + */ +interface PlanCacheEntry { + stamp: string; + drift: Map; +} +const planCache = new Map(); + +/** Drop the cached plan for one member, or all of them. */ +export function resetChoudoufuPlanCache(dir?: string): void { + if (dir === undefined) planCache.clear(); + else planCache.delete(dir); +} + +/** The cached change set for a member whose source has not moved, if any. */ +export function cachedChoudoufuPlan(dir: string, stamp: string | undefined): Map | undefined { + if (stamp === undefined) return undefined; + const hit = planCache.get(dir); + return hit && hit.stamp === stamp ? hit.drift : undefined; +} + +/** Store a change set against the stamp the read was taken at. A member with + * no stamp (unreadable, missing) caches nothing — the same rule `memberIr` + * follows. */ +export function cacheChoudoufuPlan(dir: string, stamp: string | undefined, drift: Map): void { + if (stamp === undefined) return; + planCache.set(dir, { stamp, drift }); +} diff --git a/src/choudoufu-route.test.ts b/src/choudoufu-route.test.ts index 599370f..db00a34 100644 --- a/src/choudoufu-route.test.ts +++ b/src/choudoufu-route.test.ts @@ -30,9 +30,28 @@ function estate(): { root: string; mono: string; teamA: string } { return { root, mono: join(root, "mono"), teamA: join(root, "team-a") }; } +/** + * #404: what `show -json` answers here. Synthetic, and deliberately so — the + * recorded plan documents are from the iam-ecr cohort and name ITS addresses, + * which no roster in this file declares, so a join against them would prove + * nothing. The parser and the change-set rules are pinned against the real + * documents in src/choudoufu-plan.test.ts; this is only the shape the route + * has to carry from a spawn to a card, keyed to an address the monolith's own + * roster declares and `live-plan` reports bound. + */ +const FAKE_PLAN = JSON.stringify({ + format_version: "1.2", + resource_changes: [ + { address: "aws_iam_role.team_a", mode: "managed", change: { actions: ["update"], before: { tags: { drifted: "out-of-band" } }, after: { tags: {} } } }, + { address: "aws_iam_policy.team_a", mode: "managed", change: { actions: ["no-op"], before: { name: "p" }, after: { name: "p" } } }, + ], +}); + /** The spawn seam: live-check per member from its recorded roster, live-mv - * from the recorded dry run or refusal, live-ls from the recorded listings. - * Records every argv so a test can assert what was (not) run. */ + * from the recorded dry run or refusal, live-ls from the recorded listings, + * and (#404) the two-spawn plan read — `plan` writes a file and prints + * progress, `show -json` prints the document. Records every argv so a test can + * assert what was (not) run. */ function fakeChoudoufu(spawns: string[][]) { return async (args: string[], cwd: string) => { spawns.push(args); @@ -42,6 +61,8 @@ function fakeChoudoufu(spawns: string[][]) { if (verb === "live-mv") return { code: 0, stderr: "", stdout: args[4] === "aws_iam_role.team_b" ? raw("choudoufu-live-mv-refused.json") : raw("choudoufu-live-mv-cross-estate-dry-run.json") }; if (verb === "live-ls") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-ls-monolith.json") : raw("choudoufu-live-ls-team-a-after-split.json") }; if (verb === "live-plan") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-plan-monolith-clean.json") : raw("choudoufu-live-plan-team-a-after-split.json") }; + if (verb === "plan") return { code: 0, stderr: "", stdout: "" }; + if (verb === "show") return { code: 0, stderr: "", stdout: inMono ? FAKE_PLAN : '{"format_version":"1.2","resource_changes":[]}' }; return { code: 2, stderr: `unexpected ${verb}`, stdout: "" }; }; } @@ -130,6 +151,66 @@ describe("GET /api/choudoufu/moves (#371)", () => { expect(body.meta.vocabulary.note).toBeUndefined(); }); + // #404: attribute drift is opt-in, and the meta says which of the two + // "nothing is drifted" answers a reader is looking at. + describe("attribute drift from the plan (#404)", () => { + type Overlay = { ir: { nodes: { id: string; attrs: Record }[] }; meta: { drift?: { read: boolean; drifted?: number } } }; + const overlay = async (app: ReturnType["app"], q = "") => (await (await app.request(`/api/overlay?env=live${q}`)).json()) as Overlay; + + it("never plans on an ordinary overlay read, and says the question was not asked", async () => { + const spawns: string[][] = []; + const { app } = served(spawns); + const body = await overlay(app); + // The distinction the SPA renders: not `drifted: 0`, which would claim a + // plan ran and found nothing. + expect(body.meta.drift).toEqual({ read: false }); + expect(body.ir.nodes.every((n) => n.attrs._planDrift === undefined)).toBe(true); + // And no spawn was paid for it. + expect(spawns.map((a) => a[0])).not.toContain("plan"); + expect(spawns.map((a) => a[0])).not.toContain("show"); + }); + + it("plans on ?plan=1, marks the bound card and leaves its ownership colour alone", async () => { + const spawns: string[][] = []; + const { app } = served(spawns); + const body = await overlay(app, "&plan=1"); + expect(body.meta.drift).toEqual({ read: true, drifted: 1 }); + const node = body.ir.nodes.find((n) => n.id === "mono/aws_iam_role.team_a")!; + expect(node.attrs._status).toBe("good"); + expect(node.attrs._planDrift).toEqual({ actions: ["update"], attributes: ["tags"] }); + // The no-op entry in the same document earns no mark. + expect(body.ir.nodes.find((n) => n.id === "mono/aws_iam_policy.team_a")!.attrs._planDrift).toBeUndefined(); + // The two spawns the module header pins, in order, per choudoufu member. + expect(spawns.filter((a) => a[0] === "plan" || a[0] === "show").map((a) => a.slice(0, 2).join(" "))).toEqual(["plan -input=false", "show -json", "plan -input=false", "show -json"]); + }); + + it("a reload after a plan read serves the cached change set instead of planning again", async () => { + const spawns: string[][] = []; + const { app } = served(spawns); + await overlay(app, "&plan=1"); + const planned = spawns.filter((a) => a[0] === "plan").length; + const again = await overlay(app); // no ?plan=1 — an ordinary reload + // The drift is still on the card... + expect(again.meta.drift).toEqual({ read: true, drifted: 1 }); + expect(again.ir.nodes.find((n) => n.id === "mono/aws_iam_role.team_a")!.attrs._planDrift).toBeDefined(); + // ...and it cost nothing: the source has not moved, so the stored answer + // still stands. + expect(spawns.filter((a) => a[0] === "plan").length).toBe(planned); + }); + + it("names the changed attributes on /api/diff, in the pane's own before/after shape", async () => { + const { app } = served(); + const body = (await (await app.request("/api/diff?env=live&plan=1")).json()) as { + nodes: Record; + }; + expect(body.nodes["mono/aws_iam_role.team_a"]).toMatchObject({ + health: "healthy", // bound is bound — drift is the second signal, not a demotion + diff: { category: "planned", changes: [{ path: "tags", oldValue: { drifted: "out-of-band" }, newValue: {} }] }, + }); + expect(body.nodes["mono/aws_iam_policy.team_a"].diff).toBeNull(); + }); + }); + // #334: a fake choudoufu means no lexicon spawn — the overlay read used to // shell a real chant for the estate's references and sat right under the // five-second budget on a CI runner. A test that wants edges hands in a diff --git a/src/server.ts b/src/server.ts index 0b4425b..aa74bd5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -144,12 +144,13 @@ import { terraformElisionNoteShort, type TerraformElision, } from "./terraform-lens.ts"; -import { choudoufuDiffNodes, readChoudoufuLive, type Runner as ChoudoufuRunner } from "./choudoufu-live.ts"; +import { choudoufuDiffNodes, paintPlanDrift, readChoudoufuLive, type Runner as ChoudoufuRunner } from "./choudoufu-live.ts"; +import { cacheChoudoufuPlan, cachedChoudoufuPlan, readChoudoufuPlan, type PlanResourceDrift } from "./choudoufu-plan.ts"; import { choudoufuLexiconNote, setEstateLexiconRead, type LexiconRead } from "./choudoufu-refs.ts"; import { discoverCarvePlans, moveMembers, moveReceipt, movesPayload, readCarvePlan, type MoveMorphMoveInput } from "./choudoufu-moves.ts"; import { memberKindOf, memberKindSpec, servesAsEstate } from "./member-kind.ts"; import { TerraformReadError, discoverTerraformRoots, terraformRootsNote, terraformRootsNoteShort } from "./terraform-member.ts"; -import { invalidateMember, memberIr } from "./member-ir.ts"; +import { invalidateMember, memberIr, memberSourceStamp } from "./member-ir.ts"; import { carveStatesFor, carveStatesUnder } from "./carve-discovery.ts"; import { foreignNote, type GraphIRWithForeign } from "./foreign.ts"; import { Broadcaster, watchSources } from "./events.ts"; @@ -2580,6 +2581,57 @@ export function createApp( } }); + /** + * #404: the attribute drift half of a choudoufu member, per member. + * + * OPT-IN. `plan` refreshes every resource in the estate — a read per + * resource against the account, where the ownership half (`live-ls` / + * `live-plan`) costs the tagging index and is flat in the estate's size. So + * an ordinary overlay read never plans; `?plan=1` does, and that is the only + * thing that spawns one. + * + * A read WITHOUT `plan=1` still serves a change set already in the cache for + * a member whose source has not moved, so a reload after a plan refresh + * keeps showing what the refresh found rather than dropping it. `read` is + * false only when no plan was asked for AND none was cached — which is what + * lets the SPA tell "not looked" from "looked, nothing drifted". + * + * A member whose plan refuses (no credentials, a provider error) is skipped: + * the ownership overlay is untouched and perfectly good, and a failed + * SECOND opinion must not blank the first one. + */ + const choudoufuPlanDrift = async (dirs: string[], wanted: boolean): Promise<{ read: boolean; byDir: Map }> }> => { + const members = estateMembers(dirs).filter((m) => m.kind === "choudoufu"); + // Keyed by DIRECTORY, not by member name: a member's name is derived from + // its path, so a call scoped to one member (the single-node diff route) + // and a call over the whole estate must agree on the key, and the + // directory is the only thing both of them know for certain. + const byDir = new Map }>(); + let read = false; + for (const m of members) { + const stamp = memberSourceStamp(m.dir); + if (!wanted) { + const hit = cachedChoudoufuPlan(m.dir, stamp); + if (hit) { + read = true; + byDir.set(m.dir, { name: m.name, drift: hit }); + } + continue; + } + try { + const drift = await readChoudoufuPlan(m.dir, cfg.choudoufu?.run); + cacheChoudoufuPlan(m.dir, stamp, drift); + read = true; + byDir.set(m.dir, { name: m.name, drift }); + } catch { + // The plan could not be read for this member — the overlay's ownership + // colours stand, and `drift.read` stays false unless another member + // answered. + } + } + return { read, byDir }; + }; + // Live / overlay — the drift-coloured graph (chant #821, shipped in chant // 0.18.31). `chant graph --live --overlay` defaults to the source-anchored // overlay: declared edges (the cross-substrate topology) kept, live status @@ -2662,6 +2714,14 @@ export function createApp( // #379/#380/#382 — the same three, in the same order, as /api/graph's // estate branch. A live overlay adds colour, never a different picture. applyTerraformPasses(ir, detail); + // #404: the second signal on a bound choudoufu card. After the paint + // passes, because it reads `_status` (only a BOUND card can carry + // attribute drift) and only ever adds `_planDrift` beside it — the + // ownership verdict stays the card's colour. + const planWanted = new URL(c.req.url).searchParams.get("plan") === "1"; + const drift = await choudoufuPlanDrift(cfg.projectDirs, planWanted); + let driftedCards = 0; + for (const e of drift.byDir.values()) driftedCards += paintPlanDrift(ir, e.drift, e.name); const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects — ` + @@ -2753,7 +2813,21 @@ export function createApp( return c.json({ ir, svg, - meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, vocabulary, behaviour, ...(note ? { note } : {}) }, + meta: { + projectDir: cfg.projectDir, + env, + mode: "overlay", + estate: est.total, + vocabulary, + behaviour, + // #404: "not looked" and "looked, nothing drifted" are different + // answers and the SPA must be able to tell them apart — a legend + // that says nothing where a plan was never read is honest; one + // that says `0 drifted` there is a claim behold did not earn. + // The figure is CARDS marked, which is what the legend counts. + drift: drift.read ? { read: true, drifted: driftedCards } : { read: false }, + ...(note ? { note } : {}), + }, }); } // #261: `runtime` forces detail 3 exactly as `logical` does, and for the @@ -3003,11 +3077,15 @@ export function createApp( // A member that cannot be read contributes nothing; the overlay's cover // note already says why. const multi = !!cfg.projectDirs; + // #404: the same opt-in the overlay takes — `?plan=1` reads a plan, no + // flag serves one already cached for a member whose source has not moved, + // and neither one ever plans on a read that did not ask. + const diffDrift = await choudoufuPlanDrift(cfg.projectDirs ?? [cfg.projectDir], new URL(c.req.url).searchParams.get("plan") === "1"); for (const m of estateMembers(cfg.projectDirs ?? [cfg.projectDir])) { if (m.kind !== "choudoufu") continue; try { const r = await readChoudoufuLive(m.dir, {}); - Object.assign(nodes, choudoufuDiffNodes(r.check, r.ls, r.plan, multi ? m.name : undefined)); + Object.assign(nodes, choudoufuDiffNodes(r.check, r.ls, r.plan, multi ? m.name : undefined, diffDrift.byDir.get(m.dir)?.drift)); } catch { // painted unobserved on the overlay; nothing to slice } @@ -3141,7 +3219,10 @@ export function createApp( if (owner) { try { const r = await readChoudoufuLive(owner.dir, {}); - const entry = choudoufuDiffNodes(r.check, r.ls, r.plan, multi ? owner.name : undefined)[node]; + // #404: the same opt-in as the bulk route above, so one node's pane + // and the whole estate's pane cannot disagree about what drifted. + const one = await choudoufuPlanDrift([owner.dir], new URL(c.req.url).searchParams.get("plan") === "1"); + const entry = choudoufuDiffNodes(r.check, r.ls, r.plan, multi ? owner.name : undefined, one.byDir.get(owner.dir)?.drift)[node]; return c.json({ node, env, ...(entry ?? { diff: null, observed: null, health: "unknown", fieldDrift: null }) }); } catch (err) { return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); diff --git a/web/app.js b/web/app.js index 49ea1ed..1dfda39 100644 --- a/web/app.js +++ b/web/app.js @@ -329,6 +329,14 @@ function vocabularyNote() { const v = lastMeta && lastMeta.vocabulary; return (v && v.note) || ""; } +// #404: whether a plan was read for this overlay, and how many cards it would +// change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers +// — "nobody looked" versus "looked, nothing drifted" — and the legend must not +// print a count for the first one. Null from a behold that predates this, and +// every reader below treats null as "not read". +function driftMeta() { + return (lastMeta && lastMeta.drift) || null; +} // M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join // paints the same `_status` vocabulary (good/warn/accent/neutral) but with // different meaning — a stack-health reading, not "managed" — so the inspect @@ -930,6 +938,13 @@ function inspect(node) { // node is instant (no per-node query). Via apiFetch, so a static export replays // the captured snapshot. Cache is per env; cleared on lens change / after an op. let bulkDiffCache = null; // { env, nodes: { : { observed, diff, health } } } +// #404: ONE-SHOT. Set by the "Re-check live with plan" palette row and cleared +// by the request that carries it, so `plan=1` is exactly as opt-in as the +// person's keystroke — an ordinary load, a lens change or a reconnect never +// spawns a plan. The server still serves the change set that read produced +// from its own cache on the loads that follow, so the drift stays on screen +// without anyone paying for a second full pass over the account. +let planDriftOnce = false; async function loadNodeDiff(id) { if (!bulkDiffCache || bulkDiffCache.env !== view.env) { bulkDiffCache = null; @@ -1010,6 +1025,11 @@ function renderObserved(panel, o, health, healthDetail) { const DIFF_LABEL = { drifted: "drifted since snapshot", + // #404: a choudoufu member has no snapshot to drift FROM — the category is + // what a plan says it would do to the live object right now, so it does not + // borrow chant's "since snapshot" wording. The rows underneath are the same + // `{path, oldValue, newValue}` triples, printed `before → after`. + planned: "the plan would change this", missing: "declared, not in cloud", orphan: "in cloud, not declared", disappeared: "gone since snapshot", @@ -2048,6 +2068,17 @@ function renderPanelModel() { if ((k === "neutral" || k === "runtime") && !c[k]) continue; host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k]))); } + // #404: attribute drift, on its own row under the ownership counts — + // beside them, never mixed into them, because a drifted card is still + // bound and is already counted on the row above. The row appears only when + // a plan was actually read; where it was not, the legend says so in words + // and prints no number, because "0 drifted" would be a claim nobody made. + const dm = driftMeta(); + if (dm && dm.read) { + host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted))); + } else if (dm) { + host.appendChild(panelMuted("attribute drift not read — the overlay is ownership only. ⌘K → “Re-check live with plan”.")); + } // The actionable nodes — foreign (adoptable) and pending (not applied yet). const attention = ir.nodes.filter((n) => { const s = n.attrs && n.attrs._status; @@ -2414,6 +2445,58 @@ function markOperatorCards(ir) { } } +/** + * #404: the second signal on a bound choudoufu card — the plan would change an + * attribute on a resource the estate demonstrably owns. + * + * The ownership verdict keeps the card's FILL: bound is still green, and this + * only adds a stroke and a corner glyph, the same post-render stamp + * `markCarvedCards`/`markOperatorCards` use (pinhole's SVG arrives from the + * server as a string and is replaced on every render, so a mark has to be + * re-applied here rather than asked for at layout time). + * + * `~` is OpenTofu's own symbol for update-in-place, so the card and the + * terminal read the same. The corner is the bottom-right one the carve and + * operator marks also use — a choudoufu card is never carved (that is chant + * source) and never the operating loop's home (that is Kubernetes), so on the + * estates this can appear on the corner is free; the guard below still skips a + * card that already carries one of those, rather than drawing over it. + */ +function markDriftedCards(ir) { + const svg = document.querySelector("#graph svg"); + if (!svg) return; + for (const n of ir.nodes || []) { + const mark = n.attrs && n.attrs._planDrift; + if (!mark || !mark.attributes || !mark.attributes.length) continue; + const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]'); + if (!g || g.querySelector('[data-plan-drift="1"]')) continue; + if (g.querySelector('[data-carved="1"]') || g.querySelector('[data-operator="1"]')) continue; + g.classList.add("plan-drifted"); + // Measured, not read off attributes — markCarvedCards()'s note applies + // here verbatim (pinhole sizes a card from its content). + const rect = g.querySelector("rect"); + const box = rect && rect.getBBox ? rect.getBBox() : null; + const tag = document.createElementNS("http://www.w3.org/2000/svg", "text"); + tag.setAttribute("data-plan-drift", "1"); + tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10)); + tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9)); + tag.setAttribute("text-anchor", "end"); + tag.setAttribute("font-size", "11"); + tag.setAttribute("font-weight", "600"); + tag.setAttribute("fill", "var(--degraded)"); + // A COUNT, not the attribute names: a name like `image_tag_mutability` is + // wider than the corner and runs into the card's own subtitle (seen in the + // browser). The count is bounded whatever the provider calls its fields, + // and the names are one hover — or one click, in the pane — away. + const count_ = mark.attributes.length; + tag.textContent = `~ ${count_} attribute${count_ === 1 ? "" : "s"}`; + const title = document.createElementNS("http://www.w3.org/2000/svg", "title"); + title.textContent = `The plan would ${(mark.actions || ["update"]).join("+")} this: ${mark.attributes.join(", ")}. Ownership is unchanged — the card's colour is still its ownership verdict.`; + g.appendChild(title); + g.appendChild(tag); + } +} + function renderPanelCarve() { if (!carveMode()) return; if (!carveHost) { @@ -3232,6 +3315,11 @@ function render(ir, svg, m) { tail = ` · ${c.good} ${w.good} · ${c.warn} ${w.warn} · ${c.accent} ${w.accent}`; if (c.neutral) tail += ` · ${c.neutral} ${w.neutral}`; if (c.runtime) tail += ` · ${c.runtime} runtime`; + // #404: beside the ownership counts, never folded into them — a drifted + // card is bound and is already inside `c.good`. Silent when no plan was + // read, so the strip never implies the question was asked and answered. + const dm = driftMeta(); + if (dm && dm.read) tail += ` · ${dm.drifted} drifted`; // Nothing observed live in this env — explain the all-blue rather than let it // read as a bug (#32). if (c.good === 0 && c.warn === 0 && c.accent > 0) tail += ` — nothing deployed in ${m.env} yet`; @@ -3299,6 +3387,7 @@ function render(ir, svg, m) { markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on markOperatorCards(ir); // #234 free rider: same, for the operating loop's home + markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass renderDial(); } @@ -4179,6 +4268,12 @@ async function load(opts = {}) { // never in your source — so it rides the overlay and means nothing // without an env. if (view.runtime) q.set("runtime", "1"); + // #404: the attribute-drift read, when this load is the one the person + // asked for it on. Consumed here — the next load goes without it. + if (planDriftOnce) { + q.set("plan", "1"); + planDriftOnce = false; + } } // Radial layout (entity view only) — curl the wide DAG onto concentric rings. if (view.radial && !view.components && !view.logical && !view.ops) q.set("radial", "1"); @@ -4242,6 +4337,35 @@ async function load(opts = {}) { } } +/** + * #404: re-read the overlay AND ask for the attribute-drift plan. + * + * A second row rather than a flag on `refresh()` above, for two reasons. + * + * The costs are different in kind. The ownership read a refresh already makes + * (`live-ls` + `live-plan`) is answered out of the account's tagging index — + * a handful of calls whatever the estate's size. `plan` refreshes every + * resource, which is one provider read per card: on the workbench's + * terralith-4 that is 301 reads for one keypress. Putting them on one button + * would make the cheap, repeatable "did anything move?" silently cost the + * expensive question every time. + * + * And they are not even the same request. `refresh()` is `POST /api/refresh`, + * which re-observes the PRIMARY project and captures a lanes frame; it never + * composes the estate. A choudoufu member is served through the estate branch + * of `GET /api/overlay`, which is where the drift read lives — so this row + * goes through `load()`, the path that actually renders those cards. + */ +async function refreshWithPlan() { + planDriftOnce = true; + bulkDiffCache = null; // the pane's rows come from /api/diff — re-fetch them against the new plan + nowline("↻ planning — a full read of the account…"); + await load(); + const dm = driftMeta(); + if (dm && dm.read) nowline(dm.drifted ? `↻ plan read — ${dm.drifted} drifted` : "↻ plan read — nothing drifted"); + else nowline("↻ the plan could not be read — the overlay is ownership only"); +} + // Refresh (#24): re-check live drift now and capture a lanes frame, in one // round-trip. Renders the returned graph directly (no second pull); the server's // `frames` event updates the lanes view. @@ -5086,6 +5210,17 @@ function paletteCommands() { // Reads — always available, even in a static export or the preview lock. if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]); + // #404: the expensive sibling, listed beside it and named for what it costs. + // Offered only with an env picked — there is no overlay to plan against + // without one — and disabled-with-its-reason otherwise, the convention the + // colour-mode and carve rows below already follow. + if (!staticMode) { + c.push( + view.env + ? ["Re-check live with plan (attribute drift) — a full read of the account", () => refreshWithPlan()] + : ["Re-check live with plan (attribute drift) — pick an environment first", () => showToast("attribute drift is read from a plan, which needs an environment to plan against", false)], + ); + } c.push(["Fit graph to view", () => fitGraph()]); c.push(["Export: current graph as SVG", () => exportSvg()]); const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed"); diff --git a/web/index.html b/web/index.html index 4173cdd..adad77b 100644 --- a/web/index.html +++ b/web/index.html @@ -451,6 +451,13 @@ here", not "something is wrong". */ #graph [data-node-id].operator-home rect:first-of-type { stroke: var(--pending); stroke-width: 2; } + /* #404: a bound choudoufu card the plan would change in place. A DASHED + --degraded edge, deliberately not a fill: the fill is the ownership + verdict and attribute drift never moves it, so the two signals have to + live in different channels. Dashed rather than solid so it reads apart + from .carved's and .operator-home's solid strokes at a glance. */ + #graph [data-node-id].plan-drifted rect:first-of-type { stroke: var(--degraded); stroke-width: 2; stroke-dasharray: 4 3; } + /* The step the playhead sits on, on the graph itself — the ops lens marks it `_playhead` and the card wears a dashed edge, so "here" reads differently from "this one is done". */