@intentius/chant-lexicon-terraform 0.124.31 → 0.124.32

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.
Files changed (141) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.js +152 -0
  20. package/dist/composites/terraform-apply-op.js +183 -0
  21. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  22. package/dist/composites/terraform-watch-op.js +219 -0
  23. package/dist/config.js +118 -0
  24. package/dist/describe-resources.js +984 -0
  25. package/dist/generated/index.js +1 -0
  26. package/dist/hcl/descend.js +208 -0
  27. package/dist/hcl/edges.js +287 -0
  28. package/dist/hcl/jsonencode.js +442 -0
  29. package/dist/hcl/marker.js +212 -0
  30. package/dist/hcl/parse.js +372 -0
  31. package/dist/hcl/references.js +156 -0
  32. package/dist/hcl/roots.js +98 -0
  33. package/dist/hcl/suppressions.js +189 -0
  34. package/dist/hcl/value.js +72 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +46 -0
  38. package/dist/integrity.json +2 -2
  39. package/dist/lint/audit-catalog.js +501 -0
  40. package/dist/lint/audit-lineage.js +442 -0
  41. package/dist/lint/post-synth/blocks.js +87 -0
  42. package/dist/lint/post-synth/fixtures/load.js +94 -0
  43. package/dist/lint/post-synth/flat-attributes.js +135 -0
  44. package/dist/lint/post-synth/index.js +90 -0
  45. package/dist/lint/post-synth/module-source.js +153 -0
  46. package/dist/lint/post-synth/scope.js +104 -0
  47. package/dist/lint/post-synth/terragrunt.js +63 -0
  48. package/dist/lint/post-synth/tf001.js +138 -0
  49. package/dist/lint/post-synth/tf002.js +143 -0
  50. package/dist/lint/post-synth/tf003.js +60 -0
  51. package/dist/lint/post-synth/tf004.js +54 -0
  52. package/dist/lint/post-synth/tf005.js +68 -0
  53. package/dist/lint/post-synth/tf006.js +43 -0
  54. package/dist/lint/post-synth/tf007.js +66 -0
  55. package/dist/lint/post-synth/tf008.js +64 -0
  56. package/dist/lint/post-synth/tf009.js +49 -0
  57. package/dist/lint/post-synth/tf010.js +40 -0
  58. package/dist/lint/post-synth/tf011.js +38 -0
  59. package/dist/lint/post-synth/tf012.js +36 -0
  60. package/dist/lint/post-synth/tf013.js +53 -0
  61. package/dist/lint/post-synth/tf014.js +61 -0
  62. package/dist/lint/post-synth/tf015.js +62 -0
  63. package/dist/lint/post-synth/tf016.js +66 -0
  64. package/dist/lint/post-synth/tf017.js +39 -0
  65. package/dist/lint/post-synth/tf018.js +63 -0
  66. package/dist/lint/post-synth/tf019.js +60 -0
  67. package/dist/lint/post-synth/tf020.js +103 -0
  68. package/dist/lint/post-synth/tf021.js +117 -0
  69. package/dist/lint/post-synth/tf022.js +51 -0
  70. package/dist/lint/post-synth/tf024.js +62 -0
  71. package/dist/lint/post-synth/tf025.js +83 -0
  72. package/dist/lint/post-synth/tf026.js +74 -0
  73. package/dist/lint/post-synth/tf027.js +66 -0
  74. package/dist/lint/post-synth/tf028.js +83 -0
  75. package/dist/lint/post-synth/tf029.js +73 -0
  76. package/dist/lint/post-synth/tf030.js +253 -0
  77. package/dist/lint/post-synth/tf031.js +332 -0
  78. package/dist/lint/post-synth/tf032.js +201 -0
  79. package/dist/lint/post-synth/tf033.js +126 -0
  80. package/dist/lint/post-synth/tf034.js +45 -0
  81. package/dist/lint/post-synth/tf035.js +63 -0
  82. package/dist/lint/post-synth/tf036.js +97 -0
  83. package/dist/lint/post-synth/tf037.js +80 -0
  84. package/dist/lint/post-synth/tf038.js +63 -0
  85. package/dist/lint/post-synth/tf039.js +55 -0
  86. package/dist/lint/post-synth/tf040.js +67 -0
  87. package/dist/lint/post-synth/tf041.js +53 -0
  88. package/dist/lint/post-synth/tf042.js +38 -0
  89. package/dist/lint/post-synth/tf043.js +45 -0
  90. package/dist/lint/post-synth/tf044.js +45 -0
  91. package/dist/lint/post-synth/tf045.js +44 -0
  92. package/dist/lint/rules/fixtures/load.js +50 -0
  93. package/dist/lint/rules/index.js +4 -0
  94. package/dist/lint/rules/plan-before-apply.js +136 -0
  95. package/dist/lint/secret-shape.js +149 -0
  96. package/dist/live-ownership.js +57 -0
  97. package/dist/lsp/completions.js +95 -0
  98. package/dist/lsp/context.js +358 -0
  99. package/dist/lsp/hover.js +45 -0
  100. package/dist/lsp/option-keys.js +93 -0
  101. package/dist/manifest.json +1 -1
  102. package/dist/op/activities/index.js +30 -0
  103. package/dist/op/activities/live-detect.js +143 -0
  104. package/dist/op/activities/pin-rollout.js +45 -0
  105. package/dist/op/activities/terraform.js +1270 -0
  106. package/dist/op/activity-contracts.js +162 -0
  107. package/dist/op/adoption.js +272 -0
  108. package/dist/op/builders.js +125 -0
  109. package/dist/op/resolve-root-mode.js +77 -0
  110. package/dist/package-cli.js +16 -0
  111. package/dist/pin/edit-ts.js +76 -0
  112. package/dist/pin/edit.js +118 -0
  113. package/dist/pin/forge.js +65 -0
  114. package/dist/pin/index.js +16 -0
  115. package/dist/pin/rollout.d.ts.map +1 -1
  116. package/dist/pin/rollout.js +382 -0
  117. package/dist/pin/scan.js +287 -0
  118. package/dist/pin/source.js +171 -0
  119. package/dist/pin/waves.js +109 -0
  120. package/dist/plan-digest.js +67 -0
  121. package/dist/plugin.js +323 -0
  122. package/dist/serializer.js +23 -0
  123. package/dist/state-ownership.js +29 -0
  124. package/dist/terragrunt/affected.d.ts.map +1 -1
  125. package/dist/terragrunt/affected.js +290 -0
  126. package/dist/terragrunt/index.js +10 -0
  127. package/dist/terragrunt/mocks.js +270 -0
  128. package/dist/terragrunt/run.js +264 -0
  129. package/dist/terragrunt/units.js +195 -0
  130. package/dist/terragrunt/wave.js +125 -0
  131. package/dist/validate-cli.js +4 -0
  132. package/dist/validate.js +31 -0
  133. package/package.json +10 -10
  134. package/src/codegen/package.ts +2 -2
  135. package/src/components/capability-plugin.test.ts +97 -2
  136. package/src/components/capability-plugin.ts +117 -12
  137. package/src/components/pr-loop.tofu.test.ts +51 -11
  138. package/src/index.ts +1 -0
  139. package/src/pin/rollout.ts +2 -1
  140. package/src/terragrunt/affected.test.ts +11 -0
  141. package/src/terragrunt/affected.ts +2 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * TF020: a `variable`, a `locals` entry, a `data` source or an aliased
