@intentius/chant-lexicon-terraform 0.57.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 +53 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts +11 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +3 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +28 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +17 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/composites/terraform-apply-op.d.ts +88 -0
- package/dist/composites/terraform-apply-op.d.ts.map +1 -0
- package/dist/composites/terraform-watch-op.d.ts +124 -0
- package/dist/composites/terraform-watch-op.d.ts.map +1 -0
- package/dist/config.d.ts +83 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +127 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/generated/index.d.ts +2 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/hcl/parse.d.ts +87 -0
- package/dist/hcl/parse.d.ts.map +1 -0
- package/dist/hcl/roots.d.ts +37 -0
- package/dist/hcl/roots.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +12 -0
- package/dist/lint/audit-catalog.d.ts +12 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/audit-lineage.d.ts +21 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/post-synth/tf001.d.ts +17 -0
- package/dist/lint/post-synth/tf001.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +5 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/plan-before-apply.d.ts +30 -0
- package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +26 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/context.d.ts +70 -0
- package/dist/lsp/context.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +12 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/lsp/option-keys.d.ts +34 -0
- package/dist/lsp/option-keys.d.ts.map +1 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +1 -0
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/TF001.md +11 -0
- package/dist/okf/rules/TF101.md +11 -0
- package/dist/op/activities/index.d.ts +22 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/terraform.d.ts +213 -0
- package/dist/op/activities/terraform.d.ts.map +1 -0
- package/dist/op/builders.d.ts +56 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/rules/plan-before-apply.ts +143 -0
- package/dist/rules/tf001.ts +66 -0
- package/dist/serializer.d.ts +19 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/skills/chant-terraform.md +92 -0
- package/dist/state-ownership.d.ts +27 -0
- package/dist/state-ownership.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +75 -0
- package/src/__fixtures__/no-backend/main.tf +24 -0
- package/src/__fixtures__/show-state.json +68 -0
- package/src/__fixtures__/with-backend/main.tf +28 -0
- package/src/__fixtures__/with-module/main.tf +49 -0
- package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
- package/src/codegen/docs-cli.ts +4 -0
- package/src/codegen/docs.ts +50 -0
- package/src/codegen/generate-cli.ts +10 -0
- package/src/codegen/generate.ts +68 -0
- package/src/codegen/package.ts +50 -0
- package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
- package/src/composites/terraform-apply-op.test.ts +193 -0
- package/src/composites/terraform-apply-op.ts +166 -0
- package/src/composites/terraform-watch-op.test.ts +184 -0
- package/src/composites/terraform-watch-op.ts +204 -0
- package/src/config.ts +76 -0
- package/src/describe-resources.test.ts +342 -0
- package/src/describe-resources.ts +357 -0
- package/src/generated/index.d.ts +2 -0
- package/src/generated/index.ts +4 -0
- package/src/generated/lexicon-terraform.json +1 -0
- package/src/hcl/parse.ts +235 -0
- package/src/hcl/roots.ts +67 -0
- package/src/index.ts +65 -0
- package/src/lint/audit-catalog.ts +30 -0
- package/src/lint/audit-lineage.ts +21 -0
- package/src/lint/audit.test.ts +45 -0
- package/src/lint/post-synth/index.ts +7 -0
- package/src/lint/post-synth/post-synth.test.ts +95 -0
- package/src/lint/post-synth/tf001.ts +66 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/plan-before-apply.test.ts +111 -0
- package/src/lint/rules/plan-before-apply.ts +143 -0
- package/src/lsp/completions.test.ts +120 -0
- package/src/lsp/completions.ts +101 -0
- package/src/lsp/context.test.ts +152 -0
- package/src/lsp/context.ts +349 -0
- package/src/lsp/hover.test.ts +82 -0
- package/src/lsp/hover.ts +44 -0
- package/src/lsp/option-keys.ts +106 -0
- package/src/op/activities/index.ts +48 -0
- package/src/op/activities/registry.test.ts +29 -0
- package/src/op/activities/terraform.test.ts +445 -0
- package/src/op/activities/terraform.ts +469 -0
- package/src/op/builders.test.ts +90 -0
- package/src/op/builders.ts +96 -0
- package/src/package-cli.ts +21 -0
- package/src/plugin.test.ts +271 -0
- package/src/plugin.ts +157 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +26 -0
- package/src/skills/chant-terraform.md +92 -0
- package/src/state-ownership.ts +32 -0
- package/src/validate-cli.ts +5 -0
- package/src/validate.ts +28 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerraformWatchOp composite (#2087): drift reporting for one root module,
|
|
3
|
+
* on a schedule.
|
|
4
|
+
*
|
|
5
|
+
* The observe position on the lifecycle dial. `TerraformApplyOp` next door
|
|
6
|
+
* changes the estate; this one never does: it inits, plans, and reports what
|
|
7
|
+
* the plan found. `terraform plan -detailed-exitcode` is the whole mechanism.
|
|
8
|
+
* Exit 0 is "the world matches the configuration", exit 2 is drift, and
|
|
9
|
+
* `terraformPlan` turns that into the boolean `changed`, which rides out of
|
|
10
|
+
* the Op as the `Drift` search attribute.
|
|
11
|
+
*
|
|
12
|
+
* Phases: Init, Plan, and (for a finding mode that opens something) Report.
|
|
13
|
+
*
|
|
14
|
+
* ## Only the human plan is ever posted
|
|
15
|
+
*
|
|
16
|
+
* `terraformPlan` returns two renders of the same plan: `json`, from
|
|
17
|
+
* `terraform show -json`, and `text`, from `terraform show -no-color`. The
|
|
18
|
+
* JSON one carries every resource attribute value the plan touches, including
|
|
19
|
+
* provider credentials that no `sensitive` marking covers, because
|
|
20
|
+
* `sensitive_values` describes the configuration's own declarations rather
|
|
21
|
+
* than everything a provider puts in a plan. So the issue and pull-request
|
|
22
|
+
* modes reference `plan.out.text` and nothing else. There is no option to
|
|
23
|
+
* post the JSON, and `terraform-watch-op.test.ts` asserts no reference to it
|
|
24
|
+
* reaches the finding step.
|
|
25
|
+
*
|
|
26
|
+
* ## Findings reuse `reconcilePr`
|
|
27
|
+
*
|
|
28
|
+
* `issue` and `pull-request` call the temporal lexicon's `reconcilePr`
|
|
29
|
+
* activity, the one place in chant that shells to `gh issue create` /
|
|
30
|
+
* `gh pr create`, rather than growing a second copy of those calls here.
|
|
31
|
+
* That activity built its own change-set summary and had no way to be handed
|
|
32
|
+
* one, so #2087 added a single field to it (`ReconcilePrArgs.body`), which is
|
|
33
|
+
* what carries the `-no-color` plan through. Note that the pull-request mode
|
|
34
|
+
* of `reconcilePr` regenerates chant TypeScript via `chant import`; a
|
|
35
|
+
* terraform-native regeneration of HCL from live state is #2089, so until
|
|
36
|
+
* then `findingMode: "issue"` is the mode with an end-to-end answer and
|
|
37
|
+
* `"pull-request"` opens a PR whose body is the plan.
|
|
38
|
+
*
|
|
39
|
+
* ## The schedule
|
|
40
|
+
*
|
|
41
|
+
* A cron makes this a `{ op, schedule }` pair, the shape
|
|
42
|
+
* `lexicons/temporal/src/composites/workflow-audit-op.ts` and
|
|
43
|
+
* `reconcile-op.ts` return. The schedule resource is `Temporal::Schedule`,
|
|
44
|
+
* built here through core's own `createResource` rather than imported from
|
|
45
|
+
* `@intentius/chant-lexicon-temporal`: no lexicon in this repo imports
|
|
46
|
+
* another lexicon's package at runtime, and a static import would mean a
|
|
47
|
+
* project that installs terraform and runs `chant run` on the local executor
|
|
48
|
+
* could not load `@intentius/chant-lexicon-terraform` at all without also
|
|
49
|
+
* installing temporal. The declarable is the same one temporal's
|
|
50
|
+
* `TemporalSchedule` produces (`createResource("Temporal::Schedule",
|
|
51
|
+
* "temporal", {})`, `lexicons/temporal/src/resources.ts`), so temporal's
|
|
52
|
+
* serializer renders it unchanged when a project has both.
|
|
53
|
+
*
|
|
54
|
+
* A project with no Temporal at all runs the same Op on a CI cron instead:
|
|
55
|
+
* `generateOpsPipeline` against the github lexicon turns the Op plus its cron
|
|
56
|
+
* into a workflow. `examples/scheduled-watch/` is that recipe.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* import { TerraformWatchOp } from "@intentius/chant-lexicon-terraform";
|
|
61
|
+
*
|
|
62
|
+
* // one-shot, local executor: chant run app-watch
|
|
63
|
+
* export const { op } = TerraformWatchOp({ name: "app-watch", root: "app" });
|
|
64
|
+
*
|
|
65
|
+
* // nightly, opening an issue when the plan is non-empty
|
|
66
|
+
* export const { op, schedule } = TerraformWatchOp({
|
|
67
|
+
* name: "app-watch",
|
|
68
|
+
* root: "app",
|
|
69
|
+
* schedule: "0 6 * * *",
|
|
70
|
+
* findingMode: "issue",
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
import { Op, phase, OpResource, type ActivityStep } from "@intentius/chant/op";
|
|
76
|
+
import { createResource } from "@intentius/chant/runtime";
|
|
77
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
78
|
+
import { DEFAULT_PLAN_FILE } from "../op/activities/terraform";
|
|
79
|
+
import { terraformInit as initStep, terraformPlan as planStep } from "../op/builders";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What to do with a non-empty plan. A subset of core's `OpFindingMode`
|
|
83
|
+
* (`packages/core/src/lexicon.ts`): `merge-request` is GitLab's spelling of
|
|
84
|
+
* `pull-request` and is produced by the CI generator, not chosen here.
|
|
85
|
+
*/
|
|
86
|
+
export type TerraformFindingMode = "report" | "issue" | "pull-request";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `Temporal::Schedule`, built without importing the temporal lexicon. See the
|
|
90
|
+
* module doc for why. Identical to `TemporalSchedule` in
|
|
91
|
+
* `lexicons/temporal/src/resources.ts`.
|
|
92
|
+
*/
|
|
93
|
+
const ScheduleResource = createResource("Temporal::Schedule", "temporal", {});
|
|
94
|
+
|
|
95
|
+
/** `app-watch` becomes `appWatchWorkflow`, matching the temporal serializer's naming. */
|
|
96
|
+
function kebabToCamel(s: string): string {
|
|
97
|
+
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface TerraformWatchOpConfig {
|
|
101
|
+
/** Op name (kebab-case). Also the default task queue and schedule id base. */
|
|
102
|
+
name: string;
|
|
103
|
+
/** Key into the project's `terraform.roots`. The root carries dir, workspace, var files and backend config. */
|
|
104
|
+
root: string;
|
|
105
|
+
/**
|
|
106
|
+
* Cron expression. When set, a `Temporal::Schedule` is returned alongside
|
|
107
|
+
* the Op; omit for one-shot `chant run` on the local executor, or for a CI
|
|
108
|
+
* cron built by `generateOpsPipeline`.
|
|
109
|
+
*/
|
|
110
|
+
schedule?: string;
|
|
111
|
+
/**
|
|
112
|
+
* What to do when the plan proposes changes. Default: `"report"`, which
|
|
113
|
+
* opens nothing. The `Drift` search attribute and the run's own log are
|
|
114
|
+
* the report.
|
|
115
|
+
*/
|
|
116
|
+
findingMode?: TerraformFindingMode;
|
|
117
|
+
/** Plan file the Plan step writes, relative to the root dir. Default: `chant.tfplan`. */
|
|
118
|
+
planFile?: string;
|
|
119
|
+
/** `-upgrade` on the Init step: re-resolve provider and module versions. */
|
|
120
|
+
upgrade?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Directory each step starts the `chant.config.*` search from, which is
|
|
123
|
+
* what `terraform.roots` and the root's relative `dir` resolve against.
|
|
124
|
+
* Default: the running process's cwd, which is the project root under
|
|
125
|
+
* `chant run`. Same field, same meaning, as `TerraformApplyOpConfig.cwd`.
|
|
126
|
+
*/
|
|
127
|
+
cwd?: string;
|
|
128
|
+
/** Issue / PR title. Default: `Terraform drift in root "<root>"`. */
|
|
129
|
+
title?: string;
|
|
130
|
+
/** Branch the pull-request mode opens from. Default: `reconcilePr`'s own. */
|
|
131
|
+
branch?: string;
|
|
132
|
+
/** Override the task queue. Defaults to `name`. */
|
|
133
|
+
taskQueue?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface TerraformWatchOpResources {
|
|
137
|
+
/** Op resource. Generates the Init/Plan/[Report] workflow. */
|
|
138
|
+
op: InstanceType<typeof OpResource>;
|
|
139
|
+
/** `Temporal::Schedule`, present only when `schedule` was given. */
|
|
140
|
+
schedule?: Declarable;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function TerraformWatchOp(config: TerraformWatchOpConfig): TerraformWatchOpResources {
|
|
144
|
+
const taskQueue = config.taskQueue ?? config.name;
|
|
145
|
+
const planFile = config.planFile ?? DEFAULT_PLAN_FILE;
|
|
146
|
+
const findingMode: TerraformFindingMode = config.findingMode ?? "report";
|
|
147
|
+
const where = config.cwd ? { cwd: config.cwd } : {};
|
|
148
|
+
|
|
149
|
+
// `id` is what makes `plan.out` legal, and `plan.out.text` is the only
|
|
150
|
+
// channel by which any part of the plan leaves this Op.
|
|
151
|
+
const plan = planStep(config.root, { planFile, ...where, id: "plan" });
|
|
152
|
+
plan.outcomeAttribute = { name: "Drift", from: "changed" };
|
|
153
|
+
|
|
154
|
+
const phases = [
|
|
155
|
+
phase("Init", [initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) })]),
|
|
156
|
+
phase("Plan", [plan]),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
if (findingMode !== "report") {
|
|
160
|
+
// `env` is `reconcilePr`'s name for the thing being reconciled; for
|
|
161
|
+
// terraform that is the root, which with its workspace is the deployment
|
|
162
|
+
// target. `entries: []` and `body` together mean the activity opens what
|
|
163
|
+
// it is given rather than running `chant lifecycle plan` to find out.
|
|
164
|
+
const report: ActivityStep = {
|
|
165
|
+
kind: "activity",
|
|
166
|
+
fn: "reconcilePr",
|
|
167
|
+
args: {
|
|
168
|
+
env: config.root,
|
|
169
|
+
mode: findingMode,
|
|
170
|
+
entries: [],
|
|
171
|
+
title: config.title ?? `Terraform drift in root "${config.root}"`,
|
|
172
|
+
body: plan.out.text,
|
|
173
|
+
...(config.branch ? { branch: config.branch } : {}),
|
|
174
|
+
},
|
|
175
|
+
outcomeAttribute:
|
|
176
|
+
findingMode === "pull-request" ? { name: "PR", from: "prUrl" } : { name: "Issue", from: "issueUrl" },
|
|
177
|
+
};
|
|
178
|
+
phases.push(phase("Report", [report]));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const op = Op({
|
|
182
|
+
name: config.name,
|
|
183
|
+
overview: `Plan the "${config.root}" terraform root and report drift`,
|
|
184
|
+
taskQueue,
|
|
185
|
+
searchAttributes: {
|
|
186
|
+
Watch: "true",
|
|
187
|
+
TerraformRoot: config.root,
|
|
188
|
+
},
|
|
189
|
+
phases,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (!config.schedule) return { op };
|
|
193
|
+
|
|
194
|
+
const schedule = new ScheduleResource({
|
|
195
|
+
scheduleId: `${config.name}-schedule`,
|
|
196
|
+
spec: { cronExpressions: [config.schedule] },
|
|
197
|
+
action: {
|
|
198
|
+
workflowType: kebabToCamel(config.name) + "Workflow",
|
|
199
|
+
taskQueue,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return { op, schedule };
|
|
204
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `terraform` namespace in `chant.config.ts` (#1344).
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import type { ChantConfig } from "@intentius/chant/config";
|
|
6
|
+
* import "@intentius/chant-lexicon-terraform"; // brings the `terraform` key into ChantConfig
|
|
7
|
+
*
|
|
8
|
+
* export default {
|
|
9
|
+
* lexicons: ["terraform"],
|
|
10
|
+
* terraform: {
|
|
11
|
+
* binary: "tofu",
|
|
12
|
+
* roots: {
|
|
13
|
+
* app: { dir: "./terraform/app", workspace: "prod", varFiles: ["prod.tfvars"] },
|
|
14
|
+
* },
|
|
15
|
+
* },
|
|
16
|
+
* } satisfies ChantConfig;
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* One schema, three consumers: core validates the namespace against it at load,
|
|
20
|
+
* the type below is derived from it, and that derived type is what augments
|
|
21
|
+
* `ChantConfig`, so an unknown key fails at build time and at compile time for
|
|
22
|
+
* the same reason, rather than being silently ignored at both.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One Terraform root module chant reads. `dir` is the only required field:
|
|
30
|
+
* everything else names how the root is invoked rather than what it declares.
|
|
31
|
+
*
|
|
32
|
+
* `strictObject`, not `object`. Core applies `.strict()` to the top level of a
|
|
33
|
+
* declared namespace itself, but nested objects are the lexicon's own to make
|
|
34
|
+
* strict, and a root entry is exactly where a typo (`varfiles`) would
|
|
35
|
+
* otherwise land silently.
|
|
36
|
+
*/
|
|
37
|
+
export const terraformRootSchema = z.strictObject({
|
|
38
|
+
/** Root module directory, relative to the project root (where `chant.config.*` lives). */
|
|
39
|
+
dir: z.string(),
|
|
40
|
+
/** Terraform workspace to select for this root. Omitted means `default`. */
|
|
41
|
+
workspace: z.string().optional(),
|
|
42
|
+
/** `-var-file` arguments, in order, relative to `dir`. */
|
|
43
|
+
varFiles: z.array(z.string()).optional(),
|
|
44
|
+
/** `-backend-config` key/value pairs handed to `init`. */
|
|
45
|
+
backendConfig: z.record(z.string(), z.string()).optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const terraformConfigSchema = z.strictObject({
|
|
49
|
+
/**
|
|
50
|
+
* Which CLI drives the roots. The two are wire-compatible for everything
|
|
51
|
+
* this lexicon does, so the choice is recorded rather than inferred.
|
|
52
|
+
*/
|
|
53
|
+
binary: z.enum(["terraform", "tofu"]).optional(),
|
|
54
|
+
/** Named root modules. The name is the entity-key prefix, so keep it stable. */
|
|
55
|
+
roots: z.record(z.string(), terraformRootSchema),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export type TerraformRootConfig = z.infer<typeof terraformRootSchema>;
|
|
59
|
+
export type TerraformConfig = z.infer<typeof terraformConfigSchema>;
|
|
60
|
+
|
|
61
|
+
declare module "@intentius/chant/config" {
|
|
62
|
+
interface ChantConfig {
|
|
63
|
+
terraform?: TerraformConfig;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compile-time proof that the augmentation above reaches `ChantConfig` (#1344).
|
|
69
|
+
*
|
|
70
|
+
* Without it this line is `Property 'terraform' does not exist on type
|
|
71
|
+
* 'ChantConfig'`, which is exactly the error a user's `chant.config.ts` gets.
|
|
72
|
+
* It lives here rather than in a test because the root tsconfig excludes test
|
|
73
|
+
* files from typechecking, so a compile-time claim asserted in one is checked
|
|
74
|
+
* by nothing.
|
|
75
|
+
*/
|
|
76
|
+
export type TerraformConfigNamespace = NonNullable<ChantConfig["terraform"]>;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terraform describeResources tests (#2087).
|
|
3
|
+
*
|
|
4
|
+
* The declared side is real: `renderTerraformRoots` parses
|
|
5
|
+
* `src/__fixtures__/with-module/` exactly as `buildRoots()` does, so the keys
|
|
6
|
+
* this reader matches against are the keys a build produces, not keys a test
|
|
7
|
+
* wrote down. The live side is `src/__fixtures__/show-state.json`, recorded
|
|
8
|
+
* from `terraform show -json` after applying that same root with terraform
|
|
9
|
+
* 1.15.8 against a local backend (`null_resource.third` was added to the HCL
|
|
10
|
+
* afterwards, which is why it is declared and not in state).
|
|
11
|
+
*
|
|
12
|
+
* Nothing here runs terraform: the `init`/`show` activities are injected.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { describeObservationConformance } from "@intentius/chant-test-utils";
|
|
20
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
21
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
22
|
+
import {
|
|
23
|
+
describeResources,
|
|
24
|
+
classifyStateOwnership,
|
|
25
|
+
indexStateResources,
|
|
26
|
+
TERRAFORM_STATE_OWNERSHIP_KEYS,
|
|
27
|
+
type TerraformReadDeps,
|
|
28
|
+
} from "./describe-resources";
|
|
29
|
+
import { renderTerraformRoots } from "./hcl/roots";
|
|
30
|
+
import { terraformPlugin } from "./plugin";
|
|
31
|
+
|
|
32
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
|
|
33
|
+
const STATE: unknown = JSON.parse(readFileSync(join(fixtures, "show-state.json"), "utf-8"));
|
|
34
|
+
|
|
35
|
+
const ROOT_DIR = join(fixtures, "with-module");
|
|
36
|
+
|
|
37
|
+
/** `buildRoots()`'s own render of the fixture root, keyed `<root>/<address>`. */
|
|
38
|
+
async function declaredEntities(): Promise<Map<string, { entityType: string; props: Record<string, unknown> }>> {
|
|
39
|
+
const { entities } = await renderTerraformRoots({
|
|
40
|
+
projectRoot: fixtures,
|
|
41
|
+
roots: { app: { dir: "./with-module" } },
|
|
42
|
+
});
|
|
43
|
+
const out = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
44
|
+
for (const [key, entity] of entities) {
|
|
45
|
+
out.set(key, {
|
|
46
|
+
entityType: entity.entityType,
|
|
47
|
+
props: (entity as Declarable & { props: Record<string, unknown> }).props,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Injected activities that answer from the recorded state, with no child process. */
|
|
54
|
+
function deps(overrides?: Partial<TerraformReadDeps>): TerraformReadDeps {
|
|
55
|
+
return {
|
|
56
|
+
init: (async () => ({ dir: ROOT_DIR })) as TerraformReadDeps["init"],
|
|
57
|
+
show: (async () => ({
|
|
58
|
+
source: "state" as const,
|
|
59
|
+
json: STATE,
|
|
60
|
+
text: "",
|
|
61
|
+
dir: ROOT_DIR,
|
|
62
|
+
adds: 0,
|
|
63
|
+
changes: 0,
|
|
64
|
+
destroys: 0,
|
|
65
|
+
})) as TerraformReadDeps["show"],
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const failing = (message: string): TerraformReadDeps["show"] =>
|
|
71
|
+
(async () => {
|
|
72
|
+
throw new Error(message);
|
|
73
|
+
}) as TerraformReadDeps["show"];
|
|
74
|
+
|
|
75
|
+
async function options(overrides?: { owned?: boolean; only?: string[] }) {
|
|
76
|
+
const entities = await declaredEntities();
|
|
77
|
+
const entityNames = overrides?.only ?? [...entities.keys()].sort();
|
|
78
|
+
return {
|
|
79
|
+
environment: "prod",
|
|
80
|
+
buildOutput: "",
|
|
81
|
+
entityNames,
|
|
82
|
+
entities,
|
|
83
|
+
...(overrides?.owned ? { owned: true } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("indexStateResources (#2087)", () => {
|
|
88
|
+
it("indexes root-module and child-module rows by fully qualified address", () => {
|
|
89
|
+
const index = indexStateResources(STATE);
|
|
90
|
+
expect([...index.rows.keys()].sort()).toEqual([
|
|
91
|
+
"module.cdn.null_resource.edge",
|
|
92
|
+
"null_resource.first",
|
|
93
|
+
"null_resource.second",
|
|
94
|
+
]);
|
|
95
|
+
expect(index.modules).toContain("module.cdn");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not double-prefix an address terraform already qualified", () => {
|
|
99
|
+
// Real `terraform show -json` writes `module.cdn.null_resource.edge`
|
|
100
|
+
// inside `child_modules`, already qualified. See the fixture.
|
|
101
|
+
const index = indexStateResources(STATE);
|
|
102
|
+
expect(index.rows.has("module.cdn.module.cdn.null_resource.edge")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("prefixes a bare address, for an output shape that does not qualify it", () => {
|
|
106
|
+
const index = indexStateResources({
|
|
107
|
+
values: {
|
|
108
|
+
root_module: {
|
|
109
|
+
child_modules: [{ address: "module.cdn", resources: [{ address: "null_resource.edge" }] }],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
expect([...index.rows.keys()]).toEqual(["module.cdn.null_resource.edge"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reads nothing from a document with no values at all", () => {
|
|
117
|
+
expect(indexStateResources({}).rows.size).toBe(0);
|
|
118
|
+
expect(indexStateResources(null).rows.size).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("classifyStateOwnership (#2087)", () => {
|
|
123
|
+
const index = indexStateResources(STATE);
|
|
124
|
+
|
|
125
|
+
it("an address in state is owned, since state membership IS the channel", () => {
|
|
126
|
+
expect(classifyStateOwnership("null_resource.first", index)).toBe("owned");
|
|
127
|
+
expect(classifyStateOwnership("module.cdn.null_resource.edge", index)).toBe("owned");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("an address outside state is unknown, never foreign", () => {
|
|
131
|
+
// `foreign` would claim chant looked at a marker and found someone else's.
|
|
132
|
+
// There is no marker: the state simply has no row.
|
|
133
|
+
expect(classifyStateOwnership("null_resource.third", index)).toBe("unknown");
|
|
134
|
+
expect(classifyStateOwnership("module.cdn", index)).toBe("unknown");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("the declared channel keys name the state, not a tag", () => {
|
|
138
|
+
expect(TERRAFORM_STATE_OWNERSHIP_KEYS.managedBy).toBe("terraform.state");
|
|
139
|
+
expect(terraformPlugin.ownershipChannel).toEqual({
|
|
140
|
+
keys: TERRAFORM_STATE_OWNERSHIP_KEYS,
|
|
141
|
+
reads: ["describeResources"],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("terraform describeResources (#2087)", () => {
|
|
147
|
+
it("maps state addresses onto the keys buildRoots() produces", async () => {
|
|
148
|
+
const { resources } = normalizeObservation(await describeResources(await options(), deps()));
|
|
149
|
+
expect(resources["app/null_resource.first"]).toMatchObject({
|
|
150
|
+
type: "Terraform::Resource",
|
|
151
|
+
physicalId: "1710777879587847269",
|
|
152
|
+
status: "managed",
|
|
153
|
+
ownership: "owned",
|
|
154
|
+
});
|
|
155
|
+
expect(resources["app/null_resource.second"].ownership).toBe("owned");
|
|
156
|
+
expect(resources["app/null_resource.first"].attributes).toEqual({
|
|
157
|
+
address: "null_resource.first",
|
|
158
|
+
root: "app",
|
|
159
|
+
resourceType: "null_resource",
|
|
160
|
+
mode: "managed",
|
|
161
|
+
provider: "registry.terraform.io/hashicorp/null",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("never surfaces a state row's attribute values", async () => {
|
|
166
|
+
// `values` is the full attribute set, provider secrets included; only the
|
|
167
|
+
// `id` is read out of it, as the physical id.
|
|
168
|
+
const result = await describeResources(await options(), deps());
|
|
169
|
+
expect(JSON.stringify(result)).not.toContain("triggers");
|
|
170
|
+
for (const meta of Object.values(normalizeObservation(result).resources)) {
|
|
171
|
+
expect(meta.attributes).not.toHaveProperty("values");
|
|
172
|
+
expect(meta.attributes).not.toHaveProperty("triggers");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("reports a declared resource with no state row as absent, not not-observed", async () => {
|
|
177
|
+
const { resources, unobserved, queried } = normalizeObservation(
|
|
178
|
+
await describeResources(await options(), deps()),
|
|
179
|
+
);
|
|
180
|
+
expect(resources).not.toHaveProperty("app/null_resource.third");
|
|
181
|
+
expect(unobserved).not.toHaveProperty("app/null_resource.third");
|
|
182
|
+
// Absence is spelled "in neither map", so `queried` is the only place it
|
|
183
|
+
// can say where it looked (#1620).
|
|
184
|
+
expect(queried["app/null_resource.third"]).toContain("null_resource.third");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reports a declared module block as present but unknown, since state has no row for it", async () => {
|
|
188
|
+
const { resources } = normalizeObservation(await describeResources(await options(), deps()));
|
|
189
|
+
expect(resources["app/module.cdn"]).toMatchObject({
|
|
190
|
+
type: "Terraform::Module",
|
|
191
|
+
physicalId: "module.cdn",
|
|
192
|
+
status: "module",
|
|
193
|
+
ownership: "unknown",
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("reports blocks that state does not record at all as unsupported-kind", async () => {
|
|
198
|
+
const { unobserved } = normalizeObservation(await describeResources(await options(), deps()));
|
|
199
|
+
for (const name of ["app/terraform", "app/provider.null", "app/var.region"]) {
|
|
200
|
+
expect(unobserved[name].reason).toBe("unsupported-kind");
|
|
201
|
+
expect(unobserved[name].detail).toContain("terraform state");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("surfaces no ownership marker, because there is no marker channel to read one off", async () => {
|
|
206
|
+
const { resources } = normalizeObservation(await describeResources(await options(), deps()));
|
|
207
|
+
for (const meta of Object.values(resources)) expect(meta.marker).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("names the root when the read is issued", async () => {
|
|
211
|
+
const { queried } = normalizeObservation(await describeResources(await options(), deps()));
|
|
212
|
+
expect(queried["app/null_resource.first"]).toBe(
|
|
213
|
+
'terraform show -json (root "app", address "null_resource.first")',
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("terraform describeResources failed reads (#2087)", () => {
|
|
219
|
+
it("a failed show reports every declared entity of that root read-failed, naming the root", async () => {
|
|
220
|
+
const opts = await options();
|
|
221
|
+
const { resources, unobserved } = normalizeObservation(
|
|
222
|
+
await describeResources(opts, deps({ show: failing("Error acquiring the state lock") })),
|
|
223
|
+
);
|
|
224
|
+
expect(Object.keys(resources)).toEqual([]);
|
|
225
|
+
expect(Object.keys(unobserved).sort()).toEqual([...opts.entityNames].sort());
|
|
226
|
+
for (const entry of Object.values(unobserved)) {
|
|
227
|
+
expect(entry.reason).toBe("read-failed");
|
|
228
|
+
expect(entry.detail).toContain("terraform.roots.app");
|
|
229
|
+
expect(entry.detail).toContain("Error acquiring the state lock");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("a failed init is read-failed too, and no entity is ever absent", async () => {
|
|
234
|
+
const opts = await options();
|
|
235
|
+
const failingInit = (async () => {
|
|
236
|
+
throw new Error("Backend initialization required");
|
|
237
|
+
}) as TerraformReadDeps["init"];
|
|
238
|
+
const { resources, unobserved } = normalizeObservation(
|
|
239
|
+
await describeResources(opts, deps({ init: failingInit })),
|
|
240
|
+
);
|
|
241
|
+
expect(Object.keys(resources)).toEqual([]);
|
|
242
|
+
for (const name of opts.entityNames) {
|
|
243
|
+
expect(unobserved[name].reason).toBe("read-failed");
|
|
244
|
+
expect(unobserved[name].detail).toContain("Backend initialization required");
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("one broken root does not un-observe a root that answered", async () => {
|
|
249
|
+
const entities = await declaredEntities();
|
|
250
|
+
entities.set("other/null_resource.away", {
|
|
251
|
+
entityType: "Terraform::Resource",
|
|
252
|
+
props: { address: "null_resource.away", root: "other", body: {}, file: "main.tf" },
|
|
253
|
+
});
|
|
254
|
+
const brokenRoot: TerraformReadDeps = {
|
|
255
|
+
init: (async (args: { root: string }) => {
|
|
256
|
+
if (args.root === "other") throw new Error("no backend configured");
|
|
257
|
+
return { dir: ROOT_DIR };
|
|
258
|
+
}) as TerraformReadDeps["init"],
|
|
259
|
+
show: deps().show,
|
|
260
|
+
};
|
|
261
|
+
const { resources, unobserved } = normalizeObservation(
|
|
262
|
+
await describeResources(
|
|
263
|
+
{
|
|
264
|
+
environment: "prod",
|
|
265
|
+
buildOutput: "",
|
|
266
|
+
entityNames: ["app/null_resource.first", "other/null_resource.away"],
|
|
267
|
+
entities,
|
|
268
|
+
},
|
|
269
|
+
brokenRoot,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
expect(resources["app/null_resource.first"].ownership).toBe("owned");
|
|
273
|
+
expect(unobserved["other/null_resource.away"].reason).toBe("read-failed");
|
|
274
|
+
expect(unobserved["other/null_resource.away"].detail).toContain("terraform.roots.other");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("terraform describeResources --owned (#2087)", () => {
|
|
279
|
+
it("withholds a non-owned present entity as filtered, never as absent", async () => {
|
|
280
|
+
const { resources, unobserved } = normalizeObservation(
|
|
281
|
+
await describeResources(await options({ owned: true }), deps()),
|
|
282
|
+
);
|
|
283
|
+
expect(resources).not.toHaveProperty("app/module.cdn");
|
|
284
|
+
expect(unobserved["app/module.cdn"].reason).toBe("filtered");
|
|
285
|
+
expect(Object.keys(resources).sort()).toEqual([
|
|
286
|
+
"app/null_resource.first",
|
|
287
|
+
"app/null_resource.second",
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describeObservationConformance({
|
|
293
|
+
lexicon: "terraform",
|
|
294
|
+
ownershipChannel: terraformPlugin.ownershipChannel,
|
|
295
|
+
scenarios: [
|
|
296
|
+
{
|
|
297
|
+
name: "resources in state",
|
|
298
|
+
declared: ["app/null_resource.first", "app/null_resource.second"],
|
|
299
|
+
expectPresent: ["app/null_resource.first", "app/null_resource.second"],
|
|
300
|
+
expectNoMarker: ["app/null_resource.first"],
|
|
301
|
+
run: async () =>
|
|
302
|
+
describeResources(
|
|
303
|
+
await options({ only: ["app/null_resource.first", "app/null_resource.second"] }),
|
|
304
|
+
deps(),
|
|
305
|
+
),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "declared but not in state",
|
|
309
|
+
declared: ["app/null_resource.third"],
|
|
310
|
+
expectAbsent: ["app/null_resource.third"],
|
|
311
|
+
run: async () => describeResources(await options({ only: ["app/null_resource.third"] }), deps()),
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: "the state lock is held",
|
|
315
|
+
declared: ["app/null_resource.first", "app/module.cdn"],
|
|
316
|
+
expectUnobserved: ["app/null_resource.first", "app/module.cdn"],
|
|
317
|
+
run: async () =>
|
|
318
|
+
describeResources(
|
|
319
|
+
await options({ only: ["app/null_resource.first", "app/module.cdn"] }),
|
|
320
|
+
deps({ show: failing("Error acquiring the state lock") }),
|
|
321
|
+
),
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "a block terraform state has no row for",
|
|
325
|
+
declared: ["app/provider.null"],
|
|
326
|
+
expectUnobserved: ["app/provider.null"],
|
|
327
|
+
run: async () => describeResources(await options({ only: ["app/provider.null"] }), deps()),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: "owned read, with the module block filtered out",
|
|
331
|
+
declared: ["app/null_resource.first", "app/module.cdn"],
|
|
332
|
+
owned: true,
|
|
333
|
+
expectPresent: ["app/null_resource.first"],
|
|
334
|
+
expectUnobserved: ["app/module.cdn"],
|
|
335
|
+
run: async () =>
|
|
336
|
+
describeResources(
|
|
337
|
+
await options({ only: ["app/null_resource.first", "app/module.cdn"], owned: true }),
|
|
338
|
+
deps(),
|
|
339
|
+
),
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
});
|