@intentius/chant-lexicon-terraform 0.65.0 → 0.66.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.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * The live path, end to end (#2360): the two reads, the screen, the engine,
3
+ * and the delta against the declared path.
4
+ *
5
+ * The estate is the recorded one `./request.test.ts` and
6
+ * `../describe-resources.live.test.ts` use — `../__fixtures__/live-estate/`
7
+ * parsed as a build parses it, and the two documents choudoufu v0.15.0
8
+ * recorded from it against its pinned floci image. The activities are
9
+ * injected, so nothing here runs choudoufu and nothing reaches an account.
10
+ *
11
+ * ## The engine
12
+ *
13
+ * `deps.predict` is augur's `predictBehaviour` in production. Here it is
14
+ * {@link tariff}, which prices a node per hour by its provider type and
15
+ * nothing else. That is a poor cost model and a good instrument: it makes an
16
+ * estate's total a plain sum over the entities that side of the path sent, so
17
+ * a difference between the two totals is a difference in **which entities were
18
+ * sent** and cannot be anything else. Which entities each side sends is the
19
+ * whole of what this issue changed.
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { beforeAll, describe, expect, it } from "vitest";
26
+ import {
27
+ behaviourReport,
28
+ isBehaviourRefusalReport,
29
+ predictedRate,
30
+ type BehaviourReport,
31
+ type BehaviourResult,
32
+ type PredictBehaviourOptions,
33
+ type PredictedBehaviour,
34
+ type UnpredictedEntity,
35
+ } from "@intentius/chant/behaviour";
36
+ import type { Declarable } from "@intentius/chant/declarable";
37
+ import type { TerraformReadDeps } from "../describe-resources";
38
+ import { renderTerraformRoots } from "../hcl/roots";
39
+ import { liveRootsOf, predictTerraformBehaviour, TERRAFORM } from "./predict";
40
+ import type { TerraformBehaviourEntity } from "./request";
41
+
42
+ const fixtures = join(dirname(fileURLToPath(import.meta.url)), "..", "__fixtures__");
43
+ const PLAN: unknown = JSON.parse(readFileSync(join(fixtures, "live-plan.json"), "utf-8"));
44
+ const LS: unknown = JSON.parse(readFileSync(join(fixtures, "live-ls.json"), "utf-8"));
45
+ const ROOT = "estate";
46
+ const ESTATE = "stateless-e2e-block";
47
+
48
+ const key = (address: string): string => `${ROOT}/${address}`;
49
+
50
+ let entities: Map<string, TerraformBehaviourEntity>;
51
+ let entityNames: string[];
52
+
53
+ beforeAll(async () => {
54
+ const rendered = await renderTerraformRoots({
55
+ projectRoot: fixtures,
56
+ roots: { [ROOT]: { dir: "./live-estate" } },
57
+ binary: "choudoufu",
58
+ });
59
+ entities = new Map();
60
+ for (const [name, entity] of rendered.entities) {
61
+ const e = entity as Declarable & { props: Record<string, unknown>; references?: readonly never[] };
62
+ entities.set(name, {
63
+ entityType: e.entityType,
64
+ props: e.props,
65
+ ...(e.references ? { references: e.references } : {}),
66
+ });
67
+ }
68
+ entityNames = [...entities.keys()].sort();
69
+ });
70
+
71
+ /* ── the activities ──────────────────────────────────────────────────────── */
72
+
73
+ /** A stock activity that must never be reached: a live root has no state file to show. */
74
+ const neverStock = (() => {
75
+ throw new Error("terraform show was run on a live root");
76
+ }) as never;
77
+
78
+ interface Call {
79
+ activity: "liveLs" | "livePlan";
80
+ root: string;
81
+ }
82
+
83
+ function deps(overrides?: Partial<TerraformReadDeps>): { deps: TerraformReadDeps; calls: Call[] } {
84
+ const calls: Call[] = [];
85
+ return {
86
+ calls,
87
+ deps: {
88
+ init: neverStock,
89
+ show: neverStock,
90
+ livePlan: (async (options: { root: string }) => {
91
+ calls.push({ activity: "livePlan", root: options.root });
92
+ return { json: PLAN, estate: ESTATE, dir: fixtures, drift: true, text: "" };
93
+ }) as unknown as TerraformReadDeps["livePlan"],
94
+ liveLs: (async (options: { root: string }) => {
95
+ calls.push({ activity: "liveLs", root: options.root });
96
+ return { json: LS, estate: ESTATE, dir: fixtures };
97
+ }) as unknown as TerraformReadDeps["liveLs"],
98
+ ...overrides,
99
+ } as TerraformReadDeps,
100
+ };
101
+ }
102
+
103
+ /* ── the engine ──────────────────────────────────────────────────────────── */
104
+
105
+ const FIXTURE_ENGINE = "tariff";
106
+ const FIXTURE_VERSION = "0.0.1";
107
+
108
+ /** Rate per hour by provider type. Round numbers, so a delta is readable. */
109
+ const RATES: Readonly<Record<string, number>> = {
110
+ aws_vpc: 0,
111
+ aws_subnet: 0,
112
+ aws_security_group: 0,
113
+ aws_security_group_rule: 0,
114
+ aws_cloudwatch_log_group: 1,
115
+ aws_eip: 2,
116
+ aws_s3_bucket: 8,
117
+ };
118
+
119
+ function providerTypeOf(props: Record<string, unknown>): string | undefined {
120
+ const stated = props.resourceType;
121
+ if (typeof stated === "string") return stated;
122
+ const address = props.address;
123
+ if (typeof address !== "string") return undefined;
124
+ const dot = address.indexOf(".");
125
+ return dot > 0 ? address.slice(0, dot) : undefined;
126
+ }
127
+
128
+ function figure(perHour: number, traffic: string): PredictedBehaviour {
129
+ return {
130
+ at: { traffic },
131
+ cost: predictedRate(perHour, "USD"),
132
+ headroom: { cpu: 0.5 },
133
+ errorRate: 0,
134
+ resilience: { failure: "one zone lost", verdict: "survives" },
135
+ provenance: {
136
+ engine: FIXTURE_ENGINE,
137
+ version: FIXTURE_VERSION,
138
+ tolerance: "±100%",
139
+ basis: "modeled",
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The engine: a rate per provider type, and a stated decline for anything
146
+ * else. Every name it is sent lands in one map or the other, which is the
147
+ * contract's rule and also what keeps a total a sum over a known set.
148
+ */
149
+ async function tariff(options: PredictBehaviourOptions): Promise<BehaviourResult> {
150
+ const priced: Record<string, PredictedBehaviour> = Object.create(null) as Record<string, PredictedBehaviour>;
151
+ const declined: Record<string, { type?: string; reason: "unsupported-kind"; detail: string }> = Object.create(
152
+ null,
153
+ ) as Record<string, { type?: string; reason: "unsupported-kind"; detail: string }>;
154
+ let total = 0;
155
+ for (const name of options.entityNames) {
156
+ const entity = options.entities.get(name);
157
+ const type = entity ? providerTypeOf(entity.props) : undefined;
158
+ if (type === undefined || !Object.prototype.hasOwnProperty.call(RATES, type)) {
159
+ declined[name] = {
160
+ ...(entity ? { type: entity.entityType } : {}),
161
+ reason: "unsupported-kind",
162
+ detail: `${FIXTURE_ENGINE} prices no ${type ?? "block of this kind"}`,
163
+ };
164
+ continue;
165
+ }
166
+ total += RATES[type];
167
+ priced[name] = figure(RATES[type], options.traffic);
168
+ }
169
+ return behaviourReport(
170
+ options,
171
+ { engine: FIXTURE_ENGINE, version: FIXTURE_VERSION, total: predictedRate(total, "USD") },
172
+ priced,
173
+ declined,
174
+ );
175
+ }
176
+
177
+ /* ── helpers ─────────────────────────────────────────────────────────────── */
178
+
179
+ const common = { environment: "prod", buildOutput: "/tmp/build", traffic: "100 rps, p50" };
180
+
181
+ async function predict(
182
+ overrides?: { owned?: boolean; deps?: Partial<TerraformReadDeps> },
183
+ ): Promise<{ result: BehaviourResult; calls: Call[] }> {
184
+ const d = deps(overrides?.deps);
185
+ const result = await predictTerraformBehaviour(
186
+ { ...common, entityNames, entities, ...(overrides?.owned ? { owned: true } : {}) },
187
+ { liveLs: d.deps.liveLs, livePlan: d.deps.livePlan, predict: tariff },
188
+ );
189
+ return { result, calls: d.calls };
190
+ }
191
+
192
+ /**
193
+ * The declared side: the same function, the same producer, `from: "declared"`.
194
+ * The activities throw, so a run that reached for the account fails loudly
195
+ * rather than quietly answering with it.
196
+ */
197
+ async function predictDeclared(): Promise<BehaviourResult> {
198
+ const reachedForTheAccount = (() => {
199
+ throw new Error("the declared side read the account");
200
+ }) as never;
201
+ return predictTerraformBehaviour(
202
+ { ...common, entityNames, entities, from: "declared" },
203
+ { liveLs: reachedForTheAccount, livePlan: reachedForTheAccount, predict: tariff },
204
+ );
205
+ }
206
+
207
+ function report(result: BehaviourResult): BehaviourReport {
208
+ if (isBehaviourRefusalReport(result)) throw new Error(`refused: ${result.refusal.reason}`);
209
+ return result;
210
+ }
211
+
212
+ /**
213
+ * The report's `unpredicted` map. Optional on the type and omitted entirely
214
+ * when empty (`behaviourReport`), so a run that predicted everything has no
215
+ * key at all rather than an empty object — reading it through here keeps that
216
+ * from reading as a missing entry.
217
+ */
218
+ function unpredictedOf(result: BehaviourResult): Record<string, UnpredictedEntity> {
219
+ return report(result).unpredicted ?? {};
220
+ }
221
+
222
+ const totalOf = (result: BehaviourResult): number => report(result).meta.total?.perHour ?? 0;
223
+
224
+ describe("which roots are read", () => {
225
+ it("names the roots the build stamped live, and no other", () => {
226
+ expect(liveRootsOf(entityNames, entities)).toEqual([ROOT]);
227
+ // A root the build did not stamp live is never read: `describeResources`
228
+ // branches the same way, and a stock root has no `live-ls` to run.
229
+ const stock = new Map(
230
+ [...entities].map(([name, e]) => [name, { ...e, props: { ...e.props, mode: "stock" } }]),
231
+ );
232
+ expect(liveRootsOf(entityNames, stock)).toEqual([]);
233
+ });
234
+
235
+ it("runs one live-ls and one live-plan for the root, and no stock activity", async () => {
236
+ const { calls } = await predict();
237
+ expect(calls).toEqual([
238
+ { activity: "liveLs", root: ROOT },
239
+ { activity: "livePlan", root: ROOT },
240
+ ]);
241
+ });
242
+ });
243
+
244
+ describe("the prediction of the account", () => {
245
+ it("prices what the account holds, including what nothing declares", async () => {
246
+ const { result } = await predict();
247
+ const predicted = report(result);
248
+ expect(Object.keys(predicted.entities)).toContain(key("aws_s3_bucket.data"));
249
+ expect(predicted.entities[key("aws_s3_bucket.data")].cost.perHour).toBe(RATES.aws_s3_bucket);
250
+ expect(predicted.meta.engine).toBe(FIXTURE_ENGINE);
251
+ expect(predicted.meta.at.traffic).toBe(common.traffic);
252
+ });
253
+
254
+ it("leaves every name the caller asked about in one map or the other", async () => {
255
+ const { result } = await predict();
256
+ const predicted = report(result);
257
+ const landed = new Set([...Object.keys(predicted.entities), ...Object.keys(unpredictedOf(result))]);
258
+ // Everything asked about, minus what the account does not hold — a
259
+ // resource the plan reported ABSENT is not the account's to predict, and
260
+ // the declared side is where it is answered for.
261
+ for (const name of entityNames) {
262
+ if (name === key("aws_cloudwatch_log_group.never_applied")) continue;
263
+ if (name === key("aws_security_group_rule.https")) continue;
264
+ expect(landed.has(name), name).toBe(true);
265
+ }
266
+ });
267
+
268
+ it("reports what --owned withheld as filtered, not as absent", async () => {
269
+ const { result } = await predict({ owned: true });
270
+ const predicted = report(result);
271
+ const held = unpredictedOf(result)[key("aws_cloudwatch_log_group.held_elsewhere")];
272
+ expect(held?.reason).toBe("filtered");
273
+ expect(held?.detail).toContain("--owned");
274
+ expect(Object.keys(predicted.entities)).not.toContain(key("aws_cloudwatch_log_group.held_elsewhere"));
275
+ });
276
+ });
277
+
278
+ describe("a read that failed", () => {
279
+ it("reports every resource of the root read-failed, naming the root, and sends none of them", async () => {
280
+ const { result } = await predict({
281
+ deps: {
282
+ liveLs: (() => {
283
+ throw new Error("no valid credential sources found for AWS Provider");
284
+ }) as never,
285
+ },
286
+ });
287
+ const predicted = report(result);
288
+ const entry = unpredictedOf(result)[key("aws_vpc.main")];
289
+ expect(entry?.reason).toBe("read-failed");
290
+ expect(entry?.detail).toContain(`terraform.roots.${ROOT}`);
291
+ // The failure names credentials, and saying so is the difference between
292
+ // an operator checking a variable and one debugging an account.
293
+ expect(entry?.detail).toContain("the failure names credentials");
294
+ // Nothing of that root was priced: a failed read must never render as an
295
+ // estate with nothing in it.
296
+ expect(Object.keys(predicted.entities)).toEqual([]);
297
+ expect(predicted.meta.total?.perHour).toBe(0);
298
+ });
299
+
300
+ it("does not claim credentials when the failure was something else", async () => {
301
+ const { result } = await predict({
302
+ deps: {
303
+ livePlan: (() => {
304
+ throw new Error("dial tcp 127.0.0.1:4660: connect: connection refused");
305
+ }) as never,
306
+ },
307
+ });
308
+ const entry = unpredictedOf(result)[key("aws_vpc.main")];
309
+ expect(entry?.reason).toBe("read-failed");
310
+ expect(entry?.detail).not.toContain("credentials");
311
+ expect(entry?.detail).toContain("connection refused");
312
+ });
313
+ });
314
+
315
+ describe("the screen runs before the engine", () => {
316
+ it("refuses a request whose props carry a credential, and asks no engine", async () => {
317
+ let asked = false;
318
+ const tainted = new Map(entities);
319
+ tainted.set(key("aws_vpc.main"), {
320
+ entityType: "Terraform::Resource",
321
+ props: {
322
+ ...entities.get(key("aws_vpc.main"))!.props,
323
+ body: { cidr_block: "10.99.0.0/16", awsSecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" },
324
+ },
325
+ });
326
+ const d = deps();
327
+ const result = await predictTerraformBehaviour(
328
+ { ...common, entityNames, entities: tainted },
329
+ {
330
+ liveLs: d.deps.liveLs,
331
+ livePlan: d.deps.livePlan,
332
+ predict: async () => {
333
+ asked = true;
334
+ throw new Error("the engine was asked after the screen refused");
335
+ },
336
+ },
337
+ );
338
+ expect(asked).toBe(false);
339
+ expect(isBehaviourRefusalReport(result)).toBe(true);
340
+ if (isBehaviourRefusalReport(result)) {
341
+ expect(result.refusal.reason).toContain(TERRAFORM);
342
+ }
343
+ });
344
+ });
345
+
346
+ describe("the delta the epic asks for", () => {
347
+ it("predicts a different figure for the drifted estate, by the size of the drift", async () => {
348
+ const live = report((await predict()).result);
349
+ const declared = report(await predictDeclared());
350
+
351
+ // The recording's drift, and the whole of it: `storage.tf` was removed
352
+ // after the apply, so the bucket is in the account and in no file; three
353
+ // blocks were restored after the apply and never applied, two of which
354
+ // the plan reports ABSENT.
355
+ const onlyLive = Object.keys(live.entities).filter((n) => !(n in declared.entities));
356
+ const onlyDeclared = Object.keys(declared.entities).filter((n) => !(n in live.entities));
357
+ expect(onlyLive).toEqual([key("aws_s3_bucket.data")]);
358
+ expect(onlyDeclared.sort()).toEqual([
359
+ key("aws_cloudwatch_log_group.never_applied"),
360
+ key("aws_security_group_rule.https"),
361
+ ]);
362
+
363
+ // The money moves by exactly the priced half of that: one bucket in, one
364
+ // log group out. The rule is the third and prices at zero, so it moves the
365
+ // membership and not the figure — which is why the membership is asserted
366
+ // above rather than inferred from the total.
367
+ expect(totalOf(live) - totalOf(declared)).toBe(RATES.aws_s3_bucket - RATES.aws_cloudwatch_log_group);
368
+ expect(totalOf(declared)).toBe(RATES.aws_cloudwatch_log_group * 4 + RATES.aws_eip);
369
+ expect(totalOf(live)).toBe(RATES.aws_cloudwatch_log_group * 3 + RATES.aws_eip + RATES.aws_s3_bucket);
370
+ });
371
+
372
+ it("keeps both sides comparable: same engine, same level, same basis", async () => {
373
+ const live = report((await predict()).result);
374
+ const declared = report(await predictDeclared());
375
+ expect(live.meta.engine).toBe(declared.meta.engine);
376
+ expect(live.meta.at).toEqual(declared.meta.at);
377
+ // And the same edge-coverage claim, so a resilience verdict on one side is
378
+ // not computed over a graph the other side does not have.
379
+ expect(live.meta.edgeCoverage.verdict).toBe(declared.meta.edgeCoverage.verdict);
380
+ });
381
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The live path for choudoufu roots (#2360): a prediction of the account as
3
+ * it stands, drift included.
4
+ *
5
+ * `describeResources` (`../describe-resources.ts`) answers "does this declared
6
+ * block exist" for a live root by running `choudoufu live-plan -json` through
7
+ * the applier's own activity. This does the same two reads — `live-ls -json`
8
+ * for what the account holds, `live-plan -json` for how that binds to the
9
+ * declaration — through the same injected activities, builds the engine's
10
+ * request from them (`./request.ts`), and hands it to whichever lexicon fronts
11
+ * the engine. The terraform lexicon has no engine of its own and dials none:
12
+ * `deps.predict` is augur's `predictBehaviour` in production, and a fixture
13
+ * in a test.
14
+ *
15
+ * ## Four moves, in this order
16
+ *
17
+ * 1. **Read.** One `live-ls` and one `live-plan` per live root — none at all
18
+ * under `from: "declared"` — no ambient
19
+ * credentials and no new shell-out: the activities are the ones
20
+ * `describeResources` already runs. A root whose read fails reports every
21
+ * resource it declares `read-failed`, naming the root, and is not sent — a
22
+ * failed read must never render as an estate with nothing in it.
23
+ * 2. **Build.** `terraformBehaviourRequest`, pure, over the parsed documents.
24
+ * 3. **Screen.** `screenBehaviourRequest`, before any engine is asked. Live
25
+ * props are exactly where a resolved connection string or a secret written
26
+ * into a tag can appear, and the screen's key-name, value-shape,
27
+ * URL-userinfo and walk-depth rules all apply to them. A refusal here is
28
+ * the result.
29
+ * 4. **Ask, and account for the rest.** The engine-fronting lexicon answers
30
+ * for what was sent. What was not sent — a resource the plan could not
31
+ * read, one withheld by `owned`, every resource of a root whose read
32
+ * failed — is merged into the report as `unpredicted` with its reason, so
33
+ * every declared name still lands in one map or the other.
34
+ *
35
+ * A root that is not live is built from its declaration by the same producer,
36
+ * so a project mixing stock and live roots gets one request with both, and
37
+ * `sources` says which is which. With no live root at all this is the
38
+ * declared path, containment included.
39
+ *
40
+ * ## Both halves of the delta come out of here
41
+ *
42
+ * `from: "declared"` reads no account and builds every root from its file,
43
+ * including a root that is live. That is the other side of the delta the epic
44
+ * wants, and it is deliberately the same function rather than a second one:
45
+ * two reports that differ because one producer was handed different input are
46
+ * a statement about the estate, and two reports that differ because two
47
+ * producers disagree about how to assemble a graph are a statement about
48
+ * chant. `edgeCoverage.containmentEdges` is the field that made the
49
+ * difference concrete (#2360's third review comment) — it is built by the same
50
+ * catalog over the same reconstruction on both sides here, so a "one zone
51
+ * lost" verdict that differs between the two differs because the account
52
+ * differs.
53
+ */
54
+
55
+ import {
56
+ behaviourReport,
57
+ isBehaviourRefusalReport,
58
+ screenBehaviourRequest,
59
+ type BehaviourResult,
60
+ type PredictBehaviourOptions,
61
+ type UnpredictedEntity,
62
+ } from "@intentius/chant/behaviour";
63
+ import { indexLivePlan, readLiveLs, type TerraformReadDeps } from "../describe-resources";
64
+ import { RESOURCE_TYPE } from "../hcl/parse";
65
+ import {
66
+ terraformBehaviourRequest,
67
+ type TerraformBehaviourEntity,
68
+ type TerraformLiveRead,
69
+ } from "./request";
70
+
71
+ /** The name this path screens and refuses under. */
72
+ export const TERRAFORM = "terraform";
73
+
74
+ /** What the live path needs beyond the request: the two reads, and an engine to ask. */
75
+ export interface TerraformBehaviourDeps {
76
+ liveLs: TerraformReadDeps["liveLs"];
77
+ livePlan: TerraformReadDeps["livePlan"];
78
+ /**
79
+ * The lexicon in front of the engine. Its own `screenBehaviourRequest` runs
80
+ * again inside; that is harmless, and the one here is what makes "screened
81
+ * before any engine call" true of this path rather than of its callee.
82
+ */
83
+ predict: (options: PredictBehaviourOptions) => Promise<BehaviourResult>;
84
+ }
85
+
86
+ /**
87
+ * The contract's options, less the three fields this path produces rather than
88
+ * accepts, plus where to read from.
89
+ *
90
+ * `edges` and `edgeCoverage` are **not** inputs. They are what
91
+ * `./request.ts` computes — from the live reads on one side and from the
92
+ * declaration on the other — and a caller has no way to know either before
93
+ * this function has decided which roots it is reading. Asking for them and
94
+ * then discarding whatever arrived would be a signature that lies about what
95
+ * it uses, and the delta this path exists for depends on both sides' coverage
96
+ * being this producer's claim rather than a caller's.
97
+ *
98
+ * `entities` is widened to carry the terraform build's `references` beside
99
+ * `props`, which the builder needs and the contract's own entity shape has no
100
+ * room for.
101
+ */
102
+ export interface TerraformPredictOptions
103
+ extends Omit<PredictBehaviourOptions, "entities" | "edges" | "edgeCoverage"> {
104
+ entities: ReadonlyMap<string, TerraformBehaviourEntity>;
105
+ /** Directory the activities start the `chant.config.*` search from. Default: the process cwd. */
106
+ cwd?: string;
107
+ /**
108
+ * Which estate to predict. `"live"`, the default, is this path's whole
109
+ * point: the account as it stands, drift included. `"declared"` runs the
110
+ * same producer over the declaration alone and reads no account, which is
111
+ * the other half of the delta the epic asks for (#2355) — a caller wanting
112
+ * that delta calls this twice and differences the two reports, and gets two
113
+ * reports assembled by one producer rather than two shapes that have each
114
+ * been through a different translation.
115
+ *
116
+ * `"declared"` on a project with no live root is the same request `"live"`
117
+ * builds, because there is no account to read; the option exists so that a
118
+ * caller can ask for the declaration of a root that *is* live.
119
+ */
120
+ from?: "live" | "declared";
121
+ }
122
+
123
+ /** Which roots the request would read live: those whose entities the parse stamped `mode: "live"`. */
124
+ export function liveRootsOf(
125
+ entityNames: readonly string[],
126
+ entities: ReadonlyMap<string, TerraformBehaviourEntity>,
127
+ ): string[] {
128
+ const roots = new Set<string>();
129
+ for (const name of entityNames) {
130
+ const props = entities.get(name)?.props;
131
+ const root = typeof props?.root === "string" ? props.root : undefined;
132
+ if (root && props?.mode === "live") roots.add(root);
133
+ }
134
+ return [...roots].sort();
135
+ }
136
+
137
+ function readsAsCredentials(text: string): boolean {
138
+ return /\b(no valid credential|credentials?|not authorized|unauthorized|accessdenied|access denied|expiredtoken|invalidclienttokenid|signature)\b/i.test(
139
+ text,
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Predict from the account for every live root, and from the declaration for
145
+ * every other, through the engine `deps.predict` fronts.
146
+ */
147
+ export async function predictTerraformBehaviour(
148
+ options: TerraformPredictOptions,
149
+ deps: TerraformBehaviourDeps,
150
+ ): Promise<BehaviourResult> {
151
+ const { cwd, from: _from, ...contract } = options;
152
+ const where = cwd ? { cwd } : {};
153
+
154
+ const reads: TerraformLiveRead[] = [];
155
+ const failed = new Map<string, string>();
156
+ const roots = options.from === "declared" ? [] : liveRootsOf(options.entityNames, options.entities);
157
+ for (const root of roots) {
158
+ try {
159
+ const listed = await deps.liveLs({ root, consistent: true, ...where });
160
+ const planned = await deps.livePlan({ root, ...where });
161
+ reads.push({ root, listing: readLiveLs(listed.json), plan: indexLivePlan(planned.json) });
162
+ } catch (err) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ failed.set(root, message);
165
+ }
166
+ }
167
+
168
+ // A root whose read failed contributes no node and every one of its
169
+ // resources as `read-failed`, naming the root: the tri-state's rule for a
170
+ // broken read, applied to a prediction.
171
+ const unpredicted: Record<string, UnpredictedEntity> = {};
172
+ const entityNames = options.entityNames.filter((name) => {
173
+ const entity = options.entities.get(name);
174
+ const root = typeof entity?.props.root === "string" ? entity.props.root : undefined;
175
+ if (!root || !failed.has(root)) return true;
176
+ if (entity?.entityType === RESOURCE_TYPE) {
177
+ const message = failed.get(root)!;
178
+ unpredicted[name] = {
179
+ type: RESOURCE_TYPE,
180
+ reason: "read-failed",
181
+ detail:
182
+ `terraform.roots.${root}: the live read failed, so the account was not predicted for this root` +
183
+ `${readsAsCredentials(message) ? " (the failure names credentials)" : ""}: ${message.split("\n")[0]}`,
184
+ };
185
+ return false;
186
+ }
187
+ return true;
188
+ });
189
+
190
+ const built = terraformBehaviourRequest({
191
+ environment: contract.environment,
192
+ buildOutput: contract.buildOutput,
193
+ traffic: contract.traffic,
194
+ ...(contract.region !== undefined ? { region: contract.region } : {}),
195
+ ...(contract.stack !== undefined ? { stack: contract.stack } : {}),
196
+ ...(contract.owned !== undefined ? { owned: contract.owned } : {}),
197
+ entityNames,
198
+ entities: options.entities,
199
+ live: reads,
200
+ });
201
+ Object.assign(unpredicted, built.unpredicted);
202
+
203
+ const unsafe = screenBehaviourRequest(TERRAFORM, built.request);
204
+ if (unsafe) return unsafe;
205
+
206
+ const result = await deps.predict(built.request);
207
+ if (isBehaviourRefusalReport(result)) return result;
208
+
209
+ const extra = Object.keys(unpredicted);
210
+ if (extra.length === 0) return result;
211
+ return behaviourReport(
212
+ {
213
+ entityNames: [...built.request.entityNames, ...extra],
214
+ traffic: result.meta.at.traffic,
215
+ edgeCoverage: result.meta.edgeCoverage,
216
+ },
217
+ {
218
+ engine: result.meta.engine,
219
+ version: result.meta.version,
220
+ ...(result.meta.total ? { total: result.meta.total } : {}),
221
+ },
222
+ result.entities,
223
+ { ...result.unpredicted, ...unpredicted },
224
+ );
225
+ }