@intentius/chant-lexicon-terraform 0.75.1 → 0.77.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 +1 -1
- package/dist/behaviour/index.d.ts.map +1 -1
- package/dist/behaviour/kinds.d.ts +9 -0
- package/dist/behaviour/kinds.d.ts.map +1 -1
- package/dist/behaviour/predict.d.ts +67 -9
- package/dist/behaviour/predict.d.ts.map +1 -1
- package/dist/behaviour/request.d.ts +1 -1
- package/dist/behaviour/request.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +3 -2
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/hcl/descend.d.ts +1 -1
- package/dist/hcl/parse.d.ts +2 -2
- package/dist/hcl/roots.d.ts +1 -1
- package/dist/hcl/suppressions.d.ts +1 -1
- package/dist/hcl/value.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/tf016.d.ts +1 -1
- package/dist/manifest.json +1 -1
- package/dist/op/activities/live-detect.d.ts +1 -1
- package/dist/op/activities/terraform.d.ts +38 -0
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/tf016.ts +1 -1
- package/package.json +5 -5
- package/src/behaviour/index.ts +3 -0
- package/src/behaviour/kinds.ts +6 -1
- package/src/behaviour/predict.test.ts +171 -3
- package/src/behaviour/predict.ts +171 -9
- package/src/behaviour/request.test.ts +8 -1
- package/src/behaviour/request.ts +2 -1
- package/src/describe-resources.ts +4 -3
- package/src/hcl/descend.ts +1 -1
- package/src/hcl/parse.ts +2 -2
- package/src/hcl/roots.ts +1 -1
- package/src/hcl/suppressions.ts +1 -1
- package/src/hcl/value.ts +1 -1
- package/src/index.ts +3 -1
- package/src/lint/post-synth/tf016.ts +1 -1
- package/src/op/activities/live-detect.ts +1 -1
- package/src/op/activities/terraform.test.ts +117 -0
- package/src/op/activities/terraform.ts +85 -28
- package/src/plugin.test.ts +23 -1
- package/src/plugin.ts +13 -0
|
@@ -33,10 +33,12 @@ import {
|
|
|
33
33
|
type PredictedBehaviour,
|
|
34
34
|
type UnpredictedEntity,
|
|
35
35
|
} from "@intentius/chant/behaviour";
|
|
36
|
+
import { behaviourRequestFromIr } from "@intentius/chant/behaviour-graph";
|
|
36
37
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
38
|
+
import { buildGraphIr } from "@intentius/chant/graph-ir";
|
|
37
39
|
import type { TerraformReadDeps } from "../describe-resources";
|
|
38
40
|
import { renderTerraformRoots } from "../hcl/roots";
|
|
39
|
-
import { liveRootsOf, predictTerraformBehaviour, TERRAFORM } from "./predict";
|
|
41
|
+
import { liveRootsOf, predictTerraformBehaviour, TERRAFORM, terraformPredictOptionsFrom } from "./predict";
|
|
40
42
|
import type { TerraformBehaviourEntity } from "./request";
|
|
41
43
|
|
|
42
44
|
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "..", "__fixtures__");
|
|
@@ -49,6 +51,8 @@ const key = (address: string): string => `${ROOT}/${address}`;
|
|
|
49
51
|
|
|
50
52
|
let entities: Map<string, TerraformBehaviourEntity>;
|
|
51
53
|
let entityNames: string[];
|
|
54
|
+
/** The build's own entities, as `chant graph` is handed them. */
|
|
55
|
+
let built: Map<string, Declarable>;
|
|
52
56
|
|
|
53
57
|
beforeAll(async () => {
|
|
54
58
|
const rendered = await renderTerraformRoots({
|
|
@@ -56,6 +60,7 @@ beforeAll(async () => {
|
|
|
56
60
|
roots: { [ROOT]: { dir: "./live-estate" } },
|
|
57
61
|
binary: "choudoufu",
|
|
58
62
|
});
|
|
63
|
+
built = rendered.entities;
|
|
59
64
|
entities = new Map();
|
|
60
65
|
for (const [name, entity] of rendered.entities) {
|
|
61
66
|
const e = entity as Declarable & { props: Record<string, unknown>; references?: readonly never[] };
|
|
@@ -183,7 +188,7 @@ async function predict(
|
|
|
183
188
|
): Promise<{ result: BehaviourResult; calls: Call[] }> {
|
|
184
189
|
const d = deps(overrides?.deps);
|
|
185
190
|
const result = await predictTerraformBehaviour(
|
|
186
|
-
{ ...common, entityNames, entities, ...(overrides?.owned ? { owned: true } : {}) },
|
|
191
|
+
{ ...common, from: "live", entityNames, entities, ...(overrides?.owned ? { owned: true } : {}) },
|
|
187
192
|
{ liveLs: d.deps.liveLs, livePlan: d.deps.livePlan, predict: tariff },
|
|
188
193
|
);
|
|
189
194
|
return { result, calls: d.calls };
|
|
@@ -325,7 +330,7 @@ describe("the screen runs before the engine", () => {
|
|
|
325
330
|
});
|
|
326
331
|
const d = deps();
|
|
327
332
|
const result = await predictTerraformBehaviour(
|
|
328
|
-
{ ...common, entityNames, entities: tainted },
|
|
333
|
+
{ ...common, from: "live", entityNames, entities: tainted },
|
|
329
334
|
{
|
|
330
335
|
liveLs: d.deps.liveLs,
|
|
331
336
|
livePlan: d.deps.livePlan,
|
|
@@ -378,4 +383,167 @@ describe("the delta the epic asks for", () => {
|
|
|
378
383
|
// not computed over a graph the other side does not have.
|
|
379
384
|
expect(live.meta.edgeCoverage.verdict).toBe(declared.meta.edgeCoverage.verdict);
|
|
380
385
|
});
|
|
386
|
+
|
|
387
|
+
it("tells the engine which estate each side is about (#2494)", async () => {
|
|
388
|
+
const asked: Array<PredictBehaviourOptions["from"]> = [];
|
|
389
|
+
const listening = async (options: PredictBehaviourOptions): Promise<BehaviourResult> => {
|
|
390
|
+
asked.push(options.from);
|
|
391
|
+
return tariff(options);
|
|
392
|
+
};
|
|
393
|
+
const d = deps();
|
|
394
|
+
for (const from of ["live", "declared"] as const) {
|
|
395
|
+
await predictTerraformBehaviour(
|
|
396
|
+
{ ...common, from, entityNames, entities },
|
|
397
|
+
{ liveLs: d.deps.liveLs, livePlan: d.deps.livePlan, predict: listening },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
expect(asked).toEqual(["live", "declared"]);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe("the contract's options, read as this producer's (#2495)", () => {
|
|
405
|
+
/** What `chant graph --traffic` hands a plugin: the graph reshaped, references left behind on the edges. */
|
|
406
|
+
const throughTheGraph = (from: "live" | "declared"): PredictBehaviourOptions =>
|
|
407
|
+
behaviourRequestFromIr(buildGraphIr(built, fixtures), {
|
|
408
|
+
...common,
|
|
409
|
+
from,
|
|
410
|
+
edgeCoverage: { verdict: "unknown" },
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
/** Run the producer and keep the request the engine was sent. */
|
|
414
|
+
async function sent(options: Parameters<typeof predictTerraformBehaviour>[0]): Promise<PredictBehaviourOptions> {
|
|
415
|
+
let request: PredictBehaviourOptions | undefined;
|
|
416
|
+
const d = deps();
|
|
417
|
+
await predictTerraformBehaviour(options, {
|
|
418
|
+
liveLs: d.deps.liveLs,
|
|
419
|
+
livePlan: d.deps.livePlan,
|
|
420
|
+
predict: async (o) => {
|
|
421
|
+
request = o;
|
|
422
|
+
return tariff(o);
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
if (!request) throw new Error("the engine was never asked");
|
|
426
|
+
return request;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** A reader that must not be reached: nothing on the declared side, or on a live side that kept its `mode`, reads the project again. */
|
|
430
|
+
const neverRedeclared = async (): Promise<never> => {
|
|
431
|
+
throw new Error("the declaration was read again");
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
it("the graph drops every reference, and the edges give each one back", async () => {
|
|
435
|
+
const graphed = throughTheGraph("declared");
|
|
436
|
+
const referencing = [...entities].filter(([, e]) => (e.references ?? []).length > 0);
|
|
437
|
+
expect(referencing.length).toBeGreaterThan(0);
|
|
438
|
+
for (const [name] of referencing) {
|
|
439
|
+
expect(Object.prototype.hasOwnProperty.call(graphed.entities.get(name), "references"), name).toBe(false);
|
|
440
|
+
}
|
|
441
|
+
const read = await terraformPredictOptionsFrom(graphed, neverRedeclared);
|
|
442
|
+
for (const [name, entity] of referencing) {
|
|
443
|
+
expect(read.entities.get(name)?.references, name).toEqual(entity.references);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it.each(["live", "declared"] as const)("sends the engine the %s request the build's own entities would have", async (from) => {
|
|
448
|
+
const direct = await sent({ ...common, from, entityNames, entities });
|
|
449
|
+
const graphed = await sent(await terraformPredictOptionsFrom(throughTheGraph(from), neverRedeclared));
|
|
450
|
+
expect(graphed.edges).toEqual(direct.edges);
|
|
451
|
+
expect(graphed.edgeCoverage).toEqual(direct.edgeCoverage);
|
|
452
|
+
expect(graphed.entityNames).toEqual(direct.entityNames);
|
|
453
|
+
expect(graphed.from).toBe(from);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("keeps the references an entity still carries over the edges' copy", async () => {
|
|
457
|
+
const [name, entity] = [...entities].find(([, e]) => (e.references ?? []).length > 0)!;
|
|
458
|
+
const read = await terraformPredictOptionsFrom({
|
|
459
|
+
...common,
|
|
460
|
+
from: "declared",
|
|
461
|
+
entityNames,
|
|
462
|
+
entities: entities as PredictBehaviourOptions["entities"],
|
|
463
|
+
edges: [{ from: name, to: "somewhere/else", kind: "ref" }],
|
|
464
|
+
edgeCoverage: { verdict: "unknown" },
|
|
465
|
+
}, neverRedeclared);
|
|
466
|
+
expect(read.entities.get(name)?.references).toEqual(entity.references);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("does not pass the caller's edges or coverage claim on", async () => {
|
|
470
|
+
const read = (await terraformPredictOptionsFrom(throughTheGraph("declared"), neverRedeclared)) as unknown as Record<
|
|
471
|
+
string,
|
|
472
|
+
unknown
|
|
473
|
+
>;
|
|
474
|
+
expect(Object.prototype.hasOwnProperty.call(read, "edges")).toBe(false);
|
|
475
|
+
expect(Object.prototype.hasOwnProperty.call(read, "edgeCoverage")).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* What `chant graph --live` hands over: observed nodes. An address, a root
|
|
480
|
+
* and the provider's type; no `mode`, no `body`, no edge. Plus one the
|
|
481
|
+
* account holds that nothing declares.
|
|
482
|
+
*/
|
|
483
|
+
const ORPHAN = key("aws_sqs_queue.nobody_declared");
|
|
484
|
+
function throughTheLiveGraph(): PredictBehaviourOptions {
|
|
485
|
+
const observed = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
486
|
+
for (const [name, entity] of entities) {
|
|
487
|
+
if (entity.entityType !== "Terraform::Resource") continue;
|
|
488
|
+
const address = String(entity.props.address);
|
|
489
|
+
observed.set(name, {
|
|
490
|
+
entityType: entity.entityType,
|
|
491
|
+
props: { address, root: ROOT, estate: ESTATE, resourceType: address.slice(0, address.indexOf(".")) },
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
observed.set(ORPHAN, {
|
|
495
|
+
entityType: "Terraform::Resource",
|
|
496
|
+
props: { address: "aws_sqs_queue.nobody_declared", root: ROOT, estate: ESTATE, resourceType: "aws_sqs_queue" },
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
...common,
|
|
500
|
+
from: "live",
|
|
501
|
+
entityNames: [...observed.keys()],
|
|
502
|
+
entities: observed,
|
|
503
|
+
edges: [],
|
|
504
|
+
edgeCoverage: { verdict: "unknown" },
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
it("declares a live graph's root again, so the account is read and the engine is sent what the build would send", async () => {
|
|
509
|
+
const direct = await sent({ ...common, from: "live", entityNames, entities });
|
|
510
|
+
const graphed = await sent(await terraformPredictOptionsFrom(throughTheLiveGraph(), async () => entities));
|
|
511
|
+
expect(graphed.entityNames).toEqual(direct.entityNames);
|
|
512
|
+
expect(graphed.edges).toEqual(direct.edges);
|
|
513
|
+
expect(graphed.edgeCoverage).toEqual(direct.edgeCoverage);
|
|
514
|
+
for (const name of direct.entityNames) {
|
|
515
|
+
expect(graphed.entities.get(name)?.props, name).toEqual(direct.entities.get(name)?.props);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("read as it arrived, the same live graph reads no account at all", async () => {
|
|
520
|
+
// The hole the test above closes, pinned so it cannot close by accident:
|
|
521
|
+
// no observed entity says `mode: "live"`, so no root is live.
|
|
522
|
+
const asArrived = throughTheLiveGraph();
|
|
523
|
+
expect(liveRootsOf(asArrived.entityNames, asArrived.entities)).toEqual([]);
|
|
524
|
+
const read = await terraformPredictOptionsFrom(asArrived, async () => entities);
|
|
525
|
+
expect(liveRootsOf(read.entityNames, read.entities)).toEqual([ROOT]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("sets an observed orphan aside for the live read to list", async () => {
|
|
529
|
+
const read = await terraformPredictOptionsFrom(throughTheLiveGraph(), async () => entities);
|
|
530
|
+
expect(read.entityNames).not.toContain(ORPHAN);
|
|
531
|
+
expect(read.entities.has(ORPHAN)).toBe(false);
|
|
532
|
+
expect([...read.entityNames].sort()).toEqual(entityNames);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("leaves a root the config does not render as it arrived", async () => {
|
|
536
|
+
const asArrived = throughTheLiveGraph();
|
|
537
|
+
const read = await terraformPredictOptionsFrom(asArrived, async () => new Map());
|
|
538
|
+
expect(read.entityNames).toEqual(asArrived.entityNames);
|
|
539
|
+
expect(read.entities.get(ORPHAN)).toEqual(asArrived.entities.get(ORPHAN));
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("reads the declaration again only for a live graph", async () => {
|
|
543
|
+
const declaredSide = { ...throughTheLiveGraph(), from: "declared" as const };
|
|
544
|
+
await expect(terraformPredictOptionsFrom(declaredSide, neverRedeclared)).resolves.toBeDefined();
|
|
545
|
+
await expect(terraformPredictOptionsFrom(throughTheLiveGraph(), neverRedeclared)).rejects.toThrow(
|
|
546
|
+
"the declaration was read again",
|
|
547
|
+
);
|
|
548
|
+
});
|
|
381
549
|
});
|
package/src/behaviour/predict.ts
CHANGED
|
@@ -60,8 +60,13 @@ import {
|
|
|
60
60
|
type PredictBehaviourOptions,
|
|
61
61
|
type UnpredictedEntity,
|
|
62
62
|
} from "@intentius/chant/behaviour";
|
|
63
|
+
import { createBehaviourPredict } from "@intentius/chant/behaviour-predict";
|
|
64
|
+
import type { EntityReference } from "@intentius/chant/graph-ir";
|
|
63
65
|
import { indexLivePlan, readLiveLs, type TerraformReadDeps } from "../describe-resources";
|
|
66
|
+
import type { TerraformConfig } from "../config";
|
|
64
67
|
import { RESOURCE_TYPE } from "../hcl/parse";
|
|
68
|
+
import { choudoufuLiveLs, choudoufuLivePlanDocument } from "../op/activities/terraform";
|
|
69
|
+
import { terraformBehaviourKinds } from "./kinds";
|
|
65
70
|
import {
|
|
66
71
|
terraformBehaviourRequest,
|
|
67
72
|
type TerraformBehaviourEntity,
|
|
@@ -105,19 +110,175 @@ export interface TerraformPredictOptions
|
|
|
105
110
|
/** Directory the activities start the `chant.config.*` search from. Default: the process cwd. */
|
|
106
111
|
cwd?: string;
|
|
107
112
|
/**
|
|
108
|
-
* Which estate to predict
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
113
|
+
* Which estate to predict, as the contract's own `from` (#2494) and required
|
|
114
|
+
* for the same reason: a default is a guess about which estate somebody
|
|
115
|
+
* meant, and the report that comes back from a wrong guess is well-formed.
|
|
116
|
+
*
|
|
117
|
+
* `"live"` is this path's whole point: the account as it stands, drift
|
|
118
|
+
* included. `"declared"` runs the same producer over the declaration alone
|
|
119
|
+
* and reads no account, which is the other half of the delta the epic asks
|
|
120
|
+
* for (#2355) — a caller wanting that delta calls this twice and differences
|
|
121
|
+
* the two reports, and gets two reports assembled by one producer rather
|
|
122
|
+
* than two shapes that have each been through a different translation.
|
|
115
123
|
*
|
|
116
124
|
* `"declared"` on a project with no live root is the same request `"live"`
|
|
117
125
|
* builds, because there is no account to read; the option exists so that a
|
|
118
126
|
* caller can ask for the declaration of a root that *is* live.
|
|
119
127
|
*/
|
|
120
|
-
from
|
|
128
|
+
from: "live" | "declared";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What the plugin method runs with, where a caller injects nothing (#2495).
|
|
133
|
+
*
|
|
134
|
+
* The two reads are the activities `describeResources` already drives. The
|
|
135
|
+
* engine front is core's, handed this lexicon's coverage rows and no other's:
|
|
136
|
+
* a plugin method takes options alone and cannot see which other lexicons the
|
|
137
|
+
* project configured, and every name this producer sends is a terraform block
|
|
138
|
+
* or a name it passed through untouched. A passed-through name of another
|
|
139
|
+
* lexicon's type comes back `unknown-type`, which is true of what this path
|
|
140
|
+
* knows about it.
|
|
141
|
+
*/
|
|
142
|
+
const REAL_DEPS: TerraformBehaviourDeps = {
|
|
143
|
+
liveLs: choudoufuLiveLs,
|
|
144
|
+
livePlan: choudoufuLivePlanDocument,
|
|
145
|
+
predict: createBehaviourPredict({ kinds: [terraformBehaviourKinds] }),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Reads the project's declaration afresh. Injectable so a test parses nothing from disk. */
|
|
149
|
+
export type TerraformDeclarationReader = (cwd?: string) => Promise<ReadonlyMap<string, TerraformBehaviourEntity>>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The build's own entities, read again from the project's config: what
|
|
153
|
+
* `buildRoots()` renders, with `mode`, `body` and `references` as the parse
|
|
154
|
+
* stamps them. The config is found the way `observeAmbient` and the
|
|
155
|
+
* activities find it, so `terraform.roots` means one thing to all of them.
|
|
156
|
+
*/
|
|
157
|
+
export const readTerraformDeclaration: TerraformDeclarationReader = async (cwd) => {
|
|
158
|
+
const { loadChantConfigUpward } = await import("@intentius/chant/config");
|
|
159
|
+
const { renderTerraformRoots } = await import("../hcl/roots");
|
|
160
|
+
const { dirname, resolve } = await import("node:path");
|
|
161
|
+
|
|
162
|
+
const start = resolve(cwd ?? process.cwd());
|
|
163
|
+
const { config, configPath } = await loadChantConfigUpward(start);
|
|
164
|
+
const namespace = (config as { terraform?: TerraformConfig }).terraform;
|
|
165
|
+
const entities = new Map<string, TerraformBehaviourEntity>();
|
|
166
|
+
if (!namespace?.roots || Object.keys(namespace.roots).length === 0) return entities;
|
|
167
|
+
const rendered = await renderTerraformRoots({
|
|
168
|
+
projectRoot: configPath ? dirname(configPath) : start,
|
|
169
|
+
roots: namespace.roots,
|
|
170
|
+
binary: namespace.binary,
|
|
171
|
+
callModuleType: namespace.callModuleType,
|
|
172
|
+
});
|
|
173
|
+
for (const [name, entity] of rendered.entities) {
|
|
174
|
+
const e = entity as unknown as TerraformBehaviourEntity;
|
|
175
|
+
entities.set(name, {
|
|
176
|
+
entityType: e.entityType,
|
|
177
|
+
props: e.props ?? {},
|
|
178
|
+
...(Array.isArray(e.references) ? { references: e.references } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return entities;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const rootOf = (entity: TerraformBehaviourEntity | undefined): string | undefined =>
|
|
185
|
+
typeof entity?.props.root === "string" ? entity.props.root : undefined;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read the contract's options as this producer's (#2495): the plugin method's
|
|
189
|
+
* whole adaptation. Two things are missing from an entity by the time a graph
|
|
190
|
+
* has carried it here, and which one depends on the graph.
|
|
191
|
+
*
|
|
192
|
+
* ## A declared graph has lost its `references`
|
|
193
|
+
*
|
|
194
|
+
* `root` and `mode` need no work: they are on `props`, the graph projects
|
|
195
|
+
* `props` onto a node's `attrs` as they stand, and `behaviourRequestFromIr`
|
|
196
|
+
* hands `attrs` back as `props`. `references` are left off `attrs` on
|
|
197
|
+
* purpose, because an edge already says the same thing (`graph-ir.ts`'s
|
|
198
|
+
* `SKIP_KEYS`), and the contract's `edges` arrive in their place. An `IREdge`
|
|
199
|
+
* is an `EntityReference` plus the entity it leaves from, so grouping the
|
|
200
|
+
* edges by `from` gives every entity its references back, field for field.
|
|
201
|
+
* Without this a block whose body holds a `${…}` is counted as an unresolved
|
|
202
|
+
* kind on a graph that resolved it. An entity that still carries its own
|
|
203
|
+
* `references` keeps them: the edges are the copy.
|
|
204
|
+
*
|
|
205
|
+
* ## A live graph has lost the declaration
|
|
206
|
+
*
|
|
207
|
+
* `chant graph --live` hands over observed nodes: an address, a root, the
|
|
208
|
+
* provider's type, and no `mode`, no `body` and no edge. Read as they stand,
|
|
209
|
+
* no root is live, so the account is never read here; no block has a size,
|
|
210
|
+
* because `body.instance_type` went with the body; and containment is empty.
|
|
211
|
+
* The figures that come back are well-formed and differ from the declared
|
|
212
|
+
* side's for reasons that have nothing to do with the account, which is the
|
|
213
|
+
* delta this producer exists to keep honest.
|
|
214
|
+
*
|
|
215
|
+
* So under `from: "live"`, a root any of whose entities arrived with no
|
|
216
|
+
* `mode` is declared again from the project's own config, and the producer
|
|
217
|
+
* then reads the account itself, as it does for any other caller. The
|
|
218
|
+
* observed entities of that root are set aside, the undeclared ones
|
|
219
|
+
* included: an owned orphan is the live read's to list, under the key
|
|
220
|
+
* `observeAmbient` gave it, and one the account no longer holds is not in a
|
|
221
|
+
* prediction of the account.
|
|
222
|
+
*
|
|
223
|
+
* A root the config does not render is left as it arrived. That is a worse
|
|
224
|
+
* reading and the only one available.
|
|
225
|
+
*
|
|
226
|
+
* ## What stops here
|
|
227
|
+
*
|
|
228
|
+
* The caller's `edges` and `edgeCoverage`. Both are this producer's to
|
|
229
|
+
* compute, from whichever reads it ends up making (see
|
|
230
|
+
* {@link TerraformPredictOptions}).
|
|
231
|
+
*/
|
|
232
|
+
export async function terraformPredictOptionsFrom(
|
|
233
|
+
options: PredictBehaviourOptions,
|
|
234
|
+
redeclare: TerraformDeclarationReader = readTerraformDeclaration,
|
|
235
|
+
): Promise<TerraformPredictOptions> {
|
|
236
|
+
const { entities: given, edges, edgeCoverage: _edgeCoverage, ...rest } = options;
|
|
237
|
+
const leaving = new Map<string, EntityReference[]>();
|
|
238
|
+
for (const edge of edges ?? []) {
|
|
239
|
+
if (edge.kind !== "ref") continue;
|
|
240
|
+
const reference: EntityReference = {
|
|
241
|
+
to: edge.to,
|
|
242
|
+
...(edge.viaAttr !== undefined ? { viaAttr: edge.viaAttr } : {}),
|
|
243
|
+
...(edge.toAttr !== undefined ? { toAttr: edge.toAttr } : {}),
|
|
244
|
+
};
|
|
245
|
+
(leaving.get(edge.from) ?? leaving.set(edge.from, []).get(edge.from)!).push(reference);
|
|
246
|
+
}
|
|
247
|
+
const entities = new Map<string, TerraformBehaviourEntity>();
|
|
248
|
+
for (const [name, entity] of given) {
|
|
249
|
+
const own = (entity as TerraformBehaviourEntity).references;
|
|
250
|
+
const references = Array.isArray(own) ? own : leaving.get(name);
|
|
251
|
+
entities.set(name, references === undefined ? entity : { ...entity, references });
|
|
252
|
+
}
|
|
253
|
+
if (options.from !== "live") return { ...rest, entities };
|
|
254
|
+
|
|
255
|
+
const observed = new Set<string>();
|
|
256
|
+
for (const name of options.entityNames) {
|
|
257
|
+
const entity = entities.get(name);
|
|
258
|
+
const root = rootOf(entity);
|
|
259
|
+
if (root !== undefined && entity?.props.mode === undefined) observed.add(root);
|
|
260
|
+
}
|
|
261
|
+
if (observed.size === 0) return { ...rest, entities };
|
|
262
|
+
|
|
263
|
+
const declared = new Map<string, Map<string, TerraformBehaviourEntity>>();
|
|
264
|
+
for (const [name, entity] of await redeclare()) {
|
|
265
|
+
const root = rootOf(entity);
|
|
266
|
+
if (root === undefined || !observed.has(root)) continue;
|
|
267
|
+
(declared.get(root) ?? declared.set(root, new Map()).get(root)!).set(name, entity);
|
|
268
|
+
}
|
|
269
|
+
const entityNames = options.entityNames.filter((name) => {
|
|
270
|
+
const root = rootOf(entities.get(name));
|
|
271
|
+
if (root === undefined || !declared.has(root)) return true;
|
|
272
|
+
entities.delete(name);
|
|
273
|
+
return false;
|
|
274
|
+
});
|
|
275
|
+
for (const again of declared.values()) {
|
|
276
|
+
for (const [name, entity] of again) {
|
|
277
|
+
entities.set(name, entity);
|
|
278
|
+
entityNames.push(name);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { ...rest, entityNames, entities };
|
|
121
282
|
}
|
|
122
283
|
|
|
123
284
|
/** Which roots the request would read live: those whose entities the parse stamped `mode: "live"`. */
|
|
@@ -146,7 +307,7 @@ function readsAsCredentials(text: string): boolean {
|
|
|
146
307
|
*/
|
|
147
308
|
export async function predictTerraformBehaviour(
|
|
148
309
|
options: TerraformPredictOptions,
|
|
149
|
-
deps: TerraformBehaviourDeps,
|
|
310
|
+
deps: TerraformBehaviourDeps = REAL_DEPS,
|
|
150
311
|
): Promise<BehaviourResult> {
|
|
151
312
|
const { cwd, from: _from, ...contract } = options;
|
|
152
313
|
const where = cwd ? { cwd } : {};
|
|
@@ -191,6 +352,7 @@ export async function predictTerraformBehaviour(
|
|
|
191
352
|
environment: contract.environment,
|
|
192
353
|
buildOutput: contract.buildOutput,
|
|
193
354
|
traffic: contract.traffic,
|
|
355
|
+
from: options.from,
|
|
194
356
|
...(contract.region !== undefined ? { region: contract.region } : {}),
|
|
195
357
|
...(contract.stack !== undefined ? { stack: contract.stack } : {}),
|
|
196
358
|
...(contract.owned !== undefined ? { owned: contract.owned } : {}),
|
|
@@ -65,11 +65,12 @@ beforeAll(async () => {
|
|
|
65
65
|
const common = { environment: "prod", buildOutput: "/tmp/build", traffic: "100 rps, p50" };
|
|
66
66
|
|
|
67
67
|
const declaredSide = (): TerraformBehaviourRequest =>
|
|
68
|
-
terraformBehaviourRequest({ ...common, entityNames, entities });
|
|
68
|
+
terraformBehaviourRequest({ ...common, from: "declared", entityNames, entities });
|
|
69
69
|
|
|
70
70
|
const liveSide = (): TerraformBehaviourRequest =>
|
|
71
71
|
terraformBehaviourRequest({
|
|
72
72
|
...common,
|
|
73
|
+
from: "live",
|
|
73
74
|
entityNames,
|
|
74
75
|
entities,
|
|
75
76
|
live: [{ root: ROOT, listing: LISTING, plan: PLAN }],
|
|
@@ -79,6 +80,11 @@ const factsOf = (built: TerraformBehaviourRequest, address: string): LiveResourc
|
|
|
79
80
|
(built.request.entities.get(key(address))!.props as { live: LiveResourceFacts }).live;
|
|
80
81
|
|
|
81
82
|
describe("the declared side", () => {
|
|
83
|
+
it("says which estate the request is about, on both sides (#2494)", () => {
|
|
84
|
+
expect(declaredSide().request.from).toBe("declared");
|
|
85
|
+
expect(liveSide().request.from).toBe("live");
|
|
86
|
+
});
|
|
87
|
+
|
|
82
88
|
it("builds every declared block, from the file and nothing else", () => {
|
|
83
89
|
const built = declaredSide();
|
|
84
90
|
expect(built.request.entityNames).toEqual(entityNames);
|
|
@@ -179,6 +185,7 @@ describe("the live side", () => {
|
|
|
179
185
|
it("withholds what is not this estate's when --owned was asked for", () => {
|
|
180
186
|
const built = terraformBehaviourRequest({
|
|
181
187
|
...common,
|
|
188
|
+
from: "live",
|
|
182
189
|
entityNames,
|
|
183
190
|
entities,
|
|
184
191
|
owned: true,
|
package/src/behaviour/request.ts
CHANGED
|
@@ -129,7 +129,7 @@ export interface LiveResourceFacts {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
export interface TerraformBehaviourRequestOptions
|
|
132
|
-
extends Pick<PredictBehaviourOptions, "environment" | "buildOutput" | "traffic" | "region" | "stack" | "owned"> {
|
|
132
|
+
extends Pick<PredictBehaviourOptions, "environment" | "buildOutput" | "traffic" | "from" | "region" | "stack" | "owned"> {
|
|
133
133
|
entityNames: readonly string[];
|
|
134
134
|
entities: ReadonlyMap<string, TerraformBehaviourEntity>;
|
|
135
135
|
/**
|
|
@@ -613,6 +613,7 @@ export function terraformBehaviourRequest(options: TerraformBehaviourRequestOpti
|
|
|
613
613
|
...(options.stack !== undefined ? { stack: options.stack } : {}),
|
|
614
614
|
...(options.region !== undefined ? { region: options.region } : {}),
|
|
615
615
|
...(options.owned !== undefined ? { owned: options.owned } : {}),
|
|
616
|
+
from: options.from,
|
|
616
617
|
traffic: options.traffic,
|
|
617
618
|
edges: reconstructed.edges,
|
|
618
619
|
edgeCoverage: edgeCoverageOf(reconstructed, [...unresolved]),
|
|
@@ -173,7 +173,7 @@ import {
|
|
|
173
173
|
import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
174
174
|
import {
|
|
175
175
|
choudoufuLiveLs,
|
|
176
|
-
|
|
176
|
+
choudoufuLivePlanDocument,
|
|
177
177
|
terraformInit,
|
|
178
178
|
terraformShow,
|
|
179
179
|
} from "./op/activities/terraform";
|
|
@@ -1007,14 +1007,15 @@ async function liveRoots(options: LiveEstateOptions): Promise<Array<{ root: stri
|
|
|
1007
1007
|
export interface TerraformReadDeps {
|
|
1008
1008
|
init: typeof terraformInit;
|
|
1009
1009
|
show: typeof terraformShow;
|
|
1010
|
-
|
|
1010
|
+
/** The document alone (chant #2498): this reader indexes it and never reads the human render. */
|
|
1011
|
+
livePlan: typeof choudoufuLivePlanDocument;
|
|
1011
1012
|
liveLs: typeof choudoufuLiveLs;
|
|
1012
1013
|
}
|
|
1013
1014
|
|
|
1014
1015
|
const REAL_DEPS: TerraformReadDeps = {
|
|
1015
1016
|
init: terraformInit,
|
|
1016
1017
|
show: terraformShow,
|
|
1017
|
-
livePlan:
|
|
1018
|
+
livePlan: choudoufuLivePlanDocument,
|
|
1018
1019
|
liveLs: choudoufuLiveLs,
|
|
1019
1020
|
};
|
|
1020
1021
|
|
package/src/hcl/descend.ts
CHANGED
|
@@ -100,7 +100,7 @@ export interface DescendOptions {
|
|
|
100
100
|
projectRoot: string;
|
|
101
101
|
/** Resolved mode. `"none"` returns immediately with nothing. */
|
|
102
102
|
callModuleType?: "local" | "none";
|
|
103
|
-
/** Injectable parser (tests); defaults to core's lazy-loaded `@
|
|
103
|
+
/** Injectable parser (tests); defaults to core's lazy-loaded `@cdktn/hcl2json`. */
|
|
104
104
|
hcl2json?: Hcl2Json;
|
|
105
105
|
/** Mode facts stamped onto the child's entities, exactly as on the root's. */
|
|
106
106
|
modeOptions?: TerraformRootModeOptions;
|
package/src/hcl/parse.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Both funnel through `blocksToEntities`, so an entity the audit path sees is
|
|
10
10
|
* the entity the build path sees.
|
|
11
11
|
*
|
|
12
|
-
* Core owns the parser glue: `loadHcl2json()` lazy-loads `@
|
|
12
|
+
* Core owns the parser glue: `loadHcl2json()` lazy-loads `@cdktn/hcl2json`
|
|
13
13
|
* (a ~1.8 MB wasm blob) and raises a one-line install hint when it is absent.
|
|
14
14
|
* `parseTerraformDir()` next to it is NOT reused: it returns carve's `TfGraph`,
|
|
15
15
|
* a scoring-and-excision shape with no room for the per-block bodies a
|
|
@@ -27,7 +27,7 @@ import type { SuppressionDirective } from "@intentius/chant/lint/suppressions";
|
|
|
27
27
|
import type { TerraformDeleteMode } from "../config";
|
|
28
28
|
import { scanSuppressions, directivesFor, type FileScan } from "./suppressions";
|
|
29
29
|
|
|
30
|
-
/** A parsed HCL block body, as `@
|
|
30
|
+
/** A parsed HCL block body, as `@cdktn/hcl2json` encodes it. */
|
|
31
31
|
export type BlockBody = Record<string, unknown>;
|
|
32
32
|
|
|
33
33
|
/**
|
package/src/hcl/roots.ts
CHANGED
|
@@ -56,7 +56,7 @@ export interface RenderTerraformRootsOptions {
|
|
|
56
56
|
* `../` is parsed as a child scope of the root that calls it.
|
|
57
57
|
*/
|
|
58
58
|
callModuleType?: CallModuleType;
|
|
59
|
-
/** Injectable parser (tests); defaults to core's lazy-loaded `@
|
|
59
|
+
/** Injectable parser (tests); defaults to core's lazy-loaded `@cdktn/hcl2json`. */
|
|
60
60
|
hcl2json?: Hcl2Json;
|
|
61
61
|
}
|
|
62
62
|
|
package/src/hcl/suppressions.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `hcl2json` drops comments entirely, and a probe of its output confirmed it
|
|
5
5
|
* exposes no source ranges either, so a suppression comment has nothing to
|
|
6
|
-
* attach to once a file goes through `@
|
|
6
|
+
* attach to once a file goes through `@cdktn/hcl2json`. This module runs a
|
|
7
7
|
* second, cheap pass over the SAME raw text `./parse.ts` hands the parser,
|
|
8
8
|
* entirely with line-oriented regexes: a brace-depth counter to find each
|
|
9
9
|
* top-level block's start line (`terraform`, `locals`, `provider "x"`,
|
package/src/hcl/value.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* A value envelope over a parsed block body (chant #2113).
|
|
3
3
|
*
|
|
4
4
|
* Every TF post-synth check reads `BlockBody` (`./parse.ts`), raw
|
|
5
|
-
* `@
|
|
5
|
+
* `@cdktn/hcl2json` output, and re-derives, by hand, whether an attribute is
|
|
6
6
|
* a plain literal, a reference/expression, or absent. tflint's OPA ruleset
|
|
7
7
|
* hands a policy `{value, unknown, sensitive, ephemeral, range}` per
|
|
8
8
|
* attribute so a check reads that uniformly instead of re-deriving it
|
package/src/index.ts
CHANGED
|
@@ -99,10 +99,12 @@ export { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
|
|
|
99
99
|
// path for a choudoufu root, reading `live-ls -json` and `live-plan -json`
|
|
100
100
|
// through the same activities `describeResources` runs, and the same producer
|
|
101
101
|
// over the declaration so the two sides of the delta are built alike. The
|
|
102
|
-
// engine is injected: this lexicon
|
|
102
|
+
// engine is injected, and defaults to core's front (#2495): this lexicon
|
|
103
|
+
// dials none.
|
|
103
104
|
export {
|
|
104
105
|
predictTerraformBehaviour,
|
|
105
106
|
terraformBehaviourRequest,
|
|
107
|
+
terraformPredictOptionsFrom,
|
|
106
108
|
TERRAFORM_REFERENCE_CATALOG,
|
|
107
109
|
type TerraformBehaviourDeps,
|
|
108
110
|
type TerraformBehaviourEntity,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* argument that takes a list or a bool.
|
|
9
9
|
*
|
|
10
10
|
* This is the one rule in the family that reads the block's SOURCE rather than
|
|
11
|
-
* its parsed body, and it has to. `@
|
|
11
|
+
* its parsed body, and it has to. `@cdktn/hcl2json` renders both `x = var.y`
|
|
12
12
|
* and `x = "${var.y}"` as the string `"${var.y}"`, so a check over the parse
|
|
13
13
|
* cannot tell the deprecated form from the idiomatic one and would report every
|
|
14
14
|
* reference in the root. The quotes only survive in the text, so the text is
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* time and is dependency-light on purpose, since it shells out to the
|
|
10
10
|
* configured binary and never touches the lexicon's HCL parse or serializer,
|
|
11
11
|
* so it never pulls in
|
|
12
|
-
* the ~1.8 MB `@
|
|
12
|
+
* the ~1.8 MB `@cdktn/hcl2json` wasm parser just to decide whether an apply
|
|
13
13
|
* should run `apply -auto-approve` with no plan file. Plain text and a
|
|
14
14
|
* hand-rolled brace counter are enough for that narrower question, and the
|
|
15
15
|
* literal sidecar filename below is kept in sync with `LIVE_SIDECAR_FILENAME`
|