3
+ * `provider` that nothing in its module scope references.
4
+ *
5
+ * Scope: root and child modules, each judged on its own. Terraform's
6
+ * namespaces are per module, so `var.region` inside `module.cdn` is the child
7
+ * module's variable and a reference to it in the root proves nothing about
8
+ * the child's. The reference index (`hcl/references.ts`) is keyed the same
9
+ * way, one scope per module, and this check never looks outside the scope the
10
+ * declaration is in.
11
+ *
12
+ * An unused declaration is dead weight rather than a defect, which is why
13
+ * this is report-only. It still earns a finding: a variable nobody reads is
14
+ * usually the leftover of a refactor, and the next reader has to prove that
15
+ * before deleting it. A data source is worse than dead weight, since it is
16
+ * read from the provider's API on every plan whether or not anything uses the
17
+ * result.
18
+ *
19
+ * Two deliberate gaps, both from the index being a string scan rather than an
20
+ * expression tree (see its module doc), and both the reason the credit to
21
+ * tflint's `terraform_unused_declarations` is `overlaps`:
22
+ *
23
+ * - A name mentioned in a string that is not an expression (a `description`
24
+ * that says "overrides var.region") reads as a reference, so the
25
+ * declaration is not reported. Quiet, and wrong in the safe direction.
26
+ * - A value reached only dynamically (`lookup(local.by_env, ...)` where the
27
+ * map is built elsewhere) is a real reference the scan does see, since the
28
+ * local is still named; but a variable consumed only through
29
+ * `TF_VAR_`-style external input is not, and a root's variables are
30
+ * frequently exactly that. Report-only keeps that from failing a build.
31
+ *
32
+ * A `provider` block with no `alias` is never reported: it is the default
33
+ * configuration for its type, used by every resource of that type without
34
+ * naming it. Only an aliased one has to be asked for by name.
35
+ */
36
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
37
+ import { DATA_TYPE, LOCALS_TYPE, PROVIDER_TYPE, VARIABLE_TYPE, scopeOfKey, } from "../../hcl/parse.js";
38
+ import { buildReferenceIndex, isReferenced } from "../../hcl/references.js";
39
+ /** What a reader is being told to do about each kind of unused declaration. */
40
+ const ADVICE = {
41
+ variable: "Delete it, or reference it where it was meant to be used.",
42
+ local: "Delete it, or reference it where it was meant to be used.",
43
+ data: "Delete it. A data source is read from the provider on every plan, so an unused one costs an API " +
44
+ "call and a permission for a value nothing consumes.",
45
+ provider: "Delete it, or pass it to the resources or modules that were meant to use it.",
46
+ };
47
+ export const tf020 = {
48
+ id: "TF020",
49
+ description: "Declaration is never referenced in its module scope",
50
+ check(ctx) {
51
+ const diagnostics = [];
52
+ const index = buildReferenceIndex(ctx.entities);
53
+ const report = (key, kind, what) => {
54
+ diagnostics.push({
55
+ checkId: "TF020",
56
+ severity: "warning",
57
+ message: `${what} is declared and never referenced in its module. ${ADVICE[kind]}`,
58
+ entity: key,
59
+ lexicon: "terraform",
60
+ });
61
+ };
62
+ for (const [key, entity] of ctx.entities) {
63
+ if (!isResourceDeclarable(entity))
64
+ continue;
65
+ const props = entity.props;
66
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
67
+ const address = typeof props.address === "string" ? props.address : key;
68
+ const scope = scopeOfKey(key);
69
+ if (entity.entityType === VARIABLE_TYPE) {
70
+ // Excluding the variable's own key matters here: a `validation` block
71
+ // reads `var.<self>` in its condition, which would otherwise make
72
+ // every validated variable look used.
73
+ if (!isReferenced(index, scope, address, key))
74
+ report(key, "variable", `Variable "${address}"`);
75
+ continue;
76
+ }
77
+ if (entity.entityType === LOCALS_TYPE) {
78
+ for (const name of Object.keys(body)) {
79
+ // No self-exclusion: one `locals` block is many declarations, and a
80
+ // local reading another local of the same block is a real use.
81
+ if (!isReferenced(index, scope, `local.${name}`))
82
+ report(key, "local", `Local value "local.${name}"`);
83
+ }
84
+ continue;
85
+ }
86
+ if (entity.entityType === DATA_TYPE) {
87
+ if (!isReferenced(index, scope, address, key))
88
+ report(key, "data", `Data source "${address}"`);
89
+ continue;
90
+ }
91
+ if (entity.entityType === PROVIDER_TYPE) {
92
+ const alias = typeof body.alias === "string" ? body.alias : undefined;
93
+ if (alias === undefined || alias.trim() === "")
94
+ continue;
95
+ const type = address.startsWith("provider.") ? address.slice("provider.".length) : address;
96
+ if (!isReferenced(index, scope, `provider.${type}.${alias}`, key)) {
97
+ report(key, "provider", `Aliased provider "${type}.${alias}"`);
98
+ }
99
+ }
100
+ }
101
+ return diagnostics;
102
+ },
103
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * TF021: `count` used where `for_each` is safer, judged narrowly.
3
+ *
4
+ * Scope: root and child modules. A resource is a resource wherever it is
5
+ * declared, and the failure mode below does not depend on the scope.
6
+ *
7
+ * `count` addresses its instances by position: `aws_instance.web[0]`,
8
+ * `[1]`, `[2]`. Delete the middle element of the list the count was derived
9
+ * from and every instance after it shifts down one, so Terraform plans to
10
+ * destroy and recreate them all, in order, to move each one's identity onto
11
+ * the next index. `for_each` addresses by key instead, and removing one
12
+ * element removes exactly one instance.
13
+ *
14
+ * The narrow part is which counts get reported. Two conditions must both
15
+ * hold:
16
+ *
17
+ * 1. The count is plural and derived, not a switch. A numeric literal of two
18
+ * or more, or `length(var.x)` / `length(local.x)`. `count = 0`, `count =
19
+ * 1` and `count = var.enabled ? 1 : 0` are the conditional-resource idiom,
20
+ * which `for_each` does not replace, and they are never reported.
21
+ * 2. The resource uses `count.index` to build something that carries the
22
+ * instance's identity: a name, a bucket, a key, a `tags.Name`. That is what
23
+ * makes a shift destructive; a `count.index` used only to pick a subnet out
24
+ * of a list is reindexed silently and harmlessly.
25
+ *
26
+ * Redeploy's `terraform_prefer_for_each` reports every `count` over a
27
+ * collection, which is a broader rule and a noisier one, so the credit is
28
+ * `overlaps` rather than `equivalent`. choudoufu's `RuleCountIndex`
29
+ * (`internal/live/lint/count_index.go`) is the survey's deepest treatment of
30
+ * the identity-bearing question, and it is credited the same way. Report-only
31
+ * either way: a `count` with a stable list behind it is fine forever, and
32
+ * only the person who knows whether that list is stable can say.
33
+ */
34
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
35
+ import { RESOURCE_TYPE } from "../../hcl/parse.js";
36
+ import { walkAttributes } from "./blocks.js";
37
+ /** `length(var.x)` or `length(local.x)`, as hcl2json renders the expression. */
38
+ const LENGTH_OF = /^\$\{?\s*length\(\s*(var|local)\.[A-Za-z_][A-Za-z0-9_-]*\s*\)\s*\}?$/;
39
+ /**
40
+ * Attribute names whose value IS the instance's identity to the provider: a
41
+ * rename destroys and recreates. Matched on the last path segment, so
42
+ * `tags.Name` and `labels.name` are covered without listing every container.
43
+ */
44
+ const IDENTITY_ATTRIBUTES = new Set([
45
+ "name",
46
+ "bucket",
47
+ "key",
48
+ "identifier",
49
+ "hostname",
50
+ "domain",
51
+ "domain_name",
52
+ "display_name",
53
+ "resource_name",
54
+ "alias",
55
+ "prefix",
56
+ "path",
57
+ "topic",
58
+ "queue_name",
59
+ "function_name",
60
+ "db_name",
61
+ "repository",
62
+ ]);
63
+ /** The `count` value, when it is one this rule reports on. */
64
+ export function pluralCount(body) {
65
+ const count = body.count;
66
+ if (typeof count === "number")
67
+ return count >= 2 ? String(count) : undefined;
68
+ if (typeof count !== "string")
69
+ return undefined;
70
+ if (LENGTH_OF.test(count.trim()))
71
+ return count.replace(/^\$\{?/, "").replace(/\}$/, "");
72
+ const literal = /^\$\{?\s*(\d+)\s*\}?$/.exec(count.trim());
73
+ return literal && Number(literal[1]) >= 2 ? literal[1] : undefined;
74
+ }
75
+ /** Identity-bearing attributes of this body whose value is built from `count.index`. */
76
+ export function identityUsesOfCountIndex(body) {
77
+ const out = [];
78
+ for (const attr of walkAttributes(body)) {
79
+ if (typeof attr.value !== "string" || !attr.value.includes("count.index"))
80
+ continue;
81
+ if (!IDENTITY_ATTRIBUTES.has(attr.name.toLowerCase()))
82
+ continue;
83
+ out.push(attr.path);
84
+ }
85
+ return out;
86
+ }
87
+ export const tf021 = {
88
+ id: "TF021",
89
+ description: "count builds instance identities from count.index where for_each is safer",
90
+ check(ctx) {
91
+ const diagnostics = [];
92
+ for (const [key, entity] of ctx.entities) {
93
+ if (entity.entityType !== RESOURCE_TYPE || !isResourceDeclarable(entity))
94
+ continue;
95
+ const props = entity.props;
96
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
97
+ const count = pluralCount(body);
98
+ if (count === undefined)
99
+ continue;
100
+ const uses = identityUsesOfCountIndex(body);
101
+ if (uses.length === 0)
102
+ continue;
103
+ const address = typeof props.address === "string" ? props.address : key;
104
+ diagnostics.push({
105
+ checkId: "TF021",
106
+ severity: "warning",
107
+ message: `Resource "${address}" has \`count = ${count}\` and builds ${uses.sort().join(", ")} from ` +
108
+ "`count.index`, so each instance's identity is its position in the list. Removing an element " +
109
+ "shifts every later instance down one index, and Terraform destroys and recreates all of them " +
110
+ "to match. Use `for_each` over a map or set so each instance is addressed by a stable key.",
111
+ entity: key,
112
+ lexicon: "terraform",
113
+ });
114
+ }
115
+ return diagnostics;
116
+ },
117
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * TF022: a credential-named attribute of a resource or data source holds a
3
+ * literal.
4
+ *
5
+ * tfsec's `general-secrets-sensitive-in-attribute`, consolidated in its v1
6
+ * line as `no-plaintext-exposure`, and KICS's `Passwords And Secrets` query.
7
+ * Broader and noisier than TF007, because a resource body has far more
8
+ * attributes than a variable has defaults, so the name heuristic does the
9
+ * gating: an attribute has to read like a credential AND hold a committed
10
+ * literal before it is reported.
11
+ *
12
+ * The value is in git history, in state, and in every plan output, and the
13
+ * blast radius is whatever the resource is: an RDS master password, a
14
+ * provider API token, a Kubernetes secret's data. The fix is always the same
15
+ * shape, a reference rather than a constant, which is what the message says
16
+ * rather than "remove this".
17
+ *
18
+ * Scope: root and child modules alike (#2112). The condition is a property
19
+ * of the block itself, so a descended module's block is read exactly as a
20
+ * root's is.
21
+ */
22
+ import { DATA_TYPE, RESOURCE_TYPE } from "../../hcl/parse.js";
23
+ import { isSecretName, secretShapedAssignment } from "../secret-shape.js";
24
+ import { blocksOfTypes, walkAttributes } from "./blocks.js";
25
+ export const tf022 = {
26
+ id: "TF022",
27
+ description: "Credential-named resource attribute holds a plaintext literal",
28
+ check(ctx) {
29
+ const diagnostics = [];
30
+ for (const block of blocksOfTypes(ctx, [RESOURCE_TYPE, DATA_TYPE])) {
31
+ for (const attr of walkAttributes(block.body)) {
32
+ if (!isSecretName(attr.name))
33
+ continue;
34
+ const shape = secretShapedAssignment(attr.name, attr.value);
35
+ if (!shape)
36
+ continue;
37
+ diagnostics.push({
38
+ checkId: "TF022",
39
+ severity: "error",
40
+ message: `"${block.address}" sets \`${attr.path}\` to a plaintext literal (${shape.length} ` +
41
+ "characters, redacted here). The value is in git history, in state, and in plan output. " +
42
+ "Replace it with a reference: a `sensitive` variable, or a data source that reads the " +
43
+ "value from a secret manager at apply time.",
44
+ entity: block.key,
45
+ lexicon: "terraform",
46
+ });
47
+ }
48
+ }
49
+ return diagnostics;
50
+ },
51
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * TF024: a live root declares a `backend` or `cloud` block.
3
+ *
4
+ * choudoufu refuses this combination at `init`, before any command runs,
5
+ * naming both blocks that collide: "Both a backend and a live configuration
6
+ * are present" (a `backend` block) or "Both a cloud and a live configuration
7
+ * are present" (a `cloud` block). A live root keeps no state to store, since
8
+ * prior state is a projection rebuilt from the live system every run, so
9
+ * either block is a contradiction about where the truth lives, not a
10
+ * redundant setting.
11
+ *
12
+ * TF001 is the equivalent check for a stock root (no remote backend is
13
+ * configured); it explicitly does not fire on a live root, and this check is
14
+ * why: the two are mutually exclusive by construction; a root is flagged by
15
+ * at most one of them.
16
+ *
17
+ * Scope: root modules only (#2112). A child module's backend or cloud block
18
+ * is TF015's finding, whatever binary the root runs.
19
+ *
20
+ * One diagnostic per root, fired from the root's `terraform` block.
21
+ */
22
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
23
+ import { TERRAFORM_TYPE } from "../../hcl/parse.js";
24
+ import { hasBlock } from "./tf001.js";
25
+ import { isRootScoped } from "./scope.js";
26
+ export const tf024 = {
27
+ id: "TF024",
28
+ description: "Live root declares a backend or cloud block, which choudoufu refuses",
29
+ check(ctx) {
30
+ const diagnostics = [];
31
+ const flagged = new Set();
32
+ for (const [name, entity] of ctx.entities) {
33
+ if (entity.entityType !== TERRAFORM_TYPE)
34
+ continue;
35
+ if (!isResourceDeclarable(entity) || !isRootScoped(entity))
36
+ continue;
37
+ const props = entity.props;
38
+ if (props.mode !== "live")
39
+ continue;
40
+ const root = typeof props.root === "string" ? props.root : "";
41
+ if (flagged.has(root))
42
+ continue;
43
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
44
+ const hasBackend = hasBlock(body, "backend");
45
+ const hasCloud = hasBlock(body, "cloud");
46
+ if (!hasBackend && !hasCloud)
47
+ continue;
48
+ flagged.add(root);
49
+ const blockKind = hasBackend ? "backend" : "cloud";
50
+ diagnostics.push({
51
+ checkId: "TF024",
52
+ severity: "error",
53
+ message: `Root module "${root}" runs choudoufu with a declared estate and also declares a ` +
54
+ `${blockKind} block. choudoufu refuses this at init: "Both a ${blockKind} and a live ` +
55
+ `configuration are present." Remove the ${blockKind} block; a live root keeps no state to store.`,
56
+ entity: name,
57
+ lexicon: "terraform",
58
+ });
59
+ }
60
+ return diagnostics;
61
+ },
62
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * TF025: a live root reads a non-default `terraform.workspace`, or configures
3
+ * one through `terraform.roots`.
4
+ *
5
+ * choudoufu refuses any workspace but `default` on a live root, and refuses
6
+ * `workspace new`/`workspace select` outright: only the default workspace
7
+ * exists there ("How you run it" in choudoufu's compatibility reference).
8
+ * Two independent ways a configuration ends up asking for one:
9
+ *
10
+ * - `terraform.roots.<name>.workspace` in `chant.config.*` selects a
11
+ * workspace via `TF_WORKSPACE` before any HCL is even read, chant's own
12
+ * equivalent of `workspace select`, so it is flagged the same way.
13
+ * - A `"${terraform.workspace}"` interpolation anywhere in a block's body
14
+ * reads the (necessarily `"default"`) workspace name in configuration,
15
+ * which is not itself refused by choudoufu but is dead code on a live
16
+ * root and a sign the configuration was written for a workspace-per-
17
+ * environment layout that live mode does not support.
18
+ *
19
+ * The first fires once per root (it names a root-level config field, not a
20
+ * particular block); the second fires once per block that contains the
21
+ * reference, since each is its own thing to fix.
22
+ *
23
+ * Scope: the config half is root-only, since it reads a root-level config
24
+ * field; the reference half reads any block of a live root, a descended child
25
+ * module's included, and names the call chain that reached it (#2112).
26
+ */
27
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
28
+ import { isRootScoped } from "./scope.js";
29
+ /** Recursively search a parsed HCL body for a `terraform.workspace` reference, un-evaluated interpolations included. */
30
+ function containsWorkspaceRef(value) {
31
+ if (typeof value === "string")
32
+ return /\bterraform\.workspace\b/.test(value);
33
+ if (Array.isArray(value))
34
+ return value.some(containsWorkspaceRef);
35
+ if (value !== null && typeof value === "object")
36
+ return Object.values(value).some(containsWorkspaceRef);
37
+ return false;
38
+ }
39
+ export const tf025 = {
40
+ id: "TF025",
41
+ description: "Live root references a non-default terraform.workspace, which choudoufu refuses",
42
+ check(ctx) {
43
+ const diagnostics = [];
44
+ const flaggedRootWorkspace = new Set();
45
+ for (const [name, entity] of ctx.entities) {
46
+ if (!isResourceDeclarable(entity))
47
+ continue;
48
+ const props = entity.props;
49
+ if (props.mode !== "live")
50
+ continue;
51
+ const root = typeof props.root === "string" ? props.root : "";
52
+ if (typeof props.workspace === "string" &&
53
+ props.workspace !== "" &&
54
+ props.workspace !== "default" &&
55
+ isRootScoped(entity) &&
56
+ !flaggedRootWorkspace.has(root)) {
57
+ flaggedRootWorkspace.add(root);
58
+ diagnostics.push({
59
+ checkId: "TF025",
60
+ severity: "error",
61
+ message: `Root module "${root}" runs choudoufu with a declared estate and configures workspace ` +
62
+ `"${props.workspace}" in terraform.roots.${root}. choudoufu refuses any workspace but ` +
63
+ '"default" on a live root. Remove `workspace` from this root\'s config (or set it to "default").',
64
+ entity: name,
65
+ lexicon: "terraform",
66
+ });
67
+ }
68
+ if (containsWorkspaceRef(props.body)) {
69
+ const address = typeof props.address === "string" ? props.address : name;
70
+ diagnostics.push({
71
+ checkId: "TF025",
72
+ severity: "error",
73
+ message: `"${address}" in root module "${root}" references \`terraform.workspace\`. choudoufu refuses ` +
74
+ 'any workspace but "default" on a live root, so this always resolves to "default" there. ' +
75
+ "Remove the reference.",
76
+ entity: name,
77
+ lexicon: "terraform",
78
+ });
79
+ }
80
+ }
81
+ return diagnostics;
82
+ },
83
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * TF026: a live root declares `delete: "never"` but its `policy` block still
3
+ * deletes an orphaned marked resource.
4
+ *
5
+ * chant's cross-lexicon delete-mode vocabulary (`terraform.roots.<name>.delete`,
6
+ * `../../config.ts`'s `TerraformDeleteMode`) maps onto choudoufu's `policy`
7
+ * block: `undeclared_tagged` governs what happens to a resource this estate
8
+ * marked that the configuration no longer declares, and defaults to
9
+ * `"delete"` when the `policy` block omits it, or the root declares no
10
+ * `policy` block at all. `delete: "never"` promises that `TerraformApplyOp`
11
+ * never proposes deleting a resource it owns, so a root making that promise
12
+ * has to have actually turned the quadrant's default off — `"keep"`,
13
+ * `"untag"` or `"report"` all satisfy it; anything else (the unset default,
14
+ * or an explicit `"delete"`) does not.
15
+ *
16
+ * One diagnostic per root, fired from the root's `Terraform::Live` entity —
17
+ * the same entity TF024 and TF025 fire from.
18
+ */
19
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
20
+ import { LIVE_TYPE } from "../../hcl/parse.js";
21
+ /** Verbs that satisfy `delete: "never"` for the `undeclared_tagged` quadrant. */
22
+ const SATISFIES_NEVER = new Set(["keep", "untag", "report"]);
23
+ /**
24
+ * The `policy { }` block nested in a `live { }` block's body, hcl2json's own
25
+ * "one block, wrapped in an array" shape. Exported for TF027 (`./tf027.ts`),
26
+ * which reads the other quadrant of the same block.
27
+ */
28
+ export function policyBlockOf(liveBody) {
29
+ const policy = liveBody["policy"];
30
+ return Array.isArray(policy) && policy.length > 0 && typeof policy[0] === "object" && policy[0] !== null
31
+ ? policy[0]
32
+ : undefined;
33
+ }
34
+ export const tf026 = {
35
+ id: "TF026",
36
+ description: "Live root declares delete: \"never\" but its policy leaves undeclared_tagged at \"delete\"",
37
+ check(ctx) {
38
+ const diagnostics = [];
39
+ const flagged = new Set();
40
+ for (const [name, entity] of ctx.entities) {
41
+ if (entity.entityType !== LIVE_TYPE)
42
+ continue;
43
+ if (!isResourceDeclarable(entity))
44
+ continue;
45
+ const props = entity.props;
46
+ if (props.mode !== "live")
47
+ continue;
48
+ if (props.delete !== "never")
49
+ continue;
50
+ const root = typeof props.root === "string" ? props.root : "";
51
+ if (flagged.has(root))
52
+ continue;
53
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
54
+ const policy = policyBlockOf(body);
55
+ const undeclaredTagged = policy && typeof policy["undeclared_tagged"] === "string" ? policy["undeclared_tagged"] : undefined;
56
+ if (undeclaredTagged !== undefined && SATISFIES_NEVER.has(undeclaredTagged))
57
+ continue;
58
+ flagged.add(root);
59
+ const found = undeclaredTagged === undefined
60
+ ? "leaves undeclared_tagged unset, which defaults to \"delete\""
61
+ : `sets undeclared_tagged = "${undeclaredTagged}"`;
62
+ diagnostics.push({
63
+ checkId: "TF026",
64
+ severity: "error",
65
+ message: `Root module "${root}" declares delete: "never" (terraform.roots.${root}.delete), but its ` +
66
+ `policy block ${found}. Add undeclared_tagged = "keep" (or "untag" or "report") to the live ` +
67
+ "block's policy block, or change this root's delete mode.",
68
+ entity: name,
69
+ lexicon: "terraform",
70
+ });
71
+ }
72
+ return diagnostics;
73
+ },
74
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TF027: a live root's `policy` block sets `undeclared_untagged = "delete"`.
3
+ *
4
+ * The `undeclared_untagged` quadrant covers a live resource this estate never
5
+ * marked and the configuration does not declare, a resource chant does not
6
+ * own. `"delete"` there is account-scoped reconciliation: choudoufu compares
7
+ * the configuration against everything a `scope` block reaches and removes
8
+ * what is not in it. chant never proposes deleting a resource it does not
9
+ * own, and neither does an Op it generates, so the setting is refused on any
10
+ * live root the project builds, scoped or not. Narrowing the estate's own
11
+ * ownership answer (`undeclared_tagged`, which TF026 checks against
12
+ * `delete: "never"`) is the setting to reach for instead.
13
+ *
14
+ * `TerraformApplyOp` (`../../composites/terraform-apply-op.ts`) makes the
15
+ * same refusal when it builds, but only for a project on a `chant.config.json`
16
+ * whose root it can resolve synchronously (#2216): a `chant.config.ts` is
17
+ * project-authored code that `resolveRootModeSync` will not evaluate, so the
18
+ * composite reads the root as stock and never asks the policy question. This
19
+ * check reads `props.mode` off the parsed HCL instead, which the build stamps
20
+ * on every entity whatever the config file is written in, so it is the
21
+ * refusal that actually fires. It also fires for a live root with no apply Op
22
+ * at all, since the setting is a statement about the estate rather than about
23
+ * one Op.
24
+ *
25
+ * One diagnostic per root, fired from the root's `Terraform::Live` entity,
26
+ * the same entity TF024, TF025 and TF026 fire from.
27
+ */
28
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
29
+ import { LIVE_TYPE } from "../../hcl/parse.js";
30
+ import { policyBlockOf } from "./tf026.js";
31
+ export const tf027 = {
32
+ id: "TF027",
33
+ description: "Live root's policy block sets undeclared_untagged = \"delete\", which deletes resources chant does not own",
34
+ check(ctx) {
35
+ const diagnostics = [];
36
+ const flagged = new Set();
37
+ for (const [name, entity] of ctx.entities) {
38
+ if (entity.entityType !== LIVE_TYPE)
39
+ continue;
40
+ if (!isResourceDeclarable(entity))
41
+ continue;
42
+ const props = entity.props;
43
+ if (props.mode !== "live")
44
+ continue;
45
+ const root = typeof props.root === "string" ? props.root : "";
46
+ if (flagged.has(root))
47
+ continue;
48
+ const body = (typeof props.body === "object" && props.body !== null ? props.body : {});
49
+ const policy = policyBlockOf(body);
50
+ if (!policy || policy["undeclared_untagged"] !== "delete")
51
+ continue;
52
+ flagged.add(root);
53
+ diagnostics.push({
54
+ checkId: "TF027",
55
+ severity: "error",
56
+ message: `Root module "${root}"'s policy block sets undeclared_untagged = "delete" (account-scoped ` +
57
+ "reconciliation, scoped by a `scope` block). chant never proposes deleting a resource it does " +
58
+ "not own, and neither does an Op it generates. Remove that setting from the policy block, or " +
59
+ "narrow the estate's ownership answer (undeclared_tagged) instead of the account's.",
60
+ entity: name,
61
+ lexicon: "terraform",
62
+ });
63
+ }
64
+ return diagnostics;
65
+ },
66
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * TF028: a live root is watched by a `TerraformWatchOp` built in stock mode.
3
+ *
4
+ * `TerraformWatchOp`'s `live` flag is declared, not detected: a composite is
5
+ * built by `chant build` without reading a root module's `.tf` files, so the
6
+ * Op author says which shape the Plan phase takes and, until #2216, nothing
7
+ * checked the answer against the root. Leaving it off a live root is silent
8
+ * and lossy. `live: true` swaps the Plan step for `choudoufuLivePlan`, whose
9
+ * single read of the live system answers three questions (drift, how many
10
+ * live resources sit at a declared identity carrying no marker, and how many
11
+ * of those an exact content match makes claimable); a stock `terraformPlan`
12
+ * step answers the first alone, so the watch reports drift and stays quiet
13
+ * about every unowned and adoptable resource in the estate.
14
+ *
15
+ * The check runs over the whole built graph because that is where both halves
16
+ * of the question are: the root's mode is stamped on every parsed entity by
17
+ * the build (`props.mode`, #2103), and the Op is a `Chant::Op` entity carrying
18
+ * the composite's own labels. `TerraformWatchOp` itself cross-checks the flag
19
+ * where it can (`../../op/resolve-root-mode.ts`), but that read is a
20
+ * `chant.config.json`-only one, so on a `chant.config.ts` project, which is
21
+ * every project in this repository, this check is what actually fires.
22
+ *
23
+ * Read off the labels the composite stamps rather than off the emitted step,
24
+ * since a stock `terraformPlan -out` step against a live root is correct in
25
+ * `TerraformApplyOp`: choudoufu v0.13.0 accepts `plan -out` and re-plans
26
+ * against the file at apply (#2157), so the apply Op builds one shape for
27
+ * both kinds of root and the step alone says nothing about a mistake.
28
+ *
29
+ * One diagnostic per Op, fired from the Op's entity.
30
+ */
31
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
32
+ import { isOpEntity } from "@intentius/chant/op/resource";
33
+ /** Every root name the build parsed as live: `terraform.binary: "choudoufu"` plus a declared estate. */
34
+ function liveRootsOf(ctx) {
35
+ const roots = new Set();
36
+ for (const entity of ctx.entities.values()) {
37
+ if (!isResourceDeclarable(entity))
38
+ continue;
39
+ const props = entity.props;
40
+ if (props.mode !== "live" || typeof props.root !== "string")
41
+ continue;
42
+ roots.add(props.root);
43
+ }
44
+ return roots;
45
+ }
46
+ export const tf028 = {
47
+ id: "TF028",
48
+ description: "A live root is watched by a TerraformWatchOp built without live: true",
49
+ check(ctx) {
50
+ const liveRoots = liveRootsOf(ctx);
51
+ if (liveRoots.size === 0)
52
+ return [];
53
+ const diagnostics = [];
54
+ for (const [name, entity] of ctx.entities) {
55
+ if (!isOpEntity(entity))
56
+ continue;
57
+ const props = entity.props;
58
+ if (!props || typeof props.name !== "string")
59
+ continue;
60
+ const labels = (typeof props.labels === "object" && props.labels !== null
61
+ ? props.labels
62
+ : {});
63
+ if (labels.Watch !== "true")
64
+ continue;
65
+ if (labels.TerraformMode === "live")
66
+ continue;
67
+ const root = labels.TerraformRoot;
68
+ if (typeof root !== "string" || !liveRoots.has(root))
69
+ continue;
70
+ diagnostics.push({
71
+ checkId: "TF028",
72
+ severity: "error",
73
+ message: `Op "${props.name}" watches root module "${root}" in stock mode, but that root runs choudoufu ` +
74
+ "with a declared estate. A stock plan reports drift alone and stays quiet about every unowned " +
75
+ `and adoptable resource in the estate. Set live: true on this TerraformWatchOp, or drop the ` +
76
+ "root's estate if it is not a live root.",
77
+ entity: name,
78
+ lexicon: "terraform",
79
+ });
80
+ }
81
+ return diagnostics;
82
+ },
83
+ };