@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.
- package/dist/behaviour/index.d.ts +4 -0
- package/dist/behaviour/index.d.ts.map +1 -0
- package/dist/behaviour/predict.d.ts +112 -0
- package/dist/behaviour/predict.d.ts.map +1 -0
- package/dist/behaviour/reference-catalog.d.ts +79 -0
- package/dist/behaviour/reference-catalog.d.ts.map +1 -0
- package/dist/behaviour/request.d.ts +178 -0
- package/dist/behaviour/request.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +51 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/behaviour/index.ts +29 -0
- package/src/behaviour/predict.test.ts +381 -0
- package/src/behaviour/predict.ts +225 -0
- package/src/behaviour/reference-catalog.ts +185 -0
- package/src/behaviour/request.test.ts +352 -0
- package/src/behaviour/request.ts +622 -0
- package/src/describe-resources.ts +15 -6
- package/src/index.ts +17 -0
|
@@ -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
|
+
}
|