@intentius/chant 0.61.0 → 0.63.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/cli/handlers/operator.d.ts +13 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +394 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +34 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
package/src/runtime-adapter.ts
CHANGED
|
@@ -40,7 +40,14 @@ export interface RuntimeAdapter {
|
|
|
40
40
|
* needs to round-trip content through a shell (`sh -c "echo … | cmd"`) just
|
|
41
41
|
* to feed a command that reads from stdin (e.g. `git hash-object --stdin`).
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* `opts.env` merges over the parent environment rather than replacing it —
|
|
45
|
+
* a caller pinning `LC_ALL=C` to parse a command's output must not also
|
|
46
|
+
* have to reconstruct `PATH`, `HOME` and everything else the child needs.
|
|
47
|
+
* An entry whose value is `""` is passed through as empty, which is how a
|
|
48
|
+
* gettext override like `LANGUAGE` is neutralised.
|
|
49
|
+
*/
|
|
50
|
+
spawn(cmd: string[], opts?: { cwd?: string; stdin?: string; env?: Record<string, string> }): Promise<SpawnResult>;
|
|
44
51
|
/** Commands to use when spawning package manager / executor */
|
|
45
52
|
readonly commands: RuntimeCommands;
|
|
46
53
|
}
|
|
@@ -64,12 +71,19 @@ class NodeRuntimeAdapter implements RuntimeAdapter {
|
|
|
64
71
|
return picomatch(pattern)(filePath);
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
async spawn(
|
|
74
|
+
async spawn(
|
|
75
|
+
cmd: string[],
|
|
76
|
+
opts?: { cwd?: string; stdin?: string; env?: Record<string, string> },
|
|
77
|
+
): Promise<SpawnResult> {
|
|
68
78
|
return new Promise((resolve) => {
|
|
69
79
|
const child = execFile(
|
|
70
80
|
cmd[0],
|
|
71
81
|
cmd.slice(1),
|
|
72
|
-
{
|
|
82
|
+
{
|
|
83
|
+
cwd: opts?.cwd,
|
|
84
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
85
|
+
...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
|
|
86
|
+
},
|
|
73
87
|
(err, stdout, stderr) => {
|
|
74
88
|
resolve({
|
|
75
89
|
stdout: stdout ?? "",
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
import { buildGraph, collectExpressions, type ExpressionRefs } from "../graph";
|
|
2
2
|
import type { Hcl2JsonTree, TfGraph } from "../types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Shortest-hop count from `address` back through inbound edges — how many
|
|
6
|
+
* dependents-of-dependents deep the estate goes behind that node (#2323).
|
|
7
|
+
* Production has no reachability pass yet (that is the spike's sub-issue 3);
|
|
8
|
+
* this is a test-only BFS over the same `TfGraph` shape, so a fixture's depth
|
|
9
|
+
* can be asserted without waiting on that feature. Folds/outputs are not
|
|
10
|
+
* special-cased: an output pseudo-address has no inbound edges by
|
|
11
|
+
* construction (`buildGraph` never lets anything reference an output), so it
|
|
12
|
+
* is always a dead end and never inflates the count.
|
|
13
|
+
*/
|
|
14
|
+
function inboundDepthOf(graph: TfGraph, address: string): number {
|
|
15
|
+
let frontier = new Set([address]);
|
|
16
|
+
const visited = new Set(frontier);
|
|
17
|
+
let depth = 0;
|
|
18
|
+
for (;;) {
|
|
19
|
+
const next = new Set<string>();
|
|
20
|
+
for (const e of graph.edges) {
|
|
21
|
+
if (frontier.has(e.to) && !visited.has(e.from)) next.add(e.from);
|
|
22
|
+
}
|
|
23
|
+
if (next.size === 0) return depth;
|
|
24
|
+
depth++;
|
|
25
|
+
for (const addr of next) visited.add(addr);
|
|
26
|
+
frontier = next;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The longest inbound chain anywhere in the graph — the same "depth" column
|
|
32
|
+
* the spike measured (docs/design/carve-dependency-blast-radius-spike.md
|
|
33
|
+
* section 2): 1 hop is a direct dependent, 2 is a dependent of a dependent,
|
|
34
|
+
* and so on. Every shipped fixture tops out at 1; `depth-estate` is built to
|
|
35
|
+
* clear that.
|
|
36
|
+
*/
|
|
37
|
+
export function maxTransitiveInboundDepth(graph: TfGraph): number {
|
|
38
|
+
let max = 0;
|
|
39
|
+
for (const node of graph.nodes) {
|
|
40
|
+
const depth = inboundDepthOf(graph, node.address);
|
|
41
|
+
if (depth > max) max = depth;
|
|
42
|
+
}
|
|
43
|
+
return max;
|
|
44
|
+
}
|
|
45
|
+
|
|
4
46
|
/**
|
|
5
47
|
* Test-only stand-in for the hcl2json expression AST (`parse.ts`'s
|
|
6
48
|
* `resolveExpressionRefs`). Fixture expressions are simple `${<accessor>}`
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveAdvise, carveJson } from "../../cli/commands/carve";
|
|
6
|
+
import { carveBridge } from "../../cli/commands/carve-bridge";
|
|
7
|
+
import { loadHcl2json } from "../parse";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The three-route estate from the blast-radius spike (#2324), through the real
|
|
11
|
+
* wasm parser: one bucket with three dependents, one reading it directly, one
|
|
12
|
+
* through a `locals` block and one through a `data` source. The advisor used to
|
|
13
|
+
* report a single dependent and band the bucket a clean leaf, and `carve bridge`
|
|
14
|
+
* left the other two routes pointing at a resource Terraform no longer manages.
|
|
15
|
+
*
|
|
16
|
+
* Skips cleanly when the optional parser is absent, like the other estate tests.
|
|
17
|
+
*/
|
|
18
|
+
let parserAvailable = false;
|
|
19
|
+
try {
|
|
20
|
+
await loadHcl2json();
|
|
21
|
+
parserAvailable = true;
|
|
22
|
+
} catch {
|
|
23
|
+
parserAvailable = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ESTATE = `resource "aws_s3_bucket" "assets" {
|
|
27
|
+
bucket = "app-assets"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
locals {
|
|
31
|
+
assets_id = aws_s3_bucket.assets.id
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
resource "aws_lambda_function" "via_local" {
|
|
35
|
+
function_name = "via-local"
|
|
36
|
+
environment { variables = { B = local.assets_id } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
data "aws_s3_bucket" "lookup" {
|
|
40
|
+
bucket = aws_s3_bucket.assets.bucket
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resource "aws_lambda_function" "via_data" {
|
|
44
|
+
function_name = "via-data"
|
|
45
|
+
environment { variables = { B = data.aws_s3_bucket.lookup.arn } }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
resource "aws_lambda_function" "direct" {
|
|
49
|
+
function_name = "direct"
|
|
50
|
+
environment { variables = { B = aws_s3_bucket.assets.arn } }
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The same estate with the direct reference taken out, so every remaining route
|
|
56
|
+
* to the bucket runs through a referrer. This is the case that actually breaks:
|
|
57
|
+
* with no direct reference the advisor found no inbound edge at all, so
|
|
58
|
+
* `carve bridge` generated no data source and rewrote nothing, and the excised
|
|
59
|
+
* bucket left two dangling references behind.
|
|
60
|
+
*/
|
|
61
|
+
const INDIRECT_ONLY = ESTATE.split('resource "aws_lambda_function" "direct"')[0];
|
|
62
|
+
|
|
63
|
+
async function withEstate<T>(source: string, fn: (dir: string) => Promise<T>): Promise<T> {
|
|
64
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-2324-"));
|
|
65
|
+
try {
|
|
66
|
+
writeFileSync(join(dir, "main.tf"), source);
|
|
67
|
+
return await fn(dir);
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("dependencies reached through locals and data blocks", () => {
|
|
74
|
+
test("the advisor counts all three dependents, not just the direct one", async () => {
|
|
75
|
+
if (!parserAvailable) return;
|
|
76
|
+
await withEstate(ESTATE, async (dir) => {
|
|
77
|
+
const report = carveJson(await carveAdvise({ from: dir }));
|
|
78
|
+
const bucket = report.resources.find((r) => r.address === "aws_s3_bucket.assets")!;
|
|
79
|
+
|
|
80
|
+
expect(bucket.breakdown.inbound).toBe(3);
|
|
81
|
+
expect(bucket.boundary!.inbound.map((e) => e.survivor)).toEqual([
|
|
82
|
+
"aws_lambda_function.direct",
|
|
83
|
+
"aws_lambda_function.via_data",
|
|
84
|
+
"aws_lambda_function.via_local",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// Three dependents at -12 each: no longer the top band on a third of the truth.
|
|
88
|
+
expect(bucket.score).toBe(64);
|
|
89
|
+
expect(bucket.band).toBe("carvable w/ edits");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("carve bridge rewires the locals and data routes, not only the direct one", async () => {
|
|
94
|
+
if (!parserAvailable) return;
|
|
95
|
+
await withEstate(ESTATE, async (dir) => {
|
|
96
|
+
const bridge = await carveBridge({
|
|
97
|
+
from: dir,
|
|
98
|
+
select: "aws_s3_bucket.assets",
|
|
99
|
+
output: join(dir, "carveout"),
|
|
100
|
+
});
|
|
101
|
+
expect(bridge.ok).toBe(true);
|
|
102
|
+
|
|
103
|
+
const rewritten = bridge.plan!.rewrites.find((r) => r.changed)!.rewritten;
|
|
104
|
+
// Every reference to the carved bucket now goes through the data source...
|
|
105
|
+
expect(rewritten).toContain("assets_id = data.aws_s3_bucket.assets.id");
|
|
106
|
+
expect(rewritten).toContain("bucket = data.aws_s3_bucket.assets.bucket");
|
|
107
|
+
expect(rewritten).toContain("B = data.aws_s3_bucket.assets.arn");
|
|
108
|
+
expect(rewritten).not.toMatch(/(?<!data\.)aws_s3_bucket\.assets\./);
|
|
109
|
+
// ...and the carved block itself is gone, so the next apply cannot re-create it.
|
|
110
|
+
expect(bridge.plan!.excised).toEqual(["aws_s3_bucket.assets"]);
|
|
111
|
+
|
|
112
|
+
// The two indirect readers are untouched: their own references were never
|
|
113
|
+
// to the bucket, and the referrers they read through still resolve.
|
|
114
|
+
expect(rewritten).toContain("B = local.assets_id");
|
|
115
|
+
expect(rewritten).toContain("B = data.aws_s3_bucket.lookup.arn");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("an estate whose only routes are indirect still gets a data source and a rewrite", async () => {
|
|
120
|
+
if (!parserAvailable) return;
|
|
121
|
+
await withEstate(INDIRECT_ONLY, async (dir) => {
|
|
122
|
+
const bridge = await carveBridge({
|
|
123
|
+
from: dir,
|
|
124
|
+
select: "aws_s3_bucket.assets",
|
|
125
|
+
output: join(dir, "carveout"),
|
|
126
|
+
});
|
|
127
|
+
expect(bridge.ok).toBe(true);
|
|
128
|
+
|
|
129
|
+
// Without an inbound edge there is no data source to rewrite references
|
|
130
|
+
// to, and the excised bucket leaves the locals and data bodies dangling.
|
|
131
|
+
expect(bridge.plan!.dataSources.map((d) => d.address)).toEqual(["aws_s3_bucket.assets"]);
|
|
132
|
+
const rewritten = bridge.plan!.rewrites.find((r) => r.changed)!.rewritten;
|
|
133
|
+
expect(rewritten).toContain("assets_id = data.aws_s3_bucket.assets.id");
|
|
134
|
+
expect(rewritten).toContain("bucket = data.aws_s3_bucket.assets.bucket");
|
|
135
|
+
expect(rewritten).not.toMatch(/(?<!data\.)aws_s3_bucket\.assets\./);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# A Terraform estate built to close the gaps the carve dependency/blast-radius
|
|
2
|
+
# spike found in every other fixture (docs/design/carve-dependency-blast-
|
|
3
|
+
# radius-spike.md, #2323): none of them has a two-hop dependency chain, and
|
|
4
|
+
# none has a `depends_on` edge. Every resource below carries a comment saying
|
|
5
|
+
# which of those gaps it demonstrates; several are lifted verbatim from the
|
|
6
|
+
# spike's appendix, which lacked the `depends_on`, `count` and module cases
|
|
7
|
+
# folded in here.
|
|
8
|
+
|
|
9
|
+
# --- The depth chain: vpc -> subnet -> db_subnet_group -> rds_cluster -> instance ---
|
|
10
|
+
# aws_vpc.main sits three inbound hops upstream of aws_rds_cluster_instance.one
|
|
11
|
+
# (vpc -> subnet.a -> db_subnet_group.main -> rds_cluster.main -> instance/
|
|
12
|
+
# lambda), the transitive chain no shipped fixture has. Every other fixture's
|
|
13
|
+
# max transitive inbound depth is 1.
|
|
14
|
+
resource "aws_vpc" "main" {
|
|
15
|
+
cidr_block = "10.0.0.0/16"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
resource "aws_subnet" "a" {
|
|
19
|
+
vpc_id = aws_vpc.main.id
|
|
20
|
+
cidr_block = "10.0.1.0/24"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
resource "aws_subnet" "b" {
|
|
24
|
+
vpc_id = aws_vpc.main.id
|
|
25
|
+
cidr_block = "10.0.2.0/24"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# count: without `--state`, `applyStateCounts` never runs, so a `.tf`-only
|
|
29
|
+
# parse reports 1 instance here and flags `hasDynamic`. The shipped
|
|
30
|
+
# terraform.tfstate resolves it to 3, so the fixture also exercises
|
|
31
|
+
# `readStateInstanceCounts`/`applyStateCounts` when advised with `--state`.
|
|
32
|
+
resource "aws_subnet" "private" {
|
|
33
|
+
count = 3
|
|
34
|
+
vpc_id = aws_vpc.main.id
|
|
35
|
+
cidr_block = "10.1.${count.index}.0/24"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
resource "aws_security_group" "db" {
|
|
39
|
+
vpc_id = aws_vpc.main.id
|
|
40
|
+
name = "db"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resource "aws_security_group" "app" {
|
|
44
|
+
vpc_id = aws_vpc.main.id
|
|
45
|
+
name = "app"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
resource "aws_db_subnet_group" "main" {
|
|
49
|
+
name = "main"
|
|
50
|
+
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
resource "aws_kms_key" "data" {
|
|
54
|
+
description = "rds + s3"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
resource "aws_rds_cluster" "main" {
|
|
58
|
+
cluster_identifier = "app-db"
|
|
59
|
+
db_subnet_group_name = aws_db_subnet_group.main.name
|
|
60
|
+
vpc_security_group_ids = [aws_security_group.db.id]
|
|
61
|
+
kms_key_id = aws_kms_key.data.arn
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Leave in Terraform: no native mapping (score 0), and the far end of the
|
|
65
|
+
# depth chain above — the node the whole fixture exists to put three hops
|
|
66
|
+
# behind aws_vpc.main.
|
|
67
|
+
resource "aws_rds_cluster_instance" "one" {
|
|
68
|
+
identifier = "app-db-1"
|
|
69
|
+
cluster_identifier = aws_rds_cluster.main.id
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# --- A second, shorter chain off the same VPC: lb -> target group -> listener ---
|
|
73
|
+
resource "aws_lb" "public" {
|
|
74
|
+
name = "app-lb"
|
|
75
|
+
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
resource "aws_lb_target_group" "app" {
|
|
79
|
+
name = "app-tg"
|
|
80
|
+
port = 8080
|
|
81
|
+
vpc_id = aws_vpc.main.id
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
resource "aws_lb_listener" "https" {
|
|
85
|
+
load_balancer_arn = aws_lb.public.arn
|
|
86
|
+
port = 443
|
|
87
|
+
default_action {
|
|
88
|
+
target_group_arn = aws_lb_target_group.app.arn
|
|
89
|
+
type = "forward"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Clean leaf: a bucket a Lambda reads from. Its versioning sub-resource folds
|
|
94
|
+
# into it (same shape as `sample-estate`) and is never ranked on its own.
|
|
95
|
+
resource "aws_s3_bucket" "assets" {
|
|
96
|
+
bucket = "app-assets"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
resource "aws_s3_bucket_versioning" "assets" {
|
|
100
|
+
bucket = aws_s3_bucket.assets.id
|
|
101
|
+
versioning_configuration {
|
|
102
|
+
status = "Enabled"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# depends_on: nothing below reads an attribute off this log group, but the
|
|
107
|
+
# Lambda names it in `depends_on` — a real pattern (ordering a function's log
|
|
108
|
+
# group ahead of the function). hcl2json renders `depends_on =
|
|
109
|
+
# [aws_cloudwatch_log_group.api]` as the interpolation
|
|
110
|
+
# "${aws_cloudwatch_log_group.api}", which `refsInBlock` already picks up with
|
|
111
|
+
# `via: ["depends_on"]` and no attribute; nothing in the fixture corpus
|
|
112
|
+
# exercised it before this estate.
|
|
113
|
+
resource "aws_cloudwatch_log_group" "api" {
|
|
114
|
+
name = "/myapp/api"
|
|
115
|
+
retention_in_days = 30
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
resource "aws_lambda_function" "api" {
|
|
119
|
+
function_name = "app-api"
|
|
120
|
+
kms_key_arn = aws_kms_key.data.arn
|
|
121
|
+
depends_on = [aws_cloudwatch_log_group.api]
|
|
122
|
+
environment {
|
|
123
|
+
variables = {
|
|
124
|
+
ASSETS = aws_s3_bucket.assets.bucket
|
|
125
|
+
DB = aws_rds_cluster.main.endpoint
|
|
126
|
+
SG = aws_security_group.app.id
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# module: a module block is one graph node whatever it contains — state is
|
|
132
|
+
# read for root-module resources only, so radius through a module undercounts
|
|
133
|
+
# by however much the module holds. No other fixture has one.
|
|
134
|
+
module "platform" {
|
|
135
|
+
source = "./modules/platform"
|
|
136
|
+
vpc_id = aws_vpc.main.id
|
|
137
|
+
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
output "lb_dns" { value = aws_lb.public.dns_name }
|
|
141
|
+
output "db_endpoint" { value = aws_rds_cluster.main.endpoint }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 4,
|
|
3
|
+
"terraform_version": "1.7.0",
|
|
4
|
+
"resources": [
|
|
5
|
+
{
|
|
6
|
+
"mode": "managed",
|
|
7
|
+
"type": "aws_subnet",
|
|
8
|
+
"name": "private",
|
|
9
|
+
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
|
10
|
+
"instances": [
|
|
11
|
+
{ "attributes": { "id": "subnet-priv-0", "cidr_block": "10.1.0.0/24" } },
|
|
12
|
+
{ "attributes": { "id": "subnet-priv-1", "cidr_block": "10.1.1.0/24" } },
|
|
13
|
+
{ "attributes": { "id": "subnet-priv-2", "cidr_block": "10.1.2.0/24" } }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { carveAdvise, carveJson, type CarveJsonResource } from "../../cli/commands/carve";
|
|
4
|
+
import { loadHcl2json, parseTerraformDir } from "../parse";
|
|
5
|
+
import { maxTransitiveInboundDepth } from "./build-graph";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `depth-estate` (#2323) closes the gap the carve dependency/blast-radius
|
|
9
|
+
* spike found: every shipped fixture — `sample-estate`, `gcp-estate`, the
|
|
10
|
+
* carve-out examples, the CDK fixture — has a max transitive inbound depth of
|
|
11
|
+
* 1 and no `depends_on` edge (docs/design/carve-dependency-blast-radius-spike.md
|
|
12
|
+
* section 2). A future transitive-reachability implementation would pass the
|
|
13
|
+
* whole existing suite whether it were correct, inverted, or a stub returning
|
|
14
|
+
* the direct count. This estate has a real chain, a `depends_on` edge, a
|
|
15
|
+
* `count` block and a module, so it can't.
|
|
16
|
+
*/
|
|
17
|
+
let parserAvailable = false;
|
|
18
|
+
try {
|
|
19
|
+
await loadHcl2json();
|
|
20
|
+
parserAvailable = true;
|
|
21
|
+
} catch {
|
|
22
|
+
parserAvailable = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ESTATE = join(__dirname, "depth-estate");
|
|
26
|
+
const STATE = join(ESTATE, "terraform.tfstate");
|
|
27
|
+
|
|
28
|
+
describe("the depth-estate fixture actually has depth", () => {
|
|
29
|
+
test("max transitive inbound depth is 3, not the 1 every other fixture tops out at", async () => {
|
|
30
|
+
if (!parserAvailable) return;
|
|
31
|
+
const graph = await parseTerraformDir(ESTATE);
|
|
32
|
+
|
|
33
|
+
// The guard that matters: a snapshot alone can't tell a flat estate from
|
|
34
|
+
// one with real depth, so this is asserted directly against the graph the
|
|
35
|
+
// production parse path builds — not against the pinned scores below,
|
|
36
|
+
// which would still pass at depth 1.
|
|
37
|
+
const depth = maxTransitiveInboundDepth(graph);
|
|
38
|
+
expect(depth).toBeGreaterThan(1);
|
|
39
|
+
// Pinned to the exact chain the fixture was built around: aws_vpc.main ->
|
|
40
|
+
// aws_subnet.a -> aws_db_subnet_group.main -> aws_rds_cluster.main ->
|
|
41
|
+
// {aws_rds_cluster_instance.one, aws_lambda_function.api}, 3 inbound hops.
|
|
42
|
+
expect(depth).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("carve advise against depth-estate", () => {
|
|
47
|
+
test("bands the chain, the depends_on edge, the count block and the module", async () => {
|
|
48
|
+
if (!parserAvailable) return;
|
|
49
|
+
const r = await carveAdvise({ from: ESTATE });
|
|
50
|
+
expect(r.ok).toBe(true);
|
|
51
|
+
const byAddr = Object.fromEntries((r.results ?? []).map((x) => [x.address, x]));
|
|
52
|
+
|
|
53
|
+
// The root of the depth chain: 7 direct inbound edges (2 subnets, the
|
|
54
|
+
// count-expanded subnet, 2 security groups, the target group, the
|
|
55
|
+
// module), no tier penalty (aws_vpc is tier 1).
|
|
56
|
+
expect(byAddr["aws_vpc.main"].band).toBe("leave in Terraform");
|
|
57
|
+
expect(byAddr["aws_vpc.main"].score).toBe(16); // 100 - 12*7
|
|
58
|
+
expect(byAddr["aws_vpc.main"].breakdown.inbound).toBe(7);
|
|
59
|
+
|
|
60
|
+
// One hop down: a data-source patch each for the db subnet group and the
|
|
61
|
+
// load balancer, both of which read it directly.
|
|
62
|
+
expect(byAddr["aws_subnet.a"].band).toBe("carvable w/ edits");
|
|
63
|
+
expect(byAddr["aws_subnet.a"].score).toBe(60); // 100 - 12*3 - 4*1
|
|
64
|
+
|
|
65
|
+
// The far end of the chain: no native mapping, so it scores 0 regardless
|
|
66
|
+
// of how deep it sits.
|
|
67
|
+
expect(byAddr["aws_rds_cluster_instance.one"].score).toBe(0);
|
|
68
|
+
expect(byAddr["aws_rds_cluster_instance.one"].band).toBe("leave in Terraform");
|
|
69
|
+
|
|
70
|
+
// depends_on: the log group carries no attribute reference from the
|
|
71
|
+
// Lambda, only a bare `depends_on` — and still counts as one inbound edge
|
|
72
|
+
// (#2323's second gap, real since hcl2json but untested until this).
|
|
73
|
+
expect(byAddr["aws_cloudwatch_log_group.api"].band).toBe("clean leaf");
|
|
74
|
+
expect(byAddr["aws_cloudwatch_log_group.api"].score).toBe(88); // 100 - 12*1
|
|
75
|
+
expect(byAddr["aws_cloudwatch_log_group.api"].breakdown.inbound).toBe(1);
|
|
76
|
+
|
|
77
|
+
// count: a `.tf`-only parse can't know the fan-out, so it stays at 1
|
|
78
|
+
// instance and gets the flat -10 `hasDynamic` penalty rather than an
|
|
79
|
+
// instances penalty.
|
|
80
|
+
expect(byAddr["aws_subnet.private"].breakdown.hasDynamic).toBe(true);
|
|
81
|
+
expect(byAddr["aws_subnet.private"].breakdown.instances).toBe(1);
|
|
82
|
+
expect(byAddr["aws_subnet.private"].score).toBe(86); // 100 - 4*1(outbound) - 10(dynamic)
|
|
83
|
+
|
|
84
|
+
// module: one node regardless of what it holds, tier 2 (a chant
|
|
85
|
+
// composite reshapes), 3 outbound edges for the vpc_id and subnet_ids
|
|
86
|
+
// inputs it takes.
|
|
87
|
+
expect(byAddr["module.platform"].kind).toBe("module");
|
|
88
|
+
expect(byAddr["module.platform"].breakdown.outbound).toBe(3);
|
|
89
|
+
expect(byAddr["module.platform"].score).toBe(73); // 100 - 4*3 - 15
|
|
90
|
+
|
|
91
|
+
// The report never proposes a mutation.
|
|
92
|
+
expect((r.results ?? []).length).toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the depends_on edge round-trips through the JSON boundary report from both ends", async () => {
|
|
96
|
+
if (!parserAvailable) return;
|
|
97
|
+
const report = carveJson(await carveAdvise({ from: ESTATE }));
|
|
98
|
+
const byAddr = Object.fromEntries(report.resources.map((x) => [x.address, x]));
|
|
99
|
+
|
|
100
|
+
// Seen from the survivor: an inbound edge with no attrs, via depends_on,
|
|
101
|
+
// bridged the same as any other inbound cut (a data-source patch).
|
|
102
|
+
expect(byAddr["aws_cloudwatch_log_group.api"].boundary).toEqual({
|
|
103
|
+
inbound: [
|
|
104
|
+
{
|
|
105
|
+
direction: "inbound",
|
|
106
|
+
survivor: "aws_lambda_function.api",
|
|
107
|
+
carved: "aws_cloudwatch_log_group.api",
|
|
108
|
+
attrs: [],
|
|
109
|
+
via: ["depends_on"],
|
|
110
|
+
bridge: "tf-data-source",
|
|
111
|
+
required: "immediately",
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
outbound: [],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Seen from the depending side: the same edge, now a deferred input.
|
|
118
|
+
const lambdaOut = byAddr["aws_lambda_function.api"].boundary!.outbound;
|
|
119
|
+
expect(lambdaOut).toContainEqual({
|
|
120
|
+
direction: "outbound",
|
|
121
|
+
survivor: "aws_cloudwatch_log_group.api",
|
|
122
|
+
carved: "aws_lambda_function.api",
|
|
123
|
+
attrs: [],
|
|
124
|
+
via: ["depends_on"],
|
|
125
|
+
bridge: "deferred-input",
|
|
126
|
+
required: "at-apply",
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const allEdges = (resources: CarveJsonResource[]) =>
|
|
131
|
+
resources.flatMap((r) => [...(r.boundary?.inbound ?? []), ...(r.boundary?.outbound ?? [])]);
|
|
132
|
+
|
|
133
|
+
test("every edge endpoint is a real address in the estate, module included", async () => {
|
|
134
|
+
if (!parserAvailable) return;
|
|
135
|
+
const report = carveJson(await carveAdvise({ from: ESTATE }));
|
|
136
|
+
const ranked = new Set(report.resources.map((r) => r.address));
|
|
137
|
+
for (const edge of allEdges(report.resources)) {
|
|
138
|
+
expect(ranked.has(edge.carved)).toBe(true);
|
|
139
|
+
expect(edge.survivor).not.toBe(edge.carved);
|
|
140
|
+
}
|
|
141
|
+
expect(ranked.has("module.platform")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("carve advise --state against depth-estate", () => {
|
|
146
|
+
test("resolves the count block's real fan-out, on top of the flat hasDynamic penalty", async () => {
|
|
147
|
+
if (!parserAvailable) return;
|
|
148
|
+
const r = await carveAdvise({ from: ESTATE, statePath: STATE });
|
|
149
|
+
expect(r.ok).toBe(true);
|
|
150
|
+
const byAddr = Object.fromEntries((r.results ?? []).map((x) => [x.address, x]));
|
|
151
|
+
|
|
152
|
+
// `readStateInstanceCounts`/`applyStateCounts` only run with `--state`
|
|
153
|
+
// (state.ts:64,:81) — the shipped terraform.tfstate resolves the 3
|
|
154
|
+
// instances the .tf-only parse above could not see. `hasDynamic` stays
|
|
155
|
+
// true (state doesn't erase that it's a count block); the instances
|
|
156
|
+
// penalty now also applies.
|
|
157
|
+
expect(byAddr["aws_subnet.private"].breakdown.instances).toBe(3);
|
|
158
|
+
expect(byAddr["aws_subnet.private"].breakdown.hasDynamic).toBe(true);
|
|
159
|
+
expect(byAddr["aws_subnet.private"].score).toBe(80); // 100 - 4*1 - 10 - 3*2
|
|
160
|
+
expect(byAddr["aws_subnet.private"].breakdown.penalties.instances).toBe(-6);
|
|
161
|
+
});
|
|
162
|
+
});
|