diff --git a/.changes/1.15.0.md b/.changes/1.16.0.md similarity index 100% rename from .changes/1.15.0.md rename to .changes/1.16.0.md diff --git a/.changes/previous-releases.md b/.changes/previous-releases.md index d3f9501009..36de9d3ed0 100644 --- a/.changes/previous-releases.md +++ b/.changes/previous-releases.md @@ -1,3 +1,4 @@ +- [v1.15](https://github.com/hashicorp/terraform/blob/v1.15/CHANGELOG.md) - [v1.14](https://github.com/hashicorp/terraform/blob/v1.14/CHANGELOG.md) - [v1.13](https://github.com/hashicorp/terraform/blob/v1.13/CHANGELOG.md) - [v1.12](https://github.com/hashicorp/terraform/blob/v1.12/CHANGELOG.md) diff --git a/.changes/v1.13/BUG FIXES-20251009-144645.yaml b/.changes/v1.13/BUG FIXES-20251009-144645.yaml deleted file mode 100644 index 2f96fe0e9c..0000000000 --- a/.changes/v1.13/BUG FIXES-20251009-144645.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Fix crash when showing a cloud plan without having a cloud backend -time: 2025-10-09T14:46:45.59398+02:00 -custom: - Issue: "37751" diff --git a/.changes/v1.13/BUG FIXES-20251103-112034.yaml b/.changes/v1.13/BUG FIXES-20251103-112034.yaml deleted file mode 100644 index abe076ad3b..0000000000 --- a/.changes/v1.13/BUG FIXES-20251103-112034.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Allow filesystem functions to return inconsistent results when evaluated within provider configuration -time: 2025-11-03T11:20:34.913068-05:00 -custom: - Issue: "37854" diff --git a/.changes/v1.14/BUG FIXES-20250924-110416.yaml b/.changes/v1.14/BUG FIXES-20250924-110416.yaml deleted file mode 100644 index 3ddb833d6e..0000000000 --- a/.changes/v1.14/BUG FIXES-20250924-110416.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'console and test: return explicit diagnostics when referencing resources that were not included in the most recent operation.' -time: 2025-09-24T11:04:16.860364+02:00 -custom: - Issue: "37663" diff --git a/.changes/v1.14/BUG FIXES-20250926-113318.yaml b/.changes/v1.14/BUG FIXES-20250926-113318.yaml deleted file mode 100644 index 3adfd48017..0000000000 --- a/.changes/v1.14/BUG FIXES-20250926-113318.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'query: generate unique resource identifiers for results of expanded list resources' -time: 2025-09-26T11:33:18.241184+02:00 -custom: - Issue: "37681" diff --git a/.changes/v1.14/BUG FIXES-20251024-164434.yaml b/.changes/v1.14/BUG FIXES-20251024-164434.yaml deleted file mode 100644 index c2fa4a88cf..0000000000 --- a/.changes/v1.14/BUG FIXES-20251024-164434.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'test: allow ephemeral outputs in root modules' -time: 2025-10-24T16:44:34.197847+02:00 -custom: - Issue: "37813" diff --git a/.changes/v1.14/BUG FIXES-20251029-175958.yaml b/.changes/v1.14/BUG FIXES-20251029-175958.yaml deleted file mode 100644 index e9f7d9d2ae..0000000000 --- a/.changes/v1.14/BUG FIXES-20251029-175958.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Combinations of replace_triggered_by and -replace could result in some instances not being replaced -time: 2025-10-29T17:59:58.326396-04:00 -custom: - Issue: "37833" diff --git a/.changes/v1.14/BUG FIXES-20251031-144915.yaml b/.changes/v1.14/BUG FIXES-20251031-144915.yaml deleted file mode 100644 index 8bc6c766e3..0000000000 --- a/.changes/v1.14/BUG FIXES-20251031-144915.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'providers lock: include providers required by terraform test' -time: 2025-10-31T14:49:15.121756+01:00 -custom: - Issue: "37851" diff --git a/.changes/v1.14/BUG FIXES-20251103-112034.yaml b/.changes/v1.14/BUG FIXES-20251103-112034.yaml deleted file mode 100644 index abe076ad3b..0000000000 --- a/.changes/v1.14/BUG FIXES-20251103-112034.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Allow filesystem functions to return inconsistent results when evaluated within provider configuration -time: 2025-11-03T11:20:34.913068-05:00 -custom: - Issue: "37854" diff --git a/.changes/v1.14/BUG FIXES-20251104-122322.yaml b/.changes/v1.14/BUG FIXES-20251104-122322.yaml deleted file mode 100644 index f8475db0aa..0000000000 --- a/.changes/v1.14/BUG FIXES-20251104-122322.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'query: improve error handling for missing identity schemas' -time: 2025-11-04T12:23:22.096828+01:00 -custom: - Issue: "37863" diff --git a/.changes/v1.14/BUG FIXES-20251124-150000.yaml b/.changes/v1.14/BUG FIXES-20251124-150000.yaml deleted file mode 100644 index 798c4744cc..0000000000 --- a/.changes/v1.14/BUG FIXES-20251124-150000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'actions: make after_create & after_update actions run after the resource has applied' -time: 2025-11-24T15:00:00.316597+01:00 -custom: - Issue: "37936" diff --git a/.changes/v1.14/BUG FIXES-20251208-170259.yaml b/.changes/v1.14/BUG FIXES-20251208-170259.yaml deleted file mode 100644 index 1f4f9dc708..0000000000 --- a/.changes/v1.14/BUG FIXES-20251208-170259.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: surface runtime issues with local values to user during plan' -time: 2025-12-08T17:02:59.971622+01:00 -custom: - Issue: "37980" diff --git a/.changes/v1.14/BUG FIXES-20251209-130050.yaml b/.changes/v1.14/BUG FIXES-20251209-130050.yaml deleted file mode 100644 index 97914871b9..0000000000 --- a/.changes/v1.14/BUG FIXES-20251209-130050.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: resource instance apply failures should not cause the resource instance state to be empty. -time: 2025-12-09T13:00:50.440436+01:00 -custom: - Issue: "37981" diff --git a/.changes/v1.14/BUG FIXES-20251209-230000.yaml b/.changes/v1.14/BUG FIXES-20251209-230000.yaml deleted file mode 100644 index d378f24610..0000000000 --- a/.changes/v1.14/BUG FIXES-20251209-230000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: change absolute paths in path.module/path.root to be relative, as documented' -time: 2025-12-09T23:00:00.316597+00:00 -custom: - Issue: "37982" diff --git a/.changes/v1.14/BUG FIXES-20260108-114527.yaml b/.changes/v1.14/BUG FIXES-20260108-114527.yaml deleted file mode 100644 index e5eae67efe..0000000000 --- a/.changes/v1.14/BUG FIXES-20260108-114527.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: Fixes an issue where any warning diagnostics generated during terraform query execution failed to render in the cloud backend session -time: 2026-01-08T11:45:27.489784-08:00 -custom: - Issue: "38040" diff --git a/.changes/v1.14/BUG FIXES-20260116-101253.yaml b/.changes/v1.14/BUG FIXES-20260116-101253.yaml deleted file mode 100644 index 6846bf3aa6..0000000000 --- a/.changes/v1.14/BUG FIXES-20260116-101253.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'Fixed an issue where terraform stacks validate was failing to resolve relative paths for modules' -time: 2026-01-16T10:12:53.854705-05:00 -custom: - Issue: "38025" diff --git a/.changes/v1.14/BUG FIXES-20260123-103307.yaml b/.changes/v1.14/BUG FIXES-20260123-103307.yaml deleted file mode 100644 index 7eac4e1b7d..0000000000 --- a/.changes/v1.14/BUG FIXES-20260123-103307.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: actions in modules without instances failed the plan graph -time: 2026-01-23T10:33:07.244665+01:00 -custom: - Issue: "38089" diff --git a/.changes/v1.14/BUGFIX-20250927-184134.yaml b/.changes/v1.14/BUGFIX-20250927-184134.yaml deleted file mode 100644 index a6772d3afb..0000000000 --- a/.changes/v1.14/BUGFIX-20250927-184134.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUGFIX -body: The CLI now summarizes the number of actions invoked during `terraform apply`, matching the plan output. -time: 2025-09-27T18:41:34.771437+02:00 -custom: - Issue: "37689" diff --git a/.changes/v1.14/ENHANCEMENTS-20250919-115253.yaml b/.changes/v1.14/ENHANCEMENTS-20250919-115253.yaml deleted file mode 100644 index 6f521d0c01..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20250919-115253.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: '`terraform stacks` command support for `-help` flag' -time: 2025-09-19T11:52:53.923764-04:00 -custom: - Issue: "37645" diff --git a/.changes/v1.14/ENHANCEMENTS-20250925-151237.yaml b/.changes/v1.14/ENHANCEMENTS-20250925-151237.yaml deleted file mode 100644 index 6e6c3381c7..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20250925-151237.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: "query: support offline validation of query files via -query flag in the validate command" -time: 2025-09-25T15:12:37.198573+02:00 -custom: - Issue: "37671" diff --git a/.changes/v1.14/ENHANCEMENTS-20251002-172626.yaml b/.changes/v1.14/ENHANCEMENTS-20251002-172626.yaml deleted file mode 100644 index 466b34fc14..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20251002-172626.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Updates to support the AWS European Sovereign Cloud -time: 2025-10-02T17:26:26.513708-04:00 -custom: - Issue: "37721" diff --git a/.changes/v1.14/ENHANCEMENTS-20251204-125848.yaml b/.changes/v1.14/ENHANCEMENTS-20251204-125848.yaml deleted file mode 100644 index 785f51cfb7..0000000000 --- a/.changes/v1.14/ENHANCEMENTS-20251204-125848.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Add component registry source resolution support to Terraform Stacks -time: 2025-12-04T12:58:48.622196-05:00 -custom: - Issue: "37888" diff --git a/.changes/v1.15/BUG FIXES-20251024-042900.yaml b/.changes/v1.15/BUG FIXES-20251024-042900.yaml deleted file mode 100644 index 989ac31a94..0000000000 --- a/.changes/v1.15/BUG FIXES-20251024-042900.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'testing: File-level error diagnostics are now included in JUnit XML skipped test elements, ensuring CI/CD pipelines can detect validation failures' -time: 2025-10-24T04:29:00.000000Z -custom: - Issue: "37801" diff --git a/.changes/v1.15/BUG FIXES-20251110-120921.yaml b/.changes/v1.15/BUG FIXES-20251110-120921.yaml deleted file mode 100644 index cc5ae0920b..0000000000 --- a/.changes/v1.15/BUG FIXES-20251110-120921.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: A refresh-only plan could result in a non-zero exit code with no changes -time: 2025-11-10T12:09:21.029489-05:00 -custom: - Issue: "37406" diff --git a/.changes/v1.15/BUG FIXES-20251112-033830.yaml b/.changes/v1.15/BUG FIXES-20251112-033830.yaml deleted file mode 100644 index 1cafafd43a..0000000000 --- a/.changes/v1.15/BUG FIXES-20251112-033830.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'cli: Fixed crash in `terraform show -json` when plan contains ephemeral resources with preconditions or postconditions' -time: 2025-11-12T03:38:30.000000-08:00 -custom: - Issue: "37834" diff --git a/.changes/v1.15/BUG FIXES-20251119-103000.yaml b/.changes/v1.15/BUG FIXES-20251119-103000.yaml deleted file mode 100644 index 28eba48641..0000000000 --- a/.changes/v1.15/BUG FIXES-20251119-103000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'cli: Fixed `terraform init -json` to properly format all backend configuration messages as JSON instead of plain text' -time: 2025-11-19T10:30:00.000000Z -custom: - Issue: "37911" diff --git a/.changes/v1.15/BUG FIXES-20251121-183045.yaml b/.changes/v1.15/BUG FIXES-20251121-183045.yaml deleted file mode 100644 index fca86c0bf7..0000000000 --- a/.changes/v1.15/BUG FIXES-20251121-183045.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: '`state show`: The `state show` command will now explicitly fail and return code 1 when it fails to render the named resources state' -time: 2025-11-21T18:30:45.571448Z -custom: - Issue: "37933" diff --git a/.changes/v1.15/BUG FIXES-20251201-114950.yaml b/.changes/v1.15/BUG FIXES-20251201-114950.yaml deleted file mode 100644 index cfa8ded58b..0000000000 --- a/.changes/v1.15/BUG FIXES-20251201-114950.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'apply: Terraform will raise an explicit error if a plan file intended for one workspace is applied against another workspace' -time: 2025-12-01T11:49:50.360928Z -custom: - Issue: "37954" diff --git a/.changes/v1.15/BUG FIXES-20251213-120000.yaml b/.changes/v1.15/BUG FIXES-20251213-120000.yaml deleted file mode 100644 index 3a9f434753..0000000000 --- a/.changes/v1.15/BUG FIXES-20251213-120000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'lifecycle: `replace_triggered_by` now reports an error when given an invalid attribute reference that does not exist in the target resource' -time: 2025-12-13T12:00:00.000000Z -custom: - Issue: "36740" diff --git a/.changes/v1.15/BUG FIXES-20251223-184516.yaml b/.changes/v1.15/BUG FIXES-20251223-184516.yaml deleted file mode 100644 index e3c65982a5..0000000000 --- a/.changes/v1.15/BUG FIXES-20251223-184516.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'backend: Fix nil pointer dereference crash during `terraform init` when the destination backend returns an error' -time: 2025-12-23T18:45:16.000000Z -custom: - Issue: "38027" diff --git a/.changes/v1.15/BUG FIXES-20260105-170648.yaml b/.changes/v1.15/BUG FIXES-20260105-170648.yaml deleted file mode 100644 index d0764cfb66..0000000000 --- a/.changes/v1.15/BUG FIXES-20260105-170648.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: send progress events if the plan fails for better UI integration' -time: 2026-01-05T17:06:48.252069+01:00 -custom: - Issue: "38039" diff --git a/.changes/v1.15/BUG FIXES-20260115-103000.yaml b/.changes/v1.15/BUG FIXES-20260115-103000.yaml deleted file mode 100644 index 61fec4c20f..0000000000 --- a/.changes/v1.15/BUG FIXES-20260115-103000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'stacks: component instances should report no-op plan/apply. This solves a UI inconsistency with convergence destroy plans ' -time: 2026-01-15T10:30:00.72402+01:00 -custom: - Issue: "38049" diff --git a/.changes/v1.15/BUG FIXES-20260210-113930.yaml b/.changes/v1.15/BUG FIXES-20260210-113930.yaml deleted file mode 100644 index d42670a8e5..0000000000 --- a/.changes/v1.15/BUG FIXES-20260210-113930.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'backend/http: Return conflicting lock info from HTTP backend instead of the lock that failed to be taken' -time: 2026-02-10T11:39:30.00000-08:00 -custom: - Issue: "38144" \ No newline at end of file diff --git a/.changes/v1.15/BUG FIXES-20260214-120000.yaml b/.changes/v1.15/BUG FIXES-20260214-120000.yaml deleted file mode 100644 index f4f4a12a54..0000000000 --- a/.changes/v1.15/BUG FIXES-20260214-120000.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: BUG FIXES -body: 'states: fixed a bug that caused Terraform to be unable to identify when two states had different output values. This may have caused issues in specific circumstances like backend migrations.' -time: 2026-02-14T12:00:00.000000+00:00 -custom: - Issue: "38181" diff --git a/.changes/v1.15/ENHANCEMENTS-20251022-162909.yaml b/.changes/v1.15/ENHANCEMENTS-20251022-162909.yaml deleted file mode 100644 index 5e78468cbf..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20251022-162909.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: 'ssh-based provisioner (file + remote-exec): Re-enable support for PowerShell' -time: 2025-10-22T16:29:09.342697+01:00 -custom: - Issue: "37794" diff --git a/.changes/v1.15/ENHANCEMENTS-20251027-132238.yaml b/.changes/v1.15/ENHANCEMENTS-20251027-132238.yaml deleted file mode 100644 index 7c4d0af699..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20251027-132238.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: terraform init log timestamps include millisecond precision -time: 2025-10-27T13:22:38.714891768-05:00 -custom: - Issue: "37818" diff --git a/.changes/v1.15/ENHANCEMENTS-20251107-140221.yaml b/.changes/v1.15/ENHANCEMENTS-20251107-140221.yaml deleted file mode 100644 index d25c1a2695..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20251107-140221.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: "init: skip dependencies declared in development override. This allows you to use `terraform init` with developer overrides and install dependencies that are not declared in the override file." -time: 2025-11-07T14:02:21.847382+01:00 -custom: - Issue: "37884" diff --git a/.changes/v1.15/ENHANCEMENTS-20260113-130449.yaml b/.changes/v1.15/ENHANCEMENTS-20260113-130449.yaml deleted file mode 100644 index 25b035e502..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260113-130449.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: "Terraform Test: Allow functions within mock blocks" -time: 2026-01-13T13:04:49.034917+01:00 -custom: - Issue: "34672" diff --git a/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml b/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml deleted file mode 100644 index a3d9aebb2f..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260120-172831.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: improve detection of deprecated resource attributes / blocks -time: 2026-01-20T17:28:31.861321+01:00 -custom: - Issue: "38077" diff --git a/.changes/v1.15/ENHANCEMENTS-20260209-142149.yaml b/.changes/v1.15/ENHANCEMENTS-20260209-142149.yaml deleted file mode 100644 index f933412f84..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260209-142149.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Deprecation messages providers set on resources / blocks / attributes are now part of the deprecation warning -time: 2026-02-09T14:21:49.076863+01:00 -custom: - Issue: "38135" diff --git a/.changes/v1.15/ENHANCEMENTS-20260219-143304.yaml b/.changes/v1.15/ENHANCEMENTS-20260219-143304.yaml deleted file mode 100644 index 7cb08cebaa..0000000000 --- a/.changes/v1.15/ENHANCEMENTS-20260219-143304.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: ENHANCEMENTS -body: Include which attribute paths are marked as sensitive in list_start JSON logs -time: 2026-02-19T14:33:04.832615-05:00 -custom: - Issue: "38197" diff --git a/.changes/v1.15/NEW FEATURES-20250926-164134.yaml b/.changes/v1.15/NEW FEATURES-20250926-164134.yaml deleted file mode 100644 index b55e44da38..0000000000 --- a/.changes/v1.15/NEW FEATURES-20250926-164134.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: We now produce builds for Windows ARM64 -time: 2025-09-26T16:41:34.771437+02:00 -custom: - Issue: "32719" diff --git a/.changes/v1.15/NEW FEATURES-20251205-171418.yaml b/.changes/v1.15/NEW FEATURES-20251205-171418.yaml deleted file mode 100644 index 4eceeadec3..0000000000 --- a/.changes/v1.15/NEW FEATURES-20251205-171418.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: You can set a `deprecated` attribute on variable and output blocks to indicate that they are deprecated. This will produce warnings when passing in a value for a deprecated variable or when referencing a deprecated output. -time: 2025-12-05T17:14:18.623477+01:00 -custom: - Issue: "38001" diff --git a/.changes/v1.15/NEW FEATURES-20260108-105919.yaml b/.changes/v1.15/NEW FEATURES-20260108-105919.yaml deleted file mode 100644 index e7e9252f12..0000000000 --- a/.changes/v1.15/NEW FEATURES-20260108-105919.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: 'backend/s3: Support authentication via `aws login`' -time: 2026-01-08T10:59:19.249387-05:00 -custom: - Issue: "37976" diff --git a/.changes/v1.15/NEW FEATURES-20260212-104240.yaml b/.changes/v1.15/NEW FEATURES-20260212-104240.yaml deleted file mode 100644 index 1567580d89..0000000000 --- a/.changes/v1.15/NEW FEATURES-20260212-104240.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: "validate: The validate command now checks the `backend` block. This ensures the backend type exists, that all required attributes are present, and that the backend's own validation logic passes." -time: 2026-02-12T10:42:40.333849Z -custom: - Issue: "38021" diff --git a/.changes/v1.15/NEW FEATURES-20260212-181401.yaml b/.changes/v1.15/NEW FEATURES-20260212-181401.yaml deleted file mode 100644 index 770c035a1e..0000000000 --- a/.changes/v1.15/NEW FEATURES-20260212-181401.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: NEW FEATURES -body: '`convert` function, which allows for precise inline type conversions' -time: 2026-02-12T18:14:01.356478-05:00 -custom: - Issue: "38160" diff --git a/.changes/v1.15/UPGRADE NOTES-20260107-154515.yaml b/.changes/v1.15/UPGRADE NOTES-20260107-154515.yaml deleted file mode 100644 index 31246c92cb..0000000000 --- a/.changes/v1.15/UPGRADE NOTES-20260107-154515.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: UPGRADE NOTES -body: 'backend/s3: The `AWS_USE_FIPS_ENDPOINT` and `AWS_USE_DUALSTACK_ENDPOINT` environment variables now only respect `true` or `false` values, aligning with the AWS SDK for Go. This replaces the previous behavior which treated any non-empty value as `true`.' -time: 2026-01-07T15:45:15.958679-05:00 -custom: - Issue: "37601" diff --git a/internal/configs/configload/testdata/add-version-constraint/.terraform/modules/child/empty.tf b/.changes/v1.16/.gitkeep similarity index 100% rename from internal/configs/configload/testdata/add-version-constraint/.terraform/modules/child/empty.tf rename to .changes/v1.16/.gitkeep diff --git a/.changie.yaml b/.changie.yaml index 4ed75a510d..f80f100db5 100644 --- a/.changie.yaml +++ b/.changie.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: BUSL-1.1 changesDir: .changes -unreleasedDir: v1.15 +unreleasedDir: v1.16 versionFooterPath: version_footer.tpl.md changelogPath: CHANGELOG.md versionExt: md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0194cc47c3..15397f4cfc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,9 +31,10 @@ label to enable the backport bot. --> -1.15.x +1.16.x + ## Rollback Plan - [ ] If a change needs to be reverted, we will roll out an update to the code within 7 days. diff --git a/.github/workflows/build-terraform-cli.yml b/.github/workflows/build-terraform-cli.yml index 7b4c3cab92..06b3244e48 100644 --- a/.github/workflows/build-terraform-cli.yml +++ b/.github/workflows/build-terraform-cli.yml @@ -85,13 +85,13 @@ jobs: echo "RPM_PACKAGE=$(basename out/*.rpm)" >> $GITHUB_ENV echo "DEB_PACKAGE=$(basename out/*.deb)" >> $GITHUB_ENV - if: ${{ inputs.goos == 'linux' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ env.RPM_PACKAGE }} path: out/${{ env.RPM_PACKAGE }} if-no-files-found: error - if: ${{ inputs.goos == 'linux' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ env.DEB_PACKAGE }} path: out/${{ env.DEB_PACKAGE }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 779c40757a..c6514ef73b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,7 +87,7 @@ jobs: version: ${{ needs.get-product-version.outputs.product-version }} product: ${{ env.PKG_NAME }} - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: metadata.json path: ${{ steps.generate-metadata-file.outputs.filepath }} @@ -258,7 +258,7 @@ jobs: fail-on-cache-miss: true enableCrossOsArchive: true - name: "Download Terraform CLI package" - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 id: clipkg with: name: terraform_${{env.version}}_${{ env.os }}_${{ env.arch }}.zip @@ -269,7 +269,7 @@ jobs: unzip "${{ needs.e2etest-build.outputs.e2e-cache-path }}/terraform-e2etest_${{ env.os }}_${{ env.arch }}.zip" unzip "./terraform_${{env.version}}_${{ env.os }}_${{ env.arch }}.zip" - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 if: ${{ contains(matrix.goarch, 'arm') }} with: platforms: all @@ -307,7 +307,7 @@ jobs: with: go-version: ${{ needs.get-go-version.outputs.go-version }} - name: Download Terraform CLI package - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 id: clipkg with: name: terraform_${{ env.version }}_linux_amd64.zip diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index f91eb3af9f..dc6a9950ce 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -35,7 +35,7 @@ jobs: steps: - name: "Changed files" - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: changelog with: filters: | diff --git a/.release/security-scan.hcl b/.release/security-scan.hcl index d287df78eb..1eac1d23ea 100644 --- a/.release/security-scan.hcl +++ b/.release/security-scan.hcl @@ -12,4 +12,12 @@ binary { go_modules = true osv = true nvd = false -} \ No newline at end of file + + triage { + suppress { + vulnerabilities = [ + "GHSA-p77j-4mvh-x3m3" + ] + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index fbab117451..8e00c4f5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,60 +1,4 @@ -## 1.15.0 (Unreleased) - - -NEW FEATURES: - -* We now produce builds for Windows ARM64 ([#32719](https://github.com/hashicorp/terraform/issues/32719)) - -* You can set a `deprecated` attribute on variable and output blocks to indicate that they are deprecated. This will produce warnings when passing in a value for a deprecated variable or when referencing a deprecated output. ([#38001](https://github.com/hashicorp/terraform/issues/38001)) - -* backend/s3: Support authentication via `aws login` ([#37967](https://github.com/hashicorp/terraform/issues/37967)) - -* validate: The validate command now checks the `backend` block. This ensures the backend type exists, that all required attributes are present, and that the backend's own validation logic passes. ([#38021](https://github.com/hashicorp/terraform/issues/38021)) - - -ENHANCEMENTS: - -* ssh-based provisioner (file + remote-exec): Re-enable support for PowerShell ([#37794](https://github.com/hashicorp/terraform/issues/37794)) - -* terraform init log timestamps include millisecond precision ([#37818](https://github.com/hashicorp/terraform/issues/37818)) - -* init: skip dependencies declared in development override. This allows you to use `terraform init` with developer overrides and install dependencies that are not declared in the override file. ([#37884](https://github.com/hashicorp/terraform/issues/37884)) - -* Terraform Test: Allow functions within mock blocks ([#34672](https://github.com/hashicorp/terraform/issues/34672)) - -* improve detection of deprecated resource attributes / blocks ([#38077](https://github.com/hashicorp/terraform/issues/38077)) - - -BUG FIXES: - -* testing: File-level error diagnostics are now included in JUnit XML skipped test elements, ensuring CI/CD pipelines can detect validation failures ([#37801](https://github.com/hashicorp/terraform/issues/37801)) - -* A refresh-only plan could result in a non-zero exit code with no changes ([#37406](https://github.com/hashicorp/terraform/issues/37406)) - -* cli: Fixed crash in `terraform show -json` when plan contains ephemeral resources with preconditions or postconditions ([#37834](https://github.com/hashicorp/terraform/issues/37834)) - -* cli: Fixed `terraform init -json` to properly format all backend configuration messages as JSON instead of plain text ([#37911](https://github.com/hashicorp/terraform/issues/37911)) - -* `state show`: The `state show` command will now explicitly fail and return code 1 when it fails to render the named resources state ([#37933](https://github.com/hashicorp/terraform/issues/37933)) - -* apply: Terraform will raise an explicit error if a plan file intended for one workspace is applied against another workspace ([#37954](https://github.com/hashicorp/terraform/issues/37954)) - -* lifecycle: `replace_triggered_by` now reports an error when given an invalid attribute reference that does not exist in the target resource ([#36740](https://github.com/hashicorp/terraform/issues/36740)) - -* backend: Fix nil pointer dereference crash during `terraform init` when the destination backend returns an error ([#38027](https://github.com/hashicorp/terraform/issues/38027)) - -* stacks: send progress events if the plan fails for better UI integration ([#38039](https://github.com/hashicorp/terraform/issues/38039)) - -* stacks: component instances should report no-op plan/apply. This solves a UI inconsistency with convergence destroy plans ([#38049](https://github.com/hashicorp/terraform/issues/38049)) - -* backend/http: Return conflicting lock info from HTTP backend instead of the lock that failed to be taken ([#38144](https://github.com/hashicorp/terraform/issues/38144)) - -* states: fixed a bug that caused Terraform to be unable to identify when two states had different output values. This may have caused issues in specific circumstances like backend migrations. ([#38181](https://github.com/hashicorp/terraform/issues/38181)) - - -UPGRADE NOTES: - -* backend/s3: The `AWS_USE_FIPS_ENDPOINT` and `AWS_USE_DUALSTACK_ENDPOINT` environment variables now only respect `true` or `false` values, aligning with the AWS SDK for Go. This replaces the previous behavior which treated any non-empty value as `true`. ([#37601](https://github.com/hashicorp/terraform/issues/37601)) +## 1.16.0 (Unreleased) EXPERIMENTS: @@ -71,6 +15,7 @@ Experiments are only enabled in alpha releases of Terraform CLI. The following f For information on prior major and minor releases, refer to their changelogs: +- [v1.15](https://github.com/hashicorp/terraform/blob/v1.15/CHANGELOG.md) - [v1.14](https://github.com/hashicorp/terraform/blob/v1.14/CHANGELOG.md) - [v1.13](https://github.com/hashicorp/terraform/blob/v1.13/CHANGELOG.md) - [v1.12](https://github.com/hashicorp/terraform/blob/v1.12/CHANGELOG.md) diff --git a/CODEOWNERS b/CODEOWNERS index d0ca00eb85..d0514d6574 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -32,3 +32,7 @@ builtin/provisioners/file @hashicorp/terraform-core builtin/provisioners/local-exec @hashicorp/terraform-core builtin/provisioners/remote-exec @hashicorp/terraform-core + +# Actions +/internal/command/jsonplan/action_invocations.go @hashicorp/team-tf-actions @hashicorp/terraform-core +/internal/plans/action_invocation.go @hashicorp/team-tf-actions @hashicorp/terraform-core diff --git a/LICENSE b/LICENSE index 8142708df2..ad310798d9 100644 --- a/LICENSE +++ b/LICENSE @@ -3,22 +3,22 @@ License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. Parameters -Licensor: HashiCorp, Inc. +Licensor: International Business Machines Corporation (IBM) Licensed Work: Terraform Version 1.6.0 or later. The Licensed Work is (c) 2024 - HashiCorp, Inc. + IBM Corp. Additional Use Grant: You may make production use of the Licensed Work, provided Your use does not include offering the Licensed Work to third parties on a hosted or embedded basis in order to compete with - HashiCorp's paid version(s) of the Licensed Work. For purposes + IBM Corp.'s paid version(s) of the Licensed Work. For purposes of this license: A "competitive offering" is a Product that is offered to third parties on a paid basis, including through paid support arrangements, that significantly overlaps with the capabilities - of HashiCorp's paid version(s) of the Licensed Work. If Your + of IBM Corp.'s paid version(s) of the Licensed Work. If Your Product is not a competitive offering when You first make it generally available, it will not become a competitive offering - later due to HashiCorp releasing a new version of the Licensed + later due to IBM Corp. releasing a new version of the Licensed Work with additional capabilities. In addition, Products that are not provided on a paid basis are not competitive. @@ -34,10 +34,10 @@ Additional Use Grant: You may make production use of the Licensed Work, provided Hosting or using the Licensed Work(s) for internal purposes within an organization is not considered a competitive - offering. HashiCorp considers your organization to include all + offering. IBM Corp. considers your organization to include all of your affiliates under common control. - For binding interpretive guidance on using HashiCorp products + For binding interpretive guidance on using IBM Corp. products under the Business Source License, please visit our FAQ. (https://www.hashicorp.com/license-faq) Change Date: Four years from the date the Licensed Work is published. diff --git a/go.mod b/go.mod index 39ac967166..57c25ac032 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/spf13/afero v1.15.0 github.com/xanzy/ssh-agent v0.3.3 github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 github.com/zclconf/go-cty-yaml v1.1.0 go.opentelemetry.io/contrib/exporters/autoexport v0.45.0 diff --git a/go.sum b/go.sum index 3773600891..a3d3e34673 100644 --- a/go.sum +++ b/go.sum @@ -750,8 +750,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/backendrun/const_variables.go b/internal/backend/backendrun/const_variables.go new file mode 100644 index 0000000000..6a85dfee25 --- /dev/null +++ b/internal/backend/backendrun/const_variables.go @@ -0,0 +1,23 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package backendrun + +import ( + "context" + + "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// ConstVariableSupplier is an optional interface that backends can implement +// to supply variable values from their remote storage. This is used to fetch +// const variable values that are needed during early configuration loading +// (e.g., for module source resolution), before a full operation is started. +type ConstVariableSupplier interface { + // FetchVariables retrieves Terraform variable values stored in the + // backend for the given workspace. Only variables that are relevant to + // Terraform (as opposed to environment variables or other categories) + // should be returned. + FetchVariables(ctx context.Context, workspace string) (map[string]arguments.UnparsedVariableValue, tfdiags.Diagnostics) +} diff --git a/internal/backend/backendrun/operation.go b/internal/backend/backendrun/operation.go index 071f7c7583..4168f7dbc1 100644 --- a/internal/backend/backendrun/operation.go +++ b/internal/backend/backendrun/operation.go @@ -14,7 +14,6 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/plans" @@ -175,15 +174,6 @@ func (o *Operation) HasConfig() bool { return o.ConfigLoader.IsConfigDir(o.ConfigDir) } -// Config loads the configuration that the operation applies to, using the -// ConfigDir and ConfigLoader fields within the receiving operation. -func (o *Operation) Config() (*configs.Config, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - config, hclDiags := o.ConfigLoader.LoadConfig(o.ConfigDir) - diags = diags.Append(hclDiags) - return config, diags -} - // ReportResult is a helper for the common chore of setting the status of // a running operation and showing any diagnostics produced during that // operation. diff --git a/internal/backend/backendrun/unparsed_value.go b/internal/backend/backendrun/unparsed_value.go index 95d8b3f8d6..298cc969fa 100644 --- a/internal/backend/backendrun/unparsed_value.go +++ b/internal/backend/backendrun/unparsed_value.go @@ -147,6 +147,19 @@ func isDefinedAny(name string, maps ...terraform.InputValues) bool { // that were successfully processed, allowing for careful analysis of the // partial result. func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + return parseVariableValues(vv, decls, false) +} + +// ParseConstVariableValues is like ParseVariableValues but only produces +// errors for missing const variables. Non-const required variables that are +// missing will still receive placeholder values but won't produce errors. +// This is used during early configuration loading (e.g. module installation) +// where only const variables are needed for module source resolution. +func ParseConstVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) (terraform.InputValues, tfdiags.Diagnostics) { + return parseVariableValues(vv, decls, true) +} + +func parseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable, constOnly bool) (terraform.InputValues, tfdiags.Diagnostics) { ret, diags := ParseDeclaredVariableValues(vv, decls) undeclared, diagsUndeclared := ParseUndeclaredVariableValues(vv, decls) @@ -166,14 +179,20 @@ func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls ma // specific error message which mentions -var and -var-file command // line options, whereas the one in Terraform Core is more general // due to supporting both root and child module variables. - if vc.Required() { + shouldError := vc.Required() + if constOnly { + shouldError = vc.Const && vc.Required() + } + if shouldError { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "No value for required variable", Detail: fmt.Sprintf("The root module input variable %q is not set, and has no default value. Use a -var or -var-file command line argument to provide a value for this variable.", name), Subject: vc.DeclRange.Ptr(), }) + } + if vc.Required() { // We'll include a placeholder value anyway, just so that our // result is complete for any calling code that wants to cautiously // analyze it for diagnostic purposes. Since our diagnostics now @@ -200,3 +219,18 @@ func ParseVariableValues(vv map[string]arguments.UnparsedVariableValue, decls ma return ret, diags } + +// HasUnsatisfiedConstVariables checks whether any const variables declared in +// the given module are required but not yet present in the provided variable +// values map. This is used to determine whether we need to fetch additional +// variable values from a backend before loading the full configuration. +func HasUnsatisfiedConstVariables(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) bool { + for name, vc := range decls { + if vc.Const && vc.Required() { + if _, defined := vv[name]; !defined { + return true + } + } + } + return false +} diff --git a/internal/backend/backendrun/unparsed_value_test.go b/internal/backend/backendrun/unparsed_value_test.go index 17822487ce..ce299016cb 100644 --- a/internal/backend/backendrun/unparsed_value_test.go +++ b/internal/backend/backendrun/unparsed_value_test.go @@ -221,6 +221,196 @@ func TestUnparsedValue(t *testing.T) { t.Errorf("wrong result\n%s", diff) } }) + + t.Run("ParseVariableValues constOnly", func(t *testing.T) { + vv := map[string]arguments.UnparsedVariableValue{ + "declared1": testUnparsedVariableValue("5"), + } + + decls := map[string]*configs.Variable{ + "declared1": { + Name: "declared1", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + }, + }, + "missing_const_required": { + Name: "missing_const_required", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + Const: true, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 3, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 3, Column: 1, Byte: 0}, + }, + }, + "missing_nonconst_required": { + Name: "missing_nonconst_required", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + Const: false, + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 4, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 4, Column: 1, Byte: 0}, + }, + }, + "missing_const_optional": { + Name: "missing_const_optional", + Type: cty.String, + ConstraintType: cty.String, + ParsingMode: configs.VariableParseLiteral, + Const: true, + Default: cty.StringVal("default"), + DeclRange: hcl.Range{ + Filename: "fake.tf", + Start: hcl.Pos{Line: 5, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 5, Column: 1, Byte: 0}, + }, + }, + } + + gotVals, diags := ParseConstVariableValues(vv, decls) + + if got, want := len(diags), 1; got != want { + t.Fatalf("wrong number of diagnostics %d; want %d", got, want) + } + + const missingRequired = `No value for required variable` + + if got, want := diags[0].Description().Summary, missingRequired; got != want { + t.Fatalf("wrong summary for diagnostic 0\ngot: %s\nwant: %s", got, want) + } + + if got, want := diags[0].Description().Detail, `"missing_const_required"`; !strings.Contains(got, want) { + t.Fatalf("wrong detail for diagnostic 0\ngot: %s\nmust contain: %s", got, want) + } + + wantVals := terraform.InputValues{ + "declared1": { + Value: cty.StringVal("5"), + SourceType: terraform.ValueFromNamedFile, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tfvars", + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + }, + }, + "missing_const_required": { + Value: cty.DynamicVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 3, Column: 1, Byte: 0}, + }, + }, + "missing_nonconst_required": { + Value: cty.DynamicVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 4, Column: 1, Byte: 0}, + }, + }, + "missing_const_optional": { + Value: cty.NilVal, + SourceType: terraform.ValueFromConfig, + SourceRange: tfdiags.SourceRange{ + Filename: "fake.tf", + Start: tfdiags.SourcePos{Line: 5, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 5, Column: 1, Byte: 0}, + }, + }, + } + + if diff := cmp.Diff(wantVals, gotVals, cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) +} + +func TestHasUnsatisfiedConstVariables(t *testing.T) { + testCases := map[string]struct { + vv map[string]arguments.UnparsedVariableValue + decls map[string]*configs.Variable + want bool + }{ + "no variables": { + vv: nil, + decls: map[string]*configs.Variable{}, + want: false, + }, + "no const variables": { + vv: nil, + decls: map[string]*configs.Variable{ + "regular": { + Name: "regular", + }, + }, + want: false, + }, + "const with default": { + vv: nil, + decls: map[string]*configs.Variable{ + "has_default": { + Name: "has_default", + Const: true, + Default: cty.StringVal("default"), + }, + }, + want: false, + }, + "const required and missing": { + vv: nil, + decls: map[string]*configs.Variable{ + "required_const": { + Name: "required_const", + Const: true, + }, + }, + want: true, + }, + "const required but provided": { + vv: map[string]arguments.UnparsedVariableValue{ + "required_const": testUnparsedVariableValue("value"), + }, + decls: map[string]*configs.Variable{ + "required_const": { + Name: "required_const", + Const: true, + }, + }, + want: false, + }, + "non-const required and missing": { + vv: nil, + decls: map[string]*configs.Variable{ + "regular_required": { + Name: "regular_required", + }, + }, + want: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got := HasUnsatisfiedConstVariables(tc.vv, tc.decls) + if got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } } type testUnparsedVariableValue string diff --git a/internal/backend/init/init.go b/internal/backend/init/init.go index 24f4c5787b..5b1549edea 100644 --- a/internal/backend/init/init.go +++ b/internal/backend/init/init.go @@ -93,6 +93,13 @@ func Backend(name string) backend.InitFn { return backends[name] } +func BackendExists(name string) bool { + backendsLock.Lock() + defer backendsLock.Unlock() + _, ok := backends[name] + return ok +} + // Set sets a new backend in the list of backends. If f is nil then the // backend will be removed from the map. If this backend already exists // then it will be overwritten. diff --git a/internal/backend/local/backend_apply_test.go b/internal/backend/local/backend_apply_test.go index 58a81ab0d2..424bfd000a 100644 --- a/internal/backend/local/backend_apply_test.go +++ b/internal/backend/local/backend_apply_test.go @@ -21,12 +21,12 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -364,7 +364,7 @@ func (s failingState) WriteState(state *states.State) error { func testOperationApply(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index a179352aed..90f63a6f25 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -146,39 +146,11 @@ func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *confi func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRun, coreOpts *terraform.ContextOpts, s statemgr.Full) (*backendrun.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - // Load the configuration using the caller-provided configuration loader. - config, configSnap, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + rootMod, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) diags = diags.Append(configDiags) if configDiags.HasErrors() { return nil, nil, diags } - run.Config = config - - if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { - var buf strings.Builder - for _, err := range errs { - fmt.Fprintf(&buf, "\n - %s", err.Error()) - } - var suggestion string - switch { - case op.DependencyLocks == nil: - // If we get here then it suggests that there's a caller that we - // didn't yet update to populate DependencyLocks, which is a bug. - suggestion = "This run has no dependency lock information provided at all, which is a bug in Terraform; please report it!" - case op.DependencyLocks.Empty(): - suggestion = "To make the initial dependency selections that will initialize the dependency lock file, run:\n terraform init" - default: - suggestion = "To update the locked dependency selections to match a changed configuration, run:\n terraform init -upgrade" - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Inconsistent dependency lock file", - fmt.Sprintf( - "The following dependency selections recorded in the lock file are inconsistent with the current configuration:%s\n\n%s", - buf.String(), suggestion, - ), - )) - } var rawVariables map[string]arguments.UnparsedVariableValue if op.AllowUnsetVariables { @@ -186,16 +158,16 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu // but unset variables with unknown values to represent that they are // placeholders for values the user would need to provide for other // operations. - rawVariables = b.stubUnsetRequiredVariables(op.Variables, config.Module.Variables) + rawVariables = b.stubUnsetRequiredVariables(op.Variables, rootMod.Variables) } else { // If interactive input is enabled, we might gather some more variable // values through interactive prompts. // TODO: Need to route the operation context through into here, so that // the interactive prompts can be sensitive to its timeouts/etc. - rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, config.Module.Variables, op.UIIn) + rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, rootMod.Variables, op.UIIn) } - variables, varDiags := backendrun.ParseVariableValues(rawVariables, config.Module.Variables) + variables, varDiags := backendrun.ParseVariableValues(rawVariables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags @@ -224,6 +196,52 @@ func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRu return nil, nil, diags } run.Core = tfCtx + + walkerSnapshot, configSnap := op.ConfigLoader.ModuleWalkerSnapshot() + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + walkerSnapshot, + variables, + configs.MockDataLoaderFunc(op.ConfigLoader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if buildDiags.HasErrors() { + return nil, nil, diags + } + run.Config = config + + snapDiags := op.ConfigLoader.AddRootModuleToSnapshot(configSnap, op.ConfigDir) + diags = diags.Append(snapDiags) + if snapDiags.HasErrors() { + return nil, nil, diags + } + + if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { + var buf strings.Builder + for _, err := range errs { + fmt.Fprintf(&buf, "\n - %s", err.Error()) + } + var suggestion string + switch { + case op.DependencyLocks == nil: + // If we get here then it suggests that there's a caller that we + // didn't yet update to populate DependencyLocks, which is a bug. + suggestion = "This run has no dependency lock information provided at all, which is a bug in Terraform; please report it!" + case op.DependencyLocks.Empty(): + suggestion = "To make the initial dependency selections that will initialize the dependency lock file, run:\n terraform init" + default: + suggestion = "To update the locked dependency selections to match a changed configuration, run:\n terraform init -upgrade" + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Inconsistent dependency lock file", + fmt.Sprintf( + "The following dependency selections recorded in the lock file are inconsistent with the current configuration:%s\n\n%s", + buf.String(), suggestion, + ), + )) + } + return run, configSnap, diags } @@ -235,6 +253,7 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade // A plan file has a snapshot of configuration embedded inside it, which // is used instead of whatever configuration might be already present // in the filesystem. + //TODO why not use pf.ReadConfig? snap, err := pf.ReadConfigSnapshot() if err != nil { diags = diags.Append(tfdiags.Sourceless( @@ -246,32 +265,16 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade } loader := configload.NewLoaderFromSnapshot(snap) loader.AllowLanguageExperiments(op.ConfigLoader.AllowsLanguageExperiments()) - config, configDiags := loader.LoadConfig(snap.Modules[""].Dir) - diags = diags.Append(configDiags) - if configDiags.HasErrors() { - return nil, snap, diags + rootMod, rootDiags := loader.LoadRootModule(snap.Modules[""].Dir) + diags = diags.Append(rootDiags) + if rootDiags.HasErrors() { + return nil, nil, diags } - run.Config = config - // NOTE: We're intentionally comparing the current locks with the - // configuration snapshot, rather than the lock snapshot in the plan file, - // because it's the current locks which dictate our plugin selections - // in coreOpts below. However, we'll also separately check that the - // plan file has identical locked plugins below, and thus we're effectively - // checking consistency with both here. - if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { - var buf strings.Builder - for _, err := range errs { - fmt.Fprintf(&buf, "\n - %s", err.Error()) - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Inconsistent dependency lock file", - fmt.Sprintf( - "The following dependency selections recorded in the lock file are inconsistent with the configuration in the saved plan:%s\n\nA saved plan can be applied only to the same configuration it was created from. Create a new plan from the updated configuration.", - buf.String(), - ), - )) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, nil, diags } // This check is an important complement to the check above: the locked @@ -359,6 +362,40 @@ func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reade return nil, nil, diags } run.Core = tfCtx + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + variables, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if buildDiags.HasErrors() { + return nil, nil, diags + } + run.Config = config + + // NOTE: We're intentionally comparing the current locks with the + // configuration snapshot, rather than the lock snapshot in the plan file, + // because it's the current locks which dictate our plugin selections + // in coreOpts below. However, we'll also separately check that the + // plan file has identical locked plugins below, and thus we're effectively + // checking consistency with both here. + if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { + var buf strings.Builder + for _, err := range errs { + fmt.Fprintf(&buf, "\n - %s", err.Error()) + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Inconsistent dependency lock file", + fmt.Sprintf( + "The following dependency selections recorded in the lock file are inconsistent with the configuration in the saved plan:%s\n\nA saved plan can be applied only to the same configuration it was created from. Create a new plan from the updated configuration.", + buf.String(), + ), + )) + } + return run, snap, diags } @@ -459,8 +496,8 @@ func (b *Local) interactiveCollectVariables(ctx context.Context, existing map[st func (b *Local) stubUnsetRequiredVariables(existing map[string]arguments.UnparsedVariableValue, vcs map[string]*configs.Variable) map[string]arguments.UnparsedVariableValue { var missing bool // Do we need to add anything? for name, vc := range vcs { - if !vc.Required() { - continue // We only stub required variables + if !vc.Required() || vc.Const { + continue // We only stub non-const required variables } if _, exists := existing[name]; !exists { missing = true @@ -475,7 +512,7 @@ func (b *Local) stubUnsetRequiredVariables(existing map[string]arguments.Unparse maps.Copy(ret, existing) // don't use clone here, so we can return a non-nil map for name, vc := range vcs { - if !vc.Required() { + if !vc.Required() || vc.Const { continue } if _, exists := existing[name]; !exists { diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index ce8fd3a25a..9c169bd4a1 100644 --- a/internal/backend/local/backend_local_test.go +++ b/internal/backend/local/backend_local_test.go @@ -19,7 +19,6 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/schemarepo" @@ -27,6 +26,7 @@ import ( "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -34,7 +34,7 @@ func TestLocalRun(t *testing.T) { configDir := "./testdata/empty" b := TestLocal(t) - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() streams, _ := terminal.StreamsForTesting(t) @@ -65,7 +65,7 @@ func TestLocalRun_error(t *testing.T) { // should then cause LocalRun to return with the state unlocked. b.Backend = backendWithStateStorageThatFailsRefresh{} - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() streams, _ := terminal.StreamsForTesting(t) @@ -92,7 +92,7 @@ func TestLocalRun_cloudPlan(t *testing.T) { configDir := "./testdata/apply" b := TestLocal(t) - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() planPath := "./testdata/plan-bookmark/bookmark.json" @@ -127,7 +127,7 @@ func TestLocalRun_stalePlan(t *testing.T) { configDir := "./testdata/apply" b := TestLocal(t) - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() // Write an empty state file with serial 3 diff --git a/internal/backend/local/backend_plan_test.go b/internal/backend/local/backend_plan_test.go index a47693354a..38d6619f69 100644 --- a/internal/backend/local/backend_plan_test.go +++ b/internal/backend/local/backend_plan_test.go @@ -20,13 +20,13 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func TestLocal_planBasic(t *testing.T) { @@ -726,7 +726,7 @@ func TestLocal_planOutPathNoChange(t *testing.T) { func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) diff --git a/internal/backend/local/backend_refresh_test.go b/internal/backend/local/backend_refresh_test.go index bb516d07a5..fb2c726bae 100644 --- a/internal/backend/local/backend_refresh_test.go +++ b/internal/backend/local/backend_refresh_test.go @@ -16,11 +16,11 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/zclconf/go-cty/cty" ) @@ -267,7 +267,7 @@ func TestLocal_refreshEmptyState(t *testing.T) { func testOperationRefresh(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams)) diff --git a/internal/backend/remote-state/azure/go.mod b/internal/backend/remote-state/azure/go.mod index 091596a922..bacc56dd8a 100644 --- a/internal/backend/remote-state/azure/go.mod +++ b/internal/backend/remote-state/azure/go.mod @@ -9,7 +9,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/jackofallops/giovanni v0.28.0 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/azure/go.sum b/internal/backend/remote-state/azure/go.sum index f5225be916..1d25e879dd 100644 --- a/internal/backend/remote-state/azure/go.sum +++ b/internal/backend/remote-state/azure/go.sum @@ -219,8 +219,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/consul/go.mod b/internal/backend/remote-state/consul/go.mod index fedc460754..f4ca9a3c45 100644 --- a/internal/backend/remote-state/consul/go.mod +++ b/internal/backend/remote-state/consul/go.mod @@ -6,7 +6,7 @@ require ( github.com/hashicorp/consul/api v1.32.1 github.com/hashicorp/consul/sdk v0.16.1 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/consul/go.sum b/internal/backend/remote-state/consul/go.sum index 44c20783d5..fcb9b37bb9 100644 --- a/internal/backend/remote-state/consul/go.sum +++ b/internal/backend/remote-state/consul/go.sum @@ -288,8 +288,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/cos/go.mod b/internal/backend/remote-state/cos/go.mod index 2cf3de9aa6..0f82d22775 100644 --- a/internal/backend/remote-state/cos/go.mod +++ b/internal/backend/remote-state/cos/go.mod @@ -43,7 +43,7 @@ require ( github.com/mozillazg/go-httpheader v0.3.0 // indirect github.com/oklog/run v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect - github.com/zclconf/go-cty v1.16.3 // indirect + github.com/zclconf/go-cty v1.18.0 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.30.0 // indirect diff --git a/internal/backend/remote-state/cos/go.sum b/internal/backend/remote-state/cos/go.sum index 8cf3b0adb4..18bd44460c 100644 --- a/internal/backend/remote-state/cos/go.sum +++ b/internal/backend/remote-state/cos/go.sum @@ -179,8 +179,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/gcs/go.mod b/internal/backend/remote-state/gcs/go.mod index 9bd62a9e69..9c99c3dbc2 100644 --- a/internal/backend/remote-state/gcs/go.mod +++ b/internal/backend/remote-state/gcs/go.mod @@ -7,7 +7,7 @@ require ( cloud.google.com/go/storage v1.30.1 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/mitchellh/go-homedir v1.1.0 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 golang.org/x/oauth2 v0.30.0 google.golang.org/api v0.155.0 google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 diff --git a/internal/backend/remote-state/gcs/go.sum b/internal/backend/remote-state/gcs/go.sum index 0599eb0aae..cf9163ffbb 100644 --- a/internal/backend/remote-state/gcs/go.sum +++ b/internal/backend/remote-state/gcs/go.sum @@ -194,8 +194,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/kubernetes/go.mod b/internal/backend/remote-state/kubernetes/go.mod index d62f3bcfed..c0d8b61bf8 100644 --- a/internal/backend/remote-state/kubernetes/go.mod +++ b/internal/backend/remote-state/kubernetes/go.mod @@ -5,7 +5,7 @@ go 1.25.7 require ( github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/mitchellh/go-homedir v1.1.0 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 k8s.io/api v0.33.0 k8s.io/apimachinery v0.33.0 k8s.io/client-go v0.33.0 diff --git a/internal/backend/remote-state/kubernetes/go.sum b/internal/backend/remote-state/kubernetes/go.sum index 5d8da920e7..8a671a8bb8 100644 --- a/internal/backend/remote-state/kubernetes/go.sum +++ b/internal/backend/remote-state/kubernetes/go.sum @@ -218,8 +218,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/oci/go.mod b/internal/backend/remote-state/oci/go.mod index 49d7bb82e1..5b837c0206 100644 --- a/internal/backend/remote-state/oci/go.mod +++ b/internal/backend/remote-state/oci/go.mod @@ -8,7 +8,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/oracle/oci-go-sdk/v65 v65.89.1 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/oci/go.sum b/internal/backend/remote-state/oci/go.sum index 84d26e2501..d6a1347439 100644 --- a/internal/backend/remote-state/oci/go.sum +++ b/internal/backend/remote-state/oci/go.sum @@ -168,8 +168,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/oss/go.mod b/internal/backend/remote-state/oss/go.mod index d55580490e..c18426ebdf 100644 --- a/internal/backend/remote-state/oss/go.mod +++ b/internal/backend/remote-state/oss/go.mod @@ -50,7 +50,7 @@ require ( github.com/satori/go.uuid v1.2.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/stretchr/testify v1.8.4 // indirect - github.com/zclconf/go-cty v1.16.3 // indirect + github.com/zclconf/go-cty v1.18.0 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.30.0 // indirect diff --git a/internal/backend/remote-state/oss/go.sum b/internal/backend/remote-state/oss/go.sum index f79cabe9ed..d9a635838a 100644 --- a/internal/backend/remote-state/oss/go.sum +++ b/internal/backend/remote-state/oss/go.sum @@ -198,8 +198,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/pg/go.mod b/internal/backend/remote-state/pg/go.mod index 87a5316cfb..a817e00b93 100644 --- a/internal/backend/remote-state/pg/go.mod +++ b/internal/backend/remote-state/pg/go.mod @@ -7,7 +7,7 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 github.com/lib/pq v1.10.3 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/pg/go.sum b/internal/backend/remote-state/pg/go.sum index bc4542f19c..0514ed7033 100644 --- a/internal/backend/remote-state/pg/go.sum +++ b/internal/backend/remote-state/pg/go.sum @@ -152,8 +152,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote-state/s3/go.mod b/internal/backend/remote-state/s3/go.mod index 6e5e3e0f30..711862cdc3 100644 --- a/internal/backend/remote-state/s3/go.mod +++ b/internal/backend/remote-state/s3/go.mod @@ -15,7 +15,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/backend/remote-state/s3/go.sum b/internal/backend/remote-state/s3/go.sum index bbe05494a5..a9a6986c89 100644 --- a/internal/backend/remote-state/s3/go.sum +++ b/internal/backend/remote-state/s3/go.sum @@ -175,8 +175,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/backend/remote/backend_apply_test.go b/internal/backend/remote/backend_apply_test.go index 261b0c0b38..7032f977e0 100644 --- a/internal/backend/remote/backend_apply_test.go +++ b/internal/backend/remote/backend_apply_test.go @@ -25,12 +25,12 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" tfversion "github.com/hashicorp/terraform/version" ) @@ -43,7 +43,7 @@ func testOperationApply(t *testing.T, configDir string) (*backendrun.Operation, func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/backend/remote/backend_common.go b/internal/backend/remote/backend_common.go index 85d42ae879..137c01aa41 100644 --- a/internal/backend/remote/backend_common.go +++ b/internal/backend/remote/backend_common.go @@ -249,11 +249,8 @@ func (b *Remote) waitForRun(stopCtx, cancelCtx context.Context, op *backendrun.O // remote system's responsibility to do final validation of the input. func (b *Remote) hasExplicitVariableValues(op *backendrun.Operation) bool { // Load the configuration using the caller-provided configuration loader. - config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + config, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) if configDiags.HasErrors() { - // If we can't load the configuration then we'll assume no explicit - // variable values just to let the remote operation start and let - // the remote system return the same set of configuration errors. return false } @@ -262,7 +259,7 @@ func (b *Remote) hasExplicitVariableValues(op *backendrun.Operation) bool { // goal here is just to make a best effort count of how many variable // values are coming from -var or -var-file CLI arguments so that we can // hint the user that those are not supported for remote operations. - variables, _ := backendrun.ParseVariableValues(op.Variables, config.Module.Variables) + variables, _ := backendrun.ParseVariableValues(op.Variables, config.Variables) // Check for explicitly-defined (-var and -var-file) variables, which the // remote backend does not support. All other source types are okay, diff --git a/internal/backend/remote/backend_context.go b/internal/backend/remote/backend_context.go index bf7ec7190d..db72d709ed 100644 --- a/internal/backend/remote/backend_context.go +++ b/internal/backend/remote/backend_context.go @@ -81,19 +81,18 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state ret.InputState = stateMgr.State() log.Printf("[TRACE] backend/remote: loading configuration for the current working directory") - config, configDiags := op.ConfigLoader.LoadConfig(op.ConfigDir) + rootMod, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) diags = diags.Append(configDiags) if configDiags.HasErrors() { return nil, nil, diags } - ret.Config = config if op.AllowUnsetVariables { // If we're not going to use the variables in an operation we'll be // more lax about them, stubbing out any unset ones as unknown. // This gives us enough information to produce a consistent context, // but not enough information to run a real operation (plan, apply, etc) - ret.PlanOpts.SetVariables = stubAllVariables(op.Variables, config.Module.Variables) + ret.PlanOpts.SetVariables = stubAllVariables(op.Variables, rootMod.Variables) } else { // The underlying API expects us to use the opaque workspace id to request // variables, so we'll need to look that up using our organization name @@ -136,7 +135,7 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state } if op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, config.Module.Variables) + variables, varDiags := backendrun.ParseVariableValues(op.Variables, rootMod.Variables) diags = diags.Append(varDiags) if diags.HasErrors() { return nil, nil, diags @@ -148,6 +147,24 @@ func (b *Remote) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, state tfCtx, ctxDiags := terraform.NewContext(&opts) diags = diags.Append(ctxDiags) ret.Core = tfCtx + if diags.HasErrors() { + return nil, nil, diags + } + + log.Printf("[TRACE] backend/remote: building configuration for the current working directory") + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + op.ConfigLoader.ModuleWalker(), + ret.PlanOpts.SetVariables, + configs.MockDataLoaderFunc(op.ConfigLoader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if diags.HasErrors() { + return nil, nil, diags + } + + ret.Config = config log.Printf("[TRACE] backend/remote: finished building terraform.Context") diff --git a/internal/backend/remote/backend_context_test.go b/internal/backend/remote/backend_context_test.go index 5cdb93077a..70883b3bae 100644 --- a/internal/backend/remote/backend_context_test.go +++ b/internal/backend/remote/backend_context_test.go @@ -17,10 +17,10 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -187,7 +187,7 @@ func TestRemoteContextWithVars(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) @@ -410,7 +410,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), backend.DefaultStateName) diff --git a/internal/backend/remote/backend_plan_test.go b/internal/backend/remote/backend_plan_test.go index 6026f9908a..3e725bcc8a 100644 --- a/internal/backend/remote/backend_plan_test.go +++ b/internal/backend/remote/backend_plan_test.go @@ -24,12 +24,12 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -41,7 +41,7 @@ func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, f func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/checks/state.go b/internal/checks/state.go index 1e258c001a..f947adc07c 100644 --- a/internal/checks/state.go +++ b/internal/checks/state.go @@ -87,6 +87,27 @@ func NewState(config *configs.Config) *State { } } +// RegisterModule registers all checkable objects declared in the given module +// configuration that are not already known to this State. +// +// This supports incremental config discovery, such as during init walks where +// child modules are loaded step by step rather than all at once. +func (c *State) RegisterModule(cfg *configs.Config) { + c.mu.Lock() + defer c.mu.Unlock() + + // Collect statuses for the new module (non-recursively — children will + // register themselves when they are loaded). + fresh := addrs.MakeMap[addrs.ConfigCheckable, *configCheckableState]() + collectInitialStatuses(fresh, cfg) + + for _, elem := range fresh.Elems { + if !c.statuses.Has(elem.Key) { + c.statuses.Put(elem.Key, elem.Value) + } + } +} + // ConfigHasChecks returns true if and only if the given address refers to // a configuration object that this State object is expecting to recieve // statuses for. diff --git a/internal/checks/state_test.go b/internal/checks/state_test.go index 2e5a30a30d..3804679036 100644 --- a/internal/checks/state_test.go +++ b/internal/checks/state_test.go @@ -1,38 +1,23 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package checks +package checks_test import ( - "context" "testing" "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/configs/configload" - "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/checks" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func TestChecksHappyPath(t *testing.T) { const fixtureDir = "testdata/happypath" - loader, close := configload.NewLoaderForTests(t) - defer close() - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, nil) - _, instDiags := inst.InstallModules(context.Background(), fixtureDir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) - if instDiags.HasErrors() { - t.Fatal(instDiags.Err()) - } - if err := loader.RefreshModules(); err != nil { - t.Fatalf("failed to refresh modules after installation: %s", err) - } - ///////////////////////////////////////////////////////////////////////// - - cfg, hclDiags := loader.LoadConfig(fixtureDir) - if hclDiags.HasErrors() { - t.Fatalf("invalid configuration: %s", hclDiags.Error()) - } + cfg, _, configCleanup := tftesting.MustLoadConfigForTests(t, fixtureDir, "tests") + t.Cleanup(configCleanup) resourceA := addrs.Resource{ Mode: addrs.ManagedResourceMode, @@ -90,36 +75,36 @@ func TestChecksHappyPath(t *testing.T) { ///////////////////////////////////////////////////////////////////////// - checks := NewState(cfg) + state := checks.NewState(cfg) missing := 0 - if addr := resourceA; !checks.ConfigHasChecks(addr) { + if addr := resourceA; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := resourceB; !checks.ConfigHasChecks(addr) { + if addr := resourceB; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := resourceC; !checks.ConfigHasChecks(addr) { + if addr := resourceC; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := rootOutput; !checks.ConfigHasChecks(addr) { + if addr := rootOutput; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := childOutput; !checks.ConfigHasChecks(addr) { + if addr := childOutput; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } - if addr := resourceNoChecks; checks.ConfigHasChecks(addr) { + if addr := resourceNoChecks; state.ConfigHasChecks(addr) { t.Errorf("checks detected for %s, even though it has none", addr) } - if addr := resourceNonExist; checks.ConfigHasChecks(addr) { + if addr := resourceNonExist; state.ConfigHasChecks(addr) { t.Errorf("checks detected for %s, even though it doesn't exist", addr) } - if addr := checkBlock; !checks.ConfigHasChecks(addr) { + if addr := checkBlock; !state.ConfigHasChecks(addr) { t.Errorf("checks not detected for %s", addr) missing++ } @@ -140,13 +125,13 @@ func TestChecksHappyPath(t *testing.T) { childOutput, checkBlock, ) - gotConfigAddrs := checks.AllConfigAddrs() + gotConfigAddrs := state.AllConfigAddrs() if diff := cmp.Diff(wantConfigAddrs, gotConfigAddrs); diff != "" { t.Errorf("wrong detected config addresses\n%s", diff) } for _, configAddr := range gotConfigAddrs { - if got, want := checks.AggregateCheckStatus(configAddr), StatusUnknown; got != want { + if got, want := state.AggregateCheckStatus(configAddr), checks.StatusUnknown; got != want { t.Errorf("incorrect initial aggregate check status for %s: %s, but want %s", configAddr, got, want) } } @@ -170,26 +155,26 @@ func TestChecksHappyPath(t *testing.T) { childOutputInst := childOutput.OutputValue.Absolute(moduleChildInst) checkBlockInst := checkBlock.Check.Absolute(addrs.RootModuleInstance) - checks.ReportCheckableObjects(resourceA, addrs.MakeSet[addrs.Checkable](resourceInstA)) - checks.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 0, StatusPass) - checks.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 1, StatusPass) - checks.ReportCheckResult(resourceInstA, addrs.ResourcePostcondition, 0, StatusPass) + state.ReportCheckableObjects(resourceA, addrs.MakeSet[addrs.Checkable](resourceInstA)) + state.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 0, checks.StatusPass) + state.ReportCheckResult(resourceInstA, addrs.ResourcePrecondition, 1, checks.StatusPass) + state.ReportCheckResult(resourceInstA, addrs.ResourcePostcondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(resourceB, addrs.MakeSet[addrs.Checkable](resourceInstB)) - checks.ReportCheckResult(resourceInstB, addrs.ResourcePrecondition, 0, StatusPass) + state.ReportCheckableObjects(resourceB, addrs.MakeSet[addrs.Checkable](resourceInstB)) + state.ReportCheckResult(resourceInstB, addrs.ResourcePrecondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(resourceC, addrs.MakeSet[addrs.Checkable](resourceInstC0, resourceInstC1)) - checks.ReportCheckResult(resourceInstC0, addrs.ResourcePostcondition, 0, StatusPass) - checks.ReportCheckResult(resourceInstC1, addrs.ResourcePostcondition, 0, StatusPass) + state.ReportCheckableObjects(resourceC, addrs.MakeSet[addrs.Checkable](resourceInstC0, resourceInstC1)) + state.ReportCheckResult(resourceInstC0, addrs.ResourcePostcondition, 0, checks.StatusPass) + state.ReportCheckResult(resourceInstC1, addrs.ResourcePostcondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(childOutput, addrs.MakeSet[addrs.Checkable](childOutputInst)) - checks.ReportCheckResult(childOutputInst, addrs.OutputPrecondition, 0, StatusPass) + state.ReportCheckableObjects(childOutput, addrs.MakeSet[addrs.Checkable](childOutputInst)) + state.ReportCheckResult(childOutputInst, addrs.OutputPrecondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(rootOutput, addrs.MakeSet[addrs.Checkable](rootOutputInst)) - checks.ReportCheckResult(rootOutputInst, addrs.OutputPrecondition, 0, StatusPass) + state.ReportCheckableObjects(rootOutput, addrs.MakeSet[addrs.Checkable](rootOutputInst)) + state.ReportCheckResult(rootOutputInst, addrs.OutputPrecondition, 0, checks.StatusPass) - checks.ReportCheckableObjects(checkBlock, addrs.MakeSet[addrs.Checkable](checkBlockInst)) - checks.ReportCheckResult(checkBlockInst, addrs.CheckAssertion, 0, StatusPass) + state.ReportCheckableObjects(checkBlock, addrs.MakeSet[addrs.Checkable](checkBlockInst)) + state.ReportCheckResult(checkBlockInst, addrs.CheckAssertion, 0, checks.StatusPass) ///////////////////////////////////////////////////////////////////////// @@ -198,9 +183,9 @@ func TestChecksHappyPath(t *testing.T) { { configCount := 0 - for _, configAddr := range checks.AllConfigAddrs() { + for _, configAddr := range state.AllConfigAddrs() { configCount++ - if got, want := checks.AggregateCheckStatus(configAddr), StatusPass; got != want { + if got, want := state.AggregateCheckStatus(configAddr), checks.StatusPass; got != want { t.Errorf("incorrect final aggregate check status for %s: %s, but want %s", configAddr, got, want) } } @@ -220,7 +205,7 @@ func TestChecksHappyPath(t *testing.T) { checkBlockInst, ) for _, addr := range objAddrs { - if got, want := checks.ObjectCheckStatus(addr), StatusPass; got != want { + if got, want := state.ObjectCheckStatus(addr), checks.StatusPass; got != want { t.Errorf("incorrect final check status for object %s: %s, but want %s", addr, got, want) } } diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 59e8d8ca37..5dae9abda8 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -123,6 +123,7 @@ type Cloud struct { var _ backend.Backend = (*Cloud)(nil) var _ backendrun.OperationsBackend = (*Cloud)(nil) var _ backendrun.Local = (*Cloud)(nil) +var _ backendrun.ConstVariableSupplier = (*Cloud)(nil) // New creates a new initialized cloud backend. func New(services *disco.Disco) *Cloud { diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 82452ce274..0c454c39cc 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -28,12 +28,12 @@ import ( "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" tfversion "github.com/hashicorp/terraform/version" ) @@ -46,7 +46,7 @@ func testOperationApply(t *testing.T, configDir string) (*backendrun.Operation, func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/backend_common.go b/internal/cloud/backend_common.go index 360da2a08b..b4f98ef2d0 100644 --- a/internal/cloud/backend_common.go +++ b/internal/cloud/backend_common.go @@ -654,12 +654,12 @@ in order to capture the filesystem context the remote workspace expects: } func (b *Cloud) parseRunVariables(op *backendrun.Operation) ([]*tfe.RunVariable, error) { - config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + config, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) if configDiags.HasErrors() { return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0]) } - variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables) + variables, varDiags := ParseCloudRunVariables(op.Variables, config.Variables) if varDiags.HasErrors() { return nil, varDiags.Err() diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index 36f9ab1e3a..4b39db0a6a 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -79,75 +79,70 @@ func (b *Cloud) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statem log.Printf("[TRACE] cloud: retrieving remote state snapshot for workspace %q", remoteWorkspaceName) ret.InputState = stateMgr.State() - log.Printf("[TRACE] cloud: loading configuration for the current working directory") - config, configDiags := op.ConfigLoader.LoadConfig(op.ConfigDir) + log.Printf("[TRACE] cloud: loading root module for the current working directory") + rootMod, configDiags := op.ConfigLoader.LoadRootModule(op.ConfigDir) diags = diags.Append(configDiags) if configDiags.HasErrors() { return nil, nil, diags } - ret.Config = config - if op.AllowUnsetVariables { - // If we're not going to use the variables in an operation we'll be - // more lax about them, stubbing out any unset ones as unknown. - // This gives us enough information to produce a consistent context, - // but not enough information to run a real operation (plan, apply, etc) - ret.PlanOpts.SetVariables = stubAllVariables(op.Variables, config.Module.Variables) - } else { - // The underlying API expects us to use the opaque workspace id to request - // variables, so we'll need to look that up using our organization name - // and workspace name. - remoteWorkspaceID, err := b.getRemoteWorkspaceID(context.Background(), op.Workspace) - if err != nil { - diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) - return nil, nil, diags + // If we're not going to use the variables in an operation we'll be + // more lax about them, stubbing out any unset ones as unknown. + // This gives us enough information to produce a consistent context, + // but not enough information to run a real operation (plan, apply, etc). + // + // However, const variables must always be resolved since they're + // needed during early configuration loading (e.g. module sources). + // We fetch backend variables so const vars can be satisfied. + fetchedVars, fetchDiags := b.FetchVariables(context.Background(), op.Workspace) + diags = diags.Append(fetchDiags) + if fetchDiags.HasErrors() { + return nil, nil, diags + } + if len(fetchedVars) > 0 { + if op.Variables == nil { + op.Variables = make(map[string]arguments.UnparsedVariableValue) } - w, err := b.fetchWorkspace(context.Background(), b.Organization, op.Workspace) - if err != nil { - diags = diags.Append(fmt.Errorf("error loading workspace: %w", err)) - return nil, nil, diags - } - - if isLocalExecutionMode(w.ExecutionMode) { - log.Printf("[TRACE] skipping retrieving variables from workspace %s/%s (%s), workspace is in Local Execution mode", remoteWorkspaceName, b.Organization, remoteWorkspaceID) - } else { - log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", remoteWorkspaceName, b.Organization, remoteWorkspaceID) - tfeVariables, err := b.client.Variables.ListAll(context.Background(), remoteWorkspaceID, nil) - if err != nil && err != tfe.ErrResourceNotFound { - diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) - return nil, nil, diags + for k, v := range fetchedVars { + if _, ok := op.Variables[k]; !ok { + op.Variables[k] = v } - - if tfeVariables != nil { - if op.Variables == nil { - op.Variables = make(map[string]arguments.UnparsedVariableValue) - } - - for _, v := range tfeVariables.Items { - if v.Category == tfe.CategoryTerraform { - if _, ok := op.Variables[v.Key]; !ok { - op.Variables[v.Key] = &remoteStoredVariableValue{ - definition: v, - } - } - } - } - } - } - - if op.Variables != nil { - variables, varDiags := backendrun.ParseVariableValues(op.Variables, config.Module.Variables) - diags = diags.Append(varDiags) - if diags.HasErrors() { - return nil, nil, diags - } - ret.PlanOpts.SetVariables = variables } } + var variables terraform.InputValues + var varDiags tfdiags.Diagnostics + if op.AllowUnsetVariables { + variables, varDiags = backendrun.ParseConstVariableValues(op.Variables, rootMod.Variables) + } else { + variables, varDiags = backendrun.ParseVariableValues(op.Variables, rootMod.Variables) + } + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, nil, diags + } + ret.PlanOpts.SetVariables = variables tfCtx, ctxDiags := terraform.NewContext(&opts) diags = diags.Append(ctxDiags) ret.Core = tfCtx + if diags.HasErrors() { + return nil, nil, diags + } + + log.Printf("[TRACE] cloud: building configuration for the current working directory") + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + op.ConfigLoader.ModuleWalker(), + ret.PlanOpts.SetVariables, + configs.MockDataLoaderFunc(op.ConfigLoader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if diags.HasErrors() { + return nil, nil, diags + } + + ret.Config = config log.Printf("[TRACE] cloud: finished building terraform.Context") @@ -185,31 +180,47 @@ func (b *Cloud) getRemoteWorkspaceID(ctx context.Context, localWorkspaceName str return remoteWorkspace.ID, nil } -func stubAllVariables(vv map[string]arguments.UnparsedVariableValue, decls map[string]*configs.Variable) terraform.InputValues { - ret := make(terraform.InputValues, len(decls)) +// FetchVariables implements backendrun.ConstVariableSupplier by retrieving +// Terraform variables from the HCP Terraform or Terraform Enterprise workspace. +func (b *Cloud) FetchVariables(ctx context.Context, workspace string) (map[string]arguments.UnparsedVariableValue, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics - for name, cfg := range decls { - raw, exists := vv[name] - if !exists { - ret[name] = &terraform.InputValue{ - Value: cty.UnknownVal(cfg.Type), - SourceType: terraform.ValueFromConfig, - } - continue - } - - val, diags := raw.ParseVariableValue(cfg.ParsingMode) - if diags.HasErrors() { - ret[name] = &terraform.InputValue{ - Value: cty.UnknownVal(cfg.Type), - SourceType: terraform.ValueFromConfig, - } - continue - } - ret[name] = val + remoteWorkspaceID, err := b.getRemoteWorkspaceID(ctx, workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error finding remote workspace: %w", err)) + return nil, diags } - return ret + w, err := b.fetchWorkspace(ctx, b.Organization, workspace) + if err != nil { + diags = diags.Append(fmt.Errorf("error loading workspace: %w", err)) + return nil, diags + } + + if isLocalExecutionMode(w.ExecutionMode) { + log.Printf("[TRACE] cloud: skipping variable fetch for workspace %s/%s (%s), workspace is in Local Execution mode", b.getRemoteWorkspaceName(workspace), b.Organization, remoteWorkspaceID) + return nil, nil + } + + log.Printf("[TRACE] cloud: retrieving variables from workspace %s/%s (%s)", b.getRemoteWorkspaceName(workspace), b.Organization, remoteWorkspaceID) + tfeVariables, err := b.client.Variables.ListAll(ctx, remoteWorkspaceID, nil) + if err != nil && err != tfe.ErrResourceNotFound { + diags = diags.Append(fmt.Errorf("error loading variables: %w", err)) + return nil, diags + } + + result := make(map[string]arguments.UnparsedVariableValue) + if tfeVariables != nil { + for _, v := range tfeVariables.Items { + if v.Category == tfe.CategoryTerraform { + result[v.Key] = &remoteStoredVariableValue{ + definition: v, + } + } + } + } + + return result, nil } // remoteStoredVariableValue is a backendrun.UnparsedVariableValue implementation diff --git a/internal/cloud/backend_context_test.go b/internal/cloud/backend_context_test.go index 06c3426e2b..3cc8b0f330 100644 --- a/internal/cloud/backend_context_test.go +++ b/internal/cloud/backend_context_test.go @@ -16,10 +16,10 @@ import ( "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -186,7 +186,7 @@ func TestRemoteContextWithVars(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), testBackendSingleWorkspaceName) @@ -409,7 +409,7 @@ func TestRemoteVariablesDoNotOverride(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") defer configCleanup() workspaceID, err := b.getRemoteWorkspaceID(context.Background(), testBackendSingleWorkspaceName) diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 4988b5b63e..98d04ae9b6 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -26,12 +26,12 @@ import ( "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -43,7 +43,7 @@ func testOperationPlan(t *testing.T, configDir string) (*backendrun.Operation, f func testOperationPlanWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/backend_query_test.go b/internal/cloud/backend_query_test.go index ecb9409063..3752f6a6ae 100644 --- a/internal/cloud/backend_query_test.go +++ b/internal/cloud/backend_query_test.go @@ -18,9 +18,9 @@ import ( "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationQuery(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -32,7 +32,7 @@ func testOperationQuery(t *testing.T, configDir string) (*backendrun.Operation, func testOperationQueryWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/backend_refresh_test.go b/internal/cloud/backend_refresh_test.go index abfac6345a..c7d38186fa 100644 --- a/internal/cloud/backend_refresh_test.go +++ b/internal/cloud/backend_refresh_test.go @@ -15,10 +15,10 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" ) func testOperationRefresh(t *testing.T, configDir string) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { @@ -30,7 +30,7 @@ func testOperationRefresh(t *testing.T, configDir string) (*backendrun.Operation func testOperationRefreshWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backendrun.Operation, func(), func(*testing.T) *terminal.TestOutput) { t.Helper() - _, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests") + _, configLoader, configCleanup := tftesting.MustLoadConfigForTests(t, configDir, "tests") streams, done := terminal.StreamsForTesting(t) view := views.NewView(streams) diff --git a/internal/cloud/test_test.go b/internal/cloud/test_test.go index d3a97372bc..fb58e819b8 100644 --- a/internal/cloud/test_test.go +++ b/internal/cloud/test_test.go @@ -13,8 +13,10 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -267,9 +269,19 @@ func TestTest_Verbose(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - config, configDiags := loader.LoadConfigWithTests(directory, "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(directory, "tests") + if hclDiags.HasErrors() { + t.Fatalf("failed to load root module: %v", hclDiags.Error()) + } + + config, configDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if configDiags.HasErrors() { - t.Fatalf("failed to load config: %v", configDiags.Error()) + t.Fatalf("failed to load config: %v", configDiags.Err()) } streams, done := terminal.StreamsForTesting(t) @@ -664,9 +676,19 @@ func TestTest_ForceCancel(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - config, configDiags := loader.LoadConfigWithTests("testdata/test-force-cancel", "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests("testdata/test-force-cancel", "tests") + if hclDiags.HasErrors() { + t.Fatalf("failed to load root module: %v", hclDiags.Error()) + } + + config, configDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if configDiags.HasErrors() { - t.Fatalf("failed to load config: %v", configDiags.Error()) + t.Fatalf("failed to load config: %v", configDiags.Err()) } streams, outputFn := terminal.StreamsForTesting(t) diff --git a/internal/command/apply.go b/internal/command/apply.go index 1fa43cf950..29cfb7698e 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -359,7 +359,7 @@ Options: Defaults to 10. -replace=resource Terraform will plan to replace this resource instance - instead of doing an update or no-op action. + instead of doing an update or no-op action. -state=path Path to read and save state (unless state-out is specified). Defaults to "terraform.tfstate". @@ -372,7 +372,7 @@ Options: Legacy option for the local backend only. See the local backend's documentation for more information. - + -var 'foo=bar' Set a value for one of the input variables in the root module of the configuration. Use this option more than once to set more than one variable. diff --git a/internal/command/arguments/get.go b/internal/command/arguments/get.go new file mode 100644 index 0000000000..2e1d125a08 --- /dev/null +++ b/internal/command/arguments/get.go @@ -0,0 +1,54 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Get represents the command-line arguments for the get command. +type Get struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + + // Update, if true, checks already-downloaded modules for available + // updates and installs the newest versions available. + Update bool + + // TestDirectory is the Terraform test directory. + TestDirectory string +} + +// ParseGet processes CLI arguments, returning a Get value and diagnostics. +// If errors are encountered, a Get value is still returned representing +// the best effort interpretation of the arguments. +func ParseGet(args []string) (*Get, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + get := &Get{ + Vars: &Vars{}, + } + + cmdFlags := extendedFlagSet("get", nil, nil, get.Vars) + cmdFlags.BoolVar(&get.Update, "update", false, "update") + cmdFlags.StringVar(&get.TestDirectory, "test-directory", "tests", "test-directory") + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + args = cmdFlags.Args() + if len(args) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + )) + } + + return get, diags +} diff --git a/internal/command/arguments/get_test.go b/internal/command/arguments/get_test.go new file mode 100644 index 0000000000..3aaf5b381e --- /dev/null +++ b/internal/command/arguments/get_test.go @@ -0,0 +1,161 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseGet_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Get + }{ + "defaults": { + nil, + &Get{ + Vars: &Vars{}, + TestDirectory: "tests", + }, + }, + "update": { + []string{"-update"}, + &Get{ + Vars: &Vars{}, + Update: true, + TestDirectory: "tests", + }, + }, + "test-directory": { + []string{"-test-directory", "custom-tests"}, + &Get{ + Vars: &Vars{}, + TestDirectory: "custom-tests", + }, + }, + "all options": { + []string{ + "-update", + "-test-directory", "custom-tests", + }, + &Get{ + Vars: &Vars{}, + Update: true, + TestDirectory: "custom-tests", + }, + }, + } + + cmpOpts := cmp.Options{cmpopts.IgnoreUnexported(Vars{})} + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGet(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseGet_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGet(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + +func TestParseGet_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Get + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-boop"}, + &Get{ + Vars: &Vars{}, + TestDirectory: "tests", + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -boop", + ), + }, + }, + "too many arguments": { + []string{"foo", "bar"}, + &Get{ + Vars: &Vars{}, + TestDirectory: "tests", + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + } + + cmpOpts := cmp.Options{cmpopts.IgnoreUnexported(Vars{})} + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseGet(tc.args) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) + }) + } +} diff --git a/internal/command/arguments/graph.go b/internal/command/arguments/graph.go new file mode 100644 index 0000000000..cbcd455403 --- /dev/null +++ b/internal/command/arguments/graph.go @@ -0,0 +1,67 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import "github.com/hashicorp/terraform/internal/tfdiags" + +// Graph represents the command-line arguments for the graph command. +type Graph struct { + // DrawCycles highlights any cycles in the graph with colored edges. + DrawCycles bool + + // GraphType is the type of operation graph to output (plan, + // plan-refresh-only, plan-destroy, or apply). Empty string means the + // default resource-dependency summary. + GraphType string + + // ModuleDepth is a deprecated option that was used in prior versions to + // control the depth of modules shown. + ModuleDepth int + + // Verbose enables verbose graph output. + Verbose bool + + // Plan is the path to a saved plan file to render as a graph. + Plan string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars +} + +// ParseGraph processes CLI arguments, returning a Graph value and errors. +// If errors are encountered, a Graph value is still returned representing +// the best effort interpretation of the arguments. +func ParseGraph(args []string) (*Graph, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + graph := &Graph{ + ModuleDepth: -1, + Vars: &Vars{}, + } + + cmdFlags := extendedFlagSet("graph", nil, nil, graph.Vars) + cmdFlags.BoolVar(&graph.DrawCycles, "draw-cycles", false, "draw-cycles") + cmdFlags.StringVar(&graph.GraphType, "type", "", "type") + cmdFlags.IntVar(&graph.ModuleDepth, "module-depth", -1, "module-depth") + cmdFlags.BoolVar(&graph.Verbose, "verbose", false, "verbose") + cmdFlags.StringVar(&graph.Plan, "plan", "", "plan") + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + args = cmdFlags.Args() + if len(args) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + )) + } + + return graph, diags +} diff --git a/internal/command/arguments/graph_test.go b/internal/command/arguments/graph_test.go new file mode 100644 index 0000000000..1f5c83ff55 --- /dev/null +++ b/internal/command/arguments/graph_test.go @@ -0,0 +1,207 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseGraph_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Graph + }{ + "defaults": { + nil, + &Graph{ + ModuleDepth: -1, + Vars: &Vars{}, + }, + }, + "plan type": { + []string{"-type=plan"}, + &Graph{ + GraphType: "plan", + ModuleDepth: -1, + Vars: &Vars{}, + }, + }, + "apply type": { + []string{"-type=apply"}, + &Graph{ + GraphType: "apply", + ModuleDepth: -1, + Vars: &Vars{}, + }, + }, + "draw-cycles": { + []string{"-draw-cycles", "-type=plan"}, + &Graph{ + DrawCycles: true, + GraphType: "plan", + ModuleDepth: -1, + Vars: &Vars{}, + }, + }, + "plan file": { + []string{"-plan=tfplan"}, + &Graph{ + Plan: "tfplan", + ModuleDepth: -1, + Vars: &Vars{}, + }, + }, + "verbose": { + []string{"-verbose"}, + &Graph{ + Verbose: true, + ModuleDepth: -1, + Vars: &Vars{}, + }, + }, + "module-depth": { + []string{"-module-depth=2"}, + &Graph{ + ModuleDepth: 2, + Vars: &Vars{}, + }, + }, + "all flags": { + []string{"-draw-cycles", "-type=plan-destroy", "-plan=tfplan", "-verbose", "-module-depth=3"}, + &Graph{ + DrawCycles: true, + GraphType: "plan-destroy", + Plan: "tfplan", + Verbose: true, + ModuleDepth: 3, + Vars: &Vars{}, + }, + }, + } + + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGraph(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n%s", diff) + } + }) + } +} + +func TestParseGraph_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Graph + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-wat"}, + &Graph{ + ModuleDepth: -1, + Vars: &Vars{}, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -wat", + ), + }, + }, + "positional argument": { + []string{"extra"}, + &Graph{ + ModuleDepth: -1, + Vars: &Vars{}, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + "too many positional arguments": { + []string{"bad", "bad"}, + &Graph{ + ModuleDepth: -1, + Vars: &Vars{}, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected no positional arguments. Did you mean to use -chdir?", + ), + }, + }, + } + + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseGraph(tc.args) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n%s", diff) + } + tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) + }) + } +} + +func TestParseGraph_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseGraph(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/arguments/init.go b/internal/command/arguments/init.go index e52d18325e..94df38df07 100644 --- a/internal/command/arguments/init.go +++ b/internal/command/arguments/init.go @@ -79,10 +79,6 @@ type Init struct { // TODO(SarahFrench/radeksimko): Remove this once the feature is no longer // experimental EnablePssExperiment bool - - // CreateDefaultWorkspace indicates whether the default workspace should be created by - // Terraform when initializing a state store for the first time. - CreateDefaultWorkspace bool } // ParseInit processes CLI arguments, returning an Init value and errors. @@ -116,7 +112,6 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti cmdFlags.BoolVar(&init.Json, "json", false, "json") cmdFlags.Var(&init.BackendConfig, "backend-config", "") cmdFlags.Var(&init.PluginPath, "plugin-dir", "plugin directory") - cmdFlags.BoolVar(&init.CreateDefaultWorkspace, "create-default-workspace", true, "when -input=false, use this flag to block creation of the default workspace") // Used for enabling experimental code that's invoked before configuration is parsed. cmdFlags.BoolVar(&init.EnablePssExperiment, "enable-pluggable-state-storage-experiment", false, "Enable the pluggable state storage experiment") @@ -133,13 +128,6 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti init.EnablePssExperiment = true } - if v := os.Getenv("TF_SKIP_CREATE_DEFAULT_WORKSPACE"); v != "" { - // If TF_SKIP_CREATE_DEFAULT_WORKSPACE is set it will override - // a -create-default-workspace=true flag that's set explicitly, - // as that's indistinguishable from the default value being used. - init.CreateDefaultWorkspace = false - } - if !experimentsEnabled { // If experiments aren't enabled then these flags should not be used. if init.EnablePssExperiment { @@ -149,24 +137,6 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti "Terraform cannot use the -enable-pluggable-state-storage-experiment flag (or TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable) unless experiments are enabled.", )) } - if !init.CreateDefaultWorkspace { - // Can only be set to false by using the flag - // and we cannot identify if -create-default-workspace=true is set explicitly. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Cannot use -create-default-workspace flag without experiments enabled", - "Terraform cannot use the -create-default-workspace flag (or TF_SKIP_CREATE_DEFAULT_WORKSPACE environment variable) unless experiments are enabled.", - )) - } - } else { - // Errors using flags despite experiments being enabled. - if !init.CreateDefaultWorkspace && !init.EnablePssExperiment { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled", - "Terraform cannot use the -create-default-workspace=false flag (or TF_SKIP_CREATE_DEFAULT_WORKSPACE environment variable) unless you also supply the -enable-pluggable-state-storage-experiment flag (or set the TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable).", - )) - } } if init.MigrateState && init.Json { diff --git a/internal/command/arguments/init_test.go b/internal/command/arguments/init_test.go index e6df4a6faa..7fdc96fb9e 100644 --- a/internal/command/arguments/init_test.go +++ b/internal/command/arguments/init_test.go @@ -40,19 +40,20 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &flagNameValue, }, - Vars: &Vars{}, - InputEnabled: true, - CompactWarnings: false, - TargetFlags: nil, - CreateDefaultWorkspace: true, + Vars: &Vars{}, + InputEnabled: true, + CompactWarnings: false, + TargetFlags: nil, }, }, "setting multiple options": { - []string{"-backend=false", "-force-copy=true", + []string{ + "-backend=false", "-force-copy=true", "-from-module=./main-dir", "-json", "-get=false", "-lock=false", "-lock-timeout=10s", "-reconfigure=true", "-upgrade=true", "-lockfile=readonly", "-compact-warnings=true", - "-ignore-remote-version=true", "-test-directory=./test-dir"}, + "-ignore-remote-version=true", "-test-directory=./test-dir", + }, &Init{ FromModule: "./main-dir", Lockfile: "readonly", @@ -73,12 +74,11 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &flagNameValue, }, - Vars: &Vars{}, - InputEnabled: true, - Args: []string{}, - CompactWarnings: true, - TargetFlags: nil, - CreateDefaultWorkspace: true, + Vars: &Vars{}, + InputEnabled: true, + Args: []string{}, + CompactWarnings: true, + TargetFlags: nil, }, }, "with cloud option": { @@ -103,12 +103,11 @@ func TestParseInit_basicValid(t *testing.T) { FlagName: "-backend-config", Items: &[]FlagNameValue{{Name: "-backend-config", Value: "backend.config"}}, }, - Vars: &Vars{}, - InputEnabled: false, - Args: []string{}, - CompactWarnings: false, - TargetFlags: []string{"foo_bar.baz"}, - CreateDefaultWorkspace: true, + Vars: &Vars{}, + InputEnabled: false, + Args: []string{}, + CompactWarnings: false, + TargetFlags: []string{"foo_bar.baz"}, }, }, } @@ -194,30 +193,6 @@ func TestParseInit_experimentalFlags(t *testing.T) { experimentsEnabled: false, wantErr: "Cannot use -enable-pluggable-state-storage-experiment flag without experiments enabled", }, - "error: -create-default-workspace=false and experiments are disabled": { - args: []string{"-create-default-workspace=false"}, - experimentsEnabled: false, - wantErr: "Cannot use -create-default-workspace flag without experiments enabled", - }, - "error: TF_SKIP_CREATE_DEFAULT_WORKSPACE is set and experiments are disabled": { - envs: map[string]string{ - "TF_SKIP_CREATE_DEFAULT_WORKSPACE": "1", - }, - experimentsEnabled: false, - wantErr: "Cannot use -create-default-workspace flag without experiments enabled", - }, - "error: -create-default-workspace=false used without -enable-pluggable-state-storage-experiment, while experiments are enabled": { - args: []string{"-create-default-workspace=false"}, - experimentsEnabled: true, - wantErr: "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled", - }, - "error: TF_SKIP_CREATE_DEFAULT_WORKSPACE used without -enable-pluggable-state-storage-experiment, while experiments are enabled": { - envs: map[string]string{ - "TF_SKIP_CREATE_DEFAULT_WORKSPACE": "1", - }, - experimentsEnabled: true, - wantErr: "Cannot use -create-default-workspace=false flag unless the pluggable state storage experiment is enabled", - }, } for name, tc := range testCases { diff --git a/internal/command/arguments/modules.go b/internal/command/arguments/modules.go index af76bf01c7..547dd71633 100644 --- a/internal/command/arguments/modules.go +++ b/internal/command/arguments/modules.go @@ -9,6 +9,9 @@ import "github.com/hashicorp/terraform/internal/tfdiags" type Modules struct { // ViewType specifies which output format to use: human, JSON, or "raw" ViewType ViewType + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseModules processes CLI arguments, returning a Modules value and error @@ -18,8 +21,10 @@ func ParseModules(args []string) (*Modules, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics var jsonOutput bool - modules := &Modules{} - cmdFlags := defaultFlagSet("modules") + modules := &Modules{ + Vars: &Vars{}, + } + cmdFlags := extendedFlagSet("modules", nil, nil, modules.Vars) cmdFlags.BoolVar(&jsonOutput, "json", false, "json") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/modules_test.go b/internal/command/arguments/modules_test.go index f4d41b6971..3a6ba941dc 100644 --- a/internal/command/arguments/modules_test.go +++ b/internal/command/arguments/modules_test.go @@ -6,6 +6,8 @@ package arguments import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,24 +20,28 @@ func TestParseModules_valid(t *testing.T) { nil, &Modules{ ViewType: ViewHuman, + Vars: &Vars{}, }, }, "json": { []string{"-json"}, &Modules{ ViewType: ViewJSON, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseModules(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n%s", diff) } }) } @@ -51,6 +57,7 @@ func TestParseModules_invalid(t *testing.T) { []string{"-sauron"}, &Modules{ ViewType: ViewHuman, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -64,6 +71,7 @@ func TestParseModules_invalid(t *testing.T) { []string{"-json", "frodo"}, &Modules{ ViewType: ViewJSON, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -75,13 +83,59 @@ func TestParseModules_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseModules(tc.args) - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseModules_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseModules(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/arguments/providers.go b/internal/command/arguments/providers.go index 5637f47f03..697376567f 100644 --- a/internal/command/arguments/providers.go +++ b/internal/command/arguments/providers.go @@ -9,6 +9,9 @@ import "github.com/hashicorp/terraform/internal/tfdiags" type Providers struct { // TestsDirectory is the directory containing Terraform test files. TestsDirectory string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProviders processes CLI arguments, returning a Providers value and @@ -16,9 +19,11 @@ type Providers struct { // representing the best effort interpretation of the arguments. func ParseProviders(args []string) (*Providers, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providers := &Providers{} + providers := &Providers{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers") + cmdFlags := extendedFlagSet("providers", nil, nil, providers.Vars) cmdFlags.StringVar(&providers.TestsDirectory, "test-directory", "tests", "test-directory") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/providers_lock.go b/internal/command/arguments/providers_lock.go index 5793927152..fca6685e97 100644 --- a/internal/command/arguments/providers_lock.go +++ b/internal/command/arguments/providers_lock.go @@ -14,6 +14,9 @@ type ProvidersLock struct { TestsDirectory string EnablePluginCache bool Providers []string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProvidersLock processes CLI arguments, returning a ProvidersLock value @@ -21,9 +24,11 @@ type ProvidersLock struct { // returned representing the best effort interpretation of the arguments. func ParseProvidersLock(args []string) (*ProvidersLock, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providersLock := &ProvidersLock{} + providersLock := &ProvidersLock{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers lock") + cmdFlags := extendedFlagSet("providers lock", nil, nil, providersLock.Vars) cmdFlags.Var(&providersLock.Platforms, "platform", "target platform") cmdFlags.StringVar(&providersLock.FSMirrorDir, "fs-mirror", "", "filesystem mirror directory") cmdFlags.StringVar(&providersLock.NetMirrorURL, "net-mirror", "", "network mirror base URL") diff --git a/internal/command/arguments/providers_lock_test.go b/internal/command/arguments/providers_lock_test.go index a59c836453..f919b431cd 100644 --- a/internal/command/arguments/providers_lock_test.go +++ b/internal/command/arguments/providers_lock_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,6 +20,7 @@ func TestParseProvidersLock_valid(t *testing.T) { nil, &ProvidersLock{ TestsDirectory: "tests", + Vars: &Vars{}, }, }, "all options": { @@ -36,17 +38,20 @@ func TestParseProvidersLock_valid(t *testing.T) { TestsDirectory: "integration-tests", EnablePluginCache: true, Providers: []string{"hashicorp/test"}, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProvidersLock(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -69,6 +74,7 @@ func TestParseProvidersLock_invalid(t *testing.T) { NetMirrorURL: "https://example.com", TestsDirectory: "tests", Providers: []string{}, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -83,6 +89,7 @@ func TestParseProvidersLock_invalid(t *testing.T) { &ProvidersLock{ TestsDirectory: "tests", Providers: []string{}, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -101,6 +108,7 @@ func TestParseProvidersLock_invalid(t *testing.T) { &ProvidersLock{ TestsDirectory: "tests", Providers: []string{"-fs-mirror=foo", "-net-mirror=https://example.com"}, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -112,13 +120,59 @@ func TestParseProvidersLock_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProvidersLock(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProvidersLock_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProvidersLock(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/arguments/providers_mirror.go b/internal/command/arguments/providers_mirror.go index 219ad50032..6b7c671a07 100644 --- a/internal/command/arguments/providers_mirror.go +++ b/internal/command/arguments/providers_mirror.go @@ -11,6 +11,9 @@ type ProvidersMirror struct { Platforms FlagStringSlice LockFile bool OutputDir string + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProvidersMirror processes CLI arguments, returning a ProvidersMirror @@ -18,9 +21,11 @@ type ProvidersMirror struct { // still returned representing the best effort interpretation of the arguments. func ParseProvidersMirror(args []string) (*ProvidersMirror, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providersMirror := &ProvidersMirror{} + providersMirror := &ProvidersMirror{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers mirror") + cmdFlags := extendedFlagSet("providers mirror", nil, nil, providersMirror.Vars) cmdFlags.Var(&providersMirror.Platforms, "platform", "target platform") cmdFlags.BoolVar(&providersMirror.LockFile, "lock-file", true, "use lock file") diff --git a/internal/command/arguments/providers_mirror_test.go b/internal/command/arguments/providers_mirror_test.go index d6fb5598ee..596ed73d66 100644 --- a/internal/command/arguments/providers_mirror_test.go +++ b/internal/command/arguments/providers_mirror_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -20,6 +21,7 @@ func TestParseProvidersMirror_valid(t *testing.T) { &ProvidersMirror{ LockFile: true, OutputDir: "./mirror", + Vars: &Vars{}, }, }, "all options": { @@ -32,17 +34,20 @@ func TestParseProvidersMirror_valid(t *testing.T) { &ProvidersMirror{ Platforms: FlagStringSlice{"linux_amd64", "darwin_arm64"}, OutputDir: "./mirror", + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProvidersMirror(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -59,6 +64,7 @@ func TestParseProvidersMirror_invalid(t *testing.T) { nil, &ProvidersMirror{ LockFile: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -72,6 +78,7 @@ func TestParseProvidersMirror_invalid(t *testing.T) { []string{"./mirror", "./extra"}, &ProvidersMirror{ LockFile: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -85,6 +92,7 @@ func TestParseProvidersMirror_invalid(t *testing.T) { []string{"-wat"}, &ProvidersMirror{ LockFile: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -101,13 +109,60 @@ func TestParseProvidersMirror_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProvidersMirror(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProvidersMirror_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "./mirror"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "./mirror"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "./mirror", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProvidersMirror(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/arguments/providers_schema.go b/internal/command/arguments/providers_schema.go index 15afa0acfc..6e7029fa1a 100644 --- a/internal/command/arguments/providers_schema.go +++ b/internal/command/arguments/providers_schema.go @@ -9,6 +9,9 @@ import "github.com/hashicorp/terraform/internal/tfdiags" // schema command. type ProvidersSchema struct { JSON bool + + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseProvidersSchema processes CLI arguments, returning a ProvidersSchema @@ -16,9 +19,11 @@ type ProvidersSchema struct { // still returned representing the best effort interpretation of the arguments. func ParseProvidersSchema(args []string) (*ProvidersSchema, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - providersSchema := &ProvidersSchema{} + providersSchema := &ProvidersSchema{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("providers schema") + cmdFlags := extendedFlagSet("providers schema", nil, nil, providersSchema.Vars) cmdFlags.BoolVar(&providersSchema.JSON, "json", false, "produce JSON output") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/providers_schema_test.go b/internal/command/arguments/providers_schema_test.go index 8f6df7220c..9cb3d3c2c2 100644 --- a/internal/command/arguments/providers_schema_test.go +++ b/internal/command/arguments/providers_schema_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,17 +20,20 @@ func TestParseProvidersSchema_valid(t *testing.T) { []string{"-json"}, &ProvidersSchema{ JSON: true, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProvidersSchema(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -44,7 +48,9 @@ func TestParseProvidersSchema_invalid(t *testing.T) { }{ "missing json": { nil, - &ProvidersSchema{}, + &ProvidersSchema{ + Vars: &Vars{}, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -57,6 +63,7 @@ func TestParseProvidersSchema_invalid(t *testing.T) { []string{"-json", "extra"}, &ProvidersSchema{ JSON: true, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -68,7 +75,9 @@ func TestParseProvidersSchema_invalid(t *testing.T) { }, "unknown flag and missing json": { []string{"-wat"}, - &ProvidersSchema{}, + &ProvidersSchema{ + Vars: &Vars{}, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -84,13 +93,60 @@ func TestParseProvidersSchema_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProvidersSchema(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProvidersSchema_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-json", "-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-json", "-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-json", + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProvidersSchema(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/arguments/providers_test.go b/internal/command/arguments/providers_test.go index b55f69704a..02b9cedd14 100644 --- a/internal/command/arguments/providers_test.go +++ b/internal/command/arguments/providers_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,23 +20,27 @@ func TestParseProviders_valid(t *testing.T) { nil, &Providers{ TestsDirectory: "tests", + Vars: &Vars{}, }, }, "test directory": { []string{"-test-directory=integration-tests"}, &Providers{ TestsDirectory: "integration-tests", + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseProviders(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } }) @@ -52,6 +57,7 @@ func TestParseProviders_invalid(t *testing.T) { []string{"-wat"}, &Providers{ TestsDirectory: "tests", + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -65,6 +71,7 @@ func TestParseProviders_invalid(t *testing.T) { []string{"foo"}, &Providers{ TestsDirectory: "tests", + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -76,13 +83,59 @@ func TestParseProviders_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseProviders(tc.args) - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) } } + +func TestParseProviders_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseProviders(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} diff --git a/internal/command/arguments/show.go b/internal/command/arguments/show.go index ebadf64cf8..03f9b029a1 100644 --- a/internal/command/arguments/show.go +++ b/internal/command/arguments/show.go @@ -15,6 +15,8 @@ type Show struct { // ViewType specifies which output format to use: human, JSON, or "raw". ViewType ViewType + + Vars *Vars } // ParseShow processes CLI arguments, returning a Show value and errors. @@ -24,10 +26,11 @@ func ParseShow(args []string) (*Show, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics show := &Show{ Path: "", + Vars: &Vars{}, } var jsonOutput bool - cmdFlags := defaultFlagSet("show") + cmdFlags := extendedFlagSet("show", nil, nil, show.Vars) cmdFlags.BoolVar(&jsonOutput, "json", false, "json") if err := cmdFlags.Parse(args); err != nil { diff --git a/internal/command/arguments/show_test.go b/internal/command/arguments/show_test.go index 4cee25ab20..6104e69e44 100644 --- a/internal/command/arguments/show_test.go +++ b/internal/command/arguments/show_test.go @@ -6,6 +6,8 @@ package arguments import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,6 +21,7 @@ func TestParseShow_valid(t *testing.T) { &Show{ Path: "", ViewType: ViewHuman, + Vars: &Vars{}, }, }, "json": { @@ -26,6 +29,7 @@ func TestParseShow_valid(t *testing.T) { &Show{ Path: "", ViewType: ViewJSON, + Vars: &Vars{}, }, }, "path": { @@ -33,18 +37,21 @@ func TestParseShow_valid(t *testing.T) { &Show{ Path: "foo", ViewType: ViewJSON, + Vars: &Vars{}, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseShow(tc.args) - if len(diags) > 0 { + if len(diags) > 0 && diags.HasErrors() { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Errorf("unexpected result\n%s", diff) } }) } @@ -61,6 +68,7 @@ func TestParseShow_invalid(t *testing.T) { &Show{ Path: "", ViewType: ViewHuman, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -75,6 +83,7 @@ func TestParseShow_invalid(t *testing.T) { &Show{ Path: "bar", ViewType: ViewJSON, + Vars: &Vars{}, }, tfdiags.Diagnostics{ tfdiags.Sourceless( @@ -86,11 +95,13 @@ func TestParseShow_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Operation{}, Vars{}, State{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseShow(tc.args) - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Errorf("unexpected result\n%s", diff) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) diff --git a/internal/command/arguments/state_mv.go b/internal/command/arguments/state_mv.go index 475c5cabcf..e0ee44b6d7 100644 --- a/internal/command/arguments/state_mv.go +++ b/internal/command/arguments/state_mv.go @@ -11,6 +11,9 @@ import ( // StateMv represents the command-line arguments for the state mv command. type StateMv struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // DryRun, if true, prints out what would be moved without actually // moving anything. DryRun bool @@ -51,9 +54,11 @@ type StateMv struct { // representing the best effort interpretation of the arguments. func ParseStateMv(args []string) (*StateMv, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - mv := &StateMv{} + mv := &StateMv{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state mv") + cmdFlags := extendedFlagSet("state mv", nil, nil, mv.Vars) cmdFlags.BoolVar(&mv.DryRun, "dry-run", false, "dry run") cmdFlags.StringVar(&mv.BackupPath, "backup", "-", "backup") cmdFlags.StringVar(&mv.BackupOutPath, "backup-out", "-", "backup") diff --git a/internal/command/arguments/state_mv_test.go b/internal/command/arguments/state_mv_test.go index be1d084dbf..7162773746 100644 --- a/internal/command/arguments/state_mv_test.go +++ b/internal/command/arguments/state_mv_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStateMv_valid(t *testing.T) { "addresses only": { []string{"test_instance.foo", "test_instance.bar"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -28,6 +32,7 @@ func TestParseStateMv_valid(t *testing.T) { "dry run": { []string{"-dry-run", "test_instance.foo", "test_instance.bar"}, &StateMv{ + Vars: &Vars{}, DryRun: true, BackupPath: "-", BackupOutPath: "-", @@ -50,6 +55,7 @@ func TestParseStateMv_valid(t *testing.T) { "test_instance.bar", }, &StateMv{ + Vars: &Vars{}, DryRun: true, BackupPath: "backup.tfstate", BackupOutPath: "backup-out.tfstate", @@ -64,19 +70,67 @@ func TestParseStateMv_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStateMv(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseStateMv_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "test_instance.foo", "test_instance.bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "test_instance.foo", "test_instance.bar"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "test_instance.foo", + "test_instance.bar", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStateMv(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseStateMv_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -86,6 +140,7 @@ func TestParseStateMv_invalid(t *testing.T) { "no arguments": { nil, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -101,6 +156,7 @@ func TestParseStateMv_invalid(t *testing.T) { "one argument": { []string{"test_instance.foo"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -117,6 +173,7 @@ func TestParseStateMv_invalid(t *testing.T) { "too many arguments": { []string{"a", "b", "c"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -134,6 +191,7 @@ func TestParseStateMv_invalid(t *testing.T) { "unknown flag": { []string{"-boop"}, &StateMv{ + Vars: &Vars{}, BackupPath: "-", BackupOutPath: "-", StateLock: true, @@ -153,10 +211,12 @@ func TestParseStateMv_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStateMv(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/arguments/state_pull.go b/internal/command/arguments/state_pull.go index 85bc653760..99673d971f 100644 --- a/internal/command/arguments/state_pull.go +++ b/internal/command/arguments/state_pull.go @@ -9,6 +9,8 @@ import ( // StatePull represents the command-line arguments for the state pull command. type StatePull struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars } // ParseStatePull processes CLI arguments, returning a StatePull value and @@ -16,9 +18,11 @@ type StatePull struct { // representing the best effort interpretation of the arguments. func ParseStatePull(args []string) (*StatePull, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - pull := &StatePull{} + pull := &StatePull{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state pull") + cmdFlags := extendedFlagSet("state pull", nil, nil, pull.Vars) if err := cmdFlags.Parse(args); err != nil { diags = diags.Append(tfdiags.Sourceless( diff --git a/internal/command/arguments/state_pull_test.go b/internal/command/arguments/state_pull_test.go index 37b9922853..7696bd94cb 100644 --- a/internal/command/arguments/state_pull_test.go +++ b/internal/command/arguments/state_pull_test.go @@ -6,6 +6,9 @@ package arguments import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -16,7 +19,55 @@ func TestParseStatePull_valid(t *testing.T) { }{ "defaults": { nil, - &StatePull{}, + &StatePull{ + Vars: &Vars{}, + }, + }, + } + + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStatePull(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseStatePull_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, }, } @@ -26,8 +77,8 @@ func TestParseStatePull_valid(t *testing.T) { if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -41,7 +92,9 @@ func TestParseStatePull_invalid(t *testing.T) { }{ "unknown flag": { []string{"-boop"}, - &StatePull{}, + &StatePull{ + Vars: &Vars{}, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -52,10 +105,12 @@ func TestParseStatePull_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStatePull(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/arguments/state_push.go b/internal/command/arguments/state_push.go index f12d0670c8..58612840a8 100644 --- a/internal/command/arguments/state_push.go +++ b/internal/command/arguments/state_push.go @@ -11,6 +11,9 @@ import ( // StatePush represents the command-line arguments for the state push command. type StatePush struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // Force writes the state even if lineages don't match or the remote // serial is higher. Force bool @@ -35,9 +38,11 @@ type StatePush struct { // representing the best effort interpretation of the arguments. func ParseStatePush(args []string) (*StatePush, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - push := &StatePush{} + push := &StatePush{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state push") + cmdFlags := extendedFlagSet("state push", nil, nil, push.Vars) cmdFlags.BoolVar(&push.Force, "force", false, "") cmdFlags.BoolVar(&push.StateLock, "lock", true, "lock state") cmdFlags.DurationVar(&push.StateLockTimeout, "lock-timeout", 0, "lock timeout") diff --git a/internal/command/arguments/state_push_test.go b/internal/command/arguments/state_push_test.go index 70a0c8b112..e69b46443d 100644 --- a/internal/command/arguments/state_push_test.go +++ b/internal/command/arguments/state_push_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStatePush_valid(t *testing.T) { "path only": { []string{"replace.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, Path: "replace.tfstate", }, @@ -25,6 +29,7 @@ func TestParseStatePush_valid(t *testing.T) { "stdin": { []string{"-"}, &StatePush{ + Vars: &Vars{}, StateLock: true, Path: "-", }, @@ -32,6 +37,7 @@ func TestParseStatePush_valid(t *testing.T) { "force": { []string{"-force", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, Force: true, StateLock: true, Path: "replace.tfstate", @@ -40,12 +46,14 @@ func TestParseStatePush_valid(t *testing.T) { "lock disabled": { []string{"-lock=false", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, Path: "replace.tfstate", }, }, "lock timeout": { []string{"-lock-timeout=5s", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, StateLockTimeout: 5 * time.Second, Path: "replace.tfstate", @@ -54,6 +62,7 @@ func TestParseStatePush_valid(t *testing.T) { "ignore remote version": { []string{"-ignore-remote-version", "replace.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, IgnoreRemoteVersion: true, Path: "replace.tfstate", @@ -61,14 +70,61 @@ func TestParseStatePush_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStatePush(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseStatePush_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "replace.tfstate"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "replace.tfstate"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "replace.tfstate", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStatePush(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -83,6 +139,7 @@ func TestParseStatePush_invalid(t *testing.T) { "no arguments": { nil, &StatePush{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -96,6 +153,7 @@ func TestParseStatePush_invalid(t *testing.T) { "too many arguments": { []string{"foo.tfstate", "bar.tfstate"}, &StatePush{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -109,6 +167,7 @@ func TestParseStatePush_invalid(t *testing.T) { "unknown flag": { []string{"-boop"}, &StatePush{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -126,10 +185,12 @@ func TestParseStatePush_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStatePush(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/arguments/state_replace_provider.go b/internal/command/arguments/state_replace_provider.go index 57f36d2c85..678d0e5a22 100644 --- a/internal/command/arguments/state_replace_provider.go +++ b/internal/command/arguments/state_replace_provider.go @@ -12,6 +12,9 @@ import ( // StateReplaceProvider represents the command-line arguments for the state // replace-provider command. type StateReplaceProvider struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // AutoApprove, if true, skips the interactive approval step. AutoApprove bool @@ -45,9 +48,11 @@ type StateReplaceProvider struct { // interpretation of the arguments. func ParseStateReplaceProvider(args []string) (*StateReplaceProvider, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - rp := &StateReplaceProvider{} + rp := &StateReplaceProvider{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state replace-provider") + cmdFlags := extendedFlagSet("state replace-provider", nil, nil, rp.Vars) cmdFlags.BoolVar(&rp.AutoApprove, "auto-approve", false, "skip interactive approval of replacements") cmdFlags.StringVar(&rp.BackupPath, "backup", "-", "backup") cmdFlags.BoolVar(&rp.StateLock, "lock", true, "lock states") diff --git a/internal/command/arguments/state_replace_provider_test.go b/internal/command/arguments/state_replace_provider_test.go index 2938cfe513..ce50616693 100644 --- a/internal/command/arguments/state_replace_provider_test.go +++ b/internal/command/arguments/state_replace_provider_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { "provider addresses only": { []string{"hashicorp/aws", "acmecorp/aws"}, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, FromProviderAddr: "hashicorp/aws", @@ -27,6 +31,7 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { "auto approve": { []string{"-auto-approve", "hashicorp/aws", "acmecorp/aws"}, &StateReplaceProvider{ + Vars: &Vars{}, AutoApprove: true, BackupPath: "-", StateLock: true, @@ -46,6 +51,7 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { "acmecorp/aws", }, &StateReplaceProvider{ + Vars: &Vars{}, AutoApprove: true, BackupPath: "backup.tfstate", StateLock: false, @@ -58,19 +64,66 @@ func TestParseStateReplaceProvider_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStateReplaceProvider(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseStateReplaceProvider_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "hashicorp/aws", "acmecorp/aws"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "hashicorp/aws", "acmecorp/aws"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "hashicorp/aws", "acmecorp/aws", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStateReplaceProvider(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseStateReplaceProvider_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -80,6 +133,7 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { "no arguments": { nil, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, }, @@ -94,6 +148,7 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { "too many arguments": { []string{"a", "b", "c", "d"}, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, }, @@ -108,6 +163,7 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { "unknown flag": { []string{"-invalid", "hashicorp/google", "acmecorp/google"}, &StateReplaceProvider{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, FromProviderAddr: "hashicorp/google", @@ -123,10 +179,12 @@ func TestParseStateReplaceProvider_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStateReplaceProvider(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/arguments/state_rm.go b/internal/command/arguments/state_rm.go index 2421903afd..2271733786 100644 --- a/internal/command/arguments/state_rm.go +++ b/internal/command/arguments/state_rm.go @@ -11,6 +11,9 @@ import ( // StateRm represents the command-line arguments for the state rm command. type StateRm struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // DryRun, if true, prints out what would be removed without actually // removing anything. DryRun bool @@ -41,9 +44,11 @@ type StateRm struct { // representing the best effort interpretation of the arguments. func ParseStateRm(args []string) (*StateRm, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - rm := &StateRm{} + rm := &StateRm{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("state rm") + cmdFlags := extendedFlagSet("state rm", nil, nil, rm.Vars) cmdFlags.BoolVar(&rm.DryRun, "dry-run", false, "dry run") cmdFlags.StringVar(&rm.BackupPath, "backup", "-", "backup") cmdFlags.BoolVar(&rm.StateLock, "lock", true, "lock state") diff --git a/internal/command/arguments/state_rm_test.go b/internal/command/arguments/state_rm_test.go index c23887406d..6e11e6490c 100644 --- a/internal/command/arguments/state_rm_test.go +++ b/internal/command/arguments/state_rm_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseStateRm_valid(t *testing.T) { "single address": { []string{"test_instance.foo"}, &StateRm{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, Addrs: []string{"test_instance.foo"}, @@ -26,6 +30,7 @@ func TestParseStateRm_valid(t *testing.T) { "multiple addresses": { []string{"test_instance.foo", "test_instance.bar"}, &StateRm{ + Vars: &Vars{}, BackupPath: "-", StateLock: true, Addrs: []string{"test_instance.foo", "test_instance.bar"}, @@ -34,6 +39,7 @@ func TestParseStateRm_valid(t *testing.T) { "all options": { []string{"-dry-run", "-backup=backup.tfstate", "-lock=false", "-lock-timeout=5s", "-state=state.tfstate", "-ignore-remote-version", "test_instance.foo"}, &StateRm{ + Vars: &Vars{}, DryRun: true, BackupPath: "backup.tfstate", StateLock: false, @@ -45,27 +51,64 @@ func TestParseStateRm_valid(t *testing.T) { }, } + cmpOpts := cmp.Options{ + cmpopts.IgnoreUnexported(Vars{}), + cmpopts.EquateEmpty(), + } + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseStateRm(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if got.DryRun != tc.want.DryRun || - got.BackupPath != tc.want.BackupPath || - got.StateLock != tc.want.StateLock || - got.StateLockTimeout != tc.want.StateLockTimeout || - got.StatePath != tc.want.StatePath || - got.IgnoreRemoteVersion != tc.want.IgnoreRemoteVersion { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } - if len(got.Addrs) != len(tc.want.Addrs) { - t.Fatalf("unexpected Addrs length\n got: %d\nwant: %d", len(got.Addrs), len(tc.want.Addrs)) + }) + } +} + +func TestParseStateRm_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "test_instance.foo", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStateRm(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) } - for i := range got.Addrs { - if got.Addrs[i] != tc.want.Addrs[i] { - t.Fatalf("unexpected Addrs[%d]\n got: %q\nwant: %q", i, got.Addrs[i], tc.want.Addrs[i]) - } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -74,12 +117,16 @@ func TestParseStateRm_valid(t *testing.T) { func TestParseStateRm_invalid(t *testing.T) { testCases := map[string]struct { args []string - wantAddrs int + want *StateRm wantDiags tfdiags.Diagnostics }{ "no arguments": { nil, - 0, + &StateRm{ + Vars: &Vars{}, + BackupPath: "-", + StateLock: true, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -90,7 +137,11 @@ func TestParseStateRm_invalid(t *testing.T) { }, "unknown flag": { []string{"-boop"}, - 0, + &StateRm{ + Vars: &Vars{}, + BackupPath: "-", + StateLock: true, + }, tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -106,11 +157,16 @@ func TestParseStateRm_invalid(t *testing.T) { }, } + cmpOpts := cmp.Options{ + cmpopts.IgnoreUnexported(Vars{}), + cmpopts.EquateEmpty(), + } + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseStateRm(tc.args) - if len(got.Addrs) != tc.wantAddrs { - t.Fatalf("unexpected Addrs length\n got: %d\nwant: %d", len(got.Addrs), tc.wantAddrs) + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) }) diff --git a/internal/command/arguments/taint.go b/internal/command/arguments/taint.go index 3be7a20d2e..bf7060af26 100644 --- a/internal/command/arguments/taint.go +++ b/internal/command/arguments/taint.go @@ -11,6 +11,9 @@ import ( // Taint represents the command-line arguments for the taint command. type Taint struct { + // Vars are the variable-related flags (-var, -var-file). + Vars *Vars + // Address is the address of the resource instance to taint. Address string @@ -44,9 +47,11 @@ type Taint struct { // the best effort interpretation of the arguments. func ParseTaint(args []string) (*Taint, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - taint := &Taint{} + taint := &Taint{ + Vars: &Vars{}, + } - cmdFlags := defaultFlagSet("taint") + cmdFlags := extendedFlagSet("taint", nil, nil, taint.Vars) cmdFlags.BoolVar(&taint.AllowMissing, "allow-missing", false, "allow missing") cmdFlags.StringVar(&taint.BackupPath, "backup", "", "path") cmdFlags.BoolVar(&taint.StateLock, "lock", true, "lock state") diff --git a/internal/command/arguments/taint_test.go b/internal/command/arguments/taint_test.go index 7a6a3c07cd..f79da4f6e2 100644 --- a/internal/command/arguments/taint_test.go +++ b/internal/command/arguments/taint_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,6 +21,7 @@ func TestParseTaint_valid(t *testing.T) { "defaults with address": { []string{"test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, }, @@ -25,6 +29,7 @@ func TestParseTaint_valid(t *testing.T) { "allow-missing": { []string{"-allow-missing", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", AllowMissing: true, StateLock: true, @@ -33,6 +38,7 @@ func TestParseTaint_valid(t *testing.T) { "backup": { []string{"-backup", "backup.tfstate", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", BackupPath: "backup.tfstate", StateLock: true, @@ -41,12 +47,14 @@ func TestParseTaint_valid(t *testing.T) { "lock disabled": { []string{"-lock=false", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", }, }, "lock-timeout": { []string{"-lock-timeout=10s", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, StateLockTimeout: 10 * time.Second, @@ -55,6 +63,7 @@ func TestParseTaint_valid(t *testing.T) { "state": { []string{"-state=foo.tfstate", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, StatePath: "foo.tfstate", @@ -63,6 +72,7 @@ func TestParseTaint_valid(t *testing.T) { "state-out": { []string{"-state-out=foo.tfstate", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, StateOutPath: "foo.tfstate", @@ -71,6 +81,7 @@ func TestParseTaint_valid(t *testing.T) { "ignore-remote-version": { []string{"-ignore-remote-version", "test_instance.foo"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, IgnoreRemoteVersion: true, @@ -88,6 +99,7 @@ func TestParseTaint_valid(t *testing.T) { "module.child.test_instance.foo", }, &Taint{ + Vars: &Vars{}, Address: "module.child.test_instance.foo", AllowMissing: true, BackupPath: "backup.tfstate", @@ -99,19 +111,66 @@ func TestParseTaint_valid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseTaint(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } }) } } +func TestParseTaint_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars", "test_instance.foo"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + "test_instance.foo", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseTaint(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) + } + }) + } +} + func TestParseTaint_invalid(t *testing.T) { testCases := map[string]struct { args []string @@ -121,6 +180,7 @@ func TestParseTaint_invalid(t *testing.T) { "unknown flag": { []string{"-unknown"}, &Taint{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -139,6 +199,7 @@ func TestParseTaint_invalid(t *testing.T) { "missing address": { nil, &Taint{ + Vars: &Vars{}, StateLock: true, }, tfdiags.Diagnostics{ @@ -152,6 +213,7 @@ func TestParseTaint_invalid(t *testing.T) { "too many arguments": { []string{"test_instance.foo", "test_instance.bar"}, &Taint{ + Vars: &Vars{}, Address: "test_instance.foo", StateLock: true, }, @@ -165,10 +227,12 @@ func TestParseTaint_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseTaint(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/arguments/validate.go b/internal/command/arguments/validate.go index 8c337b37e9..2dcccb776a 100644 --- a/internal/command/arguments/validate.go +++ b/internal/command/arguments/validate.go @@ -27,6 +27,8 @@ type Validate struct { // Query indicates that Terraform should also validate .tfquery files. Query bool + + Vars *Vars } // ParseValidate processes CLI arguments, returning a Validate value and errors. @@ -36,10 +38,11 @@ func ParseValidate(args []string) (*Validate, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics validate := &Validate{ Path: ".", + Vars: &Vars{}, } var jsonOutput bool - cmdFlags := defaultFlagSet("validate") + cmdFlags := extendedFlagSet("validate", nil, nil, validate.Vars) cmdFlags.BoolVar(&jsonOutput, "json", false, "json") cmdFlags.StringVar(&validate.TestDirectory, "test-directory", "tests", "test-directory") cmdFlags.BoolVar(&validate.NoTests, "no-tests", false, "no-tests") diff --git a/internal/command/arguments/validate_test.go b/internal/command/arguments/validate_test.go index 1e9f0939dc..8619822e0c 100644 --- a/internal/command/arguments/validate_test.go +++ b/internal/command/arguments/validate_test.go @@ -6,6 +6,8 @@ package arguments import ( "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -19,6 +21,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewHuman, }, }, @@ -27,6 +30,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewJSON, }, }, @@ -35,6 +39,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: "foo", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewJSON, }, }, @@ -43,6 +48,7 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "other", + Vars: &Vars{}, ViewType: ViewHuman, }, }, @@ -51,20 +57,67 @@ func TestParseValidate_valid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewHuman, NoTests: true, }, }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseValidate(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseValidate_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "both": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseValidate(tc.args) if len(diags) > 0 { t.Fatalf("unexpected diags: %v", diags) } - if *got != *tc.want { - t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected vars: %#v", vars) } }) } @@ -81,6 +134,7 @@ func TestParseValidate_invalid(t *testing.T) { &Validate{ Path: ".", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewHuman, }, tfdiags.Diagnostics{ @@ -96,6 +150,7 @@ func TestParseValidate_invalid(t *testing.T) { &Validate{ Path: "bar", TestDirectory: "tests", + Vars: &Vars{}, ViewType: ViewJSON, }, tfdiags.Diagnostics{ @@ -108,10 +163,12 @@ func TestParseValidate_invalid(t *testing.T) { }, } + cmpOpts := cmpopts.IgnoreUnexported(Vars{}) + for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, gotDiags := ParseValidate(tc.args) - if *got != *tc.want { + if diff := cmp.Diff(tc.want, got, cmpOpts); diff != "" { t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) } tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 6be429ba62..8eecaf1ef2 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -50,6 +50,7 @@ import ( "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/version" ) @@ -158,15 +159,31 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) } - config, snap, diags := loader.LoadConfigWithSnapshot(dir) - if diags.HasErrors() { - t.Fatal(diags.Error()) + rootMod, configDiags := loader.LoadRootModule(dir) + if configDiags.HasErrors() { + t.Fatal(configDiags.Error()) + } + + walkerSnapshot, snap := loader.ModuleWalkerSnapshot() + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + walkerSnapshot, + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatal(buildDiags.Err()) + } + + snapDiags := loader.AddRootModuleToSnapshot(snap, dir) + if snapDiags.HasErrors() { + t.Fatal(snapDiags.Error()) } return config, snap diff --git a/internal/command/e2etest/pluggable_state_store_test.go b/internal/command/e2etest/pluggable_state_store_test.go index 66865804c7..208a8001fb 100644 --- a/internal/command/e2etest/pluggable_state_store_test.go +++ b/internal/command/e2etest/pluggable_state_store_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -114,9 +115,6 @@ func TestPrimary_stateStore_unmanaged_separatePlan(t *testing.T) { if !provider.ReadStateBytesCalled() { t.Error("ReadStateBytes not called on un-managed provider") } - if !provider.WriteStateBytesCalled() { - t.Error("WriteStateBytes not called on un-managed provider") - } provider.ResetReadStateBytesCalled() provider.ResetWriteStateBytesCalled() @@ -211,12 +209,9 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) } - fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, "default", "terraform.tfstate")) - if err != nil { - t.Fatalf("failed to open default workspace's state file: %s", err) - } - if fi.Size() == 0 { - t.Fatal("default workspace's state file should not have size 0 bytes") + _, err = os.Stat(path.Join(tf.WorkDir(), workspaceDirName, "default", "terraform.tfstate")) + if !errors.Is(err, os.ErrNotExist) { + t.Fatal("expected default workspace's state file to not exist, but it exists") } //// Create Workspace: terraform workspace new @@ -229,7 +224,7 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { if !strings.Contains(stdout, expectedMsg) { t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) } - fi, err = os.Stat(path.Join(tf.WorkDir(), workspaceDirName, newWorkspace, "terraform.tfstate")) + fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, newWorkspace, "terraform.tfstate")) if err != nil { t.Fatalf("failed to open %s workspace's state file: %s", newWorkspace, err) } @@ -248,13 +243,13 @@ func TestPrimary_stateStore_workspaceCmd(t *testing.T) { //// Select Workspace: terraform workspace select selectedWorkspace := "default" - stdout, stderr, err = tf.Run("workspace", "select", selectedWorkspace, "-no-color") + stdout, stderr, err = tf.Run("workspace", "select", "-or-create", selectedWorkspace, "-no-color") if err != nil { t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) } - expectedMsg = fmt.Sprintf("Switched to workspace %q.", selectedWorkspace) + expectedMsg = fmt.Sprintf("Created and switched to workspace %q!", selectedWorkspace) if !strings.Contains(stdout, expectedMsg) { - t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + t.Errorf("unexpected output, expected %s, but got:\n%s", expectedMsg, stdout) } //// Show Workspace: terraform workspace show @@ -640,13 +635,7 @@ func TestPrimary_stateStore_providerCmds(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) } - fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, "default", "terraform.tfstate")) - if err != nil { - t.Fatalf("failed to open default workspace's state file: %s", err) - } - if fi.Size() == 0 { - t.Fatal("default workspace's state file should not have size 0 bytes") - } + // Note: The default state was already created earlier in the test //// Providers: `terraform providers` stdout, stderr, err := tf.Run("providers", "-no-color") diff --git a/internal/command/e2etest/primary_test.go b/internal/command/e2etest/primary_test.go index 583691c110..6720305189 100644 --- a/internal/command/e2etest/primary_test.go +++ b/internal/command/e2etest/primary_test.go @@ -146,7 +146,6 @@ func TestPrimarySeparatePlan(t *testing.T) { if len(stateResources) != 0 { t.Errorf("wrong resources in state after destroy; want none, but still have:%s", spew.Sdump(stateResources)) } - } func TestPrimaryChdirOption(t *testing.T) { @@ -236,7 +235,6 @@ func TestPrimaryChdirOption(t *testing.T) { } func TestPrimary_stateStore(t *testing.T) { - if !canRunGoBuild { // We're running in a separate-build-then-run context, so we can't // currently execute this test which depends on being able to build @@ -270,20 +268,16 @@ func TestPrimary_stateStore(t *testing.T) { } //// INIT - stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + _, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") if err != nil { t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) } - if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") { - t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout) - } - //// PLAN // No separate plan step; this test lets the apply make a plan. //// APPLY - stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color") + stdout, stderr, err := tf.Run("apply", "-auto-approve", "-no-color") if err != nil { t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) } @@ -315,7 +309,6 @@ func TestPrimary_stateStore(t *testing.T) { } func TestPrimary_stateStore_planFile(t *testing.T) { - if !canRunGoBuild { // We're running in a separate-build-then-run context, so we can't // currently execute this test which depends on being able to build @@ -348,15 +341,11 @@ func TestPrimary_stateStore_planFile(t *testing.T) { } //// INIT - stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + _, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") if err != nil { t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) } - if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") { - t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout) - } - //// PLAN planFile := "testplan" _, stderr, err = tf.Run("plan", "-out="+planFile, "-no-color") @@ -365,7 +354,7 @@ func TestPrimary_stateStore_planFile(t *testing.T) { } //// APPLY - stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color", planFile) + stdout, stderr, err := tf.Run("apply", "-auto-approve", "-no-color", planFile) if err != nil { t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) } @@ -432,15 +421,11 @@ func TestPrimary_stateStore_inMem(t *testing.T) { // // Note - the inmem PSS implementation means that the default workspace state created during init // is lost as soon as the command completes. - stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + _, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") if err != nil { t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) } - if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") { - t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout) - } - //// PLAN // No separate plan step; this test lets the apply make a plan. @@ -448,7 +433,7 @@ func TestPrimary_stateStore_inMem(t *testing.T) { // // Note - the inmem PSS implementation means that writing to the default workspace during apply // is creating the default state file for the first time. - stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color") + stdout, stderr, err := tf.Run("apply", "-auto-approve", "-no-color") if err != nil { t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) } diff --git a/internal/command/e2etest/provider_dev_test.go b/internal/command/e2etest/provider_dev_test.go index 739561b8d2..fe3b89ccf1 100644 --- a/internal/command/e2etest/provider_dev_test.go +++ b/internal/command/e2etest/provider_dev_test.go @@ -5,7 +5,6 @@ package e2etest import ( "fmt" - "io/ioutil" "os" "path/filepath" "strings" @@ -47,7 +46,7 @@ func TestProviderDevOverrides(t *testing.T) { providerExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple/main", providerExePrefix) t.Logf("temporary provider executable is %s", providerExe) - err := ioutil.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` + err := os.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` provider_installation { dev_overrides { "example.com/test/test" = %q @@ -86,7 +85,7 @@ func TestProviderDevOverrides(t *testing.T) { t.Errorf("stdout doesn't include the warning about development overrides\nwant: %s\n%s", want, got) } - stdout, _, _ = tf.Run("init") + stdout, _, err = tf.Run("init") if err != nil { t.Fatalf("unexpected error: %e", err) } @@ -129,7 +128,7 @@ func TestProviderDevOverridesWithProviderToDownload(t *testing.T) { providerExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple/main", providerExePrefix) t.Logf("temporary provider executable is %s", providerExe) - err := ioutil.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` + err := os.WriteFile(filepath.Join(tf.WorkDir(), "dev.tfrc"), []byte(fmt.Sprintf(` provider_installation { dev_overrides { "example.com/test/test" = %q @@ -143,7 +142,7 @@ func TestProviderDevOverridesWithProviderToDownload(t *testing.T) { tf.AddEnv("TF_CLI_CONFIG_FILE=dev.tfrc") - stdout, stderr, _ := tf.Run("providers") + stdout, stderr, err := tf.Run("providers") if err != nil { t.Fatalf("unexpected error: %s\n%s", err, stderr) } diff --git a/internal/command/get.go b/internal/command/get.go index 7de3f96be6..c3587aa62f 100644 --- a/internal/command/get.go +++ b/internal/command/get.go @@ -5,9 +5,9 @@ package command import ( "context" - "fmt" "strings" + "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -18,16 +18,26 @@ type GetCommand struct { } func (c *GetCommand) Run(args []string) int { - var update bool - var testsDirectory string + parsedArgs, diags := arguments.ParseGet(c.Meta.process(args)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } - args = c.Meta.process(args) - cmdFlags := c.Meta.defaultFlagSet("get") - cmdFlags.BoolVar(&update, "update", false, "update") - cmdFlags.StringVar(&testsDirectory, "test-directory", "tests", "test-directory") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) return 1 } @@ -35,15 +45,22 @@ func (c *GetCommand) Run(args []string) int { ctx, done := c.InterruptibleContext(c.CommandContext()) defer done() - path, err := ModulePath(cmdFlags.Args()) + path, err := ModulePath(nil) if err != nil { c.Ui.Error(err.Error()) return 1 } + diags = diags.Append(c.resolveConstVariables(path, arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + path = c.normalizePath(path) - abort, diags := getModules(ctx, &c.Meta, path, testsDirectory, update) + abort, moreDiags := getModules(ctx, &c.Meta, path, parsedArgs.TestDirectory, parsedArgs.Update) + diags = diags.Append(moreDiags) c.showDiagnostics(diags) if abort || diags.HasErrors() { return 1 @@ -75,7 +92,16 @@ Options: -no-color Disable text coloring in the output. - -test-directory=path Set the Terraform test directory, defaults to "tests". + -test-directory=path Set the Terraform test directory, defaults to "tests". + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. ` return strings.TrimSpace(helpText) diff --git a/internal/command/graph.go b/internal/command/graph.go index ded3c1f70b..02e8b3c924 100644 --- a/internal/command/graph.go +++ b/internal/command/graph.go @@ -24,27 +24,14 @@ type GraphCommand struct { Meta } -func (c *GraphCommand) Run(args []string) int { - var drawCycles bool - var graphTypeStr string - var moduleDepth int - var verbose bool - var planPath string - - args = c.Meta.process(args) - cmdFlags := c.Meta.defaultFlagSet("graph") - cmdFlags.BoolVar(&drawCycles, "draw-cycles", false, "draw-cycles") - cmdFlags.StringVar(&graphTypeStr, "type", "", "type") - cmdFlags.IntVar(&moduleDepth, "module-depth", -1, "module-depth") - cmdFlags.BoolVar(&verbose, "verbose", false, "verbose") - cmdFlags.StringVar(&planPath, "plan", "", "plan") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) +func (c *GraphCommand) Run(rawArgs []string) int { + args, diags := arguments.ParseGraph(c.Meta.process(rawArgs)) + if diags.HasErrors() { + c.showDiagnostics(diags) return 1 } - configPath, err := ModulePath(cmdFlags.Args()) + configPath, err := ModulePath(nil) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -58,16 +45,14 @@ func (c *GraphCommand) Run(args []string) int { // Try to load plan if path is specified var planFile *planfile.WrappedPlanFile - if planPath != "" { - planFile, err = c.PlanFile(planPath) + if args.Plan != "" { + planFile, err = c.PlanFile(args.Plan) if err != nil { c.Ui.Error(err.Error()) return 1 } } - var diags tfdiags.Diagnostics - // Load the backend b, backendDiags := c.backend(".", arguments.ViewHuman) diags = diags.Append(backendDiags) @@ -99,6 +84,16 @@ func (c *GraphCommand) Run(args []string) int { return 1 } + var varDiags tfdiags.Diagnostics + opReq.Variables, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { + opReq.ConfigLoader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + // Get the context lr, _, ctxDiags := local.LocalRun(opReq) diags = diags.Append(ctxDiags) @@ -106,9 +101,9 @@ func (c *GraphCommand) Run(args []string) int { c.showDiagnostics(diags) return 1 } - lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: drawCycles}) + lr.Core.SetGraphOpts(&terraform.ContextGraphOpts{SkipGraphValidation: args.DrawCycles}) - if graphTypeStr == "" { + if args.GraphType == "" { if planFile == nil { // Simple resource dependency mode: // This is based on the plan graph but we then further reduce it down @@ -125,13 +120,13 @@ func (c *GraphCommand) Run(args []string) int { g := fullG.ResourceGraph() return c.resourceOnlyGraph(g) } else { - graphTypeStr = "apply" + args.GraphType = "apply" } } var g *terraform.Graph var graphDiags tfdiags.Diagnostics - switch graphTypeStr { + switch args.GraphType { case "plan": g, graphDiags = lr.Core.PlanGraphForUI(lr.Config, lr.InputState, plans.NormalMode) case "plan-refresh-only": @@ -162,7 +157,7 @@ func (c *GraphCommand) Run(args []string) int { graphDiags = graphDiags.Append(tfdiags.Sourceless( tfdiags.Error, "Graph type no longer available", - fmt.Sprintf("The graph type %q is no longer available. Use -type=plan instead to get a similar result.", graphTypeStr), + fmt.Sprintf("The graph type %q is no longer available. Use -type=plan instead to get a similar result.", args.GraphType), )) default: graphDiags = graphDiags.Append(tfdiags.Sourceless( @@ -178,9 +173,9 @@ func (c *GraphCommand) Run(args []string) int { } graphStr, err := terraform.GraphDot(g, &dag.DotOpts{ - DrawCycles: drawCycles, - MaxDepth: moduleDepth, - Verbose: verbose, + DrawCycles: args.DrawCycles, + MaxDepth: args.ModuleDepth, + Verbose: args.Verbose, }) if err != nil { c.Ui.Error(fmt.Sprintf("Error converting graph: %s", err)) @@ -311,23 +306,32 @@ Usage: terraform [global options] graph [options] Options: - -plan=tfplan Render graph using the specified plan file instead of the - configuration in the current directory. Implies -type=apply. + -plan=tfplan Render graph using the specified plan file instead of the + configuration in the current directory. Implies -type=apply. - -draw-cycles Highlight any cycles in the graph with colored edges. - This helps when diagnosing cycle errors. This option is - supported only when illustrating a real evaluation graph, - selected using the -type=TYPE option. + -draw-cycles Highlight any cycles in the graph with colored edges. + This helps when diagnosing cycle errors. This option is + supported only when illustrating a real evaluation graph, + selected using the -type=TYPE option. - -type=TYPE Type of operation graph to output. Can be: plan, - plan-refresh-only, plan-destroy, or apply. By default - Terraform just summarizes the relationships between the - resources in your configuration, without any particular - operation in mind. Full operation graphs are more detailed - but therefore often harder to read. + -type=TYPE Type of operation graph to output. Can be: plan, + plan-refresh-only, plan-destroy, or apply. By default + Terraform just summarizes the relationships between the + resources in your configuration, without any particular + operation in mind. Full operation graphs are more detailed + but therefore often harder to read. - -module-depth=n (deprecated) In prior versions of Terraform, specified the - depth of modules to show in the output. + -module-depth=n (deprecated) In prior versions of Terraform, specified the + depth of modules to show in the output. + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. ` return strings.TrimSpace(helpText) } diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index 97bdc78621..9490ed130c 100644 --- a/internal/command/graph_test.go +++ b/internal/command/graph_test.go @@ -224,7 +224,7 @@ func TestGraph_resourcesOnly(t *testing.T) { if err != nil { t.Fatal(err) } - inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), ".", "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) diff --git a/internal/command/import.go b/internal/command/import.go index dfc0e2c7d4..f733fa2ae8 100644 --- a/internal/command/import.go +++ b/internal/command/import.go @@ -86,6 +86,54 @@ func (c *ImportCommand) Run(args []string) int { return 1 } + // Load the backend + b, backendDiags := c.backend(".", arguments.ViewHuman) + diags = diags.Append(backendDiags) + if backendDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + // We require a backendrun.Local to build a context. + // This isn't necessarily a "local.Local" backend, which provides local + // operations, however that is the only current implementation. A + // "local.Local" backend also doesn't necessarily provide local state, as + // that may be delegated to a "remotestate.Backend". + local, ok := b.(backendrun.Local) + if !ok { + c.Ui.Error(ErrUnsupportedLocalOp) + return 1 + } + + // Build the operation + var err error + opReq := c.Operation(b, arguments.ViewHuman) + opReq.ConfigDir = parsedArgs.ConfigPath + opReq.ConfigLoader, err = c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + opReq.Hooks = []terraform.Hook{c.uiHook()} + + { + // Collect variable value and add them to the operation request + var varDiags tfdiags.Diagnostics + opReq.Variables, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + opReq.ConfigLoader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + + if varDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + c.VariableValues = opReq.Variables + } + opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) + // Load the full config, so we can verify that the target resource is // already configured. config, configDiags := c.loadConfig(parsedArgs.ConfigPath) @@ -144,57 +192,11 @@ func (c *ImportCommand) Run(args []string) int { } // Check for user-supplied plugin path - var err error if c.pluginPath, err = c.loadPluginPath(); err != nil { c.Ui.Error(fmt.Sprintf("Error loading plugin path: %s", err)) return 1 } - // Load the backend - b, backendDiags := c.backend(".", arguments.ViewHuman) - diags = diags.Append(backendDiags) - if backendDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - - // We require a backendrun.Local to build a context. - // This isn't necessarily a "local.Local" backend, which provides local - // operations, however that is the only current implementation. A - // "local.Local" backend also doesn't necessarily provide local state, as - // that may be delegated to a "remotestate.Backend". - local, ok := b.(backendrun.Local) - if !ok { - c.Ui.Error(ErrUnsupportedLocalOp) - return 1 - } - - // Build the operation - opReq := c.Operation(b, arguments.ViewHuman) - opReq.ConfigDir = parsedArgs.ConfigPath - opReq.ConfigLoader, err = c.initConfigLoader() - if err != nil { - diags = diags.Append(err) - c.showDiagnostics(diags) - return 1 - } - opReq.Hooks = []terraform.Hook{c.uiHook()} - - { - // Collect variable value and add them to the operation request - var varDiags tfdiags.Diagnostics - opReq.Variables, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { - opReq.ConfigLoader.Parser().ForceFileSource(filename, src) - }) - diags = diags.Append(varDiags) - - if varDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - } - opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) - // Check remote Terraform version is compatible remoteVersionDiags := c.remoteVersionCheck(b, opReq.Workspace) diags = diags.Append(remoteVersionDiags) diff --git a/internal/command/init.go b/internal/command/init.go index 6bf73c9cf1..7033efba00 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -7,7 +7,9 @@ import ( "context" "fmt" "log" + "maps" "reflect" + "slices" "sort" "strings" @@ -26,6 +28,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/didyoumean" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/providercache" @@ -46,26 +49,29 @@ func (c *InitCommand) Run(args []string) int { var diags tfdiags.Diagnostics args = c.Meta.process(args) initArgs, initDiags := arguments.ParseInit(args, c.Meta.AllowExperimentalFeatures) + diags = diags.Append(initDiags) view := views.NewInit(initArgs.ViewType, c.View) - if initDiags.HasErrors() { - diags = diags.Append(initDiags) + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) view.Diagnostics(diags) return 1 } - // The else condition below invokes the original logic of the init command. - // An experimental version of the init code will be used if: - // > The user uses an experimental version of TF (alpha or built from source) - // > Either the flag -enable-pluggable-state-storage-experiment is passed to the init command. - // > Or, the environment variable TF_ENABLE_PLUGGABLE_STATE_STORAGE is set to any value. - if c.Meta.AllowExperimentalFeatures && initArgs.EnablePssExperiment { - // TODO(SarahFrench/radeksimko): Remove forked init logic once feature is no longer experimental - return c.runPssInit(initArgs, view) - } else { - return c.run(initArgs, view) + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = initArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 } + + return c.run(initArgs, view) } func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, earlyRoot *configs.Module, upgrade bool, view views.Init) (output bool, abort bool, diags tfdiags.Diagnostics) { @@ -152,16 +158,144 @@ func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extra return back, true, diags } -func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, extraConfig arguments.FlagNameValueSlice, viewType arguments.ViewType, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { +func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, initArgs *arguments.Init, configLocks *depsfile.Locks, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { ctx, span := tracer.Start(ctx, "initialize backend") _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here defer span.End() - view.Output(views.InitializingBackendMessage) + if root.StateStore != nil { + view.Output(views.InitializingStateStoreMessage) + } else { + view.Output(views.InitializingBackendMessage) + } - var backendConfig *configs.Backend - var backendConfigOverride hcl.Body - if root.Backend != nil { + var opts *BackendOpts + switch { + case root.StateStore != nil: + // state_store config present + factory, fDiags := c.Meta.StateStoreProviderFactoryFromConfig(root.StateStore, configLocks) + diags = diags.Append(fDiags) + if fDiags.HasErrors() { + return nil, true, diags + } + + // If overrides supplied by -backend-config CLI flag, process them + var configOverride hcl.Body + if !initArgs.BackendConfig.Empty() { + // We need to launch an instance of the provider to get the config of the state store for processing any overrides. + provider, err := factory() + defer provider.Close() // Stop the child process once we're done with it here. + if err != nil { + diags = diags.Append(fmt.Errorf("error when obtaining provider instance during state store initialization: %w", err)) + return nil, true, diags + } + + resp := provider.GetProviderSchema() + + if len(resp.StateStores) == 0 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Provider does not support pluggable state storage", + Detail: fmt.Sprintf("There are no state stores implemented by provider %s (%q)", + root.StateStore.Provider.Name, + root.StateStore.ProviderAddr), + Subject: &root.StateStore.DeclRange, + }) + return nil, true, diags + } + + stateStoreSchema, exists := resp.StateStores[root.StateStore.Type] + if !exists { + suggestions := slices.Sorted(maps.Keys(resp.StateStores)) + suggestion := didyoumean.NameSuggestion(root.StateStore.Type, suggestions) + if suggestion != "" { + suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "State store not implemented by the provider", + Detail: fmt.Sprintf("State store %q is not implemented by provider %s (%q)%s", + root.StateStore.Type, root.StateStore.Provider.Name, + root.StateStore.ProviderAddr, suggestion), + Subject: &root.StateStore.DeclRange, + }) + return nil, true, diags + } + + // Handle any overrides supplied via -backend-config CLI flags + var overrideDiags tfdiags.Diagnostics + configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, stateStoreSchema.Body) + diags = diags.Append(overrideDiags) + if overrideDiags.HasErrors() { + return nil, true, diags + } + } + + opts = &BackendOpts{ + StateStoreConfig: root.StateStore, + Locks: configLocks, + ConfigOverride: configOverride, + Init: true, + ViewType: initArgs.ViewType, + } + + case root.Backend != nil: + // backend config present + backendType := root.Backend.Type + bf := backendInit.Backend(backendType) + b := bf() + backendSchema := b.ConfigSchema() + backendConfig := root.Backend + + // If overrides supplied by -backend-config CLI flag, process them + var configOverride hcl.Body + if !initArgs.BackendConfig.Empty() { + var overrideDiags tfdiags.Diagnostics + configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, backendSchema) + diags = diags.Append(overrideDiags) + if overrideDiags.HasErrors() { + return nil, true, diags + } + } + + opts = &BackendOpts{ + BackendConfig: backendConfig, + Locks: configLocks, + ConfigOverride: configOverride, + Init: true, + ViewType: initArgs.ViewType, + } + + default: + // No config; defaults to local state storage + opts = &BackendOpts{ + Init: true, + Locks: configLocks, + ViewType: initArgs.ViewType, + } + } + + back, backDiags := c.Backend(opts) + diags = diags.Append(backDiags) + return back, true, diags +} + +func (c *InitCommand) earlyValidateBackend(root *configs.Module, initArgs *arguments.Init) (diags tfdiags.Diagnostics) { + switch { + case root.StateStore != nil && root.Backend != nil: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Conflicting backend and state_store configurations present during init", + Detail: fmt.Sprintf("When initializing the backend there was configuration data present for both backend %q and state store %q. This is a bug in Terraform and should be reported.", + root.Backend.Type, + root.StateStore.Type, + ), + Subject: &root.Backend.TypeRange, + }) + return diags + case root.StateStore != nil: + // validation requires the provider to be installed so cannot be done early + case root.Backend != nil: backendType := root.Backend.Type if backendType == "cloud" { diags = diags.Append(&hcl.Diagnostic{ @@ -170,11 +304,10 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ext Detail: fmt.Sprintf("There is no explicit backend type named %q. To configure HCP Terraform, declare a 'cloud' block instead.", backendType), Subject: &root.Backend.TypeRange, }) - return nil, true, diags + return diags } - bf := backendInit.Backend(backendType) - if bf == nil { + if !backendInit.BackendExists(backendType) { detail := fmt.Sprintf("There is no backend type named %q.", backendType) if msg, removed := backendInit.RemovedBackends[backendType]; removed { detail = msg @@ -186,24 +319,15 @@ func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, ext Detail: detail, Subject: &root.Backend.TypeRange, }) - return nil, true, diags + return diags } + default: + // No config; defaults to local state storage - b := bf() - backendSchema := b.ConfigSchema() - backendConfig = root.Backend - - var overrideDiags tfdiags.Diagnostics - backendConfigOverride, overrideDiags = c.backendConfigOverrideBody(extraConfig, backendSchema) - diags = diags.Append(overrideDiags) - if overrideDiags.HasErrors() { - return nil, true, diags - } - } else { // If the user supplied a -backend-config on the CLI but no backend // block was found in the configuration, it's likely - but not // necessarily - a mistake. Return a warning. - if !extraConfig.Empty() { + if !initArgs.BackendConfig.Empty() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Missing backend configuration", @@ -223,471 +347,7 @@ the backend configuration is present and valid. )) } } - - opts := &BackendOpts{ - BackendConfig: backendConfig, - ConfigOverride: backendConfigOverride, - Init: true, - ViewType: viewType, - } - - back, backDiags := c.Backend(opts) - diags = diags.Append(backDiags) - return back, true, diags -} - -// getProviders determines what providers are required given configuration and state data. The method downloads any missing providers -// and replaces the contents of the dependency lock file if any changes happen. -// The calling code is expected to have loaded the complete module tree and read the state file, and passes that data into this method. -// -// This method outputs to the provided view. The returned `output` boolean lets calling code know if anything has been rendered via the view. -func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, state *states.State, upgrade bool, pluginDirs []string, flagLockfile string, view views.Init) (output, abort bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "install providers") - defer span.End() - - // Dev overrides cause the result of "terraform init" to be irrelevant for - // any overridden providers, so we'll warn about it to avoid later - // confusion when Terraform ends up using a different provider than the - // lock file called for. - diags = diags.Append(c.providerDevOverrideInitWarnings()) - - // First we'll collect all the provider dependencies we can see in the - // configuration and the state. - reqs, hclDiags := config.ProviderRequirements() - diags = diags.Append(hclDiags) - if hclDiags.HasErrors() { - return false, true, diags - } - - reqs = c.removeDevOverrides(reqs) - if state != nil { - stateReqs := state.ProviderRequirements() - reqs = reqs.Merge(stateReqs) - } - for providerAddr := range reqs { - if providerAddr.IsLegacy() { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid legacy provider address", - fmt.Sprintf( - "This configuration or its associated state refers to the unqualified provider %q.\n\nYou must complete the Terraform 0.13 upgrade process before upgrading to later versions.", - providerAddr.Type, - ), - )) - } - } - - previousLocks, moreDiags := c.lockedDependencies() - diags = diags.Append(moreDiags) - - if diags.HasErrors() { - return false, true, diags - } - - var inst *providercache.Installer - if len(pluginDirs) == 0 { - // By default we use a source that looks for providers in all of the - // standard locations, possibly customized by the user in CLI config. - inst = c.providerInstaller() - } else { - // If the user passes at least one -plugin-dir then that circumvents - // the usual sources and forces Terraform to consult only the given - // directories. Anything not available in one of those directories - // is not available for installation. - source := c.providerCustomLocalDirectorySource(pluginDirs) - inst = c.providerInstallerCustomSource(source) - - // The default (or configured) search paths are logged earlier, in provider_source.go - // Log that those are being overridden by the `-plugin-dir` command line options - log.Println("[DEBUG] init: overriding provider plugin search paths") - log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) - } - - // We want to print out a nice warning if we don't manage to pull - // checksums for all our providers. This is tracked via callbacks - // and incomplete providers are stored here for later analysis. - var incompleteProviders []string - - // Because we're currently just streaming a series of events sequentially - // into the terminal, we're showing only a subset of the events to keep - // things relatively concise. Later it'd be nice to have a progress UI - // where statuses update in-place, but we can't do that as long as we - // are shimming our vt100 output to the legacy console API on Windows. - evts := &providercache.InstallerEvents{ - PendingProviders: func(reqs map[addrs.Provider]getproviders.VersionConstraints) { - view.Output(views.InitializingProviderPluginMessage) - }, - ProviderAlreadyInstalled: func(provider addrs.Provider, selectedVersion getproviders.Version) { - view.LogInitMessage(views.ProviderAlreadyInstalledMessage, provider.ForDisplay(), selectedVersion) - }, - BuiltInProviderAvailable: func(provider addrs.Provider) { - view.LogInitMessage(views.BuiltInProviderAvailableMessage, provider.ForDisplay()) - }, - BuiltInProviderFailure: func(provider addrs.Provider, err error) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid dependency on built-in provider", - fmt.Sprintf("Cannot use %s: %s.", provider.ForDisplay(), err), - )) - }, - QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) { - if locked { - view.LogInitMessage(views.ReusingPreviousVersionInfo, provider.ForDisplay()) - } else { - if len(versionConstraints) > 0 { - view.LogInitMessage(views.FindingMatchingVersionMessage, provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)) - } else { - view.LogInitMessage(views.FindingLatestVersionMessage, provider.ForDisplay()) - } - } - }, - LinkFromCacheBegin: func(provider addrs.Provider, version getproviders.Version, cacheRoot string) { - view.LogInitMessage(views.UsingProviderFromCacheDirInfo, provider.ForDisplay(), version) - }, - FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) { - view.LogInitMessage(views.InstallingProviderMessage, provider.ForDisplay(), version) - }, - QueryPackagesFailure: func(provider addrs.Provider, err error) { - switch errorTy := err.(type) { - case getproviders.ErrProviderNotFound: - sources := errorTy.Sources - displaySources := make([]string, len(sources)) - for i, source := range sources { - displaySources[i] = fmt.Sprintf(" - %s", source) - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", - provider.ForDisplay(), err, strings.Join(displaySources, "\n"), - ), - )) - case getproviders.ErrRegistryProviderNotKnown: - // We might be able to suggest an alternative provider to use - // instead of this one. - suggestion := fmt.Sprintf("\n\nAll modules should specify their required_providers so that external consumers will get the correct providers when using a module. To see which modules are currently depending on %s, run the following command:\n terraform providers", provider.ForDisplay()) - alternative := getproviders.MissingProviderSuggestion(ctx, provider, inst.ProviderSource(), reqs) - if alternative != provider { - suggestion = fmt.Sprintf( - "\n\nDid you intend to use %s? If so, you must specify that source address in each module which requires that provider. To see which modules are currently depending on %s, run the following command:\n terraform providers", - alternative.ForDisplay(), provider.ForDisplay(), - ) - } - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - case getproviders.ErrHostNoProviders: - switch { - case errorTy.Hostname == svchost.Hostname("github.com") && !errorTy.HasOtherVersion: - // If a user copies the URL of a GitHub repository into - // the source argument and removes the schema to make it - // provider-address-shaped then that's one way we can end up - // here. We'll use a specialized error message in anticipation - // of that mistake. We only do this if github.com isn't a - // provider registry, to allow for the (admittedly currently - // rather unlikely) possibility that github.com starts being - // a real Terraform provider registry in the future. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", - provider.String(), - ), - )) - - case errorTy.HasOtherVersion: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", - errorTy.Hostname, provider.String(), - ), - )) - - default: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid provider registry host", - fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry.", - errorTy.Hostname, provider.String(), - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - suggestion := fmt.Sprintf("\n\nTo see which modules are currently depending on %s and what versions are specified, run the following command:\n terraform providers", provider.ForDisplay()) - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to query available provider packages", - fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", - provider.ForDisplay(), err, suggestion, - ), - )) - } - - }, - QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { - displayWarnings := make([]string, len(warnings)) - for i, warning := range warnings { - displayWarnings[i] = fmt.Sprintf("- %s", warning) - } - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Additional provider information from registry", - fmt.Sprintf("The remote registry returned warnings for %s:\n%s", - provider.String(), - strings.Join(displayWarnings, "\n"), - ), - )) - }, - LinkFromCacheFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider from shared cache", - fmt.Sprintf("Error while importing %s v%s from the shared cache directory: %s.", provider.ForDisplay(), version, err), - )) - }, - FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - const summaryIncompatible = "Incompatible provider version" - switch err := err.(type) { - case getproviders.ErrProtocolNotSupported: - closestAvailable := err.Suggestion - switch { - case closestAvailable == getproviders.UnspecifiedVersion: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(errProviderVersionIncompatible, provider.String()), - )) - case version.GreaterThan(closestAvailable): - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - default: // version is less than closestAvailable - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(), - version, tfversion.String(), closestAvailable, closestAvailable, - getproviders.VersionConstraintsString(reqs[provider]), - ), - )) - } - case getproviders.ErrPlatformNotSupported: - switch { - case err.MirrorURL != nil: - // If we're installing from a mirror then it may just be - // the mirror lacking the package, rather than it being - // unavailable from upstream. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Your chosen provider mirror at %s does not have a %s v%s package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so this provider might not support your current platform. Alternatively, the mirror itself might have only a subset of the plugin packages available in the origin registry, at %s.", - err.MirrorURL, err.Provider, err.Version, err.Platform, - err.Provider.Hostname, - ), - )) - default: - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - summaryIncompatible, - fmt.Sprintf( - "Provider %s v%s does not have a package available for your current platform, %s.\n\nProvider releases are separate from Terraform CLI releases, so not all providers are available for all platforms. Other versions of this provider may have different platforms supported.", - err.Provider, err.Version, err.Platform, - ), - )) - } - - case getproviders.ErrRequestCanceled: - // We don't attribute cancellation to any particular operation, - // but rather just emit a single general message about it at - // the end, by checking ctx.Err(). - - default: - // We can potentially end up in here under cancellation too, - // in spite of our getproviders.ErrRequestCanceled case above, - // because not all of the outgoing requests we do under the - // "fetch package" banner are source metadata requests. - // In that case we will emit a redundant error here about - // the request being cancelled, but we'll still detect it - // as a cancellation after the installer returns and do the - // normal cancellation handling. - - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to install provider", - fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err), - )) - } - }, - FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { - var keyID string - if authResult != nil && authResult.ThirdPartySigned() { - keyID = authResult.KeyID - } - if keyID != "" { - keyID = view.PrepareMessage(views.KeyID, keyID) - } - - view.LogInitMessage(views.InstalledProviderVersionInfo, provider.ForDisplay(), version, authResult, keyID) - }, - ProvidersLockUpdated: func(provider addrs.Provider, version getproviders.Version, localHashes []getproviders.Hash, signedHashes []getproviders.Hash, priorHashes []getproviders.Hash) { - // We're going to use this opportunity to track if we have any - // "incomplete" installs of providers. An incomplete install is - // when we are only going to write the local hashes into our lock - // file which means a `terraform init` command will fail in future - // when used on machines of a different architecture. - // - // We want to print a warning about this. - - if len(signedHashes) > 0 { - // If we have any signedHashes hashes then we don't worry - as - // we know we retrieved all available hashes for this version - // anyway. - return - } - - // If local hashes and prior hashes are exactly the same then - // it means we didn't record any signed hashes previously, and - // we know we're not adding any extra in now (because we already - // checked the signedHashes), so that's a problem. - // - // In the actual check here, if we have any priorHashes and those - // hashes are not the same as the local hashes then we're going to - // accept that this provider has been configured correctly. - if len(priorHashes) > 0 && !reflect.DeepEqual(localHashes, priorHashes) { - return - } - - // Now, either signedHashes is empty, or priorHashes is exactly the - // same as our localHashes which means we never retrieved the - // signedHashes previously. - // - // Either way, this is bad. Let's complain/warn. - incompleteProviders = append(incompleteProviders, provider.ForDisplay()) - }, - ProvidersFetched: func(authResults map[addrs.Provider]*getproviders.PackageAuthenticationResult) { - thirdPartySigned := false - for _, authResult := range authResults { - if authResult.ThirdPartySigned() { - thirdPartySigned = true - break - } - } - if thirdPartySigned { - view.LogInitMessage(views.PartnerAndCommunityProvidersMessage) - } - }, - } - ctx = evts.OnContext(ctx) - - mode := providercache.InstallNewProvidersOnly - if upgrade { - if flagLockfile == "readonly" { - diags = diags.Append(fmt.Errorf("The -upgrade flag conflicts with -lockfile=readonly.")) - view.Diagnostics(diags) - return true, true, diags - } - - mode = providercache.InstallUpgrades - } - newLocks, err := inst.EnsureProviderVersions(ctx, previousLocks, reqs, mode) - if ctx.Err() == context.Canceled { - diags = diags.Append(fmt.Errorf("Provider installation was canceled by an interrupt signal.")) - view.Diagnostics(diags) - return true, true, diags - } - if err != nil { - // The errors captured in "err" should be redundant with what we - // received via the InstallerEvents callbacks above, so we'll - // just return those as long as we have some. - if !diags.HasErrors() { - diags = diags.Append(err) - } - - return true, true, diags - } - - // If the provider dependencies have changed since the last run then we'll - // say a little about that in case the reader wasn't expecting a change. - // (When we later integrate module dependencies into the lock file we'll - // probably want to refactor this so that we produce one lock-file related - // message for all changes together, but this is here for now just because - // it's the smallest change relative to what came before it, which was - // a hidden JSON file specifically for tracking providers.) - if !newLocks.Equal(previousLocks) { - // if readonly mode - if flagLockfile == "readonly" { - // check if required provider dependencies change - if !newLocks.EqualProviderAddress(previousLocks) { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - `Provider dependency changes detected`, - `Changes to the required provider dependencies were detected, but the lock file is read-only. To use and record these requirements, run "terraform init" without the "-lockfile=readonly" flag.`, - )) - return true, true, diags - } - - // suppress updating the file to record any new information it learned, - // such as a hash using a new scheme. - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - `Provider lock file not updated`, - `Changes to the provider selections were detected, but not saved in the .terraform.lock.hcl file. To record these selections, run "terraform init" without the "-lockfile=readonly" flag.`, - )) - return true, false, diags - } - - // Jump in here and add a warning if any of the providers are incomplete. - if len(incompleteProviders) > 0 { - // We don't really care about the order here, we just want the - // output to be deterministic. - sort.Slice(incompleteProviders, func(i, j int) bool { - return incompleteProviders[i] < incompleteProviders[j] - }) - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - incompleteLockFileInformationHeader, - fmt.Sprintf( - incompleteLockFileInformationBody, - strings.Join(incompleteProviders, "\n - "), - getproviders.CurrentPlatform.String()))) - } - - if previousLocks.Empty() { - // A change from empty to non-empty is special because it suggests - // we're running "terraform init" for the first time against a - // new configuration. In that case we'll take the opportunity to - // say a little about what the dependency lock file is, for new - // users or those who are upgrading from a previous Terraform - // version that didn't have dependency lock files. - view.Output(views.LockInfo) - } else { - view.Output(views.DependenciesLockChangesInfo) - } - - moreDiags = c.replaceLockedDependencies(newLocks) - diags = diags.Append(moreDiags) - } - - return true, false, diags + return diags } // getProvidersFromConfig determines what providers are required by the given configuration data. @@ -711,6 +371,8 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config return false, nil, diags } + reqs = c.removeDevOverrides(reqs) + for providerAddr := range reqs { if providerAddr.IsLegacy() { diags = diags.Append(tfdiags.Sourceless( @@ -746,7 +408,7 @@ func (c *InitCommand) getProvidersFromConfig(ctx context.Context, config *config log.Printf("[DEBUG] will search for provider plugins in %s", pluginDirs) } - evts := c.prepareInstallerEvents(ctx, reqs, diags, inst, view, views.InitializingProviderPluginFromConfigMessage, views.ReusingPreviousVersionInfo) + evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromConfigMessage, views.ReusingPreviousVersionInfo) ctx = evts.OnContext(ctx) mode := providercache.InstallNewProvidersOnly @@ -863,7 +525,7 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // things relatively concise. Later it'd be nice to have a progress UI // where statuses update in-place, but we can't do that as long as we // are shimming our vt100 output to the legacy console API on Windows. - evts := c.prepareInstallerEvents(ctx, reqs, diags, inst, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) + evts := c.prepareInstallerEvents(ctx, reqs, &diags, inst, view, views.InitializingProviderPluginFromStateMessage, views.ReusingVersionIdentifiedFromConfig) ctx = evts.OnContext(ctx) mode := providercache.InstallNewProvidersOnly @@ -898,7 +560,6 @@ func (c *InitCommand) getProvidersFromState(ctx context.Context, state *states.S // The calling code is expected to provide the previous locks (if any) and the two sets of locks determined from // configuration and state data. func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLocks *depsfile.Locks, flagLockfile string, view views.Init) (output bool, diags tfdiags.Diagnostics) { - // Get the combination of config and state locks newLocks := c.mergeLockedDependencies(configLocks, stateLocks) @@ -967,8 +628,7 @@ func (c *InitCommand) saveDependencyLockFile(previousLocks, configLocks, stateLo // prepareInstallerEvents returns an instance of *providercache.InstallerEvents. This struct defines callback functions that will be executed // when a specific type of event occurs during provider installation. // The calling code needs to provide a tfdiags.Diagnostics collection, so that provider installation code returns diags to the calling code using closures -func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags tfdiags.Diagnostics, inst *providercache.Installer, view views.Init, initMsg views.InitMessageCode, reuseMsg views.InitMessageCode) *providercache.InstallerEvents { - +func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerreqs.Requirements, diags *tfdiags.Diagnostics, inst *providercache.Installer, view views.Init, initMsg views.InitMessageCode, reuseMsg views.InitMessageCode) *providercache.InstallerEvents { // Because we're currently just streaming a series of events sequentially // into the terminal, we're showing only a subset of the events to keep // things relatively concise. Later it'd be nice to have a progress UI @@ -985,7 +645,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr view.LogInitMessage(views.BuiltInProviderAvailableMessage, provider.ForDisplay()) }, BuiltInProviderFailure: func(provider addrs.Provider, err error) { - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid dependency on built-in provider", fmt.Sprintf("Cannot use %s: %s.", provider.ForDisplay(), err), @@ -1016,7 +676,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr for i, source := range sources { displaySources[i] = fmt.Sprintf(" - %s", source) } - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to query available provider packages", fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s\n\n%s", @@ -1035,7 +695,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ) } - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to query available provider packages", fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", @@ -1053,7 +713,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr // provider registry, to allow for the (admittedly currently // rather unlikely) possibility that github.com starts being // a real Terraform provider registry in the future. - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid provider registry host", fmt.Sprintf("The given source address %q specifies a GitHub repository rather than a Terraform provider. Refer to the documentation of the provider to find the correct source address to use.", @@ -1062,7 +722,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr )) case errorTy.HasOtherVersion: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid provider registry host", fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry that is compatible with this Terraform version, but it may be compatible with a different Terraform version.", @@ -1071,7 +731,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr )) default: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Invalid provider registry host", fmt.Sprintf("The host %q given in provider source address %q does not offer a Terraform provider registry.", @@ -1087,7 +747,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr default: suggestion := fmt.Sprintf("\n\nTo see which modules are currently depending on %s and what versions are specified, run the following command:\n terraform providers", provider.ForDisplay()) - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to query available provider packages", fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s%s", @@ -1095,7 +755,6 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) } - }, QueryPackagesWarning: func(provider addrs.Provider, warnings []string) { displayWarnings := make([]string, len(warnings)) @@ -1103,7 +762,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr displayWarnings[i] = fmt.Sprintf("- %s", warning) } - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Additional provider information from registry", fmt.Sprintf("The remote registry returned warnings for %s:\n%s", @@ -1113,7 +772,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr )) }, LinkFromCacheFailure: func(provider addrs.Provider, version getproviders.Version, err error) { - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to install provider from shared cache", fmt.Sprintf("Error while importing %s v%s from the shared cache directory: %s.", provider.ForDisplay(), version, err), @@ -1126,13 +785,13 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr closestAvailable := err.Suggestion switch { case closestAvailable == getproviders.UnspecifiedVersion: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf(errProviderVersionIncompatible, provider.String()), )) case version.GreaterThan(closestAvailable): - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(), @@ -1141,7 +800,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) default: // version is less than closestAvailable - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(), @@ -1156,7 +815,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr // If we're installing from a mirror then it may just be // the mirror lacking the package, rather than it being // unavailable from upstream. - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf( @@ -1166,7 +825,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr ), )) default: - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, summaryIncompatible, fmt.Sprintf( @@ -1191,7 +850,7 @@ func (c *InitCommand) prepareInstallerEvents(ctx context.Context, reqs providerr // as a cancellation after the installer returns and do the // normal cancellation handling. - diags = diags.Append(tfdiags.Sourceless( + *diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to install provider", fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err), @@ -1476,16 +1135,18 @@ Options: -test-directory=path Set the Terraform test directory, defaults to "tests". + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + -enable-pluggable-state-storage-experiment [EXPERIMENTAL] A flag to enable an alternative init command that allows use of pluggable state storage. Only usable with experiments enabled. - - -create-default-workspace [EXPERIMENTAL] - This flag must be used alongside the -enable-pluggable-state-storage- - experiment flag with experiments enabled. This flag's value defaults - to true, which allows the default workspace to be created if it does - not exist. Use -create-default-workspace=false to disable this behavior. - ` return strings.TrimSpace(helpText) } diff --git a/internal/command/init2_test.go b/internal/command/init2_test.go new file mode 100644 index 0000000000..5efc70ce3b --- /dev/null +++ b/internal/command/init2_test.go @@ -0,0 +1,673 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/cli" +) + +func TestInit2_dynamicSourceErrors(t *testing.T) { + tests := map[string]struct { + fixture string + args []string + wantError string + }{ + "version constraint added to previously unversioned module": { + fixture: "add-version-constraint", + args: []string{"-get=false"}, + wantError: "Module version requirements have changed", + }, + "invalid registry source with version argument": { + fixture: "invalid-registry-source-with-module", + wantError: "Invalid registry module source address", + }, + "local source with version argument": { + fixture: "local-source-with-version", + wantError: "Invalid registry module source address", + }, + "non-const variable in module source": { + fixture: "local-source-with-non-const-variable", + args: []string{"-var", "module_name=example"}, + wantError: "Invalid module source", + }, + "resource reference in module source": { + fixture: "source-with-resource-reference", + wantError: "Invalid module source", + }, + "module output reference in module source": { + fixture: "source-with-module-output-reference", + wantError: "Invalid module source", + }, + "each.key in module source": { + fixture: "each-in-module-source", + wantError: "Invalid module source", + }, + "count.index in module source": { + fixture: "count-in-module-source", + wantError: "Invalid module source", + }, + "terraform.workspace in module source": { + fixture: "terraform-attr-in-module-source", + wantError: "Invalid module source", + }, + "required const variable not set": { + fixture: "local-source-with-variable", + wantError: "No value for required variable", + }, + "override default with nonexistent module": { + fixture: "local-source-with-variable-default", + args: []string{"-var", "module_name=nonexistent"}, + wantError: "", // any error; the module directory doesn't exist + }, + "version mismatch with dynamic constraint": { + fixture: "plan-with-version-mismatch", + args: []string{"-get=false", "-var", "module_version=0.0.2"}, + wantError: "Module version requirements have changed", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", tc.fixture)), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + code := c.Run(tc.args) + testOutput := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + if tc.wantError != "" { + got := testOutput.All() + if !strings.Contains(got, tc.wantError) { + t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, tc.wantError) + } + } + }) + } +} + +func TestInit2_dynamicSourceSuccess(t *testing.T) { + tests := map[string]struct { + fixture string + args []string + }{ + "const variable via -var": { + fixture: "local-source-with-variable", + args: []string{"-var", "module_name=example"}, + }, + "const variable with default value": { + fixture: "local-source-with-variable-default", + }, + "local value referencing const variable": { + fixture: "local-source-with-local-value", + args: []string{"-var", "module_name=example"}, + }, + "nested module with variable passed through parent": { + fixture: "nested-module-with-variable-source", + args: []string{"-var", "child_name=child"}, + }, + "const variable from tfvars file": { + fixture: "local-source-with-varsfile", + args: []string{"-var-file", "test.tfvars"}, + }, + "path.module in module source": { + fixture: "path-attr-in-module-source", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", tc.fixture)), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + code := c.Run(tc.args) + testOutput := done(t) + if code != 0 { + t.Fatalf("got exit status %d; want 0\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + }) + } +} + +func TestInit2_getFalseWithDynamicSource(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "get-false-with-dynamic-source")), td) + t.Chdir(td) + + // First, run init normally to install the module + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + args := []string{"-var", "module_name=example"} + code := c.Run(args) + testOutput := done(t) + if code != 0 { + t.Fatalf("first init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + // Now run init with -get=false; should succeed since modules are already installed + ui2 := new(cli.MockUi) + view2, done2 := testView(t) + c2 := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui2, + View: view2, + }, + } + + args2 := []string{"-get=false", "-var", "module_name=example"} + code = c2.Run(args2) + testOutput2 := done2(t) + if code != 0 { + t.Fatalf("init -get=false failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput2.Stderr(), testOutput2.Stdout()) + } +} + +func TestInit2_getFalseWithDynamicSourceNotInstalled(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "get-false-with-dynamic-source")), td) + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + // Run init with -get=false without having installed modules first + args := []string{"-get=false", "-var", "module_name=example"} + code := c.Run(args) + testOutput := done(t) + if code != 1 { + t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } +} + +func TestInit2_reinitWithDifferentVariable(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "local-source-with-variable-default")), td) + t.Chdir(td) + + // First init with default variable (example) + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + code := c.Run([]string{}) + testOutput := done(t) + if code != 0 { + t.Fatalf("first init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + // Re-init with different variable + ui2 := new(cli.MockUi) + view2, done2 := testView(t) + c2 := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui2, + View: view2, + }, + } + + code = c2.Run([]string{"-var", "module_name=alternate"}) + testOutput2 := done2(t) + if code != 0 { + t.Fatalf("second init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput2.Stderr(), testOutput2.Stdout()) + } +} + +func TestInit2_fromModuleWithDynamicSource(t *testing.T) { + // TODO: -from-module currently panics when the copied configuration + // contains a dynamic module source (e.g. "./modules/${var.module_name}"). + t.Skip("skipping: -from-module panics on dynamic module sources (see TODO in from_module.go)") + + // Create an empty target directory for -from-module to copy into + td := t.TempDir() + t.Chdir(td) + + ui := new(cli.MockUi) + view, done := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + View: view, + }, + } + + // Use -from-module to copy the source module (which has a dynamic source) + // into the empty working directory. This should copy the files but the + // nested dynamic module won't be resolved by -from-module itself. + srcDir := testFixturePath(filepath.Join("dynamic-module-sources", "from-module-with-dynamic-source", "source-module")) + args := []string{"-from-module=" + srcDir} + code := c.Run(args) + testOutput := done(t) + + // -from-module should succeed in copying. The dynamic module source + // within the copied configuration won't be resolved yet — that requires + // a separate init with the variable value. + if code != 0 { + t.Fatalf("init -from-module failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, testOutput.Stderr(), testOutput.Stdout()) + } + + // Verify the main.tf was copied + if _, err := os.Stat(filepath.Join(td, "main.tf")); os.IsNotExist(err) { + t.Fatal("main.tf was not copied from the source module") + } +} + +func TestPlan_dynamicModuleSource(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "plan-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planCode := planCmd.Run(args) + planOutput := planDone(t) + if planCode != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", planCode, planOutput.Stderr(), planOutput.Stdout()) + } + + output := planOutput.Stdout() + if !strings.Contains(output, "1 to add") { + t.Fatalf("expected plan to show 1 resource to add, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleSourceMismatch(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "plan-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan with a different variable value + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planArgs := []string{"-var", "module_name=nonexistent"} + code := planCmd.Run(planArgs) + planOutput := planDone(t) + if code == 0 { + t.Fatalf("expected plan to fail, but got exit status 0\nstdout:\n%s", planOutput.Stdout()) + } +} + +func TestApply_dynamicModuleSource(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "apply-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + applyUi := new(cli.MockUi) + applyView, applyDone := testView(t) + applyCmd := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: applyUi, + View: applyView, + }, + } + + applyArgs := []string{"-auto-approve", "-var", "module_name=example"} + code := applyCmd.Run(applyArgs) + applyOutput := applyDone(t) + if code != 0 { + t.Fatalf("apply failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, applyOutput.Stderr(), applyOutput.Stdout()) + } + + output := applyOutput.Stdout() + if !strings.Contains(output, "Apply complete!") { + t.Fatalf("expected apply to succeed, got:\n%s", output) + } +} + +func TestApply_dynamicModuleSourceWithDefaultPlanFile(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "apply-plan-with-dynamic-source")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run([]string{}) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Run plan with -out + planPath := filepath.Join(td, "saved.plan") + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planArgs := []string{"-out", planPath} + code := planCmd.Run(planArgs) + planOutput := planDone(t) + if code != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, planOutput.Stderr(), planOutput.Stdout()) + } + + // Verify the plan file was created + if _, err := os.Stat(planPath); os.IsNotExist(err) { + t.Fatalf("plan file was not created at %s", planPath) + } + + // Apply the saved plan + applyUi := new(cli.MockUi) + applyView, applyDone := testView(t) + applyCmd := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: applyUi, + View: applyView, + }, + } + + applyArgs := []string{planPath} + code = applyCmd.Run(applyArgs) + applyOutput := applyDone(t) + if code != 0 { + t.Fatalf("apply failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", code, applyOutput.Stderr(), applyOutput.Stdout()) + } + + output := applyOutput.Stdout() + if !strings.Contains(output, "Apply complete!") { + t.Fatalf("expected apply to succeed, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleSourceWithCount(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "module-with-count")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planCode := planCmd.Run(args) + planOutput := planDone(t) + if planCode != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", planCode, planOutput.Stderr(), planOutput.Stdout()) + } + + output := planOutput.Stdout() + if !strings.Contains(output, "2 to add") { + t.Fatalf("expected plan to show 2 resources to add, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleSourceWithForEach(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "module-with-for-each")), td) + t.Chdir(td) + + p := planFixtureProvider() + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + args := []string{"-var", "module_name=example"} + + initUi := new(cli.MockUi) + initView, initDone := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: initUi, + View: initView, + ProviderSource: providerSource, + }, + } + + initCode := initCmd.Run(args) + initOutput := initDone(t) + if initCode != 0 { + t.Fatalf("init failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", initCode, initOutput.Stderr(), initOutput.Stdout()) + } + + // Now run plan + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planCode := planCmd.Run(args) + planOutput := planDone(t) + if planCode != 0 { + t.Fatalf("plan failed with exit status %d\nstderr:\n%s\n\nstdout:\n%s", planCode, planOutput.Stderr(), planOutput.Stdout()) + } + + output := planOutput.Stdout() + if !strings.Contains(output, "2 to add") { + t.Fatalf("expected plan to show 2 resources to add, got:\n%s", output) + } +} + +func TestPlan_dynamicModuleVersionMismatch(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(filepath.Join("dynamic-module-sources", "plan-with-version-mismatch")), td) + t.Chdir(td) + + p := planFixtureProvider() + + // Plan should fail because the installed module version (0.0.1 in + // modules.json) doesn't satisfy the constraint we provide. + planUi := new(cli.MockUi) + planView, planDone := testView(t) + planCmd := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + Ui: planUi, + View: planView, + }, + } + + planArgs := []string{"-var", "module_version=0.0.2"} + code := planCmd.Run(planArgs) + planOutput := planDone(t) + if code == 0 { + t.Fatalf("expected plan to fail, but got exit status 0\nstdout:\n%s", planOutput.Stdout()) + } + got := planOutput.All() + + want := "Module version requirements have changed" + if !strings.Contains(got, want) { + t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) + } +} diff --git a/internal/command/init_run.go b/internal/command/init_run.go index 880b4b6027..d63013f94e 100644 --- a/internal/command/init_run.go +++ b/internal/command/init_run.go @@ -163,18 +163,78 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { return 1 } + if initArgs.Get { + modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) + diags = diags.Append(modsDiags) + if modsAbort || modsDiags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + if modsOutput { + header = true + } + } + + // With all of the modules (hopefully) installed, we can now try to load the + // whole configuration tree. + config, confDiags := c.loadConfigWithTests(path, initArgs.TestsDirectory) + // configDiags will be handled after: + // - the version constraint check has happened + // - and, the backend/state_store is initialised + + // Before we go further, we'll check to make sure none of the modules in + // the configuration declare that they don't support this Terraform + // version, so we can produce a version-related error message rather than + // potentially-confusing downstream errors. + versionDiags := terraform.CheckCoreVersionRequirements(config) + if versionDiags.HasErrors() { + view.Diagnostics(versionDiags) + return 1 + } + + earlyBdiags := c.earlyValidateBackend(rootModEarly, initArgs) + diags = diags.Append(earlyBdiags) + + // We've passed the core version check, now we can show errors from the early configuration. + // This prevents trying to initialise the backend with faulty configuration. + if earlyConfDiags.HasErrors() || earlyBdiags.HasErrors() { + diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError)), earlyConfDiags) + view.Diagnostics(diags) + return 1 + } + + // Now the full configuration is loaded, we can download the providers specified in the configuration. + // This is step one of a two-step provider download process + // Providers may be downloaded by this code, but the dependency lock file is only updated later in `init` + // after step two of provider download is complete. + previousLocks, moreDiags := c.lockedDependencies() + diags = diags.Append(moreDiags) + + configProvidersOutput, configLocks, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + diags = diags.Append(configProviderDiags) + if configProviderDiags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + if configProvidersOutput { + header = true + } + + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + if header { + view.Output(views.EmptyMessage) + } + var back backend.Backend - // There may be config errors or backend init errors but these will be shown later _after_ - // checking for core version requirement errors. var backDiags tfdiags.Diagnostics var backendOutput bool - switch { case initArgs.Cloud && rootModEarly.CloudConfig != nil: back, backendOutput, backDiags = c.initCloud(ctx, rootModEarly, initArgs.BackendConfig, initArgs.ViewType, view) case initArgs.Backend: - back, backendOutput, backDiags = c.initBackend(ctx, rootModEarly, initArgs.BackendConfig, initArgs.ViewType, view) + back, backendOutput, backDiags = c.initBackend(ctx, rootModEarly, initArgs, configLocks, view) default: // load the previously-stored backend config back, backDiags = c.Meta.backendFromState(ctx) @@ -182,6 +242,29 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { if backendOutput { header = true } + if header { + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + view.Output(views.EmptyMessage) + } + + // Show any errors from initializing the backend. + // No preamble using `InitConfigError` is present, as we expect + // any errors to from configuring the backend itself. + diags = diags.Append(backDiags) + if backDiags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // If everything is ok with the core version check and backend/state_store initialization, + // show other errors from loading the full configuration tree. + diags = diags.Append(confDiags) + if confDiags.HasErrors() { + diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) + view.Diagnostics(diags) + return 1 + } var state *states.State @@ -212,63 +295,37 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { state = sMgr.State() } - if initArgs.Get { - modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) - diags = diags.Append(modsDiags) - if modsAbort || modsDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if modsOutput { - header = true - } - } - - // With all of the modules (hopefully) installed, we can now try to load the - // whole configuration tree. - config, confDiags := c.loadConfigWithTests(path, initArgs.TestsDirectory) - // configDiags will be handled after the version constraint check, since an - // incorrect version of terraform may be producing errors for configuration - // constructs added in later versions. - - // Before we go further, we'll check to make sure none of the modules in - // the configuration declare that they don't support this Terraform - // version, so we can produce a version-related error message rather than - // potentially-confusing downstream errors. - versionDiags := terraform.CheckCoreVersionRequirements(config) - if versionDiags.HasErrors() { - view.Diagnostics(versionDiags) - return 1 - } - - // We've passed the core version check, now we can show errors from the - // configuration and backend initialisation. - - // Now, we can check the diagnostics from the early configuration and the - // backend. - diags = diags.Append(earlyConfDiags) - diags = diags.Append(backDiags) - if earlyConfDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) + // Now the resource state is loaded, we can download the providers specified in the state but not the configuration. + // This is step two of a two-step provider download process + stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) + diags = diags.Append(stateProvidersDiags) + if stateProvidersDiags.HasErrors() { view.Diagnostics(diags) return 1 } + if stateProvidersOutput { + header = true + } + if header { + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + view.Output(views.EmptyMessage) + } - // Now, we can show any errors from initializing the backend, but we won't - // show the InitConfigError preamble as we didn't detect problems with - // the early configuration. - if backDiags.HasErrors() { + // Now the two steps of provider download have happened, update the dependency lock file if it has changed. + lockFileOutput, lockFileDiags := c.saveDependencyLockFile(previousLocks, configLocks, stateLocks, initArgs.Lockfile, view) + diags = diags.Append(lockFileDiags) + if lockFileDiags.HasErrors() { view.Diagnostics(diags) return 1 } - - // If everything is ok with the core version check and backend initialization, - // show other errors from loading the full configuration tree. - diags = diags.Append(confDiags) - if confDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) - view.Diagnostics(diags) - return 1 + if lockFileOutput { + header = true + } + if header { + // If we outputted information, then we need to output a newline + // so that our success message is nicely spaced out from prior text. + view.Output(views.EmptyMessage) } if cb, ok := back.(*cloud.Cloud); ok { @@ -281,23 +338,6 @@ func (c *InitCommand) run(initArgs *arguments.Init, view views.Init) int { } } - // Now that we have loaded all modules, check the module tree for missing providers. - providersOutput, providersAbort, providerDiags := c.getProviders(ctx, config, state, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) - diags = diags.Append(providerDiags) - if providersAbort || providerDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if providersOutput { - header = true - } - - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - if header { - view.Output(views.EmptyMessage) - } - // If we accumulated any warnings along the way that weren't accompanied // by errors then we'll output them here so that the success message is // still the final thing shown. diff --git a/internal/command/init_run_experiment.go b/internal/command/init_run_experiment.go deleted file mode 100644 index da6b2e6419..0000000000 --- a/internal/command/init_run_experiment.go +++ /dev/null @@ -1,527 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package command - -import ( - "context" - "errors" - "fmt" - "maps" - "slices" - "strings" - - "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/terraform/internal/backend" - backendInit "github.com/hashicorp/terraform/internal/backend/init" - "github.com/hashicorp/terraform/internal/cloud" - "github.com/hashicorp/terraform/internal/command/arguments" - "github.com/hashicorp/terraform/internal/command/views" - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/depsfile" - "github.com/hashicorp/terraform/internal/didyoumean" - "github.com/hashicorp/terraform/internal/states" - "github.com/hashicorp/terraform/internal/terraform" - "github.com/hashicorp/terraform/internal/tfdiags" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" -) - -// `runPssInit` is an altered version of the logic in `run` that contains changes -// related to the PSS project. This is used by the (InitCommand.Run method only if Terraform has -// experimental features enabled. -func (c *InitCommand) runPssInit(initArgs *arguments.Init, view views.Init) int { - var diags tfdiags.Diagnostics - - c.forceInitCopy = initArgs.ForceInitCopy - c.Meta.stateLock = initArgs.StateLock - c.Meta.stateLockTimeout = initArgs.StateLockTimeout - c.reconfigure = initArgs.Reconfigure - c.migrateState = initArgs.MigrateState - c.Meta.ignoreRemoteVersion = initArgs.IgnoreRemoteVersion - c.Meta.input = initArgs.InputEnabled - c.Meta.targetFlags = initArgs.TargetFlags - c.Meta.compactWarnings = initArgs.CompactWarnings - - // Copying the state only happens during backend migration, so setting - // -force-copy implies -migrate-state - if c.forceInitCopy { - c.migrateState = true - } - - if len(initArgs.PluginPath) > 0 { - c.pluginPath = initArgs.PluginPath - } - - // Validate the arg count and get the working directory - path, err := ModulePath(initArgs.Args) - if err != nil { - diags = diags.Append(err) - view.Diagnostics(diags) - return 1 - } - - if err := c.storePluginPath(c.pluginPath); err != nil { - diags = diags.Append(fmt.Errorf("Error saving -plugin-dir to workspace directory: %s", err)) - view.Diagnostics(diags) - return 1 - } - - // Initialization can be aborted by interruption signals - ctx, done := c.InterruptibleContext(c.CommandContext()) - defer done() - - // This will track whether we outputted anything so that we know whether - // to output a newline before the success message - var header bool - - if initArgs.FromModule != "" { - src := initArgs.FromModule - - empty, err := configs.IsEmptyDir(path, initArgs.TestsDirectory) - if err != nil { - diags = diags.Append(fmt.Errorf("Error validating destination directory: %s", err)) - view.Diagnostics(diags) - return 1 - } - if !empty { - diags = diags.Append(errors.New(strings.TrimSpace(errInitCopyNotEmpty))) - view.Diagnostics(diags) - return 1 - } - - view.Output(views.CopyingConfigurationMessage, src) - header = true - - hooks := uiModuleInstallHooks{ - Ui: c.Ui, - ShowLocalPaths: false, // since they are in a weird location for init - View: view, - } - - ctx, span := tracer.Start(ctx, "-from-module=...", trace.WithAttributes( - attribute.String("module_source", src), - )) - - initDirFromModuleAbort, initDirFromModuleDiags := c.initDirFromModule(ctx, path, src, hooks) - diags = diags.Append(initDirFromModuleDiags) - if initDirFromModuleAbort || initDirFromModuleDiags.HasErrors() { - view.Diagnostics(diags) - span.SetStatus(codes.Error, "module installation failed") - span.End() - return 1 - } - span.End() - - view.Output(views.EmptyMessage) - } - - // If our directory is empty, then we're done. We can't get or set up - // the backend with an empty directory. - empty, err := configs.IsEmptyDir(path, initArgs.TestsDirectory) - if err != nil { - diags = diags.Append(fmt.Errorf("Error checking configuration: %s", err)) - view.Diagnostics(diags) - return 1 - } - if empty { - view.Output(views.OutputInitEmptyMessage) - return 0 - } - - // Load just the root module to begin backend and module initialization - rootModEarly, earlyConfDiags := c.loadSingleModuleWithTests(path, initArgs.TestsDirectory) - - // There may be parsing errors in config loading but these will be shown later _after_ - // checking for core version requirement errors. Not meeting the version requirement should - // be the first error displayed if that is an issue, but other operations are required - // before being able to check core version requirements. - if rootModEarly == nil { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError)), earlyConfDiags) - view.Diagnostics(diags) - - return 1 - } - - if initArgs.Get { - modsOutput, modsAbort, modsDiags := c.getModules(ctx, path, initArgs.TestsDirectory, rootModEarly, initArgs.Upgrade, view) - diags = diags.Append(modsDiags) - if modsAbort || modsDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if modsOutput { - header = true - } - } - - // With all of the modules (hopefully) installed, we can now try to load the - // whole configuration tree. - config, confDiags := c.loadConfigWithTests(path, initArgs.TestsDirectory) - // configDiags will be handled after: - // - the version constraint check has happened - // - and, the backend/state_store is initialised - - // Before we go further, we'll check to make sure none of the modules in - // the configuration declare that they don't support this Terraform - // version, so we can produce a version-related error message rather than - // potentially-confusing downstream errors. - versionDiags := terraform.CheckCoreVersionRequirements(config) - if versionDiags.HasErrors() { - view.Diagnostics(versionDiags) - return 1 - } - - // We've passed the core version check, now we can show errors from the early configuration. - // This prevents trying to initialise the backend with faulty configuration. - if earlyConfDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError)), earlyConfDiags) - view.Diagnostics(diags) - return 1 - } - - // Now the full configuration is loaded, we can download the providers specified in the configuration. - // This is step one of a two-step provider download process - // Providers may be downloaded by this code, but the dependency lock file is only updated later in `init` - // after step two of provider download is complete. - previousLocks, moreDiags := c.lockedDependencies() - diags = diags.Append(moreDiags) - - configProvidersOutput, configLocks, configProviderDiags := c.getProvidersFromConfig(ctx, config, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) - diags = diags.Append(configProviderDiags) - if configProviderDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if configProvidersOutput { - header = true - } - - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - if header { - view.Output(views.EmptyMessage) - } - - var back backend.Backend - - var backDiags tfdiags.Diagnostics - var backendOutput bool - switch { - case initArgs.Cloud && rootModEarly.CloudConfig != nil: - back, backendOutput, backDiags = c.initCloud(ctx, rootModEarly, initArgs.BackendConfig, initArgs.ViewType, view) - case initArgs.Backend: - back, backendOutput, backDiags = c.initPssBackend(ctx, rootModEarly, initArgs, configLocks, view) - default: - // load the previously-stored backend config - back, backDiags = c.Meta.backendFromState(ctx) - } - if backendOutput { - header = true - } - if header { - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - view.Output(views.EmptyMessage) - } - - // Show any errors from initializing the backend. - // No preamble using `InitConfigError` is present, as we expect - // any errors to from configuring the backend itself. - diags = diags.Append(backDiags) - if backDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - - // If everything is ok with the core version check and backend/state_store initialization, - // show other errors from loading the full configuration tree. - diags = diags.Append(confDiags) - if confDiags.HasErrors() { - diags = diags.Append(errors.New(view.PrepareMessage(views.InitConfigError))) - view.Diagnostics(diags) - return 1 - } - - var state *states.State - - // If we have a functional backend (either just initialized or initialized - // on a previous run) we'll use the current state as a potential source - // of provider dependencies. - if back != nil { - c.ignoreRemoteVersionConflict(back) - workspace, err := c.Workspace() - if err != nil { - diags = diags.Append(fmt.Errorf("Error selecting workspace: %s", err)) - view.Diagnostics(diags) - return 1 - } - sMgr, sDiags := back.StateMgr(workspace) - if sDiags.HasErrors() { - diags = diags.Append(fmt.Errorf("Error loading state: %s", sDiags.Err())) - view.Diagnostics(diags) - return 1 - } - - if err := sMgr.RefreshState(); err != nil { - diags = diags.Append(fmt.Errorf("Error refreshing state: %s", err)) - view.Diagnostics(diags) - return 1 - } - - state = sMgr.State() - } - - // Now the resource state is loaded, we can download the providers specified in the state but not the configuration. - // This is step two of a two-step provider download process - stateProvidersOutput, stateLocks, stateProvidersDiags := c.getProvidersFromState(ctx, state, configLocks, initArgs.Upgrade, initArgs.PluginPath, initArgs.Lockfile, view) - diags = diags.Append(configProviderDiags) - if stateProvidersDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if stateProvidersOutput { - header = true - } - if header { - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - view.Output(views.EmptyMessage) - } - - // Now the two steps of provider download have happened, update the dependency lock file if it has changed. - lockFileOutput, lockFileDiags := c.saveDependencyLockFile(previousLocks, configLocks, stateLocks, initArgs.Lockfile, view) - diags = diags.Append(lockFileDiags) - if lockFileDiags.HasErrors() { - view.Diagnostics(diags) - return 1 - } - if lockFileOutput { - header = true - } - if header { - // If we outputted information, then we need to output a newline - // so that our success message is nicely spaced out from prior text. - view.Output(views.EmptyMessage) - } - - if cb, ok := back.(*cloud.Cloud); ok { - if c.RunningInAutomation { - if err := cb.AssertImportCompatible(config); err != nil { - diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "Compatibility error", err.Error())) - view.Diagnostics(diags) - return 1 - } - } - } - - // If we accumulated any warnings along the way that weren't accompanied - // by errors then we'll output them here so that the success message is - // still the final thing shown. - view.Diagnostics(diags) - _, cloud := back.(*cloud.Cloud) - output := views.OutputInitSuccessMessage - if cloud { - output = views.OutputInitSuccessCloudMessage - } - - view.Output(output) - - if !c.RunningInAutomation { - // If we're not running in an automation wrapper, give the user - // some more detailed next steps that are appropriate for interactive - // shell usage. - output = views.OutputInitSuccessCLIMessage - if cloud { - output = views.OutputInitSuccessCLICloudMessage - } - view.Output(output) - } - return 0 -} - -func (c *InitCommand) initPssBackend(ctx context.Context, root *configs.Module, initArgs *arguments.Init, configLocks *depsfile.Locks, view views.Init) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "initialize backend") - _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here - defer span.End() - - if root.StateStore != nil { - view.Output(views.InitializingStateStoreMessage) - } else { - view.Output(views.InitializingBackendMessage) - } - - var opts *BackendOpts - switch { - case root.StateStore != nil && root.Backend != nil: - // We expect validation during config parsing to prevent mutually exclusive backend and state_store blocks, - // but checking here just in case. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Conflicting backend and state_store configurations present during init", - Detail: fmt.Sprintf("When initializing the backend there was configuration data present for both backend %q and state store %q. This is a bug in Terraform and should be reported.", - root.Backend.Type, - root.StateStore.Type, - ), - Subject: &root.Backend.TypeRange, - }) - return nil, true, diags - case root.StateStore != nil: - // state_store config present - factory, fDiags := c.Meta.StateStoreProviderFactoryFromConfig(root.StateStore, configLocks) - diags = diags.Append(fDiags) - if fDiags.HasErrors() { - return nil, true, diags - } - - // If overrides supplied by -backend-config CLI flag, process them - var configOverride hcl.Body - if !initArgs.BackendConfig.Empty() { - // We need to launch an instance of the provider to get the config of the state store for processing any overrides. - provider, err := factory() - defer provider.Close() // Stop the child process once we're done with it here. - if err != nil { - diags = diags.Append(fmt.Errorf("error when obtaining provider instance during state store initialization: %w", err)) - return nil, true, diags - } - - resp := provider.GetProviderSchema() - - if len(resp.StateStores) == 0 { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Provider does not support pluggable state storage", - Detail: fmt.Sprintf("There are no state stores implemented by provider %s (%q)", - root.StateStore.Provider.Name, - root.StateStore.ProviderAddr), - Subject: &root.StateStore.DeclRange, - }) - return nil, true, diags - } - - stateStoreSchema, exists := resp.StateStores[root.StateStore.Type] - if !exists { - suggestions := slices.Sorted(maps.Keys(resp.StateStores)) - suggestion := didyoumean.NameSuggestion(root.StateStore.Type, suggestions) - if suggestion != "" { - suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) - } - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "State store not implemented by the provider", - Detail: fmt.Sprintf("State store %q is not implemented by provider %s (%q)%s", - root.StateStore.Type, root.StateStore.Provider.Name, - root.StateStore.ProviderAddr, suggestion), - Subject: &root.StateStore.DeclRange, - }) - return nil, true, diags - } - - // Handle any overrides supplied via -backend-config CLI flags - var overrideDiags tfdiags.Diagnostics - configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, stateStoreSchema.Body) - diags = diags.Append(overrideDiags) - if overrideDiags.HasErrors() { - return nil, true, diags - } - } - - opts = &BackendOpts{ - StateStoreConfig: root.StateStore, - Locks: configLocks, - CreateDefaultWorkspace: initArgs.CreateDefaultWorkspace, - ConfigOverride: configOverride, - Init: true, - ViewType: initArgs.ViewType, - } - - case root.Backend != nil: - // backend config present - backendType := root.Backend.Type - if backendType == "cloud" { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Unsupported backend type", - Detail: fmt.Sprintf("There is no explicit backend type named %q. To configure HCP Terraform, declare a 'cloud' block instead.", backendType), - Subject: &root.Backend.TypeRange, - }) - return nil, true, diags - } - - bf := backendInit.Backend(backendType) - if bf == nil { - detail := fmt.Sprintf("There is no backend type named %q.", backendType) - if msg, removed := backendInit.RemovedBackends[backendType]; removed { - detail = msg - } - - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Unsupported backend type", - Detail: detail, - Subject: &root.Backend.TypeRange, - }) - return nil, true, diags - } - - b := bf() - backendSchema := b.ConfigSchema() - backendConfig := root.Backend - - // If overrides supplied by -backend-config CLI flag, process them - var configOverride hcl.Body - if !initArgs.BackendConfig.Empty() { - var overrideDiags tfdiags.Diagnostics - configOverride, overrideDiags = c.backendConfigOverrideBody(initArgs.BackendConfig, backendSchema) - diags = diags.Append(overrideDiags) - if overrideDiags.HasErrors() { - return nil, true, diags - } - } - - opts = &BackendOpts{ - BackendConfig: backendConfig, - Locks: configLocks, - ConfigOverride: configOverride, - Init: true, - ViewType: initArgs.ViewType, - } - - default: - // No config; defaults to local state storage - - // If the user supplied a -backend-config on the CLI but no backend - // block was found in the configuration, it's likely - but not - // necessarily - a mistake. Return a warning. - if !initArgs.BackendConfig.Empty() { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Missing backend configuration", - `-backend-config was used without a "backend" block in the configuration. - -If you intended to override the default local backend configuration, -no action is required, but you may add an explicit backend block to your -configuration to clear this warning: - -terraform { - backend "local" {} -} - -However, if you intended to override a defined backend, please verify that -the backend configuration is present and valid. -`, - )) - } - - opts = &BackendOpts{ - Init: true, - Locks: configLocks, - ViewType: initArgs.ViewType, - } - } - - back, backDiags := c.Backend(opts) - diags = diags.Append(backDiags) - return back, true, diags -} diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 5216dcfb1e..bda5709cca 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -3494,7 +3494,6 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { output := testOutput.All() expectedOutputs := []string{ "Initializing the state store...", - "Terraform created an empty state file for the default workspace", "Terraform has been successfully initialized!", } for _, expected := range expectedOutputs { @@ -3504,7 +3503,7 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("the init command creates a backend state file, and creates the default workspace by default", func(t *testing.T) { + t.Run("the init command creates a backend state file, and the default workspace is not made by default", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store td := t.TempDir() testCopyDir(t, testFixturePath("init-with-state-store"), td) @@ -3547,7 +3546,6 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { output := testOutput.All() expectedOutputs := []string{ "Initializing the state store...", - "Terraform created an empty state file for the default workspace", "Terraform has been successfully initialized!", } for _, expected := range expectedOutputs { @@ -3556,9 +3554,9 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } } - // Assert the default workspace was created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; !exists { - t.Fatal("expected the default workspace to be created during init, but it is missing") + // Assert the default workspace was not created + if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { + t.Fatal("expected the default workspace to not be created during init, but it exists") } // Assert contents of the backend state file @@ -3591,105 +3589,6 @@ func TestInit_stateStore_newWorkingDir(t *testing.T) { } }) - t.Run("an init command with the flag -create-default-workspace=false will not make the default workspace by default", func(t *testing.T) { - // Create a temporary, uninitialized working directory with configuration including a state store - td := t.TempDir() - testCopyDir(t, testFixturePath("init-with-state-store"), td) - t.Chdir(td) - - mockProvider := mockPluggableStateStorageProvider() - mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource, close := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, - }) - defer close() - - ui := new(cli.MockUi) - view, done := testView(t) - c := &InitCommand{ - Meta: Meta{ - Ui: ui, - View: view, - AllowExperimentalFeatures: true, - testingOverrides: &testingOverrides{ - Providers: map[addrs.Provider]providers.Factory{ - mockProviderAddress: providers.FactoryFixed(mockProvider), - }, - }, - ProviderSource: providerSource, - }, - } - - args := []string{"-enable-pluggable-state-storage-experiment=true", "-create-default-workspace=false"} - code := c.Run(args) - testOutput := done(t) - if code != 0 { - t.Fatalf("expected code 0 exit code, got %d, output: \n%s", code, testOutput.All()) - } - - // Check output - output := testOutput.All() - expectedOutput := `Terraform has been configured to skip creation of the default workspace` - if !strings.Contains(output, expectedOutput) { - t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) - } - - // Assert the default workspace was created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { - t.Fatal("expected Terraform to skip creating the default workspace, but it has been created") - } - }) - - t.Run("an init command with TF_SKIP_CREATE_DEFAULT_WORKSPACE set will not make the default workspace by default", func(t *testing.T) { - // Create a temporary, uninitialized working directory with configuration including a state store - td := t.TempDir() - testCopyDir(t, testFixturePath("init-with-state-store"), td) - t.Chdir(td) - - mockProvider := mockPluggableStateStorageProvider() - mockProviderAddress := addrs.NewDefaultProvider("test") - providerSource, close := newMockProviderSource(t, map[string][]string{ - "hashicorp/test": {"1.0.0"}, - }) - defer close() - - ui := new(cli.MockUi) - view, done := testView(t) - c := &InitCommand{ - Meta: Meta{ - Ui: ui, - View: view, - AllowExperimentalFeatures: true, - testingOverrides: &testingOverrides{ - Providers: map[addrs.Provider]providers.Factory{ - mockProviderAddress: providers.FactoryFixed(mockProvider), - }, - }, - ProviderSource: providerSource, - }, - } - - t.Setenv("TF_SKIP_CREATE_DEFAULT_WORKSPACE", "1") // any value - args := []string{"-enable-pluggable-state-storage-experiment=true"} - code := c.Run(args) - testOutput := done(t) - if code != 0 { - t.Fatalf("expected code 0 exit code, got %d, output: \n%s", code, testOutput.All()) - } - - // Check output - output := testOutput.All() - expectedOutput := `Terraform has been configured to skip creation of the default workspace` - if !strings.Contains(output, expectedOutput) { - t.Fatalf("expected output to include %q, but got':\n %s", expectedOutput, output) - } - - // Assert the default workspace was created - if _, exists := mockProvider.MockStates[backend.DefaultStateName]; exists { - t.Fatal("expected Terraform to skip creating the default workspace, but it has been created") - } - }) - // This scenario would be rare, but protecting against it is easy and avoids assumptions. t.Run("if a custom workspace is selected but no workspaces exist an error is returned", func(t *testing.T) { // Create a temporary, uninitialized working directory with configuration including a state store diff --git a/internal/command/jsonconfig/config.go b/internal/command/jsonconfig/config.go index b2d794bdf4..3338fb336a 100644 --- a/internal/command/jsonconfig/config.go +++ b/internal/command/jsonconfig/config.go @@ -422,9 +422,10 @@ func marshalModuleCall(c *configs.Config, mc *configs.ModuleCall, schemas *terra // a (admittedly minor) breaking change to start normalizing them // now, in case consumers of this data are expecting a particular // non-normalized syntax. - Source: mc.SourceAddrRaw, - VersionConstraint: mc.Version.Required.String(), + Source: c.SourceAddrRaw, + VersionConstraint: c.VersionConstraint.Required.String(), } + cExp := marshalExpression(mc.Count) if !cExp.Empty() { ret.CountExpression = &cExp diff --git a/internal/command/jsonformat/structured/attribute_path/matcher.go b/internal/command/jsonformat/structured/attribute_path/matcher.go index 2f453e0e81..87fb5eac13 100644 --- a/internal/command/jsonformat/structured/attribute_path/matcher.go +++ b/internal/command/jsonformat/structured/attribute_path/matcher.go @@ -18,7 +18,7 @@ import ( // // The caller of the above functions is required to know whether the next value // in the path is a list type or an object type and call the relevant function, -// otherwise these functions will crash/panic. +// otherwise no match will be returned. // // The Matches function returns true if the paths you have traversed until now // ends. @@ -151,8 +151,18 @@ func (p *PathMatcher) GetChildWithKey(key string) Matcher { continue } - if path[0].(string) == key { - child.Paths = append(child.Paths, path[1:]) + switch val := path[0].(type) { + case string: + if val == key { + child.Paths = append(child.Paths, path[1:]) + } + case float64: + // here we must assume the path being looked up no longer matches + // the given data structure, so the caller in incorrect. This is + // fine, because it only means that we don't match any paths. + default: + panic(fmt.Errorf("found invalid type within path (%v:%T), the validation shouldn't have allowed this to happen; this is a bug in Terraform, please report it", val, val)) + } } return child @@ -190,15 +200,12 @@ func (p *PathMatcher) GetChildWithIndex(index int) Matcher { switch val := path[0].(type) { case float64: - if int(path[0].(float64)) == index { + if int(val) == index { child.Paths = append(child.Paths, path[1:]) } case string: f, err := strconv.ParseFloat(val, 64) - if err != nil { - panic(fmt.Errorf("found invalid type within path (%v:%T), the validation shouldn't have allowed this to happen; this is a bug in Terraform, please report it", val, val)) - } - if int(f) == index { + if err == nil && int(f) == index { child.Paths = append(child.Paths, path[1:]) } default: diff --git a/internal/command/jsonformat/structured/attribute_path/matcher_test.go b/internal/command/jsonformat/structured/attribute_path/matcher_test.go index 4ea234a9a0..374f7dc4f5 100644 --- a/internal/command/jsonformat/structured/attribute_path/matcher_test.go +++ b/internal/command/jsonformat/structured/attribute_path/matcher_test.go @@ -254,3 +254,39 @@ func TestPathMatcher_MultiplePaths(t *testing.T) { t.Errorf("should not have partial matched at leaf level") } } + +// Since paths may be coming from relevant attributes, and those paths may no +// longer correspond to an updated schema, we can't always be certain the caller +// knows the correct type. +func TestPathMatcher_WrongKeyTypes(t *testing.T) { + var matcher Matcher + + matcher = &PathMatcher{ + Paths: [][]interface{}{ + { + float64(0), + "key", + float64(0), + }, + }, + } + + failed := matcher.GetChildWithKey("key") + if failed.Matches() || failed.MatchesPartial() { + t.Errorf("should not have any match at on failure") + } + + matcher = matcher.GetChildWithIndex(0).GetChildWithKey("key") + + if matcher.Matches() { + t.Errorf("should not have exact matched at first level") + } + if !matcher.MatchesPartial() { + t.Errorf("should have partial matched at first level") + } + + failed = matcher.GetChildWithKey("zero") + if failed.Matches() || failed.MatchesPartial() { + t.Errorf("should not have any match at on failure") + } +} diff --git a/internal/command/meta.go b/internal/command/meta.go index 6bba97540c..7a9dee7308 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -186,6 +186,8 @@ type Meta struct { // flag is set, to reinforce that experiments are not for production use. AllowExperimentalFeatures bool + VariableValues map[string]arguments.UnparsedVariableValue + //---------------------------------------------------------- // Protected: commands can set these //---------------------------------------------------------- @@ -823,19 +825,18 @@ func (m *Meta) applyStateArguments(args *arguments.State) { func (m *Meta) checkRequiredVersion() tfdiags.Diagnostics { var diags tfdiags.Diagnostics - loader, err := m.initConfigLoader() - if err != nil { - diags = diags.Append(err) - return diags - } - pwd, err := os.Getwd() if err != nil { diags = diags.Append(fmt.Errorf("Error getting pwd: %s", err)) return diags } - config, configDiags := loader.LoadConfig(pwd) + diags = diags.Append(m.resolveConstVariables(pwd, arguments.ViewHuman)) + if diags.HasErrors() { + return diags + } + + config, configDiags := m.loadConfig(pwd) if configDiags.HasErrors() { diags = diags.Append(configDiags) return diags diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index e9bf86ff09..710558b800 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -44,7 +44,6 @@ import ( "github.com/hashicorp/terraform/internal/getproviders/reattach" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" - "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" @@ -83,10 +82,6 @@ type BackendOpts struct { // ViewType will set console output format for the // initialization operation (JSON or human-readable). ViewType arguments.ViewType - - // CreateDefaultWorkspace signifies whether the operations backend should create - // the default workspace or not - CreateDefaultWorkspace bool } // BackendWithRemoteTerraformVersion is a shared interface between the 'remote' and 'cloud' backends @@ -1259,8 +1254,32 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // Verify that selected workspace exist. Otherwise prompt user to create one if opts.Init && savedStateStore != nil { if err := m.selectWorkspace(savedStateStore); err != nil { - diags = diags.Append(err) - return nil, diags + if errors.Is(err, &errBackendNoExistingWorkspaces{}) { + // We tolerate no workspaces if we're using a state store and + // the default workspace is selected. + ws, err := m.Workspace() + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to check current workspace: %w", err)) + return nil, diags + } + if ws == backend.DefaultStateName { + // If the default workspace is selected, no workspaces existing _may_ be expected. + // It's valid for the default workspace's state to not be created until the first apply takes place. + // However, it could be that the user is configuring their working directory for the first time but + // they expect pre-existing state to be in the store from previous actions. In that case, the user + // should realise their mistake once they generate a plan. + // + // So here, we will just ignore the error. + } else { + // User needs to run a `terraform workspace new` command to create the missing custom workspace. + diags = diags.Append(err) + return nil, diags + } + } else { + // Report all other errors + diags = diags.Append(err) + return nil, diags + } } } @@ -2457,11 +2476,8 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, backend // Verify that selected workspace exists in the state store. if opts.Init && b != nil { - err := m.selectWorkspace(b) - if err != nil { + if err := m.selectWorkspace(b); err != nil { if errors.Is(err, &errBackendNoExistingWorkspaces{}) { - // If there are no workspaces, Terraform either needs to create the default workspace here - // or instruct the user to run a `terraform workspace new` command. ws, err := m.Workspace() if err != nil { diags = diags.Append(fmt.Errorf("Failed to check current workspace: %w", err)) @@ -2469,21 +2485,13 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, backend } if ws == backend.DefaultStateName { - // Users control if the default workspace is created through the -create-default-workspace flag (defaults to true) - if opts.CreateDefaultWorkspace { - diags = diags.Append(m.createDefaultWorkspace(c, b)) - if !diags.HasErrors() { - // Report workspace creation to the view - view := views.NewInit(vt, m.View) - view.Output(views.DefaultWorkspaceCreatedMessage) - } - } else { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "The default workspace does not exist", - Detail: "Terraform has been configured to skip creation of the default workspace in the state store. To create it, either remove the `-create-default-workspace=false` flag and re-run the 'init' command, or create it using a 'workspace new' command", - }) - } + // If the default workspace is selected, no workspaces existing _may_ be expected. + // It's valid for the default workspace's state to not be created until the first apply takes place. + // However, it could be that the user is configuring their working directory for the first time but + // they expect pre-existing state to be in the store from previous actions. In that case, the user + // should realise their mistake once they generate a plan. + // + // So here, we will just ignore the error. } else { // User needs to run a `terraform workspace new` command to create the missing custom workspace. diags = append(diags, tfdiags.Sourceless( @@ -2813,35 +2821,6 @@ To make the initial dependency selections that will initialize the dependency lo return pVersion, diags } -// createDefaultWorkspace receives a backend made using a pluggable state store, and details about that store's config, -// and persists an empty state file in the default workspace. By creating this artifact we ensure that the default -// workspace is created and usable by Terraform in later operations. -func (m *Meta) createDefaultWorkspace(c *configs.StateStore, b backend.Backend) tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - - defaultSMgr, sDiags := b.StateMgr(backend.DefaultStateName) - diags = diags.Append(sDiags) - if sDiags.HasErrors() { - diags = diags.Append(fmt.Errorf("Failed to create a state manager for state store %q in provider %s (%q). This is a bug in Terraform and should be reported: %w", - c.Type, - c.Provider.Name, - c.ProviderAddr, - sDiags.Err())) - return diags - } - emptyState := states.NewState() - if err := defaultSMgr.WriteState(emptyState); err != nil { - diags = diags.Append(errStateStoreWorkspaceCreateDiag(err, c.Type)) - return diags - } - if err := defaultSMgr.PersistState(nil); err != nil { - diags = diags.Append(errStateStoreWorkspaceCreateDiag(err, c.Type)) - return diags - } - - return diags -} - // Initializing a saved state store from the backend state file (aka 'cache file', aka 'legacy state file') func (m *Meta) savedStateStore(sMgr *clistate.LocalState) (backend.Backend, tfdiags.Diagnostics) { // We're preparing a state_store version of backend.Backend. @@ -2923,10 +2902,11 @@ func (m *Meta) savedStateStore(sMgr *clistate.LocalState) (backend.Backend, tfdi &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Error reading state store configuration state", - Detail: fmt.Sprintf("Terraform experienced an error reading state store configuration for state store %s in provider %s (%q)", + Detail: fmt.Sprintf("Terraform experienced an error reading state store configuration for state store %s in provider %s (%q): %s", s.StateStore.Type, s.StateStore.Provider.Source.Type, s.StateStore.Provider.Source, + err, ), }, ) @@ -3403,7 +3383,7 @@ func (m *Meta) StateStoreProviderFactoryFromConfigState(cfgState *workdir.StateS Severity: hcl.DiagError, Summary: "Provider unavailable", Detail: fmt.Sprintf("The provider %s (%q) is required to initialize the %q state store, but the matching provider factory is missing. This is a bug in Terraform and should be reported.", - cfgState.Type, + cfgState.Provider.Source.Type, cfgState.Provider.Source, cfgState.Type, ), diff --git a/internal/command/meta_backend_errors.go b/internal/command/meta_backend_errors.go index e09356974f..d81284205b 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -280,23 +280,6 @@ If the backend already contains existing workspaces, you may need to update the backend configuration.` } -func errStateStoreWorkspaceCreateDiag(innerError error, storeType string) tfdiags.Diagnostic { - msg := fmt.Sprintf(`Error creating the default workspace using pluggable state store %s: %s - -This could be a bug in the provider used for state storage, or a bug in -Terraform. Please file an issue with the provider developers before reporting -a bug for Terraform.`, - storeType, - innerError, - ) - - return tfdiags.Sourceless( - tfdiags.Error, - "Cannot create the default workspace", - msg, - ) -} - // migrateOrReconfigDiag creates a diagnostic to present to users when // an init command encounters a mismatch in backend state and the current config // and Terraform needs users to provide additional instructions about how Terraform diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 6c6e80dbb2..c476378ff1 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -17,6 +17,8 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/hashicorp/terraform/internal/backend/backendrun" + "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" @@ -35,6 +37,60 @@ func (m *Meta) normalizePath(path string) string { return m.WorkingDir.NormalizePath(path) } +// resolveConstVariables checks whether the root module in rootDir declares any +// const variables that are required but not yet provided via CLI flags. If so, +// it attempts to fetch them from the configured backend (e.g. HCP Terraform +// workspace variables). This must be called before loadConfig or +// loadConfigWithTests so that const variable values are available during +// module source resolution. +// +// If no const variables are unsatisfied, or if the backend does not support +// supplying variables, this method is a no-op. +func (m *Meta) resolveConstVariables(rootDir string, viewType arguments.ViewType) tfdiags.Diagnostics { + rootMod, diags := m.loadSingleModule(rootDir) + if diags.HasErrors() { + return diags + } + + if !backendrun.HasUnsatisfiedConstVariables(m.VariableValues, rootMod.Variables) { + return nil + } + + b, backendDiags := m.backend(rootDir, viewType) + if backendDiags.HasErrors() { + // Don't report backend init errors here; they'll surface later. + return nil + } + + supplier, ok := b.(backendrun.ConstVariableSupplier) + if !ok { + return nil + } + + workspace, err := m.Workspace() + if err != nil { + diags = diags.Append(err) + return diags + } + + vars, fetchDiags := supplier.FetchVariables(context.Background(), workspace) + diags = diags.Append(fetchDiags) + if fetchDiags.HasErrors() { + return diags + } + + if m.VariableValues == nil { + m.VariableValues = make(map[string]arguments.UnparsedVariableValue) + } + for k, v := range vars { + if _, exists := m.VariableValues[k]; !exists { + m.VariableValues[k] = v + } + } + + return diags +} + // loadConfig reads a configuration from the given directory, which should // contain a root module and have already have any required descendant modules // installed. @@ -48,8 +104,28 @@ func (m *Meta) loadConfig(rootDir string) (*configs.Config, tfdiags.Diagnostics) return nil, diags } - config, hclDiags := loader.LoadConfig(rootDir) + rootMod, hclDiags := loader.LoadRootModule(rootDir) diags = diags.Append(hclDiags) + if rootMod == nil || diags.HasErrors() { + cfg := &configs.Config{ + Module: rootMod, + } + cfg.Root = cfg // Root module is self-referential. + return cfg, diags + } + vars, parseDiags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + diags = diags.Append(parseDiags) + if parseDiags.HasErrors() { + return nil, diags + } + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + vars, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + return config, diags } @@ -65,8 +141,28 @@ func (m *Meta) loadConfigWithTests(rootDir, testDir string) (*configs.Config, tf return nil, diags } - config, hclDiags := loader.LoadConfigWithTests(rootDir, testDir) + rootMod, hclDiags := loader.LoadRootModuleWithTests(rootDir, testDir) diags = diags.Append(hclDiags) + if rootMod == nil || diags.HasErrors() { + cfg := &configs.Config{ + Module: rootMod, + } + cfg.Root = cfg // Root module is self-referential. + return cfg, diags + } + vars, parseDiags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + diags = diags.Append(parseDiags) + if parseDiags.HasErrors() { + return nil, diags + } + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + vars, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + return config, diags } @@ -201,7 +297,21 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg return true, diags } - inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient()) + initializer := func(rootMod *configs.Module, walker configs.ModuleWalker) (*configs.Config, tfdiags.Diagnostics) { + variables, diags := backendrun.ParseConstVariableValues(m.VariableValues, rootMod.Variables) + ctx, ctxDiags := terraform.NewContext(&terraform.ContextOpts{ + Parallelism: 1, + }) + diags = diags.Append(ctxDiags) + if diags.HasErrors() { + return nil, diags + } + return ctx.Init(rootMod, terraform.InitOpts{ + Walker: walker, + SetVariables: variables, + }) + } + inst := initwd.NewModuleInstaller(m.modulesDir(), loader, m.registryClient(), initializer) _, moreDiags := inst.InstallModules(ctx, rootDir, testsDir, upgrade, installErrsOnly, hooks) diags = diags.Append(moreDiags) diff --git a/internal/command/modules.go b/internal/command/modules.go index cb2972dbc4..93584285eb 100644 --- a/internal/command/modules.go +++ b/internal/command/modules.go @@ -48,6 +48,23 @@ func (c *ModulesCommand) Run(rawArgs []string) int { // Set up the command's view view := views.NewModules(c.viewType, c.View) + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + rootModPath, err := ModulePath([]string{}) if err != nil { diags = diags.Append(err) @@ -63,6 +80,12 @@ func (c *ModulesCommand) Run(rawArgs []string) int { return 1 } + diags = diags.Append(c.resolveConstVariables(rootModPath, args.ViewType)) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + config, confDiags := c.loadConfig(rootModPath) // Here we check if there are any uninstalled dependencies versionDiags := terraform.CheckCoreVersionRequirements(config) @@ -127,6 +150,15 @@ Usage: terraform [global options] modules [options] Options: - -json If specified, output declared Terraform modules and - their resolved versions in a machine-readable format. + -json If specified, output declared Terraform modules and + their resolved versions in a machine-readable format. + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. ` diff --git a/internal/command/providers.go b/internal/command/providers.go index 4ada467523..1e6c2714f5 100644 --- a/internal/command/providers.go +++ b/internal/command/providers.go @@ -43,6 +43,23 @@ func (c *ProvidersCommand) Run(args []string) int { return 1 } + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + empty, err := configs.IsEmptyDir(configPath, parsedArgs.TestsDirectory) if err != nil { diags = diags.Append(tfdiags.Sourceless( @@ -67,6 +84,12 @@ func (c *ProvidersCommand) Run(args []string) int { return 1 } + diags = diags.Append(c.resolveConstVariables(configPath, arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, configDiags := c.loadConfigWithTests(configPath, parsedArgs.TestsDirectory) diags = diags.Append(configDiags) if configDiags.HasErrors() { @@ -178,5 +201,14 @@ Usage: terraform [global options] providers [options] [DIR] Options: - -test-directory=path Set the Terraform test directory, defaults to "tests". + -test-directory=path Set the Terraform test directory, defaults to "tests". + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. ` diff --git a/internal/command/providers_lock.go b/internal/command/providers_lock.go index a13a1bc86e..09ee1a791d 100644 --- a/internal/command/providers_lock.go +++ b/internal/command/providers_lock.go @@ -99,6 +99,29 @@ func (c *ProvidersLockCommand) Run(args []string) int { source = getproviders.NewRegistrySource(c.Services) } + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + diags = diags.Append(c.resolveConstVariables(".", arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, confDiags := c.loadConfigWithTests(".", parsedArgs.TestsDirectory) diags = diags.Append(confDiags) reqs, hclDiags := config.ProviderRequirements() @@ -393,7 +416,16 @@ Options: This will speed up the locking process, but the providers won't be loaded from an authoritative source. - -test-directory=path Set the Terraform test directory, defaults to "tests". + -test-directory=path Set the Terraform test directory, defaults to "tests". + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. ` } diff --git a/internal/command/providers_mirror.go b/internal/command/providers_mirror.go index cfff1b2e2c..97881300bc 100644 --- a/internal/command/providers_mirror.go +++ b/internal/command/providers_mirror.go @@ -61,6 +61,29 @@ func (c *ProvidersMirrorCommand) Run(args []string) int { ctx, done := c.InterruptibleContext(c.CommandContext()) defer done() + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + + diags = diags.Append(c.resolveConstVariables(".", arguments.ViewHuman)) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + config, confDiags := c.loadConfig(".") diags = diags.Append(confDiags) reqs, moreDiags := config.ProviderRequirements() @@ -350,21 +373,30 @@ Usage: terraform [global options] providers mirror [options] Options: - -platform=os_arch Choose which target platform to build a mirror for. - By default Terraform will obtain plugin packages - suitable for the platform where you run this command. - Use this flag multiple times to include packages for - multiple target systems. + -platform=os_arch Choose which target platform to build a mirror for. + By default Terraform will obtain plugin packages + suitable for the platform where you run this command. + Use this flag multiple times to include packages for + multiple target systems. - Target names consist of an operating system and a CPU - architecture. For example, "linux_amd64" selects the - Linux operating system running on an AMD64 or x86_64 - CPU. Each provider is available only for a limited - set of target platforms. + Target names consist of an operating system and a CPU + architecture. For example, "linux_amd64" selects the + Linux operating system running on an AMD64 or x86_64 + CPU. Each provider is available only for a limited + set of target platforms. - -lock-file=false Ignore the provider lock file when fetching providers. - By default the mirror command will use the version info - in the lock file if the configuration directory has been - previously initialized. + -lock-file=false Ignore the provider lock file when fetching providers. + By default the mirror command will use the version info + in the lock file if the configuration directory has been + previously initialized. + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. ` } diff --git a/internal/command/providers_schema.go b/internal/command/providers_schema.go index 6af9b75f3a..33541806d5 100644 --- a/internal/command/providers_schema.go +++ b/internal/command/providers_schema.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/jsonprovider" + "github.com/hashicorp/terraform/internal/tfdiags" ) // ProvidersCommand is a Command implementation that prints out information @@ -27,7 +28,7 @@ func (c *ProvidersSchemaCommand) Synopsis() string { } func (c *ProvidersSchemaCommand) Run(args []string) int { - _, diags := arguments.ParseProvidersSchema(c.Meta.process(args)) + parsedArgs, diags := arguments.ParseProvidersSchema(c.Meta.process(args)) if diags.HasErrors() { c.showDiagnostics(diags) return 1 @@ -78,6 +79,16 @@ func (c *ProvidersSchemaCommand) Run(args []string) int { return 1 } + var varDiags tfdiags.Diagnostics + opReq.Variables, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + opReq.ConfigLoader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + // Get the context lr, _, ctxDiags := local.LocalRun(opReq) diags = diags.Append(ctxDiags) @@ -108,4 +119,15 @@ Usage: terraform [global options] providers schema -json Prints out a json representation of the schemas for all providers used in the current configuration. + +Options: + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. ` diff --git a/internal/command/query_test.go b/internal/command/query_test.go index 58359e940c..eee626c4b6 100644 --- a/internal/command/query_test.go +++ b/internal/command/query_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" @@ -848,7 +849,7 @@ func TestQuery_JSON_Raw(t *testing.T) { { name: "basic query", directory: "basic", - expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"1.2"} + expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"` + views.JSON_UI_VERSION + `"} {"@level":"info","@message":"list.test_instance.example: Starting query...","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600609+02:00","list_start":{"address":"list.test_instance.example","resource_type":"test_instance","input_config":{"ami":"ami-12345","foo":null}},"type":"list_start"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600729+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 1","identity":{"id":"test-instance-1"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-12345","id":"test-instance-1"}},"type":"list_resource_found"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600759+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 2","identity":{"id":"test-instance-2"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-67890","id":"test-instance-2"}},"type":"list_resource_found"} @@ -858,7 +859,7 @@ func TestQuery_JSON_Raw(t *testing.T) { { name: "empty result", directory: "empty-result", - expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"1.2"} + expectedOut: `{"@level":"info","@message":"Terraform ` + tfVer + `","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"` + views.JSON_UI_VERSION + `"} {"@level":"info","@message":"list.test_instance.example: Starting query...","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600609+02:00","list_start":{"address":"list.test_instance.example","resource_type":"test_instance","input_config":{"ami":"ami-12345","foo":null}},"type":"list_start"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600729+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 1","identity":{"id":"test-instance-1"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-12345","id":"test-instance-1"}},"type":"list_resource_found"} {"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600759+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 2","identity":{"id":"test-instance-2"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-67890","id":"test-instance-2"}},"type":"list_resource_found"} diff --git a/internal/command/show.go b/internal/command/show.go index 19a829dfd2..461343098b 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -11,11 +11,13 @@ import ( "strings" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/cloud/cloudplan" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states/statefile" @@ -66,8 +68,20 @@ func (c *ShowCommand) Run(rawArgs []string) int { // Set up view view := views.NewShow(args.ViewType, c.View) + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + // Check for user-supplied plugin path - var err error if c.pluginPath, err = c.loadPluginPath(); err != nil { diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err)) view.Diagnostics(diags) @@ -267,7 +281,7 @@ func (c *ShowCommand) getPlanFromPath(path string) (*plans.Plan, *cloudplan.Remo } if lp, ok := pf.Local(); ok { - plan, stateFile, config, err = getDataFromPlanfileReader(lp, c.Meta.AllowExperimentalFeatures) + plan, stateFile, config, err = getDataFromPlanfileReader(lp, c.Meta.AllowExperimentalFeatures, c.Meta.VariableValues) } else if cp, ok := pf.Cloud(); ok { redacted := c.viewType != arguments.ViewJSON jsonPlan, err = c.getDataFromCloudPlan(cp, redacted) @@ -297,7 +311,7 @@ func (c *ShowCommand) getDataFromCloudPlan(plan *cloudplan.SavedPlanBookmark, re } // getDataFromPlanfileReader returns a plan, statefile, and config, extracted from a local plan file. -func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperiments bool) (*plans.Plan, *statefile.File, *configs.Config, error) { +func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperiments bool, variableValues map[string]arguments.UnparsedVariableValue) (*plans.Plan, *statefile.File, *configs.Config, error) { // Get plan plan, err := planReader.ReadPlan() if err != nil { @@ -311,7 +325,7 @@ func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperim } // Get config - config, diags := planReader.ReadConfig(allowLanguageExperiments) + config, diags := readConfig(planReader, allowLanguageExperiments, variableValues) if diags.HasErrors() { return nil, nil, nil, errUnusable(diags.Err(), "local plan") } @@ -319,6 +333,47 @@ func getDataFromPlanfileReader(planReader *planfile.Reader, allowLanguageExperim return plan, stateFile, config, err } +func readConfig(r *planfile.Reader, allowLanguageExperiments bool, variableValues map[string]arguments.UnparsedVariableValue) (*configs.Config, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + snap, err := r.ReadConfigSnapshot() + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to read configuration from plan file", + fmt.Sprintf("The configuration file snapshot in the plan file could not be read: %s.", err), + )) + return nil, diags + } + + loader := configload.NewLoaderFromSnapshot(snap) + loader.AllowLanguageExperiments(allowLanguageExperiments) + rootMod, rootDiags := loader.LoadRootModule(snap.Modules[""].Dir) + diags = diags.Append(rootDiags) + if rootDiags.HasErrors() { + return nil, diags + } + + variables, varDiags := backendrun.ParseConstVariableValues(variableValues, rootMod.Variables) + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, diags + } + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + variables, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + if buildDiags.HasErrors() { + return nil, diags + } + + return config, diags +} + // getStateFromPath returns a statefile if the user-supplied path points to a statefile. func getStateFromPath(path string) (*statefile.File, error) { file, err := os.Open(path) diff --git a/internal/command/show_test.go b/internal/command/show_test.go index 99da480f36..71995d9b90 100644 --- a/internal/command/show_test.go +++ b/internal/command/show_test.go @@ -6,6 +6,7 @@ package command import ( "bytes" "encoding/json" + "io" "io/ioutil" "os" "path/filepath" @@ -533,7 +534,7 @@ func TestShow_state(t *testing.T) { func TestShow_json_output(t *testing.T) { fixtureDir := "testdata/show-json" - testDirs, err := ioutil.ReadDir(fixtureDir) + testDirs, err := os.ReadDir(fixtureDir) if err != nil { t.Fatal(err) } @@ -584,7 +585,7 @@ func TestShow_json_output(t *testing.T) { t.Fatalf("unexpected err: %s", err) } defer wantFile.Close() - byteValue, err := ioutil.ReadAll(wantFile) + byteValue, err := io.ReadAll(wantFile) if err != nil { t.Fatalf("unexpected err: %s", err) } diff --git a/internal/command/state_mv.go b/internal/command/state_mv.go index 64d47e6eba..2548ad942e 100644 --- a/internal/command/state_mv.go +++ b/internal/command/state_mv.go @@ -36,6 +36,23 @@ func (c *StateMvCommand) Run(args []string) int { c.statePath = parsedArgs.StatePath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -541,6 +558,15 @@ Options: -ignore-remote-version A rare option used for the remote backend only. See the remote backend documentation for more information. + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + -state, state-out, and -backup are legacy options supported for the local backend only. For more information, see the local backend's documentation. diff --git a/internal/command/state_pull.go b/internal/command/state_pull.go index 6eb6da85b5..f332f6b53a 100644 --- a/internal/command/state_pull.go +++ b/internal/command/state_pull.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/tfdiags" ) // StatePullCommand is a Command implementation that allows downloading @@ -21,12 +22,28 @@ type StatePullCommand struct { } func (c *StatePullCommand) Run(args []string) int { - _, diags := arguments.ParseStatePull(c.Meta.process(args)) + parsedArgs, diags := arguments.ParseStatePull(c.Meta.process(args)) if diags.HasErrors() { c.showDiagnostics(diags) return 1 } + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -90,6 +107,17 @@ Usage: terraform [global options] state pull [options] The primary use of this is for state stored remotely. This command will still work with local state but is less useful for this. +Options: + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + ` return strings.TrimSpace(helpText) } diff --git a/internal/command/state_push.go b/internal/command/state_push.go index b99ca05cf3..97ef857ecd 100644 --- a/internal/command/state_push.go +++ b/internal/command/state_push.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" ) // StatePushCommand is a Command implementation that allows @@ -35,6 +36,22 @@ func (c *StatePushCommand) Run(args []string) int { c.Meta.stateLockTimeout = parsedArgs.StateLockTimeout c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -173,6 +190,15 @@ Options: -lock-timeout=0s Duration to retry a state lock. + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + ` return strings.TrimSpace(helpText) } diff --git a/internal/command/state_replace_provider.go b/internal/command/state_replace_provider.go index 73967806c3..74279202aa 100644 --- a/internal/command/state_replace_provider.go +++ b/internal/command/state_replace_provider.go @@ -38,6 +38,23 @@ func (c *StateReplaceProviderCommand) Run(args []string) int { c.statePath = parsedArgs.StatePath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -207,6 +224,15 @@ Options: -ignore-remote-version A rare option used for the remote backend only. See the remote backend documentation for more information. + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + -state, state-out, and -backup are legacy options supported for the local backend only. For more information, see the local backend's documentation. diff --git a/internal/command/state_rm.go b/internal/command/state_rm.go index 1c555497de..b45dff41af 100644 --- a/internal/command/state_rm.go +++ b/internal/command/state_rm.go @@ -34,6 +34,22 @@ func (c *StateRmCommand) Run(args []string) int { c.statePath = parsedArgs.StatePath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) return 1 @@ -191,6 +207,15 @@ Options: are incompatible. This may result in an unusable workspace, and should be used with extreme caution. + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + ` return strings.TrimSpace(helpText) } diff --git a/internal/command/taint.go b/internal/command/taint.go index ba9ef4c82d..9b50a0508f 100644 --- a/internal/command/taint.go +++ b/internal/command/taint.go @@ -37,6 +37,23 @@ func (c *TaintCommand) Run(rawArgs []string) int { c.Meta.stateOutPath = parsedArgs.StateOutPath c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + loader, err := c.initConfigLoader() + if err != nil { + var diags tfdiags.Diagnostics + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = parsedArgs.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + if varDiags.HasErrors() { + c.showDiagnostics(varDiags) + return 1 + } + var diags tfdiags.Diagnostics addr, addrDiags := addrs.ParseAbsResourceInstanceStr(parsedArgs.Address) @@ -224,6 +241,15 @@ Options: -ignore-remote-version A rare option used for the remote backend only. See the remote backend documentation for more information. + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + -state, state-out, and -backup are legacy options supported for the local backend only. For more information, see the local backend's documentation. diff --git a/internal/command/test.go b/internal/command/test.go index 404f162724..a3376cacd7 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -321,6 +321,57 @@ func (m *Meta) setupTestExecution(mode moduletest.CommandMode, command string, r return } + loader, err := m.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(nil, nil, diags) + return + } + + registerFileSource := func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + } + + // Collect variables for "terraform test" + preparation.TestVariables, moreDiags = arguments.CollectValuesForTests(preparation.Args.TestDirectory, registerFileSource) + diags = diags.Append(moreDiags) + + // Collect variable values and add them to the operation request. + // We must collect these before loading config, because + // loadConfigWithTests needs const variable values available in + // m.VariableValues to resolve dynamic module sources. + var varDiags tfdiags.Diagnostics + preparation.Variables, varDiags = preparation.Args.Vars.CollectValues(registerFileSource) + diags = diags.Append(varDiags) + if diags.HasErrors() { + view.Diagnostics(nil, nil, diags) + return + } + + // Only populate m.VariableValues with variables that are declared + // as const in the root module. loadConfigWithTests uses + // m.VariableValues to resolve dynamic module sources via + // ParseConstVariableValues, which would otherwise error on + // undeclared variables passed via -var that are intended for + // test runs rather than the root module. + // + // We do an early load of just the root module to discover which + // variables are const. We discard non-error diagnostics from this + // early load since loadConfigWithTests will re-parse and report them. + earlyMod, earlyDiags := m.loadSingleModuleWithTests(".", preparation.Args.TestDirectory) + if earlyDiags.HasErrors() { + diags = diags.Append(earlyDiags) + view.Diagnostics(nil, nil, diags) + return + } + constVars := make(map[string]arguments.UnparsedVariableValue) + for name, val := range preparation.Variables { + if decl, exists := earlyMod.Variables[name]; exists && decl.Const { + constVars[name] = val + } + } + m.VariableValues = constVars + preparation.Config, moreDiags = m.loadConfigWithTests(".", preparation.Args.TestDirectory) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { @@ -377,30 +428,6 @@ func (m *Meta) setupTestExecution(mode moduletest.CommandMode, command string, r return } - loader, err := m.initConfigLoader() - if err != nil { - diags = diags.Append(err) - view.Diagnostics(nil, nil, diags) - return - } - - registerFileSource := func(filename string, src []byte) { - loader.Parser().ForceFileSource(filename, src) - } - - // Collect variables for "terraform test" - preparation.TestVariables, moreDiags = arguments.CollectValuesForTests(preparation.Args.TestDirectory, registerFileSource) - diags = diags.Append(moreDiags) - - // Collect variable value and add them to the operation request - var varDiags tfdiags.Diagnostics - preparation.Variables, varDiags = preparation.Args.Vars.CollectValues(registerFileSource) - diags = diags.Append(varDiags) - if diags.HasErrors() { - view.Diagnostics(nil, nil, diags) - return - } - opts, err := m.contextOpts() if err != nil { diags = diags.Append(err) diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 92adc85738..10b51e2030 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -37,6 +37,7 @@ import ( "github.com/hashicorp/terraform/internal/registry" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -432,6 +433,25 @@ func TestTest_Runs(t *testing.T) { expectedOut: []string{"2 passed, 0 failed."}, code: 0, }, + "dynamic_source_with_default": { + expectedOut: []string{"1 passed, 0 failed."}, + code: 0, + }, + "dynamic_source_missing_var": { + initCode: 1, + expectedErr: []string{"No value for required variable"}, + code: 1, + }, + "dynamic_source_nonexistent_module": { + initCode: 1, + expectedErr: []string{"Unreadable module directory"}, + code: 1, + }, + "dynamic_source_non_const_var": { + initCode: 1, + expectedErr: []string{"Invalid module source"}, + code: 1, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { @@ -2438,6 +2458,267 @@ func TestTest_ModuleDependencies(t *testing.T) { } } +func TestTest_DynamicSourceWithVarFlag(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_with_var_flag")), td) + t.Chdir(td) + + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): func() (providers.Interface, error) { + return testing_command.NewProvider(store).Provider, nil + }, + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run([]string{"-var", "module_name=example"}); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-var", "module_name=example", "-no-color"}) + output := done(t) + + if code != 0 { + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "1 passed, 0 failed.") { + t.Errorf("output didn't contain expected string:\n\n%s", output.Stdout()) + } + + if len(store.Data) != 0 { + t.Errorf("should have deleted all resources on completion but left %d", len(store.Data)) + } +} + +func TestTest_DynamicSourceWithLocalValue(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_with_local_value")), td) + t.Chdir(td) + + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): func() (providers.Interface, error) { + return testing_command.NewProvider(store).Provider, nil + }, + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run([]string{"-var", "module_name=example"}); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-var", "module_name=example", "-no-color"}) + output := done(t) + + if code != 0 { + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "1 passed, 0 failed.") { + t.Errorf("output didn't contain expected string:\n\n%s", output.Stdout()) + } + + if len(store.Data) != 0 { + t.Errorf("should have deleted all resources on completion but left %d", len(store.Data)) + } +} + +func TestTest_DynamicSourceNested(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_nested")), td) + t.Chdir(td) + + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): func() (providers.Interface, error) { + return testing_command.NewProvider(store).Provider, nil + }, + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run([]string{"-var", "child_name=child"}); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-var", "child_name=child", "-no-color"}) + output := done(t) + + if code != 0 { + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "1 passed, 0 failed.") { + t.Errorf("output didn't contain expected string:\n\n%s", output.Stdout()) + } + + if len(store.Data) != 0 { + t.Errorf("should have deleted all resources on completion but left %d", len(store.Data)) + } +} + +func TestTest_DynamicSourceWithSetupModule(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "dynamic_source_with_setup_module")), td) + t.Chdir(td) + + // Our two providers will share a common set of values to make things + // easier. + store := &testing_command.ResourceStore{ + Data: make(map[string]cty.Value), + } + + // We set it up so the setup provider will write into the data sources + // available to the test provider. + test := testing_command.NewProvider(store) + setup := testing_command.NewProvider(store) + + test.SetDataPrefix("data") + test.SetResourcePrefix("resource") + + // Let's make the setup provider write into the data for test provider. + setup.SetResourcePrefix("data") + + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + "setup": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): providers.FactoryFixed(test.Provider), + addrs.NewDefaultProvider("setup"): providers.FactoryFixed(setup.Provider), + }, + }, + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + if code := init.Run(nil); code != 0 { + output := done(t) + t.Fatalf("expected status code 0 but got %d: %s", code, output.All()) + } + + // Reset the streams for the next command. + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{Meta: meta} + code := c.Run([]string{"-no-color"}) + output := done(t) + + printedOutput := false + + if code != 0 { + printedOutput = true + t.Errorf("expected status code 0 but got %d:\n\n%s", code, output.All()) + } + + if !strings.Contains(output.Stdout(), "2 passed, 0 failed.") { + if !printedOutput { + t.Errorf("output didn't contain expected string:\n\n%s", output.All()) + } else { + t.Errorf("output didn't contain expected string: %q", output.Stdout()) + } + } + + if test.ResourceCount() > 0 { + t.Errorf("should have deleted all resources on completion but left %s", test.ResourceString()) + } + + if setup.ResourceCount() > 0 { + t.Errorf("should have deleted all resources on completion but left %s", setup.ResourceString()) + } +} + func TestTest_CatchesErrorsBeforeDestroy(t *testing.T) { td := t.TempDir() testCopyDir(t, testFixturePath(path.Join("test", "invalid_default_state")), td) @@ -5707,7 +5988,7 @@ func testModuleInline(t *testing.T, sources map[string]string) (*configs.Config, // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -5719,9 +6000,19 @@ func testModuleInline(t *testing.T, sources map[string]string) (*configs.Config, t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadConfigWithTests(cfgPath, "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(cfgPath, "tests") + if hclDiags.HasErrors() { + t.Fatal(hclDiags.Error()) + } + + config, diags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } return config, cfgPath, func() { diff --git a/internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/child/empty.tf b/internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/child/empty.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/internal/configs/configload/testdata/add-version-constraint/.terraform/modules/modules.json b/internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/modules.json similarity index 61% rename from internal/configs/configload/testdata/add-version-constraint/.terraform/modules/modules.json rename to internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/modules.json index c02f40016b..a55de19395 100644 --- a/internal/configs/configload/testdata/add-version-constraint/.terraform/modules/modules.json +++ b/internal/command/testdata/dynamic-module-sources/add-version-constraint/.terraform/modules/modules.json @@ -3,12 +3,12 @@ { "Key": "", "Source": "", - "Dir": "testdata/add-version-constraint" + "Dir": "" }, { "Key": "child", "Source": "hashicorp/module-installer-acctest/aws", - "Dir": "testdata/add-version-constraint/.terraform/modules/child" + "Dir": ".terraform/modules/child" } ] } diff --git a/internal/configs/configload/testdata/add-version-constraint/add-version-constraint.tf b/internal/command/testdata/dynamic-module-sources/add-version-constraint/add-version-constraint.tf similarity index 100% rename from internal/configs/configload/testdata/add-version-constraint/add-version-constraint.tf rename to internal/command/testdata/dynamic-module-sources/add-version-constraint/add-version-constraint.tf diff --git a/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/main.tf new file mode 100644 index 0000000000..98de3a2672 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/modules/example/main.tf new file mode 100644 index 0000000000..11bc1e75a5 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-plan-with-dynamic-source/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "example" { + ami = "bar" +} diff --git a/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/modules/example/main.tf new file mode 100644 index 0000000000..11bc1e75a5 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/apply-with-dynamic-source/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "example" { + ami = "bar" +} diff --git a/internal/command/testdata/dynamic-module-sources/count-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/count-in-module-source/main.tf new file mode 100644 index 0000000000..50f77309dc --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/count-in-module-source/main.tf @@ -0,0 +1,4 @@ +module "example" { + count = 2 + source = "./modules/${count.index}" +} diff --git a/internal/command/testdata/dynamic-module-sources/each-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/each-in-module-source/main.tf new file mode 100644 index 0000000000..53616a849c --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/each-in-module-source/main.tf @@ -0,0 +1,4 @@ +module "example" { + for_each = toset(["one", "two"]) + source = "./modules/${each.key}" +} diff --git a/internal/command/testdata/dynamic-module-sources/from-module-with-dynamic-source/source-module/main.tf b/internal/command/testdata/dynamic-module-sources/from-module-with-dynamic-source/source-module/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/from-module-with-dynamic-source/source-module/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/get-false-with-dynamic-source/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/configs/testdata/error-files/module-local-source-with-version.tf b/internal/command/testdata/dynamic-module-sources/invalid-registry-source-with-module/main.tf similarity index 57% rename from internal/configs/testdata/error-files/module-local-source-with-version.tf rename to internal/command/testdata/dynamic-module-sources/invalid-registry-source-with-module/main.tf index f570d65fe9..99036006e8 100644 --- a/internal/configs/testdata/error-files/module-local-source-with-version.tf +++ b/internal/command/testdata/dynamic-module-sources/invalid-registry-source-with-module/main.tf @@ -1,5 +1,5 @@ module "test" { - source = "../boop" # ERROR: Invalid registry module source address + source = "---.com/HashiCorp/Consul/aws" version = "1.0.0" # Makes Terraform assume "source" is a module address } diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/main.tf new file mode 100644 index 0000000000..a4278dae8c --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/main.tf @@ -0,0 +1,12 @@ +variable "module_name" { + type = string + const = true +} + +locals { + module_path = "./modules/${var.module_name}" +} + +module "example" { + source = local.module_path +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-local-value/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-non-const-variable/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-non-const-variable/main.tf new file mode 100644 index 0000000000..836e88c627 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-non-const-variable/main.tf @@ -0,0 +1,7 @@ +variable "module_name" { + type = string +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/main.tf new file mode 100644 index 0000000000..98de3a2672 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/alternate/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/alternate/empty.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable-default/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-variable/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-variable/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/main.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/test.tfvars b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/test.tfvars new file mode 100644 index 0000000000..ae980f90f1 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-varsfile/test.tfvars @@ -0,0 +1 @@ +module_name = "example" diff --git a/internal/configs/testdata/error-files/module-invalid-registry-source-with-module.tf b/internal/command/testdata/dynamic-module-sources/local-source-with-version/main.tf similarity index 50% rename from internal/configs/testdata/error-files/module-invalid-registry-source-with-module.tf rename to internal/command/testdata/dynamic-module-sources/local-source-with-version/main.tf index 0029be8f4a..6ff0bcd606 100644 --- a/internal/configs/testdata/error-files/module-invalid-registry-source-with-module.tf +++ b/internal/command/testdata/dynamic-module-sources/local-source-with-version/main.tf @@ -1,5 +1,5 @@ module "test" { - source = "---.com/HashiCorp/Consul/aws" # ERROR: Invalid registry module source address + source = "../boop" version = "1.0.0" # Makes Terraform assume "source" is a module address } diff --git a/internal/command/testdata/dynamic-module-sources/module-with-count/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-count/main.tf new file mode 100644 index 0000000000..ccec44c408 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-count/main.tf @@ -0,0 +1,10 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" + count = 2 + number = count.index +} diff --git a/internal/command/testdata/dynamic-module-sources/module-with-count/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-count/modules/example/main.tf new file mode 100644 index 0000000000..1855478473 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-count/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_instance" "example" { + ami = "bar" +} + +variable "number" { + type = number +} diff --git a/internal/command/testdata/dynamic-module-sources/module-with-for-each/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-for-each/main.tf new file mode 100644 index 0000000000..d50cd6e85b --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-for-each/main.tf @@ -0,0 +1,10 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" + for_each = toset(["a", "b"]) + letter = each.value +} diff --git a/internal/command/testdata/dynamic-module-sources/module-with-for-each/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/module-with-for-each/modules/example/main.tf new file mode 100644 index 0000000000..9dbbae2569 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/module-with-for-each/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_instance" "example" { + ami = "bar" +} + +variable "letter" { + type = string +} diff --git a/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/main.tf b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/main.tf new file mode 100644 index 0000000000..b0db6a9f5e --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/main.tf @@ -0,0 +1,9 @@ +variable "child_name" { + type = string + const = true +} + +module "parent" { + source = "./modules/parent" + child_name = var.child_name +} diff --git a/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/child/main.tf b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/child/main.tf new file mode 100644 index 0000000000..048ba194cc --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/child/main.tf @@ -0,0 +1 @@ +# Empty child module used by dynamic-module-sources tests diff --git a/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/parent/main.tf b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/parent/main.tf new file mode 100644 index 0000000000..6a81115838 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/nested-module-with-variable-source/modules/parent/main.tf @@ -0,0 +1,8 @@ +variable "child_name" { + type = string + const = true +} + +module "child" { + source = "../${var.child_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/main.tf new file mode 100644 index 0000000000..855ccd0aef --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/main.tf @@ -0,0 +1,3 @@ +module "example" { + source = "${path.module}/modules/example" +} diff --git a/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/modules/example/empty.tf b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/modules/example/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/path-attr-in-module-source/modules/example/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/main.tf b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/main.tf new file mode 100644 index 0000000000..9b60651f81 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "example" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/modules/example/main.tf new file mode 100644 index 0000000000..11bc1e75a5 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-dynamic-source/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "example" { + ami = "bar" +} diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/child/empty.tf b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/child/empty.tf new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/child/empty.tf @@ -0,0 +1 @@ + diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/modules.json b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/modules.json new file mode 100644 index 0000000000..6b52e103bc --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/.terraform/modules/modules.json @@ -0,0 +1,15 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "" + }, + { + "Key": "child", + "Source": "hashicorp/module-installer-acctest/aws", + "Version": "0.0.1", + "Dir": ".terraform/modules/child" + } + ] +} diff --git a/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/main.tf b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/main.tf new file mode 100644 index 0000000000..766cf9987f --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/plan-with-version-mismatch/main.tf @@ -0,0 +1,15 @@ +# This fixture tests that plan detects a version mismatch when the dynamic +# version constraint changes between init and plan. +# +# The pre-populated .terraform/modules/modules.json records version 0.0.1 +# but the configuration requires a version determined by the const variable. + +variable "module_version" { + type = string + const = true +} + +module "child" { + source = "hashicorp/module-installer-acctest/aws" + version = var.module_version +} diff --git a/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/main.tf b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/main.tf new file mode 100644 index 0000000000..355f9c1001 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/main.tf @@ -0,0 +1,7 @@ +module "example" { + source = "./modules/example" +} + +module "example2" { + source = "./modules/${module.example.name}" +} diff --git a/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/modules/example/main.tf b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/modules/example/main.tf new file mode 100644 index 0000000000..a3e6a00391 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/source-with-module-output-reference/modules/example/main.tf @@ -0,0 +1,3 @@ +output "name" { + value = "example" +} diff --git a/internal/command/testdata/dynamic-module-sources/source-with-resource-reference/main.tf b/internal/command/testdata/dynamic-module-sources/source-with-resource-reference/main.tf new file mode 100644 index 0000000000..113df8bd34 --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/source-with-resource-reference/main.tf @@ -0,0 +1,5 @@ +resource "test_instance" "example" {} + +module "example" { + source = "./modules/${test_instance.example.id}" +} diff --git a/internal/command/testdata/dynamic-module-sources/terraform-attr-in-module-source/main.tf b/internal/command/testdata/dynamic-module-sources/terraform-attr-in-module-source/main.tf new file mode 100644 index 0000000000..8757a3094e --- /dev/null +++ b/internal/command/testdata/dynamic-module-sources/terraform-attr-in-module-source/main.tf @@ -0,0 +1,3 @@ +module "example" { + source = "./modules/${terraform.workspace}" +} diff --git a/internal/command/testdata/init-get/output.jsonlog b/internal/command/testdata/init-get/output.jsonlog index 88acf532fd..1fe0bb396e 100644 --- a/internal/command/testdata/init-get/output.jsonlog +++ b/internal/command/testdata/init-get/output.jsonlog @@ -1,7 +1,7 @@ {"@level":"info","@message":"Terraform 1.9.0-dev","@module":"terraform.ui","terraform":"1.9.0-dev","type":"version","ui":"1.2"} -{"@level":"info","@message":"Initializing the backend...","@module":"terraform.ui","message_code": "initializing_backend_message","type":"init_output"} {"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","message_code": "initializing_modules_message","type":"init_output"} {"@level":"info","@message":"- foo in foo","@module":"terraform.ui","type":"log"} -{"@level":"info","@message":"Initializing provider plugins...","@module":"terraform.ui","message_code": "initializing_provider_plugin_message","type":"init_output"} +{"@level":"info","@message":"Initializing provider plugins found in the configuration...","@module":"terraform.ui","message_code": "initializing_provider_plugin_from_config_message","type":"init_output"} +{"@level":"info","@message":"Initializing the backend...","@module":"terraform.ui","message_code": "initializing_backend_message","type":"init_output"} {"@level":"info","@message":"Terraform has been successfully initialized!","@module":"terraform.ui","message_code": "output_init_success_message","type":"init_output"} {"@level":"info","@message":"You may now begin working with Terraform. Try running \"terraform plan\" to see\nany changes that are required for your infrastructure. All Terraform commands\nshould now work.\n\nIf you ever set or change modules or backend configuration for Terraform,\nrerun this command to reinitialize your working directory. If you forget, other\ncommands will detect it and remind you to do so if necessary.","@module":"terraform.ui","message_code": "output_init_success_cli_message","type":"init_output"} diff --git a/internal/command/testdata/test/dynamic_source_missing_var/main.tf b/internal/command/testdata/test/dynamic_source_missing_var/main.tf new file mode 100644 index 0000000000..fb85fc0265 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_missing_var/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_missing_var/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_missing_var/main.tftest.hcl new file mode 100644 index 0000000000..ba8473960a --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_missing_var/main.tftest.hcl @@ -0,0 +1,6 @@ +run "should_not_reach" { + assert { + condition = true + error_message = "should not reach this point" + } +} diff --git a/internal/command/testdata/test/dynamic_source_missing_var/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_missing_var/modules/example/main.tf new file mode 100644 index 0000000000..41cc84e5c4 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_missing_var/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_resource" "foo" { + value = "bar" +} diff --git a/internal/command/testdata/test/dynamic_source_nested/main.tf b/internal/command/testdata/test/dynamic_source_nested/main.tf new file mode 100644 index 0000000000..b0db6a9f5e --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/main.tf @@ -0,0 +1,9 @@ +variable "child_name" { + type = string + const = true +} + +module "parent" { + source = "./modules/parent" + child_name = var.child_name +} diff --git a/internal/command/testdata/test/dynamic_source_nested/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_nested/main.tftest.hcl new file mode 100644 index 0000000000..2ff5ea44ad --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_nested_dynamic_module" { + assert { + condition = module.parent.value == "from_child" + error_message = "expected from_child from nested dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_nested/modules/child/main.tf b/internal/command/testdata/test/dynamic_source_nested/modules/child/main.tf new file mode 100644 index 0000000000..eb337b779b --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/modules/child/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "from_child" +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_nested/modules/parent/main.tf b/internal/command/testdata/test/dynamic_source_nested/modules/parent/main.tf new file mode 100644 index 0000000000..a74e631dba --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nested/modules/parent/main.tf @@ -0,0 +1,12 @@ +variable "child_name" { + type = string + const = true +} + +module "child" { + source = "../${var.child_name}" +} + +output "value" { + value = module.child.value +} diff --git a/internal/command/testdata/test/dynamic_source_non_const_var/main.tf b/internal/command/testdata/test/dynamic_source_non_const_var/main.tf new file mode 100644 index 0000000000..17affcc60b --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_non_const_var/main.tf @@ -0,0 +1,7 @@ +variable "module_name" { + type = string +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_non_const_var/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_non_const_var/main.tftest.hcl new file mode 100644 index 0000000000..ba8473960a --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_non_const_var/main.tftest.hcl @@ -0,0 +1,6 @@ +run "should_not_reach" { + assert { + condition = true + error_message = "should not reach this point" + } +} diff --git a/internal/command/testdata/test/dynamic_source_non_const_var/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_non_const_var/modules/example/main.tf new file mode 100644 index 0000000000..41cc84e5c4 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_non_const_var/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_resource" "foo" { + value = "bar" +} diff --git a/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tf b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tf new file mode 100644 index 0000000000..ef5ee0319f --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "nonexistent" +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tftest.hcl new file mode 100644 index 0000000000..ba8473960a --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nonexistent_module/main.tftest.hcl @@ -0,0 +1,6 @@ +run "should_not_reach" { + assert { + condition = true + error_message = "should not reach this point" + } +} diff --git a/internal/command/testdata/test/dynamic_source_nonexistent_module/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_nonexistent_module/modules/example/main.tf new file mode 100644 index 0000000000..41cc84e5c4 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_nonexistent_module/modules/example/main.tf @@ -0,0 +1,3 @@ +resource "test_resource" "foo" { + value = "bar" +} diff --git a/internal/command/testdata/test/dynamic_source_with_default/main.tf b/internal/command/testdata/test/dynamic_source_with_default/main.tf new file mode 100644 index 0000000000..b64283129e --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_default/main.tf @@ -0,0 +1,9 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_with_default/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_default/main.tftest.hcl new file mode 100644 index 0000000000..4adb0d7ecd --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_default/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_dynamic_module" { + assert { + condition = module.mod.value == "bar" + error_message = "expected bar from dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_default/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_default/modules/example/main.tf new file mode 100644 index 0000000000..7354f944a1 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_default/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "bar" +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_local_value/main.tf b/internal/command/testdata/test/dynamic_source_with_local_value/main.tf new file mode 100644 index 0000000000..cda19b736e --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_local_value/main.tf @@ -0,0 +1,12 @@ +variable "module_name" { + type = string + const = true +} + +locals { + module_source = "./modules/${var.module_name}" +} + +module "mod" { + source = local.module_source +} diff --git a/internal/command/testdata/test/dynamic_source_with_local_value/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_local_value/main.tftest.hcl new file mode 100644 index 0000000000..4adb0d7ecd --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_local_value/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_dynamic_module" { + assert { + condition = module.mod.value == "bar" + error_message = "expected bar from dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_local_value/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_local_value/modules/example/main.tf new file mode 100644 index 0000000000..7354f944a1 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_local_value/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "bar" +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/main.tf b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tf new file mode 100644 index 0000000000..26eff094a2 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tf @@ -0,0 +1,14 @@ +variable "module_name" { + type = string + const = true + default = "example" +} + +variable "managed_id" { + type = string +} + +module "mod" { + source = "./modules/${var.module_name}" + id = var.managed_id +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tftest.hcl new file mode 100644 index 0000000000..8789e32183 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/main.tftest.hcl @@ -0,0 +1,21 @@ +variables { + managed_id = "B853C121" +} + +run "setup" { + module { + source = "./setup" + } + + variables { + value = "Hello, world!" + id = "B853C121" + } +} + +run "test" { + assert { + condition = module.mod.value == "Hello, world!" + error_message = "expected value from setup module via dynamic source" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_setup_module/modules/example/main.tf new file mode 100644 index 0000000000..2416f28ddc --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/modules/example/main.tf @@ -0,0 +1,15 @@ +variable "id" { + type = string +} + +data "test_data_source" "managed_data" { + id = var.id +} + +resource "test_resource" "foo" { + value = data.test_data_source.managed_data.value +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_setup_module/setup/main.tf b/internal/command/testdata/test/dynamic_source_with_setup_module/setup/main.tf new file mode 100644 index 0000000000..b4e5a75aba --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_setup_module/setup/main.tf @@ -0,0 +1,13 @@ +variable "value" { + type = string +} + +variable "id" { + type = string +} + +resource "test_resource" "managed" { + provider = setup + id = var.id + value = var.value +} diff --git a/internal/command/testdata/test/dynamic_source_with_var_flag/main.tf b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tf new file mode 100644 index 0000000000..fb85fc0265 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tf @@ -0,0 +1,8 @@ +variable "module_name" { + type = string + const = true +} + +module "mod" { + source = "./modules/${var.module_name}" +} diff --git a/internal/command/testdata/test/dynamic_source_with_var_flag/main.tftest.hcl b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tftest.hcl new file mode 100644 index 0000000000..4adb0d7ecd --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_var_flag/main.tftest.hcl @@ -0,0 +1,6 @@ +run "validate_dynamic_module" { + assert { + condition = module.mod.value == "bar" + error_message = "expected bar from dynamically sourced module" + } +} diff --git a/internal/command/testdata/test/dynamic_source_with_var_flag/modules/example/main.tf b/internal/command/testdata/test/dynamic_source_with_var_flag/modules/example/main.tf new file mode 100644 index 0000000000..7354f944a1 --- /dev/null +++ b/internal/command/testdata/test/dynamic_source_with_var_flag/modules/example/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "bar" +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/validate-invalid/incorrectmodulename/output.json b/internal/command/testdata/validate-invalid/incorrectmodulename/output.json index f144313fa4..d167b1b142 100644 --- a/internal/command/testdata/validate-invalid/incorrectmodulename/output.json +++ b/internal/command/testdata/validate-invalid/incorrectmodulename/output.json @@ -1,7 +1,7 @@ { "format_version": "1.0", "valid": false, - "error_count": 4, + "error_count": 2, "warning_count": 0, "diagnostics": [ { @@ -55,58 +55,6 @@ "highlight_end_offset": 21, "values": [] } - }, - { - "severity": "error", - "summary": "Variables not allowed", - "detail": "Variables may not be used here.", - "range": { - "filename": "testdata/validate-invalid/incorrectmodulename/main.tf", - "start": { - "line": 5, - "column": 12, - "byte": 55 - }, - "end": { - "line": 5, - "column": 15, - "byte": 58 - } - }, - "snippet": { - "context": "module \"super\"", - "code": " source = var.modulename", - "start_line": 5, - "highlight_start_offset": 11, - "highlight_end_offset": 14, - "values": [] - } - }, - { - "severity": "error", - "summary": "Unsuitable value type", - "detail": "Unsuitable value: value must be known", - "range": { - "filename": "testdata/validate-invalid/incorrectmodulename/main.tf", - "start": { - "line": 5, - "column": 12, - "byte": 55 - }, - "end": { - "line": 5, - "column": 26, - "byte": 69 - } - }, - "snippet": { - "context": "module \"super\"", - "code": " source = var.modulename", - "start_line": 5, - "highlight_start_offset": 11, - "highlight_end_offset": 25, - "values": [] - } } ] } diff --git a/internal/command/validate.go b/internal/command/validate.go index 952abb3fb2..6a49ec2f55 100644 --- a/internal/command/validate.go +++ b/internal/command/validate.go @@ -47,6 +47,26 @@ func (c *ValidateCommand) Run(rawArgs []string) int { c.ParsedArgs = args view := views.NewValidate(args.ViewType, c.View) + // If the query flag is set, include query files in the validation. + c.includeQueryFiles = c.ParsedArgs.Query + + loader, err := c.initConfigLoader() + if err != nil { + diags = diags.Append(err) + view.Diagnostics(diags) + return 1 + } + + var varDiags tfdiags.Diagnostics + c.VariableValues, varDiags = args.Vars.CollectValues(func(filename string, src []byte) { + loader.Parser().ForceFileSource(filename, src) + }) + diags = diags.Append(varDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + // After this point, we must only produce JSON output if JSON mode is // enabled, so all errors should be accumulated into diags and we'll // print out a suitable result at the end, depending on the format @@ -81,8 +101,10 @@ func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics { var diags tfdiags.Diagnostics var cfg *configs.Config - // If the query flag is set, include query files in the validation. - c.includeQueryFiles = c.ParsedArgs.Query + diags = diags.Append(c.resolveConstVariables(dir, c.ParsedArgs.ViewType)) + if diags.HasErrors() { + return diags + } if c.ParsedArgs.NoTests { cfg, diags = c.loadConfig(dir) @@ -360,9 +382,19 @@ Options: -no-tests If specified, Terraform will not validate test files. - -test-directory=path Set the Terraform test directory, defaults to "tests". - + -test-directory=path Set the Terraform test directory, defaults to "tests". + -query If specified, the command will also validate .tfquery.hcl files. + + -var 'foo=bar' Set a value for one of the input variables in the root + module of the configuration. Use this option more than + once to set more than one variable. + + -var-file=filename Load variable values from the given file, in addition + to the default files terraform.tfvars and *.auto.tfvars. + Use this option more than once to include more than one + variables file. + ` return strings.TrimSpace(helpText) } diff --git a/internal/command/validate_test.go b/internal/command/validate_test.go index 9270066ee9..3e4ef667e9 100644 --- a/internal/command/validate_test.go +++ b/internal/command/validate_test.go @@ -5,7 +5,7 @@ package command import ( "encoding/json" - "io/ioutil" + "io" "os" "path" "strings" @@ -190,10 +190,6 @@ func TestModuleWithIncorrectNameShouldFail(t *testing.T) { if !strings.Contains(output.Stderr(), wantError) { t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } - wantError = `Error: Variables not allowed` - if !strings.Contains(output.Stderr(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) - } } func TestWronglyUsedInterpolationShouldFail(t *testing.T) { @@ -406,14 +402,14 @@ func TestValidate_json(t *testing.T) { for _, tc := range tests { t.Run(tc.path, func(t *testing.T) { - var want, got map[string]interface{} + var want, got map[string]any wantFile, err := os.Open(path.Join(testFixturePath(tc.path), "output.json")) if err != nil { t.Fatalf("failed to open output file: %s", err) } defer wantFile.Close() - wantBytes, err := ioutil.ReadAll(wantFile) + wantBytes, err := io.ReadAll(wantFile) if err != nil { t.Fatalf("failed to read output file: %s", err) } diff --git a/internal/command/views/init.go b/internal/command/views/init.go index f051425d48..1789c9b645 100644 --- a/internal/command/views/init.go +++ b/internal/command/views/init.go @@ -225,7 +225,7 @@ var MessageRegistry map[InitMessageCode]InitMessage = map[InitMessageCode]InitMe }, "reusing_version_during_state_provider_init": { HumanValue: "- Reusing previous version of %s", - JSONValue: "%s: Reusing previous version of %s", + JSONValue: "Reusing previous version of %s", }, "finding_matching_version_message": { HumanValue: "- Finding %s versions matching %q...", @@ -331,32 +331,27 @@ const ( // Following message codes are used and documented EXTERNALLY // Keep docs/internals/machine-readable-ui.mdx up to date with // this list when making changes here. - CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" - EmptyMessage InitMessageCode = "empty_message" - OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" - OutputInitSuccessMessage InitMessageCode = "output_init_success_message" - OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" - OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" - OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" - UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" - InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" - InitializingModulesMessage InitMessageCode = "initializing_modules_message" - InitializingBackendMessage InitMessageCode = "initializing_backend_message" - InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" - DefaultWorkspaceCreatedMessage InitMessageCode = "default_workspace_created_message" - InitializingProviderPluginMessage InitMessageCode = "initializing_provider_plugin_message" - LockInfo InitMessageCode = "lock_info" - DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" + CopyingConfigurationMessage InitMessageCode = "copying_configuration_message" + EmptyMessage InitMessageCode = "empty_message" + OutputInitEmptyMessage InitMessageCode = "output_init_empty_message" + OutputInitSuccessMessage InitMessageCode = "output_init_success_message" + OutputInitSuccessCloudMessage InitMessageCode = "output_init_success_cloud_message" + OutputInitSuccessCLIMessage InitMessageCode = "output_init_success_cli_message" + OutputInitSuccessCLICloudMessage InitMessageCode = "output_init_success_cli_cloud_message" + UpgradingModulesMessage InitMessageCode = "upgrading_modules_message" + InitializingTerraformCloudMessage InitMessageCode = "initializing_terraform_cloud_message" + InitializingModulesMessage InitMessageCode = "initializing_modules_message" + InitializingBackendMessage InitMessageCode = "initializing_backend_message" + InitializingStateStoreMessage InitMessageCode = "initializing_state_store_message" + InitializingProviderPluginFromConfigMessage InitMessageCode = "initializing_provider_plugin_from_config_message" + InitializingProviderPluginFromStateMessage InitMessageCode = "initializing_provider_plugin_from_state_message" + ReusingVersionIdentifiedFromConfig InitMessageCode = "reusing_version_during_state_provider_init" + DefaultWorkspaceCreatedMessage InitMessageCode = "default_workspace_created_message" + LockInfo InitMessageCode = "lock_info" + DependenciesLockChangesInfo InitMessageCode = "dependencies_lock_changes_info" //// Message codes below are ONLY used INTERNALLY (for now) - // InitializingProviderPluginFromConfigMessage indicates the beginning of installing of providers described in configuration - InitializingProviderPluginFromConfigMessage InitMessageCode = "initializing_provider_plugin_from_config_message" - // InitializingProviderPluginFromStateMessage indicates the beginning of installing of providers described in state - InitializingProviderPluginFromStateMessage InitMessageCode = "initializing_provider_plugin_from_state_message" - // DependenciesLockPendingChangesInfo indicates when a provider installation step will reuse a provider from a previous installation step in the current operation - ReusingVersionIdentifiedFromConfig InitMessageCode = "reusing_version_during_state_provider_init" - // InitConfigError indicates problems encountered during initialisation InitConfigError InitMessageCode = "init_config_error" // BackendConfiguredSuccessMessage indicates successful backend configuration diff --git a/internal/command/views/init_test.go b/internal/command/views/init_test.go index 4382564f8a..13847a5068 100644 --- a/internal/command/views/init_test.go +++ b/internal/command/views/init_test.go @@ -129,10 +129,10 @@ func TestNewInit_jsonViewOutput(t *testing.T) { t.Fatalf("unexpected return type %t", newInit) } - newInit.Output(InitializingProviderPluginMessage) + newInit.Output(InitializingProviderPluginFromConfigMessage) version := tfversion.String() - want := []map[string]interface{}{ + want := []map[string]any{ { "@level": "info", "@message": fmt.Sprintf("Terraform %s", version), @@ -143,8 +143,8 @@ func TestNewInit_jsonViewOutput(t *testing.T) { }, { "@level": "info", - "@message": "Initializing provider plugins...", - "message_code": "initializing_provider_plugin_message", + "@message": "Initializing provider plugins found in the configuration...", + "message_code": "initializing_provider_plugin_from_config_message", "@module": "terraform.ui", "type": "init_output", }, @@ -231,7 +231,7 @@ func TestNewInit_jsonViewLog(t *testing.T) { t.Fatalf("unexpected return type %t", newInit) } - newInit.LogInitMessage(InitializingProviderPluginMessage) + newInit.LogInitMessage(InitializingProviderPluginFromConfigMessage) version := tfversion.String() want := []map[string]interface{}{ @@ -245,7 +245,7 @@ func TestNewInit_jsonViewLog(t *testing.T) { }, { "@level": "info", - "@message": "Initializing provider plugins...", + "@message": "Initializing provider plugins found in the configuration...", "@module": "terraform.ui", "type": "log", }, @@ -282,10 +282,10 @@ func TestNewInit_humanViewOutput(t *testing.T) { t.Fatalf("unexpected return type %t", newInit) } - newInit.Output(InitializingProviderPluginMessage) + newInit.Output(InitializingProviderPluginFromConfigMessage) actual := done(t).All() - expected := "Initializing provider plugins..." + expected := "Initializing provider plugins found in the configuration..." if !strings.Contains(actual, expected) { t.Fatalf("expected output to contain: %s, but got %s", expected, actual) } diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 7bb9e96816..d9a9dc59e1 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -15,6 +15,7 @@ const ( MessageResourceDrift MessageType = "resource_drift" MessagePlannedChange MessageType = "planned_change" MessagePlannedActionInvocation MessageType = "planned_action_invocation" + MessageAppliedActionInvocation MessageType = "applied_action_invocation" MessageChangeSummary MessageType = "change_summary" MessageOutputs MessageType = "outputs" diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index f7d1a2bba2..c3b88526f0 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -17,7 +17,7 @@ import ( // This version describes the schema of JSON UI messages. This version must be // updated after making any changes to this view, the jsonHook, or any of the // command/views/json package. -const JSON_UI_VERSION = "1.2" +const JSON_UI_VERSION = "1.3" func NewJSONView(view *View) *JSONView { log := hclog.New(&hclog.LoggerOptions{ @@ -103,6 +103,14 @@ func (v *JSONView) PlannedActionInvocation(action *json.ActionInvocation) { ) } +func (v *JSONView) AppliedActionInvocation(action *json.ActionInvocation) { + v.log.Info( + fmt.Sprintf("applied action invocation: %s", action.Action.Action), + "type", json.MessageAppliedActionInvocation, + "invocation", action, + ) +} + func (v *JSONView) ResourceDrift(c *json.ResourceInstanceChange) { v.log.Info( fmt.Sprintf("%s: Drift detected (%s)", c.Resource.Addr, c.Action), diff --git a/internal/command/views/show_test.go b/internal/command/views/show_test.go index bc3ecb8949..1b2bee9ad4 100644 --- a/internal/command/views/show_test.go +++ b/internal/command/views/show_test.go @@ -13,13 +13,13 @@ import ( "github.com/hashicorp/terraform/internal/cloud/cloudplan" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/configs/configschema" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" "github.com/zclconf/go-cty/cty" ) @@ -169,7 +169,7 @@ func TestShowJSON(t *testing.T) { }, } - config, _, configCleanup := initwd.MustLoadConfigForTests(t, "./testdata/show", "tests") + config, _, configCleanup := tftesting.MustLoadConfigForTests(t, "./testdata/show", "tests") defer configCleanup() for name, testCase := range testCases { diff --git a/internal/command/workspace_command_test.go b/internal/command/workspace_command_test.go index ca90168399..55c02751de 100644 --- a/internal/command/workspace_command_test.go +++ b/internal/command/workspace_command_test.go @@ -31,6 +31,10 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { mock := testStateStoreMockWithChunkNegotiation(t, 1000) + // Mock that a custom workspace already exists. + preExistingState := "pre-existing" + mock.MockStates = map[string]interface{}{preExistingState: true} + // Assumes the mocked provider is hashicorp/test providerSource, close := newMockProviderSource(t, map[string][]string{ "hashicorp/test": {"1.2.3"}, @@ -60,9 +64,9 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { if code != 0 { t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) } - // We expect a state to have been created for the default workspace - if _, ok := mock.MockStates["default"]; !ok { - t.Fatal("expected the default workspace to exist, but it didn't") + // We expect a state to have not been created for the default workspace + if _, ok := mock.MockStates["default"]; ok { + t.Fatal("expected the default workspace to not exist, but it did") } //// Create Workspace @@ -73,9 +77,12 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { Meta: meta, } - current, _ := newCmd.Workspace() - if current != backend.DefaultStateName { - t.Fatal("before creating any custom workspaces, the current workspace should be 'default'") + current, err := newCmd.Workspace() + if err != nil { + t.Fatal(err) + } + if current != preExistingState { + t.Fatalf("before creating any custom workspaces, the current workspace should be %q, got: %q", preExistingState, current) } args = []string{newWorkspace} @@ -117,7 +124,7 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { selCmd := &WorkspaceSelectCommand{ Meta: meta, } - selectedWorkspace := backend.DefaultStateName + selectedWorkspace := preExistingState args = []string{selectedWorkspace} code = selCmd.Run(args) if code != 0 { @@ -145,8 +152,8 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { } current, _ = newCmd.Workspace() - if current != backend.DefaultStateName { - t.Fatal("current workspace should be 'default'") + if current != preExistingState { + t.Fatalf("current workspace should be %q, got %q", preExistingState, current) } //// Delete Workspace diff --git a/internal/configs/config.go b/internal/configs/config.go index 150af76314..a258f94173 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -87,6 +87,13 @@ type Config struct { // This field is meaningless for the root module, where it will always // be nil. Version *version.Version + + // VersionConstraint is the version constraint that was specified for this module. + // This field is nil if no version constraint was specified. + // + // This field is meaningless for the root module, where it will always + // be nil. + VersionConstraint VersionConstraint } // ModuleRequirements represents the provider requirements for an individual diff --git a/internal/configs/config_build.go b/internal/configs/config_build.go index 28e522d226..84abf6df3b 100644 --- a/internal/configs/config_build.go +++ b/internal/configs/config_build.go @@ -12,8 +12,10 @@ import ( version "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" ) // BuildConfig constructs a Config from a root module by loading all of its @@ -32,6 +34,20 @@ func BuildConfig(root *Module, walker ModuleWalker, loader MockDataLoader) (*Con } cfg.Root = cfg // Root module is self-referential. cfg.Children, diags = buildChildModules(cfg, walker) + diags = append(diags, FinalizeConfig(cfg, walker, loader)...) + + return cfg, diags +} + +// FinalizeConfig performs the post-load validation and setup steps that are +// shared by different configuration loaders. +// +// Callers must ensure cfg.Root is set correctly before calling this function. +func FinalizeConfig(cfg *Config, walker ModuleWalker, loader MockDataLoader) hcl.Diagnostics { + var diags hcl.Diagnostics + if cfg == nil { + return diags + } diags = append(diags, buildTestModules(cfg, walker)...) // Skip provider resolution if there are any errors, since the provider @@ -42,7 +58,7 @@ func BuildConfig(root *Module, walker ModuleWalker, loader MockDataLoader) (*Con providers := cfg.resolveProviderTypes() cfg.resolveProviderTypesForTests(providers) - if cfg.Module.StateStore != nil { + if cfg.Module != nil && cfg.Module.StateStore != nil { stateProviderDiags := cfg.resolveStateStoreProviderType() diags = append(diags, stateProviderDiags...) } @@ -54,7 +70,7 @@ func BuildConfig(root *Module, walker ModuleWalker, loader MockDataLoader) (*Con // Final step, let's side load any external mock data into our test files. diags = append(diags, installMockDataFiles(cfg, loader)...) - return cfg, diags + return diags } func installMockDataFiles(root *Config, loader MockDataLoader) hcl.Diagnostics { @@ -148,6 +164,108 @@ func buildTestModules(root *Config, walker ModuleWalker) hcl.Diagnostics { return diags } +// legacySourceHelper is used to decode module sources from the old-style +// string-only "source". It assumes that the expression does not contain any +// references and can be decoded without an evaluation context. +// In the long term, we want to get rid of this helper method. +func legacySourceHelper(expr hcl.Expression, haveVersionArg bool) (addrs.ModuleSource, hcl.Diagnostics) { + var diags hcl.Diagnostics + var sourceAddrRaw string + var addr addrs.ModuleSource + + valDiags := gohcl.DecodeExpression(expr, nil, &sourceAddrRaw) + diags = append(diags, valDiags...) + if !valDiags.HasErrors() { + var err error + if haveVersionArg { + addr, err = moduleaddrs.ParseModuleSourceRegistry(sourceAddrRaw) + } else { + addr, err = moduleaddrs.ParseModuleSource(sourceAddrRaw) + } + if err != nil { + // NOTE: We leave addr as nil for any situation where the + // source attribute is invalid, so any code which tries to carefully + // use the partial result of a failed config decode must be + // resilient to that. + addr = nil + + // NOTE: In practice it's actually very unlikely to end up here, + // because our source address parser can turn just about any string + // into some sort of remote package address, and so for most errors + // we'll detect them only during module installation. There are + // still a _few_ purely-syntax errors we can catch at parsing time, + // though, mostly related to remote package sub-paths and local + // paths. + switch err := err.(type) { + case *moduleaddrs.MaybeRelativePathErr: + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf( + "Terraform failed to determine your intended installation method for remote module package %q.\n\nIf you intended this as a path relative to the current module, use \"./%s\" instead. The \"./\" prefix indicates that the address is a relative filesystem path.", + err.Addr, err.Addr, + ), + Subject: expr.Range().Ptr(), + }) + default: + if haveVersionArg { + // In this case we'll include some extra context that + // we assumed a registry source address due to the + // version argument. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid registry module source address", + Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err), + Subject: expr.Range().Ptr(), + }) + } else { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf("Failed to parse module source address: %s.", err), + Subject: expr.Range().Ptr(), + }) + } + } + } + } + + return addr, diags +} + +// legacyVersionHelper is used to decode version constraints from the old-style +// string-only "version". It assumes that the expression does not contain any +// references and can be decoded without an evaluation context. +// In the long term, we want to get rid of this helper method. +func legacyVersionHelper(expr hcl.Expression) (VersionConstraint, hcl.Diagnostics) { + var diags hcl.Diagnostics + var versionRaw string + + ret := VersionConstraint{ + DeclRange: expr.Range(), + } + + valDiags := gohcl.DecodeExpression(expr, nil, &versionRaw) + diags = append(diags, valDiags...) + if !valDiags.HasErrors() { + constraints, err := version.NewConstraint(versionRaw) + if err != nil { + // NewConstraint doesn't return user-friendly errors, so we'll just + // ignore the provided error and produce our own generic one. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version constraint", + Detail: "This string does not use correct version constraint syntax.", // Not very actionable :( + Subject: expr.Range().Ptr(), + }) + return ret, diags + } + ret.Required = constraints + } + + return ret, diags +} + func buildChildModules(parent *Config, walker ModuleWalker) (map[string]*Config, hcl.Diagnostics) { var diags hcl.Diagnostics ret := map[string]*Config{} @@ -161,12 +279,28 @@ func buildChildModules(parent *Config, walker ModuleWalker) (map[string]*Config, path := slices.Clone(parent.Path) path = append(path, call.Name) + sourceAddr, sourceDiags := legacySourceHelper(call.SourceExpr, call.VersionExpr != nil) + diags = append(diags, sourceDiags...) + if sourceDiags.HasErrors() { + continue + } + + var versionConstraint VersionConstraint + if call.VersionExpr != nil { + var versionDiags hcl.Diagnostics + versionConstraint, versionDiags = legacyVersionHelper(call.VersionExpr) + diags = append(diags, versionDiags...) + if versionDiags.HasErrors() { + continue + } + } + req := ModuleRequest{ Name: call.Name, Path: path, - SourceAddr: call.SourceAddr, - SourceAddrRange: call.SourceAddrRange, - VersionConstraint: call.Version, + SourceAddr: sourceAddr, + SourceAddrRange: call.SourceExpr.Range(), + VersionConstraint: versionConstraint, Parent: parent, CallRange: call.DeclRange, } diff --git a/internal/configs/configload/loader.go b/internal/configs/configload/loader.go index 11be47b02f..06becad468 100644 --- a/internal/configs/configload/loader.go +++ b/internal/configs/configload/loader.go @@ -187,3 +187,8 @@ func (l *Loader) AllowLanguageExperiments(allowed bool) { func (l *Loader) AllowsLanguageExperiments() bool { return l.parser.AllowsLanguageExperiments() } + +// ModuleWalker returns a walker suitable for loading already-installed modules. +func (l *Loader) ModuleWalker() configs.ModuleWalker { + return configs.ModuleWalkerFunc(l.moduleWalkerLoad) +} diff --git a/internal/configs/configload/loader_load.go b/internal/configs/configload/loader_load.go index 236575dcd2..f27ed0d94d 100644 --- a/internal/configs/configload/loader_load.go +++ b/internal/configs/configload/loader_load.go @@ -12,41 +12,14 @@ import ( "github.com/hashicorp/terraform/internal/configs" ) -// LoadConfig reads the Terraform module in the given directory and uses it as the -// root module to build the static module tree that represents a configuration, -// assuming that all required descendant modules have already been installed. -// -// If error diagnostics are returned, the returned configuration may be either -// nil or incomplete. In the latter case, cautious static analysis is possible -// in spite of the errors. -// -// LoadConfig performs the basic syntax and uniqueness validations that are -// required to process the individual modules -func (l *Loader) LoadConfig(rootDir string) (*configs.Config, hcl.Diagnostics) { - return l.loadConfig(l.parser.LoadConfigDir(rootDir, l.parserOpts...)) +// LoadRootModule reads the root module using the loader's parser options. +func (l *Loader) LoadRootModule(rootDir string) (*configs.Module, hcl.Diagnostics) { + return l.parser.LoadConfigDir(rootDir, l.parserOpts...) } -// LoadConfigWithTests matches LoadConfig, except the configs.Config contains -// any relevant .tftest.hcl files. -func (l *Loader) LoadConfigWithTests(rootDir string, testDir string) (*configs.Config, hcl.Diagnostics) { - return l.loadConfig(l.parser.LoadConfigDir(rootDir, append(l.parserOpts, configs.MatchTestFiles(testDir))...)) -} - -func (l *Loader) loadConfig(rootMod *configs.Module, diags hcl.Diagnostics) (*configs.Config, hcl.Diagnostics) { - if rootMod == nil || diags.HasErrors() { - // Ensure we return any parsed modules here so that required_version - // constraints can be verified even when encountering errors. - cfg := &configs.Config{ - Module: rootMod, - } - - return cfg, diags - } - - cfg, cDiags := configs.BuildConfig(rootMod, configs.ModuleWalkerFunc(l.moduleWalkerLoad), configs.MockDataLoaderFunc(l.LoadExternalMockData)) - diags = append(diags, cDiags...) - - return cfg, diags +// LoadRootModuleWithTests reads the root module and includes test files from the given directory. +func (l *Loader) LoadRootModuleWithTests(rootDir string, testDir string) (*configs.Module, hcl.Diagnostics) { + return l.parser.LoadConfigDir(rootDir, append(l.parserOpts, configs.MatchTestFiles(testDir))...) } // LoadExternalMockData reads the external mock data files for the given diff --git a/internal/configs/configload/loader_load_test.go b/internal/configs/configload/loader_load_test.go deleted file mode 100644 index 22fadb6249..0000000000 --- a/internal/configs/configload/loader_load_test.go +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package configload - -import ( - "path/filepath" - "reflect" - "sort" - "strings" - "testing" - - "github.com/davecgh/go-spew/spew" - "github.com/zclconf/go-cty/cty" - - "github.com/hashicorp/terraform/internal/configs" -) - -func TestLoaderLoadConfig_okay(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadConfig(fixtureDir) - assertNoDiagnostics(t, diags) - if cfg == nil { - t.Fatalf("config is nil; want non-nil") - } - - var gotPaths []string - cfg.DeepEach(func(c *configs.Config) { - gotPaths = append(gotPaths, strings.Join(c.Path, ".")) - }) - sort.Strings(gotPaths) - wantPaths := []string{ - "", // root module - "child_a", - "child_a.child_c", - "child_b", - "child_b.child_d", - } - - if !reflect.DeepEqual(gotPaths, wantPaths) { - t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) - } - - t.Run("child_a.child_c output", func(t *testing.T) { - output := cfg.Children["child_a"].Children["child_c"].Module.Outputs["hello"] - got, diags := output.Expr.Value(nil) - assertNoDiagnostics(t, diags) - assertResultCtyEqual(t, got, cty.StringVal("Hello from child_c")) - }) - t.Run("child_b.child_d output", func(t *testing.T) { - output := cfg.Children["child_b"].Children["child_d"].Module.Outputs["hello"] - got, diags := output.Expr.Value(nil) - assertNoDiagnostics(t, diags) - assertResultCtyEqual(t, got, cty.StringVal("Hello from child_d")) - }) -} - -func TestLoaderLoadConfig_addVersion(t *testing.T) { - // This test is for what happens when there is a version constraint added - // to a module that previously didn't have one. - fixtureDir := filepath.Clean("testdata/add-version-constraint") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, diags := loader.LoadConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatalf("success; want error") - } - got := diags.Error() - want := "Module version requirements have changed" - if !strings.Contains(got, want) { - t.Fatalf("wrong error\ngot:\n%s\n\nwant: containing %q", got, want) - } -} - -func TestLoaderLoadConfig_loadDiags(t *testing.T) { - // building a config which didn't load correctly may cause configs to panic - fixtureDir := filepath.Clean("testdata/invalid-names") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatal("success; want error") - } - - if cfg == nil { - t.Fatal("partial config not returned with diagnostics") - } - - if cfg.Module == nil { - t.Fatal("expected config module") - } -} - -func TestLoaderLoadConfig_loadDiagsFromSubmodules(t *testing.T) { - // building a config which didn't load correctly may cause configs to panic - fixtureDir := filepath.Clean("testdata/invalid-names-in-submodules") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatalf("loading succeeded; want an error") - } - if got, want := diags.Error(), " Invalid provider local name"; !strings.Contains(got, want) { - t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) - } - - if cfg == nil { - t.Fatal("partial config not returned with diagnostics") - } - - if cfg.Module == nil { - t.Fatal("expected config module") - } -} - -func TestLoaderLoadConfig_childProviderGrandchildCount(t *testing.T) { - // This test is focused on the specific situation where: - // - A child module contains a nested provider block, which is no longer - // recommended but supported for backward-compatibility. - // - A child of that child does _not_ contain a nested provider block, - // and is called with "count" (would also apply to "for_each" and - // "depends_on"). - // It isn't valid to use "count" with a module that _itself_ contains - // a provider configuration, but it _is_ valid for a module with a - // provider configuration to call another module with count. We previously - // botched this rule and so this is a regression test to cover the - // solution to that mistake: - // https://github.com/hashicorp/terraform/issues/31081 - - // Since this test is based on success rather than failure and it's - // covering a relatively large set of code where only a small part - // contributes to the test, we'll make sure to test both the success and - // failure cases here so that we'll have a better chance of noticing if a - // future change makes this succeed only because we've reorganized the code - // so that the check isn't happening at all anymore. - // - // If the "not okay" subtest fails, you should also be skeptical about - // whether the "okay" subtest is still valid, even if it happens to - // still be passing. - t.Run("okay", func(t *testing.T) { - fixtureDir := filepath.Clean("testdata/child-provider-grandchild-count") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - cfg, diags := loader.LoadConfig(fixtureDir) - assertNoDiagnostics(t, diags) - if cfg == nil { - t.Fatalf("config is nil; want non-nil") - } - - var gotPaths []string - cfg.DeepEach(func(c *configs.Config) { - gotPaths = append(gotPaths, strings.Join(c.Path, ".")) - }) - sort.Strings(gotPaths) - wantPaths := []string{ - "", // root module - "child", - "child.grandchild", - } - - if !reflect.DeepEqual(gotPaths, wantPaths) { - t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) - } - }) - t.Run("not okay", func(t *testing.T) { - fixtureDir := filepath.Clean("testdata/child-provider-child-count") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, diags := loader.LoadConfig(fixtureDir) - if !diags.HasErrors() { - t.Fatalf("loading succeeded; want an error") - } - if got, want := diags.Error(), "Module is incompatible with count, for_each, and depends_on"; !strings.Contains(got, want) { - t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) - } - }) - -} diff --git a/internal/configs/configload/loader_snapshot.go b/internal/configs/configload/loader_snapshot.go index 5388e8bb1b..ba982527c1 100644 --- a/internal/configs/configload/loader_snapshot.go +++ b/internal/configs/configload/loader_snapshot.go @@ -20,26 +20,16 @@ import ( "github.com/hashicorp/terraform/internal/modsdir" ) -// LoadConfigWithSnapshot is a variant of LoadConfig that also simultaneously -// creates an in-memory snapshot of the configuration files used, which can -// be later used to create a loader that may read only from this snapshot. -func (l *Loader) LoadConfigWithSnapshot(rootDir string) (*configs.Config, *Snapshot, hcl.Diagnostics) { - rootMod, diags := l.parser.LoadConfigDir(rootDir, l.parserOpts...) - if rootMod == nil { - return nil, nil, diags - } - +func (l *Loader) ModuleWalkerSnapshot() (configs.ModuleWalker, *Snapshot) { snap := &Snapshot{ Modules: map[string]*SnapshotModule{}, } - walker := l.makeModuleWalkerSnapshot(snap) - cfg, cDiags := configs.BuildConfig(rootMod, walker, configs.MockDataLoaderFunc(l.LoadExternalMockData)) - diags = append(diags, cDiags...) - addDiags := l.addModuleToSnapshot(snap, "", rootDir, "", nil) - diags = append(diags, addDiags...) + return l.makeModuleWalkerSnapshot(snap), snap +} - return cfg, snap, diags +func (l *Loader) AddRootModuleToSnapshot(snap *Snapshot, rootDir string) hcl.Diagnostics { + return l.addModuleToSnapshot(snap, "", rootDir, "", nil) } // NewLoaderFromSnapshot creates a Loader that reads files only from the diff --git a/internal/configs/configload/loader_snapshot_test.go b/internal/configs/configload/loader_snapshot_test.go deleted file mode 100644 index 00411ecc18..0000000000 --- a/internal/configs/configload/loader_snapshot_test.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package configload - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/davecgh/go-spew/spew" - "github.com/go-test/deep" -) - -func TestLoadConfigWithSnapshot(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, got, diags := loader.LoadConfigWithSnapshot(fixtureDir) - assertNoDiagnostics(t, diags) - if got == nil { - t.Fatalf("snapshot is nil; want non-nil") - } - - t.Log(spew.Sdump(got)) - - { - gotModuleDirs := map[string]string{} - for k, m := range got.Modules { - gotModuleDirs[k] = m.Dir - } - wantModuleDirs := map[string]string{ - "": "testdata/already-installed", - "child_a": "testdata/already-installed/.terraform/modules/child_a", - "child_a.child_c": "testdata/already-installed/.terraform/modules/child_a/child_c", - "child_b": "testdata/already-installed/.terraform/modules/child_b", - "child_b.child_d": "testdata/already-installed/.terraform/modules/child_b.child_d", - } - - problems := deep.Equal(wantModuleDirs, gotModuleDirs) - for _, problem := range problems { - t.Error(problem) - } - if len(problems) > 0 { - return - } - } - - gotRoot := got.Modules[""] - wantRoot := &SnapshotModule{ - Dir: "testdata/already-installed", - Files: map[string][]byte{ - "root.tf": []byte(` -module "child_a" { - source = "example.com/foo/bar_a/baz" - version = ">= 1.0.0" -} - -module "child_b" { - source = "example.com/foo/bar_b/baz" - version = ">= 1.0.0" -} -`), - }, - } - if !reflect.DeepEqual(gotRoot, wantRoot) { - t.Errorf("wrong root module snapshot\ngot: %swant: %s", spew.Sdump(gotRoot), spew.Sdump(wantRoot)) - } - -} - -func TestLoadConfigWithSnapshot_invalidSource(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed-now-invalid") - - old, _ := os.Getwd() - os.Chdir(fixtureDir) - defer os.Chdir(old) - - loader, err := NewLoader(&Config{ - ModulesDir: ".terraform/modules", - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, _, diags := loader.LoadConfigWithSnapshot(".") - if !diags.HasErrors() { - t.Error("LoadConfigWithSnapshot succeeded; want errors") - } -} - -func TestSnapshotRoundtrip(t *testing.T) { - fixtureDir := filepath.Clean("testdata/already-installed") - loader, err := NewLoader(&Config{ - ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), - }) - if err != nil { - t.Fatalf("unexpected error from NewLoader: %s", err) - } - - _, snap, diags := loader.LoadConfigWithSnapshot(fixtureDir) - assertNoDiagnostics(t, diags) - if snap == nil { - t.Fatalf("snapshot is nil; want non-nil") - } - - snapLoader := NewLoaderFromSnapshot(snap) - if loader == nil { - t.Fatalf("loader is nil; want non-nil") - } - - config, diags := snapLoader.LoadConfig(fixtureDir) - assertNoDiagnostics(t, diags) - if config == nil { - t.Fatalf("config is nil; want non-nil") - } - if config.Module == nil { - t.Fatalf("config has no root module") - } - if got, want := config.Module.SourceDir, "testdata/already-installed"; got != want { - t.Errorf("wrong root module sourcedir %q; want %q", got, want) - } - if got, want := len(config.Module.ModuleCalls), 2; got != want { - t.Errorf("wrong number of module calls in root module %d; want %d", got, want) - } - childA := config.Children["child_a"] - if childA == nil { - t.Fatalf("child_a config is nil; want non-nil") - } - if childA.Module == nil { - t.Fatalf("child_a config has no module") - } - if got, want := childA.Module.SourceDir, "testdata/already-installed/.terraform/modules/child_a"; got != want { - t.Errorf("wrong child_a sourcedir %q; want %q", got, want) - } - if got, want := len(childA.Module.ModuleCalls), 1; got != want { - t.Errorf("wrong number of module calls in child_a %d; want %d", got, want) - } -} diff --git a/internal/configs/configload/loader_test.go b/internal/configs/configload/loader_test.go deleted file mode 100644 index e61f3134ad..0000000000 --- a/internal/configs/configload/loader_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright IBM Corp. 2014, 2026 -// SPDX-License-Identifier: BUSL-1.1 - -package configload - -import ( - "testing" - - "github.com/hashicorp/hcl/v2" - "github.com/zclconf/go-cty/cty" -) - -func assertNoDiagnostics(t *testing.T, diags hcl.Diagnostics) bool { - t.Helper() - return assertDiagnosticCount(t, diags, 0) -} - -func assertDiagnosticCount(t *testing.T, diags hcl.Diagnostics, want int) bool { - t.Helper() - if len(diags) != want { - t.Errorf("wrong number of diagnostics %d; want %d", len(diags), want) - for _, diag := range diags { - t.Logf("- %s", diag) - } - return true - } - return false -} -func assertResultCtyEqual(t *testing.T, got, want cty.Value) bool { - t.Helper() - if !got.RawEquals(want) { - t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want) - return true - } - return false -} diff --git a/internal/configs/import_test.go b/internal/configs/import_test.go index 659d8f51c4..1d1c383958 100644 --- a/internal/configs/import_test.go +++ b/internal/configs/import_test.go @@ -16,13 +16,6 @@ import ( ) func TestParseConfigResourceFromExpression(t *testing.T) { - mustExpr := func(expr hcl.Expression, diags hcl.Diagnostics) hcl.Expression { - if diags != nil { - panic(diags.Error()) - } - return expr - } - tests := []struct { expr hcl.Expression expect addrs.ConfigResource @@ -280,3 +273,10 @@ func mustAbsResourceInstanceAddr(str string) addrs.AbsResourceInstance { } return addr } + +func mustExpr(expr hcl.Expression, diags hcl.Diagnostics) hcl.Expression { + if diags != nil { + panic(diags.Error()) + } + return expr +} diff --git a/internal/configs/module_call.go b/internal/configs/module_call.go index f1064b359c..e01b584cd0 100644 --- a/internal/configs/module_call.go +++ b/internal/configs/module_call.go @@ -7,26 +7,19 @@ import ( "fmt" "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" - - "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" ) // ModuleCall represents a "module" block in a module or file. type ModuleCall struct { Name string - SourceAddr addrs.ModuleSource - SourceAddrRaw string - SourceAddrRange hcl.Range - SourceSet bool + SourceExpr hcl.Expression Config hcl.Body - Version VersionConstraint + VersionExpr hcl.Expression Count hcl.Expression ForEach hcl.Expression @@ -66,75 +59,12 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno }) } - haveVersionArg := false if attr, exists := content.Attributes["version"]; exists { - var versionDiags hcl.Diagnostics - mc.Version, versionDiags = decodeVersionConstraint(attr) - diags = append(diags, versionDiags...) - haveVersionArg = true + mc.VersionExpr = attr.Expr } if attr, exists := content.Attributes["source"]; exists { - mc.SourceSet = true - mc.SourceAddrRange = attr.Expr.Range() - valDiags := gohcl.DecodeExpression(attr.Expr, nil, &mc.SourceAddrRaw) - diags = append(diags, valDiags...) - if !valDiags.HasErrors() { - var addr addrs.ModuleSource - var err error - if haveVersionArg { - addr, err = moduleaddrs.ParseModuleSourceRegistry(mc.SourceAddrRaw) - } else { - addr, err = moduleaddrs.ParseModuleSource(mc.SourceAddrRaw) - } - mc.SourceAddr = addr - if err != nil { - // NOTE: We leave mc.SourceAddr as nil for any situation where the - // source attribute is invalid, so any code which tries to carefully - // use the partial result of a failed config decode must be - // resilient to that. - mc.SourceAddr = nil - - // NOTE: In practice it's actually very unlikely to end up here, - // because our source address parser can turn just about any string - // into some sort of remote package address, and so for most errors - // we'll detect them only during module installation. There are - // still a _few_ purely-syntax errors we can catch at parsing time, - // though, mostly related to remote package sub-paths and local - // paths. - switch err := err.(type) { - case *moduleaddrs.MaybeRelativePathErr: - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid module source address", - Detail: fmt.Sprintf( - "Terraform failed to determine your intended installation method for remote module package %q.\n\nIf you intended this as a path relative to the current module, use \"./%s\" instead. The \"./\" prefix indicates that the address is a relative filesystem path.", - err.Addr, err.Addr, - ), - Subject: mc.SourceAddrRange.Ptr(), - }) - default: - if haveVersionArg { - // In this case we'll include some extra context that - // we assumed a registry source address due to the - // version argument. - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid registry module source address", - Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err), - Subject: mc.SourceAddrRange.Ptr(), - }) - } else { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid module source address", - Detail: fmt.Sprintf("Failed to parse module source address: %s.", err), - Subject: mc.SourceAddrRange.Ptr(), - }) - } - } - } - } + mc.SourceExpr = attr.Expr } if attr, exists := content.Attributes["count"]; exists { diff --git a/internal/configs/module_call_test.go b/internal/configs/module_call_test.go index 4e94d00c44..4c865c3622 100644 --- a/internal/configs/module_call_test.go +++ b/internal/configs/module_call_test.go @@ -10,7 +10,7 @@ import ( "github.com/go-test/deep" "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/hcl/v2/hclsyntax" ) func TestLoadModuleCall(t *testing.T) { @@ -31,15 +31,11 @@ func TestLoadModuleCall(t *testing.T) { gotModules := file.ModuleCalls wantModules := []*ModuleCall{ { - Name: "foo", - SourceAddr: addrs.ModuleSourceLocal("./foo"), - SourceAddrRaw: "./foo", - SourceSet: true, - SourceAddrRange: hcl.Range{ - Filename: "module-calls.tf", - Start: hcl.Pos{Line: 3, Column: 12, Byte: 27}, - End: hcl.Pos{Line: 3, Column: 19, Byte: 34}, - }, + Name: "foo", + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"./foo\""), "module-calls.tf", + hcl.Pos{Line: 3, Column: 12, Byte: 27}, + )), DeclRange: hcl.Range{ Filename: "module-calls.tf", Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, @@ -48,21 +44,10 @@ func TestLoadModuleCall(t *testing.T) { }, { Name: "bar", - SourceAddr: addrs.ModuleSourceRegistry{ - Package: addrs.ModuleRegistryPackage{ - Host: addrs.DefaultModuleRegistryHost, - Namespace: "hashicorp", - Name: "bar", - TargetSystem: "aws", - }, - }, - SourceAddrRaw: "hashicorp/bar/aws", - SourceSet: true, - SourceAddrRange: hcl.Range{ - Filename: "module-calls.tf", - Start: hcl.Pos{Line: 8, Column: 12, Byte: 113}, - End: hcl.Pos{Line: 8, Column: 31, Byte: 132}, - }, + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"hashicorp/bar/aws\""), "module-calls.tf", + hcl.Pos{Line: 8, Column: 12, Byte: 113}, + )), DeclRange: hcl.Range{ Filename: "module-calls.tf", Start: hcl.Pos{Line: 7, Column: 1, Byte: 87}, @@ -71,16 +56,10 @@ func TestLoadModuleCall(t *testing.T) { }, { Name: "baz", - SourceAddr: addrs.ModuleSourceRemote{ - Package: addrs.ModulePackage("git::https://example.com/"), - }, - SourceAddrRaw: "git::https://example.com/", - SourceSet: true, - SourceAddrRange: hcl.Range{ - Filename: "module-calls.tf", - Start: hcl.Pos{Line: 15, Column: 12, Byte: 193}, - End: hcl.Pos{Line: 15, Column: 39, Byte: 220}, - }, + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"git::https://example.com/\""), "module-calls.tf", + hcl.Pos{Line: 15, Column: 12, Byte: 193}, + )), DependsOn: []hcl.Traversal{ { hcl.TraverseRoot{ diff --git a/internal/configs/module_merge.go b/internal/configs/module_merge.go index ff27d502e3..f0570c037f 100644 --- a/internal/configs/module_merge.go +++ b/internal/configs/module_merge.go @@ -160,6 +160,11 @@ func (o *Output) merge(oo *Output) hcl.Diagnostics { o.Ephemeral = oo.Ephemeral o.EphemeralSet = oo.EphemeralSet } + if oo.TypeSet { + o.ConstraintType = oo.ConstraintType + o.TypeDefaults = oo.TypeDefaults + o.TypeSet = oo.TypeSet + } // We don't allow depends_on to be overridden because that is likely to // cause confusing misbehavior. @@ -178,11 +183,8 @@ func (o *Output) merge(oo *Output) hcl.Diagnostics { func (mc *ModuleCall) merge(omc *ModuleCall) hcl.Diagnostics { var diags hcl.Diagnostics - if omc.SourceSet { - mc.SourceAddr = omc.SourceAddr - mc.SourceAddrRaw = omc.SourceAddrRaw - mc.SourceAddrRange = omc.SourceAddrRange - mc.SourceSet = omc.SourceSet + if omc.SourceExpr != nil { + mc.SourceExpr = omc.SourceExpr } if omc.Count != nil { @@ -193,8 +195,8 @@ func (mc *ModuleCall) merge(omc *ModuleCall) hcl.Diagnostics { mc.ForEach = omc.ForEach } - if len(omc.Version.Required) != 0 { - mc.Version = omc.Version + if omc.VersionExpr != nil { + mc.VersionExpr = omc.VersionExpr } mc.Config = MergeBodies(mc.Config, omc.Config) diff --git a/internal/configs/module_merge_test.go b/internal/configs/module_merge_test.go index 78b2825e21..f6351594ee 100644 --- a/internal/configs/module_merge_test.go +++ b/internal/configs/module_merge_test.go @@ -9,8 +9,10 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/hashicorp/terraform/internal/addrs" ) @@ -91,23 +93,11 @@ func TestModuleOverrideModule(t *testing.T) { got := mod.ModuleCalls["example"] want := &ModuleCall{ - Name: "example", - SourceAddr: addrs.ModuleSourceLocal("./example2-a_override"), - SourceAddrRaw: "./example2-a_override", - SourceAddrRange: hcl.Range{ - Filename: "testdata/valid-modules/override-module/a_override.tf", - Start: hcl.Pos{ - Line: 3, - Column: 12, - Byte: 31, - }, - End: hcl.Pos{ - Line: 3, - Column: 35, - Byte: 54, - }, - }, - SourceSet: true, + Name: "example", + SourceExpr: mustExpr(hclsyntax.ParseExpression( + []byte("\"./example2-a_override\""), "testdata/valid-modules/override-module/a_override.tf", + hcl.Pos{Line: 3, Column: 12, Byte: 31}, + )), DeclRange: hcl.Range{ Filename: "testdata/valid-modules/override-module/primary.tf", Start: hcl.Pos{ @@ -424,6 +414,60 @@ func TestModuleOverrideConstVariable(t *testing.T) { } } +func TestModuleOverrideOutputType(t *testing.T) { + type testCase struct { + constraintType cty.Type + typeDefaults *typeexpr.Defaults + typeSet bool + } + cases := map[string]testCase{ + "fully_overridden": { + constraintType: cty.Number, + typeDefaults: nil, + typeSet: true, + }, + "no_override": { + constraintType: cty.String, + typeDefaults: nil, + typeSet: true, + }, + "type_added_by_override": { + constraintType: cty.List(cty.String), + typeDefaults: nil, + typeSet: true, + }, + } + + mod, diags := testModuleFromDir("testdata/valid-modules/override-output-type") + + assertNoDiagnostics(t, diags) + + if mod == nil { + t.Fatalf("module is nil") + } + + for name, want := range cases { + t.Run(fmt.Sprintf("output %s", name), func(t *testing.T) { + got, exists := mod.Outputs[name] + if !exists { + t.Fatalf("output %q not found", name) + } + + if !got.ConstraintType.Equals(want.constraintType) { + t.Errorf("wrong result for constraint type\ngot: %#v\nwant: %#v", got.ConstraintType, want.constraintType) + } + + if got.TypeSet != want.typeSet { + t.Errorf("wrong result for type set\ngot: %t want: %t", got.TypeSet, want.typeSet) + } + + if got.TypeDefaults != want.typeDefaults { + t.Errorf("wrong result for type defaults\ngot: %#v want: %#v", got.TypeDefaults, want.typeDefaults) + } + }) + } +} + func TestModuleOverrideResourceFQNs(t *testing.T) { mod, diags := testModuleFromDir("testdata/valid-modules/override-resource-provider") assertNoDiagnostics(t, diags) diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index bd5dedec63..789a4ed5cc 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -144,6 +144,25 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno v.ConstSet = true } + if v.Const { + if v.Sensitive { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable cannot be sensitive", + Detail: "A variable that is marked as \"const\" cannot also be marked as \"sensitive\".", + Subject: v.DeclRange.Ptr(), + }) + } + if v.Ephemeral { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable cannot be ephemeral", + Detail: "A variable that is marked as \"const\" cannot also be marked as \"ephemeral\".", + Subject: v.DeclRange.Ptr(), + }) + } + } + if attr, exists := content.Attributes["nullable"]; exists { valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Nullable) diags = append(diags, valDiags...) @@ -369,12 +388,24 @@ type Output struct { Ephemeral bool Deprecated string + // ConstraintType is a type constraint which the result is guaranteed + // to conform to when used in the calling module. + ConstraintType cty.Type + // TypeDefaults describes any optional attribute defaults that should be + // applied to the Expr result before type conversion. + TypeDefaults *typeexpr.Defaults + Preconditions []*CheckRule DescriptionSet bool SensitiveSet bool EphemeralSet bool DeprecatedSet bool + // TypeSet is true if there was an explicit "type" argument in the + // configuration block. This is mainly to allow distinguish explicitly + // setting vs. just using the default type constraint when processing + // override files. + TypeSet bool DeclRange hcl.Range DeprecatedRange hcl.Range @@ -415,6 +446,19 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic o.Expr = attr.Expr } + if attr, exists := content.Attributes["type"]; exists { + ty, defaults, moreDiags := typeexpr.TypeConstraintWithDefaults(attr.Expr) + diags = append(diags, moreDiags...) + o.ConstraintType = ty + o.TypeDefaults = defaults + o.TypeSet = true + } + if o.ConstraintType == cty.NilType { + // If no constraint is given then the type will be inferred + // automatically from the value. + o.ConstraintType = cty.DynamicPseudoType + } + if attr, exists := content.Attributes["sensitive"]; exists { valDiags := gohcl.DecodeExpression(attr.Expr, nil, &o.Sensitive) diags = append(diags, valDiags...) @@ -568,6 +612,9 @@ var outputBlockSchema = &hcl.BodySchema{ { Name: "deprecated", }, + { + Name: "type", + }, }, Blocks: []hcl.BlockHeaderSchema{ {Type: "precondition"}, diff --git a/internal/configs/provider_validation.go b/internal/configs/provider_validation.go index 78adb8cc9a..009f33ed4c 100644 --- a/internal/configs/provider_validation.go +++ b/internal/configs/provider_validation.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" ) @@ -253,14 +254,14 @@ func validateProviderConfigsForTests(cfg *Config) (diags hcl.Diagnostics) { // Let's make a little fake module call that we can use to call // into validateProviderConfigs. + sourceExpr := hcl.StaticExpr(cty.StringVal(run.Module.Source.String()), run.Module.SourceDeclRange) + versionExpr := hcl.StaticExpr(cty.StringVal(run.Module.Version.Required.String()), run.Module.Version.DeclRange) mc := &ModuleCall{ - Name: run.Name, - SourceAddr: run.Module.Source, - SourceAddrRange: run.Module.SourceDeclRange, - SourceSet: true, - Version: run.Module.Version, - Providers: providers, - DeclRange: run.Module.DeclRange, + Name: run.Name, + SourceExpr: sourceExpr, + VersionExpr: versionExpr, + Providers: providers, + DeclRange: run.Module.DeclRange, } diags = append(diags, validateProviderConfigs(mc, run.ConfigUnderTest, nil)...) diff --git a/internal/configs/testdata/error-files/const-ephemeral-variable.tf b/internal/configs/testdata/error-files/const-ephemeral-variable.tf new file mode 100644 index 0000000000..04423cd9f0 --- /dev/null +++ b/internal/configs/testdata/error-files/const-ephemeral-variable.tf @@ -0,0 +1,6 @@ +variable "example" { # ERROR: Const variable cannot be ephemeral + type = string + default = "hello" + const = true + ephemeral = true +} diff --git a/internal/configs/testdata/error-files/const-sensitive-variable.tf b/internal/configs/testdata/error-files/const-sensitive-variable.tf new file mode 100644 index 0000000000..2466f2a434 --- /dev/null +++ b/internal/configs/testdata/error-files/const-sensitive-variable.tf @@ -0,0 +1,6 @@ +variable "example" { # ERROR: Const variable cannot be sensitive + type = string + default = "hello" + const = true + sensitive = true +} diff --git a/internal/configs/testdata/invalid-files/version-variable.tf b/internal/configs/testdata/invalid-files/version-variable.tf deleted file mode 100644 index 7c871053de..0000000000 --- a/internal/configs/testdata/invalid-files/version-variable.tf +++ /dev/null @@ -1,6 +0,0 @@ -variable "module_version" { default = "v1.0" } - -module "foo" { - source = "./ff" - version = var.module_version -} diff --git a/internal/configs/testdata/valid-files/output-type-constraint.tf b/internal/configs/testdata/valid-files/output-type-constraint.tf new file mode 100644 index 0000000000..13be13befd --- /dev/null +++ b/internal/configs/testdata/valid-files/output-type-constraint.tf @@ -0,0 +1,11 @@ +output "string" { + type = string + value = "Hello" +} + +output "object" { + type = object({ + name = optional(string, "Bart"), + }) + value = {} +} diff --git a/internal/configs/testdata/valid-modules/override-output-type/a_override.tf b/internal/configs/testdata/valid-modules/override-output-type/a_override.tf new file mode 100644 index 0000000000..ca9bf7f070 --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-output-type/a_override.tf @@ -0,0 +1,7 @@ +output "fully_overridden" { + type = number +} + +output "type_added_by_override" { + type = list(string) +} diff --git a/internal/configs/testdata/valid-modules/override-output-type/primary.tf b/internal/configs/testdata/valid-modules/override-output-type/primary.tf new file mode 100644 index 0000000000..0fff30800d --- /dev/null +++ b/internal/configs/testdata/valid-modules/override-output-type/primary.tf @@ -0,0 +1,13 @@ +output "fully_overridden" { + value = "hello" + type = string +} + +output "no_override" { + value = "hello" + type = string +} + +output "type_added_by_override" { + value = "hello" +} diff --git a/internal/deprecation/deprecation.go b/internal/deprecation/deprecation.go index f7065680a6..a176c4ca00 100644 --- a/internal/deprecation/deprecation.go +++ b/internal/deprecation/deprecation.go @@ -54,22 +54,19 @@ func (d *Deprecations) ValidateAndUnmark(value cty.Value, module addrs.Module, r // It finds the most specific range possible for each diagnostic. func (d *Deprecations) ValidateExpressionDeepAndUnmark(value cty.Value, module addrs.Module, expr hcl.Expression) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - unmarked, pvms := value.UnmarkDeepWithPaths() + undeprecatedVal, pdms := marks.GetDeprecationMarksDeep(value) // Check if we need to suppress deprecation warnings for this module call. if d.IsModuleCallDeprecationSuppressed(module) { - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + return undeprecatedVal, diags } - for _, pvm := range pvms { - for m := range pvm.Marks { - if depMark, ok := m.(marks.DeprecationMark); ok { - rng := tfdiags.RangeForExpressionAtPath(expr, pvm.Path) - diags = diags.Append(deprecationMarkToDiagnostic(depMark, &rng)) - } - } + for _, pdm := range pdms { + rng := tfdiags.RangeForExpressionAtPath(expr, pdm.Path) + diags = diags.Append(deprecationMarkToDiagnostic(pdm.Mark, &rng)) } - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + + return undeprecatedVal, diags } func (d *Deprecations) deprecationMarksToDiagnostics(deprecationMarks []marks.DeprecationMark, module addrs.Module, rng *hcl.Range) tfdiags.Diagnostics { @@ -109,41 +106,34 @@ func deprecationMarkToDiagnostic(depMark marks.DeprecationMark, subject *hcl.Ran // unless deprecation warnings are suppressed for the given module. func (d *Deprecations) ValidateAndUnmarkConfig(value cty.Value, schema *configschema.Block, module addrs.Module) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - unmarked, pvms := value.UnmarkDeepWithPaths() + undeprecatedVal, pdms := marks.GetDeprecationMarksDeep(value) if d.IsModuleCallDeprecationSuppressed(module) { // Even if we don't want to get deprecation warnings we want to remove the marks - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + return undeprecatedVal, diags } - for _, pvm := range pvms { - for m := range pvm.Marks { - if depMark, ok := m.(marks.DeprecationMark); ok { - diag := tfdiags.AttributeValue( - tfdiags.Warning, - "Deprecated value used", - depMark.Message, - pvm.Path, - ) - if depMark.OriginDescription != "" { - diag = tfdiags.Override( - diag, - tfdiags.Warning, // We just want to override the extra info - func() tfdiags.DiagnosticExtraWrapper { - return &tfdiags.DeprecationOriginDiagnosticExtra{ - // TODO: Remove common prefixes from origin descriptions? - OriginDescription: depMark.OriginDescription, - } - }) - } - - diags = diags.Append(diag) - - } + for _, pdm := range pdms { + diag := tfdiags.AttributeValue( + tfdiags.Warning, + "Deprecated value used", + pdm.Mark.Message, + pdm.Path, + ) + if pdm.Mark.OriginDescription != "" { + diag = tfdiags.Override( + diag, + tfdiags.Warning, // We just want to override the extra info + func() tfdiags.DiagnosticExtraWrapper { + return &tfdiags.DeprecationOriginDiagnosticExtra{ + OriginDescription: pdm.Mark.OriginDescription, + } + }) } + diags = diags.Append(diag) } - return unmarked.MarkWithPaths(marks.RemoveAll(pvms, marks.Deprecation)), diags + return undeprecatedVal, diags } func (d *Deprecations) IsModuleCallDeprecationSuppressed(addr addrs.Module) bool { diff --git a/internal/e2e/e2e.go b/internal/e2e/e2e.go index 5cdc7b1b2a..2695d77d77 100644 --- a/internal/e2e/e2e.go +++ b/internal/e2e/e2e.go @@ -7,7 +7,6 @@ import ( "bytes" "fmt" "io" - "io/ioutil" "os" "os/exec" "path/filepath" @@ -177,7 +176,7 @@ func (b *binary) OpenFile(path ...string) (*os.File, error) { // directory. func (b *binary) ReadFile(path ...string) ([]byte, error) { flatPath := b.Path(path...) - return ioutil.ReadFile(flatPath) + return os.ReadFile(flatPath) } // FileExists is a helper for easily testing whether a particular file @@ -247,7 +246,7 @@ func (b *binary) SetLocalState(state *states.State) error { func GoBuild(pkgPath, tmpPrefix string) string { dir, prefix := filepath.Split(tmpPrefix) - tmpFile, err := ioutil.TempFile(dir, prefix) + tmpFile, err := os.CreateTemp(dir, prefix) if err != nil { panic(err) } diff --git a/internal/getproviders/public_keys.go b/internal/getproviders/public_keys.go index 8b692d7b10..df310b0b59 100644 --- a/internal/getproviders/public_keys.go +++ b/internal/getproviders/public_keys.go @@ -126,6 +126,128 @@ aTS71iR7nZNZ1f8LZV2OvGE6fJVtgJ1J4Nu02K54uuIhU3tg1+7Xt+IqwRc9rbVr pHH/hFCYBPW2D2dxB+k2pQlg5NI+TpsXj5Zun8kRw5RtVb+dLuiH/xmxArIee8Jq ZF5q4h4I33PSGDdSvGXn9UMY5Isjpg== =7pIB +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGB9+xkBEACabYZOWKmgZsHTdRDiyPJxhbuUiKX65GUWkyRMJKi/1dviVxOX +PG6hBPtF48IFnVgxKpIb7G6NjBousAV+CuLlv5yqFKpOZEGC6sBV+Gx8Vu1CICpl +Zm+HpQPcIzwBpN+Ar4l/exCG/f/MZq/oxGgH+TyRF3XcYDjG8dbJCpHO5nQ5Cy9h +QIp3/Bh09kET6lk+4QlofNgHKVT2epV8iK1cXlbQe2tZtfCUtxk+pxvU0UHXp+AB +0xc3/gIhjZp/dePmCOyQyGPJbp5bpO4UeAJ6frqhexmNlaw9Z897ltZmRLGq1p4a +RnWL8FPkBz9SCSKXS8uNyV5oMNVn4G1obCkc106iWuKBTibffYQzq5TG8FYVJKrh +RwWB6piacEB8hl20IIWSxIM3J9tT7CPSnk5RYYCTRHgA5OOrqZhC7JefudrP8n+M +pxkDgNORDu7GCfAuisrf7dXYjLsxG4tu22DBJJC0c/IpRpXDnOuJN1Q5e/3VUKKW +mypNumuQpP5lc1ZFG64TRzb1HR6oIdHfbrVQfdiQXpvdcFx+Fl57WuUraXRV6qfb +4ZmKHX1JEwM/7tu21QE4F1dz0jroLSricZxfaCTHHWNfvGJoZ30/MZUrpSC0IfB3 +iQutxbZrwIlTBt+fGLtm3vDtwMFNWM+Rb1lrOxEQd2eijdxhvBOHtlIcswARAQAB +tERIYXNoaUNvcnAgU2VjdXJpdHkgKGhhc2hpY29ycC5jb20vc2VjdXJpdHkpIDxz +ZWN1cml0eUBoYXNoaWNvcnAuY29tPokCVAQTAQoAPgIbAwULCQgHAgYVCgkICwIE +FgIDAQIeAQIXgBYhBMh0AR8KtAURDQIQVTQ2XZRy10aPBQJplkfQBQkQrOy3AAoJ +EDQ2XZRy10aPw6gP/3GUEMUa6mCRuuSOT9UnziPIvXYd63mcN6A6Jwmwj8JaB2qu +OCijvJkw56UbZK3x1FZIbe0hA6VUAwNSNmSIxVJkilgwIYYFO0tnL79XhIeP7jYF +ydXLZ4rTi1FDl8lltAujTNARdY8UGg4hGlcM9OrEeXEFLWugJNiChL15FVoxZqIS +jeduaEqyxGfJnyVwy8z3pZfgODeFr7xs2NkUIMSfuRg24VcL4aW8Frt3jW8P45y3 +o/5fsi6Aw2tZ0wD9NSgkVc8VD1NRV9eSZ95Bv+Awf9IXa+Cn5OCjc8Jc+XF+nLfB +oPswOO7E8dLiuBUw6/GzSLMbVs8qf8BNXB92dOe1VccVTqjCxK2sEpVaHh7e+co8 +d8lDGBIWMGh7NS6XlGORpFb/T6gxjjOYUV3SKd4QDebUUG8kMkb5juLljOoq+YOP +vgNLDZLZteFpmH+zB9DpOY1YtHZB/OD+DtzLMaSl6VPF2Ln0j5aQGwNDt7sheyAe +sXbu0qn2H5FxojSfvhT0kUDKZ0mgg5y3Oflg49MiAOhjLGY0JocFpBeMILw27fbw +fpIBP7siQWFTFJ1O+l2NQiWAwC2x5fX2EakyCBJmrkPV2hr4nEogNqg9/RDskIUq +cpcOOd/0BntiXMyUCCH2AoCt5acaTQ0WU6CAosZPojOYhtGGgOgeQSdflpMSuQIN +BGB9+xkBEACoklYsfvWRCjOwS8TOKBTfl8myuP9V9uBNbyHufzNETbhYeT33Cj0M +GCNd9GdoaknzBQLbQVSQogA+spqVvQPz1MND18GIdtmr0BXENiZE7SRvu76jNqLp +KxYALoK2Pc3yK0JGD30HcIIgx+lOofrVPA2dfVPTj1wXvm0rbSGA4Wd4Ng3d2AoR +G/wZDAQ7sdZi1A9hhfugTFZwfqR3XAYCk+PUeoFrkJ0O7wngaon+6x2GJVedVPOs +2x/XOR4l9ytFP3o+5ILhVnsK+ESVD9AQz2fhDEU6RhvzaqtHe+sQccR3oVLoGcat +ma5rbfzH0Fhj0JtkbP7WreQf9udYgXxVJKXLQFQgel34egEGG+NlbGSPG+qHOZtY +4uWdlDSvmo+1P95P4VG/EBteqyBbDDGDGiMs6lAMg2cULrwOsbxWjsWka8y2IN3z +1stlIJFvW2kggU+bKnQ+sNQnclq3wzCJjeDBfucR3a5WRojDtGoJP6Fc3luUtS7V +5TAdOx4dhaMFU9+01OoH8ZdTRiHZ1K7RFeAIslSyd4iA/xkhOhHq89F4ECQf3Bt4 +ZhGsXDTaA/VgHmf3AULbrC94O7HNqOvTWzwGiWHLfcxXQsr+ijIEQvh6rHKmJK8R +9NMHqc3L18eMO6bqrzEHW0Xoiu9W8Yj+WuB3IKdhclT3w0pO4Pj8gQARAQABiQI8 +BBgBCgAmAhsMFiEEyHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWR+0FCRCs7NQACgkQ +NDZdlHLXRo/R0A//QW1opBlzWSmWww1q9QuJA2WCIIs8tJKRDOsmgJPscNpzwZFU +N1Df0wWNjqi1BDReei7lZTHwUk+ebBn0bkI3ANmmgYg7LBueAt5UWSingOc+rvKA +N32BDzBYkMckRzJSQsmeC5hm3J3wLSy90uaIlrJJE9GJZkf/W2Ob+4SQZZ+dnnRP +JokDdW1DuZS9PbxSLJKD5eIWHBxJnFM1CmHfOfrjTJ+MYvVGM5sxSY8R7E+GADj5 +L/i4N+tTFJLuTMYARGfA6d+KPKcMJtgpUPjSMAg8nGUhukctpuBs27mOKW0CBtmJ +82X/qYROTL0+vGTvUYflYiuceVlhX/kw0JZnMaG5V/mpHq8SwD07pCGOf69j/mNa +5EL3++Pmzg0s0stw3Ea5pCN0cL/nKkoWchHBfW15W4JOnKAIspyD1vH670P4WfeV +E9B9d6tgKSbM/9JlXoQS5ZdG+kbdosieELhmVWmvojyK7K+Ry6C9wgd+UfnW5jXd +iNwKW3KHuautQwlFhHRNMyDg08c+pI5emTMT3IUQyGWo+Gska3TqGujFcABx7Ip+ +mHNmMrCkSD+XC2bvzvRR7FcM0/B9fsjLX/Wttm5vRJ1d2oAoEPvw2IZnJIXpOt2z +zo55sJTztNu4lWGgDVgtp9SXO5a0E5YvFHQNZN5QLeVTTFu6I7qG+ME1E/K5Ag0E +YH3+JQEQALivllTjMolxUW2OxrXb+a2Pt6vjCBsiJzrUj0Pa63U+lT9jldbCCfgP +wDpcDuO1O05Q8k1MoYZ6HddjWnqKG7S3eqkV5c3ct3amAXp513QDKZUfIDylOmhU +qvxjEgvGjdRjz6kECFGYr6Vnj/p6AwWv4/FBRFlrq7cnQgPynbIH4hrWvewp3Tqw +GVgqm5RRofuAugi8iZQVlAiQZJo88yaztAQ/7VsXBiHTn61ugQ8bKdAsr8w/ZZU5 +HScHLqRolcYg0cKN91c0EbJq9k1LUC//CakPB9mhi5+aUVUGusIM8ECShUEgSTCi +KQiJUPZ2CFbbPE9L5o9xoPCxjXoX+r7L/WyoCPTeoS3YRUMEnWKvc42Yxz3meRb+ +BmaqgbheNmzOah5nMwPupJYmHrjWPkX7oyyHxLSFw4dtoP2j6Z7GdRXKa2dUYdk2 +x3JYKocrDoPHh3Q0TAZujtpdjFi1BS8pbxYFb3hHmGSdvz7T7KcqP7ChC7k2RAKO +GiG7QQe4NX3sSMgweYpl4OwvQOn73t5CVWYp/gIBNZGsU3Pto8g27vHeWyH9mKr4 +cSepDhw+/X8FGRNdxNfpLKm7Vc0Sm9Sof8TRFrBTqX+vIQupYHRi5QQCuYaV6OVr +ITeegNK3So4m39d6ajCR9QxRbmjnx9UcnSYYDmIB6fpBuwT0ogNtABEBAAGJBHIE +GAEKACYCGwIWIQTIdAEfCrQFEQ0CEFU0Nl2UctdGjwUCYH4bgAUJAeFQ2wJAwXQg +BBkBCgAdFiEEs2y6kaLAcwxDX8KAsLRBCXaFtnYFAmB9/iUACgkQsLRBCXaFtnYX +BhAAlxejyFXoQwyGo9U+2g9N6LUb/tNtH29RHYxy4A3/ZUY7d/FMkArmh4+dfjf0 +p9MJz98Zkps20kaYP+2YzYmaizO6OA6RIddcEXQDRCPHmLts3097mJ/skx9qLAf6 +rh9J7jWeSqWO6VW6Mlx8j9m7sm3Ae1OsjOx/m7lGZOhY4UYfY627+Jf7WQ5103Qs +lgQ09es/vhTCx0g34SYEmMW15Tc3eCjQ21b1MeJD/V26npeakV8iCZ1kHZHawPq/ +aCCuYEcCeQOOteTWvl7HXaHMhHIx7jjOd8XX9V+UxsGz2WCIxX/j7EEEc7CAxwAN +nWp9jXeLfxYfjrUB7XQZsGCd4EHHzUyCf7iRJL7OJ3tz5Z+rOlNjSgci+ycHEccL +YeFAEV+Fz+sj7q4cFAferkr7imY1XEI0Ji5P8p/uRYw/n8uUf7LrLw5TzHmZsTSC +UaiL4llRzkDC6cVhYfqQWUXDd/r385OkE4oalNNE+n+txNRx92rpvXWZ5qFYfv7E +95fltvpXc0iOugPMzyof3lwo3Xi4WZKc1CC/jEviKTQhfn3WZukuF5lbz3V1PQfI +xFsYe9WYQmp25XGgezjXzp89C/OIcYsVB1KJAKihgbYdHyUN4fRCmOszmOUwEAKR +3k5j4X8V5bk08sA69NVXPn2ofxyk3YYOMYWW8ouObnXoS8QJEDQ2XZRy10aPMpsQ +AIbwX21erVqUDMPn1uONP6o4NBEq4MwG7d+fT85rc1U0RfeKBwjucAE/iStZDQoM +ZKWvGhFR+uoyg1LrXNKuSPB82unh2bpvj4zEnJsJadiwtShTKDsikhrfFEK3aCK8 +Zuhpiu3jxMFDhpFzlxsSwaCcGJqcdwGhWUx0ZAVD2X71UCFoOXPjF9fNnpy80YNp +flPjj2RnOZbJyBIM0sWIVMd8F44qkTASf8K5Qb47WFN5tSpePq7OCm7s8u+lYZGK +wR18K7VliundR+5a8XAOyUXOL5UsDaQCK4Lj4lRaeFXunXl3DJ4E+7BKzZhReJL6 +EugV5eaGonA52TWtFdB8p+79wPUeI3KcdPmQ9Ll5Zi/jBemY4bzasmgKzNeMtwWP +fk6WgrvBwptqohw71HDymGxFUnUP7XYYjic2sVKhv9AevMGycVgwWBiWroDCQ9Ja +btKfxHhI2p+g+rcywmBobWJbZsujTNjhtme+kNn1mhJsD3bKPjKQfAxaTskBLb0V +wgV21891TS1Dq9kdPLwoS4XNpYg2LLB4p9hmeG3fu9+OmqwY5oKXsHiWc43dei9Y +yxZ1AAUOIaIdPkq+YG/PhlGE4YcQZ4RPpltAr0HfGgZhmXWigbGS+66pUj+Ojysc +j0K5tCVxVu0fhhFpOlHv0LWaxCbnkgkQH9jfMEJkAWMOuQINBGCAXCYBEADW6RNr +ZVGNXvHVBqSiOWaxl1XOiEoiHPt50Aijt25yXbG+0kHIFSoR+1g6Lh20JTCChgfQ +kGGjzQvEuG1HTw07YhsvLc0pkjNMfu6gJqFox/ogc53mz69OxXauzUQ/TZ27GDVp +UBu+EhDKt1s3OtA6Bjz/csop/Um7gT0+ivHyvJ/jGdnPEZv8tNuSE/Uo+hn/Q9hg +8SbveZzo3C+U4KcabCESEFl8Gq6aRi9vAfa65oxD5jKaIz7cy+pwb0lizqlW7H9t +Qlr3dBfdIcdzgR55hTFC5/XrcwJ6/nHVH/xGskEasnfCQX8RYKMuy0UADJy72TkZ +bYaCx+XXIcVB8GTOmJVoAhrTSSVLAZspfCnjwnSxisDn3ZzsYrq3cV6sU8b+QlIX +7VAjurE+5cZiVlaxgCjyhKqlGgmonnReWOBacCgL/UvuwMmMp5TTLmiLXLT7uxeG +ojEyoCk4sMrqrU1jevHyGlDJH9Taux15GILDwnYFfAvPF9WCid4UZ4Ouwjcaxfys +3LxNiZIlUsXNKwS3mhiMRL4TRsbs4k4QE+LIMOsauIvcvm8/frydvQ/kUwIhVTH8 +0XGOH909bYtJvY3fudK7ShIwm7ZFTduBJUG473E/Fn3VkhTmBX6+PjOC50HR/Hyb +waRCzfDruMe3TAcE/tSP5CUOb9C7+P+hPzQcDwARAQABiQRyBBgBCgAmAhsCFiEE +yHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWSAoFCRCqi+QCQMF0IAQZAQoAHRYhBDdO +x1tIWRNgSoMcx8ggxtXNJ6uHBQJggFwmAAoJEMggxtXNJ6uHRfAP/2CGdSyg0K7U +66Vygl0dugxrMm8O3/Oe211BKdQsFUSWAznOTRTK/zvMUHO4LJAlYvdtZ6xDa4XH +l9FYQ8MR9ZV0OuOlAZvU4IJDLPVCU09X/UzX/GEoZL0R5esvwPAXopMaRHCfXJeI +/gEaB94UhAeYlwpcRn0eSuk1vyZx7GRE6/hog8DCf4hoT40dW20gGe58xcvJ+mRY +lC0lr16WH08wuUcee6+dgu+4Cg6SG6+zt9cMyl8VnTUL5BK/V3MebnYZJK0RFDNn +nXDhzStgOd5gOeIL+xBPXHd0/ld/rDM74SFExpuS+hNsyo+xMQ/HJavak21MFinu +l9COwfGEmlAXTGMY30Lf3Pt/eAkbwgmGc966VSoRmOFEXJVlDr+yJR6ru+7j50z8 +lAv6Lsop7sun1Qysbo0swf6W1qgPf6VWbx91NTFLkw0+gD8jxwrU5ZMkeSuntX9d +pjuZS29CflXXIRPlvhuiDPicwTpYuIUx37vHveAH5gnowZg247x780Urrsx8duTX +8CI9MAnqzm4dFAiRlwE8bvLk+l9wekiXA9gIMZiVNqNlduXIqvAG21Wdgq8qyeXK +y/XWCVKDQOmEbFAltfNam8E3KEw0fl199x+93d5ckDGcPzUYPbNkCuIwngC/ZN96 +pDafF3Z12fSNfhZUe0C8td8KAszYa96GCRA0Nl2UctdGj1gKD/4jOGhEGTg88Vyu +PVjeK+zkwrTIZSvHdUHfTt/+rTLSNb/RQiBCUQuEZvafj6FrntS7bAEhccGqH894 +T3St5K0AXWkvsLd6K+cbIQdlnFA2zb6geJUCk6qx5NgWpRc3i0DS7CheGwl+Bwu7 ++n9pNjNjiHV+rYDgqbQXG0dtGysB0/3qIRgEDHFO0HJu/dcte4oXrQIqrZrpOwe8 +WxqFqdU918JpSUcc8coiFp9YtwpgqQNxGVZ+rhgnTGdZzk1f/Yhhimh+2B0ReaFv +k3UzVBj3HQ9C6+Ot3MyDEhSgdhjr9e25Tm9S5YfhwtWmghRw9RKPyLMSXSxm/Uc0 +mK1NucAp8TQBwKqKzNpCk5IdrBSWRUbjOoOFyzyCsY6gS285GCpSIzI39hTf+3gd +wYPlE6fj+F2TZzdhx62DPnzBzBHnByYTVdJ649bx0FFp4Q+5TbIWtxu/AQkRDxmW +NQfE+6GgeshlrhXWsh6+PGDzt+2raG6zUT913sdz7Ctw4fLjmsKOTdTz3Xa9pr8l +xfI/JuukSgt9o/n3GirhTB3zE1w/I/Xt6k7oASiP3zQSuHtB/CYKYHDtOCWwjo7J +PEGtb/FkreKNxsk/p20jnlrB8WZxxswdr2Vri9NmFeyMDVX7qF3WqT+8aCV9GtS1 +GCHx/5nGBdDwoxEsXqpI3IUqPb6FDg== +=wtp+ -----END PGP PUBLIC KEY BLOCK-----` // HashicorpPartnersKey is a key created by HashiCorp, used to generate and diff --git a/internal/initwd/from_module.go b/internal/initwd/from_module.go index aed383d035..aeb6799664 100644 --- a/internal/initwd/from_module.go +++ b/internal/initwd/from_module.go @@ -6,7 +6,6 @@ package initwd import ( "context" "fmt" - "io/ioutil" "log" "os" "path/filepath" @@ -19,6 +18,7 @@ import ( "github.com/hashicorp/terraform/internal/copy" "github.com/hashicorp/terraform/internal/getmodules" "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" + "github.com/zclconf/go-cty/cty" version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/modsdir" @@ -59,7 +59,7 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu // The target directory must exist but be empty. { - entries, err := ioutil.ReadDir(rootDir) + entries, err := os.ReadDir(rootDir) if err != nil { if os.IsNotExist(err) { diags = diags.Append(tfdiags.Sourceless( @@ -94,7 +94,7 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu } instDir := filepath.Join(rootDir, ".terraform/init-from-module") - inst := NewModuleInstaller(instDir, loader, reg) + inst := NewModuleInstaller(instDir, loader, reg, nil) log.Printf("[DEBUG] installing modules in %s to initialize working directory from %q", instDir, sourceAddrStr) os.RemoveAll(instDir) // if this fails then we'll fail on MkdirAll below too err := os.MkdirAll(instDir, os.ModePerm) @@ -129,24 +129,17 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu // Now we need to create an artificial root module that will seed our // installation process. - sourceAddr, err := moduleaddrs.ParseModuleSource(sourceAddrStr) - if err != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Invalid module source address", - fmt.Sprintf("Failed to parse module source address: %s", err), - )) + fakeRange := hcl.Range{ + Filename: initFromModuleRootFilename, + Start: hcl.InitialPos, + End: hcl.InitialPos, } fakeRootModule := &configs.Module{ ModuleCalls: map[string]*configs.ModuleCall{ initFromModuleRootCallName: { Name: initFromModuleRootCallName, - SourceAddr: sourceAddr, - DeclRange: hcl.Range{ - Filename: initFromModuleRootFilename, - Start: hcl.InitialPos, - End: hcl.InitialPos, - }, + SourceExpr: hcl.StaticExpr(cty.StringVal(sourceAddrStr), fakeRange), + DeclRange: fakeRange, }, }, ProviderRequirements: &configs.RequiredProviders{}, @@ -167,11 +160,20 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu fetcher := getmodules.NewPackageFetcher() walker := inst.moduleInstallWalker(ctx, instManifest, true, wrapHooks, fetcher) - _, cDiags := inst.installDescendantModules(fakeRootModule, instManifest, walker, true) + _, cDiags := inst.installDescendantModules(fakeRootModule, walker, true) if cDiags.HasErrors() { return diags.Append(cDiags) } + err = instManifest.WriteSnapshotToDir(instDir) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to update module manifest", + fmt.Sprintf("Unable to write the module manifest file: %s", err), + )) + } + // If all of that succeeded then we'll now migrate what was installed // into the final directory structure. err = os.MkdirAll(modulesDir, os.ModePerm) @@ -215,9 +217,12 @@ func DirFromModule(ctx context.Context, loader *configload.Loader, rootDir, modu mod, _ := loader.Parser().LoadConfigDir(rootDir) // ignore diagnostics since we're just doing value-add here anyway if mod != nil { for _, mc := range mod.ModuleCalls { - if pathTraversesUp(mc.SourceAddrRaw) { + // TODO improve this + sourceVal, _ := mc.SourceExpr.Value(nil) + sourceRaw := sourceVal.AsString() + if pathTraversesUp(sourceRaw) { packageAddr, givenSubdir := moduleaddrs.SplitPackageSubdir(sourceAddrStr) - newSubdir := filepath.Join(givenSubdir, mc.SourceAddrRaw) + newSubdir := filepath.Join(givenSubdir, sourceRaw) if pathTraversesUp(newSubdir) { // This should never happen in any reasonable // configuration since this suggests a path that diff --git a/internal/initwd/from_module_test.go b/internal/initwd/from_module_test.go index 1b7bd9ce9b..690f142c78 100644 --- a/internal/initwd/from_module_test.go +++ b/internal/initwd/from_module_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/copy" "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -107,8 +108,16 @@ func TestDirFromModule_registry(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") - tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + tfdiags.AssertNoDiagnostics(t, buildDiags) wantTraces := map[string]string{ "": "in example", @@ -187,8 +196,16 @@ func TestDirFromModule_submodules(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") - tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + tfdiags.AssertNoDiagnostics(t, buildDiags) wantTraces := map[string]string{ "": "in root module", @@ -312,8 +329,16 @@ func TestDirFromModule_rel_submodules(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") - tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + tfdiags.AssertNoDiagnostics(t, buildDiags) wantTraces := map[string]string{ "": "in root module", diff --git a/internal/initwd/module_install.go b/internal/initwd/module_install.go index a3547e915d..1a4992d42c 100644 --- a/internal/initwd/module_install.go +++ b/internal/initwd/module_install.go @@ -30,6 +30,8 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) +type Initializer func(rootMod *configs.Module, walker configs.ModuleWalker) (*configs.Config, tfdiags.Diagnostics) + type ModuleInstaller struct { modsDir string loader *configload.Loader @@ -42,6 +44,8 @@ type ModuleInstaller struct { // The keys in moduleVersionsUrl are the moduleVersion struct below and // addresses and the values are underlying remote source addresses. registryPackageSources map[moduleVersion]addrs.ModuleSourceRemote + + initializer Initializer } type moduleVersion struct { @@ -49,13 +53,14 @@ type moduleVersion struct { version string } -func NewModuleInstaller(modsDir string, loader *configload.Loader, reg *registry.Client) *ModuleInstaller { +func NewModuleInstaller(modsDir string, loader *configload.Loader, reg *registry.Client, initializer Initializer) *ModuleInstaller { return &ModuleInstaller{ modsDir: modsDir, loader: loader, reg: reg, registryPackageVersions: make(map[addrs.ModuleRegistryPackage]*response.ModuleVersions), registryPackageSources: make(map[moduleVersion]addrs.ModuleSourceRemote), + initializer: initializer, } } @@ -137,8 +142,31 @@ func (i *ModuleInstaller) InstallModules(ctx context.Context, rootDir, testsDir } walker := i.moduleInstallWalker(ctx, manifest, upgrade, hooks, fetcher) - cfg, instDiags := i.installDescendantModules(rootMod, manifest, walker, installErrsOnly) - diags = append(diags, instDiags...) + var cfg *configs.Config + var instDiags tfdiags.Diagnostics + if i.initializer != nil { + cfg, instDiags = i.initializer(rootMod, walker) + diags = diags.Append(instDiags) + } else { + cfg, instDiags = i.installDescendantModules(rootMod, walker, installErrsOnly) + diags = diags.Append(instDiags) + } + + finalDiags := configs.FinalizeConfig(cfg, walker, configs.MockDataLoaderFunc(i.loader.LoadExternalMockData)) + diags = diags.Append(finalDiags) + + if diags.HasErrors() { + return nil, diags + } + + err = manifest.WriteSnapshotToDir(i.modsDir) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to update module manifest", + fmt.Sprintf("Unable to write the module manifest file: %s", err), + )) + } return cfg, diags } @@ -292,7 +320,7 @@ func (i *ModuleInstaller) moduleInstallWalker(ctx context.Context, manifest mods ) } -func (i *ModuleInstaller) installDescendantModules(rootMod *configs.Module, manifest modsdir.Manifest, installWalker configs.ModuleWalker, installErrsOnly bool) (*configs.Config, tfdiags.Diagnostics) { +func (i *ModuleInstaller) installDescendantModules(rootMod *configs.Module, installWalker configs.ModuleWalker, installErrsOnly bool) (*configs.Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics // When attempting to initialize the current directory with a module @@ -332,15 +360,6 @@ func (i *ModuleInstaller) installDescendantModules(rootMod *configs.Module, mani diags = tfdiags.OverrideAll(diags, tfdiags.Warning, nil) } - err := manifest.WriteSnapshotToDir(i.modsDir) - if err != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to update module manifest", - fmt.Sprintf("Unable to write the module manifest file: %s", err), - )) - } - return cfg, diags } diff --git a/internal/initwd/module_install_test.go b/internal/initwd/module_install_test.go index 89573ac572..d36b2bf03d 100644 --- a/internal/initwd/module_install_test.go +++ b/internal/initwd/module_install_test.go @@ -25,6 +25,7 @@ import ( "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/copy" "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" _ "github.com/hashicorp/terraform/internal/logging" @@ -45,7 +46,7 @@ func TestModuleInstaller(t *testing.T) { modulesDir := filepath.Join(dir, ".terraform/modules") loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -77,7 +78,15 @@ func TestModuleInstaller(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -109,7 +118,7 @@ func TestModuleInstaller_error(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -130,7 +139,7 @@ func TestModuleInstaller_emptyModuleName(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -151,7 +160,7 @@ func TestModuleInstaller_invalidModuleName(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) if !diags.HasErrors() { @@ -189,7 +198,7 @@ func TestModuleInstaller_packageEscapeError(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -227,7 +236,7 @@ func TestModuleInstaller_explicitPackageBoundary(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if diags.HasErrors() { @@ -250,7 +259,7 @@ func TestModuleInstaller_ExactMatchPrerelease(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if diags.HasErrors() { @@ -277,7 +286,7 @@ func TestModuleInstaller_PartialMatchPrerelease(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) cfg, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if diags.HasErrors() { @@ -300,7 +309,7 @@ func TestModuleInstaller_invalid_version_constraint_error(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -326,7 +335,7 @@ func TestModuleInstaller_invalidVersionConstraintGetter(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -352,7 +361,7 @@ func TestModuleInstaller_invalidVersionConstraintLocal(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) if !diags.HasErrors() { @@ -378,7 +387,7 @@ func TestModuleInstaller_symlink(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -410,7 +419,15 @@ func TestModuleInstaller_symlink(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -454,7 +471,7 @@ func TestLoaderInstallModules_invalidRegistry(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) if !diags.HasErrors() { @@ -493,7 +510,7 @@ func TestLoaderInstallModules_registry(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -608,7 +625,15 @@ func TestLoaderInstallModules_registry(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -656,7 +681,7 @@ func TestLoaderInstallModules_goGetter(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -738,7 +763,15 @@ func TestLoaderInstallModules_goGetter(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfig(".") + rootMod, hclDiags := loader.LoadRootModule(".") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) wantTraces := map[string]string{ @@ -774,7 +807,7 @@ func TestModuleInstaller_fromTests(t *testing.T) { modulesDir := filepath.Join(dir, ".terraform/modules") loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, nil) + inst := NewModuleInstaller(modulesDir, loader, nil, nil) _, diags := inst.InstallModules(context.Background(), ".", "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -800,7 +833,15 @@ func TestModuleInstaller_fromTests(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfigWithTests(".", "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(".", "tests") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests["tests/main.tftest.hcl"].Runs[0].ConfigUnderTest == nil { @@ -831,7 +872,7 @@ func TestLoadInstallModules_registryFromTest(t *testing.T) { loader, close := configload.NewLoaderForTests(t) defer close() - inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil)) + inst := NewModuleInstaller(modulesDir, loader, registry.NewClient(nil, nil), nil) _, diags := inst.InstallModules(context.Background(), dir, "tests", false, false, hooks) tfdiags.AssertNoDiagnostics(t, diags) @@ -909,7 +950,15 @@ func TestLoadInstallModules_registryFromTest(t *testing.T) { // Make sure the configuration is loadable now. // (This ensures that correct information is recorded in the manifest.) - config, loadDiags := loader.LoadConfigWithTests(".", "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(".", "tests") + tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(hclDiags)) + + config, loadDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) tfdiags.AssertNoDiagnostics(t, tfdiags.Diagnostics{}.Append(loadDiags)) if config.Module.Tests["main.tftest.hcl"].Runs[0].ConfigUnderTest == nil { diff --git a/internal/lang/funcs/encoding.go b/internal/lang/funcs/encoding.go index 886be8fc95..05f661d878 100644 --- a/internal/lang/funcs/encoding.go +++ b/internal/lang/funcs/encoding.go @@ -137,7 +137,7 @@ var TextDecodeBase64Func = function.New(&function.Spec{ if err != nil { switch err := err.(type) { case base64.CorruptInputError: - return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given value is has an invalid base64 symbol at offset %d", int(err)) + return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "the given value has an invalid base64 symbol at offset %d", int(err)) default: return cty.UnknownVal(cty.String), function.NewArgErrorf(0, "invalid source string: %w", err) } diff --git a/internal/lang/funcs/encoding_test.go b/internal/lang/funcs/encoding_test.go index 6ff593f558..cf0bc8c892 100644 --- a/internal/lang/funcs/encoding_test.go +++ b/internal/lang/funcs/encoding_test.go @@ -322,7 +322,7 @@ func TestBase64TextDecode(t *testing.T) { cty.StringVal(""), cty.StringVal("cp437"), cty.UnknownVal(cty.String).RefineNotNull(), - `the given value is has an invalid base64 symbol at offset 0`, + `the given value has an invalid base64 symbol at offset 0`, }, { cty.StringVal("gQ=="), // this is 0x81, which is not defined in windows-1250 diff --git a/internal/lang/globalref/analyzer_contributing_resources_test.go b/internal/lang/globalref/analyzer_contributing_resources_test.go index 464bd5f0d7..0464648c42 100644 --- a/internal/lang/globalref/analyzer_contributing_resources_test.go +++ b/internal/lang/globalref/analyzer_contributing_resources_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package globalref +package globalref_test import ( "sort" @@ -10,17 +10,18 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/lang/globalref" ) func TestAnalyzerContributingResources(t *testing.T) { azr := testAnalyzer(t, "contributing-resources") tests := map[string]struct { - StartRefs func() []Reference + StartRefs func() []globalref.Reference WantAddrs []string }{ "root output 'network'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "network"}.Absolute(addrs.RootModuleInstance), ) @@ -32,7 +33,7 @@ func TestAnalyzerContributingResources(t *testing.T) { }, }, "root output 'c10s_url'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "c10s_url"}.Absolute(addrs.RootModuleInstance), ) @@ -51,7 +52,7 @@ func TestAnalyzerContributingResources(t *testing.T) { }, }, "module.compute.test_thing.load_balancer": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.ManagedResourceMode, @@ -68,7 +69,7 @@ func TestAnalyzerContributingResources(t *testing.T) { }, }, "data.test_thing.environment": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.DataResourceMode, @@ -104,11 +105,11 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { azr := testAnalyzer(t, "contributing-resources") tests := map[string]struct { - StartRefs func() []Reference + StartRefs func() []globalref.Reference WantAttrs []string }{ "root output 'network'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "network"}.Absolute(addrs.RootModuleInstance), ) @@ -120,7 +121,7 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { }, }, "root output 'c10s_url'": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromOutputValue( addrs.OutputValue{Name: "c10s_url"}.Absolute(addrs.RootModuleInstance), ) @@ -133,7 +134,7 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { }, }, "module.compute.test_thing.load_balancer": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.ManagedResourceMode, @@ -150,7 +151,7 @@ func TestAnalyzerContributingResourceAttrs(t *testing.T) { }, }, "data.test_thing.environment": { - func() []Reference { + func() []globalref.Reference { return azr.ReferencesFromResourceInstance( addrs.Resource{ Mode: addrs.DataResourceMode, diff --git a/internal/lang/globalref/analyzer_meta_references_test.go b/internal/lang/globalref/analyzer_meta_references_test.go index 164fc984ae..2b7b9c129a 100644 --- a/internal/lang/globalref/analyzer_meta_references_test.go +++ b/internal/lang/globalref/analyzer_meta_references_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package globalref +package globalref_test import ( "sort" @@ -9,6 +9,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/lang/globalref" ) func TestAnalyzerMetaReferences(t *testing.T) { @@ -152,7 +153,7 @@ func TestAnalyzerMetaReferences(t *testing.T) { t.Fatalf("input reference is invalid: %s", diags.Err()) } - ref := Reference{ + ref := globalref.Reference{ ContainerAddr: containerAddr, LocalRef: localRef, } diff --git a/internal/lang/globalref/analyzer_test.go b/internal/lang/globalref/testing_test.go similarity index 77% rename from internal/lang/globalref/analyzer_test.go rename to internal/lang/globalref/testing_test.go index 7f50e6aca4..9d6d843f21 100644 --- a/internal/lang/globalref/analyzer_test.go +++ b/internal/lang/globalref/testing_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package globalref +package globalref_test import ( "context" @@ -11,20 +11,25 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/lang/globalref" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" ) -func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { +// testAnalyzer creates an analyzer for testing by loading a configuration +// and setting up provider schemas. +func testAnalyzer(t *testing.T, fixtureName string) *globalref.Analyzer { configDir := filepath.Join("testdata", fixtureName) loader, cleanup := configload.NewLoaderForTests(t) defer cleanup() - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), configDir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatalf("unexpected module installation errors: %s", instDiags.Err().Error()) @@ -33,9 +38,19 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { t.Fatalf("failed to refresh modules after install: %s", err) } - cfg, loadDiags := loader.LoadConfig(configDir) + rootMod, loadDiags := loader.LoadRootModule(configDir) if loadDiags.HasErrors() { - t.Fatalf("unexpected configuration errors: %s", loadDiags.Error()) + t.Fatalf("invalid root module: %s", loadDiags.Error()) + } + + cfg, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", buildDiags.Err()) } resourceTypeSchema := &configschema.Block{ @@ -101,6 +116,5 @@ func testAnalyzer(t *testing.T, fixtureName string) *Analyzer { }, }, } - - return NewAnalyzer(cfg, schemas) + return globalref.NewAnalyzer(cfg, schemas) } diff --git a/internal/lang/marks/marks.go b/internal/lang/marks/marks.go index 7639419e2f..d2aca10924 100644 --- a/internal/lang/marks/marks.go +++ b/internal/lang/marks/marks.go @@ -5,6 +5,7 @@ package marks import ( "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/ctymarks" ) // valueMarks allow creating strictly typed values for use as cty.Value marks. @@ -24,12 +25,11 @@ func Has(val cty.Value, mark interface{}) bool { // For value marks Has returns true if a mark of the type is present case DeprecationMark: - for depMark := range val.Marks() { - if _, ok := depMark.(DeprecationMark); ok { - return true - } + for range cty.ValueMarksOfType[DeprecationMark](val) { + return true } return false + default: panic("Unknown mark type") } @@ -72,26 +72,30 @@ func GetDeprecationMarks(val cty.Value) (cty.Value, []DeprecationMark) { return unmarked.WithMarks(other), depMarks } -// RemoveDeprecationMarks returns a copy of the given cty.Value with all -// deprecation marks removed. -func RemoveDeprecationMarks(val cty.Value) cty.Value { - newVal, marks := val.Unmark() - - for mark := range marks { - if _, ok := mark.(DeprecationMark); !ok { - newVal = newVal.Mark(mark) - } - } - - return newVal +type PathDeprecationMark struct { + Mark DeprecationMark + Path cty.Path } -// RemoveDeprecationMarksDeep returns a copy of the given cty.Value with all -// deprecation marks deeply removed. -func RemoveDeprecationMarksDeep(val cty.Value) cty.Value { - newVal, pvms := val.UnmarkDeepWithPaths() - otherPvms := RemoveAll(pvms, Deprecation) - return newVal.MarkWithPaths(otherPvms) +// GetDeprecationMarksDeep returns a copy of the given cty.Value with all +// deprecation marks removed, along with a slice of all deprecation marks found +// in the value and their paths. +func GetDeprecationMarksDeep(value cty.Value) (cty.Value, []PathDeprecationMark) { + pdms := []PathDeprecationMark{} + undeprecatedVal, _ := value.WrangleMarksDeep(func(mark any, path cty.Path) (ctymarks.WrangleAction, error) { + if depMark, ok := mark.(DeprecationMark); ok { + pdms = append(pdms, PathDeprecationMark{ + Mark: depMark, + Path: path.Copy(), + }) + // We want to drop the deprecation marks + return ctymarks.WrangleDrop, nil + } + // and ignore all other marks + return ctymarks.WrangleKeep, nil + }) + + return undeprecatedVal, pdms } // Sensitive indicates that this value is marked as sensitive in the context of diff --git a/internal/legacy/go.mod b/internal/legacy/go.mod index 0fb7d8ea70..fd55d95c69 100644 --- a/internal/legacy/go.mod +++ b/internal/legacy/go.mod @@ -11,7 +11,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/reflectwalk v1.0.2 - github.com/zclconf/go-cty v1.16.3 + github.com/zclconf/go-cty v1.18.0 ) require ( diff --git a/internal/legacy/go.sum b/internal/legacy/go.sum index f187b86343..5b2d438690 100644 --- a/internal/legacy/go.sum +++ b/internal/legacy/go.sum @@ -81,8 +81,8 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= diff --git a/internal/moduleref/resolver.go b/internal/moduleref/resolver.go index 8871abc49a..081579dcee 100644 --- a/internal/moduleref/resolver.go +++ b/internal/moduleref/resolver.go @@ -56,12 +56,10 @@ func (r *Resolver) findAndTrimReferencedEntries(cfg *configs.Config, parentRecor var name string var versionConstraints version.Constraints if parentKey != nil { - for key := range cfg.Parent.Children { + for key, child := range cfg.Parent.Children { if key == *parentKey { name = key - if cfg.Parent.Module.ModuleCalls[key] != nil { - versionConstraints = cfg.Parent.Module.ModuleCalls[key].Version.Required - } + versionConstraints = child.VersionConstraint.Required break } } diff --git a/internal/moduletest/graph/eval_context_test.go b/internal/moduletest/graph/eval_context_test.go index eec1a08cdc..d2bde834fb 100644 --- a/internal/moduletest/graph/eval_context_test.go +++ b/internal/moduletest/graph/eval_context_test.go @@ -835,7 +835,7 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config { // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -847,9 +847,19 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config { t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadConfigWithTests(cfgPath, "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(cfgPath, "tests") + if hclDiags.HasErrors() { + t.Fatal(hclDiags.Error()) + } + + config, diags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } return config diff --git a/internal/plans/planfile/config_snapshot.go b/internal/plans/planfile/config_snapshot.go index 25c8c2843d..925cce08c1 100644 --- a/internal/plans/planfile/config_snapshot.go +++ b/internal/plans/planfile/config_snapshot.go @@ -30,7 +30,7 @@ type configSnapshotModuleRecord struct { } type configSnapshotModuleManifest []configSnapshotModuleRecord -func readConfigSnapshot(z *zip.Reader) (*configload.Snapshot, error) { +func ReadConfigSnapshot(z *zip.Reader) (*configload.Snapshot, error) { // Errors from this function are expected to be reported with some // additional prefix context about them being in a config snapshot, // so they should not themselves refer to the config snapshot. @@ -145,13 +145,13 @@ func readConfigSnapshot(z *zip.Reader) (*configload.Snapshot, error) { return snap, nil } -// writeConfigSnapshot adds to the given zip.Writer one or more files +// WriteConfigSnapshot adds to the given zip.Writer one or more files // representing the given snapshot. // // This file creates new files in the writer, so any already-open writer // for the file will be invalidated by this call. The writer remains open // when this function returns. -func writeConfigSnapshot(snap *configload.Snapshot, z *zip.Writer) error { +func WriteConfigSnapshot(snap *configload.Snapshot, z *zip.Writer) error { // Errors from this function are expected to be reported with some // additional prefix context about them being in a config snapshot, // so they should not themselves refer to the config snapshot. diff --git a/internal/plans/planfile/reader.go b/internal/plans/planfile/reader.go index 3bb0f439b8..da6c5ca87e 100644 --- a/internal/plans/planfile/reader.go +++ b/internal/plans/planfile/reader.go @@ -10,7 +10,6 @@ import ( "io" "os" - "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/plans" @@ -188,34 +187,7 @@ func (r *Reader) ReadPrevStateFile() (*statefile.File, error) { // This is a lower-level alternative to ReadConfig that just extracts the // source files, without attempting to parse them. func (r *Reader) ReadConfigSnapshot() (*configload.Snapshot, error) { - return readConfigSnapshot(&r.zip.Reader) -} - -// ReadConfig reads the configuration embedded in the plan file. -// -// Internally this function delegates to the configs/configload package to -// parse the embedded configuration and so it returns diagnostics (rather than -// a native Go error as with other methods on Reader). -func (r *Reader) ReadConfig(allowLanguageExperiments bool) (*configs.Config, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - - snap, err := r.ReadConfigSnapshot() - if err != nil { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Failed to read configuration from plan file", - fmt.Sprintf("The configuration file snapshot in the plan file could not be read: %s.", err), - )) - return nil, diags - } - - loader := configload.NewLoaderFromSnapshot(snap) - loader.AllowLanguageExperiments(allowLanguageExperiments) - rootDir := snap.Modules[""].Dir // Root module base directory - config, configDiags := loader.LoadConfig(rootDir) - diags = diags.Append(configDiags) - - return config, diags + return ReadConfigSnapshot(&r.zip.Reader) } // ReadDependencyLocks reads the dependency lock information embedded in diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 5e430c59ce..1509474297 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -26,8 +26,10 @@ import ( "github.com/hashicorp/terraform/version" ) -const tfplanFormatVersion = 3 -const tfplanFilename = "tfplan" +const ( + tfplanFormatVersion = 3 + tfplanFilename = "tfplan" +) // --------------------------------------------------------------------------- // This file deals with the internal structure of the "tfplan" sub-file within @@ -410,7 +412,6 @@ func ActionFromProto(rawAction planproto.Action) (plans.Action, error) { default: return plans.NoOp, fmt.Errorf("invalid change action %s", rawAction) } - } func changeFromTfplan(rawChange *planproto.Change) (*plans.ChangeSrc, error) { @@ -1320,6 +1321,15 @@ func CheckResultsToPlanProto(checkResults *states.CheckResults) ([]*planproto.Ch } } +// ActionInvocationFromProto decodes an isolated action invocation from +// its representation as a protocol buffers message. +// +// This is used by the stackplan package, which includes planproto messages +// in its own wire format while using a different overall container. +func ActionInvocationFromProto(rawAction *planproto.ActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { + return actionInvocationFromTfplan(rawAction) +} + func actionInvocationFromTfplan(rawAction *planproto.ActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { if rawAction == nil { // Should never happen in practice, since protobuf can't represent @@ -1448,3 +1458,12 @@ func actionInvocationToTfPlan(action *plans.ActionInvocationInstanceSrc) (*planp return ret, nil } + +// ActionInvocationToProto encodes an action invocation from its internal +// representation into the protobuf representation for persistence. +// +// This is a public wrapper around actionInvocationToTfPlan for use by +// external packages like stackplan. +func ActionInvocationToProto(action *plans.ActionInvocationInstanceSrc) (*planproto.ActionInvocationInstance, error) { + return actionInvocationToTfPlan(action) +} diff --git a/internal/plans/planfile/writer.go b/internal/plans/planfile/writer.go index 7781700584..a4db0b3fff 100644 --- a/internal/plans/planfile/writer.go +++ b/internal/plans/planfile/writer.go @@ -111,7 +111,7 @@ func Create(filename string, args CreateArgs) error { // tfconfig directory { - err := writeConfigSnapshot(args.ConfigSnapshot, zw) + err := WriteConfigSnapshot(args.ConfigSnapshot, zw) if err != nil { return fmt.Errorf("failed to write config snapshot: %s", err) } diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index d54652b6d9..d375e90ca5 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log" + "slices" "sort" "strings" @@ -205,13 +206,7 @@ func (i *Installer) EnsureProviderVersions(ctx context.Context, locks *depsfile. if provider.IsBuiltIn() { // Built in providers do not require installation but we'll still // verify that the requested provider name is valid. - valid := false - for _, name := range i.builtInProviderTypes { - if name == provider.Type { - valid = true - break - } - } + valid := slices.Contains(i.builtInProviderTypes, provider.Type) var err error if valid { if len(versionConstraints) == 0 { diff --git a/internal/refactoring/move_statement_test.go b/internal/refactoring/move_statement_test.go index c80a53288a..975f026930 100644 --- a/internal/refactoring/move_statement_test.go +++ b/internal/refactoring/move_statement_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package refactoring +package refactoring_test import ( "sort" @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -114,9 +115,9 @@ func TestImpliedMoveStatements(t *testing.T) { ) }) - explicitStmts := FindMoveStatements(rootCfg) - got := ImpliedMoveStatements(rootCfg, prevRunState, explicitStmts) - want := []MoveStatement{ + explicitStmts := refactoring.FindMoveStatements(rootCfg) + got := refactoring.ImpliedMoveStatements(rootCfg, prevRunState, explicitStmts) + want := []refactoring.MoveStatement{ { From: addrs.ImpliedMoveStatementEndpoint(resourceAddr("formerly_count").Instance(addrs.IntKey(0)), tfdiags.SourceRange{}), To: addrs.ImpliedMoveStatementEndpoint(resourceAddr("formerly_count").Instance(addrs.NoKey), tfdiags.SourceRange{}), @@ -199,7 +200,7 @@ func TestImpliedMoveStatements(t *testing.T) { sort.Slice(got, func(i, j int) bool { // This is just an arbitrary sort to make the result consistent - // regardless of what order the ImpliedMoveStatements function + // regardless of what order the refactoring.ImpliedMoveStatements function // visits the entries in the state/config. return got[i].DeclRange.Start.Line < got[j].DeclRange.Start.Line }) diff --git a/internal/refactoring/move_validate_test.go b/internal/refactoring/move_validate_test.go index 9e44da0e58..598a2eaf41 100644 --- a/internal/refactoring/move_validate_test.go +++ b/internal/refactoring/move_validate_test.go @@ -1,23 +1,17 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package refactoring +package refactoring_test import ( - "context" "strings" "testing" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/zclconf/go-cty/cty/gocty" "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/configs/configload" - "github.com/hashicorp/terraform/internal/initwd" - "github.com/hashicorp/terraform/internal/instances" - "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -25,7 +19,7 @@ func TestValidateMoves(t *testing.T) { rootCfg, instances := loadRefactoringFixture(t, "testdata/move-validate-zoo") tests := map[string]struct { - Statements []MoveStatement + Statements []refactoring.MoveStatement WantError string }{ "no move statements": { @@ -33,7 +27,7 @@ func TestValidateMoves(t *testing.T) { WantError: ``, }, "some valid statements": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ // This is just a grab bag of various valid cases that don't // generate any errors at all. makeTestMoveStmt(t, @@ -110,7 +104,7 @@ func TestValidateMoves(t *testing.T) { WantError: ``, }, "two statements with the same endpoints": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -125,7 +119,7 @@ func TestValidateMoves(t *testing.T) { WantError: ``, }, "moving nowhere": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -135,7 +129,7 @@ func TestValidateMoves(t *testing.T) { WantError: `Redundant move statement: This statement declares a move from module.a to the same address, which is the same as not declaring this move at all.`, }, "cyclic chain": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -160,7 +154,7 @@ func TestValidateMoves(t *testing.T) { A chain of move statements must end with an address that doesn't appear in any other statements, and which typically also refers to an object still declared in the configuration.`, }, "module.single as a call still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single`, @@ -172,7 +166,7 @@ A chain of move statements must end with an address that doesn't appear in any o Change your configuration so that this call will be declared as module.other instead.`, }, "module.single as an instance still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single`, @@ -184,7 +178,7 @@ Change your configuration so that this call will be declared as module.other ins Change your configuration so that this instance will be declared as module.other[0] instead.`, }, "module.count[0] still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.count[0]`, @@ -196,7 +190,7 @@ Change your configuration so that this instance will be declared as module.other Change your configuration so that this instance will be declared as module.other instead.`, }, `module.for_each["a"] still exists in configuration`: { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.for_each["a"]`, @@ -208,7 +202,7 @@ Change your configuration so that this instance will be declared as module.other Change your configuration so that this instance will be declared as module.other instead.`, }, "test.single as a resource still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.single`, @@ -220,7 +214,7 @@ Change your configuration so that this instance will be declared as module.other Change your configuration so that this resource will be declared as test.other instead.`, }, "test.single as an instance still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.single`, @@ -232,7 +226,7 @@ Change your configuration so that this resource will be declared as test.other i Change your configuration so that this instance will be declared as test.other[0] instead.`, }, "module.single.test.single as a resource still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single.test.single`, @@ -244,7 +238,7 @@ Change your configuration so that this instance will be declared as test.other[0 Change your configuration so that this resource will be declared as test.other instead.`, }, "module.single.test.single as a resource declared in module.single still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, `single`, `test.single`, @@ -256,7 +250,7 @@ Change your configuration so that this resource will be declared as test.other i Change your configuration so that this resource will be declared as module.single.test.other instead.`, }, "module.single.test.single as an instance still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.single.test.single`, @@ -268,7 +262,7 @@ Change your configuration so that this resource will be declared as module.singl Change your configuration so that this instance will be declared as test.other[0] instead.`, }, "module.count[0].test.single still exists in configuration": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.count[0].test.single`, @@ -280,7 +274,7 @@ Change your configuration so that this instance will be declared as test.other[0 Change your configuration so that this resource will be declared as test.other instead.`, }, "two different moves from test.nonexist": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.nonexist`, @@ -297,7 +291,7 @@ Change your configuration so that this resource will be declared as test.other i Each resource can move to only one destination resource.`, }, "two different moves to test.single": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.other1`, @@ -314,7 +308,7 @@ Each resource can move to only one destination resource.`, Each resource can have moved from only one source resource.`, }, "two different moves to module.count[0].test.single across two modules": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.other1`, @@ -331,7 +325,7 @@ Each resource can have moved from only one source resource.`, Each resource can have moved from only one source resource.`, }, "move from resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.fake_external.test.thing`, @@ -341,7 +335,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move to resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `test.thing`, @@ -351,7 +345,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move from module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.fake_external.module.a`, @@ -361,7 +355,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move to module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.a`, @@ -371,7 +365,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move from resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `module.fake_external.test.thing`, @@ -382,7 +376,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move to resource in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `test.thing`, @@ -393,7 +387,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move from module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `module.fake_external.module.a`, @@ -404,7 +398,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "implied move to module call in another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestImpliedMoveStmt(t, ``, `module.a`, @@ -415,7 +409,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, }, "move to a call that refers to another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.nonexist`, @@ -425,7 +419,7 @@ Each resource can have moved from only one source resource.`, WantError: ``, // This is okay because the call itself is not considered to be inside the package it refers to }, "move to instance of a call that refers to another module package": { - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.nonexist`, @@ -436,7 +430,7 @@ Each resource can have moved from only one source resource.`, }, "crossing nested statements": { // overlapping nested moves will result in a cycle. - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, ``, `module.nonexist.test.single`, `module.count[0].test.count[0]`, @@ -456,7 +450,7 @@ A chain of move statements must end with an address that doesn't appear in any o // we have to avoid a cycle because the nested moves appear in both // the from and to address of the parent when only the module index // is changing. - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, `count`, `test.count`, `test.count[0]`, @@ -471,7 +465,7 @@ A chain of move statements must end with an address that doesn't appear in any o // we have to avoid a cycle because the nested moves appear in both // the from and to address of the parent when only the module index // is changing. - Statements: []MoveStatement{ + Statements: []refactoring.MoveStatement{ makeTestMoveStmt(t, `count`, `module.count`, `module.count[0]`, @@ -490,7 +484,7 @@ A chain of move statements must end with an address that doesn't appear in any o for name, test := range tests { t.Run(name, func(t *testing.T) { - gotDiags := ValidateMoves(test.Statements, rootCfg, instances) + gotDiags := refactoring.ValidateMoves(test.Statements, rootCfg, instances) switch { case test.WantError != "": @@ -509,136 +503,7 @@ A chain of move statements must end with an address that doesn't appear in any o } } -// loadRefactoringFixture reads a configuration from the given directory and -// does some naive static processing on any count and for_each expressions -// inside, in order to get a realistic-looking instances.Set for what it -// declares without having to run a full Terraform plan. -func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instances.Set) { - t.Helper() - - loader, cleanup := configload.NewLoaderForTests(t) - defer cleanup() - - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) - _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) - if instDiags.HasErrors() { - t.Fatal(instDiags.Err()) - } - - // Since module installer has modified the module manifest on disk, we need - // to refresh the cache of it in the loader. - if err := loader.RefreshModules(); err != nil { - t.Fatalf("failed to refresh modules after installation: %s", err) - } - - rootCfg, diags := loader.LoadConfig(dir) - if diags.HasErrors() { - t.Fatalf("failed to load root module: %s", diags.Error()) - } - - expander := instances.NewExpander(nil) - staticPopulateExpanderModule(t, rootCfg, addrs.RootModuleInstance, expander) - return rootCfg, expander.AllInstances() -} - -func staticPopulateExpanderModule(t *testing.T, rootCfg *configs.Config, moduleAddr addrs.ModuleInstance, expander *instances.Expander) { - t.Helper() - - modCfg := rootCfg.DescendantForInstance(moduleAddr) - if modCfg == nil { - t.Fatalf("no configuration for %s", moduleAddr) - } - - if len(modCfg.Path) > 0 && modCfg.Path[len(modCfg.Path)-1] == "fake_external" { - // As a funny special case we modify the source address of this - // module to be something that counts as a separate package, - // so we can test rules relating to crossing package boundaries - // even though we really just loaded the module from a local path. - modCfg.SourceAddr = fakeExternalModuleSource - } - - for _, call := range modCfg.Module.ModuleCalls { - callAddr := addrs.ModuleCall{Name: call.Name} - - if call.Name == "fake_external" { - // As a funny special case we modify the source address of this - // module to be something that counts as a separate package, - // so we can test rules relating to crossing package boundaries - // even though we really just loaded the module from a local path. - call.SourceAddr = fakeExternalModuleSource - } - - // In order to get a valid, useful set of instances here we're going - // to just statically evaluate the count and for_each expressions. - // Normally it's valid to use references and functions there, but for - // our unit tests we'll just limit it to literal values to avoid - // bringing all of the core evaluator complexity. - switch { - case call.ForEach != nil: - val, diags := call.ForEach.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid for_each: %s", diags.Error()) - } - expander.SetModuleForEach(moduleAddr, callAddr, val.AsValueMap()) - case call.Count != nil: - val, diags := call.Count.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid count: %s", diags.Error()) - } - var count int - err := gocty.FromCtyValue(val, &count) - if err != nil { - t.Fatalf("invalid count at %s: %s", call.Count.Range(), err) - } - expander.SetModuleCount(moduleAddr, callAddr, count) - default: - expander.SetModuleSingle(moduleAddr, callAddr) - } - - // We need to recursively analyze the child modules too. - calledMod := modCfg.Path.Child(call.Name) - for _, inst := range expander.ExpandModule(calledMod, false) { - staticPopulateExpanderModule(t, rootCfg, inst, expander) - } - } - - for _, rc := range modCfg.Module.ManagedResources { - staticPopulateExpanderResource(t, moduleAddr, rc, expander) - } - for _, rc := range modCfg.Module.DataResources { - staticPopulateExpanderResource(t, moduleAddr, rc, expander) - } - -} - -func staticPopulateExpanderResource(t *testing.T, moduleAddr addrs.ModuleInstance, rCfg *configs.Resource, expander *instances.Expander) { - t.Helper() - - addr := rCfg.Addr() - switch { - case rCfg.ForEach != nil: - val, diags := rCfg.ForEach.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid for_each: %s", diags.Error()) - } - expander.SetResourceForEach(moduleAddr, addr, val.AsValueMap()) - case rCfg.Count != nil: - val, diags := rCfg.Count.Value(nil) - if diags.HasErrors() { - t.Fatalf("invalid count: %s", diags.Error()) - } - var count int - err := gocty.FromCtyValue(val, &count) - if err != nil { - t.Fatalf("invalid count at %s: %s", rCfg.Count.Range(), err) - } - expander.SetResourceCount(moduleAddr, addr, count) - default: - expander.SetResourceSingle(moduleAddr, addr) - } -} - -func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatement { +func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) refactoring.MoveStatement { t.Helper() module := addrs.RootModule @@ -669,7 +534,7 @@ func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatem t.Fatalf("incompatible move endpoints") } - return MoveStatement{ + return refactoring.MoveStatement{ From: fromInModule, To: toInModule, DeclRange: tfdiags.SourceRange{ @@ -680,13 +545,9 @@ func makeTestMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatem } } -func makeTestImpliedMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) MoveStatement { +func makeTestImpliedMoveStmt(t *testing.T, moduleStr, fromStr, toStr string) refactoring.MoveStatement { t.Helper() ret := makeTestMoveStmt(t, moduleStr, fromStr, toStr) ret.Implied = true return ret } - -var fakeExternalModuleSource = addrs.ModuleSourceRemote{ - Package: addrs.ModulePackage("fake-external:///"), -} diff --git a/internal/refactoring/remove_statement_test.go b/internal/refactoring/remove_statement_test.go index 049e7aad36..e15ceaa15f 100644 --- a/internal/refactoring/remove_statement_test.go +++ b/internal/refactoring/remove_statement_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package refactoring +package refactoring_test import ( "testing" @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -59,8 +60,8 @@ func TestFindRemoveStatements(t *testing.T) { configModuleInModule := addrs.Module{"child", "grandchild"} - want := addrs.MakeMap[addrs.ConfigMoveable, RemoveStatement]( - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceBasic, RemoveStatement{ + want := addrs.MakeMap[addrs.ConfigMoveable, refactoring.RemoveStatement]( + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceBasic, refactoring.RemoveStatement{ From: configResourceBasic, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -69,7 +70,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 2, Column: 8, Byte: 34}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceWithModule, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceWithModule, refactoring.RemoveStatement{ From: configResourceWithModule, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -78,7 +79,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 10, Column: 8, Byte: 145}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configModuleBasic, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configModuleBasic, refactoring.RemoveStatement{ From: configModuleBasic, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -87,7 +88,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 18, Column: 8, Byte: 260}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceOverridden, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceOverridden, refactoring.RemoveStatement{ From: configResourceOverridden, Destroy: true, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -96,7 +97,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 30, Column: 8, Byte: 435}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configResourceInModule, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configResourceInModule, refactoring.RemoveStatement{ From: configResourceInModule, Destroy: true, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -105,7 +106,7 @@ func TestFindRemoveStatements(t *testing.T) { End: hcl.Pos{Line: 10, Column: 8, Byte: 148}, }), }), - addrs.MakeMapElem[addrs.ConfigMoveable, RemoveStatement](configModuleInModule, RemoveStatement{ + addrs.MakeMapElem[addrs.ConfigMoveable, refactoring.RemoveStatement](configModuleInModule, refactoring.RemoveStatement{ From: configModuleInModule, Destroy: false, DeclRange: tfdiags.SourceRangeFromHCL(hcl.Range{ @@ -116,7 +117,7 @@ func TestFindRemoveStatements(t *testing.T) { }), ) - got, diags := FindRemoveStatements(rootCfg) + got, diags := refactoring.FindRemoveStatements(rootCfg) if diags.HasErrors() { t.Fatal(diags.Err().Error()) } diff --git a/internal/refactoring/testing_helpers.go b/internal/refactoring/testing_helpers.go new file mode 100644 index 0000000000..32d7b15750 --- /dev/null +++ b/internal/refactoring/testing_helpers.go @@ -0,0 +1,109 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package refactoring + +import ( + "testing" + + "github.com/hashicorp/hcl/v2/hcltest" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/instances" +) + +// FakeExternalModuleSource is used in tests to simulate an external module source. +var FakeExternalModuleSource = addrs.ModuleSourceRemote{ + Package: addrs.ModulePackage("example.com/test/fake"), +} + +// StaticPopulateExpanderModule populates an expander for testing by statically +// evaluating count and for_each expressions in a configuration. +// +// This is exported so that test code in package refactoring_test can use it +// without creating an import cycle with the terraform package. +func StaticPopulateExpanderModule(t *testing.T, rootCfg *configs.Config, moduleAddr addrs.ModuleInstance, expander *instances.Expander) { + t.Helper() + + modCfg := rootCfg.DescendantForInstance(moduleAddr) + if modCfg == nil { + t.Fatalf("no configuration for %s", moduleAddr) + } + + if len(modCfg.Path) > 0 && modCfg.Path[len(modCfg.Path)-1] == "fake_external" { + modCfg.SourceAddr = FakeExternalModuleSource + } + + for _, call := range modCfg.Module.ModuleCalls { + callAddr := addrs.ModuleCall{Name: call.Name} + + if call.Name == "fake_external" { + call.SourceExpr = hcltest.MockExprLiteral(cty.StringVal(FakeExternalModuleSource.String())) + } + + switch { + case call.ForEach != nil: + val, diags := call.ForEach.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid for_each: %s", diags.Error()) + } + expander.SetModuleForEach(moduleAddr, callAddr, val.AsValueMap()) + case call.Count != nil: + val, diags := call.Count.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid count: %s", diags.Error()) + } + var count int + err := gocty.FromCtyValue(val, &count) + if err != nil { + t.Fatalf("invalid count at %s: %s", call.Count.Range(), err) + } + expander.SetModuleCount(moduleAddr, callAddr, count) + default: + expander.SetModuleSingle(moduleAddr, callAddr) + } + + calledMod := modCfg.Path.Child(call.Name) + for _, inst := range expander.ExpandModule(calledMod, false) { + StaticPopulateExpanderModule(t, rootCfg, inst, expander) + } + } + + for _, rc := range modCfg.Module.ManagedResources { + StaticPopulateExpanderResource(t, moduleAddr, rc, expander) + } + for _, rc := range modCfg.Module.DataResources { + StaticPopulateExpanderResource(t, moduleAddr, rc, expander) + } +} + +// StaticPopulateExpanderResource populates resource instances in an expander for testing. +func StaticPopulateExpanderResource(t *testing.T, moduleAddr addrs.ModuleInstance, rCfg *configs.Resource, expander *instances.Expander) { + t.Helper() + + addr := rCfg.Addr() + switch { + case rCfg.ForEach != nil: + val, diags := rCfg.ForEach.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid for_each: %s", diags.Error()) + } + expander.SetResourceForEach(moduleAddr, addr, val.AsValueMap()) + case rCfg.Count != nil: + val, diags := rCfg.Count.Value(nil) + if diags.HasErrors() { + t.Fatalf("invalid count: %s", diags.Error()) + } + var count int + err := gocty.FromCtyValue(val, &count) + if err != nil { + t.Fatalf("invalid count at %s: %s", rCfg.Count.Range(), err) + } + expander.SetResourceCount(moduleAddr, addr, count) + default: + expander.SetResourceSingle(moduleAddr, addr) + } +} diff --git a/internal/refactoring/testing_test.go b/internal/refactoring/testing_test.go new file mode 100644 index 0000000000..1fab18b0b2 --- /dev/null +++ b/internal/refactoring/testing_test.go @@ -0,0 +1,58 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package refactoring_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/internal/instances" + "github.com/hashicorp/terraform/internal/refactoring" + "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" +) + +// loadRefactoringFixture reads a configuration from the given directory and +// does some naive static processing on any count and for_each expressions +// inside, in order to get a realistic-looking instances.Set for what it +// declares without having to run a full Terraform plan. +func loadRefactoringFixture(t *testing.T, dir string) (*configs.Config, instances.Set) { + t.Helper() + + loader, cleanup := configload.NewLoaderForTests(t) + defer cleanup() + + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) + _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) + if instDiags.HasErrors() { + t.Fatal(instDiags.Err()) + } + + if err := loader.RefreshModules(); err != nil { + t.Fatalf("failed to refresh modules after installation: %s", err) + } + + rootMod, diags := loader.LoadRootModule(dir) + if diags.HasErrors() { + t.Fatalf("invalid root module: %s", diags.Error()) + } + + rootCfg, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatalf("invalid configuration: %s", buildDiags.Err()) + } + + expander := instances.NewExpander(nil) + refactoring.StaticPopulateExpanderModule(t, rootCfg, addrs.RootModuleInstance, expander) + return rootCfg, expander.AllInstances() +} diff --git a/internal/releaseauth/signature.go b/internal/releaseauth/signature.go index 2b1af10587..eb2db3ca5f 100644 --- a/internal/releaseauth/signature.go +++ b/internal/releaseauth/signature.go @@ -181,4 +181,126 @@ aTS71iR7nZNZ1f8LZV2OvGE6fJVtgJ1J4Nu02K54uuIhU3tg1+7Xt+IqwRc9rbVr pHH/hFCYBPW2D2dxB+k2pQlg5NI+TpsXj5Zun8kRw5RtVb+dLuiH/xmxArIee8Jq ZF5q4h4I33PSGDdSvGXn9UMY5Isjpg== =7pIB +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGB9+xkBEACabYZOWKmgZsHTdRDiyPJxhbuUiKX65GUWkyRMJKi/1dviVxOX +PG6hBPtF48IFnVgxKpIb7G6NjBousAV+CuLlv5yqFKpOZEGC6sBV+Gx8Vu1CICpl +Zm+HpQPcIzwBpN+Ar4l/exCG/f/MZq/oxGgH+TyRF3XcYDjG8dbJCpHO5nQ5Cy9h +QIp3/Bh09kET6lk+4QlofNgHKVT2epV8iK1cXlbQe2tZtfCUtxk+pxvU0UHXp+AB +0xc3/gIhjZp/dePmCOyQyGPJbp5bpO4UeAJ6frqhexmNlaw9Z897ltZmRLGq1p4a +RnWL8FPkBz9SCSKXS8uNyV5oMNVn4G1obCkc106iWuKBTibffYQzq5TG8FYVJKrh +RwWB6piacEB8hl20IIWSxIM3J9tT7CPSnk5RYYCTRHgA5OOrqZhC7JefudrP8n+M +pxkDgNORDu7GCfAuisrf7dXYjLsxG4tu22DBJJC0c/IpRpXDnOuJN1Q5e/3VUKKW +mypNumuQpP5lc1ZFG64TRzb1HR6oIdHfbrVQfdiQXpvdcFx+Fl57WuUraXRV6qfb +4ZmKHX1JEwM/7tu21QE4F1dz0jroLSricZxfaCTHHWNfvGJoZ30/MZUrpSC0IfB3 +iQutxbZrwIlTBt+fGLtm3vDtwMFNWM+Rb1lrOxEQd2eijdxhvBOHtlIcswARAQAB +tERIYXNoaUNvcnAgU2VjdXJpdHkgKGhhc2hpY29ycC5jb20vc2VjdXJpdHkpIDxz +ZWN1cml0eUBoYXNoaWNvcnAuY29tPokCVAQTAQoAPgIbAwULCQgHAgYVCgkICwIE +FgIDAQIeAQIXgBYhBMh0AR8KtAURDQIQVTQ2XZRy10aPBQJplkfQBQkQrOy3AAoJ +EDQ2XZRy10aPw6gP/3GUEMUa6mCRuuSOT9UnziPIvXYd63mcN6A6Jwmwj8JaB2qu +OCijvJkw56UbZK3x1FZIbe0hA6VUAwNSNmSIxVJkilgwIYYFO0tnL79XhIeP7jYF +ydXLZ4rTi1FDl8lltAujTNARdY8UGg4hGlcM9OrEeXEFLWugJNiChL15FVoxZqIS +jeduaEqyxGfJnyVwy8z3pZfgODeFr7xs2NkUIMSfuRg24VcL4aW8Frt3jW8P45y3 +o/5fsi6Aw2tZ0wD9NSgkVc8VD1NRV9eSZ95Bv+Awf9IXa+Cn5OCjc8Jc+XF+nLfB +oPswOO7E8dLiuBUw6/GzSLMbVs8qf8BNXB92dOe1VccVTqjCxK2sEpVaHh7e+co8 +d8lDGBIWMGh7NS6XlGORpFb/T6gxjjOYUV3SKd4QDebUUG8kMkb5juLljOoq+YOP +vgNLDZLZteFpmH+zB9DpOY1YtHZB/OD+DtzLMaSl6VPF2Ln0j5aQGwNDt7sheyAe +sXbu0qn2H5FxojSfvhT0kUDKZ0mgg5y3Oflg49MiAOhjLGY0JocFpBeMILw27fbw +fpIBP7siQWFTFJ1O+l2NQiWAwC2x5fX2EakyCBJmrkPV2hr4nEogNqg9/RDskIUq +cpcOOd/0BntiXMyUCCH2AoCt5acaTQ0WU6CAosZPojOYhtGGgOgeQSdflpMSuQIN +BGB9+xkBEACoklYsfvWRCjOwS8TOKBTfl8myuP9V9uBNbyHufzNETbhYeT33Cj0M +GCNd9GdoaknzBQLbQVSQogA+spqVvQPz1MND18GIdtmr0BXENiZE7SRvu76jNqLp +KxYALoK2Pc3yK0JGD30HcIIgx+lOofrVPA2dfVPTj1wXvm0rbSGA4Wd4Ng3d2AoR +G/wZDAQ7sdZi1A9hhfugTFZwfqR3XAYCk+PUeoFrkJ0O7wngaon+6x2GJVedVPOs +2x/XOR4l9ytFP3o+5ILhVnsK+ESVD9AQz2fhDEU6RhvzaqtHe+sQccR3oVLoGcat +ma5rbfzH0Fhj0JtkbP7WreQf9udYgXxVJKXLQFQgel34egEGG+NlbGSPG+qHOZtY +4uWdlDSvmo+1P95P4VG/EBteqyBbDDGDGiMs6lAMg2cULrwOsbxWjsWka8y2IN3z +1stlIJFvW2kggU+bKnQ+sNQnclq3wzCJjeDBfucR3a5WRojDtGoJP6Fc3luUtS7V +5TAdOx4dhaMFU9+01OoH8ZdTRiHZ1K7RFeAIslSyd4iA/xkhOhHq89F4ECQf3Bt4 +ZhGsXDTaA/VgHmf3AULbrC94O7HNqOvTWzwGiWHLfcxXQsr+ijIEQvh6rHKmJK8R +9NMHqc3L18eMO6bqrzEHW0Xoiu9W8Yj+WuB3IKdhclT3w0pO4Pj8gQARAQABiQI8 +BBgBCgAmAhsMFiEEyHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWR+0FCRCs7NQACgkQ +NDZdlHLXRo/R0A//QW1opBlzWSmWww1q9QuJA2WCIIs8tJKRDOsmgJPscNpzwZFU +N1Df0wWNjqi1BDReei7lZTHwUk+ebBn0bkI3ANmmgYg7LBueAt5UWSingOc+rvKA +N32BDzBYkMckRzJSQsmeC5hm3J3wLSy90uaIlrJJE9GJZkf/W2Ob+4SQZZ+dnnRP +JokDdW1DuZS9PbxSLJKD5eIWHBxJnFM1CmHfOfrjTJ+MYvVGM5sxSY8R7E+GADj5 +L/i4N+tTFJLuTMYARGfA6d+KPKcMJtgpUPjSMAg8nGUhukctpuBs27mOKW0CBtmJ +82X/qYROTL0+vGTvUYflYiuceVlhX/kw0JZnMaG5V/mpHq8SwD07pCGOf69j/mNa +5EL3++Pmzg0s0stw3Ea5pCN0cL/nKkoWchHBfW15W4JOnKAIspyD1vH670P4WfeV +E9B9d6tgKSbM/9JlXoQS5ZdG+kbdosieELhmVWmvojyK7K+Ry6C9wgd+UfnW5jXd +iNwKW3KHuautQwlFhHRNMyDg08c+pI5emTMT3IUQyGWo+Gska3TqGujFcABx7Ip+ +mHNmMrCkSD+XC2bvzvRR7FcM0/B9fsjLX/Wttm5vRJ1d2oAoEPvw2IZnJIXpOt2z +zo55sJTztNu4lWGgDVgtp9SXO5a0E5YvFHQNZN5QLeVTTFu6I7qG+ME1E/K5Ag0E +YH3+JQEQALivllTjMolxUW2OxrXb+a2Pt6vjCBsiJzrUj0Pa63U+lT9jldbCCfgP +wDpcDuO1O05Q8k1MoYZ6HddjWnqKG7S3eqkV5c3ct3amAXp513QDKZUfIDylOmhU +qvxjEgvGjdRjz6kECFGYr6Vnj/p6AwWv4/FBRFlrq7cnQgPynbIH4hrWvewp3Tqw +GVgqm5RRofuAugi8iZQVlAiQZJo88yaztAQ/7VsXBiHTn61ugQ8bKdAsr8w/ZZU5 +HScHLqRolcYg0cKN91c0EbJq9k1LUC//CakPB9mhi5+aUVUGusIM8ECShUEgSTCi +KQiJUPZ2CFbbPE9L5o9xoPCxjXoX+r7L/WyoCPTeoS3YRUMEnWKvc42Yxz3meRb+ +BmaqgbheNmzOah5nMwPupJYmHrjWPkX7oyyHxLSFw4dtoP2j6Z7GdRXKa2dUYdk2 +x3JYKocrDoPHh3Q0TAZujtpdjFi1BS8pbxYFb3hHmGSdvz7T7KcqP7ChC7k2RAKO +GiG7QQe4NX3sSMgweYpl4OwvQOn73t5CVWYp/gIBNZGsU3Pto8g27vHeWyH9mKr4 +cSepDhw+/X8FGRNdxNfpLKm7Vc0Sm9Sof8TRFrBTqX+vIQupYHRi5QQCuYaV6OVr +ITeegNK3So4m39d6ajCR9QxRbmjnx9UcnSYYDmIB6fpBuwT0ogNtABEBAAGJBHIE +GAEKACYCGwIWIQTIdAEfCrQFEQ0CEFU0Nl2UctdGjwUCYH4bgAUJAeFQ2wJAwXQg +BBkBCgAdFiEEs2y6kaLAcwxDX8KAsLRBCXaFtnYFAmB9/iUACgkQsLRBCXaFtnYX +BhAAlxejyFXoQwyGo9U+2g9N6LUb/tNtH29RHYxy4A3/ZUY7d/FMkArmh4+dfjf0 +p9MJz98Zkps20kaYP+2YzYmaizO6OA6RIddcEXQDRCPHmLts3097mJ/skx9qLAf6 +rh9J7jWeSqWO6VW6Mlx8j9m7sm3Ae1OsjOx/m7lGZOhY4UYfY627+Jf7WQ5103Qs +lgQ09es/vhTCx0g34SYEmMW15Tc3eCjQ21b1MeJD/V26npeakV8iCZ1kHZHawPq/ +aCCuYEcCeQOOteTWvl7HXaHMhHIx7jjOd8XX9V+UxsGz2WCIxX/j7EEEc7CAxwAN +nWp9jXeLfxYfjrUB7XQZsGCd4EHHzUyCf7iRJL7OJ3tz5Z+rOlNjSgci+ycHEccL +YeFAEV+Fz+sj7q4cFAferkr7imY1XEI0Ji5P8p/uRYw/n8uUf7LrLw5TzHmZsTSC +UaiL4llRzkDC6cVhYfqQWUXDd/r385OkE4oalNNE+n+txNRx92rpvXWZ5qFYfv7E +95fltvpXc0iOugPMzyof3lwo3Xi4WZKc1CC/jEviKTQhfn3WZukuF5lbz3V1PQfI +xFsYe9WYQmp25XGgezjXzp89C/OIcYsVB1KJAKihgbYdHyUN4fRCmOszmOUwEAKR +3k5j4X8V5bk08sA69NVXPn2ofxyk3YYOMYWW8ouObnXoS8QJEDQ2XZRy10aPMpsQ +AIbwX21erVqUDMPn1uONP6o4NBEq4MwG7d+fT85rc1U0RfeKBwjucAE/iStZDQoM +ZKWvGhFR+uoyg1LrXNKuSPB82unh2bpvj4zEnJsJadiwtShTKDsikhrfFEK3aCK8 +Zuhpiu3jxMFDhpFzlxsSwaCcGJqcdwGhWUx0ZAVD2X71UCFoOXPjF9fNnpy80YNp +flPjj2RnOZbJyBIM0sWIVMd8F44qkTASf8K5Qb47WFN5tSpePq7OCm7s8u+lYZGK +wR18K7VliundR+5a8XAOyUXOL5UsDaQCK4Lj4lRaeFXunXl3DJ4E+7BKzZhReJL6 +EugV5eaGonA52TWtFdB8p+79wPUeI3KcdPmQ9Ll5Zi/jBemY4bzasmgKzNeMtwWP +fk6WgrvBwptqohw71HDymGxFUnUP7XYYjic2sVKhv9AevMGycVgwWBiWroDCQ9Ja +btKfxHhI2p+g+rcywmBobWJbZsujTNjhtme+kNn1mhJsD3bKPjKQfAxaTskBLb0V +wgV21891TS1Dq9kdPLwoS4XNpYg2LLB4p9hmeG3fu9+OmqwY5oKXsHiWc43dei9Y +yxZ1AAUOIaIdPkq+YG/PhlGE4YcQZ4RPpltAr0HfGgZhmXWigbGS+66pUj+Ojysc +j0K5tCVxVu0fhhFpOlHv0LWaxCbnkgkQH9jfMEJkAWMOuQINBGCAXCYBEADW6RNr +ZVGNXvHVBqSiOWaxl1XOiEoiHPt50Aijt25yXbG+0kHIFSoR+1g6Lh20JTCChgfQ +kGGjzQvEuG1HTw07YhsvLc0pkjNMfu6gJqFox/ogc53mz69OxXauzUQ/TZ27GDVp +UBu+EhDKt1s3OtA6Bjz/csop/Um7gT0+ivHyvJ/jGdnPEZv8tNuSE/Uo+hn/Q9hg +8SbveZzo3C+U4KcabCESEFl8Gq6aRi9vAfa65oxD5jKaIz7cy+pwb0lizqlW7H9t +Qlr3dBfdIcdzgR55hTFC5/XrcwJ6/nHVH/xGskEasnfCQX8RYKMuy0UADJy72TkZ +bYaCx+XXIcVB8GTOmJVoAhrTSSVLAZspfCnjwnSxisDn3ZzsYrq3cV6sU8b+QlIX +7VAjurE+5cZiVlaxgCjyhKqlGgmonnReWOBacCgL/UvuwMmMp5TTLmiLXLT7uxeG +ojEyoCk4sMrqrU1jevHyGlDJH9Taux15GILDwnYFfAvPF9WCid4UZ4Ouwjcaxfys +3LxNiZIlUsXNKwS3mhiMRL4TRsbs4k4QE+LIMOsauIvcvm8/frydvQ/kUwIhVTH8 +0XGOH909bYtJvY3fudK7ShIwm7ZFTduBJUG473E/Fn3VkhTmBX6+PjOC50HR/Hyb +waRCzfDruMe3TAcE/tSP5CUOb9C7+P+hPzQcDwARAQABiQRyBBgBCgAmAhsCFiEE +yHQBHwq0BRENAhBVNDZdlHLXRo8FAmmWSAoFCRCqi+QCQMF0IAQZAQoAHRYhBDdO +x1tIWRNgSoMcx8ggxtXNJ6uHBQJggFwmAAoJEMggxtXNJ6uHRfAP/2CGdSyg0K7U +66Vygl0dugxrMm8O3/Oe211BKdQsFUSWAznOTRTK/zvMUHO4LJAlYvdtZ6xDa4XH +l9FYQ8MR9ZV0OuOlAZvU4IJDLPVCU09X/UzX/GEoZL0R5esvwPAXopMaRHCfXJeI +/gEaB94UhAeYlwpcRn0eSuk1vyZx7GRE6/hog8DCf4hoT40dW20gGe58xcvJ+mRY +lC0lr16WH08wuUcee6+dgu+4Cg6SG6+zt9cMyl8VnTUL5BK/V3MebnYZJK0RFDNn +nXDhzStgOd5gOeIL+xBPXHd0/ld/rDM74SFExpuS+hNsyo+xMQ/HJavak21MFinu +l9COwfGEmlAXTGMY30Lf3Pt/eAkbwgmGc966VSoRmOFEXJVlDr+yJR6ru+7j50z8 +lAv6Lsop7sun1Qysbo0swf6W1qgPf6VWbx91NTFLkw0+gD8jxwrU5ZMkeSuntX9d +pjuZS29CflXXIRPlvhuiDPicwTpYuIUx37vHveAH5gnowZg247x780Urrsx8duTX +8CI9MAnqzm4dFAiRlwE8bvLk+l9wekiXA9gIMZiVNqNlduXIqvAG21Wdgq8qyeXK +y/XWCVKDQOmEbFAltfNam8E3KEw0fl199x+93d5ckDGcPzUYPbNkCuIwngC/ZN96 +pDafF3Z12fSNfhZUe0C8td8KAszYa96GCRA0Nl2UctdGj1gKD/4jOGhEGTg88Vyu +PVjeK+zkwrTIZSvHdUHfTt/+rTLSNb/RQiBCUQuEZvafj6FrntS7bAEhccGqH894 +T3St5K0AXWkvsLd6K+cbIQdlnFA2zb6geJUCk6qx5NgWpRc3i0DS7CheGwl+Bwu7 ++n9pNjNjiHV+rYDgqbQXG0dtGysB0/3qIRgEDHFO0HJu/dcte4oXrQIqrZrpOwe8 +WxqFqdU918JpSUcc8coiFp9YtwpgqQNxGVZ+rhgnTGdZzk1f/Yhhimh+2B0ReaFv +k3UzVBj3HQ9C6+Ot3MyDEhSgdhjr9e25Tm9S5YfhwtWmghRw9RKPyLMSXSxm/Uc0 +mK1NucAp8TQBwKqKzNpCk5IdrBSWRUbjOoOFyzyCsY6gS285GCpSIzI39hTf+3gd +wYPlE6fj+F2TZzdhx62DPnzBzBHnByYTVdJ649bx0FFp4Q+5TbIWtxu/AQkRDxmW +NQfE+6GgeshlrhXWsh6+PGDzt+2raG6zUT913sdz7Ctw4fLjmsKOTdTz3Xa9pr8l +xfI/JuukSgt9o/n3GirhTB3zE1w/I/Xt6k7oASiP3zQSuHtB/CYKYHDtOCWwjo7J +PEGtb/FkreKNxsk/p20jnlrB8WZxxswdr2Vri9NmFeyMDVX7qF3WqT+8aCV9GtS1 +GCHx/5nGBdDwoxEsXqpI3IUqPb6FDg== +=wtp+ -----END PGP PUBLIC KEY BLOCK-----` diff --git a/internal/repl/session_test.go b/internal/repl/session_test.go index 71c82849d2..9609270ebe 100644 --- a/internal/repl/session_test.go +++ b/internal/repl/session_test.go @@ -14,11 +14,11 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs/configschema" - "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" + tftesting "github.com/hashicorp/terraform/internal/terraform/testing" _ "github.com/hashicorp/terraform/internal/logging" ) @@ -275,7 +275,7 @@ func testSession(t *testing.T, test testSessionTest) { }, } - config, _, cleanup, configDiags := initwd.LoadConfigForTests(t, "testdata/config-fixture", "tests") + config, _, cleanup, configDiags := tftesting.LoadConfigForTests(t, "testdata/config-fixture", "tests") defer cleanup() if configDiags.HasErrors() { t.Fatalf("unexpected problems loading config: %s", configDiags.Err()) diff --git a/internal/rpcapi/dependencies_provider_schema.go b/internal/rpcapi/dependencies_provider_schema.go index 95dd517d47..812bd83999 100644 --- a/internal/rpcapi/dependencies_provider_schema.go +++ b/internal/rpcapi/dependencies_provider_schema.go @@ -141,6 +141,7 @@ func providerSchemaToProto(schemaResp providers.GetProviderSchemaResponse) *depe mrtSchemas := make(map[string]*dependencies.Schema, len(schemaResp.ResourceTypes)) drtSchemas := make(map[string]*dependencies.Schema, len(schemaResp.DataSources)) + actionSchemas := make(map[string]*dependencies.ActionSchema, len(schemaResp.Actions)) for name, elem := range schemaResp.ResourceTypes { mrtSchemas[name] = schemaElementToProto(elem) @@ -148,11 +149,15 @@ func providerSchemaToProto(schemaResp providers.GetProviderSchemaResponse) *depe for name, elem := range schemaResp.DataSources { drtSchemas[name] = schemaElementToProto(elem) } + for name, elem := range schemaResp.Actions { + actionSchemas[name] = actionElementToProto(elem) + } return &dependencies.ProviderSchema{ ProviderConfig: schemaElementToProto(schemaResp.Provider), ManagedResourceTypes: mrtSchemas, DataResourceTypes: drtSchemas, + ActionTypes: actionSchemas, } } @@ -162,6 +167,14 @@ func schemaElementToProto(elem providers.Schema) *dependencies.Schema { } } +func actionElementToProto(elem providers.ActionSchema) *dependencies.ActionSchema { + return &dependencies.ActionSchema{ + Schema: &dependencies.Schema{ + Block: schemaBlockToProto(elem.ConfigSchema), + }, + } +} + func schemaBlockToProto(block *configschema.Block) *dependencies.Schema_Block { if block == nil { return &dependencies.Schema_Block{} diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index ca1e05783d..e39c71d103 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -928,7 +928,6 @@ func (s *stacksServer) CloseTerraformState(ctx context.Context, request *stacks. } func (s *stacksServer) MigrateTerraformState(request *stacks.MigrateTerraformState_Request, server stacks.Stacks_MigrateTerraformStateServer) error { - previousStateHandle := handle[*states.State](request.StateHandle) previousState := s.handles.TerraformState(previousStateHandle) if previousState == nil { @@ -1207,6 +1206,86 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou return span }, + ReportActionInvocationPlanned: func(ctx context.Context, span any, ai *hooks.ActionInvocation) any { + span.(trace.Span).AddEvent("planned action invocation", trace.WithAttributes( + attribute.String("component_instance", ai.Addr.Component.String()), + attribute.String("action_invocation_instance", ai.Addr.Item.String()), + )) + + inv, err := actionInvocationPlanned(ai) + if err != nil { + return span + } + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationPlanned_{ + ActionInvocationPlanned: inv, + }, + }) + + return span + }, + + ReportActionInvocationStatus: func(ctx context.Context, span any, statusData *hooks.ActionInvocationStatusHookData) any { + span.(trace.Span).AddEvent("action invocation status", trace.WithAttributes( + attribute.String("component_instance", statusData.Addr.Component.String()), + attribute.String("action_invocation_instance", statusData.Addr.Item.String()), + attribute.String("status", statusData.Status.String()), + )) + + providerAddr := "" + if !statusData.ProviderAddr.IsZero() { + providerAddr = statusData.ProviderAddr.String() + } + + protoStatus := &stacks.StackChangeProgress_ActionInvocationStatus{ + Addr: stacks.NewActionInvocationInStackAddr(statusData.Addr), + Status: statusData.Status.ForProtobuf(), + ProviderAddr: providerAddr, + } + + // Set the action trigger oneof + setActionInvocationStatusTrigger(protoStatus, statusData.Addr.Component, statusData.Trigger) + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationStatus_{ + ActionInvocationStatus: protoStatus, + }, + }) + + return span + }, + + ReportActionInvocationProgress: func(ctx context.Context, span any, progressData *hooks.ActionInvocationProgressHookData) any { + span.(trace.Span).AddEvent("action invocation progress", trace.WithAttributes( + attribute.String("component_instance", progressData.Addr.Component.String()), + attribute.String("action_invocation_instance", progressData.Addr.Item.String()), + attribute.String("message", progressData.Message), + )) + + providerAddr := "" + if !progressData.ProviderAddr.IsZero() { + providerAddr = progressData.ProviderAddr.String() + } + + protoProgress := &stacks.StackChangeProgress_ActionInvocationProgress{ + Addr: stacks.NewActionInvocationInStackAddr(progressData.Addr), + Message: progressData.Message, + ProviderAddr: providerAddr, + } + + // Set the action trigger oneof + setActionInvocationProgressTrigger(protoProgress, progressData.Addr.Component, progressData.Trigger) + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationProgress_{ + ActionInvocationProgress: protoProgress, + }, + }) + + return span + }, + ReportResourceInstanceDeferred: func(ctx context.Context, span any, change *hooks.DeferredResourceInstanceChange) any { span.(trace.Span).AddEvent("deferred resource instance", trace.WithAttributes( attribute.String("component_instance", change.Change.Addr.Component.String()), @@ -1241,14 +1320,15 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou ComponentAddr: stackaddrs.ConfigComponentForAbsInstance(cic.Addr).String(), ComponentInstanceAddr: cic.Addr.String(), }, - Total: int32(cic.Total()), - Add: int32(cic.Add), - Change: int32(cic.Change), - Import: int32(cic.Import), - Remove: int32(cic.Remove), - Defer: int32(cic.Defer), - Move: int32(cic.Move), - Forget: int32(cic.Forget), + Total: int32(cic.Total()), + Add: int32(cic.Add), + Change: int32(cic.Change), + Import: int32(cic.Import), + Remove: int32(cic.Remove), + Defer: int32(cic.Defer), + Move: int32(cic.Move), + Forget: int32(cic.Forget), + ActionInvocation: int32(cic.ActionInvocation), }, }, }) @@ -1268,14 +1348,15 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou ComponentAddr: stackaddrs.ConfigComponentForAbsInstance(cic.Addr).String(), ComponentInstanceAddr: cic.Addr.String(), }, - Total: int32(cic.Total()), - Add: int32(cic.Add), - Change: int32(cic.Change), - Import: int32(cic.Import), - Remove: int32(cic.Remove), - Defer: int32(cic.Defer), - Move: int32(cic.Move), - Forget: int32(cic.Forget), + Total: int32(cic.Total()), + Add: int32(cic.Add), + Change: int32(cic.Change), + Import: int32(cic.Import), + Remove: int32(cic.Remove), + Defer: int32(cic.Defer), + Move: int32(cic.Move), + Forget: int32(cic.Forget), + ActionInvocation: int32(cic.ActionInvocation), }, }, }) @@ -1317,6 +1398,89 @@ func resourceInstancePlanned(ric *hooks.ResourceInstanceChange) (*stacks.StackCh }, nil } +func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangeProgress_ActionInvocationPlanned, error) { + res := &stacks.StackChangeProgress_ActionInvocationPlanned{ + Addr: stacks.NewActionInvocationInStackAddr(ai.Addr), + ProviderAddr: ai.ProviderAddr.String(), + } + + setActionInvocationPlannedTrigger(res, ai.Addr.Component, ai.Trigger) + + return res, nil +} + +// setActionInvocationStatusTrigger sets the ActionTrigger oneof field on an ActionInvocationStatus message. +func setActionInvocationStatusTrigger(msg *stacks.StackChangeProgress_ActionInvocationStatus, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { + case *plans.ResourceActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + } +} + +// setActionInvocationProgressTrigger sets the ActionTrigger oneof field on an ActionInvocationProgress message. +func setActionInvocationProgressTrigger(msg *stacks.StackChangeProgress_ActionInvocationProgress, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { + case *plans.ResourceActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + } +} + +// setActionInvocationPlannedTrigger sets the ActionTrigger oneof field on an ActionInvocationPlanned message. +func setActionInvocationPlannedTrigger(msg *stacks.StackChangeProgress_ActionInvocationPlanned, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { + case *plans.ResourceActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + } +} + func evtComponentInstanceStatus(ci stackaddrs.AbsComponentInstance, status hooks.ComponentInstanceStatus) *stacks.StackChangeProgress { return &stacks.StackChangeProgress{ Event: &stacks.StackChangeProgress_ComponentInstanceStatus_{ diff --git a/internal/rpcapi/stacks_test.go b/internal/rpcapi/stacks_test.go index 676da2a4ed..82881999b4 100644 --- a/internal/rpcapi/stacks_test.go +++ b/internal/rpcapi/stacks_test.go @@ -640,7 +640,6 @@ func TestStackChangeProgressDuringPlanNormal(t *testing.T) { }, want: []*stacks.StackChangeProgress{ { - Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{ ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{ Addr: &stacks.ComponentInstanceInStackAddr{ @@ -695,7 +694,8 @@ func TestStackChangeProgressDuringPlanNormal(t *testing.T) { ProviderAddr: "registry.terraform.io/hashicorp/testing", }, }, - }, { + }, + { Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{ ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{ Addr: &stacks.ComponentInstanceInStackAddr{ @@ -982,6 +982,27 @@ func TestStackChangeProgressDuringPlanNormal(t *testing.T) { }, }, }, + "action_invocations": { + // This test verifies that the ActionInvocation field exists in ComponentInstanceChanges + // and is included in the total count. Once we implement action invocation tracking logic, + // this field will have a value > 0 for components with actions. + source: "git::https://example.com/action_invocations.git", + want: []*stacks.StackChangeProgress{ + { + Event: &stacks.StackChangeProgress_ComponentInstanceChanges_{ + ComponentInstanceChanges: &stacks.StackChangeProgress_ComponentInstanceChanges{ + Addr: &stacks.ComponentInstanceInStackAddr{ + ComponentAddr: "component.self", + ComponentInstanceAddr: "component.self", + }, + Total: 2, + Add: 1, + ActionInvocation: 1, + }, + }, + }, + }, + }, } for name, tc := range tcs { @@ -1588,7 +1609,6 @@ func TestStackChangeProgressDuringApply(t *testing.T) { return values }(), }) - if err != nil { t.Fatalf("unexpected error: %s", err) } @@ -1896,7 +1916,6 @@ func TestStacksMigrateTerraformState(t *testing.T) { }, }, }) - if err != nil { t.Fatalf("unexpected error: %s", err) } diff --git a/internal/rpcapi/terraform1/stacks/conversion.go b/internal/rpcapi/terraform1/stacks/conversion.go index 9a3728fc57..4d87fdfe58 100644 --- a/internal/rpcapi/terraform1/stacks/conversion.go +++ b/internal/rpcapi/terraform1/stacks/conversion.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" @@ -45,6 +46,52 @@ func ChangeTypesForPlanAction(action plans.Action) ([]ChangeType, error) { } } +// ActionTriggerEventForStackChangeProgress converts a [configs.ActionTriggerEvent] +// to its [StackChangeProgress_ActionTriggerEvent] protobuf equivalent. +func ActionTriggerEventForStackChangeProgress(event configs.ActionTriggerEvent) (StackChangeProgress_ActionTriggerEvent, error) { + switch event { + case configs.BeforeCreate: + return StackChangeProgress_BEFORE_CREATE, nil + case configs.AfterCreate: + return StackChangeProgress_AFTER_CREATE, nil + case configs.BeforeUpdate: + return StackChangeProgress_BEFORE_UPDATE, nil + case configs.AfterUpdate: + return StackChangeProgress_AFTER_UPDATE, nil + case configs.BeforeDestroy: + return StackChangeProgress_BEFORE_DESTROY, nil + case configs.AfterDestroy: + return StackChangeProgress_AFTER_DESTROY, nil + case configs.Invoke: + return StackChangeProgress_INVOKE, nil + default: + return StackChangeProgress_INVALID_EVENT, fmt.Errorf("unsupported trigger event %s", event) + } +} + +// ActionTriggerEventForPlannedChange converts a [configs.ActionTriggerEvent] +// to its [PlannedChange_ActionTriggerEvent] protobuf equivalent. +func ActionTriggerEventForPlannedChange(event configs.ActionTriggerEvent) (PlannedChange_ActionTriggerEvent, error) { + switch event { + case configs.BeforeCreate: + return PlannedChange_BEFORE_CREATE, nil + case configs.AfterCreate: + return PlannedChange_AFTER_CREATE, nil + case configs.BeforeUpdate: + return PlannedChange_BEFORE_UPDATE, nil + case configs.AfterUpdate: + return PlannedChange_AFTER_UPDATE, nil + case configs.BeforeDestroy: + return PlannedChange_BEFORE_DESTROY, nil + case configs.AfterDestroy: + return PlannedChange_AFTER_DESTROY, nil + case configs.Invoke: + return PlannedChange_INVOKE, nil + default: + return PlannedChange_INVALID_EVENT, fmt.Errorf("unsupported trigger event %s", event) + } +} + // ToDynamicValue uses NewDynamicValue to construct a DynamicValue from the // provider cty.Value. This function will strip out the sensitive paths and // include them in the returned dynamic value. If from contains marks other diff --git a/internal/rpcapi/terraform1/stacks/conversion_test.go b/internal/rpcapi/terraform1/stacks/conversion_test.go index 7c0d89a28e..341f4e2c3b 100644 --- a/internal/rpcapi/terraform1/stacks/conversion_test.go +++ b/internal/rpcapi/terraform1/stacks/conversion_test.go @@ -10,9 +10,68 @@ import ( "google.golang.org/protobuf/testing/protocmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" ) +func TestActionTriggerEventForStackChangeProgress(t *testing.T) { + tests := []struct { + event configs.ActionTriggerEvent + want StackChangeProgress_ActionTriggerEvent + wantErr bool + }{ + {configs.BeforeCreate, StackChangeProgress_BEFORE_CREATE, false}, + {configs.AfterCreate, StackChangeProgress_AFTER_CREATE, false}, + {configs.BeforeUpdate, StackChangeProgress_BEFORE_UPDATE, false}, + {configs.AfterUpdate, StackChangeProgress_AFTER_UPDATE, false}, + {configs.BeforeDestroy, StackChangeProgress_BEFORE_DESTROY, false}, + {configs.AfterDestroy, StackChangeProgress_AFTER_DESTROY, false}, + {configs.Invoke, StackChangeProgress_INVOKE, false}, + {configs.Unknown, StackChangeProgress_INVALID_EVENT, true}, + } + + for _, tt := range tests { + t.Run(tt.event.String(), func(t *testing.T) { + got, err := ActionTriggerEventForStackChangeProgress(tt.event) + if (err != nil) != tt.wantErr { + t.Fatalf("ActionTriggerEventForStackChangeProgress(%v) error = %v, wantErr %v", tt.event, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ActionTriggerEventForStackChangeProgress(%v) = %v, want %v", tt.event, got, tt.want) + } + }) + } +} + +func TestActionTriggerEventForPlannedChange(t *testing.T) { + tests := []struct { + event configs.ActionTriggerEvent + want PlannedChange_ActionTriggerEvent + wantErr bool + }{ + {configs.BeforeCreate, PlannedChange_BEFORE_CREATE, false}, + {configs.AfterCreate, PlannedChange_AFTER_CREATE, false}, + {configs.BeforeUpdate, PlannedChange_BEFORE_UPDATE, false}, + {configs.AfterUpdate, PlannedChange_AFTER_UPDATE, false}, + {configs.BeforeDestroy, PlannedChange_BEFORE_DESTROY, false}, + {configs.AfterDestroy, PlannedChange_AFTER_DESTROY, false}, + {configs.Invoke, PlannedChange_INVOKE, false}, + {configs.Unknown, PlannedChange_INVALID_EVENT, true}, + } + + for _, tt := range tests { + t.Run(tt.event.String(), func(t *testing.T) { + got, err := ActionTriggerEventForPlannedChange(tt.event) + if (err != nil) != tt.wantErr { + t.Fatalf("ActionTriggerEventForPlannedChange(%v) error = %v, wantErr %v", tt.event, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ActionTriggerEventForPlannedChange(%v) = %v, want %v", tt.event, got, tt.want) + } + }) + } +} + func TestNewActionInvocationInStackAddr(t *testing.T) { tests := []struct { name string diff --git a/internal/rpcapi/terraform1/stacks/stacks.pb.go b/internal/rpcapi/terraform1/stacks/stacks.pb.go index 491d9ae8fb..7b33b0f703 100644 --- a/internal/rpcapi/terraform1/stacks/stacks.pb.go +++ b/internal/rpcapi/terraform1/stacks/stacks.pb.go @@ -5231,7 +5231,7 @@ type PlannedChange_ActionInvocationInstance struct { ConfigValue *DynamicValue `protobuf:"bytes,4,opt,name=config_value,json=configValue,proto3" json:"config_value,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *PlannedChange_ActionInvocationInstance_LifecycleActionTrigger + // *PlannedChange_ActionInvocationInstance_ResourceActionTrigger // *PlannedChange_ActionInvocationInstance_InvokeActionTrigger ActionTrigger isPlannedChange_ActionInvocationInstance_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -5303,10 +5303,10 @@ func (x *PlannedChange_ActionInvocationInstance) GetActionTrigger() isPlannedCha return nil } -func (x *PlannedChange_ActionInvocationInstance) GetLifecycleActionTrigger() *PlannedChange_LifecycleActionTrigger { +func (x *PlannedChange_ActionInvocationInstance) GetResourceActionTrigger() *PlannedChange_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*PlannedChange_ActionInvocationInstance_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*PlannedChange_ActionInvocationInstance_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -5325,15 +5325,15 @@ type isPlannedChange_ActionInvocationInstance_ActionTrigger interface { isPlannedChange_ActionInvocationInstance_ActionTrigger() } -type PlannedChange_ActionInvocationInstance_LifecycleActionTrigger struct { - LifecycleActionTrigger *PlannedChange_LifecycleActionTrigger `protobuf:"bytes,6,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type PlannedChange_ActionInvocationInstance_ResourceActionTrigger struct { + ResourceActionTrigger *PlannedChange_ResourceActionTrigger `protobuf:"bytes,6,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type PlannedChange_ActionInvocationInstance_InvokeActionTrigger struct { InvokeActionTrigger *PlannedChange_InvokeActionTrigger `protobuf:"bytes,7,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*PlannedChange_ActionInvocationInstance_LifecycleActionTrigger) isPlannedChange_ActionInvocationInstance_ActionTrigger() { +func (*PlannedChange_ActionInvocationInstance_ResourceActionTrigger) isPlannedChange_ActionInvocationInstance_ActionTrigger() { } func (*PlannedChange_ActionInvocationInstance_InvokeActionTrigger) isPlannedChange_ActionInvocationInstance_ActionTrigger() { @@ -5397,9 +5397,9 @@ func (x *PlannedChange_ActionInvocationDeferred) GetActionInvocation() *PlannedC return nil } -// LifecycleActionTrigger contains details on the conditions that led to the +// ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. -type PlannedChange_LifecycleActionTrigger struct { +type PlannedChange_ResourceActionTrigger struct { state protoimpl.MessageState `protogen:"open.v1"` TriggeringResourceAddress *ResourceInstanceInStackAddr `protobuf:"bytes,1,opt,name=triggering_resource_address,json=triggeringResourceAddress,proto3" json:"triggering_resource_address,omitempty"` TriggerEvent PlannedChange_ActionTriggerEvent `protobuf:"varint,2,opt,name=trigger_event,json=triggerEvent,proto3,enum=terraform1.stacks.PlannedChange_ActionTriggerEvent" json:"trigger_event,omitempty"` @@ -5409,20 +5409,20 @@ type PlannedChange_LifecycleActionTrigger struct { sizeCache protoimpl.SizeCache } -func (x *PlannedChange_LifecycleActionTrigger) Reset() { - *x = PlannedChange_LifecycleActionTrigger{} +func (x *PlannedChange_ResourceActionTrigger) Reset() { + *x = PlannedChange_ResourceActionTrigger{} mi := &file_stacks_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *PlannedChange_LifecycleActionTrigger) String() string { +func (x *PlannedChange_ResourceActionTrigger) String() string { return protoimpl.X.MessageStringOf(x) } -func (*PlannedChange_LifecycleActionTrigger) ProtoMessage() {} +func (*PlannedChange_ResourceActionTrigger) ProtoMessage() {} -func (x *PlannedChange_LifecycleActionTrigger) ProtoReflect() protoreflect.Message { +func (x *PlannedChange_ResourceActionTrigger) ProtoReflect() protoreflect.Message { mi := &file_stacks_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -5434,33 +5434,33 @@ func (x *PlannedChange_LifecycleActionTrigger) ProtoReflect() protoreflect.Messa return mi.MessageOf(x) } -// Deprecated: Use PlannedChange_LifecycleActionTrigger.ProtoReflect.Descriptor instead. -func (*PlannedChange_LifecycleActionTrigger) Descriptor() ([]byte, []int) { +// Deprecated: Use PlannedChange_ResourceActionTrigger.ProtoReflect.Descriptor instead. +func (*PlannedChange_ResourceActionTrigger) Descriptor() ([]byte, []int) { return file_stacks_proto_rawDescGZIP(), []int{23, 8} } -func (x *PlannedChange_LifecycleActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { +func (x *PlannedChange_ResourceActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { if x != nil { return x.TriggeringResourceAddress } return nil } -func (x *PlannedChange_LifecycleActionTrigger) GetTriggerEvent() PlannedChange_ActionTriggerEvent { +func (x *PlannedChange_ResourceActionTrigger) GetTriggerEvent() PlannedChange_ActionTriggerEvent { if x != nil { return x.TriggerEvent } return PlannedChange_INVALID_EVENT } -func (x *PlannedChange_LifecycleActionTrigger) GetActionTriggerBlockIndex() int64 { +func (x *PlannedChange_ResourceActionTrigger) GetActionTriggerBlockIndex() int64 { if x != nil { return x.ActionTriggerBlockIndex } return 0 } -func (x *PlannedChange_LifecycleActionTrigger) GetActionsListIndex() int64 { +func (x *PlannedChange_ResourceActionTrigger) GetActionsListIndex() int64 { if x != nil { return x.ActionsListIndex } @@ -6432,7 +6432,7 @@ type StackChangeProgress_ActionInvocationPlanned struct { ProviderAddr string `protobuf:"bytes,2,opt,name=provider_addr,json=providerAddr,proto3" json:"provider_addr,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger + // *StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger // *StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger ActionTrigger isStackChangeProgress_ActionInvocationPlanned_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -6490,10 +6490,10 @@ func (x *StackChangeProgress_ActionInvocationPlanned) GetActionTrigger() isStack return nil } -func (x *StackChangeProgress_ActionInvocationPlanned) GetLifecycleActionTrigger() *StackChangeProgress_LifecycleActionTrigger { +func (x *StackChangeProgress_ActionInvocationPlanned) GetResourceActionTrigger() *StackChangeProgress_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -6512,15 +6512,15 @@ type isStackChangeProgress_ActionInvocationPlanned_ActionTrigger interface { isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() } -type StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger struct { - LifecycleActionTrigger *StackChangeProgress_LifecycleActionTrigger `protobuf:"bytes,3,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger struct { + ResourceActionTrigger *StackChangeProgress_ResourceActionTrigger `protobuf:"bytes,3,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger struct { InvokeActionTrigger *StackChangeProgress_InvokeActionTrigger `protobuf:"bytes,4,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger) isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() { +func (*StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger) isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() { } func (*StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger) isStackChangeProgress_ActionInvocationPlanned_ActionTrigger() { @@ -6533,7 +6533,7 @@ type StackChangeProgress_ActionInvocationStatus struct { ProviderAddr string `protobuf:"bytes,3,opt,name=provider_addr,json=providerAddr,proto3" json:"provider_addr,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger + // *StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger // *StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger ActionTrigger isStackChangeProgress_ActionInvocationStatus_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -6598,10 +6598,10 @@ func (x *StackChangeProgress_ActionInvocationStatus) GetActionTrigger() isStackC return nil } -func (x *StackChangeProgress_ActionInvocationStatus) GetLifecycleActionTrigger() *StackChangeProgress_LifecycleActionTrigger { +func (x *StackChangeProgress_ActionInvocationStatus) GetResourceActionTrigger() *StackChangeProgress_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -6620,15 +6620,15 @@ type isStackChangeProgress_ActionInvocationStatus_ActionTrigger interface { isStackChangeProgress_ActionInvocationStatus_ActionTrigger() } -type StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger struct { - LifecycleActionTrigger *StackChangeProgress_LifecycleActionTrigger `protobuf:"bytes,4,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger struct { + ResourceActionTrigger *StackChangeProgress_ResourceActionTrigger `protobuf:"bytes,4,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger struct { InvokeActionTrigger *StackChangeProgress_InvokeActionTrigger `protobuf:"bytes,5,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger) isStackChangeProgress_ActionInvocationStatus_ActionTrigger() { +func (*StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger) isStackChangeProgress_ActionInvocationStatus_ActionTrigger() { } func (*StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger) isStackChangeProgress_ActionInvocationStatus_ActionTrigger() { @@ -6641,7 +6641,7 @@ type StackChangeProgress_ActionInvocationProgress struct { ProviderAddr string `protobuf:"bytes,3,opt,name=provider_addr,json=providerAddr,proto3" json:"provider_addr,omitempty"` // Types that are valid to be assigned to ActionTrigger: // - // *StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger + // *StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger // *StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger ActionTrigger isStackChangeProgress_ActionInvocationProgress_ActionTrigger `protobuf_oneof:"action_trigger"` unknownFields protoimpl.UnknownFields @@ -6706,10 +6706,10 @@ func (x *StackChangeProgress_ActionInvocationProgress) GetActionTrigger() isStac return nil } -func (x *StackChangeProgress_ActionInvocationProgress) GetLifecycleActionTrigger() *StackChangeProgress_LifecycleActionTrigger { +func (x *StackChangeProgress_ActionInvocationProgress) GetResourceActionTrigger() *StackChangeProgress_ResourceActionTrigger { if x != nil { - if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger); ok { - return x.LifecycleActionTrigger + if x, ok := x.ActionTrigger.(*StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger); ok { + return x.ResourceActionTrigger } } return nil @@ -6728,23 +6728,23 @@ type isStackChangeProgress_ActionInvocationProgress_ActionTrigger interface { isStackChangeProgress_ActionInvocationProgress_ActionTrigger() } -type StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger struct { - LifecycleActionTrigger *StackChangeProgress_LifecycleActionTrigger `protobuf:"bytes,4,opt,name=lifecycle_action_trigger,json=lifecycleActionTrigger,proto3,oneof"` +type StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger struct { + ResourceActionTrigger *StackChangeProgress_ResourceActionTrigger `protobuf:"bytes,4,opt,name=resource_action_trigger,json=resourceActionTrigger,proto3,oneof"` } type StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger struct { InvokeActionTrigger *StackChangeProgress_InvokeActionTrigger `protobuf:"bytes,5,opt,name=invoke_action_trigger,json=invokeActionTrigger,proto3,oneof"` } -func (*StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger) isStackChangeProgress_ActionInvocationProgress_ActionTrigger() { +func (*StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger) isStackChangeProgress_ActionInvocationProgress_ActionTrigger() { } func (*StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger) isStackChangeProgress_ActionInvocationProgress_ActionTrigger() { } -// LifecycleActionTrigger contains details on the conditions that led to the +// ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. -type StackChangeProgress_LifecycleActionTrigger struct { +type StackChangeProgress_ResourceActionTrigger struct { state protoimpl.MessageState `protogen:"open.v1"` TriggeringResourceAddress *ResourceInstanceInStackAddr `protobuf:"bytes,1,opt,name=triggering_resource_address,json=triggeringResourceAddress,proto3" json:"triggering_resource_address,omitempty"` TriggerEvent StackChangeProgress_ActionTriggerEvent `protobuf:"varint,2,opt,name=trigger_event,json=triggerEvent,proto3,enum=terraform1.stacks.StackChangeProgress_ActionTriggerEvent" json:"trigger_event,omitempty"` @@ -6754,20 +6754,20 @@ type StackChangeProgress_LifecycleActionTrigger struct { sizeCache protoimpl.SizeCache } -func (x *StackChangeProgress_LifecycleActionTrigger) Reset() { - *x = StackChangeProgress_LifecycleActionTrigger{} +func (x *StackChangeProgress_ResourceActionTrigger) Reset() { + *x = StackChangeProgress_ResourceActionTrigger{} mi := &file_stacks_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *StackChangeProgress_LifecycleActionTrigger) String() string { +func (x *StackChangeProgress_ResourceActionTrigger) String() string { return protoimpl.X.MessageStringOf(x) } -func (*StackChangeProgress_LifecycleActionTrigger) ProtoMessage() {} +func (*StackChangeProgress_ResourceActionTrigger) ProtoMessage() {} -func (x *StackChangeProgress_LifecycleActionTrigger) ProtoReflect() protoreflect.Message { +func (x *StackChangeProgress_ResourceActionTrigger) ProtoReflect() protoreflect.Message { mi := &file_stacks_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -6779,33 +6779,33 @@ func (x *StackChangeProgress_LifecycleActionTrigger) ProtoReflect() protoreflect return mi.MessageOf(x) } -// Deprecated: Use StackChangeProgress_LifecycleActionTrigger.ProtoReflect.Descriptor instead. -func (*StackChangeProgress_LifecycleActionTrigger) Descriptor() ([]byte, []int) { +// Deprecated: Use StackChangeProgress_ResourceActionTrigger.ProtoReflect.Descriptor instead. +func (*StackChangeProgress_ResourceActionTrigger) Descriptor() ([]byte, []int) { return file_stacks_proto_rawDescGZIP(), []int{26, 7} } -func (x *StackChangeProgress_LifecycleActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { +func (x *StackChangeProgress_ResourceActionTrigger) GetTriggeringResourceAddress() *ResourceInstanceInStackAddr { if x != nil { return x.TriggeringResourceAddress } return nil } -func (x *StackChangeProgress_LifecycleActionTrigger) GetTriggerEvent() StackChangeProgress_ActionTriggerEvent { +func (x *StackChangeProgress_ResourceActionTrigger) GetTriggerEvent() StackChangeProgress_ActionTriggerEvent { if x != nil { return x.TriggerEvent } return StackChangeProgress_INVALID_EVENT } -func (x *StackChangeProgress_LifecycleActionTrigger) GetActionTriggerBlockIndex() int64 { +func (x *StackChangeProgress_ResourceActionTrigger) GetActionTriggerBlockIndex() int64 { if x != nil { return x.ActionTriggerBlockIndex } return 0 } -func (x *StackChangeProgress_LifecycleActionTrigger) GetActionsListIndex() int64 { +func (x *StackChangeProgress_ResourceActionTrigger) GetActionsListIndex() int64 { if x != nil { return x.ActionsListIndex } @@ -7671,7 +7671,7 @@ const file_stacks_proto_rawDesc = "" + "\x17component_instance_addr\x18\x01 \x01(\tR\x15componentInstanceAddr\x124\n" + "\x16resource_instance_addr\x18\x02 \x01(\tR\x14resourceInstanceAddr\x12\x1f\n" + "\vdeposed_key\x18\x03 \x01(\tR\n" + - "deposedKey\"\xc2 \n" + + "deposedKey\"\xbe \n" + "\rPlannedChange\x12&\n" + "\x03raw\x18\x01 \x03(\v2\x14.google.protobuf.AnyR\x03raw\x12V\n" + "\fdescriptions\x18\x02 \x03(\v22.terraform1.stacks.PlannedChange.ChangeDescriptionR\fdescriptions\x1a\xe9\x06\n" + @@ -7727,20 +7727,20 @@ const file_stacks_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x127\n" + "\aactions\x18\x02 \x03(\x0e2\x1d.terraform1.stacks.ChangeTypeR\aactions\x12=\n" + "\x06values\x18\x03 \x01(\v2%.terraform1.stacks.DynamicValueChangeR\x06values\x122\n" + - "\x15required_during_apply\x18\x04 \x01(\bR\x13requiredDuringApply\x1a\xe3\x03\n" + + "\x15required_during_apply\x18\x04 \x01(\bR\x13requiredDuringApply\x1a\xe0\x03\n" + "\x18ActionInvocationInstance\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12#\n" + "\rprovider_addr\x18\x02 \x01(\tR\fproviderAddr\x12\x1f\n" + "\vaction_type\x18\x03 \x01(\tR\n" + "actionType\x12B\n" + - "\fconfig_value\x18\x04 \x01(\v2\x1f.terraform1.stacks.DynamicValueR\vconfigValue\x12s\n" + - "\x18lifecycle_action_trigger\x18\x06 \x01(\v27.terraform1.stacks.PlannedChange.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12j\n" + + "\fconfig_value\x18\x04 \x01(\v2\x1f.terraform1.stacks.DynamicValueR\vconfigValue\x12p\n" + + "\x17resource_action_trigger\x18\x06 \x01(\v26.terraform1.stacks.PlannedChange.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12j\n" + "\x15invoke_action_trigger\x18\a \x01(\v24.terraform1.stacks.PlannedChange.InvokeActionTriggerH\x00R\x13invokeActionTriggerB\x10\n" + "\x0eaction_trigger\x1a\xbb\x01\n" + "\x18ActionInvocationDeferred\x127\n" + "\bdeferred\x18\x01 \x01(\v2\x1b.terraform1.stacks.DeferredR\bdeferred\x12f\n" + - "\x11action_invocation\x18\x02 \x01(\v29.terraform1.stacks.PlannedChange.ActionInvocationInstanceR\x10actionInvocation\x1a\xcd\x02\n" + - "\x16LifecycleActionTrigger\x12n\n" + + "\x11action_invocation\x18\x02 \x01(\v29.terraform1.stacks.PlannedChange.ActionInvocationInstanceR\x10actionInvocation\x1a\xcc\x02\n" + + "\x15ResourceActionTrigger\x12n\n" + "\x1btriggering_resource_address\x18\x01 \x01(\v2..terraform1.stacks.ResourceInstanceInStackAddrR\x19triggeringResourceAddress\x12X\n" + "\rtrigger_event\x18\x02 \x01(\x0e23.terraform1.stacks.PlannedChange.ActionTriggerEventR\ftriggerEvent\x12;\n" + "\x1aaction_trigger_block_index\x18\x03 \x01(\x03R\x17actionTriggerBlockIndex\x12,\n" + @@ -7800,7 +7800,7 @@ const file_stacks_proto_rawDesc = "" + "\rInputVariable\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12<\n" + "\tnew_value\x18\x02 \x01(\v2\x1f.terraform1.stacks.DynamicValueR\bnewValue\x1a\t\n" + - "\aNothing\"\xe3+\n" + + "\aNothing\"\xd9+\n" + "\x13StackChangeProgress\x12|\n" + "\x19component_instance_status\x18\x01 \x01(\v2>.terraform1.stacks.StackChangeProgress.ComponentInstanceStatusH\x00R\x17componentInstanceStatus\x12y\n" + "\x18resource_instance_status\x18\x02 \x01(\v2=.terraform1.stacks.StackChangeProgress.ResourceInstanceStatusH\x00R\x16resourceInstanceStatus\x12\x8f\x01\n" + @@ -7854,18 +7854,18 @@ const file_stacks_proto_rawDesc = "" + "\aunknown\x18\x02 \x01(\bR\aunknown\x1a\xbe\x01\n" + "%DeferredResourceInstancePlannedChange\x127\n" + "\bdeferred\x18\x01 \x01(\v2\x1b.terraform1.stacks.DeferredR\bdeferred\x12\\\n" + - "\x06change\x18\x02 \x01(\v2D.terraform1.stacks.StackChangeProgress.ResourceInstancePlannedChangeR\x06change\x1a\x89\x03\n" + + "\x06change\x18\x02 \x01(\v2D.terraform1.stacks.StackChangeProgress.ResourceInstancePlannedChangeR\x06change\x1a\x86\x03\n" + "\x17ActionInvocationPlanned\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12#\n" + - "\rprovider_addr\x18\x02 \x01(\tR\fproviderAddr\x12y\n" + - "\x18lifecycle_action_trigger\x18\x03 \x01(\v2=.terraform1.stacks.StackChangeProgress.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12p\n" + + "\rprovider_addr\x18\x02 \x01(\tR\fproviderAddr\x12v\n" + + "\x17resource_action_trigger\x18\x03 \x01(\v2<.terraform1.stacks.StackChangeProgress.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12p\n" + "\x15invoke_action_trigger\x18\x04 \x01(\v2:.terraform1.stacks.StackChangeProgress.InvokeActionTriggerH\x00R\x13invokeActionTriggerB\x10\n" + - "\x0eaction_trigger\x1a\xb3\x04\n" + + "\x0eaction_trigger\x1a\xb0\x04\n" + "\x16ActionInvocationStatus\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12\\\n" + "\x06status\x18\x02 \x01(\x0e2D.terraform1.stacks.StackChangeProgress.ActionInvocationStatus.StatusR\x06status\x12#\n" + - "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12y\n" + - "\x18lifecycle_action_trigger\x18\x04 \x01(\v2=.terraform1.stacks.StackChangeProgress.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12p\n" + + "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12v\n" + + "\x17resource_action_trigger\x18\x04 \x01(\v2<.terraform1.stacks.StackChangeProgress.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12p\n" + "\x15invoke_action_trigger\x18\x05 \x01(\v2:.terraform1.stacks.StackChangeProgress.InvokeActionTriggerH\x00R\x13invokeActionTrigger\"K\n" + "\x06Status\x12\v\n" + "\aINVALID\x10\x00\x12\v\n" + @@ -7873,15 +7873,15 @@ const file_stacks_proto_rawDesc = "" + "\aRUNNING\x10\x02\x12\r\n" + "\tCOMPLETED\x10\x03\x12\v\n" + "\aERRORED\x10\x04B\x10\n" + - "\x0eaction_trigger\x1a\xa4\x03\n" + + "\x0eaction_trigger\x1a\xa1\x03\n" + "\x18ActionInvocationProgress\x12J\n" + "\x04addr\x18\x01 \x01(\v26.terraform1.stacks.ActionInvocationInstanceInStackAddrR\x04addr\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12#\n" + - "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12y\n" + - "\x18lifecycle_action_trigger\x18\x04 \x01(\v2=.terraform1.stacks.StackChangeProgress.LifecycleActionTriggerH\x00R\x16lifecycleActionTrigger\x12p\n" + + "\rprovider_addr\x18\x03 \x01(\tR\fproviderAddr\x12v\n" + + "\x17resource_action_trigger\x18\x04 \x01(\v2<.terraform1.stacks.StackChangeProgress.ResourceActionTriggerH\x00R\x15resourceActionTrigger\x12p\n" + "\x15invoke_action_trigger\x18\x05 \x01(\v2:.terraform1.stacks.StackChangeProgress.InvokeActionTriggerH\x00R\x13invokeActionTriggerB\x10\n" + - "\x0eaction_trigger\x1a\xd3\x02\n" + - "\x16LifecycleActionTrigger\x12n\n" + + "\x0eaction_trigger\x1a\xd2\x02\n" + + "\x15ResourceActionTrigger\x12n\n" + "\x1btriggering_resource_address\x18\x01 \x01(\v2..terraform1.stacks.ResourceInstanceInStackAddrR\x19triggeringResourceAddress\x12^\n" + "\rtrigger_event\x18\x02 \x01(\x0e29.terraform1.stacks.StackChangeProgress.ActionTriggerEventR\ftriggerEvent\x12;\n" + "\x1aaction_trigger_block_index\x18\x03 \x01(\x03R\x17actionTriggerBlockIndex\x12,\n" + @@ -8091,7 +8091,7 @@ var file_stacks_proto_goTypes = []any{ (*PlannedChange_InputVariable)(nil), // 95: terraform1.stacks.PlannedChange.InputVariable (*PlannedChange_ActionInvocationInstance)(nil), // 96: terraform1.stacks.PlannedChange.ActionInvocationInstance (*PlannedChange_ActionInvocationDeferred)(nil), // 97: terraform1.stacks.PlannedChange.ActionInvocationDeferred - (*PlannedChange_LifecycleActionTrigger)(nil), // 98: terraform1.stacks.PlannedChange.LifecycleActionTrigger + (*PlannedChange_ResourceActionTrigger)(nil), // 98: terraform1.stacks.PlannedChange.ResourceActionTrigger (*PlannedChange_InvokeActionTrigger)(nil), // 99: terraform1.stacks.PlannedChange.InvokeActionTrigger (*PlannedChange_ResourceInstance_Index)(nil), // 100: terraform1.stacks.PlannedChange.ResourceInstance.Index (*PlannedChange_ResourceInstance_Moved)(nil), // 101: terraform1.stacks.PlannedChange.ResourceInstance.Moved @@ -8111,7 +8111,7 @@ var file_stacks_proto_goTypes = []any{ (*StackChangeProgress_ActionInvocationPlanned)(nil), // 115: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned (*StackChangeProgress_ActionInvocationStatus)(nil), // 116: terraform1.stacks.StackChangeProgress.ActionInvocationStatus (*StackChangeProgress_ActionInvocationProgress)(nil), // 117: terraform1.stacks.StackChangeProgress.ActionInvocationProgress - (*StackChangeProgress_LifecycleActionTrigger)(nil), // 118: terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + (*StackChangeProgress_ResourceActionTrigger)(nil), // 118: terraform1.stacks.StackChangeProgress.ResourceActionTrigger (*StackChangeProgress_InvokeActionTrigger)(nil), // 119: terraform1.stacks.StackChangeProgress.InvokeActionTrigger (*StackChangeProgress_ProvisionerStatus)(nil), // 120: terraform1.stacks.StackChangeProgress.ProvisionerStatus (*StackChangeProgress_ProvisionerOutput)(nil), // 121: terraform1.stacks.StackChangeProgress.ProvisionerOutput @@ -8225,12 +8225,12 @@ var file_stacks_proto_depIdxs = []int32{ 27, // 94: terraform1.stacks.PlannedChange.InputVariable.values:type_name -> terraform1.stacks.DynamicValueChange 31, // 95: terraform1.stacks.PlannedChange.ActionInvocationInstance.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr 26, // 96: terraform1.stacks.PlannedChange.ActionInvocationInstance.config_value:type_name -> terraform1.stacks.DynamicValue - 98, // 97: terraform1.stacks.PlannedChange.ActionInvocationInstance.lifecycle_action_trigger:type_name -> terraform1.stacks.PlannedChange.LifecycleActionTrigger + 98, // 97: terraform1.stacks.PlannedChange.ActionInvocationInstance.resource_action_trigger:type_name -> terraform1.stacks.PlannedChange.ResourceActionTrigger 99, // 98: terraform1.stacks.PlannedChange.ActionInvocationInstance.invoke_action_trigger:type_name -> terraform1.stacks.PlannedChange.InvokeActionTrigger 35, // 99: terraform1.stacks.PlannedChange.ActionInvocationDeferred.deferred:type_name -> terraform1.stacks.Deferred 96, // 100: terraform1.stacks.PlannedChange.ActionInvocationDeferred.action_invocation:type_name -> terraform1.stacks.PlannedChange.ActionInvocationInstance - 32, // 101: terraform1.stacks.PlannedChange.LifecycleActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr - 4, // 102: terraform1.stacks.PlannedChange.LifecycleActionTrigger.trigger_event:type_name -> terraform1.stacks.PlannedChange.ActionTriggerEvent + 32, // 101: terraform1.stacks.PlannedChange.ResourceActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr + 4, // 102: terraform1.stacks.PlannedChange.ResourceActionTrigger.trigger_event:type_name -> terraform1.stacks.PlannedChange.ActionTriggerEvent 26, // 103: terraform1.stacks.PlannedChange.ResourceInstance.Index.value:type_name -> terraform1.stacks.DynamicValue 32, // 104: terraform1.stacks.PlannedChange.ResourceInstance.Moved.prev_addr:type_name -> terraform1.stacks.ResourceInstanceInStackAddr 130, // 105: terraform1.stacks.AppliedChange.RawChange.value:type_name -> google.protobuf.Any @@ -8258,17 +8258,17 @@ var file_stacks_proto_depIdxs = []int32{ 35, // 127: terraform1.stacks.StackChangeProgress.DeferredResourceInstancePlannedChange.deferred:type_name -> terraform1.stacks.Deferred 113, // 128: terraform1.stacks.StackChangeProgress.DeferredResourceInstancePlannedChange.change:type_name -> terraform1.stacks.StackChangeProgress.ResourceInstancePlannedChange 31, // 129: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr - 118, // 130: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.lifecycle_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + 118, // 130: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.resource_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.ResourceActionTrigger 119, // 131: terraform1.stacks.StackChangeProgress.ActionInvocationPlanned.invoke_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.InvokeActionTrigger 31, // 132: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr 9, // 133: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.status:type_name -> terraform1.stacks.StackChangeProgress.ActionInvocationStatus.Status - 118, // 134: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.lifecycle_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + 118, // 134: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.resource_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.ResourceActionTrigger 119, // 135: terraform1.stacks.StackChangeProgress.ActionInvocationStatus.invoke_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.InvokeActionTrigger 31, // 136: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.addr:type_name -> terraform1.stacks.ActionInvocationInstanceInStackAddr - 118, // 137: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.lifecycle_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.LifecycleActionTrigger + 118, // 137: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.resource_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.ResourceActionTrigger 119, // 138: terraform1.stacks.StackChangeProgress.ActionInvocationProgress.invoke_action_trigger:type_name -> terraform1.stacks.StackChangeProgress.InvokeActionTrigger - 32, // 139: terraform1.stacks.StackChangeProgress.LifecycleActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr - 6, // 140: terraform1.stacks.StackChangeProgress.LifecycleActionTrigger.trigger_event:type_name -> terraform1.stacks.StackChangeProgress.ActionTriggerEvent + 32, // 139: terraform1.stacks.StackChangeProgress.ResourceActionTrigger.triggering_resource_address:type_name -> terraform1.stacks.ResourceInstanceInStackAddr + 6, // 140: terraform1.stacks.StackChangeProgress.ResourceActionTrigger.trigger_event:type_name -> terraform1.stacks.StackChangeProgress.ActionTriggerEvent 33, // 141: terraform1.stacks.StackChangeProgress.ProvisionerStatus.addr:type_name -> terraform1.stacks.ResourceInstanceObjectInStackAddr 120, // 142: terraform1.stacks.StackChangeProgress.ProvisionerStatus.status:type_name -> terraform1.stacks.StackChangeProgress.ProvisionerStatus 33, // 143: terraform1.stacks.StackChangeProgress.ProvisionerOutput.addr:type_name -> terraform1.stacks.ResourceInstanceObjectInStackAddr @@ -8370,7 +8370,7 @@ func file_stacks_proto_init() { (*PlannedChange_ChangeDescription_ActionInvocationDeferred)(nil), } file_stacks_proto_msgTypes[85].OneofWrappers = []any{ - (*PlannedChange_ActionInvocationInstance_LifecycleActionTrigger)(nil), + (*PlannedChange_ActionInvocationInstance_ResourceActionTrigger)(nil), (*PlannedChange_ActionInvocationInstance_InvokeActionTrigger)(nil), } file_stacks_proto_msgTypes[93].OneofWrappers = []any{ @@ -8382,15 +8382,15 @@ func file_stacks_proto_init() { (*AppliedChange_ChangeDescription_ComponentInstance)(nil), } file_stacks_proto_msgTypes[104].OneofWrappers = []any{ - (*StackChangeProgress_ActionInvocationPlanned_LifecycleActionTrigger)(nil), + (*StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger)(nil), (*StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger)(nil), } file_stacks_proto_msgTypes[105].OneofWrappers = []any{ - (*StackChangeProgress_ActionInvocationStatus_LifecycleActionTrigger)(nil), + (*StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger)(nil), (*StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger)(nil), } file_stacks_proto_msgTypes[106].OneofWrappers = []any{ - (*StackChangeProgress_ActionInvocationProgress_LifecycleActionTrigger)(nil), + (*StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger)(nil), (*StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger)(nil), } type x struct{} diff --git a/internal/rpcapi/terraform1/stacks/stacks.proto b/internal/rpcapi/terraform1/stacks/stacks.proto index d7e3b4c673..233c3a01bf 100644 --- a/internal/rpcapi/terraform1/stacks/stacks.proto +++ b/internal/rpcapi/terraform1/stacks/stacks.proto @@ -670,7 +670,7 @@ message PlannedChange { DynamicValue config_value = 4; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 6; + ResourceActionTrigger resource_action_trigger = 6; InvokeActionTrigger invoke_action_trigger = 7; } } @@ -687,9 +687,9 @@ message PlannedChange { ActionInvocationInstance action_invocation = 2; } - // LifecycleActionTrigger contains details on the conditions that led to the + // ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. - message LifecycleActionTrigger { + message ResourceActionTrigger { ResourceInstanceInStackAddr triggering_resource_address = 1; ActionTriggerEvent trigger_event = 2; int64 action_trigger_block_index = 3; @@ -940,7 +940,7 @@ message StackChangeProgress { ActionInvocationInstanceInStackAddr addr = 1; string provider_addr = 2; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 3; + ResourceActionTrigger resource_action_trigger = 3; InvokeActionTrigger invoke_action_trigger = 4; } } @@ -951,7 +951,7 @@ message StackChangeProgress { string provider_addr = 3; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 4; + ResourceActionTrigger resource_action_trigger = 4; InvokeActionTrigger invoke_action_trigger = 5; } @@ -970,14 +970,14 @@ message StackChangeProgress { string provider_addr = 3; oneof action_trigger { - LifecycleActionTrigger lifecycle_action_trigger = 4; + ResourceActionTrigger resource_action_trigger = 4; InvokeActionTrigger invoke_action_trigger = 5; } } - // LifecycleActionTrigger contains details on the conditions that led to the + // ResourceActionTrigger contains details on the conditions that led to the // triggering of an action. - message LifecycleActionTrigger { + message ResourceActionTrigger { ResourceInstanceInStackAddr triggering_resource_address = 1; ActionTriggerEvent trigger_event = 2; int64 action_trigger_block_index = 3; diff --git a/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tf b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tf new file mode 100644 index 0000000000..dbf571a4ee --- /dev/null +++ b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } + } +} + +resource "testing_resource" "this" { + id = "test" + value = "hello" + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.testing_action.example] + } + } +} + +action "testing_action" "example" { + config { + message = "Test action invocation" + } +} diff --git a/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tfcomponent.hcl b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tfcomponent.hcl new file mode 100644 index 0000000000..00ac98d1ca --- /dev/null +++ b/internal/rpcapi/testdata/sourcebundle/action_invocations/action_invocations.tfcomponent.hcl @@ -0,0 +1,15 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +component "self" { + source = "./" + providers = { + testing = provider.testing.default + } +} diff --git a/internal/rpcapi/testdata/sourcebundle/terraform-sources.json b/internal/rpcapi/testdata/sourcebundle/terraform-sources.json index 6421282d51..55b96909d0 100644 --- a/internal/rpcapi/testdata/sourcebundle/terraform-sources.json +++ b/internal/rpcapi/testdata/sourcebundle/terraform-sources.json @@ -45,6 +45,11 @@ "source": "git::https://example.com/empty.git", "local": "empty", "meta": {} + }, + { + "source": "git::https://example.com/action_invocations.git", + "local": "action_invocations", + "meta": {} } ] } diff --git a/internal/stacks/stackaddrs/in_component.go b/internal/stacks/stackaddrs/in_component.go index 7283720efe..c130c215db 100644 --- a/internal/stacks/stackaddrs/in_component.go +++ b/internal/stacks/stackaddrs/in_component.go @@ -162,3 +162,34 @@ func ParseAbsResourceInstanceObjectStr(s string) (AbsResourceInstanceObject, tfd diags = diags.Append(moreDiags) return ret, diags } + +func ParseAbsActionInvocationInstance(traversal hcl.Traversal) (AbsActionInvocationInstance, tfdiags.Diagnostics) { + component, remain, diags := ParseAbsComponentInstanceOnly(traversal) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + action, actionDiags := addrs.ParseAbsActionInstance(remain) + diags = diags.Append(actionDiags) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + return AbsActionInvocationInstance{ + Component: component, + Item: action, + }, diags +} + +func ParseActionInvocationInstanceStr(s string) (AbsActionInvocationInstance, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + traversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(s), "", hcl.InitialPos) + diags = diags.Append(hclDiags) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + ret, moreDiags := ParseAbsActionInvocationInstance(traversal) + diags = diags.Append(moreDiags) + return ret, diags +} diff --git a/internal/stacks/stackconfig/checks.go b/internal/stacks/stackconfig/checks.go new file mode 100644 index 0000000000..e4afb6dc1d --- /dev/null +++ b/internal/stacks/stackconfig/checks.go @@ -0,0 +1,72 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackconfig + +import "github.com/hashicorp/hcl/v2" + +// CheckRule represents a custom validation rule for a stack input variable. +// +// This is the stacks-specific equivalent of configs.CheckRule in the core +// Terraform package. It is intentionally duplicated here to maintain +// separation between stacks and core Terraform, allowing each to evolve +// independently. +type CheckRule struct { + // Condition is an expression that must evaluate to true if the validation + // passes, or false if it fails. The expression may only refer to the + // variable being validated (via var.). + Condition hcl.Expression + + // ErrorMessage is an expression that evaluates to the error message shown + // to the user when the condition is false. It must evaluate to a string. + ErrorMessage hcl.Expression + + DeclRange hcl.Range +} + +// decodeCheckRuleBlock decodes a validation block for stack input variables. +// This is duplicated from the core configs package to maintain separation between +// stacks and core Terraform, allowing each to evolve independently. +func decodeCheckRuleBlock(block *hcl.Block) (*CheckRule, hcl.Diagnostics) { + var diags hcl.Diagnostics + cr := &CheckRule{ + DeclRange: block.DefRange, + } + + content, hclDiags := block.Body.Content(checkRuleBlockSchema) + diags = append(diags, hclDiags...) + + if attr, exists := content.Attributes["condition"]; exists { + cr.Condition = attr.Expr + + if len(cr.Condition.Variables()) == 0 { + // A condition expression that doesn't refer to any variable is + // pointless, because its result would always be a constant. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid validation expression", + Detail: "The condition expression must refer to at least one object from elsewhere in the configuration, or else its result would not be checking anything.", + Subject: cr.Condition.Range().Ptr(), + }) + } + } + + if attr, exists := content.Attributes["error_message"]; exists { + cr.ErrorMessage = attr.Expr + } + + return cr, diags +} + +var checkRuleBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "condition", + Required: true, + }, + { + Name: "error_message", + Required: true, + }, + }, +} diff --git a/internal/stacks/stackconfig/input_variable.go b/internal/stacks/stackconfig/input_variable.go index 87315917e3..6f8fbb7a72 100644 --- a/internal/stacks/stackconfig/input_variable.go +++ b/internal/stacks/stackconfig/input_variable.go @@ -23,6 +23,12 @@ type InputVariable struct { Sensitive bool Ephemeral bool + // Validations contains custom validation rules for this variable. + // These rules are evaluated at runtime during the plan phase to ensure + // that provided values meet the specified constraints. + // Each CheckRule includes a condition expression that must evaluate to true, + // and an error message to display if the validation fails. + Validations []*CheckRule DeclRange tfdiags.SourceRange } @@ -89,13 +95,25 @@ func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnos diags = diags.Append(hclDiags) } + // Process any nested blocks (currently only validation blocks are supported) for _, block := range content.Blocks { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Custom variable validation not yet supported", - Detail: "Input variables for a stack configuration do not yet support custom variable validation.", - Subject: block.DefRange.Ptr(), - }) + switch block.Type { + case "validation": + // Decode the validation block into a CheckRule structure. + // This only validates the syntax and structure of the validation block itself, + // not the actual runtime validation of input values. + vv, hclDiags := decodeCheckRuleBlock(block) + diags = diags.Append(hclDiags) + // Only add the validation rule if it was successfully parsed. + // If there were errors (e.g., missing condition or error_message), + // those errors are already captured in diags above. + if !hclDiags.HasErrors() { + ret.Validations = append(ret.Validations, vv) + } + default: + // Should not get here as the schema defines what blocks are allowed + panic("unhandled block type " + block.Type) + } } return ret, diags @@ -109,6 +127,8 @@ var inputVariableBlockSchema = &hcl.BodySchema{ {Name: "sensitive", Required: false}, {Name: "ephemeral", Required: false}, }, + // Validation blocks allow custom validation rules for input variables. + // Multiple validation blocks are allowed per variable. Blocks: []hcl.BlockHeaderSchema{ {Type: "validation"}, }, diff --git a/internal/stacks/stackconfig/input_variable_test.go b/internal/stacks/stackconfig/input_variable_test.go new file mode 100644 index 0000000000..a3bb1d6ae9 --- /dev/null +++ b/internal/stacks/stackconfig/input_variable_test.go @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackconfig + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func TestDecodeInputVariableBlock_constNotSupported(t *testing.T) { + // const = true is not supported in the stacks component language. + // This test documents that using const produces an "Unsupported argument" + // error from the HCL schema validation. + src := []byte(`variable "example" { + type = string + const = true +}`) + file, diags := hclsyntax.ParseConfig(src, "test.tfcomponent.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("unexpected parse error: %s", diags.Error()) + } + + content, diags := file.Body.Content(&hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + {Type: "variable", LabelNames: []string{"name"}}, + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected schema error: %s", diags.Error()) + } + + if len(content.Blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(content.Blocks)) + } + + _, decodeDiags := decodeInputVariableBlock(content.Blocks[0]) + if len(decodeDiags) != 1 { + t.Fatalf("expected exactly 1 diagnostic, got %d:\n%s", len(decodeDiags), decodeDiags.NonFatalErr()) + } + + diag := decodeDiags[0] + if got, want := diag.Description().Summary, "Unsupported argument"; got != want { + t.Errorf("wrong summary\ngot: %s\nwant: %s", got, want) + } + if got, want := diag.Description().Detail, `An argument named "const" is not expected here.`; got != want { + t.Errorf("wrong detail\ngot: %s\nwant: %s", got, want) + } +} diff --git a/internal/stacks/stackplan/component.go b/internal/stacks/stackplan/component.go index 87b564f2aa..99b639960e 100644 --- a/internal/stacks/stackplan/component.go +++ b/internal/stacks/stackplan/component.go @@ -52,6 +52,14 @@ type Component struct { // that have changes that are deferred to a later plan and apply cycle. DeferredResourceInstanceChanges addrs.Map[addrs.AbsResourceInstanceObject, *plans.DeferredResourceInstanceChangeSrc] + // ActionInvocations describes planned action invocations that should be + // preserved into the modules runtime apply plan. + ActionInvocations []*plans.ActionInvocationInstanceSrc + + // DeferredActionInvocations describes action invocations that were deferred + // to a later plan and apply cycle. + DeferredActionInvocations []*plans.DeferredActionInvocationSrc + // PlanTimestamp is the time Terraform Core recorded as the single "plan // timestamp", which is used only for the result of the "plantimestamp" // function during apply and must not be used for any other purpose. @@ -114,6 +122,18 @@ func (c *Component) ForModulesRuntime() (*plans.Plan, error) { } } + for _, action := range c.ActionInvocations { + if action != nil { + changes.ActionInvocations = append(changes.ActionInvocations, action) + } + } + + for _, deferredAction := range c.DeferredActionInvocations { + if deferredAction != nil { + plan.DeferredActionInvocations = append(plan.DeferredActionInvocations, deferredAction) + } + } + priorState := states.NewState() ss := priorState.SyncWrapper() for _, elem := range c.ResourceInstancePriorState.Elems { @@ -163,5 +183,23 @@ func (c *Component) RequiredProviderInstances() addrs.Set[addrs.RootProviderConf Alias: elem.Value.Alias, }) } + for _, action := range c.ActionInvocations { + if action == nil { + continue + } + providerInstances.Add(addrs.RootProviderConfig{ + Provider: action.ProviderAddr.Provider, + Alias: action.ProviderAddr.Alias, + }) + } + for _, deferredAction := range c.DeferredActionInvocations { + if deferredAction == nil || deferredAction.ActionInvocationInstanceSrc == nil { + continue + } + providerInstances.Add(addrs.RootProviderConfig{ + Provider: deferredAction.ActionInvocationInstanceSrc.ProviderAddr.Provider, + Alias: deferredAction.ActionInvocationInstanceSrc.ProviderAddr.Alias, + }) + } return providerInstances } diff --git a/internal/stacks/stackplan/from_plan.go b/internal/stacks/stackplan/from_plan.go index cd6ab1e6f1..df80431416 100644 --- a/internal/stacks/stackplan/from_plan.go +++ b/internal/stacks/stackplan/from_plan.go @@ -37,6 +37,9 @@ type PlanProducer interface { // ResourceSchema returns the schema for a resource type from a provider. ResourceSchema(ctx context.Context, providerTypeAddr addrs.Provider, mode addrs.ResourceMode, resourceType string) (providers.Schema, error) + + // ActionSchema returns the schema for an action type from a provider. + ActionSchema(ctx context.Context, providerTypeAddr addrs.Provider, actionType string) (providers.ActionSchema, error) } func FromPlan(ctx context.Context, config *configs.Config, plan *plans.Plan, refreshPlan *plans.Plan, action plans.Action, producer PlanProducer) ([]PlannedChange, tfdiags.Diagnostics) { @@ -174,6 +177,62 @@ func FromPlan(ctx context.Context, config *configs.Config, plan *plans.Plan, ref seenObjects.Add(objAddr) } + // Handle action invocations from the plan + for _, actionChange := range plan.Changes.ActionInvocations { + schema, err := producer.ActionSchema( + ctx, + actionChange.ProviderAddr.Provider, + actionChange.Addr.Action.Action.Type, + ) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Can't fetch provider schema to save plan", + fmt.Sprintf( + "Failed to retrieve the schema for %s from provider %s: %s. This is a bug in Terraform.", + actionChange.Addr, actionChange.ProviderAddr.Provider, err, + ), + )) + continue + } + + changes = append(changes, &PlannedChangeActionInvocationInstancePlanned{ + ActionInvocationAddr: stackaddrs.AbsActionInvocationInstance{ + Component: producer.Addr(), + Item: actionChange.Addr, + }, + Invocation: actionChange, + Schema: schema, + ProviderConfigAddr: actionChange.ProviderAddr, + }) + } + + // Handle deferred action invocations from the plan + for _, deferredAction := range plan.DeferredActionInvocations { + invocation := deferredAction.ActionInvocationInstanceSrc + + if invocation == nil { + continue + } + + // For deferred actions, the provider address is typically empty because + // actions are deferred before being fully evaluated. We create the planned + // change without schema since we can't fetch it without a provider address. + plannedActionInvocation := PlannedChangeActionInvocationInstancePlanned{ + ActionInvocationAddr: stackaddrs.AbsActionInvocationInstance{ + Component: producer.Addr(), + Item: invocation.Addr, + }, + Invocation: invocation, + Schema: providers.ActionSchema{}, // Empty schema for deferred actions + ProviderConfigAddr: invocation.ProviderAddr, // Will be empty, that's expected + } + changes = append(changes, &PlannedChangeDeferredActionInvocation{ + DeferredReason: deferredAction.DeferredReason, + ActionInvocationPlanned: plannedActionInvocation, + }) + } + // We also need to catch any objects that exist in the "prior state" // but don't have any actions planned, since we still need to capture // the prior state part in case it was updated by refreshing during diff --git a/internal/stacks/stackplan/from_proto.go b/internal/stacks/stackplan/from_proto.go index 6e2990b8fd..db9b440280 100644 --- a/internal/stacks/stackplan/from_proto.go +++ b/internal/stacks/stackplan/from_proto.go @@ -237,6 +237,8 @@ func (l *Loader) AddRaw(rawMsg *anypb.Any) error { ResourceInstancePriorState: addrs.MakeMap[addrs.AbsResourceInstanceObject, *states.ResourceInstanceObjectSrc](), ResourceInstanceProviderConfig: addrs.MakeMap[addrs.AbsResourceInstanceObject, addrs.AbsProviderConfig](), DeferredResourceInstanceChanges: addrs.MakeMap[addrs.AbsResourceInstanceObject, *plans.DeferredResourceInstanceChangeSrc](), + ActionInvocations: make([]*plans.ActionInvocationInstanceSrc, 0), + DeferredActionInvocations: make([]*plans.DeferredActionInvocationSrc, 0), }) err = c.PlanTimestamp.UnmarshalText([]byte(msg.PlanTimestamp)) if err != nil { @@ -301,6 +303,44 @@ func (l *Loader) AddRaw(rawMsg *anypb.Any) error { DeferredReason: deferredReason, }) + case *tfstackdata1.PlanActionInvocationPlanned: + c, fullAddr, err := LoadComponentForActionInvocation(l.ret, msg) + if err != nil { + return err + } + + action, err := ValidateActionInvocation(msg, fullAddr) + if err != nil { + return err + } + if action != nil { + c.ActionInvocations = append(c.ActionInvocations, action) + } + + case *tfstackdata1.PlanDeferredActionInvocation: + if msg.Deferred == nil { + return fmt.Errorf("missing deferred from PlanDeferredActionInvocation") + } + if msg.Invocation == nil { + return fmt.Errorf("missing invocation from PlanDeferredActionInvocation") + } + + c, fullAddr, err := LoadComponentForActionInvocation(l.ret, msg.Invocation) + if err != nil { + return err + } + + action, err := ValidateActionInvocation(msg.Invocation, fullAddr) + if err != nil { + return err + } + + deferredReason, _ := planfile.DeferredReasonFromProto(msg.Deferred.Reason) + c.DeferredActionInvocations = append(c.DeferredActionInvocations, &plans.DeferredActionInvocationSrc{ + DeferredReason: deferredReason, + ActionInvocationInstanceSrc: action, + }) + default: // Should not get here, because a stack plan can only be loaded by // the same version of Terraform that created it, and the above @@ -469,3 +509,42 @@ func LoadComponentForPartialResourceInstance(plan *Plan, change *tfstackdata1.Pl return c, fullAddr, providerConfigAddr, nil } + +func ValidateActionInvocation(change *tfstackdata1.PlanActionInvocationPlanned, fullAddr stackaddrs.AbsActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { + if change.Invocation == nil { + return nil, nil + } + + action, err := planfile.ActionInvocationFromProto(change.Invocation) + if err != nil { + return nil, fmt.Errorf("invalid action invocation: %w", err) + } + if !action.Addr.Equal(fullAddr.Item) { + return nil, fmt.Errorf("planned action invocation has inconsistent address to its containing object") + } + return action, nil +} + +func LoadComponentForActionInvocation(plan *Plan, change *tfstackdata1.PlanActionInvocationPlanned) (*Component, stackaddrs.AbsActionInvocationInstance, error) { + cAddr, diags := stackaddrs.ParsePartialComponentInstanceStr(change.ComponentInstanceAddr) + if diags.HasErrors() { + return nil, stackaddrs.AbsActionInvocationInstance{}, fmt.Errorf("invalid component instance address syntax in %q", change.ComponentInstanceAddr) + } + + actionAddr, diags := addrs.ParseAbsActionInstanceStr(change.ActionInvocationAddr) + if diags.HasErrors() { + return nil, stackaddrs.AbsActionInvocationInstance{}, fmt.Errorf("invalid action invocation address syntax in %q", change.ActionInvocationAddr) + } + + fullAddr := stackaddrs.AbsActionInvocationInstance{ + Component: cAddr, + Item: actionAddr, + } + + c, ok := plan.Root.GetOk(cAddr) + if !ok { + return nil, stackaddrs.AbsActionInvocationInstance{}, fmt.Errorf("action invocation change for unannounced component instance %s", cAddr) + } + + return c, fullAddr, nil +} diff --git a/internal/stacks/stackplan/from_proto_test.go b/internal/stacks/stackplan/from_proto_test.go index 43e1a458f7..0950c3c556 100644 --- a/internal/stacks/stackplan/from_proto_test.go +++ b/internal/stacks/stackplan/from_proto_test.go @@ -11,9 +11,14 @@ import ( "github.com/zclconf/go-cty/cty" "google.golang.org/protobuf/types/known/anypb" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/collections" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/plans/planproto" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" "github.com/hashicorp/terraform/internal/stacks/tfstackdata1" ) @@ -100,3 +105,109 @@ func TestAddRaw(t *testing.T) { }) } } + +func TestAddRaw_ActionInvocations(t *testing.T) { + provider := addrs.MustParseProviderSourceString("example.com/test/actions") + providerConfig := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: provider, + } + action := &plans.ActionInvocationInstanceSrc{ + Addr: addrs.RootModuleInstance.ActionInstance("webhook", "notify", addrs.NoKey), + ActionTrigger: &plans.ResourceActionTrigger{ + TriggeringResourceAddr: addrs.RootModuleInstance.ResourceInstance(addrs.ManagedResourceMode, "example_resource", "main", addrs.NoKey), + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + ProviderAddr: providerConfig, + } + rawAction, err := planfile.ActionInvocationToProto(action) + if err != nil { + t.Fatal(err) + } + + loader := NewLoader() + err = loader.AddRaw(mustMarshalAnyPb(&tfstackdata1.PlanComponentInstance{ + ComponentInstanceAddr: "component.web", + PlannedAction: planproto.Action_NOOP, + Mode: planproto.Mode_NORMAL, + PlanTimestamp: "2017-03-27T10:00:00-08:00", + })) + if err != nil { + t.Fatalf("adding component: %v", err) + } + err = loader.AddRaw(mustMarshalAnyPb(&tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: "component.web", + ActionInvocationAddr: action.Addr.String(), + ProviderConfigAddr: provider.String(), + Invocation: rawAction, + })) + if err != nil { + t.Fatalf("adding planned action invocation: %v", err) + } + err = loader.AddRaw(mustMarshalAnyPb(&tfstackdata1.PlanDeferredActionInvocation{ + Deferred: &planproto.Deferred{ + Reason: planproto.DeferredReason_DEFERRED_PREREQ, + }, + Invocation: &tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: "component.web", + ActionInvocationAddr: action.Addr.String(), + ProviderConfigAddr: provider.String(), + Invocation: rawAction, + }, + })) + if err != nil { + t.Fatalf("adding deferred action invocation: %v", err) + } + + componentAddr, diags := stackaddrs.ParseAbsComponentInstanceStr("component.web") + if diags.HasErrors() { + t.Fatalf("parsing component address: %s", diags.Err()) + } + component := loader.ret.GetComponent(componentAddr) + if component == nil { + t.Fatal("expected component to be loaded") + } + + if len(component.ActionInvocations) != 1 { + t.Fatalf("expected 1 planned action invocation, got %d", len(component.ActionInvocations)) + } + if diff := cmp.Diff(action, component.ActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong planned action invocation (-want +got):\n%s", diff) + } + if len(component.DeferredActionInvocations) != 1 { + t.Fatalf("expected 1 deferred action invocation, got %d", len(component.DeferredActionInvocations)) + } + if diff := cmp.Diff(&plans.DeferredActionInvocationSrc{ + DeferredReason: providers.DeferredReasonDeferredPrereq, + ActionInvocationInstanceSrc: action, + }, component.DeferredActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong deferred action invocation (-want +got):\n%s", diff) + } + + modulesPlan, err := component.ForModulesRuntime() + if err != nil { + t.Fatalf("ForModulesRuntime: %v", err) + } + if len(modulesPlan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 planned action invocation in modules runtime plan, got %d", len(modulesPlan.Changes.ActionInvocations)) + } + if diff := cmp.Diff(action, modulesPlan.Changes.ActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong modules runtime action invocation (-want +got):\n%s", diff) + } + if len(modulesPlan.DeferredActionInvocations) != 1 { + t.Fatalf("expected 1 deferred action invocation in modules runtime plan, got %d", len(modulesPlan.DeferredActionInvocations)) + } + if diff := cmp.Diff(&plans.DeferredActionInvocationSrc{ + DeferredReason: providers.DeferredReasonDeferredPrereq, + ActionInvocationInstanceSrc: action, + }, modulesPlan.DeferredActionInvocations[0], ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong modules runtime deferred action invocation (-want +got):\n%s", diff) + } + + requiredProviders := component.RequiredProviderInstances() + if !requiredProviders.Has(addrs.RootProviderConfig{Provider: provider}) { + t.Fatalf("expected action provider %s to be required", provider) + } +} diff --git a/internal/stacks/stackplan/planned_change.go b/internal/stacks/stackplan/planned_change.go index 7c01bab437..5c6c0dae63 100644 --- a/internal/stacks/stackplan/planned_change.go +++ b/internal/stacks/stackplan/planned_change.go @@ -493,7 +493,6 @@ func (pc *PlannedChangeResourceInstancePlanned) ChangeDescription() (*stacks.Pla }, }, }, nil - } func DynamicValueToTerraform1(val cty.Value, ty cty.Type) (*stacks.DynamicValue, error) { @@ -854,3 +853,203 @@ func (pc *PlannedChangeProviderFunctionResults) PlannedChangeProto() (*stacks.Pl Raw: []*anypb.Any{&raw}, }, nil } + +// PlannedChangeActionInvocationInstancePlanned represents a planned action +// invocation within a component instance. +type PlannedChangeActionInvocationInstancePlanned struct { + ActionInvocationAddr stackaddrs.AbsActionInvocationInstance + + // Invocation describes the planned invocation. + Invocation *plans.ActionInvocationInstanceSrc + + // ProviderConfigAddr is the address of the provider configuration + // that planned this change, resolved in terms of the configuration for + // the component this action invocation belongs to. + ProviderConfigAddr addrs.AbsProviderConfig + + // Schema MUST be the same schema that was used to encode the dynamic + // values inside Invocation. + // + // Can be empty if and only if Invocation is nil. + Schema providers.ActionSchema +} + +var _ PlannedChange = (*PlannedChangeActionInvocationInstancePlanned)(nil) + +// PlanActionInvocationProto converts the planned action invocation to the +// internal protobuf representation for persistence. +func (pc *PlannedChangeActionInvocationInstancePlanned) PlanActionInvocationProto() (*tfstackdata1.PlanActionInvocationPlanned, error) { + addr := pc.ActionInvocationAddr + + if pc.Invocation == nil { + // Return a minimal placeholder if there's no actual invocation + return &tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: addr.Component.String(), + ActionInvocationAddr: addr.Item.String(), + ProviderConfigAddr: pc.ProviderConfigAddr.Provider.String(), + }, nil + } + + invocationProto, err := planfile.ActionInvocationToProto(pc.Invocation) + if err != nil { + return nil, fmt.Errorf("converting action invocation to proto: %w", err) + } + + return &tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: addr.Component.String(), + ActionInvocationAddr: addr.Item.String(), + ProviderConfigAddr: pc.ProviderConfigAddr.Provider.String(), + Invocation: invocationProto, + }, nil +} + +// ChangeDescription implements PlannedChange by producing an external +// description of the action invocation for the RPC API. +func (pc *PlannedChangeActionInvocationInstancePlanned) ChangeDescription() (*stacks.PlannedChange_ChangeDescription, error) { + addr := pc.ActionInvocationAddr + + // We only emit an external description if there's an invocation to describe. + if pc.Invocation == nil { + return nil, nil + } + + invoke := stacks.PlannedChange_ActionInvocationInstance{ + Addr: stacks.NewActionInvocationInStackAddr(addr), + ProviderAddr: pc.Invocation.ProviderAddr.Provider.String(), + ActionType: pc.Invocation.Addr.Action.Action.Type, + + ConfigValue: stacks.NewDynamicValue( + pc.Invocation.ConfigValue, + pc.Invocation.SensitiveConfigPaths, + ), + } + + // Convert the action trigger information + switch at := pc.Invocation.ActionTrigger.(type) { + case *plans.ResourceActionTrigger: + triggerEvent, err := stacks.ActionTriggerEventForPlannedChange(at.ActionTriggerEvent) + if err != nil { + return nil, err + } + + invoke.ActionTrigger = &stacks.PlannedChange_ActionInvocationInstance_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.PlannedChange_ResourceActionTrigger{ + TriggerEvent: triggerEvent, + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr(stackaddrs.AbsResourceInstance{ + Component: addr.Component, + Item: at.TriggeringResourceAddr, + }), + ActionTriggerBlockIndex: int64(at.ActionTriggerBlockIndex), + ActionsListIndex: int64(at.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + // TODO Implement this when implementing Stacks support for Direct Action Invocation + invoke.ActionTrigger = &stacks.PlannedChange_ActionInvocationInstance_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.PlannedChange_InvokeActionTrigger{}, + } + default: + // This should be exhaustive + return nil, fmt.Errorf("unsupported action trigger type: %T", at) + } + + return &stacks.PlannedChange_ChangeDescription{ + Description: &stacks.PlannedChange_ChangeDescription_ActionInvocationPlanned{ + ActionInvocationPlanned: &invoke, + }, + }, nil +} + +// PlannedChangeProto implements PlannedChange. +func (pc *PlannedChangeActionInvocationInstancePlanned) PlannedChangeProto() (*stacks.PlannedChange, error) { + paip, err := pc.PlanActionInvocationProto() + if err != nil { + return nil, err + } + var raw anypb.Any + err = anypb.MarshalFrom(&raw, paip, proto.MarshalOptions{}) + if err != nil { + return nil, err + } + + if pc.Invocation == nil { + // We only emit a "raw" in this case, because this is a relatively + // uninteresting edge-case. The PlanActionInvocationProto + // function should have returned a placeholder value for this use case. + + return &stacks.PlannedChange{ + Raw: []*anypb.Any{&raw}, + }, nil + } + + var descs []*stacks.PlannedChange_ChangeDescription + desc, err := pc.ChangeDescription() + if err != nil { + return nil, err + } + if desc != nil { + descs = append(descs, desc) + } + + return &stacks.PlannedChange{ + Raw: []*anypb.Any{&raw}, + Descriptions: descs, + }, nil +} + +// PlannedChangeDeferredActionInvocation represents an action invocation +// that was deferred due to incomplete information. +type PlannedChangeDeferredActionInvocation struct { + // ActionInvocationPlanned is the planned action invocation that is being deferred. + ActionInvocationPlanned PlannedChangeActionInvocationInstancePlanned + + // DeferredReason is the reason why the action invocation is being deferred. + DeferredReason providers.DeferredReason +} + +var _ PlannedChange = (*PlannedChangeDeferredActionInvocation)(nil) + +// PlannedChangeProto implements PlannedChange. +func (dpc *PlannedChangeDeferredActionInvocation) PlannedChangeProto() (*stacks.PlannedChange, error) { + action, err := dpc.ActionInvocationPlanned.PlanActionInvocationProto() + if err != nil { + return nil, err + } + + // Convert the deferred reason to proto format + deferredReason, _ := planfile.DeferredReasonToProto(dpc.DeferredReason) + + var raw anypb.Any + err = anypb.MarshalFrom(&raw, &tfstackdata1.PlanDeferredActionInvocation{ + Invocation: action, + Deferred: &planproto.Deferred{ + Reason: deferredReason, + }, + }, proto.MarshalOptions{}) + if err != nil { + return nil, err + } + + // Build the change description + aicd, err := dpc.ActionInvocationPlanned.ChangeDescription() + if err != nil { + return nil, err + } + + var descs []*stacks.PlannedChange_ChangeDescription + if aicd != nil { + descs = append(descs, &stacks.PlannedChange_ChangeDescription{ + Description: &stacks.PlannedChange_ChangeDescription_ActionInvocationDeferred{ + ActionInvocationDeferred: &stacks.PlannedChange_ActionInvocationDeferred{ + ActionInvocation: aicd.GetActionInvocationPlanned(), + Deferred: EncodeDeferred(dpc.DeferredReason), + }, + }, + }) + } + + return &stacks.PlannedChange{ + Raw: []*anypb.Any{&raw}, + Descriptions: descs, + }, nil +} diff --git a/internal/stacks/stackplan/planned_change_test.go b/internal/stacks/stackplan/planned_change_test.go index b9b920e3c6..ddc63fd16f 100644 --- a/internal/stacks/stackplan/planned_change_test.go +++ b/internal/stacks/stackplan/planned_change_test.go @@ -16,6 +16,8 @@ import ( "google.golang.org/protobuf/types/known/anypb" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planproto" @@ -927,6 +929,117 @@ func TestPlannedChangeAsProto(t *testing.T) { }, }, }, + "action invocation lifecycle trigger": { + Receiver: &PlannedChangeActionInvocationInstancePlanned{ + ActionInvocationAddr: stackaddrs.AbsActionInvocationInstance{ + Component: stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "web"}, + }, + }, + Item: addrs.AbsActionInstance{ + Module: addrs.RootModuleInstance, + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "webhook", + Name: "notify", + }, + Key: addrs.NoKey, + }, + }, + }, + ProviderConfigAddr: addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.MustParseProviderSourceString("example.com/webhooks/http"), + }, + Invocation: &plans.ActionInvocationInstanceSrc{ + Addr: addrs.AbsActionInstance{ + Module: addrs.RootModuleInstance, + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "webhook", + Name: "notify", + }, + Key: addrs.NoKey, + }, + }, + ActionTrigger: &plans.ResourceActionTrigger{ + TriggeringResourceAddr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "example_resource", + Name: "main", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + ConfigValue: emptyObjectForPlan, + ProviderAddr: addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.MustParseProviderSourceString("example.com/webhooks/http"), + }, + }, + Schema: providers.ActionSchema{ + ConfigSchema: &configschema.Block{}, + }, + }, + Want: &stacks.PlannedChange{ + Raw: []*anypb.Any{ + mustMarshalAnyPb(&tfstackdata1.PlanActionInvocationPlanned{ + ComponentInstanceAddr: "component.web", + ActionInvocationAddr: "action.webhook.notify", + ProviderConfigAddr: "example.com/webhooks/http", + Invocation: &planproto.ActionInvocationInstance{ + Addr: "action.webhook.notify", + Provider: `provider["example.com/webhooks/http"]`, + ActionTrigger: &planproto.ActionInvocationInstance_ResourceActionTrigger{ + ResourceActionTrigger: &planproto.ResourceActionTrigger{ + TriggeringResourceAddr: "example_resource.main", + TriggerEvent: planproto.ActionTriggerEvent_AFTER_CREATE, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + }, + ConfigValue: &planproto.DynamicValue{ + Msgpack: emptyObjectForPlan, + }, + }, + }), + }, + Descriptions: []*stacks.PlannedChange_ChangeDescription{ + { + Description: &stacks.PlannedChange_ChangeDescription_ActionInvocationPlanned{ + ActionInvocationPlanned: &stacks.PlannedChange_ActionInvocationInstance{ + Addr: &stacks.ActionInvocationInstanceInStackAddr{ + ComponentInstanceAddr: "component.web", + ActionInvocationInstanceAddr: "action.webhook.notify", + }, + ProviderAddr: "example.com/webhooks/http", + ActionType: "webhook", + ConfigValue: &stacks.DynamicValue{ + Msgpack: emptyObjectForPlan, + }, + ActionTrigger: &stacks.PlannedChange_ActionInvocationInstance_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.PlannedChange_ResourceActionTrigger{ + TriggeringResourceAddress: &stacks.ResourceInstanceInStackAddr{ + ComponentInstanceAddr: "component.web", + ResourceInstanceAddr: "example_resource.main", + }, + TriggerEvent: stacks.PlannedChange_AFTER_CREATE, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + }, + }, + }, + }, + }, + }, + }, + // TODO: Add test for "action invocation invoke trigger" when implementing + // direct action invocation in a later iteration. The InvokeActionTrigger + // case is mentioned in the interface for completeness but not yet implemented. } for name, test := range tests { diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go new file mode 100644 index 0000000000..8f63fed045 --- /dev/null +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -0,0 +1,215 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackruntime + +import ( + "testing" + + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" +) + +// TestActionInvocationHooksValidation validates that action invocation status +// hooks work correctly, including enum values, hook data structure, and lifecycle ordering. +func TestActionInvocationHooksValidation(t *testing.T) { + t.Run("hook_capture_mechanism", func(t *testing.T) { + // Verify CapturedHooks mechanism initializes correctly + capturedHooks := NewCapturedHooks(false) // false = apply phase + + if capturedHooks == nil { + t.Fatal("CapturedHooks should not be nil") + } + + // Verify the hooks slice starts empty (nil or zero length) + if len(capturedHooks.ReportActionInvocationStatus) != 0 { + t.Errorf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) + } + + // Verify we can append to it + capturedHooks.ReportActionInvocationStatus = append( + capturedHooks.ReportActionInvocationStatus, + &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, + }, + ) + + if len(capturedHooks.ReportActionInvocationStatus) != 1 { + t.Errorf("after append, expected 1 hook, got %d", len(capturedHooks.ReportActionInvocationStatus)) + } + }) + + t.Run("action_invocation_status_enum", func(t *testing.T) { + // Test that all enum constants are defined and have valid string representations + statuses := []hooks.ActionInvocationStatus{ + hooks.ActionInvocationStatusInvalid, + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, + hooks.ActionInvocationErrored, + } + + expectedStrings := map[hooks.ActionInvocationStatus]string{ + hooks.ActionInvocationStatusInvalid: "ActionInvocationStatusInvalid", + hooks.ActionInvocationPending: "ActionInvocationPending", + hooks.ActionInvocationRunning: "ActionInvocationRunning", + hooks.ActionInvocationCompleted: "ActionInvocationCompleted", + hooks.ActionInvocationErrored: "ActionInvocationErrored", + } + + // Verify String() returns expected values + for _, status := range statuses { + str := status.String() + expected, ok := expectedStrings[status] + if !ok { + t.Errorf("unexpected status constant: %v", status) + continue + } + if str != expected { + t.Errorf("status %v: expected String() = %q, got %q", status, expected, str) + } + } + + // Verify ForProtobuf() returns valid values (non-negative) + for _, status := range statuses { + proto := status.ForProtobuf() + if proto < 0 { + t.Errorf("status %v has invalid protobuf value: %v", status, proto) + } + } + + // Verify we have exactly 5 status values + if len(statuses) != 5 { + t.Errorf("expected 5 status constants, got %d", len(statuses)) + } + }) + + t.Run("hook_data_structure", func(t *testing.T) { + // Validate ActionInvocationStatusHookData structure and methods + hookData := &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, + } + + // Verify fields are set + if hookData.Addr.String() == "" { + t.Error("Addr should not be empty") + } + if hookData.ProviderAddr.String() == "" { + t.Error("ProviderAddr should not be empty") + } + if hookData.Status == hooks.ActionInvocationStatusInvalid { + t.Error("Status should not be Invalid when explicitly set to Running") + } + + // Verify String() method + str := hookData.String() + if str == "" || str == "" { + t.Errorf("String() should return valid representation, got: %q", str) + } + + // Verify String() contains address + if !contains(str, "component.test") { + t.Errorf("String() should contain address, got: %q", str) + } + + // Verify nil handling + var nilHook *hooks.ActionInvocationStatusHookData + if nilHook.String() != "" { + t.Errorf("nil hook String() should return , got: %q", nilHook.String()) + } + }) + + t.Run("hook_status_lifecycle_ordering", func(t *testing.T) { + // Test expected hook status sequences for different scenarios + testCases := []struct { + name string + capturedStatuses []hooks.ActionInvocationStatus + wantValid bool + description string + }{ + { + name: "successful_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, + }, + wantValid: true, + description: "Action starts running and completes successfully", + }, + { + name: "failed_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationErrored, + }, + wantValid: true, + description: "Action starts running but encounters an error", + }, + { + name: "pending_then_running_then_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, + }, + wantValid: true, + description: "Action goes through all states including pending", + }, + { + name: "invalid_only_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationCompleted, + }, + wantValid: false, + description: "Invalid: completed without running", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Verify we captured the expected number of statuses + if len(tc.capturedStatuses) == 0 { + t.Error("test case should have at least one status") + return + } + + // For valid sequences, verify terminal state is at the end + if tc.wantValid && len(tc.capturedStatuses) > 0 { + lastStatus := tc.capturedStatuses[len(tc.capturedStatuses)-1] + isTerminal := lastStatus == hooks.ActionInvocationCompleted || + lastStatus == hooks.ActionInvocationErrored + + if !isTerminal { + t.Errorf("valid sequence should end in terminal state (Completed/Errored), got %v", lastStatus) + } + } + + // For invalid sequences starting with Completed, verify it's actually invalid + if !tc.wantValid && len(tc.capturedStatuses) > 0 { + firstStatus := tc.capturedStatuses[0] + if firstStatus == hooks.ActionInvocationCompleted && len(tc.capturedStatuses) == 1 { + // This is indeed invalid - can't complete without running + t.Logf("correctly identified invalid sequence: %v", tc.capturedStatuses) + } + } + }) + } + }) +} + +// contains checks if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/stacks/stackruntime/helper_hooks_test.go b/internal/stacks/stackruntime/helper_hooks_test.go index bace454a2a..8cc9bbe431 100644 --- a/internal/stacks/stackruntime/helper_hooks_test.go +++ b/internal/stacks/stackruntime/helper_hooks_test.go @@ -33,6 +33,9 @@ type ExpectedHooks struct { ReportResourceInstanceDrift []*hooks.ResourceInstanceChange ReportResourceInstancePlanned []*hooks.ResourceInstanceChange ReportResourceInstanceDeferred []*hooks.DeferredResourceInstanceChange + ReportActionInvocationPlanned []*hooks.ActionInvocation + ReportActionInvocationStatus []*hooks.ActionInvocationStatusHookData + ReportActionInvocationProgress []*hooks.ActionInvocationProgressHookData ReportComponentInstancePlanned []*hooks.ComponentInstanceChange ReportComponentInstanceApplied []*hooks.ComponentInstanceChange } @@ -59,6 +62,15 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { sort.SliceStable(expectedHooks.ReportResourceInstanceDeferred, func(i, j int) bool { return expectedHooks.ReportResourceInstanceDeferred[i].Change.Addr.String() < expectedHooks.ReportResourceInstanceDeferred[j].Change.Addr.String() }) + sort.SliceStable(expectedHooks.ReportActionInvocationPlanned, func(i, j int) bool { + return expectedHooks.ReportActionInvocationPlanned[i].Addr.String() < expectedHooks.ReportActionInvocationPlanned[j].Addr.String() + }) + sort.SliceStable(expectedHooks.ReportActionInvocationStatus, func(i, j int) bool { + return expectedHooks.ReportActionInvocationStatus[i].Addr.String() < expectedHooks.ReportActionInvocationStatus[j].Addr.String() + }) + sort.SliceStable(expectedHooks.ReportActionInvocationProgress, func(i, j int) bool { + return expectedHooks.ReportActionInvocationProgress[i].Addr.String() < expectedHooks.ReportActionInvocationProgress[j].Addr.String() + }) sort.SliceStable(expectedHooks.ReportComponentInstancePlanned, func(i, j int) bool { return expectedHooks.ReportComponentInstancePlanned[i].Addr.String() < expectedHooks.ReportComponentInstancePlanned[j].Addr.String() }) @@ -114,6 +126,15 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { if diff := cmp.Diff(expectedHooks.ReportResourceInstanceDeferred, eh.ReportResourceInstanceDeferred); len(diff) > 0 { t.Errorf("wrong ReportResourceInstanceDeferred hooks: %s", diff) } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationPlanned, eh.ReportActionInvocationPlanned); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationPlanned hooks: %s", diff) + } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationStatus, eh.ReportActionInvocationStatus); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationStatus hooks: %s", diff) + } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationProgress, eh.ReportActionInvocationProgress); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationProgress hooks: %s", diff) + } if diff := cmp.Diff(expectedHooks.ReportComponentInstancePlanned, eh.ReportComponentInstancePlanned); len(diff) > 0 { t.Errorf("wrong ReportComponentInstancePlanned hooks: %s", diff) } @@ -369,6 +390,51 @@ func (ch *CapturedHooks) captureHooks() *Hooks { ch.ReportResourceInstanceDeferred = append(ch.ReportResourceInstanceDeferred, change) return a }, + ReportActionInvocationPlanned: func(ctx context.Context, a any, ai *hooks.ActionInvocation) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(ai.Addr.Component) { + panic("tried to report action invocation planned before component") + } + + if ch.ComponentInstanceFinished(ai.Addr.Component) { + panic("tried to report action invocation planned after component") + } + + ch.ReportActionInvocationPlanned = append(ch.ReportActionInvocationPlanned, ai) + return a + }, + ReportActionInvocationStatus: func(ctx context.Context, a any, status *hooks.ActionInvocationStatusHookData) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(status.Addr.Component) { + panic("tried to report action invocation status before component") + } + + if ch.ComponentInstanceFinished(status.Addr.Component) { + panic("tried to report action invocation status after component") + } + + ch.ReportActionInvocationStatus = append(ch.ReportActionInvocationStatus, status) + return a + }, + ReportActionInvocationProgress: func(ctx context.Context, a any, progress *hooks.ActionInvocationProgressHookData) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(progress.Addr.Component) { + panic("tried to report action invocation progress before component") + } + + if ch.ComponentInstanceFinished(progress.Addr.Component) { + panic("tried to report action invocation progress after component") + } + + ch.ReportActionInvocationProgress = append(ch.ReportActionInvocationProgress, progress) + return a + }, ReportComponentInstancePlanned: func(ctx context.Context, a any, change *hooks.ComponentInstanceChange) any { ch.Lock() defer ch.Unlock() diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index e92a63da1f..15e91a7f3a 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -52,7 +52,6 @@ type TestContext struct { // TestCycle defines a single plan / apply cycle that should be performed within // a test. type TestCycle struct { - // Validate options wantValidateDiags tfdiags.Diagnostics @@ -441,6 +440,10 @@ func plannedChangeSortKey(change stackplan.PlannedChange) string { // There should only be a single timestamp in a plan, so we can just // return a simple string. return "function-results" + case *stackplan.PlannedChangeActionInvocationInstancePlanned: + return change.ActionInvocationAddr.String() + case *stackplan.PlannedChangeDeferredActionInvocation: + return change.ActionInvocationPlanned.ActionInvocationAddr.String() default: // This is only going to happen during tests, so we can panic here. panic(fmt.Errorf("unrecognized planned change type: %T", change)) @@ -481,7 +484,6 @@ func diagnosticSortFunc(diags tfdiags.Diagnostics) func(i, j int) bool { return sortDescription(id.Description(), jd.Description()) } if id.Source().Subject != nil && jd.Source().Subject != nil { - return sortRange(id.Source().Subject, jd.Source().Subject) } @@ -525,6 +527,14 @@ func mustAbsComponentInstance(addr string) stackaddrs.AbsComponentInstance { return ret } +func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocationInstance { + ret, diags := stackaddrs.ParseActionInvocationInstanceStr(addr) + if len(diags) > 0 { + panic(fmt.Sprintf("failed to parse action invocation instance address %q: %s", addr, diags)) + } + return ret +} + func mustAbsComponent(addr string) stackaddrs.AbsComponent { ret, diags := stackaddrs.ParsePartialComponentInstanceStr(addr) if len(diags) > 0 { diff --git a/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go b/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go new file mode 100644 index 0000000000..26cbe82003 --- /dev/null +++ b/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go @@ -0,0 +1,41 @@ +// Code generated by "stringer -type=ActionInvocationStatus resource_instance.go"; DO NOT EDIT. + +package hooks + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ActionInvocationStatusInvalid-0] + _ = x[ActionInvocationPending-112] + _ = x[ActionInvocationRunning-114] + _ = x[ActionInvocationCompleted-67] + _ = x[ActionInvocationErrored-69] +} + +const ( + _ActionInvocationStatus_name_0 = "ActionInvocationStatusInvalid" + _ActionInvocationStatus_name_1 = "ActionInvocationCompleted" + _ActionInvocationStatus_name_2 = "ActionInvocationErrored" + _ActionInvocationStatus_name_3 = "ActionInvocationPending" + _ActionInvocationStatus_name_4 = "ActionInvocationRunning" +) + +func (i ActionInvocationStatus) String() string { + switch { + case i == 0: + return _ActionInvocationStatus_name_0 + case i == 67: + return _ActionInvocationStatus_name_1 + case i == 69: + return _ActionInvocationStatus_name_2 + case i == 112: + return _ActionInvocationStatus_name_3 + case i == 114: + return _ActionInvocationStatus_name_4 + default: + return "ActionInvocationStatus(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/internal/stacks/stackruntime/hooks/component_instance.go b/internal/stacks/stackruntime/hooks/component_instance.go index 5b0f2bb729..cfae9ada9a 100644 --- a/internal/stacks/stackruntime/hooks/component_instance.go +++ b/internal/stacks/stackruntime/hooks/component_instance.go @@ -53,14 +53,15 @@ func (s ComponentInstanceStatus) ForProtobuf() stacks.StackChangeProgress_Compon // ComponentInstanceChange is the argument type for hook callbacks which // signal a set of planned or applied changes for a component instance. type ComponentInstanceChange struct { - Addr stackaddrs.AbsComponentInstance - Add int - Change int - Import int - Remove int - Defer int - Move int - Forget int + Addr stackaddrs.AbsComponentInstance + Add int + Change int + Import int + Remove int + Defer int + Move int + Forget int + ActionInvocation int } // Total sums all of the change counts as a forwards-compatibility measure. If @@ -68,7 +69,7 @@ type ComponentInstanceChange struct { // that the component instance has some unknown changes, rather than falsely // stating that there are no changes at all. func (cic ComponentInstanceChange) Total() int { - return cic.Add + cic.Change + cic.Import + cic.Remove + cic.Defer + cic.Move + cic.Forget + return cic.Add + cic.Change + cic.Import + cic.Remove + cic.Defer + cic.Move + cic.Forget + cic.ActionInvocation } // CountNewAction increments zero or more of the count fields based on the diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index f0f739dd78..c00a93e629 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -117,3 +117,68 @@ type DeferredResourceInstanceChange struct { Reason providers.DeferredReason Change *ResourceInstanceChange } + +type ActionInvocation struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Trigger plans.ActionTrigger +} + +// ActionInvocationStatus represents the lifecycle status of an action invocation. +type ActionInvocationStatus rune + +//go:generate go tool golang.org/x/tools/cmd/stringer -type=ActionInvocationStatus resource_instance.go + +const ( + ActionInvocationStatusInvalid ActionInvocationStatus = 0 + ActionInvocationPending ActionInvocationStatus = 'p' + ActionInvocationRunning ActionInvocationStatus = 'r' + ActionInvocationCompleted ActionInvocationStatus = 'C' + ActionInvocationErrored ActionInvocationStatus = 'E' +) + +// ForProtobuf converts the typed status to the protobuf enum value. +func (s ActionInvocationStatus) ForProtobuf() stacks.StackChangeProgress_ActionInvocationStatus_Status { + switch s { + case ActionInvocationPending: + return stacks.StackChangeProgress_ActionInvocationStatus_PENDING + case ActionInvocationRunning: + return stacks.StackChangeProgress_ActionInvocationStatus_RUNNING + case ActionInvocationCompleted: + return stacks.StackChangeProgress_ActionInvocationStatus_COMPLETED + case ActionInvocationErrored: + return stacks.StackChangeProgress_ActionInvocationStatus_ERRORED + default: + return stacks.StackChangeProgress_ActionInvocationStatus_INVALID + } +} + +type ActionInvocationStatusHookData struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Status ActionInvocationStatus + Trigger plans.ActionTrigger +} + +// String returns a concise string representation of the action invocation status. +func (a *ActionInvocationStatusHookData) String() string { + if a == nil { + return "" + } + return a.Addr.String() + " [" + a.Status.String() + "]" +} + +type ActionInvocationProgressHookData struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Message string + Trigger plans.ActionTrigger +} + +// String returns a concise string representation of the action invocation progress. +func (a *ActionInvocationProgressHookData) String() string { + if a == nil { + return "" + } + return a.Addr.String() + ": " + a.Message +} diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index a39b7c8f02..e14c22a97d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -127,6 +127,24 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi hookSingle(ctx, hooksFromContext(ctx).PendingComponentInstanceApply, inst.Addr()) seq, ctx := hookBegin(ctx, h.BeginComponentInstanceApply, h.ContextAttach, inst.Addr()) + // Fire PENDING status for all planned action invocations + // These actions are queued and ready to execute during the apply phase + if plan.Changes != nil && len(plan.Changes.ActionInvocations) > 0 { + for _, action := range plan.Changes.ActionInvocations { + absActionAddr := stackaddrs.AbsActionInvocationInstance{ + Component: inst.Addr(), + Item: action.Addr, + } + + hookMore(ctx, seq, h.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: absActionAddr, + ProviderAddr: action.ProviderAddr.Provider, + Status: hooks.ActionInvocationPending, + Trigger: action.ActionTrigger, + }) + } + } + moduleTree := inst.ModuleTree(ctx) if moduleTree == nil { // We should not get here because if the configuration was statically @@ -174,6 +192,15 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi hooks: hooksFromContext(ctx), addr: inst.Addr(), } + + // Populate action invocation provider address map for hook callbacks + if plan.Changes != nil && len(plan.Changes.ActionInvocations) > 0 { + tfHook.actionInvocationProviderAddr = addrs.MakeMap[addrs.AbsActionInstance, addrs.Provider]() + for _, action := range plan.Changes.ActionInvocations { + tfHook.actionInvocationProviderAddr.Put(action.Addr, action.ProviderAddr.Provider) + } + } + tfCtx, err := terraform.NewContext(&terraform.ContextOpts{ Hooks: []terraform.Hook{ tfHook, diff --git a/internal/stacks/stackruntime/internal/stackeval/component_config.go b/internal/stacks/stackruntime/internal/stackeval/component_config.go index 4e5817f90f..7813106d31 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_config.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_config.go @@ -186,6 +186,19 @@ func validateModuleForStacks(moduleAddr addrs.Module, module *configs.Module) tf } } + // Const variables are not supported in stacks, because stacks does not + // perform the early evaluation phase that const variables rely on. + for _, v := range module.Variables { + if v.ConstSet { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable not supported in stacks", + Detail: "Variables with const = true are not supported in modules used as stack components. Const variables are evaluated during configuration loading, which is not supported in the stacks runtime.", + Subject: v.DeclRange.Ptr(), + }) + } + } + return diags } diff --git a/internal/stacks/stackruntime/internal/stackeval/component_instance.go b/internal/stacks/stackruntime/internal/stackeval/component_instance.go index 8b0cfb8d8e..b1ea7ac257 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_instance.go @@ -814,6 +814,20 @@ func (c *ComponentInstance) ResourceSchema(ctx context.Context, providerTypeAddr return ret, nil } +// ActionSchema implements stackplan.PlanProducer. +func (c *ComponentInstance) ActionSchema(ctx context.Context, providerTypeAddr addrs.Provider, actionType string) (providers.ActionSchema, error) { + providerType := c.main.ProviderType(providerTypeAddr) + providerSchema, err := providerType.Schema(ctx) + if err != nil { + return providers.ActionSchema{}, err + } + ret := providerSchema.SchemaForActionType(actionType) + if ret.ConfigSchema == nil { + return providers.ActionSchema{}, fmt.Errorf("schema does not include action type %q", actionType) + } + return ret, nil +} + // RequiredComponents implements stackplan.PlanProducer. func (c *ComponentInstance) RequiredComponents(ctx context.Context) collections.Set[stackaddrs.AbsComponent] { return c.call.RequiredComponents(ctx) diff --git a/internal/stacks/stackruntime/internal/stackeval/hooks.go b/internal/stacks/stackruntime/internal/stackeval/hooks.go index 567fa813c2..3d4e63d78b 100644 --- a/internal/stacks/stackruntime/internal/stackeval/hooks.go +++ b/internal/stacks/stackruntime/internal/stackeval/hooks.go @@ -130,6 +130,10 @@ type Hooks struct { // [Hooks.BeginComponentInstancePlan]. ReportResourceInstanceDeferred hooks.MoreFunc[*hooks.DeferredResourceInstanceChange] + ReportActionInvocationPlanned hooks.MoreFunc[*hooks.ActionInvocation] + ReportActionInvocationStatus hooks.MoreFunc[*hooks.ActionInvocationStatusHookData] + ReportActionInvocationProgress hooks.MoreFunc[*hooks.ActionInvocationProgressHookData] + // ReportComponentInstancePlanned is called after a component instance // is planned. It should be called inside a tracing context established by // [Hooks.BeginComponentInstancePlan]. diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index 24e5073e18..48a883f71c 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -6,16 +6,19 @@ package stackeval import ( "context" "fmt" + "strings" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" + "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/objchange" "github.com/hashicorp/terraform/internal/promising" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackplan" "github.com/hashicorp/terraform/internal/stacks/stackstate" "github.com/hashicorp/terraform/internal/tfdiags" @@ -144,10 +147,7 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } - // First, apply any defaults that are declared in the - // configuration. - - // Next, convert the value to the expected type. + // Convert the value to the expected type. val, err = convert.Convert(val, wantTy) if err != nil { diags = diags.Append(&hcl.Diagnostic{ @@ -186,9 +186,20 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } - // TODO: check the value against any custom validation rules - // declared in the configuration. - return cfg.markValue(val), diags + // Mark the value before validation so that validation can detect + // sensitive/ephemeral marks and avoid leaking protected values in error messages. + val = cfg.markValue(val) + + // Evaluate custom validation rules against the input value. + // Validation is skipped during ValidatePhase because actual input values + // are not yet available at that phase — only plan/apply provide them. + // This matches the behavior of core Terraform's variable validation. + if phase != ValidatePhase { + moreDiags := v.evalVariableValidations(ctx, val, phase) + diags = diags.Append(moreDiags) + } + + return val, diags default: definedByCallInst, definedByRemovedCallInst := v.DefinedByStackCallInstance(ctx, phase) @@ -197,18 +208,32 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va allVals := definedByCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // TODO: check the value against any custom validation rules - // declared in the configuration. + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) - return cfg.markValue(val), diags + // Evaluate custom validation rules for values from stack call instances. + // Skip during ValidatePhase as values are not yet available. + if phase != ValidatePhase { + moreDiags := v.evalVariableValidations(ctx, val, phase) + diags = diags.Append(moreDiags) + } + + return val, diags case definedByRemovedCallInst != nil: allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // TODO: check the value against any custom validation rules - // declared in the configuration. + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) - return cfg.markValue(val), diags + // Evaluate validation rules for removed stack instances. + // Skip during ValidatePhase as values are not yet available. + if phase != ValidatePhase { + moreDiags := v.evalVariableValidations(ctx, val, phase) + diags = diags.Append(moreDiags) + } + + return val, diags default: // We seem to belong to a call instance that doesn't actually // exist in the configuration. That either means that @@ -364,6 +389,229 @@ func (v *InputVariable) tracingName() string { return v.addr.String() } +// evalVariableValidations evaluates all custom validation rules for this input variable +// against the given value, returning diagnostics if any validations fail. +// +// This function implements runtime validation checking, which is distinct from the +// config-time parsing done in stackconfig. The validation rules were parsed and stored +// during config loading; this function evaluates those rules against actual input values. +// +// The validation process: +// 1. Creates an HCL evaluation context with the variable's value and available functions +// 2. Evaluates each validation rule's condition and error_message expressions +// 3. Always validates the error_message structure (sensitive/ephemeral marks are flagged +// regardless of whether the condition passes or fails) +// 4. If the condition is false, reports an "Invalid value for variable" diagnostic +// +// This follows the same approach as core Terraform's evalVariableValidations, including +// handling of sensitive values, unknown values, and error message evaluation. +func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Value, phase EvalPhase) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + rules := v.config.config.Validations + if len(rules) == 0 { + // No validation rules defined, nothing to check + return diags + } + + // Get provider-defined functions from the stack scope. + // These will be combined with built-in functions below. + functions, moreDiags := v.stack.ExternalFunctions(ctx) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // If we can't get the function table, we can't evaluate validation expressions + // that depend on functions. Return early to avoid confusing downstream errors. + return diags + } + + // Create a scope to get the complete function table (provider-defined + built-in). + // fakeScope.Functions() will combine the provider functions with built-in functions + // like length(), regex(), etc. We don't need a full evaluation context, just the functions. + fakeScope := &lang.Scope{ + Data: nil, // not a real scope; can't actually make an evalcontext + BaseDir: ".", + PureOnly: phase != ApplyPhase, + PlanTimestamp: v.stack.PlanTimestamp(), + ExternalFuncs: functions, + } + + // Create an HCL evaluation context with the variable value and functions. + // The variable is made available as var. within validation expressions. + hclCtx := &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + v.addr.Item.Name: val, + }), + }, + Functions: fakeScope.Functions(), + } + + // Evaluate each validation rule independently. + // Multiple validation failures will all be reported. + for _, validation := range rules { + moreDiags := evalVariableValidation(validation, hclCtx, v.config.config.DeclRange.ToHCL()) + diags = diags.Append(moreDiags) + } + + return diags +} + +// evalVariableValidation evaluates a single validation rule against a variable value. +// +// This function handles the evaluation of one validation block's condition and error_message. +// It follows the same logic as core Terraform's variable validation: +// +// 1. Evaluates the condition and error_message expressions up front +// 2. Handles unknown/null/invalid condition results appropriately +// 3. Always validates the error_message structure — sensitive/ephemeral marks +// in the message are flagged even when the condition passes +// 4. Returns early if the condition passes (after reporting any message issues) +// 5. Otherwise constructs an "Invalid value for variable" diagnostic +// +// Parameters: +// - validation: The validation rule to evaluate (contains condition and error_message expressions) +// - hclCtx: The HCL evaluation context with the variable value and functions +// - valueRng: The source range of the variable declaration (for diagnostic reporting) +func evalVariableValidation(validation *stackconfig.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics { + const errInvalidCondition = "Invalid variable validation result" + const errInvalidValue = "Invalid value for variable" + var diags tfdiags.Diagnostics + + result, moreDiags := validation.Condition.Value(hclCtx) + diags = diags.Append(moreDiags) + + if moreDiags.HasErrors() { + // If we couldn't evaluate the condition at all (syntax error, etc.), + // return early. The error is already in diags. + return diags + } + + // If the condition result is unknown, we can't determine validity yet. + // This can happen when the condition references computed values. + // Skip validation for now - it will be checked during apply if needed. + if !result.IsKnown() { + return diags + } + + // Check if the result is null + if result.IsNull() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidCondition, + Detail: "Validation condition expression must return either true or false, not null.", + Subject: validation.Condition.Range().Ptr(), + Expression: validation.Condition, + EvalContext: hclCtx, + }) + return diags + } + + // Convert result to boolean + var err error + result, err = convert.Convert(result, cty.Bool) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidCondition, + Detail: fmt.Sprintf("Invalid validation condition result value: %s.", tfdiags.FormatError(err)), + Subject: validation.Condition.Range().Ptr(), + Expression: validation.Condition, + EvalContext: hclCtx, + }) + return diags + } + + // Remove any marks (sensitive, ephemeral) before checking the boolean value. + // The marks don't affect the validation result, only how we handle the error message. + result, _ = result.Unmark() + + // Always evaluate the error_message expression, even when the condition passes — + // unknown, sensitive, or ephemeral values in the message are structural problems + // regardless of whether the check succeeds or fails. + errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) + diags = diags.Append(errorDiags) + + var errorMessage string + if !errorDiags.HasErrors() { + if !errorValue.IsKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: "Unsuitable value for error message: expression refers to values that won't be known until the apply phase.", + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + return diags + } else if !errorValue.IsNull() { + errorValue, err = convert.Convert(errorValue, cty.String) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid error message", + Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)), + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + } else { + // Check for sensitive/ephemeral marks; these are flagged even when + // the condition passes, since the error message is structurally invalid. + if marks.Has(errorValue, marks.Sensitive) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to sensitive values", + Detail: `The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message. + +You can correct this by removing references to sensitive values, or by carefully using the nonsensitive() function if the expression will not reveal the sensitive data.`, + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + errorMessage = "The error message included a sensitive value, so it will not be displayed." + } else if marks.Has(errorValue, marks.Ephemeral) { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error message refers to ephemeral values", + Detail: `The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message. + +You can correct this by removing references to ephemeral values, or by carefully using the ephemeralasnull() function if the expression will not reveal the ephemeral data.`, + Subject: validation.ErrorMessage.Range().Ptr(), + Expression: validation.ErrorMessage, + EvalContext: hclCtx, + }) + errorMessage = "The error message included an ephemeral value, so it will not be displayed." + } else { + errorMessage = strings.TrimSpace(errorValue.AsString()) + } + } + } + } + if errorMessage == "" { + errorMessage = "Failed to evaluate condition error message." + } + + // If the condition evaluated to true, the validation passed. We've validated + // the error message above, so any structural issues are already reported. + if result.True() { + return diags + } + + // Construct the validation failure diagnostic. + // The detail includes both the custom error message and a reference to where + // the validation rule is defined, helping users locate the validation in their config. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidValue, + Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", errorMessage, validation.DeclRange.String()), + Subject: &valueRng, + Expression: validation.Condition, + EvalContext: hclCtx, + }) + + return diags +} + // ExternalInputValue represents the value of an input variable provided // from outside the stack configuration. type ExternalInputValue struct { diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go index 67a69ccbff..e8c3f27457 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go @@ -5,10 +5,15 @@ package stackeval import ( "context" + "fmt" + "strings" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hcltest" "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" "google.golang.org/protobuf/encoding/prototext" @@ -17,9 +22,13 @@ import ( "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/promising" + "github.com/hashicorp/terraform/internal/providers" + testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackplan" "github.com/hashicorp/terraform/internal/stacks/stackstate" + "github.com/hashicorp/terraform/internal/tfdiags" ) func TestInputVariableValue(t *testing.T) { @@ -487,3 +496,617 @@ func TestInputVariablePlanChanges(t *testing.T) { }) } } + +// TestEvalVariableValidation tests the evalVariableValidation function directly, +// covering all the "invalid" cases: sensitive/ephemeral values in the error message, +// unknown/null condition results, and unknown error messages. These tests +// exercise the logic independently of the full stack-evaluator machinery. +func TestEvalVariableValidation(t *testing.T) { + // parseExpr parses a real HCL expression from a source string. + parseExpr := func(t *testing.T, src string) hcl.Expression { + t.Helper() + expr, diags := hclsyntax.ParseExpression([]byte(src), "test.hcl", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse expression %q: %s", src, diags.Error()) + } + return expr + } + + // makeFakeRule builds a minimal stackconfig.CheckRule from two expressions. + makeFakeRule := func(condition, errorMessage hcl.Expression) *stackconfig.CheckRule { + return &stackconfig.CheckRule{ + Condition: condition, + ErrorMessage: errorMessage, + DeclRange: hcl.Range{ + Filename: "test.hcl", + Start: hcl.Pos{Line: 2, Column: 1}, + End: hcl.Pos{Line: 5, Column: 1}, + }, + } + } + + // makeVarCtx builds an HCL evaluation context that exposes var.foo = val. + makeVarCtx := func(val cty.Value) *hcl.EvalContext { + return &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + "foo": val, + }), + }, + } + } + + valueRange := hcl.Range{ + Filename: "test.hcl", + Start: hcl.Pos{Line: 1, Column: 1}, + End: hcl.Pos{Line: 1, Column: 10}, + } + + // --- Basic pass/fail --- + + t.Run("condition passes, clean message → no diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertNoDiags(t, diags) + }) + + t.Run("condition fails, clean message → Invalid value for variable", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Value must be 'good'.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Sensitive error message --- + + t.Run("condition passes, sensitive error_message → flagged even on success", func(t *testing.T) { + // The error_message evaluates to a sensitive string even though the + // condition passes. This structural problem must always be reported. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + // Condition passed, so there must be no "Invalid value for variable". + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed") + } + } + }) + + t.Run("condition fails, sensitive error_message → both diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Ephemeral error message --- + + t.Run("condition passes, ephemeral error_message → flagged even on success", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + // Condition passed, so there must be no "Invalid value for variable". + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed") + } + } + }) + + t.Run("condition fails, ephemeral error_message → both diagnostics", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + // --- Unknown / null condition results --- + + t.Run("condition result unknown → no diagnostics", func(t *testing.T) { + // Unknown condition means we cannot determine validity yet; skip quietly. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.UnknownVal(cty.Bool)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.UnknownVal(cty.String)), valueRange) + assertNoDiags(t, diags) + }) + + t.Run("condition result null → Invalid variable validation result", func(t *testing.T) { + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.NullVal(cty.Bool)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid variable validation result" + }) + }) + + // --- Unknown error message --- + + t.Run("error message unknown, condition fails → Invalid error message only", func(t *testing.T) { + // An unknown error_message is always a structural problem: the validation + // block is invalid regardless of whether the condition passes or fails, + // because Terraform can never safely display the message. + // We return early on the unknown message, so "Invalid value for variable" + // must NOT also be emitted. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when error message is unknown") + } + } + }) + + t.Run("error message unknown, condition passes → Invalid error message only", func(t *testing.T) { + // An unknown error_message is always a structural problem: the validation + // block is invalid regardless of whether the condition passes or fails, + // because Terraform can never safely display the message. + // We return early on the unknown message, so "Invalid value for variable" + // must NOT be emitted even though the condition passed. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when error message is unknown") + } + } + }) + + // --- Sensitive variable value in condition expression --- + + t.Run("sensitive variable value, plain error message, condition fails → only Invalid value for variable", func(t *testing.T) { + // var.foo carries a sensitive mark. The condition expression + // (var.foo == "good") evaluates to a sensitive bool; Unmark() peels + // off the mark so the check works correctly. The error_message is a + // plain literal → no "Error message refers to sensitive values" diag. + hclCtx := makeVarCtx(cty.StringVal("bad").Mark(marks.Sensitive)) + rule := makeFakeRule( + parseExpr(t, `var.foo == "good"`), + hcltest.MockExprLiteral(cty.StringVal("Value is not allowed.")), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + for _, d := range diags { + if d.Description().Summary == "Error message refers to sensitive values" { + t.Errorf("unexpected 'Error message refers to sensitive values' when error message is plain text") + } + } + }) + + t.Run("sensitive variable referenced in error message, condition fails → both diagnostics", func(t *testing.T) { + // When the error_message interpolates a sensitive variable the + // evaluated message is itself sensitive — both the sensitive-value + // diagnostic and the generic failure diagnostic must be emitted. + hclCtx := makeVarCtx(cty.StringVal("secret").Mark(marks.Sensitive)) + rule := makeFakeRule( + parseExpr(t, `var.foo == "good"`), + parseExpr(t, `"Value '${var.foo}' is not allowed."`), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to sensitive values" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + + t.Run("ephemeral variable referenced in error message, condition passes → flagged even on success", func(t *testing.T) { + // The condition passes but the error_message references an ephemeral + // variable, making the message itself ephemeral. This structural + // problem must still be reported. + hclCtx := makeVarCtx(cty.StringVal("good").Mark(marks.Ephemeral)) + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + parseExpr(t, `"Value '${var.foo}' is not allowed."`), + ) + diags := evalVariableValidation(rule, hclCtx, valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Error message refers to ephemeral values" + }) + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when condition passed") + } + } + }) + + // --- Condition evaluation error --- + + t.Run("condition evaluation error → early return with HCL error", func(t *testing.T) { + // When the condition expression itself fails to evaluate (e.g. it references + // an undefined variable), evalVariableValidation must return early with the + // evaluation error and must NOT emit "Invalid value for variable" or + // "Invalid variable validation result". + rule := makeFakeRule( + parseExpr(t, "undefined_var.foo"), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + // hclCtx only has "var", so "undefined_var" is unknown → evaluation error. + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + if !diags.HasErrors() { + t.Fatal("expected at least one error diagnostic, got none") + } + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' on condition evaluation error") + } + if d.Description().Summary == "Invalid variable validation result" { + t.Errorf("unexpected 'Invalid variable validation result' on condition evaluation error") + } + } + }) + + // --- Non-bool condition result --- + + t.Run("condition result is non-bool (list) → Invalid variable validation result", func(t *testing.T) { + // A condition that returns a list (or any value that cannot be converted + // to bool) hits the convert.Convert(result, cty.Bool) failure path. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)), + hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid variable validation result" + }) + // Must return early — no "Invalid value for variable" should follow. + for _, d := range diags { + if d.Description().Summary == "Invalid value for variable" { + t.Errorf("unexpected 'Invalid value for variable' when condition type conversion failed") + } + } + }) + + // --- Null error message --- + + t.Run("null error message, condition fails → Invalid value for variable with fallback text", func(t *testing.T) { + // A null error_message is skipped during string conversion; the framework + // falls back to "Failed to evaluate condition error message." in the + // detail of the "Invalid value for variable" diagnostic. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.NullVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" && + strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.") + }) + }) + + // --- Non-string error message --- + + t.Run("non-string error message (list), condition fails → Invalid error message + fallback in failure diag", func(t *testing.T) { + // An error_message that evaluates to a list (unconvertible to string) + // hits the convert.Convert(errorValue, cty.String) failure path. Both + // "Invalid error message" and "Invalid value for variable" (with the + // fallback text) must be emitted. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.False), + hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid error message" + }) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" && + strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.") + }) + }) +} + +// TestInputVariableValidation exercises evalVariableValidations end-to-end +// through CheckValue, using the "validation" fixture that declares variables +// with validation blocks. +func TestInputVariableValidation(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation") + + tests := map[string]struct { + varName string + inputVal cty.Value + wantSummaries []string // diagnostics that MUST be present (by Summary) + wantNoErrors bool // if true, no error diagnostics are expected + }{ + // --- validated (plain error message) --- + "validated: clean pass": { + varName: "validated", + inputVal: cty.StringVal("good"), + wantNoErrors: true, + }, + "validated: clean fail": { + varName: "validated", + inputVal: cty.StringVal("bad"), + wantSummaries: []string{"Invalid value for variable"}, + }, + + // --- with_msg_ref (error message interpolates var.with_msg_ref) --- + "with_msg_ref: clean pass": { + varName: "with_msg_ref", + inputVal: cty.StringVal("good"), + wantNoErrors: true, + }, + "with_msg_ref: clean fail": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad"), + wantSummaries: []string{"Invalid value for variable"}, + }, + "with_msg_ref: sensitive value passes → error message diag only": { + // Condition passes, but the interpolated error_message is sensitive + // → we should still flag the structural problem. + varName: "with_msg_ref", + inputVal: cty.StringVal("good").Mark(marks.Sensitive), + wantSummaries: []string{"Error message refers to sensitive values"}, + }, + "with_msg_ref: sensitive value fails → both diags": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad").Mark(marks.Sensitive), + wantSummaries: []string{ + "Error message refers to sensitive values", + "Invalid value for variable", + }, + }, + "with_msg_ref: ephemeral value passes → error message diag only": { + varName: "with_msg_ref", + inputVal: cty.StringVal("good").Mark(marks.Ephemeral), + wantSummaries: []string{"Error message refers to ephemeral values"}, + }, + "with_msg_ref: ephemeral value fails → both diags": { + varName: "with_msg_ref", + inputVal: cty.StringVal("bad").Mark(marks.Ephemeral), + wantSummaries: []string{ + "Error message refers to ephemeral values", + "Invalid value for variable", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + tc.varName: tc.inputVal, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: tc.varName}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + + if tc.wantNoErrors { + if diags.HasErrors() { + t.Errorf("unexpected errors: %s", diags.Err()) + } + return + } + + for _, wantSummary := range tc.wantSummaries { + wantSummary := wantSummary // capture for closure + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == wantSummary + }) + } + }) + }) + } +} + +// TestInputVariableValidationWithProviderFunction verifies that provider-defined +// functions can be called inside a variable validation condition expression. +// It uses the "validation_provider_function" fixture together with a mock provider +// that exposes a simple "upper" string function. +func TestInputVariableValidationWithProviderFunction(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation_provider_function") + providerTypeAddr := addrs.MustParseProviderSourceString("terraform.io/builtin/testing") + + newMockProvider := func(t *testing.T) (*testing_provider.MockProvider, providers.Factory) { + t.Helper() + mockProvider := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Functions: map[string]providers.FunctionDecl{ + "upper": { + Parameters: []providers.FunctionParam{ + {Name: "input", Type: cty.String}, + }, + ReturnType: cty.String, + Summary: "Converts a string to upper-case.", + }, + }, + }, + CallFunctionFn: func(req providers.CallFunctionRequest) providers.CallFunctionResponse { + if req.FunctionName != "upper" { + return providers.CallFunctionResponse{ + Err: fmt.Errorf("unexpected function call: %s", req.FunctionName), + } + } + input, _ := req.Arguments[0].Unmark() + return providers.CallFunctionResponse{ + Result: cty.StringVal(strings.ToUpper(input.AsString())), + } + }, + } + return mockProvider, providers.FactoryFixed(mockProvider) + } + + t.Run("passes validation", func(t *testing.T) { + _, providerFactory := newMockProvider(t) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "foo": cty.StringVal("hello"), // upper("hello") == "HELLO" → condition passes + }, + ProviderFactories: ProviderFactories{ + providerTypeAddr: providerFactory, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + assertNoDiags(t, diags) + }) + }) + + t.Run("fails validation", func(t *testing.T) { + _, providerFactory := newMockProvider(t) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "foo": cty.StringVal("world"), // upper("world") == "WORLD" ≠ "HELLO" → condition fails + }, + ProviderFactories: ProviderFactories{ + providerTypeAddr: providerFactory, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool { + return d.Severity() == tfdiags.Error && + d.Description().Summary == "Invalid value for variable" + }) + }) + }) +} + +// TestInputVariableMultipleValidationRules verifies that when a variable has +// more than one validation block, every failing rule produces its own +// diagnostic — i.e., all rules are evaluated and none are short-circuited. +// +// The "multi_rule" variable in the "validation" fixture has two rules: +// +// Rule 1: length(var.multi_rule) >= 5 +// Rule 2: var.multi_rule != "bad" +// +// The value "bad" has length 3 (< 5) and equals "bad", so it violates both +// rules simultaneously, giving us exactly two "Invalid value for variable" +// diagnostics. +func TestInputVariableMultipleValidationRules(t *testing.T) { + cfg := testStackConfig(t, "input_variable", "validation") + + tests := map[string]struct { + inputVal cty.Value + wantErrCount int // expected number of "Invalid value for variable" diagnostics + }{ + "passes both rules": { + inputVal: cty.StringVal("hello"), // length 5 >= 5, != "bad" + wantErrCount: 0, + }, + "fails first rule only": { + inputVal: cty.StringVal("hi"), // length 2 < 5, != "bad" + wantErrCount: 1, + }, + "fails second rule only": { + // length("hello!") = 6 >= 5 → first passes; "hello!" != "bad" → second passes. + // To fail only the second rule we need length >= 5 AND value == "bad". + // "bad" itself has length 3, so the only way to isolate rule 2 failure + // is with a longer value that equals "bad" — impossible for a plain + // string. We therefore omit this sub-case and rely on the unit-level + // TestEvalVariableValidation coverage instead. + inputVal: cty.StringVal("hello"), // deliberately a pass case + wantErrCount: 0, + }, + "fails both rules": { + inputVal: cty.StringVal("bad"), // length 3 < 5 AND == "bad" + wantErrCount: 2, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "multi_rule": tc.inputVal, + }, + }) + inPromisingTask(t, func(ctx context.Context, t *testing.T) { + mainStack := main.MainStack() + rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "multi_rule"}) + _, diags := rootVar.CheckValue(ctx, InspectPhase) + + var failCount int + for _, d := range diags { + if d.Severity() == tfdiags.Error && d.Description().Summary == "Invalid value for variable" { + failCount++ + } + } + if failCount != tc.wantErrCount { + t.Errorf("expected %d 'Invalid value for variable' diagnostic(s), got %d; diags:\n%s", + tc.wantErrCount, failCount, diags.ErrWithWarnings()) + } + }) + }) + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/planning.go b/internal/stacks/stackruntime/internal/stackeval/planning.go index cbcb9d6ba5..5fddb770e8 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning.go @@ -105,6 +105,18 @@ func ReportComponentInstance(ctx context.Context, plan *plans.Plan, h *Hooks, se }) } + for _, actInvoke := range plan.Changes.ActionInvocations { + cic.ActionInvocation++ + hookMore(ctx, seq, h.ReportActionInvocationPlanned, &hooks.ActionInvocation{ + Addr: stackaddrs.AbsActionInvocationInstance{ + Component: addr, + Item: actInvoke.Addr, + }, + ProviderAddr: actInvoke.ProviderAddr.Provider, + Trigger: actInvoke.ActionTrigger, + }) + } + hookMore(ctx, seq, h.ReportComponentInstancePlanned, cic) } diff --git a/internal/stacks/stackruntime/internal/stackeval/planning_test.go b/internal/stacks/stackruntime/internal/stackeval/planning_test.go index 62d1595840..12e0f8b798 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning_test.go @@ -968,7 +968,6 @@ func TestPlanning_LocalsDataSource(t *testing.T) { assertNoDiagnostics(t, diags) return rawPlan, nil }) - if err != nil { t.Fatal(err) } @@ -1013,7 +1012,8 @@ func TestPlanning_LocalsDataSource(t *testing.T) { expectedString := cty.StringVal("through-local-aloha-foo-foo") expectedList := []cty.Value{ cty.StringVal("through-local-aloha-foo"), - cty.StringVal("foo")} + cty.StringVal("foo"), + } expectedMap := map[string]cty.Value{ "key": cty.StringVal("through-local-aloha-foo"), @@ -1040,7 +1040,6 @@ func TestPlanning_LocalsDataSource(t *testing.T) { return state, nil }) - if err != nil { t.Fatal(err) } @@ -1053,3 +1052,139 @@ func mustPlanDynamicValue(t *testing.T, v cty.Value) *tfstackdata1.DynamicValue } return tfstackdata1.Terraform1ToStackDataDynamicValue(ret) } + +func TestPlanning_ActionInvocationLifecycle(t *testing.T) { + // This integration test verifies that action invocations with lifecycle + // triggers are correctly planned and included in the PlannedChange objects. + + cfg := testStackConfig(t, "planning", "action_lifecycle") + componentInstAddr := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{ + Name: "web", + }, + }, + } + actionInstAddr := addrs.AbsActionInstance{ + Module: addrs.RootModuleInstance, + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "test_action", + Name: "notify", + }, + Key: addrs.NoKey, + }, + } + providerAddr := addrs.NewBuiltInProvider("test") + providerInstAddr := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: providerAddr, + } + + resourceTypeSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + } + actionTypeSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "message": { + Type: cty.String, + Required: true, + }, + }, + } + + main := NewForPlanning(cfg, stackstate.NewState(), PlanOpts{ + PlanningMode: plans.NormalMode, + PlanTimestamp: time.Now().UTC(), + ProviderFactories: ProviderFactories{ + addrs.NewBuiltInProvider("test"): func() (providers.Interface, error) { + return &providerTesting.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Provider: providers.Schema{ + Body: &configschema.Block{}, + }, + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Body: resourceTypeSchema, + }, + }, + Actions: map[string]providers.ActionSchema{ + "test_action": { + ConfigSchema: actionTypeSchema, + }, + }, + }, + ConfigureProviderFn: func(cpr providers.ConfigureProviderRequest) providers.ConfigureProviderResponse { + return providers.ConfigureProviderResponse{} + }, + PlanResourceChangeFn: func(prcr providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: prcr.ProposedNewState, + } + }, + }, nil + }, + }, + }) + + outp, outpTest := testPlanOutput(t) + main.PlanAll(context.Background(), outp) + plan, diags := outpTest.Close(t) + assertNoDiagnostics(t, diags) + + cmpPlan := plan.GetComponent(componentInstAddr) + if cmpPlan == nil { + t.Fatalf("no plan for %s", componentInstAddr) + } + + // Verify that we have planned changes for action invocations + plannedChanges := outpTest.PlannedChanges() + var foundActionChange *stackplan.PlannedChangeActionInvocationInstancePlanned + for _, pc := range plannedChanges { + if actionChange, ok := pc.(*stackplan.PlannedChangeActionInvocationInstancePlanned); ok { + foundActionChange = actionChange + break + } + } + + if foundActionChange == nil { + t.Fatalf("no action invocation planned change found; got %d changes", len(plannedChanges)) + } + + // Verify the action invocation details + if got, want := foundActionChange.ActionInvocationAddr.Component.String(), componentInstAddr.String(); got != want { + t.Errorf("wrong component instance\ngot: %s\nwant: %s", got, want) + } + if got, want := foundActionChange.ActionInvocationAddr.Item.String(), actionInstAddr.String(); got != want { + t.Errorf("wrong action instance\ngot: %s\nwant: %s", got, want) + } + if got, want := foundActionChange.ProviderConfigAddr.String(), providerInstAddr.String(); got != want { + t.Errorf("wrong provider config addr\ngot: %s\nwant: %s", got, want) + } + + // Verify the invocation has the correct trigger type + if foundActionChange.Invocation == nil { + t.Fatal("invocation is nil") + } + if _, ok := foundActionChange.Invocation.ActionTrigger.(*plans.ResourceActionTrigger); !ok { + t.Errorf("wrong action trigger type\ngot: %T\nwant: *plans.ResourceActionTrigger", foundActionChange.Invocation.ActionTrigger) + } + + // Verify we can convert to proto successfully + protoChange, err := foundActionChange.PlannedChangeProto() + if err != nil { + t.Fatalf("failed to convert to proto: %s", err) + } + if protoChange == nil { + t.Fatal("proto change is nil") + } + if len(protoChange.Descriptions) == 0 { + t.Error("expected at least one description in proto change") + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go index 9686a96895..2ea52af215 100644 --- a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go @@ -363,6 +363,20 @@ func (r *RemovedComponentInstance) ResourceSchema(ctx context.Context, providerT return ret, nil } +// ActionSchema implements stackplan.PlanProducer. +func (r *RemovedComponentInstance) ActionSchema(ctx context.Context, providerTypeAddr addrs.Provider, actionType string) (providers.ActionSchema, error) { + providerType := r.main.ProviderType(providerTypeAddr) + providerSchema, err := providerType.Schema(ctx) + if err != nil { + return providers.ActionSchema{}, err + } + ret := providerSchema.SchemaForActionType(actionType) + if ret.ConfigSchema == nil { + return providers.ActionSchema{}, fmt.Errorf("schema does not include action type %q", actionType) + } + return ret, nil +} + // tracingName implements Plannable. func (r *RemovedComponentInstance) tracingName() string { return r.Addr().String() + " (removed)" diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index b07b0beea0..71d279d04f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -42,6 +42,10 @@ type componentInstanceTerraformHook struct { // change counts for the apply operation, so we record whether or not apply // failed here. resourceInstanceObjectApplySuccess addrs.Set[addrs.AbsResourceInstanceObject] + + // Track provider addresses for action invocations so we can report them + // in action lifecycle hooks. + actionInvocationProviderAddr addrs.Map[addrs.AbsActionInstance, addrs.Provider] } var _ terraform.Hook = (*componentInstanceTerraformHook)(nil) @@ -211,3 +215,82 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectAppliedAction(add func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyApplied() addrs.Set[addrs.AbsResourceInstanceObject] { return h.resourceInstanceObjectApplySuccess } + +// StartAction fires when action execution begins +func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { + ai := h.actionInvocationFromHookActionIdentity(id) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } + + // Report status transition: RUNNING (action execution starts) + // Note: PENDING status should have been reported during component apply preparation + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: providerAddr, + Status: hooks.ActionInvocationRunning, + Trigger: ai.Trigger, + }) + return terraform.HookActionContinue, nil +} + +// ProgressAction fires for intermediate diagnostic messages from the provider. +func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { + ai := h.actionInvocationFromHookActionIdentity(id) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } + + // Always report progress message + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ + Addr: ai.Addr, + ProviderAddr: providerAddr, + Message: progress, + Trigger: ai.Trigger, + }) + return terraform.HookActionContinue, nil +} + +// CompleteAction fires when action finishes (success or error) +func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { + ai := h.actionInvocationFromHookActionIdentity(id) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } + + // Report final status based on error + status := hooks.ActionInvocationCompleted + if err != nil { + status = hooks.ActionInvocationErrored + } + + // Report status transition: RUNNING → COMPLETED or ERRORED (action finishes) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: providerAddr, + Status: status, + Trigger: ai.Trigger, + }) + return terraform.HookActionContinue, nil +} + +// actionInvocationFromHookActionIdentity attempts to build a *hooks.ActionInvocation +// from a core terraform.HookActionIdentity. +func (h *componentInstanceTerraformHook) actionInvocationFromHookActionIdentity(id terraform.HookActionIdentity) *hooks.ActionInvocation { + providerAddr, _ := h.actionInvocationProviderAddr.GetOk(id.Addr) + ai := &hooks.ActionInvocation{ + Addr: stackaddrs.AbsActionInvocationInstance{ + Component: h.addr, + Item: id.Addr, + }, + ProviderAddr: providerAddr, + Trigger: id.ActionTrigger, + } + return ai +} diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go new file mode 100644 index 0000000000..fb9369bfb0 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -0,0 +1,103 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackeval + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" + "github.com/hashicorp/terraform/internal/terraform" +) + +func TestActionHookForwarding(t *testing.T) { + var statusCount int + var statuses []hooks.ActionInvocationStatus + + hks := &Hooks{} + hks.ReportActionInvocationStatus = func(ctx context.Context, span any, data *hooks.ActionInvocationStatusHookData) any { + statusCount++ + statuses = append(statuses, data.Status) + return nil + } + + // Create a simple concrete component instance address for the hook + compAddr := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "testcomp"}, + Key: addrs.NoKey, + }, + } + + // Create the componentInstanceTerraformHook with our Hooks + c := &componentInstanceTerraformHook{ + ctx: context.Background(), + seq: &hookSeq{}, + hooks: hks, + addr: compAddr, + } + + // Prepare a HookActionIdentity with an invoke trigger + actionAddr := addrs.AbsActionInstance{} + id := terraform.HookActionIdentity{ + Addr: actionAddr, + ActionTrigger: &plans.InvokeActionTrigger{}, + } + + // Pre-populate the provider address map + providerAddr := addrs.Provider{ + Type: "test", + Namespace: "hashicorp", + Hostname: "registry.terraform.io", + } + c.actionInvocationProviderAddr = addrs.MakeMap[addrs.AbsActionInstance, addrs.Provider]() + c.actionInvocationProviderAddr.Put(actionAddr, providerAddr) + + // StartAction should trigger a status hook with "Running" status + _, _ = c.StartAction(id) + if statusCount != 1 { + t.Fatalf("expected StartAction to trigger status hook once, got %d", statusCount) + } + if statuses[0] != hooks.ActionInvocationRunning { + t.Fatalf("expected ActionInvocationRunning status from StartAction, got %s", statuses[0].String()) + } + + // ProgressAction should not trigger status hooks + _, _ = c.ProgressAction(id, "in-progress") + if statusCount != 1 { + t.Fatalf("expected ProgressAction to avoid status hooks, got %d total", statusCount) + } + + // ProgressAction with "pending" should still avoid status hooks + _, _ = c.ProgressAction(id, "pending") + if statusCount != 1 { + t.Fatalf("expected ProgressAction to avoid status hooks, got %d total", statusCount) + } + + // CompleteAction with no error should complete successfully + _, _ = c.CompleteAction(id, nil) + if statusCount != 2 { + t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) + } + if statuses[1] != hooks.ActionInvocationCompleted { + t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[1].String()) + } + + // Test error case + statusCount = 0 + statuses = statuses[:0] + + // CompleteAction with error should mark as errored + _, _ = c.CompleteAction(id, context.DeadlineExceeded) + if statusCount != 1 { + t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) + } + if statuses[0] != hooks.ActionInvocationErrored { + t.Fatalf("expected ActionInvocationErrored status, got %s", statuses[0].String()) + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl new file mode 100644 index 0000000000..5e80f82b09 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl @@ -0,0 +1,43 @@ + +# A variable with a simple validation that does not reference the variable +# value inside the error_message. +variable "validated" { + type = string + + validation { + condition = var.validated != "bad" + error_message = "Value must not be 'bad'." + } +} + +# A variable whose error_message expression interpolates the variable value. +# When the input carries a sensitive or ephemeral mark, evaluating the +# interpolation causes the error_message result to inherit that mark — which +# is the behaviour we want to exercise. +variable "with_msg_ref" { + type = string + + validation { + condition = var.with_msg_ref != "bad" + error_message = "Got disallowed value '${var.with_msg_ref}'." + } +} + +# A variable with two validation blocks to verify that all rules are evaluated +# and all failures are reported independently. +# +# Inputs that trigger both failures simultaneously: +# "bad" — length("bad") = 3 < 5 AND "bad" == "bad" +variable "multi_rule" { + type = string + + validation { + condition = length(var.multi_rule) >= 5 + error_message = "Value must be at least 5 characters long." + } + + validation { + condition = var.multi_rule != "bad" + error_message = "Value must not be 'bad'." + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl new file mode 100644 index 0000000000..15b9ac35ea --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl @@ -0,0 +1,25 @@ + +# This fixture is used by TestInputVariableValidationWithProviderFunction to +# verify that provider-defined functions can be called inside a validation +# condition expression. +# +# The mock test provider exposes a single function "upper" that converts a +# string to upper-case; the validation here checks that the given value +# equals "HELLO" when converted to upper-case. + +required_providers { + testing = { + source = "terraform.io/builtin/testing" + } +} + +provider "testing" "main" {} + +variable "foo" { + type = string + + validation { + condition = provider::testing::upper(var.foo) == "HELLO" + error_message = "Value must equal 'hello' (case-insensitive)." + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl new file mode 100644 index 0000000000..b43965bb65 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl @@ -0,0 +1,24 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +required_providers { + test = { + source = "terraform.io/builtin/test" + } +} + +provider "test" "main" { +} + +component "web" { + source = "./module_web" + + providers = { + test = provider.test.main + } +} + +output "result" { + type = string + value = component.web.result +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf new file mode 100644 index 0000000000..1d1a4d191a --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf @@ -0,0 +1,31 @@ + +terraform { + required_providers { + test = { + source = "terraform.io/builtin/test" + + configuration_aliases = [ test ] + } + } +} + +action "test_action" "notify" { + config { + message = "resource created" + } +} + +resource "test_resource" "main" { + value = "example" + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.test_action.notify] + } + } +} + +output "result" { + value = test_resource.main.value +} diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index b82fcaea28..33c699ed22 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -25,8 +25,10 @@ import ( "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" "github.com/hashicorp/terraform/internal/addrs" + terraformProvider "github.com/hashicorp/terraform/internal/builtin/providers/terraform" "github.com/hashicorp/terraform/internal/collections" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" @@ -1940,7 +1942,6 @@ func TestPlanWithComplexVariableDefaults(t *testing.T) { if diff := cmp.Diff(wantChanges, changes, changesCmpOpts); diff != "" { t.Errorf("wrong changes\n%s", diff) } - } func TestPlanWithSingleResource(t *testing.T) { @@ -4714,7 +4715,6 @@ func TestPlanWithStateManipulation(t *testing.T) { for name, tc := range tcs { t.Run(name, func(t *testing.T) { - ctx := context.Background() cfg := loadMainBundleConfigForTest(t, path.Join("state-manipulation", name)) @@ -6383,10 +6383,567 @@ func expectOutput(t *testing.T, name string, changes []stackplan.PlannedChange) for _, change := range changes { if v, ok := change.(*stackplan.PlannedChangeOutputValue); ok && v.Addr.Name == name { return v - } } t.Fatalf("expected output value %q", name) return nil } + +func TestPlanWithActionInvocationHooks(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "planning-action-lifecycle") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + testCtx := TestContext{ + config: cfg, + providers: map[addrs.Provider]providers.Factory{ + addrs.NewBuiltInProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + timestamp: &fakePlanTimestamp, + } + + // Create dynamic values for resource change + resourceBeforeVal := cty.NullVal(cty.Object(map[string]cty.Type{ + "id": cty.String, + "value": cty.String, + })) + resourceAfterVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "value": cty.StringVal("example"), + }) + resourceBeforeDynVal, err := plans.NewDynamicValue(resourceBeforeVal, resourceBeforeVal.Type()) + if err != nil { + t.Fatal(err) + } + resourceAfterDynVal, err := plans.NewDynamicValue(resourceAfterVal, resourceAfterVal.Type()) + if err != nil { + t.Fatal(err) + } + + // Common addresses used throughout the test + webComponentInstance := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "web"}, + }, + } + webComponent := stackaddrs.AbsComponent{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.Component{Name: "web"}, + } + testResourceInstance := addrs.RootModuleInstance.ResourceInstance(addrs.ManagedResourceMode, "testing_resource", "main", addrs.NoKey) + testResourceObject := stackaddrs.AbsResourceInstanceObject{ + Component: webComponentInstance, + Item: addrs.AbsResourceInstanceObject{ + ResourceInstance: testResourceInstance, + }, + } + testActionInstance := addrs.RootModuleInstance.ActionInstance("testing_action", "notify", addrs.NoKey) + testActionInvocationAddr := stackaddrs.AbsActionInvocationInstance{ + Component: webComponentInstance, + Item: testActionInstance, + } + testProviderConfig := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewBuiltInProvider("testing"), + } + + expectedHooks := ExpectedHooks{ + ReportActionInvocationPlanned: []*hooks.ActionInvocation{ + { + Addr: testActionInvocationAddr, + ProviderAddr: addrs.NewBuiltInProvider("testing"), + Trigger: &plans.ResourceActionTrigger{ + TriggeringResourceAddr: testResourceInstance, + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + }, + }, + ComponentExpanded: []*hooks.ComponentInstances{ + { + ComponentAddr: webComponent, + InstanceAddrs: []stackaddrs.AbsComponentInstance{webComponentInstance}, + }, + }, + PendingComponentInstancePlan: collections.NewSet(webComponentInstance), + BeginComponentInstancePlan: collections.NewSet(webComponentInstance), + EndComponentInstancePlan: collections.NewSet(webComponentInstance), + ReportResourceInstanceStatus: []*hooks.ResourceInstanceStatusHookData{ + { + Addr: testResourceObject, + ProviderAddr: addrs.NewBuiltInProvider("testing"), + Status: hooks.ResourceInstancePlanning, + }, + { + Addr: testResourceObject, + ProviderAddr: addrs.NewBuiltInProvider("testing"), + Status: hooks.ResourceInstancePlanned, + }, + }, + ReportResourceInstancePlanned: []*hooks.ResourceInstanceChange{ + { + Addr: testResourceObject, + Change: &plans.ResourceInstanceChangeSrc{ + Addr: testResourceInstance, + PrevRunAddr: testResourceInstance, + ProviderAddr: testProviderConfig, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: resourceBeforeDynVal, + After: resourceAfterDynVal, + }, + }, + }, + }, + ReportComponentInstancePlanned: []*hooks.ComponentInstanceChange{ + { + Addr: webComponentInstance, + Add: 1, + ActionInvocation: 1, + }, + }, + } + + cycle := TestCycle{ + planMode: plans.NormalMode, + wantPlannedHooks: &expectedHooks, + } + + testCtx.Plan(t, ctx, stackstate.NewState(), cycle) +} + +func TestPlanWithDeferredActionInvocation(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "deferred-action") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1994-09-05T08:50:00Z") + if err != nil { + t.Fatal(err) + } + + changesCh := make(chan stackplan.PlannedChange) + diagsCh := make(chan tfdiags.Diagnostic) + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + req := PlanRequest{ + Config: cfg, + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + DependencyLocks: *lock, + ForcePlanTimestamp: &fakePlanTimestamp, + InputValues: map[stackaddrs.InputVariable]ExternalInputValue{ + {Name: "id"}: { + Value: cty.StringVal("test-id-123"), + }, + {Name: "defer"}: { + Value: cty.BoolVal(true), + }, + }, + } + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + go Plan(ctx, &req, &resp) + gotChanges, diags := collectPlanOutput(changesCh, diagsCh) + + reportDiagnosticsForTest(t, diags) + if len(diags) != 0 { + t.FailNow() // We reported the diags above + } + + sort.SliceStable(gotChanges, func(i, j int) bool { + return plannedChangeSortKey(gotChanges[i]) < plannedChangeSortKey(gotChanges[j]) + }) + + // Find the deferred action invocation in the changes + var foundDeferredAction bool + for _, change := range gotChanges { + if _, ok := change.(*stackplan.PlannedChangeDeferredActionInvocation); ok { + foundDeferredAction = true + break + } + } + + if !foundDeferredAction { + t.Error("Expected to find a deferred action invocation in the plan changes, but none was found") + t.Logf("Got %d changes:", len(gotChanges)) + for i, change := range gotChanges { + t.Logf(" [%d] %T", i, change) + } + } +} + +// TestPlan_variableValidationAdvanced tests advanced variable validation scenarios +func TestPlan_variableValidationAdvanced(t *testing.T) { + ctx := context.Background() + + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + + fakePlanTimestamp, _ := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + + testCases := map[string]struct { + configPath string + planInputVars map[string]cty.Value + wantErrorMessages []string // Just check for error message presence, not exact diagnostic structure + }{ + // Type validation tests + "types-number-pass": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: nil, + }, + "types-number-out-of-range": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(150), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"Number must be between 0 and 100."}, + }, + "types-list-too-many": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("1"), cty.StringVal("2"), cty.StringVal("3"), cty.StringVal("4"), cty.StringVal("5"), cty.StringVal("6")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"List must contain 1-5 items."}, + }, + "types-list-empty-string": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item"), cty.StringVal("")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"List items cannot be empty strings."}, + }, + "types-map-missing-key": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"other_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"Map must contain 'required_key'."}, + }, + + // Sensitive variable validation tests + "sensitive-password-pass": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: nil, + }, + "sensitive-password-too-short": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("Short1"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must be at least 8 characters long."}, + }, + "sensitive-password-no-uppercase": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("securepass123"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must contain at least one uppercase letter."}, + }, + "sensitive-password-no-number": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must contain at least one number."}, + }, + "sensitive-api-key-wrong-length": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("abc123"), + }, + wantErrorMessages: []string{"API key must be exactly 32 characters."}, + }, + "sensitive-api-key-invalid-chars": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("ABCDEF0123456789ABCDEF0123456789"), + }, + wantErrorMessages: []string{"API key must only contain lowercase hex characters."}, + }, + + // Complex validation tests + "complex-email-pass": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "env": cty.StringVal("dev")}), + }, + wantErrorMessages: nil, + }, + "complex-email-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("not-an-email"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Must be a valid email address."}, + }, + "complex-ip-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("999.999.999.999"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Must be a valid IPv4 address."}, + }, + "complex-environment-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("test"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Environment must be dev, staging, or prod."}, + }, + "complex-tags-invalid-key": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"Owner": cty.StringVal("team"), "owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens."}, + }, + "complex-tags-missing-owner": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"env": cty.StringVal("dev")}), + }, + wantErrorMessages: []string{"Tags must include 'owner' key."}, + }, + "complex-tags-empty-value": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal(""), "env": cty.StringVal("dev")}), + }, + wantErrorMessages: []string{"Tag values must be 1-256 characters."}, + }, + "complex-tags-value-too-long": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "description": cty.StringVal(strings.Repeat("x", 257))}), + }, + wantErrorMessages: []string{"Tag values must be 1-256 characters."}, + }, + + // Invalid error message tests - these verify that invalid error messages + // are caught even when validation passes or fails + "invalid-error-message-sensitive-in-error": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("short"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}), + "api_key": cty.StringVal("abcdef0123456789"), + }, + wantErrorMessages: []string{ + "error expression used to explain this condition refers to sensitive values", + }, + }, + "invalid-error-message-ephemeral-in-error": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("short_token"), + "count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}), + "api_key": cty.StringVal("abcdef0123456789"), + }, + wantErrorMessages: []string{ + "error expression used to explain this condition refers to ephemeral values", + }, + }, + "invalid-error-message-not-string": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.SetValEmpty(cty.String), // empty set fails condition; a set cannot be converted to a string + "api_key": cty.StringVal("abcdef0123456789"), + }, + // A set value cannot be converted to a string, so we get an "Invalid error message" diagnostic + // rather than the condition failure message. + wantErrorMessages: []string{ + "Unsuitable value for error message", + }, + }, + "invalid-error-message-sensitive-even-when-passing": { + configPath: path.Join("with-single-input", "validation-invalid-error-message"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "token": cty.StringVal("abcdef0123456789abcdef0123456789"), + "count_value": cty.SetVal([]cty.Value{cty.StringVal("a")}), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789abcdef0123456789"), + }, + // This tests that we evaluate error_message even when validation passes + wantErrorMessages: []string{ + "error expression used to explain this condition refers to sensitive values", + }, + }, + + // Provider function tests + "provider-functions-pass": { + configPath: path.Join("with-single-input", "validation-provider-functions"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "echo_value": cty.StringVal("test_value"), + "combined": cty.StringVal("long_enough"), + }, + wantErrorMessages: nil, + }, + "provider-functions-fail": { + configPath: path.Join("with-single-input", "validation-provider-functions"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "echo_value": cty.StringVal("test"), + "combined": cty.StringVal("short"), + }, + wantErrorMessages: []string{ + "Combined value must be longer than 5 characters after echo", + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + cfg := loadMainBundleConfigForTest(t, tc.configPath) + + req := PlanRequest{ + Config: cfg, + InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { + inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(tc.planInputVars)) + for k, v := range tc.planInputVars { + inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v} + } + return inputs + }(), + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + DependencyLocks: *lock, + ForcePlanTimestamp: &fakePlanTimestamp, + } + + changesCh := make(chan stackplan.PlannedChange) + diagsCh := make(chan tfdiags.Diagnostic) + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &req, &resp) + _, diags := collectPlanOutput(changesCh, diagsCh) + + // Check that we get the expected error messages + if tc.wantErrorMessages == nil { + if len(diags) > 0 { + t.Errorf("expected no diagnostics, got: %s", diags.ErrWithWarnings()) + } + } else { + if len(diags) == 0 { + t.Fatalf("expected diagnostics with messages %v, got none", tc.wantErrorMessages) + } + // Check that all expected error messages are present + for _, wantMsg := range tc.wantErrorMessages { + found := false + for _, diag := range diags { + if strings.Contains(diag.Description().Detail, wantMsg) { + found = true + break + } + } + if !found { + t.Errorf("expected error message %q not found in diagnostics: %s", wantMsg, diags.ErrWithWarnings()) + } + } + } + }) + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tf new file mode 100644 index 0000000000..1c9b27c6cc --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } + } +} + +variable "input" { + type = string + const = true + default = "hello" +} + +resource "testing_resource" "data" { + value = var.input +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tfcomponent.hcl new file mode 100644 index 0000000000..18c62cbcbf --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/const-variable-in-component/const-variable-in-component.tfcomponent.hcl @@ -0,0 +1,16 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +component "self" { + source = "./" + + providers = { + testing = provider.testing.default + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/deferred-action.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/deferred-action.tfcomponent.hcl new file mode 100644 index 0000000000..2577e0a8d2 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/deferred-action.tfcomponent.hcl @@ -0,0 +1,29 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +variable "id" { + type = string +} + +variable "defer" { + type = bool +} + +component "self" { + source = "./" + + providers = { + testing = provider.testing.default + } + + inputs = { + id = var.id + defer = var.defer + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf new file mode 100644 index 0000000000..8a8cc18806 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf @@ -0,0 +1,39 @@ + +terraform { + required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } + } +} + +variable "id" { + type = string + default = null + nullable = true # We'll generate an ID if none provided. +} + +variable "defer" { + type = bool +} + +# Action that should be invoked when resource is created +action "testing_action" "notify" { + config { + message = "resource created with id ${var.id}" + } +} + +# Deferred resource with action trigger +resource "testing_deferred_resource" "data" { + id = var.id + deferred = var.defer + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.testing_action.notify] + } + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/module_web/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/module_web/main.tf new file mode 100644 index 0000000000..d1619e0479 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/module_web/main.tf @@ -0,0 +1,31 @@ + +terraform { + required_providers { + testing = { + source = "terraform.io/builtin/testing" + + configuration_aliases = [ testing ] + } + } +} + +action "testing_action" "notify" { + config { + message = "resource created" + } +} + +resource "testing_resource" "main" { + value = "example" + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.testing_action.notify] + } + } +} + +output "result" { + value = testing_resource.main.value +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/planning-action-lifecycle.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/planning-action-lifecycle.tfcomponent.hcl new file mode 100644 index 0000000000..dbfc141779 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/planning-action-lifecycle/planning-action-lifecycle.tfcomponent.hcl @@ -0,0 +1,24 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +required_providers { + testing = { + source = "terraform.io/builtin/testing" + } +} + +provider "testing" "main" { +} + +component "web" { + source = "./module_web" + + providers = { + testing = provider.testing.main + } +} + +output "result" { + type = string + value = component.web.result +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl new file mode 100644 index 0000000000..eb8d61bf4e --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl @@ -0,0 +1,71 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "email" { + type = string + + validation { + condition = can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", var.email)) + error_message = "Must be a valid email address." + } +} + +variable "ip_address" { + type = string + + validation { + condition = can(regex("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", var.ip_address)) + error_message = "Must be a valid IPv4 address." + } +} + +variable "environment" { + type = string + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "tags" { + type = map(string) + + validation { + condition = alltrue([for k, v in var.tags : can(regex("^[a-z][a-z0-9-]*$", k))]) + error_message = "Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens." + } + + validation { + condition = alltrue([for k, v in var.tags : length(v) > 0 && length(v) <= 256]) + error_message = "Tag values must be 1-256 characters." + } + + validation { + condition = contains(keys(var.tags), "owner") + error_message = "Tags must include 'owner' key." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl new file mode 100644 index 0000000000..ed7d2fffd2 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl @@ -0,0 +1,68 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +# Test case: error_message references sensitive value +variable "password" { + type = string + sensitive = true + + validation { + condition = length(var.password) >= 8 + error_message = "Password '${var.password}' is too short." + } +} + +# Test case: error_message references ephemeral value +variable "token" { + type = string + ephemeral = true + + validation { + condition = length(var.token) == 32 + error_message = "Token '${var.token}' is invalid." + } +} + +# Test case: error_message that is not a string (and cannot be converted to one) +variable "count_value" { + type = set(string) + + validation { + condition = length(var.count_value) > 0 + error_message = var.count_value # Invalid: a set cannot be converted to a string + } +} + +# Test case: error_message references sensitive value even when validation passes +variable "api_key" { + type = string + sensitive = true + + validation { + condition = length(var.api_key) >= 16 + error_message = "API key '${var.api_key}' must be at least 16 characters." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl new file mode 100644 index 0000000000..ae6770daaf --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl @@ -0,0 +1,45 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +# Test case: Validation using provider-defined function in condition +variable "echo_value" { + type = string + + validation { + condition = provider::testing::echo(var.echo_value) == var.echo_value + error_message = "Echo function did not return the same value." + } +} + +# Test case: Validation using provider function with built-in functions +variable "combined" { + type = string + + validation { + condition = length(provider::testing::echo(var.combined)) > 5 + error_message = "Combined value must be longer than 5 characters after echo." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl new file mode 100644 index 0000000000..7b17506956 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl @@ -0,0 +1,60 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "password" { + type = string + sensitive = true + + validation { + condition = length(var.password) >= 8 + error_message = "Password must be at least 8 characters long." + } + + validation { + condition = can(regex("[A-Z]", var.password)) + error_message = "Password must contain at least one uppercase letter." + } + + validation { + condition = can(regex("[0-9]", var.password)) + error_message = "Password must contain at least one number." + } +} + +variable "api_key" { + type = string + sensitive = true + + validation { + condition = length(var.api_key) == 32 + error_message = "API key must be exactly 32 characters." + } + + validation { + condition = can(regex("^[a-f0-9]+$", var.api_key)) + error_message = "API key must only contain lowercase hex characters." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl new file mode 100644 index 0000000000..80945407ad --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl @@ -0,0 +1,57 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "number_input" { + type = number + + validation { + condition = var.number_input > 0 && var.number_input < 100 + error_message = "Number must be between 0 and 100." + } +} + +variable "list_input" { + type = list(string) + + validation { + condition = length(var.list_input) > 0 && length(var.list_input) <= 5 + error_message = "List must contain 1-5 items." + } + + validation { + condition = alltrue([for s in var.list_input : length(s) > 0]) + error_message = "List items cannot be empty strings." + } +} + +variable "map_input" { + type = map(string) + + validation { + condition = contains(keys(var.map_input), "required_key") + error_message = "Map must contain 'required_key'." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl new file mode 100644 index 0000000000..b4068e1b6c --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl @@ -0,0 +1,44 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +variable "input" { + type = string + + validation { + condition = length(var.input) > 5 + error_message = "Input must be longer than 5 characters." + } + + validation { + condition = startswith(var.input, "H") + error_message = "Input must start with H." + } + + validation { + condition = !contains(["bad", "invalid", "nope"], var.input) + error_message = "Input cannot be 'bad', 'invalid', or 'nope'." + } + + validation { + condition = can(regex("^[A-Z]", var.input)) + error_message = "Input must start with an uppercase letter." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} diff --git a/internal/stacks/stackruntime/testing/provider.go b/internal/stacks/stackruntime/testing/provider.go index 2359489bb6..cd30b4396b 100644 --- a/internal/stacks/stackruntime/testing/provider.go +++ b/internal/stacks/stackruntime/testing/provider.go @@ -113,6 +113,14 @@ var ( Nesting: configschema.NestingSingle, }, } + + TestingActionSchema = providers.ActionSchema{ + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "message": {Type: cty.String, Optional: true}, + }, + }, + } ) // MockProvider wraps the standard MockProvider with a simple in-memory @@ -223,6 +231,9 @@ func NewProviderWithData(t *testing.T, store *ResourceStore) *MockProvider { ReturnType: cty.DynamicPseudoType, }, }, + Actions: map[string]providers.ActionSchema{ + "testing_action": TestingActionSchema, + }, ServerCapabilities: providers.ServerCapabilities{ MoveResourceState: true, }, @@ -322,6 +333,29 @@ func NewProviderWithData(t *testing.T, store *ResourceStore) *MockProvider { }), } }, + PlanActionFn: func(request providers.PlanActionRequest) providers.PlanActionResponse { + // Simple action planning - no drift, just validation + return providers.PlanActionResponse{ + Diagnostics: tfdiags.Diagnostics{}, + } + }, + InvokeActionFn: func(request providers.InvokeActionRequest) providers.InvokeActionResponse { + // Simple action invocation - just emit a completed event + return providers.InvokeActionResponse{ + Events: func(yield func(providers.InvokeActionEvent) bool) { + yield(providers.InvokeActionEvent_Completed{ + Diagnostics: tfdiags.Diagnostics{}, + }) + }, + Diagnostics: tfdiags.Diagnostics{}, + } + }, + ValidateActionConfigFn: func(request providers.ValidateActionConfigRequest) providers.ValidateActionConfigResponse { + // No validation errors for testing actions + return providers.ValidateActionConfigResponse{ + Diagnostics: tfdiags.Diagnostics{}, + } + }, }, ResourceStore: store, } diff --git a/internal/stacks/stackruntime/validate_test.go b/internal/stacks/stackruntime/validate_test.go index c155937228..fe5a25810d 100644 --- a/internal/stacks/stackruntime/validate_test.go +++ b/internal/stacks/stackruntime/validate_test.go @@ -42,6 +42,7 @@ var ( "variable-output-roundtrip": {}, "variable-output-roundtrip-nested": {}, "aliased-provider": {}, + "planning-action-lifecycle": {}, filepath.Join("with-single-input", "input-from-component"): {}, filepath.Join("with-single-input", "input-from-component-list"): { planInputVars: map[string]cty.Value{ @@ -71,6 +72,22 @@ var ( // invalidConfigurations are shared between the validate and plan tests. invalidConfigurations = map[string]validateTestInput{ + "const-variable-in-component": { + diags: func() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variable not supported in stacks", + Detail: "Variables with const = true are not supported in modules used as stack components. Const variables are evaluated during configuration loading, which is not supported in the stacks runtime.", + Subject: &hcl.Range{ + Filename: mainBundleSourceAddrStr("const-variable-in-component/const-variable-in-component.tf"), + Start: hcl.Pos{Line: 10, Column: 1, Byte: 124}, + End: hcl.Pos{Line: 10, Column: 17, Byte: 140}, + }, + }) + return diags + }, + }, "validate-undeclared-variable": { diags: func() tfdiags.Diagnostics { var diags tfdiags.Diagnostics diff --git a/internal/terraform/config_graph_build.go b/internal/terraform/config_graph_build.go new file mode 100644 index 0000000000..e8451f238f --- /dev/null +++ b/internal/terraform/config_graph_build.go @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// BuildConfigWithGraph builds a configuration tree using the init graph so +// that module sources and versions can be resolved with full expression +// evaluation before loading descendant modules. +func BuildConfigWithGraph(rootMod *configs.Module, walker configs.ModuleWalker, vars InputValues, loader configs.MockDataLoader) (*configs.Config, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + ctx, ctxDiags := NewContext(&ContextOpts{ + Parallelism: 1, + }) + diags = diags.Append(ctxDiags) + if ctxDiags.HasErrors() { + return nil, diags + } + + cfg, initDiags := ctx.Init(rootMod, InitOpts{ + Walker: walker, + SetVariables: vars, + }) + diags = diags.Append(initDiags) + if diags.HasErrors() { + if cfg == nil && rootMod != nil { + cfg = &configs.Config{Module: rootMod} + cfg.Root = cfg + } + return cfg, diags + } + + finalDiags := configs.FinalizeConfig(cfg, walker, loader) + diags = diags.Append(finalDiags) + + return cfg, diags +} diff --git a/internal/terraform/config_graph_build_test.go b/internal/terraform/config_graph_build_test.go new file mode 100644 index 0000000000..459be8004b --- /dev/null +++ b/internal/terraform/config_graph_build_test.go @@ -0,0 +1,342 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/go-test/deep" + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestLoadConfigWithSnapshot(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, got, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if got == nil { + t.Fatalf("snapshot is nil; want non-nil") + } + + t.Log(spew.Sdump(got)) + + { + gotModuleDirs := map[string]string{} + for k, m := range got.Modules { + gotModuleDirs[k] = m.Dir + } + wantModuleDirs := map[string]string{ + "": "testdata/config-graph/already-installed", + "child_a": "testdata/config-graph/already-installed/.terraform/modules/child_a", + "child_a.child_c": "testdata/config-graph/already-installed/.terraform/modules/child_a/child_c", + "child_b": "testdata/config-graph/already-installed/.terraform/modules/child_b", + "child_b.child_d": "testdata/config-graph/already-installed/.terraform/modules/child_b.child_d", + } + + problems := deep.Equal(wantModuleDirs, gotModuleDirs) + for _, problem := range problems { + t.Error(problem) + } + if len(problems) > 0 { + return + } + } + + gotRoot := got.Modules[""] + wantRoot := &configload.SnapshotModule{ + Dir: "testdata/config-graph/already-installed", + Files: map[string][]byte{ + "root.tf": []byte(` +module "child_a" { + source = "example.com/foo/bar_a/baz" + version = ">= 1.0.0" +} + +module "child_b" { + source = "example.com/foo/bar_b/baz" + version = ">= 1.0.0" +} +`), + }, + } + if !reflect.DeepEqual(gotRoot, wantRoot) { + t.Errorf("wrong root module snapshot\ngot: %swant: %s", spew.Sdump(gotRoot), spew.Sdump(wantRoot)) + } + +} + +func TestLoadConfigWithSnapshot_invalidSource(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed-now-invalid") + + old, _ := os.Getwd() + os.Chdir(fixtureDir) + defer os.Chdir(old) + + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: ".terraform/modules", + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, _, diags := testLoadWithSnapshot(".", loader, nil) + if !diags.HasErrors() { + t.Error("LoadConfigWithSnapshot succeeded; want errors") + } +} + +func TestSnapshotRoundtrip(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, snap, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if snap == nil { + t.Fatalf("snapshot is nil; want non-nil") + } + + snapLoader := configload.NewLoaderFromSnapshot(snap) + if loader == nil { + t.Fatalf("loader is nil; want non-nil") + } + rootMod, rootDiags := snapLoader.LoadRootModule(snap.Modules[""].Dir) + assertNoDiagnostics(t, rootDiags) + + config, diags := BuildConfigWithGraph( + rootMod, + snapLoader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(snapLoader.LoadExternalMockData), + ) + assertNoDiagnostics(t, diags) + if config == nil { + t.Fatalf("config is nil; want non-nil") + } + if config.Module == nil { + t.Fatalf("config has no root module") + } + if got, want := config.Module.SourceDir, "testdata/config-graph/already-installed"; got != want { + t.Errorf("wrong root module sourcedir %q; want %q", got, want) + } + if got, want := len(config.Module.ModuleCalls), 2; got != want { + t.Errorf("wrong number of module calls in root module %d; want %d", got, want) + } + childA := config.Children["child_a"] + if childA == nil { + t.Fatalf("child_a config is nil; want non-nil") + } + if childA.Module == nil { + t.Fatalf("child_a config has no module") + } + if got, want := childA.Module.SourceDir, "testdata/config-graph/already-installed/.terraform/modules/child_a"; got != want { + t.Errorf("wrong child_a sourcedir %q; want %q", got, want) + } + if got, want := len(childA.Module.ModuleCalls), 1; got != want { + t.Errorf("wrong number of module calls in child_a %d; want %d", got, want) + } +} + +func TestBuildConfigWithGraph_okay(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/already-installed") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + cfg, _, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if cfg == nil { + t.Fatalf("config is nil; want non-nil") + } + + var gotPaths []string + cfg.DeepEach(func(c *configs.Config) { + gotPaths = append(gotPaths, strings.Join(c.Path, ".")) + }) + sort.Strings(gotPaths) + wantPaths := []string{ + "", // root module + "child_a", + "child_a.child_c", + "child_b", + "child_b.child_d", + } + + if !reflect.DeepEqual(gotPaths, wantPaths) { + t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) + } + + t.Run("child_a.child_c output", func(t *testing.T) { + output := cfg.Children["child_a"].Children["child_c"].Module.Outputs["hello"] + got, diags := output.Expr.Value(nil) + assertNoDiagnostics(t, diags) + if !got.RawEquals(cty.StringVal("Hello from child_c")) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, cty.StringVal("Hello from child_c")) + } + }) + t.Run("child_b.child_d output", func(t *testing.T) { + output := cfg.Children["child_b"].Children["child_d"].Module.Outputs["hello"] + got, diags := output.Expr.Value(nil) + assertNoDiagnostics(t, diags) + if !got.RawEquals(cty.StringVal("Hello from child_d")) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, cty.StringVal("Hello from child_d")) + } + }) +} + +func TestBuildConfigWithGraph_loadDiags(t *testing.T) { + // building a config which didn't load correctly may cause configs to panic + fixtureDir := filepath.Clean("testdata/config-graph/invalid-names") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + rootMod, rootDiags := loader.LoadRootModule(fixtureDir) + if !rootDiags.HasErrors() { + t.Fatal("success; want error") + } + + if rootMod == nil { + t.Fatal("partial module not returned with diagnostics") + } +} + +func TestBuildConfigWithGraph_loadDiagsFromSubmodules(t *testing.T) { + // building a config which didn't load correctly may cause configs to panic + fixtureDir := filepath.Clean("testdata/config-graph/invalid-names-in-submodules") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + rootMod, rootDiags := loader.LoadRootModule(fixtureDir) + if rootDiags.HasErrors() { + t.Fatalf("unexpected root module load error: %s", rootDiags.Error()) + } + + _, diags := BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if !diags.HasErrors() { + t.Fatalf("loading succeeded; want an error") + } + if got, want := diags.Err().Error(), " Invalid provider local name"; !strings.Contains(got, want) { + t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) + } +} + +func TestBuildConfigWithGraph_childProviderGrandchildCount(t *testing.T) { + // This test is focused on the specific situation where: + // - A child module contains a nested provider block, which is no longer + // recommended but supported for backward-compatibility. + // - A child of that child does _not_ contain a nested provider block, + // and is called with "count" (would also apply to "for_each" and + // "depends_on"). + // It isn't valid to use "count" with a module that _itself_ contains + // a provider configuration, but it _is_ valid for a module with a + // provider configuration to call another module with count. We previously + // botched this rule and so this is a regression test to cover the + // solution to that mistake: + // https://github.com/hashicorp/terraform/issues/31081 + + // Since this test is based on success rather than failure and it's + // covering a relatively large set of code where only a small part + // contributes to the test, we'll make sure to test both the success and + // failure cases here so that we'll have a better chance of noticing if a + // future change makes this succeed only because we've reorganized the code + // so that the check isn't happening at all anymore. + // + // If the "not okay" subtest fails, you should also be skeptical about + // whether the "okay" subtest is still valid, even if it happens to + // still be passing. + t.Run("okay", func(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/child-provider-grandchild-count") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + cfg, _, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + assertNoDiagnostics(t, diags) + if cfg == nil { + t.Fatalf("config is nil; want non-nil") + } + + var gotPaths []string + cfg.DeepEach(func(c *configs.Config) { + gotPaths = append(gotPaths, strings.Join(c.Path, ".")) + }) + sort.Strings(gotPaths) + wantPaths := []string{ + "", // root module + "child", + "child.grandchild", + } + + if !reflect.DeepEqual(gotPaths, wantPaths) { + t.Fatalf("wrong module paths\ngot: %swant %s", spew.Sdump(gotPaths), spew.Sdump(wantPaths)) + } + }) + t.Run("not okay", func(t *testing.T) { + fixtureDir := filepath.Clean("testdata/config-graph/child-provider-child-count") + loader, err := configload.NewLoader(&configload.Config{ + ModulesDir: filepath.Join(fixtureDir, ".terraform/modules"), + }) + if err != nil { + t.Fatalf("unexpected error from NewLoader: %s", err) + } + + _, _, diags := testLoadWithSnapshot(fixtureDir, loader, nil) + if !diags.HasErrors() { + t.Fatalf("loading succeeded; want an error") + } + if got, want := diags.Err().Error(), "Module is incompatible with count, for_each, and depends_on"; !strings.Contains(got, want) { + t.Errorf("missing expected error\nwant substring: %s\ngot: %s", want, got) + } + }) +} + +func assertNoDiagnostics[D hcl.Diagnostics | tfdiags.Diagnostics](t *testing.T, diags D) bool { + t.Helper() + + if len(diags) != 0 { + t.Errorf("wrong number of diagnostics %d; want %d", len(diags), 0) + return true + } + return false +} diff --git a/internal/plans/planfile/config_snapshot_test.go b/internal/terraform/config_snapshot_test.go similarity index 74% rename from internal/plans/planfile/config_snapshot_test.go rename to internal/terraform/config_snapshot_test.go index 39344e0dcf..fbc1dcfcbc 100644 --- a/internal/plans/planfile/config_snapshot_test.go +++ b/internal/terraform/config_snapshot_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package planfile +package terraform import ( "archive/zip" @@ -13,10 +13,11 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/plans/planfile" ) func TestConfigSnapshotRoundtrip(t *testing.T) { - fixtureDir := filepath.Join("testdata", "test-config") + fixtureDir := filepath.Join("testdata", "planfile", "test-config") loader, err := configload.NewLoader(&configload.Config{ ModulesDir: filepath.Join(fixtureDir, ".terraform", "modules"), }) @@ -24,14 +25,14 @@ func TestConfigSnapshotRoundtrip(t *testing.T) { t.Fatal(err) } - _, snapIn, diags := loader.LoadConfigWithSnapshot(fixtureDir) + _, snapIn, diags := testLoadWithSnapshot(fixtureDir, loader, nil) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } var buf bytes.Buffer zw := zip.NewWriter(&buf) - err = writeConfigSnapshot(snapIn, zw) + err = planfile.WriteConfigSnapshot(snapIn, zw) if err != nil { t.Fatalf("failed to write snapshot: %s", err) } @@ -44,7 +45,7 @@ func TestConfigSnapshotRoundtrip(t *testing.T) { t.Fatal(err) } - snapOut, err := readConfigSnapshot(zr) + snapOut, err := planfile.ReadConfigSnapshot(zr) if err != nil { t.Fatalf("failed to read snapshot: %s", err) } diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index d271331946..2d534ea878 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -4845,3 +4845,50 @@ resource "test_resource" "test" { }) } } + +func TestContext2Apply_outputWithTypeContraint(t *testing.T) { + m := testModule(t, "apply-output-type-constraint") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + tfdiags.AssertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m, nil) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + wantValues := map[string]cty.Value{ + "string": cty.StringVal("true"), + "object_default": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Bart"), + }), + "object_override": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Lisa"), + }), + } + ovs := state.RootOutputValues + for name, want := range wantValues { + os, ok := ovs[name] + if !ok { + t.Errorf("missing output value %q", name) + continue + } + if got := os.Value; !want.RawEquals(got) { + t.Errorf("wrong value for output %q\ngot: %#v\nwant: %#v", name, got, want) + } + } + + for gotName := range ovs { + if _, ok := wantValues[gotName]; !ok { + t.Errorf("unexpected extra output value %q", gotName) + } + } +} diff --git a/internal/terraform/context_init.go b/internal/terraform/context_init.go new file mode 100644 index 0000000000..e84a4b2f5e --- /dev/null +++ b/internal/terraform/context_init.go @@ -0,0 +1,60 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type InitOpts struct { + Walker configs.ModuleWalker + + // SetVariables are the raw values for root module variables as provided + // by the user who is requesting the run, prior to any normalization or + // substitution of defaults. See the documentation for the InputValue + // type for more information on how to correctly populate this. + SetVariables InputValues +} + +func (c *Context) Init(rootMod *configs.Module, initOpts InitOpts) (*configs.Config, tfdiags.Diagnostics) { + return c.init(rootMod, initOpts) +} + +func (c *Context) init(rootMod *configs.Module, initOpts InitOpts) (*configs.Config, tfdiags.Diagnostics) { + defer c.acquireRun("init")() + var diags tfdiags.Diagnostics + + config := &configs.Config{ + Module: rootMod, + Path: addrs.RootModule, + Children: map[string]*configs.Config{}, + } + config.Root = config + + graph, moreDiags := c.initGraph(config, initOpts) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + return nil, diags + } + + walker, walkDiags := c.walk(graph, walkInit, &graphWalkOpts{ + Config: config, + }) + diags = diags.Append(walker.NonFatalDiagnostics) + diags = diags.Append(walkDiags) + + return config, diags +} + +func (c *Context) initGraph(config *configs.Config, initOpts InitOpts) (*Graph, tfdiags.Diagnostics) { + graph, diags := (&InitGraphBuilder{ + Config: config, + RootVariableValues: initOpts.SetVariables, + Walker: initOpts.Walker, + }).Build(addrs.RootModuleInstance) + + return graph, diags +} diff --git a/internal/terraform/context_init_test.go b/internal/terraform/context_init_test.go new file mode 100644 index 0000000000..1e9dc51360 --- /dev/null +++ b/internal/terraform/context_init_test.go @@ -0,0 +1,793 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + version "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +var _ configs.ModuleWalker = (*MockModuleWalker)(nil) + +type MockModuleWalker struct { + Calls []*configs.ModuleRequest + DefaultModule *configs.Module + // the string key refers to ModuleSource.String() + MockedCalls map[string]*configs.Module +} + +func (m *MockModuleWalker) LoadModule(req *configs.ModuleRequest) (*configs.Module, *version.Version, hcl.Diagnostics) { + m.Calls = append(m.Calls, req) + + if mod, ok := m.MockedCalls[req.SourceAddr.String()]; ok { + return mod, nil, nil + } + + return m.DefaultModule, nil, nil +} + +func (m *MockModuleWalker) MockModuleCalls(t *testing.T, calls map[string]*configs.Module) { + t.Helper() + if m.MockedCalls == nil { + m.MockedCalls = make(map[string]*configs.Module) + } + for k, v := range calls { + // Make sure we can parse the module source + ms := mustModuleSource(t, k) + m.MockedCalls[ms.String()] = v + } +} + +func TestInit(t *testing.T) { + for name, tc := range map[string]struct { + module map[string]string + vars InputValues + mockedLoadModuleCalls map[string]map[string]string + // m -> root module + // mc -> module calls + expectDiags func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics + expectLoadModuleCalls []*configs.ModuleRequest + }{ + "empty config": { + module: map[string]string{"main.tf": ``}, + }, + "local - no variables": { + module: map[string]string{ + "main.tf": ` +module "example" { + source = "./modules/example" +} +`, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "remote - no variables": { + module: map[string]string{ + "main.tf": ` +module "example" { + source = "terraform-aws-modules/vpc/aws" + version = "6.6.0" +} + +module "example2" { + source = "terraform-iaac/cert-manager/kubernetes" +} + `, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + }, { + SourceAddr: mustModuleSource(t, "terraform-aws-modules/vpc/aws"), + VersionConstraint: mustVersionContraint(t, "= 6.6.0"), + }}, + }, + + "local - with variables": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "local with non-const variables": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg}, + }, + + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + // TODO: We should try to somehow add an "extra" into the diagnostics to indicate + // that this may be caused by a non-const variable used during init. + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid module source`, + Detail: "The value of a reference in the module source is unknown." + constVariableDetail, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 6, Column: 27, Byte: 82}, + End: hcl.Pos{Line: 6, Column: 35, Byte: 90}, + }, + }) + }, + }, + + "remote - with variable in source": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example2" { + source = "terraform-iaac/${var.name}/kubernetes" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + }}, + }, + "remote - with variable in constraint": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example2" { + source = "terraform-iaac/cert-manager/kubernetes" + version = ">= ${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("1.2.3"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + VersionConstraint: mustVersionContraint(t, ">= 1.2.3"), + }}, + }, + + "locals in module sources": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} + +locals { + org_and_repo = "terraform-iaac/${var.name}" +} + +module "example2" { + source = "${local.org_and_repo}/kubernetes" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + VersionConstraint: mustVersionContraint(t, ">= 1.2.3"), + }}, + }, + + "each in module sources": { + module: map[string]string{ + "main.tf": ` +module "example" { + for_each = toset(["cert-manager", "helm"]) + source = "terraform-iaac/${each.key}/kubernetes" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid module source`, + Detail: `The module source can only reference constant input variables and local values.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 4, Column: 31, Byte: 95}, + End: hcl.Pos{Line: 4, Column: 39, Byte: 103}, + }, + }) + }, + }, + + "module variables in source": { + module: map[string]string{ + "main.tf": ` +module "mod" { + source = "./mod" + name = "cert-manager" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("cert-manager"), SourceType: ValueFromCLIArg}, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./mod": { + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example" { + source = "terraform-iaac/${var.name}/kubernetes" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./mod"), + }, { + SourceAddr: mustModuleSource(t, "terraform-iaac/cert-manager/kubernetes"), + }}, + }, + + "undefined variable in module source": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true +} +module "example2" { + source = "terraform-iaac/${var.name}/kubernetes" +} +`, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Required variable not set", + Detail: `The variable "name" is required, but is not set.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 16, Byte: 16}, + }, + }) + }, + }, + + "resource reference in module source": { + module: map[string]string{ + "main.tf": ` +resource "null_resource" "example" {} + +module "example" { + source = "terraform-iaac/${null_resource.example.id}/kubernetes" +} +`, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source can only reference constant input variables and local values.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 5, Column: 33, Byte: 91}, + End: hcl.Pos{Line: 5, Column: 54, Byte: 112}, + }, + }) + }, + }, + "resource reference in module call": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + default = "aws" + const = true +} +resource "null_resource" "example" {} + +module "example" { + source = "./${var.name}" + + name = var.name + this_should_be_unknown_and_not_cause_error = null_resource.example.id +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./aws": { + "main.tf": ` +variable "name" { + type = string + const = true +} + +variable "this_should_be_unknown_and_not_cause_error" { + type = string +} + +module "example" { + source = "terraform-iaac/${var.name}/kubernetes" +} + + +output "foo" { + value = var.this_should_be_unknown_and_not_cause_error +} + `, + }, + }, + + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "terraform-iaac/aws/kubernetes"), + }, { + SourceAddr: mustModuleSource(t, "./aws"), + }}, + }, + + "module output reference in module source": { + module: map[string]string{ + "main.tf": ` +module "example" { + source = "./module/example" +} + +module "example2" { + source = "terraform-iaac/${module.example.id}/kubernetes" +} + `, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./module/example": { + "main.tf": ` +output "id" { + value = "example-id" +} + `, + }}, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./module/example"), + }}, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source can only reference constant input variables and local values.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 33, Byte: 107}, + End: hcl.Pos{Line: 7, Column: 50, Byte: 124}, + }, + }) + }, + }, + + "nested module loading - no variables": { + module: map[string]string{ + "main.tf": ` +module "parent" { + source = "hashicorp/parent/aws" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "hashicorp/parent/aws": { + "main.tf": ` +module "child" { + source = "hashicorp/child/aws" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "hashicorp/parent/aws"), + }, { + SourceAddr: mustModuleSource(t, "hashicorp/child/aws"), + }}, + }, + + "nested module loading - with variables": { + module: map[string]string{ + "main.tf": ` +module "parent" { + source = "hashicorp/parent/aws" + name = "child" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "hashicorp/parent/aws": { + "main.tf": ` +variable "name" { + type = string + const = true +} +module "child" { + source = "hashicorp/${var.name}/aws" + name = "grand${var.name}" +} + `, + }, + "hashicorp/child/aws": { + "main.tf": ` +variable "name" { + type = string + const = true +} +module "grandchild" { + source = "hashicorp/${var.name}/aws" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "hashicorp/parent/aws"), + }, { + SourceAddr: mustModuleSource(t, "hashicorp/child/aws"), + }, { + SourceAddr: mustModuleSource(t, "hashicorp/grandchild/aws"), + }}, + }, + "module nested expansion": { + module: map[string]string{ + "main.tf": ` +module "fromdisk" { + source = "./mod" + namespace = "terraform-iaac" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./mod": { + "main.tf": ` +locals { + source = var.namespace +} +variable "namespace" { + type = string + const = true +} +module "terraform" { + source = "${var.namespace}/helm/kubernetes" +} +output "name" { + value = "fooo" +} +`, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./mod"), + }, { + SourceAddr: mustModuleSource(t, "terraform-iaac/helm/kubernetes"), + }}, + }, + + "const variable with no value and no default": { + module: map[string]string{"main.tf": ` +variable "name" { + type = string + const = true +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Required variable not set`, + Detail: `The variable "name" is required, but is not set.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 16, Byte: 16}, + }, + }) + }, + }, + + "const variable with default": { + module: map[string]string{"main.tf": ` +variable "name" { + type = string + const = true + default = "example" +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "non-const variable passed into const module variable": { + module: map[string]string{"main.tf": ` +variable "name" { + type = string + default = "example" +} +module "example" { + source = "./modules/example" + name = "./modules/${var.name}2" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./modules/example": { + "main.tf": ` +variable "name" { + type = string + const = true +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Const variables must be known`, + Detail: `Only a constant value can be passed into a constant module variable.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 8, Column: 10, Byte: 118}, + End: hcl.Pos{Line: 8, Column: 34, Byte: 142}, + }, + }) + }, + }, + + "non-static module variable used as static": { + module: map[string]string{"main.tf": ` +module "example" { + source = "./modules/example" + + name = "foo" +} +`, + }, + mockedLoadModuleCalls: map[string]map[string]string{ + "./modules/example": { + "main.tf": ` +variable "name" { + type = string +} + +module "nested" { + source = "./modules/${var.name}" +} + `, + }, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + // TODO: We should try to somehow add an "extra" into the diagnostics to indicate + // that this may be caused by a non-const variable used during init. + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Invalid module source`, + Detail: "The value of a reference in the module source is unknown." + constVariableDetail, + Subject: &hcl.Range{ + Filename: filepath.Join(mc["./modules/example"].SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 27, Byte: 82}, + End: hcl.Pos{Line: 7, Column: 35, Byte: 90}, + }, + }) + }, + }, + + "const variable with passing validation": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true + + validation { + condition = var.name != "" + error_message = "must not be empty" + } +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("example"), SourceType: ValueFromCLIArg}, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/example"), + }}, + }, + + "const variable with failing validation": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + const = true + + validation { + condition = var.name != "bad" + error_message = "must not be bad" + } +} +module "example" { + source = "./modules/${var.name}" +} +`, + }, + vars: InputValues{ + "name": &InputValue{Value: cty.StringVal("bad"), SourceType: ValueFromCLIArg}, + }, + expectDiags: func(m *configs.Module, mc map[string]*configs.Module) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid value for variable", + Detail: fmt.Sprintf("must not be bad\n\nThis was checked by the validation rule at %s.", m.Variables["name"].Validations[0].DeclRange.String()), + Subject: m.Variables["name"].DeclRange.Ptr(), + }) + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/bad"), + }}, + }, + + "non-const variable validation does not run during init": { + module: map[string]string{ + "main.tf": ` +variable "name" { + type = string + default = "bad" + + validation { + condition = var.name != "bad" + error_message = "must not be bad" + } +} +module "example" { + source = "./modules/fixed" +} +`, + }, + expectLoadModuleCalls: []*configs.ModuleRequest{{ + SourceAddr: mustModuleSource(t, "./modules/fixed"), + }}, + }, + } { + t.Run(name, func(t *testing.T) { + m := testRootModuleInline(t, tc.module) + + ctx := testContext2(t, &ContextOpts{ + Parallelism: 1, + }) + moduleWalker := MockModuleWalker{ + DefaultModule: testRootModuleInline(t, map[string]string{"main.tf": `// empty`}), + } + mockedModules := make(map[string]*configs.Module) + if tc.mockedLoadModuleCalls != nil { + for k, v := range tc.mockedLoadModuleCalls { + mockedModules[k] = testRootModuleInline(t, v) + } + moduleWalker.MockModuleCalls(t, mockedModules) + } + _, diags := ctx.Init(m, InitOpts{ + SetVariables: tc.vars, + Walker: &moduleWalker, + }) + if tc.expectDiags != nil { + tfdiags.AssertDiagnosticsMatch(t, diags, tc.expectDiags(m, mockedModules)) + } else { + tfdiags.AssertNoDiagnostics(t, diags) + } + + if len(moduleWalker.Calls) != len(tc.expectLoadModuleCalls) { + t.Fatalf("expected %d LoadModule calls, got %d", len(tc.expectLoadModuleCalls), len(moduleWalker.Calls)) + } + + // Create a map of expected sources for easier comparison + expectedSources := make(map[string]bool) + foundSources := []string{} + for _, expected := range tc.expectLoadModuleCalls { + expectedSources[expected.SourceAddr.String()] = false + } + + // Mark sources as found + for _, call := range moduleWalker.Calls { + source := call.SourceAddr.String() + foundSources = append(foundSources, source) + if _, exists := expectedSources[source]; !exists { + t.Errorf("unexpected LoadModule call for source %q", source) + } else { + expectedSources[source] = true + } + } + + // Check all expected sources were called + for source, found := range expectedSources { + if !found { + t.Errorf("expected LoadModule call for source %q but it was not called. Calls that were made: \n %s", source, strings.Join(foundSources, ", ")) + } + } + }) + } +} + +func mustModuleSource(t *testing.T, rawStr string) addrs.ModuleSource { + src, err := moduleaddrs.ParseModuleSource(rawStr) + if err != nil { + t.Fatalf("failed to parse module source %q: %s", rawStr, err) + } + return src +} + +func mustVersionContraint(t *testing.T, rawStr string) configs.VersionConstraint { + constraints, err := version.NewConstraint(rawStr) + if err != nil { + t.Fatalf("failed to parse version constraint %q: %s", rawStr, err) + } + return configs.VersionConstraint{ + Required: constraints, + } +} diff --git a/internal/terraform/context_plan_test.go b/internal/terraform/context_plan_test.go index c49c37d0e0..0e5e73682e 100644 --- a/internal/terraform/context_plan_test.go +++ b/internal/terraform/context_plan_test.go @@ -7026,6 +7026,84 @@ func TestContext2Plan_variableCustomValidationsSensitive(t *testing.T) { } } +func TestContext2Plan_constVariableCustomValidationPass(t *testing.T) { + vars := map[string]*InputValue{ + "a": { + Value: cty.StringVal("valid"), + }, + } + m := testModuleInlineWithVars(t, map[string]string{ + "main.tf": ` +variable "a" { + type = string + const = true + + validation { + condition = var.a == "valid" + error_message = "Value must be valid." + } +} +`, + }, vars) + + p := testProvider("test") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + SetVariables: vars, + }) + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s", diags.Err().Error()) + } +} + +func TestContext2Plan_constVariableCustomValidationFail(t *testing.T) { + m := testModuleInlineWithVars(t, map[string]string{ + "main.tf": ` +variable "a" { + type = string + const = true + + validation { + condition = var.a == "valid" + error_message = "Value must be valid." + } +} +`, + }, map[string]*InputValue{ + "a": { + Value: cty.StringVal("valid"), + }, + }) + + p := testProvider("test") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + SetVariables: map[string]*InputValue{ + "a": { + Value: cty.StringVal("invalid"), + }, + }, + }) + if !diags.HasErrors() { + t.Fatalf("unexpected success") + } + gotDiags := diags.Err().Error() + wantDiagSubstr := "Value must be valid." + if !strings.Contains(gotDiags, wantDiagSubstr) { + t.Errorf("missing expected error message\nwant substring: %s\ngot:\n%s", wantDiagSubstr, gotDiags) + } +} + func TestContext2Plan_nullOutputNoOp(t *testing.T) { // this should always plan a NoOp change for the output m := testModuleInline(t, map[string]string{ diff --git a/internal/terraform/context_test.go b/internal/terraform/context_test.go index 61feeccd8f..ef4276874c 100644 --- a/internal/terraform/context_test.go +++ b/internal/terraform/context_test.go @@ -810,7 +810,25 @@ func contextOptsForPlanViaFile(t *testing.T, configSnap *configload.Snapshot, pl return nil, nil, nil, err } - config, diags := pr.ReadConfig(false) + snap, err := pr.ReadConfigSnapshot() + if err != nil { + return nil, nil, nil, err + } + + loader := configload.NewLoaderFromSnapshot(snap) + rootMod, hclDiags := loader.LoadRootModule(snap.Modules[""].Dir) + diags := tfdiags.Diagnostics(nil).Append(hclDiags) + if diags.HasErrors() { + return nil, nil, nil, diags.Err() + } + + config, buildDiags := BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) if diags.HasErrors() { return nil, nil, nil, diags.Err() } diff --git a/internal/terraform/context_validate_test.go b/internal/terraform/context_validate_test.go index f4f726e456..9a554252d7 100644 --- a/internal/terraform/context_validate_test.go +++ b/internal/terraform/context_validate_test.go @@ -1494,6 +1494,40 @@ variable "test" { } } +func TestContext2Validate_constVariableCustomValidationPass(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +module "child" { + source = "./child" + a = "valid" +} +`, + "child/main.tf": ` +variable "a" { + type = string + const = true + + validation { + condition = var.a == "valid" + error_message = "Value must be valid." + } +} +`, + }) + + p := testProvider("test") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + diags := ctx.Validate(m, nil) + if diags.HasErrors() { + t.Fatalf("unexpected error\ngot: %s", diags.Err().Error()) + } +} + func TestContext2Validate_expandModules(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` diff --git a/internal/terraform/eval_for_each.go b/internal/terraform/eval_for_each.go index 8f0951d1b4..e6eb4535c6 100644 --- a/internal/terraform/eval_for_each.go +++ b/internal/terraform/eval_for_each.go @@ -90,7 +90,7 @@ func (ev *forEachEvaluator) ResourceValue() (map[string]cty.Value, bool, tfdiags return res, false, diags } - forEachVal = marks.RemoveDeprecationMarks(forEachVal) + forEachVal, _ = marks.GetDeprecationMarks(forEachVal) if forEachVal.IsNull() || !forEachVal.IsKnown() || markSafeLengthInt(forEachVal) == 0 { // we check length, because an empty set returns a nil map which will panic below return res, true, diags diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 6842ec8c3c..ee1374427a 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -166,6 +166,10 @@ var _ lang.Data = (*evaluationStateData)(nil) // the evaluator embedded in this data object, using this data object's // static module path. func (d *evaluationStateData) StaticValidateReferences(refs []*addrs.Reference, self addrs.Referenceable, source addrs.Referenceable) tfdiags.Diagnostics { + if d.Operation == walkInit { + // Skip static validation during init walks + return tfdiags.Diagnostics{} + } return d.Evaluator.StaticValidateReferences(refs, d.ModulePath.Module(), self, source) } @@ -174,6 +178,12 @@ func (d *evaluationStateData) GetCountAttr(addr addrs.CountAttr, rng tfdiags.Sou switch addr.Name { case "index": + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + idxVal := d.InstanceKeyData.CountIndex if idxVal == cty.NilVal { diags = diags.Append(&hcl.Diagnostic{ @@ -226,6 +236,12 @@ func (d *evaluationStateData) GetForEachAttr(addr addrs.ForEachAttr, rng tfdiags return cty.DynamicVal, diags } + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + if returnVal == cty.NilVal { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -387,6 +403,12 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc return cty.DynamicVal, diags } + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + // We'll consult the configuration to see what output names we are // expecting, so we can ensure the resulting object is of the expected // type even if our data is incomplete for some reason. @@ -398,21 +420,66 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc } outputConfigs := moduleConfig.Module.Outputs - // We don't do instance expansion during validation, and so we need to - // return an unknown value. Technically we should always return - // cty.DynamicVal here because the final value during plan will always - // be an object or tuple type with unpredictable attributes/elements, - // but because we never actually carry values forward from validation to - // planning we lie a little here and return unknown list and map types, - // just to give us more opportunities to catch author mistakes during - // validation. - // - // This means that in practice any expression that refers to a module - // call must be written to be valid for either a collection type or - // structural type of similar kind, so that it can be considered as - // valid during both the validate and plan walks. - if d.Operation == walkValidate { - // In case of non-expanded module calls we return a known object with unknonwn values + // typeDefined tracks if a module has defined any output type at all. We can + // use this as a flag to abandon some subtly incorrect legacy behavior. + // Start false because any TypeSet will flip the flag to true + typeDefined := false + + // noDynamicTypes indicates that the module fully defines all output types, + // and they themselves contain no dynamic types. This allows us to create + // more precise unknowns for outputs, and use lists and maps when + // applicable. + // Start true because any dynamic type will flip the flag to false. + noDynamicTypes := true + + for _, out := range outputConfigs { + typeDefined = typeDefined || out.TypeSet + noDynamicTypes = noDynamicTypes && !out.ConstraintType.HasDynamicTypes() + } + + if d.Operation == walkValidate && typeDefined { + atys := make(map[string]cty.Type, len(outputConfigs)) + as := make(map[string]cty.Value, len(outputConfigs)) + for name, c := range outputConfigs { + // atys is used to create the module object type for expanded modules + atys[name] = c.ConstraintType + // the unknown val can be used when we return a single module + // instance with unknown outputs + val := cty.UnknownVal(c.ConstraintType) + + if c.DeprecatedSet { + val = val.Mark(marks.NewDeprecation(c.Deprecated, absAddr.Output(name).ConfigOutputValue().ForDisplay())) + } + as[name] = val + } + instTy := cty.Object(atys) + + switch { + case callConfig.Count != nil && noDynamicTypes: + return cty.UnknownVal(cty.List(instTy)), diags + case callConfig.ForEach != nil && noDynamicTypes: + return cty.UnknownVal(cty.Map(instTy)), diags + case callConfig.Count != nil || callConfig.ForEach != nil: + return cty.DynamicVal, diags + default: + val := cty.ObjectVal(as) + return val, diags + } + } else if d.Operation == walkValidate { + // the legacy behavior here is slightly wrong, but we're going to + // preserve it for now when modules don't define any typed output. The + // fact that we are returning a list or map is incorrect when the types + // are unknown, because the known values we get later are going to be + // tuples and objects. This usually doesn't present a problem, but it is + // possible to write complex expressions where it can only pass during + // one of validation or planning because the types will cause a mismatch + // in the other case. + // This means that in practice any expression that refers to a module + // call must be written to be valid for either a collection type or + // structural type of similar kind, so that it can be considered as + // valid during both the validate and plan walks. + + // In case of non-expanded module calls we return a known object with unknown values // In case of an expanded module call we return unknown list/map // This means deprecation can only for non-expanded modules be detected during validate // since we don't want false positives. The plan walk will give definitive warnings. @@ -465,16 +532,15 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc for name, cfg := range outputConfigs { outputAddr := moduleInstAddr.OutputValue(name) - // Although we do typically expect the graph dependencies to - // ensure that values get registered before they are needed, - // we track depedencies with specific output values where - // possible, instead of with entire module calls, and so - // in this specific case it's valid for some of this call's - // output values to not be known yet, with the graph builder - // being responsible for making sure that no expression - // in the configuration can actually observe that. + // Although we do typically expect the graph dependencies to ensure + // that values get registered before they are needed, we track + // dependencies with specific output values where possible, instead + // of with entire module calls, and so in this specific case it's + // valid for some of this call's output values to not be known yet, + // with the graph builder being responsible for making sure that no + // expression in the configuration can actually observe that. if !namedVals.HasOutputValue(outputAddr) { - attrs[name] = cty.DynamicVal + attrs[name] = cty.UnknownVal(cfg.ConstraintType) continue } outputVal := namedVals.GetOutputValue(outputAddr) @@ -513,7 +579,11 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc elems = append(elems, instVal) diags = diags.Append(moreDiags) } - return cty.TupleVal(elems), diags + if noDynamicTypes { + return cty.ListVal(elems), diags + } else { + return cty.TupleVal(elems), diags + } case addrs.StringKeyType: attrs := make(map[string]cty.Value, len(instKeys)) @@ -522,7 +592,11 @@ func (d *evaluationStateData) GetModule(addr addrs.ModuleCall, rng tfdiags.Sourc attrs[string(instKey.(addrs.StringKey))] = instVal diags = diags.Append(moreDiags) } - return cty.ObjectVal(attrs), diags + if noDynamicTypes { + return cty.MapVal(attrs), diags + } else { + return cty.ObjectVal(attrs), diags + } default: diags = diags.Append(&hcl.Diagnostic{ @@ -561,6 +635,11 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc return cty.DynamicVal, diags } + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } // Much of this function was written before we had factored out the handling // of instance keys into the separate instance expander model, and so it // does a bunch of instance-related work itself below. @@ -575,7 +654,7 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc // TODO: When deferred actions are more stable and robust in stacks, it // would be nice to rework this function to rely on the ResourceInstanceKeys // result for _all_ of its work, rather than continuing to duplicate a bunch - // of the logic we've tried to encapsulate over ther already. + // of the logic we've tried to encapsulate over there already. if d.Operation == walkPlan || d.Operation == walkApply { if !d.Evaluator.Instances.ResourceInstanceExpanded(addr.Absolute(moduleAddr)) { // Then we've asked for a resource that hasn't been evaluated yet. @@ -768,14 +847,6 @@ func (d *evaluationStateData) GetResource(addr addrs.Resource, rng tfdiags.Sourc } case walkImport: - // Import does not yet plan resource changes, so new resources from - // config are not going to be found here. Once walkImport fully - // plans resources, this case should not longer be needed. - // In the single instance case, we can return a typed unknown value - // for the instance to better satisfy other expressions using the - // value. This of course will not help if statically known - // attributes are expected to be known elsewhere, but reduces the - // number of problematic configs for now. // Unlike in plan and apply above we can't be sure the count or // for_each instances are empty, so we return a DynamicVal. We // don't really have a good value to return otherwise -- empty @@ -1000,7 +1071,7 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi func (d *evaluationStateData) getEphemeralResource(addr addrs.Resource, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - if d.Operation == walkValidate || d.Operation == walkEval { + if d.Operation == walkValidate || d.Operation == walkEval || d.Operation == walkInit { // Ephemeral instances are never live during the validate walk. Eval is // similarly offline, and since there is no value stored we can't return // anything other than dynamic. @@ -1120,6 +1191,12 @@ func (d *evaluationStateData) getResourceSchema(addr addrs.Resource, providerAdd func (d *evaluationStateData) GetOutput(addr addrs.OutputValue, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + if d.Operation == walkInit { + // During init walks we don't have any state or prior knowledge + // about resources, so we just return unknown. + return cty.DynamicVal, diags + } + // First we'll make sure the requested value is declared in configuration, // so we can produce a nice message if not. moduleConfig := d.Evaluator.Config.DescendantForInstance(d.ModulePath) diff --git a/internal/terraform/evaluate_data.go b/internal/terraform/evaluate_data.go index 3d33b598d8..250e1abc77 100644 --- a/internal/terraform/evaluate_data.go +++ b/internal/terraform/evaluate_data.go @@ -99,6 +99,10 @@ func (d *evaluationData) GetPathAttr(addr addrs.PathAttr, rng tfdiags.SourceRang // GetTerraformAttr implements lang.Data. func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + if d.Evaluator.Operation == walkInit { + return cty.DynamicVal, tfdiags.Diagnostics{} + } + var diags tfdiags.Diagnostics switch addr.Name { @@ -154,6 +158,10 @@ func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfdiags. // StaticValidateReferences implements lang.Data. func (d *evaluationData) StaticValidateReferences(refs []*addrs.Reference, self addrs.Referenceable, source addrs.Referenceable) tfdiags.Diagnostics { + if d.Evaluator.Operation == walkInit { + // Skip static validation during init walks + return tfdiags.Diagnostics{} + } return d.Evaluator.StaticValidateReferences(refs, d.Module, self, source) } diff --git a/internal/terraform/evaluate_test.go b/internal/terraform/evaluate_test.go index 0f590dbf71..e2853fa70b 100644 --- a/internal/terraform/evaluate_test.go +++ b/internal/terraform/evaluate_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/davecgh/go-spew/spew" + "github.com/hashicorp/hcl/v2/hcltest" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" @@ -568,7 +569,7 @@ func TestEvaluatorGetResource_changes(t *testing.T) { } func TestEvaluatorGetModule(t *testing.T) { - evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper()) + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), nil, nil) evaluator.Instances.SetModuleSingle(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}) evaluator.NamedValues.SetOutputValue( addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{addrs.ModuleInstanceStep{Name: "mod"}}), @@ -593,7 +594,304 @@ func TestEvaluatorGetModule(t *testing.T) { } } -func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesSync) *Evaluator { +func TestEvaluatorGetModule_validateTypedOutputs(t *testing.T) { + tests := map[string]struct { + configureModuleCall func(*configs.ModuleCall) + want cty.Value + }{ + "single": { + want: cty.ObjectVal(map[string]cty.Value{ + "out": cty.UnknownVal(cty.String), + }), + }, + "count": { + configureModuleCall: func(call *configs.ModuleCall) { + call.Count = hcltest.MockExprLiteral(cty.NumberIntVal(1)) + }, + want: cty.UnknownVal(cty.List(cty.Object(map[string]cty.Type{ + "out": cty.String, + }))), + }, + "for_each": { + configureModuleCall: func(call *configs.ModuleCall) { + call.ForEach = hcltest.MockExprLiteral(cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("a"), + })) + }, + want: cty.UnknownVal(cty.Map(cty.Object(map[string]cty.Type{ + "out": cty.String, + }))), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), func(out *configs.Output) { + out.ConstraintType = cty.String + out.TypeSet = true + }, test.configureModuleCall) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkValidate, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(test.want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} + +func TestEvaluatorGetModule_validateTypedOutputsWithDynamicTypes(t *testing.T) { + tests := map[string]struct { + configureModuleCall func(*configs.ModuleCall) + }{ + "count": { + configureModuleCall: func(call *configs.ModuleCall) { + call.Count = hcltest.MockExprLiteral(cty.NumberIntVal(1)) + }, + }, + "for_each": { + configureModuleCall: func(call *configs.ModuleCall) { + call.ForEach = hcltest.MockExprLiteral(cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("a"), + })) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), func(out *configs.Output) { + out.ConstraintType = cty.DynamicPseudoType + out.TypeSet = true + }, test.configureModuleCall) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkValidate, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(cty.DynamicVal) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, cty.DynamicVal) + } + }) + } +} + +func TestEvaluatorGetModule_planTypedOutputs(t *testing.T) { + tests := map[string]struct { + setupInstances func(*instances.Expander) + setupOutputs func(*namedvals.State) + want cty.Value + }{ + "count": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleCount(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, 2) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(0)}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(1)}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + "for_each": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleForEach(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, map[string]cty.Value{ + "a": cty.StringVal("a"), + "b": cty.StringVal("b"), + }) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("a")}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("b")}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + "b": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), func(out *configs.Output) { + out.ConstraintType = cty.String + out.TypeSet = true + }, nil) + + test.setupInstances(evaluator.Instances) + test.setupOutputs(evaluator.NamedValues) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkPlan, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(test.want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} + +func TestEvaluatorGetModule_planUntypedOutputsRemainStructural(t *testing.T) { + tests := map[string]struct { + setupInstances func(*instances.Expander) + setupOutputs func(*namedvals.State) + want cty.Value + }{ + "count": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleCount(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, 2) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(0)}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.IntKey(1)}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + "for_each": { + setupInstances: func(expander *instances.Expander) { + expander.SetModuleForEach(addrs.RootModuleInstance, addrs.ModuleCall{Name: "mod"}, map[string]cty.Value{ + "a": cty.StringVal("a"), + "b": cty.StringVal("b"), + }) + }, + setupOutputs: func(namedValues *namedvals.State) { + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("a")}, + }), + cty.StringVal("first").Mark(marks.Sensitive), + ) + namedValues.SetOutputValue( + addrs.OutputValue{Name: "out"}.Absolute(addrs.ModuleInstance{ + addrs.ModuleInstanceStep{Name: "mod", InstanceKey: addrs.StringKey("b")}, + }), + cty.StringVal("second").Mark(marks.Sensitive), + ) + }, + want: cty.ObjectVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("first").Mark(marks.Sensitive)}), + "b": cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("second").Mark(marks.Sensitive)}), + }), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + evaluator := evaluatorForModule(states.NewState().SyncWrapper(), plans.NewChanges().SyncWrapper(), nil, nil) + + test.setupInstances(evaluator.Instances) + test.setupOutputs(evaluator.NamedValues) + + data := &evaluationStateData{ + evaluationData: &evaluationData{ + Evaluator: evaluator, + }, + Operation: walkPlan, + } + scope := evaluator.Scope(data, nil, nil, lang.ExternalFuncs{}) + + got, diags := scope.Data.GetModule(addrs.ModuleCall{ + Name: "mod", + }, tfdiags.SourceRange{}) + + if len(diags) != 0 { + t.Errorf("unexpected diagnostics %s", spew.Sdump(diags)) + } + if !got.RawEquals(test.want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} + +func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesSync, configureOutput func(*configs.Output), configureModuleCall func(*configs.ModuleCall)) *Evaluator { + moduleCall := &configs.ModuleCall{ + Name: "mod", + } + if configureModuleCall != nil { + configureModuleCall(moduleCall) + } + + output := &configs.Output{ + Name: "out", + Sensitive: true, + ConstraintType: cty.DynamicPseudoType, + } + if configureOutput != nil { + configureOutput(output) + } + return &Evaluator{ Meta: &ContextMeta{ Env: "foo", @@ -601,9 +899,7 @@ func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesS Config: &configs.Config{ Module: &configs.Module{ ModuleCalls: map[string]*configs.ModuleCall{ - "mod": { - Name: "mod", - }, + "mod": moduleCall, }, }, Children: map[string]*configs.Config{ @@ -611,10 +907,7 @@ func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesS Path: addrs.Module{"module.mod"}, Module: &configs.Module{ Outputs: map[string]*configs.Output{ - "out": { - Name: "out", - Sensitive: true, - }, + "out": output, }, }, }, diff --git a/internal/terraform/graph_builder_eval.go b/internal/terraform/graph_builder_eval.go index 5913ce6106..260e8a7c51 100644 --- a/internal/terraform/graph_builder_eval.go +++ b/internal/terraform/graph_builder_eval.go @@ -74,8 +74,8 @@ func (b *EvalGraphBuilder) Steps() []GraphTransformer { }, // Add dynamic values - &RootVariableTransformer{Config: b.Config, RawValues: b.RootVariableValues, Planning: true}, - &ModuleVariableTransformer{Config: b.Config, Planning: true}, + &RootVariableTransformer{Config: b.Config, RawValues: b.RootVariableValues, ValidateChecks: true}, + &ModuleVariableTransformer{Config: b.Config, ValidateChecks: true}, &variableValidationTransformer{}, &LocalTransformer{Config: b.Config}, &OutputTransformer{ diff --git a/internal/terraform/graph_builder_init.go b/internal/terraform/graph_builder_init.go new file mode 100644 index 0000000000..bf22bec4c3 --- /dev/null +++ b/internal/terraform/graph_builder_init.go @@ -0,0 +1,87 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "log" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/dag" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type InitGraphBuilder struct { + // A config derived from the root module + Config *configs.Config + + RootVariableValues InputValues + + Walker configs.ModuleWalker +} + +// See GraphBuilder +func (b *InitGraphBuilder) Build(path addrs.ModuleInstance) (*Graph, tfdiags.Diagnostics) { + log.Printf("[TRACE] building graph for terraform dependencies") + return (&BasicGraphBuilder{ + Steps: b.Steps(), + Name: "InitGraphBuilder", + }).Build(path) +} + +// See GraphBuilder +func (b *InitGraphBuilder) Steps() []GraphTransformer { + steps := []GraphTransformer{} + + if b.Config.Parent == nil { + steps = append(steps, &RootVariableTransformer{ + Config: b.Config, + RawValues: b.RootVariableValues, + ValidateChecks: true, + }) + } else { + steps = append(steps, &ModuleVariableTransformer{ + Config: b.Config, + ModuleOnly: true, + ValidateChecks: true, + }) + } + + steps = append(steps, []GraphTransformer{ + &ModuleTransformer{ + Config: b.Config, + Walker: b.Walker, + }, + + &LocalTransformer{ + Config: b.Config, + }, + + &ReferenceTransformer{}, + + // Filters out any vertices that aren't relevant to the init graph + &TransformFilter{ + Keep: func(v dag.Vertex) bool { + switch n := v.(type) { + case *nodeInstallModule: + return true + case *NodeRootVariable: + return n.Config.Const + case *nodeExpandModuleVariable: + return n.Config.Const + default: + return false + } + }, + }, + + &variableValidationTransformer{}, + + &RootTransformer{}, + + &TransitiveReductionTransformer{}, + }...) + + return steps +} diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index e7b7525fb8..19d2cc50eb 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -194,15 +194,15 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { // Add dynamic values &RootVariableTransformer{ - Config: b.Config, - RawValues: b.RootVariableValues, - Planning: true, - DestroyApply: false, // always false for planning + Config: b.Config, + RawValues: b.RootVariableValues, + ValidateChecks: true, + DestroyApply: false, // always false for planning }, &ModuleVariableTransformer{ - Config: b.Config, - Planning: true, - DestroyApply: false, // always false for planning + Config: b.Config, + ValidateChecks: true, + DestroyApply: false, // always false for planning }, &variableValidationTransformer{ validateWalk: b.Operation == walkValidate, diff --git a/internal/terraform/graph_walk_operation.go b/internal/terraform/graph_walk_operation.go index a408f78465..fcfc23bfae 100644 --- a/internal/terraform/graph_walk_operation.go +++ b/internal/terraform/graph_walk_operation.go @@ -17,4 +17,5 @@ const ( walkDestroy walkImport walkEval // used just to prepare EvalContext for expression evaluation, with no other actions + walkInit ) diff --git a/internal/terraform/node_module_install.go b/internal/terraform/node_module_install.go new file mode 100644 index 0000000000..102bd5e597 --- /dev/null +++ b/internal/terraform/node_module_install.go @@ -0,0 +1,402 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "fmt" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/getmodules/moduleaddrs" + "github.com/hashicorp/terraform/internal/lang/langrefs" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +type nodeInstallModule struct { + // We're using a ModuleInstance here, + // because the downstream graph builder requires it. + // But it was constructed with addrs.NoKey + Addr addrs.ModuleInstance + ModuleCall *configs.ModuleCall + Parent *configs.Config + Walker configs.ModuleWalker + + // Stores the configuration of the installed module + Config *configs.Config + // Stores the version of the installed module + Version *version.Version +} + +var ( + _ GraphNodeExecutable = (*nodeInstallModule)(nil) + _ GraphNodeReferencer = (*nodeInstallModule)(nil) + _ GraphNodeDynamicExpandable = (*nodeInstallModule)(nil) + _ GraphNodeModuleInstance = (*nodeInstallModule)(nil) +) + +func (n *nodeInstallModule) Path() addrs.ModuleInstance { + return n.Addr.Parent() +} + +func (n *nodeInstallModule) Name() string { + return n.Addr.String() +} + +func (n *nodeInstallModule) ModulePath() addrs.Module { + return n.Addr.Module().Parent() +} + +func (n *nodeInstallModule) References() []*addrs.Reference { + var refs []*addrs.Reference + + sourceRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, n.ModuleCall.SourceExpr) + refs = append(refs, sourceRefs...) + versionRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, n.ModuleCall.VersionExpr) + refs = append(refs, versionRefs...) + + // We need to resolve all module inputs as well, because some might be used + // in the module as a constant variable to build a nested module source + attrs, _ := n.ModuleCall.Config.JustAttributes() + for _, attr := range attrs { + inputRefs, _ := langrefs.ReferencesInExpr(addrs.ParseRef, attr.Expr) + refs = append(refs, inputRefs...) + } + + return refs +} + +func (n *nodeInstallModule) Execute(ctx EvalContext, walkOp walkOperation) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + var version configs.VersionConstraint + if n.ModuleCall.VersionExpr != nil { + var versionDiags tfdiags.Diagnostics + version, versionDiags = evalVersionConstraint(n.ModuleCall.VersionExpr, ctx) + diags = diags.Append(versionDiags) + if diags.HasErrors() { + return diags + } + } + + hasVersion := n.ModuleCall.VersionExpr != nil + source, sourceRaw, sourceDiags := evalSource(n.ModuleCall.SourceExpr, hasVersion, ctx) + diags = diags.Append(sourceDiags) + if diags.HasErrors() { + return diags + } + + req := &configs.ModuleRequest{ + Name: n.ModuleCall.Name, + Path: n.Addr.Module(), + SourceAddr: source, + SourceAddrRange: n.ModuleCall.SourceExpr.Range(), + VersionConstraint: version, + Parent: n.Parent, + CallRange: n.ModuleCall.DeclRange, + } + + cfg, v, modDiags := n.Walker.LoadModule(req) + diags = diags.Append(modDiags) + if diags.HasErrors() { + return diags + } + + config := &configs.Config{ + Module: cfg, + Parent: n.Parent, + Path: n.Addr.Module(), + Root: n.Parent.Root, + Children: map[string]*configs.Config{}, + CallRange: n.ModuleCall.DeclRange, + SourceAddr: source, + SourceAddrRaw: sourceRaw, + SourceAddrRange: n.ModuleCall.SourceExpr.Range(), + Version: v, + VersionConstraint: version, + } + + // Insert the installed module into the children of the current module + currentModuleKey := n.Addr[len(n.Addr)-1].Name + n.Parent.Children[currentModuleKey] = config + + // During init, modules are loaded incrementally so the checks state + // built at walk start only knows about the root module. Register all + // checkable objects from the newly loaded module so that validation + // nodes added by DynamicExpand can find their check entries. + ctx.Checks().RegisterModule(config) + + n.Config = config + n.Version = v + + return nil +} + +func (n *nodeInstallModule) DynamicExpand(ctx EvalContext) (*Graph, tfdiags.Diagnostics) { + var g Graph + var diags tfdiags.Diagnostics + + expander := ctx.InstanceExpander() + _, call := n.Addr.Call() + expander.SetModuleSingle(n.Path(), call) + + graph, graphDiags := (&InitGraphBuilder{ + Config: n.Config, + Walker: n.Walker, + }).Build(n.Addr) + diags = diags.Append(graphDiags) + if graphDiags.HasErrors() { + return nil, diags + } + g.Subsume(&graph.AcyclicGraph.Graph) + + addRootNodeToGraph(&g) + + return &g, nil +} + +const constVariableDetail = "\n\nOnly literal values and constant variables (with const = true) are allowed for this attribute, as well as values derived from these." + +func evalSource(sourceExpr hcl.Expression, hasVersion bool, ctx EvalContext) (addrs.ModuleSource, string, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + var addr addrs.ModuleSource + var err error + + refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, sourceExpr) + diags = diags.Append(refsDiags) + if diags.HasErrors() { + return nil, "", diags + } + + for _, ref := range refs { + switch ref.Subject.(type) { + case addrs.InputVariable, addrs.LocalValue, addrs.PathAttr: + // These are allowed + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source can only reference constant input variables and local values.", + Subject: ref.SourceRange.ToHCL().Ptr(), + }) + return nil, "", diags + } + } + + value, valueDiags := ctx.EvaluateExpr(sourceExpr, cty.String, nil) + diags = diags.Append(valueDiags) + if diags.HasErrors() { + return nil, "", diags + } + + if !value.IsWhollyKnown() { + tExpr, ok := sourceExpr.(*hclsyntax.TemplateExpr) + if !ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source contains a reference that is unknown during init." + constVariableDetail, + Subject: sourceExpr.Range().Ptr(), + }) + return nil, "", diags + } + for _, part := range tExpr.Parts { + partVal, partDiags := ctx.EvaluateExpr(part, cty.DynamicPseudoType, nil) + diags = diags.Append(partDiags) + if diags.HasErrors() { + return nil, "", diags + } + + scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey) + hclCtx, evalDiags := scope.EvalContext(refs) + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return nil, "", diags + } + if !partVal.IsKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The value of a reference in the module source is unknown." + constVariableDetail, + Subject: part.Range().Ptr(), + Expression: part, + EvalContext: hclCtx, + Extra: diagnosticCausedByUnknown(true), + }) + return nil, "", diags + } + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source", + Detail: "The module source contains a reference that is unknown." + constVariableDetail, + Subject: sourceExpr.Range().Ptr(), + }) + return nil, "", diags + } + + rawSource := value.AsString() + if hasVersion { + addr, err = moduleaddrs.ParseModuleSourceRegistry(rawSource) + } else { + addr, err = moduleaddrs.ParseModuleSource(rawSource) + } + if err != nil { + // NOTE: We leave add as nil for any situation where the + // source attribute is invalid, so any code which tries to carefully + // use the partial result of a failed config decode must be + // resilient to that. + addr = nil + + // NOTE: In practice it's actually very unlikely to end up here, + // because our source address parser can turn just about any string + // into some sort of remote package address, and so for most errors + // we'll detect them only during module installation. There are + // still a _few_ purely-syntax errors we can catch at parsing time, + // though, mostly related to remote package sub-paths and local + // paths. + switch err := err.(type) { + case *moduleaddrs.MaybeRelativePathErr: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf( + "Terraform failed to determine your intended installation method for remote module package %q.\n\nIf you intended this as a path relative to the current module, use \"./%s\" instead. The \"./\" prefix indicates that the address is a relative filesystem path.", + err.Addr, err.Addr, + ), + Subject: sourceExpr.Range().Ptr(), + }) + default: + if hasVersion { + // In this case we'll include some extra context that + // we assumed a registry source address due to the + // version argument. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid registry module source address", + Detail: fmt.Sprintf("Failed to parse module registry address: %s.\n\nTerraform assumed that you intended a module registry source address because you also set the argument \"version\", which applies only to registry modules.", err), + Subject: sourceExpr.Range().Ptr(), + }) + } else { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module source address", + Detail: fmt.Sprintf("Failed to parse module source address: %s.", err), + Subject: sourceExpr.Range().Ptr(), + }) + } + } + } + + return addr, rawSource, diags +} + +func evalVersionConstraint(versionExpr hcl.Expression, ctx EvalContext) (configs.VersionConstraint, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + rng := versionExpr.Range() + + ret := configs.VersionConstraint{ + DeclRange: rng, + } + + refs, refsDiags := langrefs.ReferencesInExpr(addrs.ParseRef, versionExpr) + diags = diags.Append(refsDiags) + if diags.HasErrors() { + return ret, diags + } + + for _, ref := range refs { + switch ref.Subject.(type) { + case addrs.InputVariable, addrs.LocalValue: + // These are allowed + default: + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The module version can only reference constant input variables and local values.", + Subject: ref.SourceRange.ToHCL().Ptr(), + }) + return ret, diags + } + } + + value, valueDiags := ctx.EvaluateExpr(versionExpr, cty.String, nil) + diags = diags.Append(valueDiags) + if diags.HasErrors() { + return ret, diags + } + + if value.IsNull() { + // A null version constraint is strange, but we'll just treat it + // like an empty constraint set. + return ret, diags + } + + if !value.IsWhollyKnown() { + tExpr, ok := versionExpr.(*hclsyntax.TemplateExpr) + if !ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The module version contains a reference that is unknown during init." + constVariableDetail, + Subject: versionExpr.Range().Ptr(), + }) + return ret, diags + } + for _, part := range tExpr.Parts { + partVal, partDiags := ctx.EvaluateExpr(part, cty.DynamicPseudoType, nil) + diags = diags.Append(partDiags) + if diags.HasErrors() { + return ret, diags + } + + scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey) + hclCtx, evalDiags := scope.EvalContext(refs) + diags = diags.Append(evalDiags) + if diags.HasErrors() { + return ret, diags + } + if !partVal.IsKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The value of a reference in the module version is unknown." + constVariableDetail, + Subject: part.Range().Ptr(), + Expression: part, + EvalContext: hclCtx, + Extra: diagnosticCausedByUnknown(true), + }) + return ret, diags + } + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid module version", + Detail: "The module version contains a reference that is unknown." + constVariableDetail, + Subject: versionExpr.Range().Ptr(), + }) + return ret, diags + } + + constraintStr := value.AsString() + constraints, err := version.NewConstraint(constraintStr) + if err != nil { + // NewConstraint doesn't return user-friendly errors, so we'll just + // ignore the provided error and produce our own generic one. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version constraint", + Detail: "This string does not use correct version constraint syntax.", // Not very actionable :( + Subject: rng.Ptr(), + }) + return ret, diags + } + + ret.Required = constraints + return ret, diags +} diff --git a/internal/terraform/node_module_variable.go b/internal/terraform/node_module_variable.go index a458af0990..3aadece928 100644 --- a/internal/terraform/node_module_variable.go +++ b/internal/terraform/node_module_variable.go @@ -26,9 +26,8 @@ type nodeExpandModuleVariable struct { Config *configs.Variable Expr hcl.Expression - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ValidateChecks should be set to true if the graph should run the user-defined validations for this variable + ValidateChecks bool // DestroyApply must be set to true when planning or applying a destroy // operation, and false otherwise. @@ -55,7 +54,7 @@ func (n *nodeExpandModuleVariable) DynamicExpand(ctx EvalContext) (*Graph, tfdia // We should only do this during planning as the apply phase starts with // all the same checkable objects that were registered during the plan. var checkableAddrs addrs.Set[addrs.Checkable] - if n.Planning { + if n.ValidateChecks { if checkState := ctx.Checks(); checkState.ConfigHasChecks(n.Addr.InModule(n.Module)) { checkableAddrs = addrs.MakeSet[addrs.Checkable]() } @@ -217,14 +216,26 @@ func (n *nodeModuleVariable) Execute(ctx EvalContext, op walkOperation) (diags t log.Printf("[TRACE] nodeModuleVariable: evaluating %s", n.Addr) var val cty.Value + var errSourceRange tfdiags.SourceRange var err error switch op { case walkValidate: - val, err = n.evalModuleVariable(ctx, true) + val, errSourceRange, err = n.evalModuleVariable(ctx, true) diags = diags.Append(err) + case walkInit: + // During init we only want to record the value if it's static; + // otherwise we record it as dynamic to prevent its use in + // static contexts. + // We still evaluate it fully here to catch any errors early. + if n.Config.Const { + val, errSourceRange, err = n.evalModuleVariable(ctx, false) + diags = diags.Append(err) + } else { + val = cty.DynamicVal + } default: - val, err = n.evalModuleVariable(ctx, false) + val, errSourceRange, err = n.evalModuleVariable(ctx, false) diags = diags.Append(err) } if diags.HasErrors() { @@ -236,6 +247,15 @@ func (n *nodeModuleVariable) Execute(ctx EvalContext, op walkOperation) (diags t diags = diags.Append(deprecationDiags) } + if op == walkInit && n.Config.Const && !val.IsWhollyKnown() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Const variables must be known", + Detail: "Only a constant value can be passed into a constant module variable.", + Subject: errSourceRange.ToHCL().Ptr(), + }) + } + // Set values for arguments of a child module call, for later retrieval // during expression evaluation. ctx.NamedValues().SetInputVariableValue(n.Addr, val) @@ -263,7 +283,7 @@ func (n *nodeModuleVariable) DotNode(name string, opts *dag.DotOpts) *dag.DotNod // validateOnly indicates that this evaluation is only for config // validation, and we will not have any expansion module instance // repetition data. -func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bool) (cty.Value, error) { +func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bool) (cty.Value, tfdiags.SourceRange, error) { var diags tfdiags.Diagnostics var givenVal cty.Value var errSourceRange tfdiags.SourceRange @@ -289,7 +309,7 @@ func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bo val, moreDiags := scope.EvalExpr(expr, cty.DynamicPseudoType) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { - return cty.DynamicVal, diags.ErrWithWarnings() + return cty.DynamicVal, errSourceRange, diags.ErrWithWarnings() } givenVal = val errSourceRange = tfdiags.SourceRangeFromHCL(expr.Range()) @@ -320,7 +340,7 @@ func (n *nodeModuleVariable) evalModuleVariable(ctx EvalContext, validateOnly bo }) } - return finalVal, diags.ErrWithWarnings() + return finalVal, errSourceRange, diags.ErrWithWarnings() } // nodeModuleVariableInPartialModule represents an infinite set of possible diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index eadd281681..ddb3e22646 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -8,7 +8,9 @@ import ( "log" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" @@ -448,7 +450,7 @@ func (n *NodeApplyableOutput) Execute(ctx EvalContext, op walkOperation) (diags // This has to run before we have a state lock, since evaluation also // reads the state var evalDiags tfdiags.Diagnostics - val, evalDiags = ctx.EvaluateExpr(n.Config.Expr, cty.DynamicPseudoType, nil) + val, evalDiags = evalOutputValue(ctx, n.Config.Expr, n.Config.ConstraintType, n.Config.TypeDefaults) diags = diags.Append(evalDiags) // We'll handle errors below, after we have loaded the module. @@ -519,7 +521,7 @@ If you do intend to export this data, annotate the output value as sensitive by } if n.Config.DeprecatedSet { - val = marks.RemoveDeprecationMarksDeep(val) + val, _ = marks.GetDeprecationMarksDeep(val) if n.Addr.Module.IsRoot() { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -546,6 +548,51 @@ If you do intend to export this data, annotate the output value as sensitive by return diags } +// evalOutputValue encapsulates the logic for transforming an author's value +// expression into a valid value of their declared type constraint, or returning +// an error describing why that isn't possible. +func evalOutputValue(ctx EvalContext, expr hcl.Expression, wantType cty.Type, defaults *typeexpr.Defaults) (cty.Value, tfdiags.Diagnostics) { + // We can't pass wantType to EvaluateExpr here because we'll need to + // possibly apply our defaults before attempting type conversion below. + val, diags := ctx.EvaluateExpr(expr, cty.DynamicPseudoType, nil) + if diags.HasErrors() { + return cty.UnknownVal(wantType), diags + } + + if defaults != nil { + val = defaults.Apply(val) + } + + refs, moreDiags := langrefs.ReferencesInExpr(addrs.ParseRef, expr) + diags = diags.Append(moreDiags) + + scope := ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey) + var hclCtx *hcl.EvalContext + if scope != nil { + hclCtx, moreDiags = scope.EvalContext(refs) + } else { + // This shouldn't happen in real code, but it can unfortunately arise + // in unit tests due to incompletely-implemented mocks. :( + hclCtx = &hcl.EvalContext{} + } + diags = diags.Append(moreDiags) + + val, err := convert.Convert(val, wantType) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid output value", + Detail: fmt.Sprintf("The value expression does not match this output value's type constraint: %s.", tfdiags.FormatError(err)), + Subject: expr.Range().Ptr(), + Expression: expr, + EvalContext: hclCtx, + }) + return cty.UnknownVal(wantType), diags + } + + return val, diags +} + // dag.GraphNodeDotter impl. func (n *NodeApplyableOutput) DotNode(name string, opts *dag.DotOpts) *dag.DotNode { return &dag.DotNode{ diff --git a/internal/terraform/node_output_test.go b/internal/terraform/node_output_test.go index 042ce190fc..c86a4eeadf 100644 --- a/internal/terraform/node_output_test.go +++ b/internal/terraform/node_output_test.go @@ -25,7 +25,7 @@ func TestNodeApplyableOutputExecute_knownValue(t *testing.T) { ctx.ChecksState = checks.NewState(nil) ctx.DeferralsState = deferring.NewDeferred(false) - config := &configs.Output{Name: "map-output"} + config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType} addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} val := cty.MapVal(map[string]cty.Value{ @@ -58,7 +58,7 @@ func TestNodeApplyableOutputExecute_knownValue(t *testing.T) { func TestNodeApplyableOutputExecute_noState(t *testing.T) { ctx := new(MockEvalContext) - config := &configs.Output{Name: "map-output"} + config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType} addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} val := cty.MapVal(map[string]cty.Value{ @@ -86,6 +86,7 @@ func TestNodeApplyableOutputExecute_invalidDependsOn(t *testing.T) { hcl.TraverseAttr{Name: "bar"}, }, }, + ConstraintType: cty.DynamicPseudoType, } addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} @@ -108,7 +109,7 @@ func TestNodeApplyableOutputExecute_sensitiveValueNotOutput(t *testing.T) { ctx.StateState = states.NewState().SyncWrapper() ctx.ChecksState = checks.NewState(nil) - config := &configs.Output{Name: "map-output"} + config := &configs.Output{Name: "map-output", ConstraintType: cty.DynamicPseudoType} addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} val := cty.MapVal(map[string]cty.Value{ @@ -132,8 +133,9 @@ func TestNodeApplyableOutputExecute_sensitiveValueAndOutput(t *testing.T) { ctx.DeferralsState = deferring.NewDeferred(false) config := &configs.Output{ - Name: "map-output", - Sensitive: true, + Name: "map-output", + Sensitive: true, + ConstraintType: cty.DynamicPseudoType, } addr := addrs.OutputValue{Name: config.Name}.Absolute(addrs.RootModuleInstance) node := &NodeApplyableOutput{Config: config, Addr: addr} diff --git a/internal/terraform/node_root_variable.go b/internal/terraform/node_root_variable.go index c05c0c9ba0..f1b390475f 100644 --- a/internal/terraform/node_root_variable.go +++ b/internal/terraform/node_root_variable.go @@ -27,9 +27,8 @@ type NodeRootVariable struct { // set at all. RawValue *InputValue - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ValidateChecks should be set to true if the graph should run the user-defined validations for this variable + ValidateChecks bool // DestroyApply must be set to true when applying a destroy operation and // false otherwise. @@ -102,7 +101,7 @@ func (n *NodeRootVariable) Execute(ctx EvalContext, op walkOperation) tfdiags.Di }) } - if n.Planning { + if n.ValidateChecks { if checkState := ctx.Checks(); checkState.ConfigHasChecks(n.Addr.InModule(addrs.RootModule)) { ctx.Checks().ReportCheckableObjects( n.Addr.InModule(addrs.RootModule), @@ -110,19 +109,43 @@ func (n *NodeRootVariable) Execute(ctx EvalContext, op walkOperation) tfdiags.Di } } - finalVal, moreDiags := PrepareFinalInputVariableValue( - addr, - givenVal, - n.Config, - ) - diags = diags.Append(moreDiags) - if moreDiags.HasErrors() { - // No point in proceeding to validations then, because they'll - // probably fail trying to work with a value of the wrong type. - return diags - } + // During init we only want to prepare the final value for const variables. + if op == walkInit { + var finalVal cty.Value + if n.Config.Const { + var moreDiags tfdiags.Diagnostics + finalVal, moreDiags = PrepareFinalInputVariableValue( + addr, + givenVal, + n.Config, + ) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // No point in proceeding to validations then, because they'll + // probably fail trying to work with a value of the wrong type. + return diags + } + } else { + // All non-const variables are unknown during init. + finalVal = cty.UnknownVal(n.Config.Type) + } + ctx.NamedValues().SetInputVariableValue(addr, finalVal) - ctx.NamedValues().SetInputVariableValue(addr, finalVal) + } else { + finalVal, moreDiags := PrepareFinalInputVariableValue( + addr, + givenVal, + n.Config, + ) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + // No point in proceeding to validations then, because they'll + // probably fail trying to work with a value of the wrong type. + return diags + } + + ctx.NamedValues().SetInputVariableValue(addr, finalVal) + } // Custom validation rules are handled by a separate graph node of type // nodeVariableValidation, added by variableValidationTransformer. diff --git a/internal/terraform/node_root_variable_test.go b/internal/terraform/node_root_variable_test.go index 6881e0f91a..b60d0a1145 100644 --- a/internal/terraform/node_root_variable_test.go +++ b/internal/terraform/node_root_variable_test.go @@ -122,7 +122,7 @@ func TestNodeRootVariableExecute(t *testing.T) { Value: varValue, SourceType: ValueFromUnknown, }, - Planning: true, + ValidateChecks: true, } configAddr, validationRules, defnRange := n.variableValidationRules() validateN := &nodeVariableValidation{ diff --git a/internal/plans/planfile/planfile_test.go b/internal/terraform/planfile_test.go similarity index 88% rename from internal/plans/planfile/planfile_test.go rename to internal/terraform/planfile_test.go index 2342f9404a..ca0f59c6e3 100644 --- a/internal/plans/planfile/planfile_test.go +++ b/internal/terraform/planfile_test.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package planfile +package terraform import ( "path/filepath" @@ -16,13 +16,14 @@ import ( "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/plans/planfile" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" tfversion "github.com/hashicorp/terraform/version" ) func TestRoundtrip(t *testing.T) { - fixtureDir := filepath.Join("testdata", "test-config") + fixtureDir := filepath.Join("testdata", "planfile", "test-config") loader, err := configload.NewLoader(&configload.Config{ ModulesDir: filepath.Join(fixtureDir, ".terraform", "modules"), }) @@ -30,9 +31,9 @@ func TestRoundtrip(t *testing.T) { t.Fatal(err) } - _, snapIn, diags := loader.LoadConfigWithSnapshot(fixtureDir) + _, snapIn, diags := testLoadWithSnapshot(fixtureDir, loader, nil) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } // Just a minimal state file so we can test that it comes out again at all. @@ -92,7 +93,7 @@ func TestRoundtrip(t *testing.T) { planFn := filepath.Join(t.TempDir(), "tfplan") - err = Create(planFn, CreateArgs{ + err = planfile.Create(planFn, planfile.CreateArgs{ ConfigSnapshot: snapIn, PreviousRunStateFile: prevStateFileIn, StateFile: stateFileIn, @@ -103,7 +104,7 @@ func TestRoundtrip(t *testing.T) { t.Fatalf("failed to create plan file: %s", err) } - wpf, err := OpenWrapped(planFn) + wpf, err := planfile.OpenWrapped(planFn) if err != nil { t.Fatalf("failed to open plan file for reading: %s", err) } @@ -155,16 +156,6 @@ func TestRoundtrip(t *testing.T) { } }) - t.Run("ReadConfig", func(t *testing.T) { - // Reading from snapshots is tested in the configload package, so - // here we'll just test that we can successfully do it, to see if the - // glue code in _this_ package is correct. - _, diags := pr.ReadConfig(false) - if diags.HasErrors() { - t.Errorf("when reading config: %s", diags.Err()) - } - }) - t.Run("ReadDependencyLocks", func(t *testing.T) { locksOut, diags := pr.ReadDependencyLocks() if diags.HasErrors() { @@ -181,14 +172,14 @@ func TestRoundtrip(t *testing.T) { func TestWrappedError(t *testing.T) { // Open something that isn't a cloud or local planfile: should error wrongFile := "not a valid zip file" - _, err := OpenWrapped(filepath.Join("testdata", "test-config", "root.tf")) + _, err := planfile.OpenWrapped(filepath.Join("testdata", "planfile", "test-config", "root.tf")) if !strings.Contains(err.Error(), wrongFile) { t.Fatalf("expected %q, got %q", wrongFile, err) } // Open something that doesn't exist: should error missingFile := "no such file or directory" - _, err = OpenWrapped(filepath.Join("testdata", "absent.tfplan")) + _, err = planfile.OpenWrapped(filepath.Join("testdata", "planfile", "absent.tfplan")) if !strings.Contains(err.Error(), missingFile) { t.Fatalf("expected %q, got %q", missingFile, err) } @@ -196,7 +187,7 @@ func TestWrappedError(t *testing.T) { func TestWrappedCloud(t *testing.T) { // Loading valid cloud plan results in a wrapped cloud plan - wpf, err := OpenWrapped(filepath.Join("testdata", "cloudplan.json")) + wpf, err := planfile.OpenWrapped(filepath.Join("testdata", "planfile", "cloudplan.json")) if err != nil { t.Fatalf("failed to open valid cloud plan: %s", err) } diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 53071437f3..3eab0c60d8 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -26,6 +26,7 @@ import ( "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/registry" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" _ "github.com/hashicorp/terraform/internal/logging" ) @@ -67,7 +68,7 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), dir, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -79,17 +80,53 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config t.Fatalf("failed to refresh modules after installation: %s", err) } - config, snap, diags := loader.LoadConfigWithSnapshot(dir) + config, snap, diags := testLoadWithSnapshot(dir, loader, nil) if diags.HasErrors() { - t.Fatal(diags.Error()) + t.Fatal(diags.Err()) } return config, snap } +func testLoadWithSnapshot(dir string, loader *configload.Loader, vars InputValues) (*configs.Config, *configload.Snapshot, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + rootMod, configDiags := loader.LoadRootModule(dir) + if configDiags.HasErrors() { + diags = diags.Append(configDiags) + return nil, nil, diags + } + + walkerSnapshot, snap := loader.ModuleWalkerSnapshot() + config, buildDiags := BuildConfigWithGraph( + rootMod, + walkerSnapshot, + vars, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + diags = diags.Append(buildDiags) + return nil, nil, diags + } + + snapDiags := loader.AddRootModuleToSnapshot(snap, dir) + if snapDiags.HasErrors() { + diags = diags.Append(snapDiags) + return nil, nil, diags + } + + return config, snap, nil +} + // testModuleInline takes a map of path -> config strings and yields a config // structure with those files loaded from disk func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...configs.Option) *configs.Config { + return testModuleInlineWithVars(t, sources, nil, parserOpts...) +} + +// testModuleInlineWithVars is the same as testModuleInline but also allows passing in variable values to be used when loading the config. +func testModuleInlineWithVars(t testing.TB, sources map[string]string, vars InputValues, parserOpts ...configs.Option) *configs.Config { + t.Helper() cfgPath, err := filepath.EvalSymlinks(t.TempDir()) @@ -127,7 +164,7 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con // Test modules usually do not refer to remote sources, and for local // sources only this ultimately just records all of the module paths // in a JSON file so that we can load them below. - inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) _, instDiags := inst.InstallModules(context.Background(), cfgPath, "tests", true, false, initwd.ModuleInstallHooksImpl{}) if instDiags.HasErrors() { t.Fatal(instDiags.Err()) @@ -139,12 +176,65 @@ func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...con t.Fatalf("failed to refresh modules after installation: %s", err) } - config, diags := loader.LoadConfigWithTests(cfgPath, "tests") + rootMod, hclDiags := loader.LoadRootModuleWithTests(cfgPath, "tests") + if hclDiags.HasErrors() { + t.Fatal(hclDiags.Error()) + } + + config, buildDiags := BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + vars, + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + if buildDiags.HasErrors() { + t.Fatal(buildDiags.Err()) + } + + return config +} + +func testRootModuleInline(t testing.TB, sources map[string]string) *configs.Module { + t.Helper() + + cfgPath, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + for path, configStr := range sources { + dir := filepath.Dir(path) + if dir != "." { + err := os.MkdirAll(filepath.Join(cfgPath, dir), os.FileMode(0777)) + if err != nil { + t.Fatalf("Error creating subdir: %s", err) + } + } + // Write the configuration + cfgF, err := os.Create(filepath.Join(cfgPath, path)) + if err != nil { + t.Fatalf("Error creating temporary file for config: %s", err) + } + + _, err = io.Copy(cfgF, strings.NewReader(configStr)) + cfgF.Close() + if err != nil { + t.Fatalf("Error creating temporary file for config: %s", err) + } + } + + loader, cleanup := configload.NewLoaderForTests(t) + defer cleanup() + + // We need to be able to exercise experimental features in our integration tests. + loader.AllowLanguageExperiments(true) + + mod, diags := loader.Parser().LoadConfigDir(cfgPath) if diags.HasErrors() { t.Fatal(diags.Error()) } - return config + return mod } // testSetResourceInstanceCurrent is a helper function for tests that sets a Current, diff --git a/internal/terraform/testdata/apply-output-type-constraint/apply-output-type-constraint.tf b/internal/terraform/testdata/apply-output-type-constraint/apply-output-type-constraint.tf new file mode 100644 index 0000000000..604e31bc32 --- /dev/null +++ b/internal/terraform/testdata/apply-output-type-constraint/apply-output-type-constraint.tf @@ -0,0 +1,20 @@ +output "string" { + type = string + value = true +} + +output "object_default" { + type = object({ + name = optional(string, "Bart") + }) + value = {} +} + +output "object_override" { + type = object({ + name = optional(string, "Bart") + }) + value = { + name = "Lisa" + } +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/already-installed-now-invalid/.terraform/modules/modules.json new file mode 100644 index 0000000000..a09a3f4826 --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/.terraform/modules/modules.json @@ -0,0 +1,7 @@ +{ + "Modules": [ + { "Key": "", "Source": "", "Dir": "." }, + { "Key": "foo", "Source": "./foo", "Dir": "foo" }, + { "Key": "foo.bar", "Source": "./bar", "Dir": "foo/bar" } + ] +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/bar/main.tf b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/bar/main.tf new file mode 100644 index 0000000000..48b5e2e067 --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/bar/main.tf @@ -0,0 +1,3 @@ +output "hello" { + value = "Hello from foo/bar" +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/main.tf b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/main.tf new file mode 100644 index 0000000000..9fba57235c --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/foo/main.tf @@ -0,0 +1,3 @@ +module "bar" { + source = "${path.module}/bar" +} diff --git a/internal/terraform/testdata/config-graph/already-installed-now-invalid/root.tf b/internal/terraform/testdata/config-graph/already-installed-now-invalid/root.tf new file mode 100644 index 0000000000..020494e84d --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed-now-invalid/root.tf @@ -0,0 +1,3 @@ +module "foo" { + source = "./foo" +} diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_a.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_a.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_a.tf rename to internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_a.tf diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_c/child_c.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_c/child_c.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_a/child_c/child_c.tf rename to internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_a/child_c/child_c.tf diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_b.child_d/child_d.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b.child_d/child_d.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_b.child_d/child_d.tf rename to internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b.child_d/child_d.tf diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/child_b/child_b.tf b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b/child_b.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/child_b/child_b.tf rename to internal/terraform/testdata/config-graph/already-installed/.terraform/modules/child_b/child_b.tf diff --git a/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/modules.json new file mode 100644 index 0000000000..957a8aebed --- /dev/null +++ b/internal/terraform/testdata/config-graph/already-installed/.terraform/modules/modules.json @@ -0,0 +1,32 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "testdata/config-graph/already-installed" + }, + { + "Key": "child_a", + "Source": "example.com/foo/bar_a/baz", + "Version": "1.0.1", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_a" + }, + { + "Key": "child_b", + "Source": "example.com/foo/bar_b/baz", + "Version": "1.0.0", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_b" + }, + { + "Key": "child_a.child_c", + "Source": "./child_c", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_a/child_c" + }, + { + "Key": "child_b.child_d", + "Source": "example.com/foo/bar_d/baz", + "Version": "1.2.0", + "Dir": "testdata/config-graph/already-installed/.terraform/modules/child_b.child_d" + } + ] +} diff --git a/internal/plans/planfile/testdata/test-config/root.tf b/internal/terraform/testdata/config-graph/already-installed/root.tf similarity index 100% rename from internal/plans/planfile/testdata/test-config/root.tf rename to internal/terraform/testdata/config-graph/already-installed/root.tf diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/child-provider-child-count/.terraform/modules/modules.json new file mode 100644 index 0000000000..5da6d5aac1 --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/.terraform/modules/modules.json @@ -0,0 +1,19 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "." + }, + { + "Key": "child", + "Source": "./child", + "Dir": "testdata/config-graph/child-provider-child-count/child" + }, + { + "Key": "child.grandchild", + "Source": "../grandchild", + "Dir": "testdata/config-graph/child-provider-child-count/grandchild" + } + ] +} diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/child-provider-child-count.tf b/internal/terraform/testdata/config-graph/child-provider-child-count/child-provider-child-count.tf new file mode 100644 index 0000000000..5b39941a03 --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/child-provider-child-count.tf @@ -0,0 +1,4 @@ +module "child" { + source = "./child" + count = 1 +} diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/child/child-provider-child-count-child.tf b/internal/terraform/testdata/config-graph/child-provider-child-count/child/child-provider-child-count-child.tf new file mode 100644 index 0000000000..524742c3fc --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/child/child-provider-child-count-child.tf @@ -0,0 +1,7 @@ +provider "boop" { + blah = true +} + +module "grandchild" { + source = "../grandchild" +} diff --git a/internal/terraform/testdata/config-graph/child-provider-child-count/grandchild/child-provider-child-count-grandchild.tf b/internal/terraform/testdata/config-graph/child-provider-child-count/grandchild/child-provider-child-count-grandchild.tf new file mode 100644 index 0000000000..ccd9dcef9e --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-child-count/grandchild/child-provider-child-count-grandchild.tf @@ -0,0 +1 @@ +# Intentionally blank diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/.terraform/modules/modules.json new file mode 100644 index 0000000000..c70acf81dd --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/.terraform/modules/modules.json @@ -0,0 +1,19 @@ +{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "." + }, + { + "Key": "child", + "Source": "./child", + "Dir": "testdata/config-graph/child-provider-grandchild-count/child" + }, + { + "Key": "child.grandchild", + "Source": "../grandchild", + "Dir": "testdata/config-graph/child-provider-grandchild-count/grandchild" + } + ] +} diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child-provider-grandchild-count.tf b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child-provider-grandchild-count.tf new file mode 100644 index 0000000000..1f95749fa7 --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child-provider-grandchild-count.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child/child-provider-grandchild-count-child.tf b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child/child-provider-grandchild-count-child.tf new file mode 100644 index 0000000000..8d3fe1023d --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/child/child-provider-grandchild-count-child.tf @@ -0,0 +1,12 @@ +provider "boop" { + blah = true +} + +module "grandchild" { + source = "../grandchild" + + # grandchild's caller (this file) has a legacy nested provider block, but + # grandchild itself does not and so it's valid to use "count" here even + # though it wouldn't be valid to call "child" (this file) with "count". + count = 2 +} diff --git a/internal/terraform/testdata/config-graph/child-provider-grandchild-count/grandchild/child-provider-grandchild-count-grandchild.tf b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/grandchild/child-provider-grandchild-count-grandchild.tf new file mode 100644 index 0000000000..ccd9dcef9e --- /dev/null +++ b/internal/terraform/testdata/config-graph/child-provider-grandchild-count/grandchild/child-provider-grandchild-count-grandchild.tf @@ -0,0 +1 @@ +# Intentionally blank diff --git a/internal/terraform/testdata/config-graph/invalid-names-in-submodules/.terraform/modules/modules.json b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/.terraform/modules/modules.json new file mode 100644 index 0000000000..5f253d5439 --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/.terraform/modules/modules.json @@ -0,0 +1,14 @@ +{ + "Modules": [ + { + "Key": "test", + "Source": "./sub", + "Dir": "testdata/config-graph/invalid-names-in-submodules/sub" + }, + { + "Key": "", + "Source": "", + "Dir": "." + } + ] +} \ No newline at end of file diff --git a/internal/terraform/testdata/config-graph/invalid-names-in-submodules/main.tf b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/main.tf new file mode 100644 index 0000000000..3fbc8c68cf --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/main.tf @@ -0,0 +1,3 @@ +module "test" { + source = "./sub" +} diff --git a/internal/terraform/testdata/config-graph/invalid-names-in-submodules/sub/main.tf b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/sub/main.tf new file mode 100644 index 0000000000..aacab2c441 --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names-in-submodules/sub/main.tf @@ -0,0 +1,7 @@ +resource "aws-_foo" "test" { + +} + +data "aws-_bar" "test" { + +} diff --git a/internal/terraform/testdata/config-graph/invalid-names/main.tf b/internal/terraform/testdata/config-graph/invalid-names/main.tf new file mode 100644 index 0000000000..d4eee4c3e2 --- /dev/null +++ b/internal/terraform/testdata/config-graph/invalid-names/main.tf @@ -0,0 +1,3 @@ +provider "42_bad!" { + invalid_provider_name = "yes" +} diff --git a/internal/plans/planfile/testdata/cloudplan.json b/internal/terraform/testdata/planfile/cloudplan.json similarity index 100% rename from internal/plans/planfile/testdata/cloudplan.json rename to internal/terraform/testdata/planfile/cloudplan.json diff --git a/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_a.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_a.tf new file mode 100644 index 0000000000..2f4d0f1a0b --- /dev/null +++ b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_a.tf @@ -0,0 +1,4 @@ + +module "child_c" { + source = "./child_c" +} diff --git a/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_c/child_c.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_c/child_c.tf new file mode 100644 index 0000000000..785d98d98a --- /dev/null +++ b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_a/child_c/child_c.tf @@ -0,0 +1,4 @@ + +output "hello" { + value = "Hello from child_c" +} diff --git a/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b.child_d/child_d.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b.child_d/child_d.tf new file mode 100644 index 0000000000..145576a365 --- /dev/null +++ b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b.child_d/child_d.tf @@ -0,0 +1,4 @@ + +output "hello" { + value = "Hello from child_d" +} diff --git a/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b/child_b.tf b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b/child_b.tf new file mode 100644 index 0000000000..4a1b247d39 --- /dev/null +++ b/internal/terraform/testdata/planfile/test-config/.terraform/modules/child_b/child_b.tf @@ -0,0 +1,5 @@ + +module "child_d" { + source = "example.com/foo/bar_d/baz" + # Intentionally no version here +} diff --git a/internal/plans/planfile/testdata/test-config/.terraform/modules/modules.json b/internal/terraform/testdata/planfile/test-config/.terraform/modules/modules.json similarity index 57% rename from internal/plans/planfile/testdata/test-config/.terraform/modules/modules.json rename to internal/terraform/testdata/planfile/test-config/.terraform/modules/modules.json index ba691877ff..ebd4f0f778 100644 --- a/internal/plans/planfile/testdata/test-config/.terraform/modules/modules.json +++ b/internal/terraform/testdata/planfile/test-config/.terraform/modules/modules.json @@ -3,30 +3,30 @@ { "Key": "", "Source": "", - "Dir": "testdata/test-config" + "Dir": "testdata/planfile/test-config" }, { "Key": "child_a", "Source": "example.com/foo/bar_a/baz", "Version": "1.0.1", - "Dir": "testdata/test-config/.terraform/modules/child_a" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_a" }, { "Key": "child_b", "Source": "example.com/foo/bar_b/baz", "Version": "1.0.0", - "Dir": "testdata/test-config/.terraform/modules/child_b" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_b" }, { "Key": "child_a.child_c", "Source": "./child_c", - "Dir": "testdata/test-config/.terraform/modules/child_a/child_c" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_a/child_c" }, { "Key": "child_b.child_d", "Source": "example.com/foo/bar_d/baz", "Version": "1.2.0", - "Dir": "testdata/test-config/.terraform/modules/child_b.child_d" + "Dir": "testdata/planfile/test-config/.terraform/modules/child_b.child_d" } ] } diff --git a/internal/terraform/testdata/planfile/test-config/root.tf b/internal/terraform/testdata/planfile/test-config/root.tf new file mode 100644 index 0000000000..8a4473942d --- /dev/null +++ b/internal/terraform/testdata/planfile/test-config/root.tf @@ -0,0 +1,10 @@ + +module "child_a" { + source = "example.com/foo/bar_a/baz" + version = ">= 1.0.0" +} + +module "child_b" { + source = "example.com/foo/bar_b/baz" + version = ">= 1.0.0" +} diff --git a/internal/initwd/testing.go b/internal/terraform/testing/config.go similarity index 66% rename from internal/initwd/testing.go rename to internal/terraform/testing/config.go index d2c9f7f5c6..8d3b9ab953 100644 --- a/internal/initwd/testing.go +++ b/internal/terraform/testing/config.go @@ -1,7 +1,7 @@ // Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 -package initwd +package testing import ( "context" @@ -9,27 +9,31 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" + "github.com/hashicorp/terraform/internal/initwd" "github.com/hashicorp/terraform/internal/registry" + "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) // LoadConfigForTests is a convenience wrapper around configload.NewLoaderForTests, -// ModuleInstaller.InstallModules and configload.Loader.LoadConfig that allows -// a test configuration to be loaded in a single step. +// initwd.ModuleInstaller.InstallModules and terraform.BuildConfigWithGraph that +// allows a test configuration to be loaded in a single step using the graph-based +// configuration loading mechanism. // // If module installation fails, t.Fatal (or similar) is called to halt // execution of the test, under the assumption that installation failures are // not expected. If installation failures _are_ expected then use -// NewLoaderForTests and work with the loader object directly. If module -// installation succeeds but generates warnings, these warnings are discarded. +// configload.NewLoaderForTests and work with the loader object directly. If +// module installation succeeds but generates warnings, these warnings are +// discarded. // // If installation succeeds but errors are detected during loading then a // possibly-incomplete config is returned along with error diagnostics. The // test run is not aborted in this case, so that the caller can make assertions // against the returned diagnostics. // -// As with NewLoaderForTests, a cleanup function is returned which must be -// called before the test completes in order to remove the temporary +// As with configload.NewLoaderForTests, a cleanup function is returned which +// must be called before the test completes in order to remove the temporary // modules directory. func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs.Config, *configload.Loader, func(), tfdiags.Diagnostics) { t.Helper() @@ -37,9 +41,9 @@ func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs var diags tfdiags.Diagnostics loader, cleanup := configload.NewLoaderForTests(t) - inst := NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil)) + inst := initwd.NewModuleInstaller(loader.ModulesDir(), loader, registry.NewClient(nil, nil), nil) - _, moreDiags := inst.InstallModules(context.Background(), rootDir, testsDir, true, false, ModuleInstallHooksImpl{}) + _, moreDiags := inst.InstallModules(context.Background(), rootDir, testsDir, true, false, initwd.ModuleInstallHooksImpl{}) diags = diags.Append(moreDiags) if diags.HasErrors() { cleanup() @@ -53,8 +57,20 @@ func LoadConfigForTests(t *testing.T, rootDir string, testsDir string) (*configs t.Fatalf("failed to refresh modules after installation: %s", err) } - config, hclDiags := loader.LoadConfigWithTests(rootDir, testsDir) + rootMod, hclDiags := loader.LoadRootModuleWithTests(rootDir, testsDir) diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { + return nil, loader, cleanup, diags + } + + config, buildDiags := terraform.BuildConfigWithGraph( + rootMod, + loader.ModuleWalker(), + nil, // No input variables for test configs + configs.MockDataLoaderFunc(loader.LoadExternalMockData), + ) + diags = diags.Append(buildDiags) + return config, loader, cleanup, diags } diff --git a/internal/terraform/transform_filter.go b/internal/terraform/transform_filter.go new file mode 100644 index 0000000000..2feb115ac2 --- /dev/null +++ b/internal/terraform/transform_filter.go @@ -0,0 +1,42 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "github.com/hashicorp/terraform/internal/dag" +) + +// TransformFilter is a GraphTransformer that filters out nodes from the graph based on a provided function. The Keep function should return true for nodes that should be kept in the graph, and false for nodes that should be removed. The transformer will mark all nodes that the node to keep depends on as well, ensuring that the resulting graph is still valid. +type TransformFilter struct { + Keep func(node dag.Vertex) bool +} + +var _ GraphTransformer = (*TransformFilter)(nil) + +func (t *TransformFilter) Transform(g *Graph) error { + // Partition vertices into kept and candidates for removal. + var kept []dag.Vertex + var removalCandidates []dag.Vertex + for _, v := range g.Vertices() { + if t.Keep(v) { + kept = append(kept, v) + } else { + removalCandidates = append(removalCandidates, v) + } + } + + // Also keep all ancestors (transitive dependencies) of the kept + // nodes so the resulting graph stays valid. + ancestors := g.Ancestors(kept...) + + // Remove every vertex that isn't explicitly kept and isn't an + // ancestor of a kept node. + for _, v := range removalCandidates { + if !ancestors.Include(v) { + g.Remove(v) + } + } + + return nil +} diff --git a/internal/terraform/transform_filter_test.go b/internal/terraform/transform_filter_test.go new file mode 100644 index 0000000000..6fff7182aa --- /dev/null +++ b/internal/terraform/transform_filter_test.go @@ -0,0 +1,386 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "strings" + "testing" + + "github.com/hashicorp/terraform/internal/dag" +) + +func TestTransformFilter(t *testing.T) { + t.Run("empty graph", func(t *testing.T) { + var g Graph + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return true + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + if actual != "" { + t.Fatalf("expected empty graph, got:\n%s", actual) + } + }) + + t.Run("keep all", func(t *testing.T) { + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return true + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("remove all", func(t *testing.T) { + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return false + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + if actual != "" { + t.Fatalf("expected empty graph, got:\n%s", actual) + } + }) + + t.Run("keep node preserves its dependencies", func(t *testing.T) { + // a -> b -> c + // Keep only "a"; "b" and "c" should be preserved as ancestors. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("keep leaf removes dependents", func(t *testing.T) { + // a -> b -> c + // Keep only "c"; "a" and "b" are not ancestors of "c" so they + // should be removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "c" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := "c" + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("keep middle preserves dependencies and removes dependents", func(t *testing.T) { + // a -> b -> c + // Keep "b"; "c" is an ancestor and stays, "a" depends on "b" + // but is not an ancestor so it is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "b" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +b + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("diamond keep root preserves all", func(t *testing.T) { + // a -> b -> d + // a -> c -> d + // Keep "a"; everything is an ancestor of "a" so nothing is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Add("d") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("a", "c")) + g.Connect(dag.BasicEdge("b", "d")) + g.Connect(dag.BasicEdge("c", "d")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b + c +b + d +c + d +d +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("diamond keep one branch", func(t *testing.T) { + // a -> b -> d + // a -> c -> d + // Keep "b"; "d" is an ancestor of "b" so it stays. "a" and "c" + // are not ancestors of "b" so they are removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Add("d") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("a", "c")) + g.Connect(dag.BasicEdge("b", "d")) + g.Connect(dag.BasicEdge("c", "d")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "b" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +b + d +d +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("disconnected nodes are removed", func(t *testing.T) { + // a -> b, c (standalone) + // Keep "a"; "b" is preserved as ancestor, "c" has no connection + // and is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "b")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("multiple kept nodes merge their ancestors", func(t *testing.T) { + // a -> b -> d + // c -> d + // Keep "a" and "c"; their combined ancestors are "b" and "d", + // so the entire graph is preserved. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Add("d") + g.Connect(dag.BasicEdge("a", "b")) + g.Connect(dag.BasicEdge("b", "d")) + g.Connect(dag.BasicEdge("c", "d")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + s := v.(string) + return s == "a" || s == "c" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + b +b + d +c + d +d +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("shared dependency kept through one branch", func(t *testing.T) { + // a -> c + // b -> c + // Keep "a"; "c" is an ancestor and stays, "b" is removed. + var g Graph + g.Add("a") + g.Add("b") + g.Add("c") + g.Connect(dag.BasicEdge("a", "c")) + g.Connect(dag.BasicEdge("b", "c")) + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return v.(string) == "a" + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +a + c +c +`) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("single node kept", func(t *testing.T) { + var g Graph + g.Add("a") + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return true + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := "a" + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } + }) + + t.Run("single node removed", func(t *testing.T) { + var g Graph + g.Add("a") + + tf := &TransformFilter{ + Keep: func(v dag.Vertex) bool { + return false + }, + } + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + if actual != "" { + t.Fatalf("expected empty graph, got:\n%s", actual) + } + }) +} diff --git a/internal/terraform/transform_module_install.go b/internal/terraform/transform_module_install.go new file mode 100644 index 0000000000..175ebc8574 --- /dev/null +++ b/internal/terraform/transform_module_install.go @@ -0,0 +1,48 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "log" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/dag" +) + +type ModuleTransformer struct { + Config *configs.Config + Walker configs.ModuleWalker +} + +func (t *ModuleTransformer) Transform(graph *Graph) error { + if t.Config == nil { + return nil + } + + for _, call := range t.Config.Module.ModuleCalls { + instancePath := graph.Path.Child(call.Name, addrs.NoKey) + + err := t.transform(graph, t.Config, instancePath, call) + if err != nil { + return err + } + } + + return nil +} + +func (t *ModuleTransformer) transform(graph *Graph, cfg *configs.Config, path addrs.ModuleInstance, modCall *configs.ModuleCall) error { + n := &nodeInstallModule{ + Addr: path, + ModuleCall: modCall, + Parent: cfg, + Walker: t.Walker, + } + var installNode dag.Vertex = n + graph.Add(installNode) + log.Printf("[TRACE] ModuleTransformer: Added %s as %T", path, installNode) + + return nil +} diff --git a/internal/terraform/transform_module_variable.go b/internal/terraform/transform_module_variable.go index 0dc19eb122..fe7987467e 100644 --- a/internal/terraform/transform_module_variable.go +++ b/internal/terraform/transform_module_variable.go @@ -29,9 +29,12 @@ import ( type ModuleVariableTransformer struct { Config *configs.Config - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ModuleOnly, if true, makes the transformer only process the + // variables in the current module, skipping any child modules. + ModuleOnly bool + + // ValidateChecks should be set to true if the graph should run the user-defined validations for child module variables + ValidateChecks bool // DestroyApply must be set to true when applying a destroy operation and // false otherwise. @@ -39,7 +42,11 @@ type ModuleVariableTransformer struct { } func (t *ModuleVariableTransformer) Transform(g *Graph) error { - return t.transform(g, nil, t.Config) + if t.ModuleOnly && t.Config.Parent != nil { + return t.transformSingle(g, t.Config.Parent, t.Config) + } else { + return t.transform(g, nil, t.Config) + } } func (t *ModuleVariableTransformer) transform(g *Graph, parent, c *configs.Config) error { @@ -114,11 +121,11 @@ func (t *ModuleVariableTransformer) transformSingle(g *Graph, parent, c *configs Addr: addrs.InputVariable{ Name: v.Name, }, - Module: c.Path, - Config: v, - Expr: expr, - Planning: t.Planning, - DestroyApply: t.DestroyApply, + Module: c.Path, + Config: v, + Expr: expr, + ValidateChecks: t.ValidateChecks, + DestroyApply: t.DestroyApply, } g.Add(node) } diff --git a/internal/terraform/transform_variable.go b/internal/terraform/transform_variable.go index cba3191319..4c85fba185 100644 --- a/internal/terraform/transform_variable.go +++ b/internal/terraform/transform_variable.go @@ -19,9 +19,8 @@ type RootVariableTransformer struct { RawValues InputValues - // Planning must be set to true when building a planning graph, and must be - // false when building an apply graph. - Planning bool + // ValidateChecks should be set to true if the graph should run the user-defined validations for root module variables + ValidateChecks bool // DestroyApply must be set to true when applying a destroy operation and // false otherwise. @@ -44,10 +43,10 @@ func (t *RootVariableTransformer) Transform(g *Graph) error { Addr: addrs.InputVariable{ Name: v.Name, }, - Config: v, - RawValue: t.RawValues[v.Name], - Planning: t.Planning, - DestroyApply: t.DestroyApply, + Config: v, + RawValue: t.RawValues[v.Name], + ValidateChecks: t.ValidateChecks, + DestroyApply: t.DestroyApply, } g.Add(node) } diff --git a/internal/terraform/walkoperation_string.go b/internal/terraform/walkoperation_string.go index 20a8220844..5500ba0817 100644 --- a/internal/terraform/walkoperation_string.go +++ b/internal/terraform/walkoperation_string.go @@ -16,11 +16,12 @@ func _() { _ = x[walkDestroy-5] _ = x[walkImport-6] _ = x[walkEval-7] + _ = x[walkInit-8] } -const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEval" +const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEvalwalkInit" -var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84} +var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84, 92} func (i walkOperation) String() string { idx := int(i) - 0 diff --git a/version/VERSION b/version/VERSION index 9a4866bbce..1f0d2f3351 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -1.15.0-dev +1.16.0-dev