@intentius/chant-lexicon-terraform 0.58.0 → 0.59.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/README.md +9 -0
- package/dist/composites/terraform-adopt-op.d.ts +7 -2
- package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
- package/dist/composites/terraform-apply-op.d.ts +8 -2
- package/dist/composites/terraform-apply-op.d.ts.map +1 -1
- package/dist/composites/terraform-watch-op.d.ts +30 -7
- package/dist/composites/terraform-watch-op.d.ts.map +1 -1
- package/dist/hcl/parse.d.ts +14 -0
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/integrity.json +7 -5
- package/dist/lint/audit-catalog.d.ts +2 -2
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +39 -12
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/tf001.d.ts +40 -6
- package/dist/lint/post-synth/tf001.d.ts.map +1 -1
- package/dist/lint/post-synth/tf015.d.ts +11 -2
- package/dist/lint/post-synth/tf015.d.ts.map +1 -1
- package/dist/lint/post-synth/tf026.d.ts +7 -0
- package/dist/lint/post-synth/tf026.d.ts.map +1 -1
- package/dist/lint/post-synth/tf027.d.ts +30 -0
- package/dist/lint/post-synth/tf027.d.ts.map +1 -0
- package/dist/lint/post-synth/tf028.d.ts +33 -0
- package/dist/lint/post-synth/tf028.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 +2 -0
- package/dist/okf/rules/TF027.md +11 -0
- package/dist/okf/rules/TF028.md +11 -0
- package/dist/op/activities/terraform.d.ts +32 -11
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/builders.d.ts +7 -5
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/resolve-root-mode.d.ts +29 -15
- package/dist/op/resolve-root-mode.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/tf001.ts +70 -14
- package/dist/rules/tf015.ts +11 -2
- package/dist/rules/tf026.ts +6 -2
- package/dist/rules/tf027.ts +74 -0
- package/dist/rules/tf028.ts +96 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/ACCEPTANCE.md +181 -0
- package/src/__fixtures__/remote-backend/main.tf +34 -0
- package/src/__fixtures__/with-backend/main.tf +7 -0
- package/src/codegen/lint-rules-page.test.ts +18 -1
- package/src/composites/terraform-adopt-op.acceptance.test.ts +46 -19
- package/src/composites/terraform-adopt-op.test.ts +9 -4
- package/src/composites/terraform-adopt-op.ts +8 -3
- package/src/composites/terraform-apply-op.acceptance.test.ts +14 -9
- package/src/composites/terraform-apply-op.test.ts +13 -5
- package/src/composites/terraform-apply-op.ts +23 -7
- package/src/composites/terraform-watch-op.test.ts +85 -4
- package/src/composites/terraform-watch-op.ts +59 -8
- package/src/hcl/parse.ts +20 -0
- package/src/lint/audit-catalog.ts +26 -2
- package/src/lint/audit-lineage.ts +79 -14
- package/src/lint/audit.test.ts +171 -3
- package/src/lint/post-synth/fixtures/TF001/negative-cloud.tf +28 -0
- package/src/lint/post-synth/fixtures/TF001/negative.tf +4 -2
- package/src/lint/post-synth/fixtures/TF001/positive-local.tf +31 -0
- package/src/lint/post-synth/fixtures/TF014/negative/main.tf +2 -2
- package/src/lint/post-synth/fixtures/TF014/positive/main.tf +2 -2
- package/src/lint/post-synth/fixtures/TF020/scoped/main.tf +2 -2
- package/src/lint/post-synth/fixtures/TF027/live-default/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF027/live-untagged-delete/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF027/live-untagged-delete-scoped/main.tf +17 -0
- package/src/lint/post-synth/fixtures/TF027/live-untagged-report/main.tf +13 -0
- package/src/lint/post-synth/fixtures/TF028/live/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF028/stock/main.tf +10 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/live-refusals-config-format.test.ts +143 -0
- package/src/lint/post-synth/module-rules.test.ts +1 -1
- package/src/lint/post-synth/post-synth.test.ts +64 -6
- package/src/lint/post-synth/tf001.ts +70 -14
- package/src/lint/post-synth/tf015.ts +11 -2
- package/src/lint/post-synth/tf026.ts +6 -2
- package/src/lint/post-synth/tf027.test.ts +62 -0
- package/src/lint/post-synth/tf027.ts +74 -0
- package/src/lint/post-synth/tf028.test.ts +92 -0
- package/src/lint/post-synth/tf028.ts +96 -0
- package/src/lsp/option-keys.ts +3 -2
- package/src/op/activities/choudoufu.acceptance.test.ts +7 -23
- package/src/op/activities/choudoufu.test.ts +49 -17
- package/src/op/activities/terraform.ts +67 -29
- package/src/op/builders.ts +7 -5
- package/src/op/resolve-root-mode.ts +29 -15
- package/src/plugin.test.ts +38 -0
- package/src/plugin.ts +122 -31
- package/src/validate.ts +10 -1
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TF001: a root module keeps its state on local disk.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and
|
|
4
|
+
* Two ways a root gets there, and this reports both (#2218). A `terraform`
|
|
5
|
+
* block with no `backend "<type>"` and no `cloud {}` falls back to the local
|
|
6
|
+
* backend implicitly. A `backend "local"` block asks for the same backend by
|
|
7
|
+
* name. Either way `terraform.tfstate` lands in the working directory, and
|
|
8
|
+
* that file holds every attribute of every managed resource, secrets
|
|
9
|
+
* included, and it is not shared, not locked, and not versioned. The first
|
|
10
|
+
* apply from a second machine or a CI runner starts from an empty state and
|
|
11
|
+
* proposes to create the estate again.
|
|
12
|
+
*
|
|
13
|
+
* So the rule reads the backend block's TYPE LABEL rather than its presence:
|
|
14
|
+
* the id means "no remote backend", which is what its name, its message and
|
|
15
|
+
* its page have always argued. `cloud {}` counts as remote (it is Terraform
|
|
16
|
+
* Cloud / HCP state, held off the machine and locked). Every other backend
|
|
17
|
+
* type is taken as remote without a list to maintain, since `local` is the
|
|
18
|
+
* only backend Terraform ships that writes to the working directory.
|
|
19
|
+
*
|
|
20
|
+
* A root that keeps local state on purpose says so with a
|
|
21
|
+
* `# chant-ignore-block: TF001` on the line above its `terraform` block
|
|
22
|
+
* (chant #2111), which is what the three shipped examples do.
|
|
10
23
|
*
|
|
11
24
|
* One diagnostic per root, fired from the root's `terraform` block. A root
|
|
12
25
|
* with no `terraform` block at all is not flagged: it declares no version
|
|
@@ -46,6 +59,39 @@ export function hasBlock(body: BlockBody, key: string): boolean {
|
|
|
46
59
|
return typeof a.value === "object" && a.value !== null ? Object.keys(a.value).length > 0 : true;
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
/** The one backend type that writes state to the working directory. */
|
|
63
|
+
export const LOCAL_BACKEND = "local";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The type labels of the `backend` blocks in a `terraform` block body.
|
|
67
|
+
*
|
|
68
|
+
* `backend` takes one label, so hcl2json nests it one level deeper than an
|
|
69
|
+
* unlabelled block: `backend "s3" { bucket = "b" }` is
|
|
70
|
+
* `{ backend: { s3: [{ bucket: "b" }] } }`, where `cloud { ... }` is just
|
|
71
|
+
* `{ cloud: [{ ... }] }`. The keys of that inner object are the labels, which
|
|
72
|
+
* is the only place the type survives the parse. A malformed body that puts
|
|
73
|
+
* an array or a scalar under `backend` yields no labels, and the caller then
|
|
74
|
+
* treats the root as having no remote backend, the same verdict a missing
|
|
75
|
+
* block gets.
|
|
76
|
+
*/
|
|
77
|
+
export function backendTypes(body: BlockBody): string[] {
|
|
78
|
+
const a = attr(body, "backend");
|
|
79
|
+
if (a.kind === "absent") return [];
|
|
80
|
+
const value = a.value;
|
|
81
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
|
|
82
|
+
return Object.keys(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether the body configures state somewhere other than the working
|
|
87
|
+
* directory: any `backend` block whose type is not `local`, or a `cloud`
|
|
88
|
+
* block.
|
|
89
|
+
*/
|
|
90
|
+
export function hasRemoteBackend(body: BlockBody): boolean {
|
|
91
|
+
if (hasBlock(body, "cloud")) return true;
|
|
92
|
+
return backendTypes(body).some((type) => type !== LOCAL_BACKEND);
|
|
93
|
+
}
|
|
94
|
+
|
|
49
95
|
export const tf001: PostSynthCheck = {
|
|
50
96
|
id: "TF001",
|
|
51
97
|
description: "Root module declares no remote backend",
|
|
@@ -64,23 +110,33 @@ export const tf001: PostSynthCheck = {
|
|
|
64
110
|
if (flagged.has(root)) continue;
|
|
65
111
|
|
|
66
112
|
const body = (typeof props.body === "object" && props.body !== null ? props.body : {}) as BlockBody;
|
|
67
|
-
if (
|
|
113
|
+
if (hasRemoteBackend(body)) continue;
|
|
114
|
+
|
|
115
|
+
// What the root actually declares, so the message names it rather than
|
|
116
|
+
// saying "no remote backend" over a block the reader can see.
|
|
117
|
+
const found = backendTypes(body).includes(LOCAL_BACKEND)
|
|
118
|
+
? 'the backend it declares is `backend "local"`'
|
|
119
|
+
: "there is no backend block at all";
|
|
68
120
|
|
|
69
121
|
flagged.add(root);
|
|
70
122
|
diagnostics.push({
|
|
71
123
|
checkId: "TF001",
|
|
72
124
|
severity: "warning",
|
|
73
125
|
message:
|
|
74
|
-
`Root module "${root}" declares no remote backend. State
|
|
75
|
-
"terraform.tfstate, which is unshared, unlocked and holds every
|
|
76
|
-
'plaintext.
|
|
126
|
+
`Root module "${root}" declares no remote backend: ${found}. State is a ` +
|
|
127
|
+
"terraform.tfstate in the working directory, which is unshared, unlocked and holds every " +
|
|
128
|
+
'resource attribute in plaintext. Declare a `backend "<type>"` naming a remote type (`s3`, ' +
|
|
129
|
+
"`gcs`, `azurerm`, `http`) or a `cloud {}` block, or keep the local state on purpose with a " +
|
|
130
|
+
"`# chant-ignore-block: TF001` above the terraform block.",
|
|
77
131
|
entity: name,
|
|
78
132
|
lexicon: "terraform",
|
|
79
|
-
// The missing-resource shape (chant #2113): there is no
|
|
80
|
-
// `cloud` block to point at, only the root that lacks one.
|
|
81
|
-
// above still names the `terraform` block this fired from (by
|
|
133
|
+
// The missing-resource shape (chant #2113): there is no remote
|
|
134
|
+
// `backend`/`cloud` block to point at, only the root that lacks one.
|
|
135
|
+
// `entity` above still names the `terraform` block this fired from (by
|
|
82
136
|
// convenience, not because that block is what's wrong), so `missing`
|
|
83
|
-
// is what a suppression should key on instead.
|
|
137
|
+
// is what a suppression should key on instead. `kind` stays `backend`
|
|
138
|
+
// in both cases: what is absent is a remote backend, whether or not a
|
|
139
|
+
// `backend "local"` block is sitting where one should be.
|
|
84
140
|
missing: { kind: "backend", scope: root },
|
|
85
141
|
});
|
|
86
142
|
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* TF015: a `backend` or `cloud` block inside a child module.
|
|
3
3
|
*
|
|
4
4
|
* Scope: child modules only. This is TF001's mirror image. TF001 wants a root
|
|
5
|
-
* module to declare a backend, because a root without one keeps its
|
|
6
|
-
* local disk. A child module must declare neither: state belongs to
|
|
5
|
+
* module to declare a REMOTE backend, because a root without one keeps its
|
|
6
|
+
* state on local disk. A child module must declare neither: state belongs to
|
|
7
|
+
* the root,
|
|
7
8
|
* one state per root however many modules it calls, and Terraform says so
|
|
8
9
|
* directly, "A backend block can only appear in the root module" and the
|
|
9
10
|
* same for `cloud`. Depending on the version, the block is ignored with a
|
|
@@ -15,6 +16,14 @@
|
|
|
15
16
|
* along. The rest of that block is fine to keep: `required_version` and
|
|
16
17
|
* `required_providers` are meaningful in a child module and are not reported
|
|
17
18
|
* here.
|
|
19
|
+
*
|
|
20
|
+
* Label-blind on purpose (#2218). TF001 had to start reading the backend
|
|
21
|
+
* block's type label, since `local` is the state placement it warns about
|
|
22
|
+
* rather than a cure for it. Nothing of the sort applies here: what a child
|
|
23
|
+
* module may not do is name a backend at all, `local` included, and
|
|
24
|
+
* Terraform's own refusal ("A backend block can only appear in the root
|
|
25
|
+
* module") does not read the label either. `hasBlock` is the right test, and
|
|
26
|
+
* the `positive` fixture's child declares `backend "local"` for that reason.
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
29
|
import type {
|
|
@@ -28,8 +28,12 @@ import { LIVE_TYPE, type BlockBody } from "../../hcl/parse";
|
|
|
28
28
|
/** Verbs that satisfy `delete: "never"` for the `undeclared_tagged` quadrant. */
|
|
29
29
|
const SATISFIES_NEVER = new Set(["keep", "untag", "report"]);
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The `policy { }` block nested in a `live { }` block's body, hcl2json's own
|
|
33
|
+
* "one block, wrapped in an array" shape. Exported for TF027 (`./tf027.ts`),
|
|
34
|
+
* which reads the other quadrant of the same block.
|
|
35
|
+
*/
|
|
36
|
+
export function policyBlockOf(liveBody: BlockBody): BlockBody | undefined {
|
|
33
37
|
const policy = liveBody["policy"];
|
|
34
38
|
return Array.isArray(policy) && policy.length > 0 && typeof policy[0] === "object" && policy[0] !== null
|
|
35
39
|
? (policy[0] as BlockBody)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
5
|
+
import { tf027 } from "./tf027";
|
|
6
|
+
import { parseTerraformRootDir } from "../../hcl/parse";
|
|
7
|
+
|
|
8
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "TF027");
|
|
9
|
+
|
|
10
|
+
async function ctxFor(
|
|
11
|
+
fixture: string,
|
|
12
|
+
opts: { binary?: string } = {},
|
|
13
|
+
root = "estate",
|
|
14
|
+
): Promise<PostSynthContext> {
|
|
15
|
+
const entities = await parseTerraformRootDir(join(fixtures, fixture), root, undefined, {
|
|
16
|
+
binary: opts.binary ?? "choudoufu",
|
|
17
|
+
});
|
|
18
|
+
return { outputs: new Map(), entities } as unknown as PostSynthContext;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('TF027: undeclared_untagged = "delete" on a live root', () => {
|
|
22
|
+
test("fires on a policy block that sets it, naming the root and the setting", async () => {
|
|
23
|
+
const diags = tf027.check(await ctxFor("live-untagged-delete"));
|
|
24
|
+
expect(diags).toHaveLength(1);
|
|
25
|
+
expect(diags[0].checkId).toBe("TF027");
|
|
26
|
+
expect(diags[0].severity).toBe("error");
|
|
27
|
+
expect(diags[0].lexicon).toBe("terraform");
|
|
28
|
+
expect(diags[0].message).toContain('Root module "estate"');
|
|
29
|
+
expect(diags[0].message).toContain('undeclared_untagged = "delete"');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("fires even when a scope block narrows the account reconciliation", async () => {
|
|
33
|
+
const diags = tf027.check(await ctxFor("live-untagged-delete-scoped"));
|
|
34
|
+
expect(diags).toHaveLength(1);
|
|
35
|
+
expect(diags[0].checkId).toBe("TF027");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('passes when the quadrant is set to something else ("report")', async () => {
|
|
39
|
+
expect(tf027.check(await ctxFor("live-untagged-report"))).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("passes when the root declares no policy block at all", async () => {
|
|
43
|
+
expect(tf027.check(await ctxFor("live-default"))).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("does not fire on a stock root: the policy block is inert off choudoufu", async () => {
|
|
47
|
+
expect(tf027.check(await ctxFor("live-untagged-delete", { binary: "terraform" }))).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("fires once per root, not once per live entity", async () => {
|
|
51
|
+
const a = await parseTerraformRootDir(join(fixtures, "live-untagged-delete"), "estate", undefined, {
|
|
52
|
+
binary: "choudoufu",
|
|
53
|
+
});
|
|
54
|
+
const b = await parseTerraformRootDir(join(fixtures, "live-untagged-delete"), "other", undefined, {
|
|
55
|
+
binary: "choudoufu",
|
|
56
|
+
});
|
|
57
|
+
const entities = new Map([...a, ...b]);
|
|
58
|
+
const diags = tf027.check({ outputs: new Map(), entities } as unknown as PostSynthContext);
|
|
59
|
+
expect(diags).toHaveLength(2);
|
|
60
|
+
expect(diags.map((d) => d.message.match(/Root module "([^"]+)"/)?.[1]).sort()).toEqual(["estate", "other"]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
|
|
29
|
+
import type {
|
|
30
|
+
PostSynthCheck,
|
|
31
|
+
PostSynthContext,
|
|
32
|
+
PostSynthDiagnostic,
|
|
33
|
+
} from "@intentius/chant/lint/post-synth";
|
|
34
|
+
import { isResourceDeclarable } from "@intentius/chant/declarable";
|
|
35
|
+
import { LIVE_TYPE, type BlockBody } from "../../hcl/parse";
|
|
36
|
+
import { policyBlockOf } from "./tf026";
|
|
37
|
+
|
|
38
|
+
export const tf027: PostSynthCheck = {
|
|
39
|
+
id: "TF027",
|
|
40
|
+
description: "Live root's policy block sets undeclared_untagged = \"delete\", which deletes resources chant does not own",
|
|
41
|
+
|
|
42
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
43
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
44
|
+
const flagged = new Set<string>();
|
|
45
|
+
|
|
46
|
+
for (const [name, entity] of ctx.entities) {
|
|
47
|
+
if (entity.entityType !== LIVE_TYPE) continue;
|
|
48
|
+
if (!isResourceDeclarable(entity)) continue;
|
|
49
|
+
const props = entity.props as { root?: unknown; body?: unknown; mode?: unknown };
|
|
50
|
+
if (props.mode !== "live") continue;
|
|
51
|
+
const root = typeof props.root === "string" ? props.root : "";
|
|
52
|
+
if (flagged.has(root)) continue;
|
|
53
|
+
|
|
54
|
+
const body = (typeof props.body === "object" && props.body !== null ? props.body : {}) as BlockBody;
|
|
55
|
+
const policy = policyBlockOf(body);
|
|
56
|
+
if (!policy || policy["undeclared_untagged"] !== "delete") continue;
|
|
57
|
+
|
|
58
|
+
flagged.add(root);
|
|
59
|
+
diagnostics.push({
|
|
60
|
+
checkId: "TF027",
|
|
61
|
+
severity: "error",
|
|
62
|
+
message:
|
|
63
|
+
`Root module "${root}"'s policy block sets undeclared_untagged = "delete" (account-scoped ` +
|
|
64
|
+
"reconciliation, scoped by a `scope` block). chant never proposes deleting a resource it does " +
|
|
65
|
+
"not own, and neither does an Op it generates. Remove that setting from the policy block, or " +
|
|
66
|
+
"narrow the estate's ownership answer (undeclared_tagged) instead of the account's.",
|
|
67
|
+
entity: name,
|
|
68
|
+
lexicon: "terraform",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return diagnostics;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
5
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
6
|
+
import { tf028 } from "./tf028";
|
|
7
|
+
import { parseTerraformRootDir } from "../../hcl/parse";
|
|
8
|
+
import { TerraformWatchOp, type TerraformWatchOpConfig } from "../../composites/terraform-watch-op";
|
|
9
|
+
import { TerraformApplyOp } from "../../composites/terraform-apply-op";
|
|
10
|
+
|
|
11
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "TF028");
|
|
12
|
+
|
|
13
|
+
/** The entities of one fixture root, parsed the way `buildRoots` parses a configured root. */
|
|
14
|
+
async function rootEntities(fixture: string, root: string, binary = "choudoufu"): Promise<Map<string, Declarable>> {
|
|
15
|
+
return parseTerraformRootDir(join(fixtures, fixture), root, undefined, { binary });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A `Chant::Op` entity from the real composite, keyed the way a discovered Op is. */
|
|
19
|
+
function watchOp(config: TerraformWatchOpConfig): [string, Declarable] {
|
|
20
|
+
const { op } = TerraformWatchOp(config);
|
|
21
|
+
return [config.name, op as unknown as Declarable];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ctxOf(...maps: Array<Map<string, Declarable> | [string, Declarable]>): PostSynthContext {
|
|
25
|
+
const entities = new Map<string, Declarable>();
|
|
26
|
+
for (const m of maps) {
|
|
27
|
+
if (Array.isArray(m)) entities.set(m[0], m[1]);
|
|
28
|
+
else for (const [k, v] of m) entities.set(k, v);
|
|
29
|
+
}
|
|
30
|
+
return { outputs: new Map(), entities } as unknown as PostSynthContext;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("TF028: a live root planned by a stock terraformPlan step", () => {
|
|
34
|
+
test("fires on a TerraformWatchOp built without live: true", async () => {
|
|
35
|
+
const diags = tf028.check(
|
|
36
|
+
ctxOf(await rootEntities("live", "estate"), watchOp({ name: "estate-watch", root: "estate" })),
|
|
37
|
+
);
|
|
38
|
+
expect(diags).toHaveLength(1);
|
|
39
|
+
expect(diags[0].checkId).toBe("TF028");
|
|
40
|
+
expect(diags[0].severity).toBe("error");
|
|
41
|
+
expect(diags[0].lexicon).toBe("terraform");
|
|
42
|
+
expect(diags[0].entity).toBe("estate-watch");
|
|
43
|
+
expect(diags[0].message).toContain('Op "estate-watch"');
|
|
44
|
+
expect(diags[0].message).toContain('root module "estate"');
|
|
45
|
+
expect(diags[0].message).toContain("live: true");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("passes when the same Op is built with live: true", async () => {
|
|
49
|
+
const diags = tf028.check(
|
|
50
|
+
ctxOf(
|
|
51
|
+
await rootEntities("live", "estate"),
|
|
52
|
+
watchOp({ name: "estate-watch", root: "estate", live: true }),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
expect(diags).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("passes on a stock root, which is what terraformPlan is for", async () => {
|
|
59
|
+
const diags = tf028.check(
|
|
60
|
+
ctxOf(await rootEntities("stock", "app", "terraform"), watchOp({ name: "app-watch", root: "app" })),
|
|
61
|
+
);
|
|
62
|
+
expect(diags).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("names the Op that plans the live root, not a sibling Op on another root", async () => {
|
|
66
|
+
const diags = tf028.check(
|
|
67
|
+
ctxOf(
|
|
68
|
+
await rootEntities("live", "estate"),
|
|
69
|
+
await rootEntities("stock", "app", "terraform"),
|
|
70
|
+
watchOp({ name: "estate-watch", root: "estate" }),
|
|
71
|
+
watchOp({ name: "app-watch", root: "app" }),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
expect(diags).toHaveLength(1);
|
|
75
|
+
expect(diags[0].entity).toBe("estate-watch");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("does not fire on a TerraformApplyOp, whose stock plan step is correct on a live root", async () => {
|
|
79
|
+
const { op } = TerraformApplyOp({ name: "estate-apply", root: "estate" });
|
|
80
|
+
const diags = tf028.check(
|
|
81
|
+
ctxOf(await rootEntities("live", "estate"), ["estate-apply", op as unknown as Declarable]),
|
|
82
|
+
);
|
|
83
|
+
expect(diags).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("ignores a build with no live root at all", async () => {
|
|
87
|
+
const diags = tf028.check(
|
|
88
|
+
ctxOf(await rootEntities("stock", "estate", "terraform"), watchOp({ name: "estate-watch", root: "estate" })),
|
|
89
|
+
);
|
|
90
|
+
expect(diags).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
|
|
32
|
+
import type {
|
|
33
|
+
PostSynthCheck,
|
|
34
|
+
PostSynthContext,
|
|
35
|
+
PostSynthDiagnostic,
|
|
36
|
+
} from "@intentius/chant/lint/post-synth";
|
|
37
|
+
import { isResourceDeclarable } from "@intentius/chant/declarable";
|
|
38
|
+
import { isOpEntity } from "@intentius/chant/op/resource";
|
|
39
|
+
|
|
40
|
+
/** The labels `TerraformWatchOp` stamps on the Op it builds (`../../composites/terraform-watch-op.ts`). */
|
|
41
|
+
interface WatchOpLabels {
|
|
42
|
+
Watch?: unknown;
|
|
43
|
+
TerraformRoot?: unknown;
|
|
44
|
+
TerraformMode?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every root name the build parsed as live: `terraform.binary: "choudoufu"` plus a declared estate. */
|
|
48
|
+
function liveRootsOf(ctx: PostSynthContext): Set<string> {
|
|
49
|
+
const roots = new Set<string>();
|
|
50
|
+
for (const entity of ctx.entities.values()) {
|
|
51
|
+
if (!isResourceDeclarable(entity)) continue;
|
|
52
|
+
const props = entity.props as { root?: unknown; mode?: unknown };
|
|
53
|
+
if (props.mode !== "live" || typeof props.root !== "string") continue;
|
|
54
|
+
roots.add(props.root);
|
|
55
|
+
}
|
|
56
|
+
return roots;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const tf028: PostSynthCheck = {
|
|
60
|
+
id: "TF028",
|
|
61
|
+
description: "A live root is watched by a TerraformWatchOp built without live: true",
|
|
62
|
+
|
|
63
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
64
|
+
const liveRoots = liveRootsOf(ctx);
|
|
65
|
+
if (liveRoots.size === 0) return [];
|
|
66
|
+
|
|
67
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
68
|
+
|
|
69
|
+
for (const [name, entity] of ctx.entities) {
|
|
70
|
+
if (!isOpEntity(entity)) continue;
|
|
71
|
+
const props = (entity as { props?: Record<string, unknown> }).props;
|
|
72
|
+
if (!props || typeof props.name !== "string") continue;
|
|
73
|
+
const labels = (typeof props.labels === "object" && props.labels !== null
|
|
74
|
+
? props.labels
|
|
75
|
+
: {}) as WatchOpLabels;
|
|
76
|
+
if (labels.Watch !== "true") continue;
|
|
77
|
+
if (labels.TerraformMode === "live") continue;
|
|
78
|
+
const root = labels.TerraformRoot;
|
|
79
|
+
if (typeof root !== "string" || !liveRoots.has(root)) continue;
|
|
80
|
+
|
|
81
|
+
diagnostics.push({
|
|
82
|
+
checkId: "TF028",
|
|
83
|
+
severity: "error",
|
|
84
|
+
message:
|
|
85
|
+
`Op "${props.name}" watches root module "${root}" in stock mode, but that root runs choudoufu ` +
|
|
86
|
+
"with a declared estate. A stock plan reports drift alone and stays quiet about every unowned " +
|
|
87
|
+
`and adoptable resource in the estate. Set live: true on this TerraformWatchOp, or drop the ` +
|
|
88
|
+
"root's estate if it is not a live root.",
|
|
89
|
+
entity: name,
|
|
90
|
+
lexicon: "terraform",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return diagnostics;
|
|
95
|
+
},
|
|
96
|
+
};
|
package/src/lsp/option-keys.ts
CHANGED
|
@@ -30,11 +30,12 @@ export const ROOT_ENTRY_KEYS: OptionKey[] = [
|
|
|
30
30
|
|
|
31
31
|
/** `TerraformApplyOpConfig`'s keys (`TerraformApplyOp({ <here> })`). */
|
|
32
32
|
export const APPLY_OP_KEYS: OptionKey[] = [
|
|
33
|
-
{ key: "name", detail: "Op name (kebab-case). `
|
|
33
|
+
{ key: "name", detail: "Op name (kebab-case). `gateName` defaults to `approve-<name>`." },
|
|
34
34
|
{ key: "root", detail: "Key into the project's `terraform.roots`." },
|
|
35
35
|
{ key: "planFile", detail: "Plan file written by Plan and consumed by Apply. Default: `chant.tfplan`." },
|
|
36
36
|
{ key: "gate", detail: '"on-destroy" | "always" | "never" — when to emit the approval gate. Default: "on-destroy".' },
|
|
37
|
-
{ key: "
|
|
37
|
+
{ key: "gateName", detail: "The gate's name. Default: `approve-<name>`." },
|
|
38
|
+
{ key: "signalName", detail: "Deprecated spelling of `gateName` (#2202). Accepted through 0.59.0, removed in 0.60.0." },
|
|
38
39
|
{ key: "gateTimeout", detail: "How long a recorded pending gate stays valid, as a duration string. Default: core's own (48h)." },
|
|
39
40
|
{ key: "gateDescription", detail: "Override the gate description shown to the approver." },
|
|
40
41
|
{ key: "upgrade", detail: "`-upgrade` on the Init step: re-resolve provider and module versions." },
|
|
@@ -22,22 +22,16 @@
|
|
|
22
22
|
* port; `http://localhost:<port>` is this variable's value) and export
|
|
23
23
|
* it before running this suite.
|
|
24
24
|
*
|
|
25
|
-
* ## Why this skips today even with both present
|
|
26
|
-
*
|
|
27
|
-
* choudoufu#894: `live-plan -json` is reachable only through the `-estate`
|
|
28
|
-
* form, and that form is refused on a configuration that names its own estate
|
|
29
|
-
* ("Estate named by both the live block and -estate"), while the same root
|
|
30
|
-
* without `-estate` refuses with "Machine-readable output is not available
|
|
31
|
-
* under live resource markers yet". `__fixtures__/live` is exactly that
|
|
32
|
-
* shape, so `choudoufuLivePlan` throws here on any real binary. Found by
|
|
33
|
-
* chant #2104; `../../composites/terraform-adopt-op.acceptance.test.ts`
|
|
34
|
-
* carries the same gate for the same reason. When #894 ships, drop
|
|
35
|
-
* `CHOUDOUFU_894_OPEN` from both and the suites run as written.
|
|
36
|
-
*
|
|
37
25
|
* Gating copied from `../../composites/terraform-apply-op.acceptance.test.ts`
|
|
38
26
|
* (`onPath`), which in turn copies `lexicons/k3s/src/serializer.acceptance.
|
|
39
27
|
* test.ts`'s pattern of skipping with the reason named in the describe title
|
|
40
28
|
* rather than failing when the real dependency is absent.
|
|
29
|
+
*
|
|
30
|
+
* `../../__fixtures__/ACCEPTANCE.md` records what this block has last passed
|
|
31
|
+
* against. The `live-plan` half was gated shut on choudoufu#894 until
|
|
32
|
+
* v0.14.0, which made the `-json` document reachable on a configuration that
|
|
33
|
+
* names its own estate and kept stdout to it (choudoufu PR 915); chant #2168
|
|
34
|
+
* dropped the gate and recorded the first run.
|
|
41
35
|
*/
|
|
42
36
|
|
|
43
37
|
import { execSync } from "node:child_process";
|
|
@@ -59,21 +53,11 @@ function onPath(cmd: string): boolean {
|
|
|
59
53
|
const hasChoudoufu = onPath("choudoufu");
|
|
60
54
|
const emulatorEndpoint = process.env.CHOUDOUFU_EMULATOR_ENDPOINT;
|
|
61
55
|
|
|
62
|
-
/**
|
|
63
|
-
* https://github.com/INTENTIUS/choudoufu/issues/894. Flip to `false` when the
|
|
64
|
-
* `-json` document becomes reachable on a root that declares its own estate.
|
|
65
|
-
* chant #2168 tracks this and the other four reversals that unblock together.
|
|
66
|
-
*/
|
|
67
|
-
const CHOUDOUFU_894_OPEN = true;
|
|
68
|
-
|
|
69
56
|
const skipReason = !hasChoudoufu
|
|
70
57
|
? "no choudoufu binary on PATH"
|
|
71
58
|
: !emulatorEndpoint
|
|
72
59
|
? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
|
|
73
|
-
:
|
|
74
|
-
? "choudoufu#894: live-plan -json is refused on a configuration that declares its own estate, " +
|
|
75
|
-
"so the fixture's live plan cannot produce the #788 document yet"
|
|
76
|
-
: "";
|
|
60
|
+
: "";
|
|
77
61
|
|
|
78
62
|
const FIXTURE = join(import.meta.dirname, "..", "..", "__fixtures__", "live");
|
|
79
63
|
const workspaces: string[] = [];
|