@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.
Files changed (131) hide show
  1. package/README.md +53 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +11 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate.d.ts +28 -0
  9. package/dist/codegen/generate.d.ts.map +1 -0
  10. package/dist/codegen/package.d.ts +17 -0
  11. package/dist/codegen/package.d.ts.map +1 -0
  12. package/dist/composites/terraform-apply-op.d.ts +88 -0
  13. package/dist/composites/terraform-apply-op.d.ts.map +1 -0
  14. package/dist/composites/terraform-watch-op.d.ts +124 -0
  15. package/dist/composites/terraform-watch-op.d.ts.map +1 -0
  16. package/dist/config.d.ts +83 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/describe-resources.d.ts +127 -0
  19. package/dist/describe-resources.d.ts.map +1 -0
  20. package/dist/generated/index.d.ts +2 -0
  21. package/dist/generated/index.d.ts.map +1 -0
  22. package/dist/hcl/parse.d.ts +87 -0
  23. package/dist/hcl/parse.d.ts.map +1 -0
  24. package/dist/hcl/roots.d.ts +37 -0
  25. package/dist/hcl/roots.d.ts.map +1 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/integrity.json +12 -0
  29. package/dist/lint/audit-catalog.d.ts +12 -0
  30. package/dist/lint/audit-catalog.d.ts.map +1 -0
  31. package/dist/lint/audit-lineage.d.ts +21 -0
  32. package/dist/lint/audit-lineage.d.ts.map +1 -0
  33. package/dist/lint/post-synth/index.d.ts +3 -0
  34. package/dist/lint/post-synth/index.d.ts.map +1 -0
  35. package/dist/lint/post-synth/tf001.d.ts +17 -0
  36. package/dist/lint/post-synth/tf001.d.ts.map +1 -0
  37. package/dist/lint/rules/index.d.ts +5 -0
  38. package/dist/lint/rules/index.d.ts.map +1 -0
  39. package/dist/lint/rules/plan-before-apply.d.ts +30 -0
  40. package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
  41. package/dist/lsp/completions.d.ts +26 -0
  42. package/dist/lsp/completions.d.ts.map +1 -0
  43. package/dist/lsp/context.d.ts +70 -0
  44. package/dist/lsp/context.d.ts.map +1 -0
  45. package/dist/lsp/hover.d.ts +12 -0
  46. package/dist/lsp/hover.d.ts.map +1 -0
  47. package/dist/lsp/option-keys.d.ts +34 -0
  48. package/dist/lsp/option-keys.d.ts.map +1 -0
  49. package/dist/manifest.json +6 -0
  50. package/dist/meta.json +1 -0
  51. package/dist/okf/index.md +8 -0
  52. package/dist/okf/rules/TF001.md +11 -0
  53. package/dist/okf/rules/TF101.md +11 -0
  54. package/dist/op/activities/index.d.ts +22 -0
  55. package/dist/op/activities/index.d.ts.map +1 -0
  56. package/dist/op/activities/terraform.d.ts +213 -0
  57. package/dist/op/activities/terraform.d.ts.map +1 -0
  58. package/dist/op/builders.d.ts +56 -0
  59. package/dist/op/builders.d.ts.map +1 -0
  60. package/dist/package-cli.d.ts +3 -0
  61. package/dist/package-cli.d.ts.map +1 -0
  62. package/dist/plugin.d.ts +11 -0
  63. package/dist/plugin.d.ts.map +1 -0
  64. package/dist/rules/plan-before-apply.ts +143 -0
  65. package/dist/rules/tf001.ts +66 -0
  66. package/dist/serializer.d.ts +19 -0
  67. package/dist/serializer.d.ts.map +1 -0
  68. package/dist/skills/chant-terraform.md +92 -0
  69. package/dist/state-ownership.d.ts +27 -0
  70. package/dist/state-ownership.d.ts.map +1 -0
  71. package/dist/types/index.d.ts +2 -0
  72. package/dist/validate-cli.d.ts +3 -0
  73. package/dist/validate-cli.d.ts.map +1 -0
  74. package/dist/validate.d.ts +15 -0
  75. package/dist/validate.d.ts.map +1 -0
  76. package/package.json +75 -0
  77. package/src/__fixtures__/no-backend/main.tf +24 -0
  78. package/src/__fixtures__/show-state.json +68 -0
  79. package/src/__fixtures__/with-backend/main.tf +28 -0
  80. package/src/__fixtures__/with-module/main.tf +49 -0
  81. package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
  82. package/src/codegen/docs-cli.ts +4 -0
  83. package/src/codegen/docs.ts +50 -0
  84. package/src/codegen/generate-cli.ts +10 -0
  85. package/src/codegen/generate.ts +68 -0
  86. package/src/codegen/package.ts +50 -0
  87. package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
  88. package/src/composites/terraform-apply-op.test.ts +193 -0
  89. package/src/composites/terraform-apply-op.ts +166 -0
  90. package/src/composites/terraform-watch-op.test.ts +184 -0
  91. package/src/composites/terraform-watch-op.ts +204 -0
  92. package/src/config.ts +76 -0
  93. package/src/describe-resources.test.ts +342 -0
  94. package/src/describe-resources.ts +357 -0
  95. package/src/generated/index.d.ts +2 -0
  96. package/src/generated/index.ts +4 -0
  97. package/src/generated/lexicon-terraform.json +1 -0
  98. package/src/hcl/parse.ts +235 -0
  99. package/src/hcl/roots.ts +67 -0
  100. package/src/index.ts +65 -0
  101. package/src/lint/audit-catalog.ts +30 -0
  102. package/src/lint/audit-lineage.ts +21 -0
  103. package/src/lint/audit.test.ts +45 -0
  104. package/src/lint/post-synth/index.ts +7 -0
  105. package/src/lint/post-synth/post-synth.test.ts +95 -0
  106. package/src/lint/post-synth/tf001.ts +66 -0
  107. package/src/lint/rules/index.ts +7 -0
  108. package/src/lint/rules/plan-before-apply.test.ts +111 -0
  109. package/src/lint/rules/plan-before-apply.ts +143 -0
  110. package/src/lsp/completions.test.ts +120 -0
  111. package/src/lsp/completions.ts +101 -0
  112. package/src/lsp/context.test.ts +152 -0
  113. package/src/lsp/context.ts +349 -0
  114. package/src/lsp/hover.test.ts +82 -0
  115. package/src/lsp/hover.ts +44 -0
  116. package/src/lsp/option-keys.ts +106 -0
  117. package/src/op/activities/index.ts +48 -0
  118. package/src/op/activities/registry.test.ts +29 -0
  119. package/src/op/activities/terraform.test.ts +445 -0
  120. package/src/op/activities/terraform.ts +469 -0
  121. package/src/op/builders.test.ts +90 -0
  122. package/src/op/builders.ts +96 -0
  123. package/src/package-cli.ts +21 -0
  124. package/src/plugin.test.ts +271 -0
  125. package/src/plugin.ts +157 -0
  126. package/src/serializer.test.ts +26 -0
  127. package/src/serializer.ts +26 -0
  128. package/src/skills/chant-terraform.md +92 -0
  129. package/src/state-ownership.ts +32 -0
  130. package/src/validate-cli.ts +5 -0
  131. 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
+ });