@intentius/chant-lexicon-terraform 0.57.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composites/terraform-adopt-op.d.ts +126 -0
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -0
- package/dist/composites/terraform-apply-op.d.ts +63 -16
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-watch-op.d.ts +80 -36
- package/dist/composites/terraform-watch-op.d.ts.map +1 -1
- package/dist/config.d.ts +49 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +256 -16
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/hcl/descend.d.ts +98 -0
- package/dist/hcl/descend.d.ts.map +1 -0
- package/dist/hcl/marker.d.ts +130 -0
- package/dist/hcl/marker.d.ts.map +1 -0
- package/dist/hcl/parse.d.ts +142 -9
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/references.d.ts +76 -0
- package/dist/hcl/references.d.ts.map +1 -0
- package/dist/hcl/roots.d.ts +22 -3
- package/dist/hcl/roots.d.ts.map +1 -1
- package/dist/hcl/suppressions.d.ts +54 -0
- package/dist/hcl/suppressions.d.ts.map +1 -0
- package/dist/hcl/value.d.ts +77 -0
- package/dist/hcl/value.d.ts.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +32 -5
- package/dist/lint/audit-catalog.d.ts +3 -2
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +68 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/blocks.d.ts +53 -0
- package/dist/lint/post-synth/blocks.d.ts.map +1 -0
- package/dist/lint/post-synth/fixtures/load.d.ts +37 -0
- package/dist/lint/post-synth/fixtures/load.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/module-source.d.ts +38 -0
- package/dist/lint/post-synth/module-source.d.ts.map +1 -0
- package/dist/lint/post-synth/scope.d.ts +65 -0
- package/dist/lint/post-synth/scope.d.ts.map +1 -0
- package/dist/lint/post-synth/tf001.d.ts +18 -0
- package/dist/lint/post-synth/tf001.d.ts.map +1 -1
- package/dist/lint/post-synth/tf002.d.ts +32 -0
- package/dist/lint/post-synth/tf002.d.ts.map +1 -0
- package/dist/lint/post-synth/tf003.d.ts +22 -0
- package/dist/lint/post-synth/tf003.d.ts.map +1 -0
- package/dist/lint/post-synth/tf004.d.ts +22 -0
- package/dist/lint/post-synth/tf004.d.ts.map +1 -0
- package/dist/lint/post-synth/tf005.d.ts +28 -0
- package/dist/lint/post-synth/tf005.d.ts.map +1 -0
- package/dist/lint/post-synth/tf006.d.ts +21 -0
- package/dist/lint/post-synth/tf006.d.ts.map +1 -0
- package/dist/lint/post-synth/tf007.d.ts +21 -0
- package/dist/lint/post-synth/tf007.d.ts.map +1 -0
- package/dist/lint/post-synth/tf008.d.ts +22 -0
- package/dist/lint/post-synth/tf008.d.ts.map +1 -0
- package/dist/lint/post-synth/tf009.d.ts +24 -0
- package/dist/lint/post-synth/tf009.d.ts.map +1 -0
- package/dist/lint/post-synth/tf010.d.ts +19 -0
- package/dist/lint/post-synth/tf010.d.ts.map +1 -0
- package/dist/lint/post-synth/tf011.d.ts +18 -0
- package/dist/lint/post-synth/tf011.d.ts.map +1 -0
- package/dist/lint/post-synth/tf012.d.ts +17 -0
- package/dist/lint/post-synth/tf012.d.ts.map +1 -0
- package/dist/lint/post-synth/tf013.d.ts +21 -0
- package/dist/lint/post-synth/tf013.d.ts.map +1 -0
- package/dist/lint/post-synth/tf014.d.ts +25 -0
- package/dist/lint/post-synth/tf014.d.ts.map +1 -0
- package/dist/lint/post-synth/tf015.d.ts +21 -0
- package/dist/lint/post-synth/tf015.d.ts.map +1 -0
- package/dist/lint/post-synth/tf016.d.ts +30 -0
- package/dist/lint/post-synth/tf016.d.ts.map +1 -0
- package/dist/lint/post-synth/tf017.d.ts +19 -0
- package/dist/lint/post-synth/tf017.d.ts.map +1 -0
- package/dist/lint/post-synth/tf018.d.ts +20 -0
- package/dist/lint/post-synth/tf018.d.ts.map +1 -0
- package/dist/lint/post-synth/tf019.d.ts +25 -0
- package/dist/lint/post-synth/tf019.d.ts.map +1 -0
- package/dist/lint/post-synth/tf020.d.ts +38 -0
- package/dist/lint/post-synth/tf020.d.ts.map +1 -0
- package/dist/lint/post-synth/tf021.d.ts +41 -0
- package/dist/lint/post-synth/tf021.d.ts.map +1 -0
- package/dist/lint/post-synth/tf022.d.ts +24 -0
- package/dist/lint/post-synth/tf022.d.ts.map +1 -0
- package/dist/lint/post-synth/tf024.d.ts +24 -0
- package/dist/lint/post-synth/tf024.d.ts.map +1 -0
- package/dist/lint/post-synth/tf025.d.ts +29 -0
- package/dist/lint/post-synth/tf025.d.ts.map +1 -0
- package/dist/lint/post-synth/tf026.d.ts +21 -0
- package/dist/lint/post-synth/tf026.d.ts.map +1 -0
- package/dist/lint/rules/fixtures/load.d.ts +13 -0
- package/dist/lint/rules/fixtures/load.d.ts.map +1 -0
- package/dist/lint/rules/plan-before-apply.d.ts +11 -0
- package/dist/lint/rules/plan-before-apply.d.ts.map +1 -1
- package/dist/lint/secret-shape.d.ts +68 -0
- package/dist/lint/secret-shape.d.ts.map +1 -0
- package/dist/live-ownership.d.ts +55 -0
- package/dist/live-ownership.d.ts.map +1 -0
- package/dist/lsp/option-keys.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +24 -0
- package/dist/okf/rules/TF002.md +11 -0
- package/dist/okf/rules/TF003.md +11 -0
- package/dist/okf/rules/TF004.md +11 -0
- package/dist/okf/rules/TF005.md +11 -0
- package/dist/okf/rules/TF006.md +11 -0
- package/dist/okf/rules/TF007.md +11 -0
- package/dist/okf/rules/TF008.md +11 -0
- package/dist/okf/rules/TF009.md +11 -0
- package/dist/okf/rules/TF010.md +11 -0
- package/dist/okf/rules/TF011.md +11 -0
- package/dist/okf/rules/TF012.md +11 -0
- package/dist/okf/rules/TF013.md +11 -0
- package/dist/okf/rules/TF014.md +11 -0
- package/dist/okf/rules/TF015.md +11 -0
- package/dist/okf/rules/TF016.md +11 -0
- package/dist/okf/rules/TF017.md +11 -0
- package/dist/okf/rules/TF018.md +11 -0
- package/dist/okf/rules/TF019.md +11 -0
- package/dist/okf/rules/TF020.md +11 -0
- package/dist/okf/rules/TF021.md +11 -0
- package/dist/okf/rules/TF022.md +11 -0
- package/dist/okf/rules/TF024.md +11 -0
- package/dist/okf/rules/TF025.md +11 -0
- package/dist/okf/rules/TF026.md +11 -0
- package/dist/op/activities/index.d.ts +13 -6
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/live-detect.d.ts +51 -0
- package/dist/op/activities/live-detect.d.ts.map +1 -0
- package/dist/op/activities/terraform.d.ts +445 -17
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +148 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/adoption.d.ts +118 -0
- package/dist/op/adoption.d.ts.map +1 -0
- package/dist/op/builders.d.ts +46 -6
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/resolve-root-mode.d.ts +45 -0
- package/dist/op/resolve-root-mode.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/blocks.ts +109 -0
- package/dist/rules/module-source.ts +121 -0
- package/dist/rules/plan-before-apply.ts +11 -0
- package/dist/rules/scope.ts +107 -0
- package/dist/rules/tf001.ts +32 -8
- package/dist/rules/tf002.ts +151 -0
- package/dist/rules/tf003.ts +71 -0
- package/dist/rules/tf004.ts +63 -0
- package/dist/rules/tf005.ts +76 -0
- package/dist/rules/tf006.ts +53 -0
- package/dist/rules/tf007.ts +78 -0
- package/dist/rules/tf008.ts +75 -0
- package/dist/rules/tf009.ts +59 -0
- package/dist/rules/tf010.ts +51 -0
- package/dist/rules/tf011.ts +49 -0
- package/dist/rules/tf012.ts +47 -0
- package/dist/rules/tf013.ts +63 -0
- package/dist/rules/tf014.ts +72 -0
- package/dist/rules/tf015.ts +63 -0
- package/dist/rules/tf016.ts +75 -0
- package/dist/rules/tf017.ts +50 -0
- package/dist/rules/tf018.ts +72 -0
- package/dist/rules/tf019.ts +72 -0
- package/dist/rules/tf020.ts +123 -0
- package/dist/rules/tf021.ts +126 -0
- package/dist/rules/tf022.ts +61 -0
- package/dist/rules/tf024.ts +70 -0
- package/dist/rules/tf025.ts +101 -0
- package/dist/rules/tf026.ts +80 -0
- package/dist/skills/chant-terraform.md +1 -1
- package/package.json +7 -2
- package/src/__fixtures__/live/main.tf +28 -0
- package/src/__fixtures__/live-adopt/estate.chdf.hcl +6 -0
- package/src/__fixtures__/live-adopt/main.tf +21 -0
- package/src/__fixtures__/live-estate/README.md +58 -0
- package/src/__fixtures__/live-estate/compute.tf +10 -0
- package/src/__fixtures__/live-estate/estate.chdf.hcl +1 -0
- package/src/__fixtures__/live-estate/logs.tf +8 -0
- package/src/__fixtures__/live-estate/network.tf +24 -0
- package/src/__fixtures__/live-estate/rule.tf +11 -0
- package/src/__fixtures__/live-estate/unowned.tf +21 -0
- package/src/__fixtures__/live-estate/versions.tf +31 -0
- package/src/__fixtures__/live-ls.json +98 -0
- package/src/__fixtures__/live-plan.json +285 -0
- package/src/__fixtures__/live-sidecar/estate.chdf.hcl +3 -0
- package/src/__fixtures__/live-sidecar/main.tf +24 -0
- package/src/__fixtures__/module-tree/README.md +16 -0
- package/src/__fixtures__/module-tree/cycle/main.tf +3 -0
- package/src/__fixtures__/module-tree/cycle/modules/a/main.tf +9 -0
- package/src/__fixtures__/module-tree/cycle/modules/b/main.tf +9 -0
- package/src/__fixtures__/module-tree/missing/main.tf +9 -0
- package/src/__fixtures__/module-tree/outside/root/main.tf +9 -0
- package/src/__fixtures__/module-tree/root/main.tf +28 -0
- package/src/__fixtures__/module-tree/root/modules/cdn/main.tf +15 -0
- package/src/__fixtures__/module-tree/root/modules/cdn/modules/bucket/main.tf +11 -0
- package/src/__fixtures__/module-tree/shared/main.tf +5 -0
- package/src/__fixtures__/references/main.tf +56 -0
- package/src/codegen/lint-rules-page.test.ts +28 -0
- package/src/composites/terraform-adopt-op.acceptance.test.ts +166 -0
- package/src/composites/terraform-adopt-op.test.ts +170 -0
- package/src/composites/terraform-adopt-op.ts +197 -0
- package/src/composites/terraform-apply-op.acceptance.test.ts +183 -5
- package/src/composites/terraform-apply-op.test.ts +167 -15
- package/src/composites/terraform-apply-op.ts +105 -28
- package/src/composites/terraform-watch-op.test.ts +85 -26
- package/src/composites/terraform-watch-op.ts +130 -73
- package/src/config.ts +32 -3
- package/src/describe-resources.live.test.ts +598 -0
- package/src/describe-resources.test.ts +53 -0
- package/src/describe-resources.ts +811 -28
- package/src/hcl/descend.test.ts +162 -0
- package/src/hcl/descend.ts +273 -0
- package/src/hcl/marker.test.ts +147 -0
- package/src/hcl/marker.ts +235 -0
- package/src/hcl/parse.test.ts +69 -0
- package/src/hcl/parse.ts +249 -27
- package/src/hcl/references.test.ts +116 -0
- package/src/hcl/references.ts +172 -0
- package/src/hcl/roots.ts +59 -5
- package/src/hcl/suppressions.test.ts +147 -0
- package/src/hcl/suppressions.ts +204 -0
- package/src/hcl/value.test.ts +73 -0
- package/src/hcl/value.ts +106 -0
- package/src/index.ts +31 -1
- package/src/lint/audit-catalog.ts +264 -3
- package/src/lint/audit-lineage.ts +319 -1
- package/src/lint/audit.test.ts +40 -1
- package/src/lint/post-synth/blocks.ts +109 -0
- package/src/lint/post-synth/fixtures/TF001/negative.tf +28 -0
- package/src/lint/post-synth/fixtures/TF001/positive.tf +24 -0
- package/src/lint/post-synth/fixtures/TF002/negative.tf +17 -0
- package/src/lint/post-synth/fixtures/TF002/positive-legacy.tf +10 -0
- package/src/lint/post-synth/fixtures/TF002/positive-no-version.tf +12 -0
- package/src/lint/post-synth/fixtures/TF002/positive.tf +7 -0
- package/src/lint/post-synth/fixtures/TF003/negative.tf +10 -0
- package/src/lint/post-synth/fixtures/TF003/positive.tf +8 -0
- package/src/lint/post-synth/fixtures/TF004/negative-git.tf +3 -0
- package/src/lint/post-synth/fixtures/TF004/negative-local.tf +3 -0
- package/src/lint/post-synth/fixtures/TF004/negative.tf +4 -0
- package/src/lint/post-synth/fixtures/TF004/positive.tf +3 -0
- package/src/lint/post-synth/fixtures/TF005/negative-local.tf +3 -0
- package/src/lint/post-synth/fixtures/TF005/negative-registry.tf +3 -0
- package/src/lint/post-synth/fixtures/TF005/negative-sha.tf +3 -0
- package/src/lint/post-synth/fixtures/TF005/negative.tf +3 -0
- package/src/lint/post-synth/fixtures/TF005/positive-branch.tf +3 -0
- package/src/lint/post-synth/fixtures/TF005/positive-non-semver.tf +3 -0
- package/src/lint/post-synth/fixtures/TF005/positive.tf +3 -0
- package/src/lint/post-synth/fixtures/TF006/negative.tf +5 -0
- package/src/lint/post-synth/fixtures/TF006/positive-empty-default.tf +11 -0
- package/src/lint/post-synth/fixtures/TF006/positive.tf +6 -0
- package/src/lint/post-synth/fixtures/TF007/negative.tf +10 -0
- package/src/lint/post-synth/fixtures/TF007/positive.tf +9 -0
- package/src/lint/post-synth/fixtures/TF008/negative-variable.tf +10 -0
- package/src/lint/post-synth/fixtures/TF008/negative.tf +8 -0
- package/src/lint/post-synth/fixtures/TF008/positive-other-provider.tf +10 -0
- package/src/lint/post-synth/fixtures/TF008/positive.tf +5 -0
- package/src/lint/post-synth/fixtures/TF009/negative.tf +10 -0
- package/src/lint/post-synth/fixtures/TF009/positive.tf +4 -0
- package/src/lint/post-synth/fixtures/TF010/negative.tf +5 -0
- package/src/lint/post-synth/fixtures/TF010/positive.tf +4 -0
- package/src/lint/post-synth/fixtures/TF011/negative.tf +5 -0
- package/src/lint/post-synth/fixtures/TF011/positive-blank.tf +5 -0
- package/src/lint/post-synth/fixtures/TF011/positive.tf +4 -0
- package/src/lint/post-synth/fixtures/TF012/negative.tf +4 -0
- package/src/lint/post-synth/fixtures/TF012/positive.tf +3 -0
- package/src/lint/post-synth/fixtures/TF013/negative.tf +8 -0
- package/src/lint/post-synth/fixtures/TF013/positive-data.tf +8 -0
- package/src/lint/post-synth/fixtures/TF013/positive.tf +8 -0
- package/src/lint/post-synth/fixtures/TF014/negative/main.tf +26 -0
- package/src/lint/post-synth/fixtures/TF014/negative/modules/cdn/main.tf +19 -0
- package/src/lint/post-synth/fixtures/TF014/positive/main.tf +16 -0
- package/src/lint/post-synth/fixtures/TF014/positive/modules/cdn/main.tf +15 -0
- package/src/lint/post-synth/fixtures/TF015/negative/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF015/negative/modules/cdn/main.tf +16 -0
- package/src/lint/post-synth/fixtures/TF015/positive/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF015/positive/modules/cdn/main.tf +14 -0
- package/src/lint/post-synth/fixtures/TF016/negative-template.tf +7 -0
- package/src/lint/post-synth/fixtures/TF016/negative.tf +16 -0
- package/src/lint/post-synth/fixtures/TF016/positive-shared-file.tf +12 -0
- package/src/lint/post-synth/fixtures/TF016/positive.tf +13 -0
- package/src/lint/post-synth/fixtures/TF017/negative.tf +4 -0
- package/src/lint/post-synth/fixtures/TF017/positive.tf +5 -0
- package/src/lint/post-synth/fixtures/TF018/negative.tf +9 -0
- package/src/lint/post-synth/fixtures/TF018/positive-data.tf +9 -0
- package/src/lint/post-synth/fixtures/TF018/positive.tf +4 -0
- package/src/lint/post-synth/fixtures/TF019/negative.tf +14 -0
- package/src/lint/post-synth/fixtures/TF019/positive-output.tf +5 -0
- package/src/lint/post-synth/fixtures/TF019/positive.tf +16 -0
- package/src/lint/post-synth/fixtures/TF020/negative.tf +43 -0
- package/src/lint/post-synth/fixtures/TF020/positive.tf +32 -0
- package/src/lint/post-synth/fixtures/TF020/scoped/main.tf +21 -0
- package/src/lint/post-synth/fixtures/TF020/scoped/modules/cdn/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF021/negative.tf +31 -0
- package/src/lint/post-synth/fixtures/TF021/positive-length.tf +8 -0
- package/src/lint/post-synth/fixtures/TF021/positive.tf +8 -0
- package/src/lint/post-synth/fixtures/TF022/negative-locator.tf +7 -0
- package/src/lint/post-synth/fixtures/TF022/negative.tf +10 -0
- package/src/lint/post-synth/fixtures/TF022/positive-nested.tf +10 -0
- package/src/lint/post-synth/fixtures/TF022/positive.tf +6 -0
- package/src/lint/post-synth/fixtures/TF023/negative/main.tf +15 -0
- package/src/lint/post-synth/fixtures/TF023/positive/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF023/positive/state.json +24 -0
- package/src/lint/post-synth/fixtures/TF024/live-clean/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF024/live-with-backend/main.tf +14 -0
- package/src/lint/post-synth/fixtures/TF024/live-with-cloud/main.tf +16 -0
- package/src/lint/post-synth/fixtures/TF025/live-clean/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF025/live-with-workspace-ref/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF026/live-default/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF026/live-explicit-delete/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF026/live-keep/main.tf +13 -0
- package/src/lint/post-synth/fixtures/load.ts +108 -0
- package/src/lint/post-synth/index.ts +48 -0
- package/src/lint/post-synth/module-rules.test.ts +153 -0
- package/src/lint/post-synth/module-source.test.ts +75 -0
- package/src/lint/post-synth/module-source.ts +121 -0
- package/src/lint/post-synth/post-synth.test.ts +14 -15
- package/src/lint/post-synth/scope.ts +107 -0
- package/src/lint/post-synth/single-block.test.ts +263 -0
- package/src/lint/post-synth/suppression.test.ts +110 -0
- package/src/lint/post-synth/tf001.ts +32 -8
- package/src/lint/post-synth/tf002.test.ts +93 -0
- package/src/lint/post-synth/tf002.ts +151 -0
- package/src/lint/post-synth/tf003.test.ts +75 -0
- package/src/lint/post-synth/tf003.ts +71 -0
- package/src/lint/post-synth/tf004.test.ts +47 -0
- package/src/lint/post-synth/tf004.ts +63 -0
- package/src/lint/post-synth/tf005.test.ts +57 -0
- package/src/lint/post-synth/tf005.ts +76 -0
- package/src/lint/post-synth/tf006.ts +53 -0
- package/src/lint/post-synth/tf007.ts +78 -0
- package/src/lint/post-synth/tf008.ts +75 -0
- package/src/lint/post-synth/tf009.ts +59 -0
- package/src/lint/post-synth/tf010.ts +51 -0
- package/src/lint/post-synth/tf011.ts +49 -0
- package/src/lint/post-synth/tf012.ts +47 -0
- package/src/lint/post-synth/tf013.ts +63 -0
- package/src/lint/post-synth/tf014.ts +72 -0
- package/src/lint/post-synth/tf015.ts +63 -0
- package/src/lint/post-synth/tf016.ts +75 -0
- package/src/lint/post-synth/tf017.ts +50 -0
- package/src/lint/post-synth/tf018.ts +72 -0
- package/src/lint/post-synth/tf019.ts +72 -0
- package/src/lint/post-synth/tf020.ts +123 -0
- package/src/lint/post-synth/tf021.ts +126 -0
- package/src/lint/post-synth/tf022.ts +61 -0
- package/src/lint/post-synth/tf024.test.ts +42 -0
- package/src/lint/post-synth/tf024.ts +70 -0
- package/src/lint/post-synth/tf025.test.ts +46 -0
- package/src/lint/post-synth/tf025.ts +101 -0
- package/src/lint/post-synth/tf026.test.ts +56 -0
- package/src/lint/post-synth/tf026.ts +80 -0
- package/src/lint/presets.test.ts +72 -0
- package/src/lint/rules/fixtures/TF101/negative-op.ts +2 -0
- package/src/lint/rules/fixtures/TF101/positive-op.ts +2 -0
- package/src/lint/rules/fixtures/load.ts +53 -0
- package/src/lint/rules/plan-before-apply.test.ts +112 -7
- package/src/lint/rules/plan-before-apply.ts +11 -0
- package/src/lint/secret-shape.test.ts +150 -0
- package/src/lint/secret-shape.ts +159 -0
- package/src/live-ownership.ts +60 -0
- package/src/lsp/completions.test.ts +3 -2
- package/src/lsp/option-keys.ts +10 -5
- package/src/op/activities/choudoufu.acceptance.test.ts +143 -0
- package/src/op/activities/choudoufu.test.ts +693 -0
- package/src/op/activities/index.ts +40 -4
- package/src/op/activities/live-detect.test.ts +143 -0
- package/src/op/activities/live-detect.ts +162 -0
- package/src/op/activities/registry.test.ts +11 -3
- package/src/op/activities/terraform.test.ts +7 -3
- package/src/op/activities/terraform.ts +823 -69
- package/src/op/activity-contracts.test.ts +147 -0
- package/src/op/activity-contracts.ts +178 -0
- package/src/op/adoption.test.ts +170 -0
- package/src/op/adoption.ts +241 -0
- package/src/op/builders.test.ts +60 -5
- package/src/op/builders.ts +93 -5
- package/src/op/resolve-root-mode.test.ts +85 -0
- package/src/op/resolve-root-mode.ts +72 -0
- package/src/plugin.test.ts +68 -0
- package/src/plugin.ts +75 -9
- package/src/skills/chant-terraform.md +1 -1
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Everything else in this lexicon's Op suite proves the shape of what chant
|
|
5
5
|
* emits. This proves the shape runs: build a `TerraformApplyOp` with
|
|
6
|
-
* `gate: "never"` (
|
|
6
|
+
* `gate: "never"` (a gate ends a local run as `gated` since #2119, so an
|
|
7
|
+
* acceptance run that must reach Apply declares none), hand
|
|
7
8
|
* it to `runOpLocally` with the activities the registry resolves by
|
|
8
9
|
* convention, and let a real `terraform` — or `tofu` — init, plan and apply
|
|
9
10
|
* the #2083 `with-backend` fixture root into a temp directory. Two
|
|
@@ -23,13 +24,21 @@
|
|
|
23
24
|
|
|
24
25
|
import { execSync } from "node:child_process";
|
|
25
26
|
import { lookup } from "node:dns/promises";
|
|
26
|
-
import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
27
28
|
import { tmpdir } from "node:os";
|
|
28
29
|
import { join } from "node:path";
|
|
29
30
|
import { afterAll, describe, expect, it } from "vitest";
|
|
30
31
|
import { loadActivities, loadProfiles, runOpLocally, type OpConfig } from "@intentius/chant/op";
|
|
31
32
|
import { TerraformApplyOp } from "./terraform-apply-op";
|
|
32
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
MIN_CHOUDOUFU_VERSION,
|
|
35
|
+
isOlderVersion,
|
|
36
|
+
parseChoudoufuVersion,
|
|
37
|
+
terraformApply,
|
|
38
|
+
terraformInit,
|
|
39
|
+
terraformPlan,
|
|
40
|
+
terraformShow,
|
|
41
|
+
} from "../op/activities/terraform";
|
|
33
42
|
|
|
34
43
|
function onPath(cmd: string): boolean {
|
|
35
44
|
try {
|
|
@@ -113,7 +122,7 @@ describe.skipIf(skipReason !== "")(
|
|
|
113
122
|
await loadProfiles(),
|
|
114
123
|
);
|
|
115
124
|
|
|
116
|
-
expect(result.
|
|
125
|
+
expect(result.status).toBe("ok");
|
|
117
126
|
expect(result.records.map((r) => `${r.phase}:${r.status}`)).toEqual([
|
|
118
127
|
"Init:ok",
|
|
119
128
|
"Plan:ok",
|
|
@@ -121,7 +130,7 @@ describe.skipIf(skipReason !== "")(
|
|
|
121
130
|
]);
|
|
122
131
|
|
|
123
132
|
// The plan proposed two creates, and said so through the outcome the
|
|
124
|
-
// Plan step surfaces as the `Changed`
|
|
133
|
+
// Plan step surfaces as the `Changed` run outcome.
|
|
125
134
|
const plan = result.records.find((r) => r.phase === "Plan")!;
|
|
126
135
|
expect(plan.outcome).toEqual({ name: "Changed", value: true });
|
|
127
136
|
|
|
@@ -136,3 +145,172 @@ describe.skipIf(skipReason !== "")(
|
|
|
136
145
|
);
|
|
137
146
|
},
|
|
138
147
|
);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `TerraformApplyOp` on a live root, against choudoufu's own pinned emulator
|
|
151
|
+
* (#2106 follow-up). Two suites, gated the same way
|
|
152
|
+
* `../op/activities/choudoufu.acceptance.test.ts` is, plus a version floor:
|
|
153
|
+
*
|
|
154
|
+
* - no `choudoufu` on PATH,
|
|
155
|
+
* - a `choudoufu` older than {@link MIN_CHOUDOUFU_VERSION}, which is the
|
|
156
|
+
* release that shipped the approval artifact
|
|
157
|
+
* ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878),
|
|
158
|
+
* PR 889): before it, `plan -out` was refused under a live block and this
|
|
159
|
+
* Op could not be built the way it is built now,
|
|
160
|
+
* - `CHOUDOUFU_EMULATOR_ENDPOINT` unset (bring up choudoufu's `just smoke`
|
|
161
|
+
* docker compose stack and export `http://localhost:<mapped port>`).
|
|
162
|
+
*
|
|
163
|
+
* The #894 clause the first version of this suite carried is gone. That issue
|
|
164
|
+
* is still open, but it is about `live-plan -json`, which the apply Op no
|
|
165
|
+
* longer runs: the plan half is the stock `plan -out` path. `TerraformWatchOp`
|
|
166
|
+
* and `TerraformAdoptOp` still need the document and still wait on it.
|
|
167
|
+
*
|
|
168
|
+
* The first test is the happy path end to end through `runOpLocally`. The
|
|
169
|
+
* second is the refusal, and it runs the activities directly rather than
|
|
170
|
+
* through the Op, because the world has to move between the Plan step and the
|
|
171
|
+
* Apply step and there is no seam inside a running Op to do that from.
|
|
172
|
+
*/
|
|
173
|
+
|
|
174
|
+
function versionOf(binary: string): string | undefined {
|
|
175
|
+
try {
|
|
176
|
+
return parseChoudoufuVersion(execSync(`${binary} version`, { encoding: "utf8" }));
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const LIVE_FIXTURE = join(import.meta.dirname, "..", "__fixtures__", "live");
|
|
183
|
+
|
|
184
|
+
/** A throwaway project holding a copy of the live fixture as its one root. */
|
|
185
|
+
function liveProject(): string {
|
|
186
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-choudoufu-apply-accept-"));
|
|
187
|
+
workspaces.push(dir);
|
|
188
|
+
cpSync(LIVE_FIXTURE, join(dir, "root"), { recursive: true });
|
|
189
|
+
writeFileSync(
|
|
190
|
+
join(dir, "chant.config.json"),
|
|
191
|
+
JSON.stringify(
|
|
192
|
+
{ lexicons: ["terraform"], terraform: { binary: "choudoufu", roots: { estate: { dir: "./root" } } } },
|
|
193
|
+
null,
|
|
194
|
+
2,
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
return dir;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The estate-wide marker sweep configures the AWS provider whatever the root declares. */
|
|
201
|
+
function pointAtTheEmulator(endpoint: string): void {
|
|
202
|
+
process.env.AWS_ENDPOINT_URL ??= endpoint;
|
|
203
|
+
process.env.AWS_ACCESS_KEY_ID ??= "choudoufu-emulator";
|
|
204
|
+
process.env.AWS_SECRET_ACCESS_KEY ??= "choudoufu-emulator";
|
|
205
|
+
process.env.AWS_DEFAULT_REGION ??= "us-east-1";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const choudoufuVersion = onPath("choudoufu") ? versionOf("choudoufu") : undefined;
|
|
209
|
+
const emulatorEndpoint = process.env.CHOUDOUFU_EMULATOR_ENDPOINT;
|
|
210
|
+
|
|
211
|
+
const liveSkipReason: string = !onPath("choudoufu")
|
|
212
|
+
? "no choudoufu binary on PATH"
|
|
213
|
+
: choudoufuVersion === undefined
|
|
214
|
+
? "the choudoufu on PATH reports no release version (a dev build), so the approval artifact cannot be assumed"
|
|
215
|
+
: isOlderVersion(choudoufuVersion, MIN_CHOUDOUFU_VERSION)
|
|
216
|
+
? `choudoufu ${choudoufuVersion} is older than v${MIN_CHOUDOUFU_VERSION}, which shipped the approval artifact (choudoufu #878)`
|
|
217
|
+
: !emulatorEndpoint
|
|
218
|
+
? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
|
|
219
|
+
: "";
|
|
220
|
+
|
|
221
|
+
describe.skipIf(liveSkipReason !== "")(
|
|
222
|
+
`TerraformApplyOp applies a live root against choudoufu's emulator${liveSkipReason ? ` (skipped: ${liveSkipReason})` : ""}`,
|
|
223
|
+
() => {
|
|
224
|
+
it(
|
|
225
|
+
'inits, plans to a file, applies that file, gate: "never" — two null_resources applied',
|
|
226
|
+
{ timeout: 600_000 },
|
|
227
|
+
async () => {
|
|
228
|
+
pointAtTheEmulator(emulatorEndpoint!);
|
|
229
|
+
const project = liveProject();
|
|
230
|
+
|
|
231
|
+
const { op } = TerraformApplyOp({
|
|
232
|
+
name: "choudoufu-acceptance",
|
|
233
|
+
root: "estate",
|
|
234
|
+
gate: "never",
|
|
235
|
+
cwd: project,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// The plan file is what crosses the (skipped) gate, so the built Op
|
|
239
|
+
// has to name one even here, where nothing waits on it.
|
|
240
|
+
const applyStep = (op as unknown as { props: OpConfig }).props.phases
|
|
241
|
+
.find((p) => p.name === "Apply")!
|
|
242
|
+
.steps[0] as { args?: Record<string, unknown> };
|
|
243
|
+
expect(applyStep.args?.planFile).toBeDefined();
|
|
244
|
+
|
|
245
|
+
const activities = await loadActivities(["terraform"]);
|
|
246
|
+
const result = await runOpLocally(
|
|
247
|
+
(op as unknown as { props: OpConfig }).props,
|
|
248
|
+
activities,
|
|
249
|
+
await loadProfiles(),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(result.status).toBe("ok");
|
|
253
|
+
expect(result.records.map((r) => `${r.phase}:${r.status}`)).toEqual(["Init:ok", "Plan:ok", "Apply:ok"]);
|
|
254
|
+
|
|
255
|
+
// A live root has no state file to read back: the state cache is not
|
|
256
|
+
// stock's `terraform.tfstate` and `show` over it renders nothing. The
|
|
257
|
+
// estate's own answer is the next plan, which is built from the live
|
|
258
|
+
// system, so what was applied is read back the way choudoufu means it
|
|
259
|
+
// to be. Empty, and its prior state holds exactly the two resources.
|
|
260
|
+
const after = await terraformPlan({ root: "estate", cwd: project });
|
|
261
|
+
expect(after.changed).toBe(false);
|
|
262
|
+
expect({ adds: after.adds, changes: after.changes, destroys: after.destroys }).toEqual({
|
|
263
|
+
adds: 0,
|
|
264
|
+
changes: 0,
|
|
265
|
+
destroys: 0,
|
|
266
|
+
});
|
|
267
|
+
const applied = (
|
|
268
|
+
(after.json as { prior_state?: { values?: { root_module?: { resources?: Array<{ address?: string }> } } } })
|
|
269
|
+
.prior_state?.values?.root_module?.resources ?? []
|
|
270
|
+
).map((r) => r.address);
|
|
271
|
+
expect(applied.sort()).toEqual(["null_resource.first", "null_resource.second"]);
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
it(
|
|
276
|
+
"a plan file the live system has moved past comes back as a named refusal, not a throw",
|
|
277
|
+
{ timeout: 600_000 },
|
|
278
|
+
async () => {
|
|
279
|
+
pointAtTheEmulator(emulatorEndpoint!);
|
|
280
|
+
const project = liveProject();
|
|
281
|
+
const mainTf = join(project, "root", "main.tf");
|
|
282
|
+
const original = readFileSync(mainTf, "utf8");
|
|
283
|
+
|
|
284
|
+
await terraformInit({ root: "estate", cwd: project });
|
|
285
|
+
const plan = await terraformPlan({ root: "estate", cwd: project });
|
|
286
|
+
expect(plan.changed).toBe(true);
|
|
287
|
+
expect(plan.adds).toBe(2);
|
|
288
|
+
|
|
289
|
+
// The configuration moves after the approval: one of the two resources
|
|
290
|
+
// the approver read is gone, so the fresh plan the apply builds has a
|
|
291
|
+
// change the file does not, and the file has one the fresh plan does not.
|
|
292
|
+
writeFileSync(mainTf, original.replace(/resource "null_resource" "second" \{[\s\S]*?\n\}\n/, ""));
|
|
293
|
+
|
|
294
|
+
const refusedRun = await terraformApply({ root: "estate", cwd: project, planFile: plan.planFile });
|
|
295
|
+
expect(refusedRun.applied).toBe(false);
|
|
296
|
+
expect(refusedRun.refused).toBe("approval-mismatch");
|
|
297
|
+
expect(refusedRun.refusal).toContain("The approved plan no longer matches the live system");
|
|
298
|
+
expect(refusedRun.refusal).toContain("null_resource.second");
|
|
299
|
+
|
|
300
|
+
// The sibling refusal, from the same exit status: the file was produced
|
|
301
|
+
// against another estate than the one this directory now declares.
|
|
302
|
+
writeFileSync(mainTf, original.replace('estate = "fixture-estate"', 'estate = "renamed-estate"'));
|
|
303
|
+
const wrongEstate = await terraformApply({ root: "estate", cwd: project, planFile: plan.planFile });
|
|
304
|
+
expect(wrongEstate.applied).toBe(false);
|
|
305
|
+
expect(wrongEstate.refused).toBe("wrong-estate");
|
|
306
|
+
expect(wrongEstate.refusal).toContain("The approved plan belongs to a different estate");
|
|
307
|
+
|
|
308
|
+
// And with the world back where the approval found it, the same file applies.
|
|
309
|
+
writeFileSync(mainTf, original);
|
|
310
|
+
const applied = await terraformApply({ root: "estate", cwd: project, planFile: plan.planFile });
|
|
311
|
+
expect(applied.applied).toBe(true);
|
|
312
|
+
expect(applied.refused).toBeUndefined();
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
},
|
|
316
|
+
);
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TerraformApplyOp composite tests (#2086) — the phase names, the gate, the
|
|
3
|
-
* plan-to-apply reference, and the compensation refusal.
|
|
3
|
+
* plan-to-apply reference, and the compensation refusal. A live root builds
|
|
4
|
+
* the same four phases (#2106 follow-up), and what is particular to it is
|
|
5
|
+
* below, in its own describe block.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
|
-
import {
|
|
8
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, describe, test, expect } from "vitest";
|
|
7
12
|
import {
|
|
8
|
-
|
|
13
|
+
isGated,
|
|
9
14
|
isStepOutputRef,
|
|
10
15
|
runOpLocally,
|
|
11
|
-
|
|
16
|
+
memoryGateLedgerPort,
|
|
12
17
|
type ActivityStep,
|
|
13
18
|
type GateStep,
|
|
14
19
|
type OpConfig,
|
|
@@ -95,7 +100,7 @@ describe("TerraformApplyOp gate (#2086)", () => {
|
|
|
95
100
|
test("the Gate phase reports the plan's destroy count before it waits", () => {
|
|
96
101
|
// GateStep has no condition field, so "on-destroy" cannot branch at build
|
|
97
102
|
// time. The approver is told instead: a `show` of the saved plan runs
|
|
98
|
-
// first and surfaces `destroys` as a
|
|
103
|
+
// first and surfaces `destroys` as a run outcome.
|
|
99
104
|
const op = props({ name: "prod-apply", root: "app" });
|
|
100
105
|
const steps = op.phases.find((p) => p.name === "Gate")!.steps;
|
|
101
106
|
const show = steps[0] as ActivityStep;
|
|
@@ -168,26 +173,173 @@ describe("TerraformApplyOp compensation — total or refused (#2086)", () => {
|
|
|
168
173
|
});
|
|
169
174
|
});
|
|
170
175
|
|
|
171
|
-
describe("TerraformApplyOp on the local executor (#2086)", () => {
|
|
172
|
-
test("a gated Op
|
|
176
|
+
describe("TerraformApplyOp on the local executor (#2086, gate-as-fact #2119)", () => {
|
|
177
|
+
test("a gated Op stops at the gate and records the pending fact, naming the signal", async () => {
|
|
173
178
|
const op = props({ name: "prod-apply", root: "app" });
|
|
174
|
-
|
|
179
|
+
const ran: string[] = [];
|
|
180
|
+
const stub = (fn: string) => [fn, async () => { ran.push(fn); return {}; }] as const;
|
|
181
|
+
const activities = new Map(
|
|
182
|
+
["terraformInit", "terraformPlan", "terraformShow", "terraformApply"].map(stub),
|
|
183
|
+
);
|
|
184
|
+
const gates = memoryGateLedgerPort();
|
|
185
|
+
const result = await runOpLocally(op, activities, {}, undefined, { gates, now: "2026-09-05T12:00:00.000Z" });
|
|
186
|
+
|
|
187
|
+
expect(result.status).toBe("gated");
|
|
188
|
+
expect(result.gate?.op).toBe("prod-apply");
|
|
189
|
+
expect(result.gate?.gate).toBe(op.phases.flatMap((p) => p.steps).find(isGate)?.signalName);
|
|
190
|
+
expect(gates.appended).toHaveLength(1);
|
|
191
|
+
// Init, Plan and the pre-gate `show` ran; Apply is behind the gate and did not.
|
|
192
|
+
expect(ran).toEqual(["terraformInit", "terraformPlan", "terraformShow"]);
|
|
175
193
|
});
|
|
176
194
|
|
|
177
|
-
test('gate: "never"
|
|
178
|
-
expect(
|
|
195
|
+
test('gate: "never" means there is no gate to stop at', () => {
|
|
196
|
+
expect(isGated(props({ name: "prod-apply", root: "app", gate: "never" }))).toBe(false);
|
|
197
|
+
expect(isGated(props({ name: "prod-apply", root: "app" }))).toBe(true);
|
|
179
198
|
});
|
|
180
199
|
});
|
|
181
200
|
|
|
182
201
|
describe("TerraformApplyOp Op metadata (#2086)", () => {
|
|
183
|
-
test("
|
|
202
|
+
test("labels name the root, and no task queue rides on the Op (#2118)", () => {
|
|
184
203
|
const op = props({ name: "app-apply", root: "app" });
|
|
185
204
|
expect(op.name).toBe("app-apply");
|
|
186
|
-
expect(op.
|
|
187
|
-
expect(op
|
|
205
|
+
expect(op.labels).toEqual({ Apply: "true", TerraformRoot: "app" });
|
|
206
|
+
expect(op).not.toHaveProperty("taskQueue");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── Live root (#2106) ────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
const dirs: string[] = [];
|
|
213
|
+
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/** A throwaway project whose one root is live, stock, or live with a policy block, per `policy`. */
|
|
219
|
+
function project(policy?: string): string {
|
|
220
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-tf-apply-op-live-"));
|
|
221
|
+
dirs.push(dir);
|
|
222
|
+
mkdirSync(join(dir, "root"), { recursive: true });
|
|
223
|
+
writeFileSync(
|
|
224
|
+
join(dir, "root", "main.tf"),
|
|
225
|
+
[
|
|
226
|
+
"terraform {",
|
|
227
|
+
" live {",
|
|
228
|
+
' estate = "fixture-estate"',
|
|
229
|
+
...(policy ? [" policy {", ` ${policy}`, " }"] : []),
|
|
230
|
+
" }",
|
|
231
|
+
"}",
|
|
232
|
+
"",
|
|
233
|
+
'resource "null_resource" "x" {}',
|
|
234
|
+
"",
|
|
235
|
+
].join("\n"),
|
|
236
|
+
);
|
|
237
|
+
writeFileSync(
|
|
238
|
+
join(dir, "chant.config.json"),
|
|
239
|
+
JSON.stringify({ terraform: { binary: "choudoufu", roots: { estate: { dir: "./root" } } } }),
|
|
240
|
+
);
|
|
241
|
+
return dir;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
describe("TerraformApplyOp on a live root (#2106 follow-up)", () => {
|
|
245
|
+
test("the same Init, Plan, Gate, Apply as a stock root, with the plan file paired through", () => {
|
|
246
|
+
const dir = project();
|
|
247
|
+
const op = props({ name: "estate-apply", root: "estate", cwd: dir });
|
|
248
|
+
expect(phaseNames(op)).toEqual(["Init", "Plan", "Gate", "Apply"]);
|
|
249
|
+
|
|
250
|
+
const activitySteps = op.phases.flatMap((p) => p.steps.filter(isActivity));
|
|
251
|
+
expect(activitySteps.map((s) => s.fn)).toEqual([
|
|
252
|
+
"terraformInit",
|
|
253
|
+
"terraformPlan",
|
|
254
|
+
"terraformShow",
|
|
255
|
+
"terraformApply",
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
const plan = op.phases.find((p) => p.name === "Plan")!.steps[0] as ActivityStep;
|
|
259
|
+
expect(plan.args?.planFile).toBe("chant.tfplan");
|
|
260
|
+
|
|
261
|
+
// The Apply step names the Plan step's own output, never a literal path:
|
|
262
|
+
// on a live root that reference is the approval artifact itself.
|
|
263
|
+
const apply = op.phases.find((p) => p.name === "Apply")!.steps[0] as ActivityStep;
|
|
264
|
+
expect(isStepOutputRef(apply.args?.planFile)).toBe(true);
|
|
265
|
+
expect(apply.args?.planFile).toMatchObject({ step: "plan", path: "planFile" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("no choudoufuLivePlan step anywhere: the watch and adopt Ops still use it, this one does not", () => {
|
|
269
|
+
const dir = project();
|
|
270
|
+
const op = props({ name: "estate-apply", root: "estate", cwd: dir });
|
|
271
|
+
const fns = op.phases.flatMap((p) => p.steps.filter(isActivity)).map((s) => s.fn);
|
|
272
|
+
expect(fns).not.toContain("choudoufuLivePlan");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('gate: "never" drops the Gate phase, leaving Init, Plan, Apply', () => {
|
|
276
|
+
const dir = project();
|
|
277
|
+
const op = props({ name: "estate-apply", root: "estate", cwd: dir, gate: "never" });
|
|
278
|
+
expect(phaseNames(op)).toEqual(["Init", "Plan", "Apply"]);
|
|
279
|
+
expect(op.phases.flatMap((p) => p.steps).some(isGate)).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("Plan reports Changed; the Gate's show step reports Destroys off the saved plan", () => {
|
|
283
|
+
const dir = project();
|
|
284
|
+
const op = props({ name: "estate-apply", root: "estate", cwd: dir });
|
|
285
|
+
const plan = op.phases.find((p) => p.name === "Plan")!.steps[0] as ActivityStep;
|
|
286
|
+
expect(plan.outcomeAttribute).toEqual({ name: "Changed", from: "changed" });
|
|
287
|
+
|
|
288
|
+
const gateSteps = op.phases.find((p) => p.name === "Gate")!.steps;
|
|
289
|
+
const show = gateSteps[0] as ActivityStep;
|
|
290
|
+
expect(show.fn).toBe("terraformShow");
|
|
291
|
+
expect(show.outcomeAttribute).toEqual({ name: "Destroys", from: "destroys" });
|
|
292
|
+
expect(gateSteps[1].kind).toBe("gate");
|
|
188
293
|
});
|
|
189
294
|
|
|
190
|
-
test("
|
|
191
|
-
|
|
295
|
+
test("the gate description tells the approver what the exit-3 refusal does", () => {
|
|
296
|
+
const dir = project();
|
|
297
|
+
const op = props({ name: "estate-apply", root: "estate", cwd: dir });
|
|
298
|
+
const gateStep = op.phases.find((p) => p.name === "Gate")!.steps.find(isGate)!;
|
|
299
|
+
expect(gateStep.description).toContain("live root");
|
|
300
|
+
expect(gateStep.description).toContain("exit status 3");
|
|
301
|
+
expect(gateStep.description).toContain("choudoufu #878");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("a stock root's gate description says nothing about a live re-plan", () => {
|
|
305
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-tf-apply-op-live-"));
|
|
306
|
+
dirs.push(dir);
|
|
307
|
+
mkdirSync(join(dir, "root"), { recursive: true });
|
|
308
|
+
writeFileSync(join(dir, "root", "main.tf"), 'resource "null_resource" "x" {}\n');
|
|
309
|
+
writeFileSync(
|
|
310
|
+
join(dir, "chant.config.json"),
|
|
311
|
+
JSON.stringify({ terraform: { binary: "terraform", roots: { estate: { dir: "./root" } } } }),
|
|
312
|
+
);
|
|
313
|
+
const op = props({ name: "estate-apply", root: "estate", cwd: dir });
|
|
314
|
+
const gateStep = op.phases.find((p) => p.name === "Gate")!.steps.find(isGate)!;
|
|
315
|
+
expect(gateStep.description).not.toContain("exit status 3");
|
|
316
|
+
expect(gateStep.description).not.toContain("live root");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("refuses to build when the root's policy sets undeclared_untagged = \"delete\"", () => {
|
|
320
|
+
const dir = project('undeclared_untagged = "delete"');
|
|
321
|
+
expect(() => TerraformApplyOp({ name: "estate-apply", root: "estate", cwd: dir })).toThrow(
|
|
322
|
+
/TerraformApplyOp "estate-apply".*undeclared_untagged = "delete"/s,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("builds fine when the root's policy sets undeclared_untagged to something else", () => {
|
|
327
|
+
const dir = project('undeclared_untagged = "report"');
|
|
328
|
+
expect(() => TerraformApplyOp({ name: "estate-apply", root: "estate", cwd: dir })).not.toThrow();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a choudoufu root with no declared estate builds the same phases and skips the policy read", () => {
|
|
332
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-tf-apply-op-live-"));
|
|
333
|
+
dirs.push(dir);
|
|
334
|
+
mkdirSync(join(dir, "root"), { recursive: true });
|
|
335
|
+
writeFileSync(join(dir, "root", "main.tf"), 'resource "null_resource" "x" {}\n');
|
|
336
|
+
writeFileSync(
|
|
337
|
+
join(dir, "chant.config.json"),
|
|
338
|
+
JSON.stringify({ terraform: { binary: "choudoufu", roots: { estate: { dir: "./root" } } } }),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const op = props({ name: "estate-apply", root: "estate", cwd: dir, gate: "never" });
|
|
342
|
+
const fns = op.phases.flatMap((p) => p.steps.filter(isActivity)).map((s) => s.fn);
|
|
343
|
+
expect(fns).toEqual(["terraformInit", "terraformPlan", "terraformApply"]);
|
|
192
344
|
});
|
|
193
345
|
});
|
|
@@ -1,17 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TerraformApplyOp composite (#2086)
|
|
2
|
+
* TerraformApplyOp composite (#2086), init, plan, approve, apply for one root
|
|
3
3
|
* module named in `terraform.roots`.
|
|
4
4
|
*
|
|
5
5
|
* Authority stays with terraform: chant runs the binary, reads its plan, and
|
|
6
|
-
* hands the same saved plan to apply. The Apply step never
|
|
7
|
-
* it takes the Plan step's own `planFile` as a
|
|
8
|
-
*
|
|
6
|
+
* hands the same saved plan to apply. The Apply step never applies something
|
|
7
|
+
* no gate saw, because it takes the Plan step's own `planFile` as a
|
|
8
|
+
* `StepOutputRef`.
|
|
9
9
|
*
|
|
10
|
-
* Phases: Init, Plan, Gate, Apply.
|
|
10
|
+
* Phases: Init, Plan, Gate, Apply. One shape, both kinds of root. A live root
|
|
11
|
+
* (#2103, `terraform.binary: "choudoufu"` plus a declared estate) ran a
|
|
12
|
+
* different shape for one release, because choudoufu refused `-out` and
|
|
13
|
+
* `apply <planfile>`; choudoufu v0.13.0 admits both under a `live` block
|
|
14
|
+
* ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878),
|
|
15
|
+
* PR 889), so the plan-file discipline is back and the two shapes are one
|
|
16
|
+
* again.
|
|
11
17
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
18
|
+
* **What the approval covers on a live root.** `plan -out=<planFile>` writes
|
|
19
|
+
* stock's own plan file, and that file is what the approver reads and what
|
|
20
|
+
* the Apply step names. `apply <planFile>` does not replay it: prior state on
|
|
21
|
+
* a live root is a projection rebuilt from the live system every run, so the
|
|
22
|
+
* apply re-plans against the live system the way every live-markers run does,
|
|
23
|
+
* then compares its fresh plan with the file's. Same addresses, same actions,
|
|
24
|
+
* same live objects, and the same planned values, or it refuses before
|
|
25
|
+
* anything changes:
|
|
26
|
+
*
|
|
27
|
+
* - `The approved plan no longer matches the live system`, and
|
|
28
|
+
* - `The approved plan belongs to a different estate`,
|
|
29
|
+
*
|
|
30
|
+
* both with exit status **3**, which is neither 1 (any ordinary failure) nor
|
|
31
|
+
* `-detailed-exitcode`'s 2. `terraformApply` maps that status to a named
|
|
32
|
+
* result rather than a thrown error (`refused: "approval-mismatch" |
|
|
33
|
+
* "wrong-estate"`, with choudoufu's message in `refusal`), so a workflow
|
|
34
|
+
* reading the Apply step's output can route the run back to review instead of
|
|
35
|
+
* treating it as a broken step. The seam #2106 left named and unused on the
|
|
36
|
+
* apply args is now the plan file itself.
|
|
37
|
+
*
|
|
38
|
+
* TF101 therefore fires on a live root's `terraformApply` call again: there
|
|
39
|
+
* is a plan file to pair once more, and pairing it is what the refusal above
|
|
40
|
+
* has to compare against.
|
|
41
|
+
*
|
|
42
|
+
* **Delete modes.** chant's `delete: "never" | "owned-only" | "gated"`
|
|
43
|
+
* (`terraform.roots.<name>.delete`, `../config.ts`) maps onto choudoufu's
|
|
44
|
+
* `policy` block. `"owned-only"` is choudoufu's own default verb for the
|
|
45
|
+
* `undeclared_tagged` quadrant (an orphaned marked resource) and needs
|
|
46
|
+
* nothing; `"gated"` needs nothing beyond this composite's own approval gate;
|
|
47
|
+
* `"never"` requires the root's `policy` block to set `undeclared_tagged` to
|
|
48
|
+
* `"keep"`, `"untag"` or `"report"`, and TF026 (`../lint/post-synth/tf026.ts`)
|
|
49
|
+
* enforces that at build time, naming the setting to add. Separately, and
|
|
50
|
+
* regardless of `delete`, this composite refuses outright to build against a
|
|
51
|
+
* root whose `policy` sets `undeclared_untagged = "delete"` (account-scoped
|
|
52
|
+
* reconciliation, which needs a `scope` block): that quadrant deletes
|
|
53
|
+
* resources the estate never claimed, anywhere the scope reaches, and chant
|
|
54
|
+
* never proposes deleting a resource it does not own, not for a root, and not
|
|
55
|
+
* for an Op it generated.
|
|
56
|
+
*
|
|
57
|
+
* A gate is a fact on the gate ledger, not a wait (#2119). Reaching one,
|
|
58
|
+
* `chant run` consults the ledger (`packages/core/src/op/gate.ts`): a
|
|
59
|
+
* resolution newer than the gate's newest pending fact lets the run walk
|
|
60
|
+
* through, and anything else records the pending fact and ends the run with
|
|
61
|
+
* status `gated` and exit 3. `chant approve <op> <gate>` writes the
|
|
62
|
+
* resolution, and the next run reads it. `gate: "never"` drops the Gate phase
|
|
63
|
+
* entirely, so a run never stops.
|
|
15
64
|
*
|
|
16
65
|
* @example
|
|
17
66
|
* ```typescript
|
|
@@ -23,6 +72,8 @@
|
|
|
23
72
|
|
|
24
73
|
import { Op, phase, gate, activity, OpResource } from "@intentius/chant/op";
|
|
25
74
|
import { DEFAULT_PLAN_FILE } from "../op/activities/terraform";
|
|
75
|
+
import { detectLivePolicyVerbs } from "../op/activities/live-detect";
|
|
76
|
+
import { resolveRootModeSync } from "../op/resolve-root-mode";
|
|
26
77
|
import {
|
|
27
78
|
terraformInit as initStep,
|
|
28
79
|
terraformPlan as planStep,
|
|
@@ -38,14 +89,14 @@ import {
|
|
|
38
89
|
* cannot branch at build time on a count the plan only produces at run time.
|
|
39
90
|
* The workable v1 is that `"on-destroy"` and `"always"` emit the same Gate
|
|
40
91
|
* phase, and the approver is told what is at stake instead: the phase reports
|
|
41
|
-
* the plan's `destroys` count as the `Destroys`
|
|
42
|
-
*
|
|
43
|
-
* plan turns out additive is a new step kind, out of scope here.
|
|
92
|
+
* the plan's `destroys` count as the `Destroys` run outcome before the
|
|
93
|
+
* gate is reached, and the gate description says so. A gate that skips itself
|
|
94
|
+
* when the plan turns out additive is a new step kind, out of scope here.
|
|
44
95
|
*/
|
|
45
96
|
export type TerraformGateMode = "on-destroy" | "always" | "never";
|
|
46
97
|
|
|
47
98
|
export interface TerraformApplyOpConfig {
|
|
48
|
-
/** Op name (kebab-case).
|
|
99
|
+
/** Op name (kebab-case). `signalName` defaults to `approve-<name>`. */
|
|
49
100
|
name: string;
|
|
50
101
|
/** Key into the project's `terraform.roots`. The root carries dir, workspace, var files and backend config. */
|
|
51
102
|
root: string;
|
|
@@ -59,7 +110,7 @@ export interface TerraformApplyOpConfig {
|
|
|
59
110
|
gate?: TerraformGateMode;
|
|
60
111
|
/** Gate signal name. Default: `approve-<name>`, as `ApplyOp` does. */
|
|
61
112
|
signalName?: string;
|
|
62
|
-
/**
|
|
113
|
+
/** How long a recorded pending gate stays valid, as a duration string. Default: core's own (48h). */
|
|
63
114
|
gateTimeout?: string;
|
|
64
115
|
/** Override the gate description shown to the approver. */
|
|
65
116
|
gateDescription?: string;
|
|
@@ -78,15 +129,13 @@ export interface TerraformApplyOpConfig {
|
|
|
78
129
|
* Terraform has no automatic rollback: a half-applied plan is undone by
|
|
79
130
|
* planning and applying the inverse, which is a decision about the estate
|
|
80
131
|
* rather than something chant can synthesize. So compensation here is
|
|
81
|
-
* total or refused, the same stance `ApplyOp` takes (`
|
|
132
|
+
* total or refused, the same stance `ApplyOp` takes (`packages/core/src/op/
|
|
82
133
|
* composites/apply-op.ts`) — `true`, or an object with no `command`, throws
|
|
83
134
|
* when the Op is built, naming the Op, rather than warning at the moment a
|
|
84
135
|
* rollback is already needed. Supply `compensate: { command: "..." }` with a
|
|
85
136
|
* rollback of your own, or leave it unset.
|
|
86
137
|
*/
|
|
87
138
|
compensate?: boolean | { command?: string };
|
|
88
|
-
/** Override the task queue. Defaults to `name`. */
|
|
89
|
-
taskQueue?: string;
|
|
90
139
|
}
|
|
91
140
|
|
|
92
141
|
export interface TerraformApplyOpResources {
|
|
@@ -95,7 +144,6 @@ export interface TerraformApplyOpResources {
|
|
|
95
144
|
}
|
|
96
145
|
|
|
97
146
|
export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApplyOpResources {
|
|
98
|
-
const taskQueue = config.taskQueue ?? config.name;
|
|
99
147
|
const planFile = config.planFile ?? DEFAULT_PLAN_FILE;
|
|
100
148
|
const gateMode: TerraformGateMode = config.gate ?? "on-destroy";
|
|
101
149
|
|
|
@@ -116,20 +164,45 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
|
|
|
116
164
|
// travels with each of them rather than being read off the process once.
|
|
117
165
|
const where = config.cwd ? { cwd: config.cwd } : {};
|
|
118
166
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
// Best-effort, synchronous (chant.config.json only — see
|
|
168
|
+
// resolveRootModeSync's own doc comment): "unknown" reads as stock, the
|
|
169
|
+
// conservative direction. The mode no longer changes which steps are
|
|
170
|
+
// emitted; it decides the policy refusal below and the wording of the gate.
|
|
171
|
+
const resolved = resolveRootModeSync(config.root, config.cwd);
|
|
172
|
+
const live = resolved?.mode === "live";
|
|
173
|
+
|
|
174
|
+
if (live) {
|
|
175
|
+
// Regardless of `delete`: an account-scoped purge is never something
|
|
176
|
+
// chant proposes on an Op's own initiative. TF026 handles the narrower,
|
|
177
|
+
// config-driven `delete: "never"` requirement; this is unconditional.
|
|
178
|
+
const verbs = detectLivePolicyVerbs(resolved!.dir);
|
|
179
|
+
if (verbs?.undeclaredUntagged === "delete") {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`TerraformApplyOp "${config.name}": root "${config.root}"'s policy block sets ` +
|
|
182
|
+
`undeclared_untagged = "delete" (account-scoped reconciliation, scoped by a \`scope\` block). ` +
|
|
183
|
+
"chant never proposes deleting a resource it does not own, and neither does an Op it " +
|
|
184
|
+
"generates. Remove that setting from the policy block, or narrow the estate's ownership " +
|
|
185
|
+
"answer instead of the account's.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
123
189
|
|
|
124
190
|
const phases = [
|
|
125
191
|
phase("Init", [initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) })]),
|
|
126
|
-
phase("Plan", [plan]),
|
|
127
192
|
];
|
|
128
193
|
|
|
194
|
+
// `id` is what makes `plan.out` legal, and `plan.out.planFile` is how the
|
|
195
|
+
// Apply step below names this step's saved plan. On a live root that file
|
|
196
|
+
// is also the approval artifact the apply re-plans against, so the same
|
|
197
|
+
// reference carries the approval as well as the path.
|
|
198
|
+
const plan = planStep(config.root, { planFile, ...where, id: "plan" });
|
|
199
|
+
plan.outcomeAttribute = { name: "Changed", from: "changed" };
|
|
200
|
+
phases.push(phase("Plan", [plan]));
|
|
201
|
+
|
|
129
202
|
if (gateMode !== "never") {
|
|
130
|
-
// Re-render the saved plan just before the
|
|
203
|
+
// Re-render the saved plan just before the gate, so the approver's
|
|
131
204
|
// `Destroys` attribute comes off the plan that will actually apply.
|
|
132
|
-
// `show` against a plan file calls no provider.
|
|
205
|
+
// `show` against a plan file calls no provider, on either kind of root.
|
|
133
206
|
const show = showStep(config.root, { ...where, planFile: plan.out.planFile });
|
|
134
207
|
show.outcomeAttribute = { name: "Destroys", from: "destroys" };
|
|
135
208
|
phases.push(
|
|
@@ -139,8 +212,13 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
|
|
|
139
212
|
...(config.gateTimeout ? { timeout: config.gateTimeout } : {}),
|
|
140
213
|
description:
|
|
141
214
|
config.gateDescription ??
|
|
142
|
-
`Approve terraform apply of root "${config.root}" (gate: ${gateMode}). ` +
|
|
143
|
-
`The Destroys
|
|
215
|
+
`Approve terraform apply of ${live ? "live " : ""}root "${config.root}" (gate: ${gateMode}). ` +
|
|
216
|
+
`The Destroys run outcome from this phase is the plan's destroy count.` +
|
|
217
|
+
(live
|
|
218
|
+
? " The apply that follows re-plans against the live system and applies this plan file only" +
|
|
219
|
+
" if its own fresh plan agrees; otherwise it refuses with exit status 3 and the Apply" +
|
|
220
|
+
" step reports a named refusal for review (choudoufu #878)."
|
|
221
|
+
: ""),
|
|
144
222
|
}),
|
|
145
223
|
]),
|
|
146
224
|
);
|
|
@@ -151,8 +229,7 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
|
|
|
151
229
|
const op = Op({
|
|
152
230
|
name: config.name,
|
|
153
231
|
overview: `Init, plan and apply the "${config.root}" terraform root`,
|
|
154
|
-
|
|
155
|
-
searchAttributes: {
|
|
232
|
+
labels: {
|
|
156
233
|
Apply: "true",
|
|
157
234
|
TerraformRoot: config.root,
|
|
158
235
|
},
|