@intentius/chant-lexicon-terraform 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * How the aws provider's resources reference each other, as a `ReferenceCatalog`
3
+ * over terraform's own argument names (#2360).
4
+ *
5
+ * `packages/core/src/graph-refs.ts`'s `reconstructEdges` turns a bag of nodes
6
+ * into a graph by reading identifier values out of each node's attributes and
7
+ * matching them against every other node's identity. The aws lexicon's
8
+ * catalog does that over the SDK's shape (`VpcId`, `SecurityGroups[].GroupId`);
9
+ * this one does it over a terraform block's shape (`vpc_id`,
10
+ * `vpc_security_group_ids[]`), because that is the shape a block's body has
11
+ * after `../hcl/parse.ts` and the shape `./request.ts` hands the resolver on
12
+ * both the declared and the live side.
13
+ *
14
+ * ## Keyed by provider type, not by chant entity type
15
+ *
16
+ * Every terraform `resource` block is `Terraform::Resource` to chant. A rule
17
+ * keyed on that would apply to a VPC and an instance alike, so the nodes
18
+ * `./request.ts` builds for the resolver carry the **provider type** as their
19
+ * `kind` — `aws_subnet` — and the rules here are keyed the same way. That is
20
+ * also why this catalog is not declared on the plugin as `referenceCatalog`:
21
+ * `chant graph --live` builds its nodes with `kind: "Terraform::Resource"`
22
+ * and attributes that carry no arguments at all, so declaring it there would
23
+ * be a catalog that matched nothing and looked like coverage.
24
+ *
25
+ * ## Two relations, and why the split matters to an engine
26
+ *
27
+ * A `containment` rule (subnet ∈ VPC, instance ∈ subnet) becomes a boundary
28
+ * pair and a traversable containment edge; a `reference` rule becomes an
29
+ * ordinary edge. `PredictBehaviourOptions.edges` carries the second and
30
+ * `edgeCoverage.containmentEdges` the first, and the contract keeps them
31
+ * apart for a reason that is the whole of #2360's first review comment: a
32
+ * "one zone lost" verdict is computed over zone membership, and membership is
33
+ * not a reference. Both sides of the declared-versus-live delta run these
34
+ * same rules, so neither side is structurally short of containment the other
35
+ * has.
36
+ *
37
+ * ## Multi-kind arguments carry no `targetKind`
38
+ *
39
+ * `reconstructEdges` records a value under a rule with a `targetKind` as
40
+ * dangling when the value resolves to a node of another kind. An argument
41
+ * that legitimately points at more than one kind — a target-group attachment's
42
+ * `target_id` is an instance or a function — would therefore report every
43
+ * match of the other kind as a reference leaving the estate. Those rules name
44
+ * no `targetKind` and take whatever the identifier resolves to, the same
45
+ * choice the aws catalog makes for its CloudFormation template paths.
46
+ *
47
+ * ## What "no rule" means
48
+ *
49
+ * A kind with no rule here produces no edges and, on its own, no complaint —
50
+ * the quiet gap `edgeCoverage.unresolvedKinds` exists to name. But "no rule"
51
+ * has two causes that should not read alike: nobody has written one, or the
52
+ * kind holds no identifier-bearing argument to write one about. A VPC
53
+ * references nothing; an SQS queue names its dead-letter queue inside a JSON
54
+ * document this catalog does not read. {@link REFERENCES_NOTHING} is the list
55
+ * of kinds in the first position, checked rather than assumed, so that an
56
+ * estate with a VPC in it is not `partial` forever and an estate with a queue
57
+ * in it is not `complete` by omission.
58
+ */
59
+
60
+ import type { ReferenceCatalog } from "@intentius/chant/graph-refs";
61
+
62
+ /**
63
+ * Provider types this catalog has looked at and found to hold no argument
64
+ * that identifies another resource. A node of one of these kinds is not an
65
+ * unresolved kind: nothing was looked for because there is nothing to find.
66
+ *
67
+ * Deliberately short. A kind is added here when every identifier-bearing
68
+ * argument it has is also a rule below, or when it has none — not when its
69
+ * references happen to be inconvenient to read.
70
+ */
71
+ export const REFERENCES_NOTHING: ReadonlySet<string> = new Set([
72
+ "aws_vpc",
73
+ "aws_s3_bucket",
74
+ "aws_ecs_cluster",
75
+ "aws_ecr_repository",
76
+ "aws_route53_zone",
77
+ ]);
78
+
79
+ /** The catalog. Rules are grouped by the relation they produce. */
80
+ export const TERRAFORM_REFERENCE_CATALOG: ReferenceCatalog = {
81
+ // Every node is indexed by its own id and physical id (`reconstructEdges`
82
+ // does that itself), and a block's body never carries a second identifier
83
+ // for the block, so there are no identity rules to declare.
84
+ identities: [],
85
+ refs: [
86
+ // ── containment (→ boundary pairs, and containment edges) ──
87
+ { from: "aws_subnet", path: "vpc_id", targetKind: "aws_vpc", relation: "containment", label: "in VPC" },
88
+ { from: "aws_security_group", path: "vpc_id", targetKind: "aws_vpc", relation: "containment", label: "in VPC" },
89
+ { from: "aws_route_table", path: "vpc_id", targetKind: "aws_vpc", relation: "containment", label: "in VPC" },
90
+ { from: "aws_internet_gateway", path: "vpc_id", targetKind: "aws_vpc", relation: "containment", label: "in VPC" },
91
+ { from: "aws_lb_target_group", path: "vpc_id", targetKind: "aws_vpc", relation: "containment", label: "in VPC" },
92
+ { from: "aws_vpc_endpoint", path: "vpc_id", targetKind: "aws_vpc", relation: "containment", label: "in VPC" },
93
+ // An instance's subnet is the hop a topology fold takes first, so it is
94
+ // traversable as an ordinary edge too — the same `viaAttr` opt-in the aws
95
+ // catalog gives `AWS::EC2::Instance.SubnetId`.
96
+ { from: "aws_instance", path: "subnet_id", targetKind: "aws_subnet", relation: "containment", label: "in subnet", viaAttr: "subnet_id" },
97
+ { from: "aws_nat_gateway", path: "subnet_id", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
98
+ { from: "aws_network_interface", path: "subnet_id", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
99
+ { from: "aws_lb", path: "subnets[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
100
+ { from: "aws_alb", path: "subnets[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
101
+ { from: "aws_lambda_function", path: "vpc_config[].subnet_ids[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
102
+ { from: "aws_db_subnet_group", path: "subnet_ids[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
103
+ { from: "aws_elasticache_subnet_group", path: "subnet_ids[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
104
+ { from: "aws_db_instance", path: "db_subnet_group_name", targetKind: "aws_db_subnet_group", relation: "containment", label: "in subnet group" },
105
+ { from: "aws_rds_cluster", path: "db_subnet_group_name", targetKind: "aws_db_subnet_group", relation: "containment", label: "in subnet group" },
106
+ { from: "aws_elasticache_cluster", path: "subnet_group_name", targetKind: "aws_elasticache_subnet_group", relation: "containment", label: "in subnet group" },
107
+ { from: "aws_ecs_service", path: "network_configuration[].subnets[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
108
+ { from: "aws_eks_cluster", path: "vpc_config[].subnet_ids[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
109
+ { from: "aws_eks_node_group", path: "subnet_ids[]", targetKind: "aws_subnet", relation: "containment", label: "in subnet" },
110
+
111
+ // ── references (→ edges) ──
112
+ { from: "aws_security_group_rule", path: "security_group_id", targetKind: "aws_security_group", relation: "reference", label: "rule on", viaAttr: "security_group_id" },
113
+ { from: "aws_security_group_rule", path: "source_security_group_id", targetKind: "aws_security_group", relation: "reference", label: "allows" },
114
+ { from: "aws_vpc_security_group_ingress_rule", path: "security_group_id", targetKind: "aws_security_group", relation: "reference", label: "rule on", viaAttr: "security_group_id" },
115
+ { from: "aws_vpc_security_group_ingress_rule", path: "referenced_security_group_id", targetKind: "aws_security_group", relation: "reference", label: "allows" },
116
+ { from: "aws_vpc_security_group_egress_rule", path: "security_group_id", targetKind: "aws_security_group", relation: "reference", label: "rule on", viaAttr: "security_group_id" },
117
+ { from: "aws_vpc_security_group_egress_rule", path: "referenced_security_group_id", targetKind: "aws_security_group", relation: "reference", label: "allows" },
118
+ { from: "aws_instance", path: "vpc_security_group_ids[]", targetKind: "aws_security_group", relation: "reference", label: "sg", viaAttr: "vpc_security_group_ids" },
119
+ { from: "aws_instance", path: "security_groups[]", targetKind: "aws_security_group", relation: "reference", label: "sg", viaAttr: "security_groups" },
120
+ { from: "aws_instance", path: "iam_instance_profile", targetKind: "aws_iam_instance_profile", relation: "reference", label: "as" },
121
+ { from: "aws_network_interface", path: "security_groups[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
122
+ { from: "aws_lambda_function", path: "vpc_config[].security_group_ids[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
123
+ { from: "aws_lambda_function", path: "role", targetKind: "aws_iam_role", relation: "reference", label: "as" },
124
+ { from: "aws_db_instance", path: "vpc_security_group_ids[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
125
+ { from: "aws_rds_cluster", path: "vpc_security_group_ids[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
126
+ { from: "aws_rds_cluster_instance", path: "cluster_identifier", targetKind: "aws_rds_cluster", relation: "reference", label: "in cluster" },
127
+ { from: "aws_elasticache_cluster", path: "security_group_ids[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
128
+ { from: "aws_lb", path: "security_groups[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
129
+ { from: "aws_alb", path: "security_groups[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
130
+ { from: "aws_lb_listener", path: "load_balancer_arn", targetKind: "aws_lb", relation: "reference", label: "on" },
131
+ { from: "aws_lb_listener", path: "default_action[].target_group_arn", targetKind: "aws_lb_target_group", relation: "reference", label: "forwards to" },
132
+ { from: "aws_lb_target_group_attachment", path: "target_group_arn", targetKind: "aws_lb_target_group", relation: "reference", label: "in group" },
133
+ // An instance, a function or an IP; no `targetKind` (see the module doc).
134
+ { from: "aws_lb_target_group_attachment", path: "target_id", relation: "reference", label: "targets" },
135
+ { from: "aws_route", path: "route_table_id", targetKind: "aws_route_table", relation: "reference", label: "in table", viaAttr: "route_table_id" },
136
+ { from: "aws_route", path: "gateway_id", targetKind: "aws_internet_gateway", relation: "reference", label: "via", viaAttr: "gateway_id" },
137
+ { from: "aws_route", path: "nat_gateway_id", targetKind: "aws_nat_gateway", relation: "reference", label: "via" },
138
+ { from: "aws_route_table_association", path: "subnet_id", targetKind: "aws_subnet", relation: "reference", label: "associates", viaAttr: "subnet_id" },
139
+ { from: "aws_route_table_association", path: "route_table_id", targetKind: "aws_route_table", relation: "reference", label: "to table", viaAttr: "route_table_id" },
140
+ { from: "aws_nat_gateway", path: "allocation_id", targetKind: "aws_eip", relation: "reference", label: "uses" },
141
+ { from: "aws_eip", path: "instance", targetKind: "aws_instance", relation: "reference", label: "attached to" },
142
+ { from: "aws_eip", path: "network_interface", targetKind: "aws_network_interface", relation: "reference", label: "attached to" },
143
+ { from: "aws_ecs_service", path: "cluster", targetKind: "aws_ecs_cluster", relation: "reference", label: "in cluster" },
144
+ { from: "aws_ecs_service", path: "task_definition", targetKind: "aws_ecs_task_definition", relation: "reference", label: "runs" },
145
+ { from: "aws_ecs_service", path: "load_balancer[].target_group_arn", targetKind: "aws_lb_target_group", relation: "reference", label: "registered in" },
146
+ { from: "aws_ecs_service", path: "network_configuration[].security_groups[]", targetKind: "aws_security_group", relation: "reference", label: "sg" },
147
+ { from: "aws_ecs_task_definition", path: "execution_role_arn", targetKind: "aws_iam_role", relation: "reference", label: "as" },
148
+ { from: "aws_ecs_task_definition", path: "task_role_arn", targetKind: "aws_iam_role", relation: "reference", label: "as" },
149
+ { from: "aws_eks_node_group", path: "cluster_name", targetKind: "aws_eks_cluster", relation: "reference", label: "in cluster" },
150
+ { from: "aws_eks_cluster", path: "role_arn", targetKind: "aws_iam_role", relation: "reference", label: "as" },
151
+ { from: "aws_s3_bucket_policy", path: "bucket", targetKind: "aws_s3_bucket", relation: "reference", label: "grant on" },
152
+ { from: "aws_s3_bucket_versioning", path: "bucket", targetKind: "aws_s3_bucket", relation: "reference", label: "setting on" },
153
+ { from: "aws_s3_bucket_public_access_block", path: "bucket", targetKind: "aws_s3_bucket", relation: "reference", label: "setting on" },
154
+ { from: "aws_s3_bucket_server_side_encryption_configuration", path: "bucket", targetKind: "aws_s3_bucket", relation: "reference", label: "setting on" },
155
+ { from: "aws_s3_bucket_lifecycle_configuration", path: "bucket", targetKind: "aws_s3_bucket", relation: "reference", label: "setting on" },
156
+ { from: "aws_s3_bucket_ownership_controls", path: "bucket", targetKind: "aws_s3_bucket", relation: "reference", label: "setting on" },
157
+ { from: "aws_s3_bucket_acl", path: "bucket", targetKind: "aws_s3_bucket", relation: "reference", label: "grant on" },
158
+ { from: "aws_sqs_queue_policy", path: "queue_url", targetKind: "aws_sqs_queue", relation: "reference", label: "grant on" },
159
+ { from: "aws_lambda_event_source_mapping", path: "event_source_arn", relation: "reference", label: "drains" },
160
+ { from: "aws_lambda_event_source_mapping", path: "function_name", targetKind: "aws_lambda_function", relation: "reference", label: "invokes" },
161
+ { from: "aws_lambda_permission", path: "function_name", targetKind: "aws_lambda_function", relation: "reference", label: "grant on" },
162
+ { from: "aws_sns_topic_subscription", path: "topic_arn", targetKind: "aws_sns_topic", relation: "reference", label: "on" },
163
+ { from: "aws_cloudwatch_log_group", path: "kms_key_id", targetKind: "aws_kms_key", relation: "reference", label: "encrypted with" },
164
+ { from: "aws_sns_topic", path: "kms_master_key_id", targetKind: "aws_kms_key", relation: "reference", label: "encrypted with" },
165
+ { from: "aws_dynamodb_table", path: "server_side_encryption[].kms_key_arn", targetKind: "aws_kms_key", relation: "reference", label: "encrypted with" },
166
+ { from: "aws_iam_role_policy_attachment", path: "role", targetKind: "aws_iam_role", relation: "reference", label: "on" },
167
+ { from: "aws_iam_role_policy_attachment", path: "policy_arn", targetKind: "aws_iam_policy", relation: "reference", label: "attaches" },
168
+ { from: "aws_iam_role_policy", path: "role", targetKind: "aws_iam_role", relation: "reference", label: "on" },
169
+ { from: "aws_iam_instance_profile", path: "role", targetKind: "aws_iam_role", relation: "reference", label: "holds" },
170
+ { from: "aws_route53_record", path: "zone_id", targetKind: "aws_route53_zone", relation: "reference", label: "in zone" },
171
+ // A bucket's regional domain name or a load balancer's DNS name; no `targetKind`.
172
+ { from: "aws_cloudfront_distribution", path: "origin[].domain_name", relation: "reference", label: "origin" },
173
+ ],
174
+ };
175
+
176
+ /** Every provider type the catalog has at least one rule from. */
177
+ export const CATALOGUED_KINDS: ReadonlySet<string> = new Set(TERRAFORM_REFERENCE_CATALOG.refs.map((r) => r.from));
178
+
179
+ /**
180
+ * True when nothing would be looked for on a node of this kind: no rule is
181
+ * written for it, and it is not one of the kinds known to reference nothing.
182
+ */
183
+ export function isUnresolvedKind(kind: string): boolean {
184
+ return !CATALOGUED_KINDS.has(kind) && !REFERENCES_NOTHING.has(kind);
185
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The behaviour engine's request, built from the account and from the file (#2360).
3
+ *
4
+ * Both sides run against the same recorded floci estate
5
+ * `../describe-resources.live.test.ts` uses: `../__fixtures__/live-estate/` is
6
+ * parsed by `renderTerraformRoots` exactly as `buildRoots()` parses it, and
7
+ * `../__fixtures__/live-plan.json` and `../__fixtures__/live-ls.json` were
8
+ * recorded from that configuration by the choudoufu v0.15.0 release binary
9
+ * against choudoufu's pinned floci image (`live-estate/README.md` is the
10
+ * recording log). So the drift asserted below is drift a run produced, not
11
+ * drift a fixture author typed: `storage.tf` was removed after the apply, and
12
+ * three blocks were restored after it and never applied.
13
+ *
14
+ * Nothing here runs choudoufu. The two documents are already parsed, and
15
+ * `terraformBehaviourRequest` is pure over them.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { beforeAll, describe, expect, it } from "vitest";
22
+ import type { Declarable } from "@intentius/chant/declarable";
23
+ import { indexLivePlan, readLiveLs } from "../describe-resources";
24
+ import { renderTerraformRoots } from "../hcl/roots";
25
+ import {
26
+ blockAddressOf,
27
+ edgeCoverageOf,
28
+ regionOfArn,
29
+ substituteReferences,
30
+ terraformBehaviourRequest,
31
+ type LiveResourceFacts,
32
+ type TerraformBehaviourEntity,
33
+ type TerraformBehaviourRequest,
34
+ } from "./request";
35
+ import { isUnresolvedKind, REFERENCES_NOTHING, TERRAFORM_REFERENCE_CATALOG } from "./reference-catalog";
36
+
37
+ const fixtures = join(dirname(fileURLToPath(import.meta.url)), "..", "__fixtures__");
38
+ const PLAN = indexLivePlan(JSON.parse(readFileSync(join(fixtures, "live-plan.json"), "utf-8")));
39
+ const LISTING = readLiveLs(JSON.parse(readFileSync(join(fixtures, "live-ls.json"), "utf-8")));
40
+ const ROOT = "estate";
41
+
42
+ const key = (address: string): string => `${ROOT}/${address}`;
43
+
44
+ let entities: Map<string, TerraformBehaviourEntity>;
45
+ let entityNames: string[];
46
+
47
+ beforeAll(async () => {
48
+ const rendered = await renderTerraformRoots({
49
+ projectRoot: fixtures,
50
+ roots: { [ROOT]: { dir: "./live-estate" } },
51
+ binary: "choudoufu",
52
+ });
53
+ entities = new Map();
54
+ for (const [name, entity] of rendered.entities) {
55
+ const e = entity as Declarable & { props: Record<string, unknown>; references?: readonly never[] };
56
+ entities.set(name, {
57
+ entityType: e.entityType,
58
+ props: e.props,
59
+ ...(e.references ? { references: e.references } : {}),
60
+ });
61
+ }
62
+ entityNames = [...entities.keys()].sort();
63
+ });
64
+
65
+ const common = { environment: "prod", buildOutput: "/tmp/build", traffic: "100 rps, p50" };
66
+
67
+ const declaredSide = (): TerraformBehaviourRequest =>
68
+ terraformBehaviourRequest({ ...common, entityNames, entities });
69
+
70
+ const liveSide = (): TerraformBehaviourRequest =>
71
+ terraformBehaviourRequest({
72
+ ...common,
73
+ entityNames,
74
+ entities,
75
+ live: [{ root: ROOT, listing: LISTING, plan: PLAN }],
76
+ });
77
+
78
+ const factsOf = (built: TerraformBehaviourRequest, address: string): LiveResourceFacts =>
79
+ (built.request.entities.get(key(address))!.props as { live: LiveResourceFacts }).live;
80
+
81
+ describe("the declared side", () => {
82
+ it("builds every declared block, from the file and nothing else", () => {
83
+ const built = declaredSide();
84
+ expect(built.request.entityNames).toEqual(entityNames);
85
+ expect(built.absent).toEqual([]);
86
+ expect(built.unpredicted).toEqual({});
87
+ expect(new Set(Object.values(built.sources))).toEqual(new Set(["declared"]));
88
+ // No block carries a live fact, because no account was read.
89
+ for (const [, entity] of built.request.entities) {
90
+ expect(Object.prototype.hasOwnProperty.call(entity.props, "live")).toBe(false);
91
+ }
92
+ });
93
+
94
+ it("resolves a reference to the entity it names", () => {
95
+ // `aws_security_group_rule.https` names `aws_security_group.main` through
96
+ // `security_group_id`; nothing else in this root references anything.
97
+ expect(declaredSide().request.edges).toEqual([
98
+ {
99
+ from: key("aws_security_group_rule.https"),
100
+ to: key("aws_security_group.main"),
101
+ kind: "ref",
102
+ viaAttr: "security_group_id",
103
+ },
104
+ ]);
105
+ });
106
+ });
107
+
108
+ describe("the live side", () => {
109
+ it("predicts what the account holds, including what nothing declares", () => {
110
+ const built = liveSide();
111
+ // `storage.tf` was removed after the apply, so the bucket it created is
112
+ // still in the account and in no file. It is in the request all the same:
113
+ // the live path predicts the account as it stands.
114
+ expect(built.request.entityNames).toContain(key("aws_s3_bucket.data"));
115
+ expect(declaredSide().request.entityNames).not.toContain(key("aws_s3_bucket.data"));
116
+ const orphan = built.request.entities.get(key("aws_s3_bucket.data"))!;
117
+ expect(orphan.props.resourceType).toBe("aws_s3_bucket");
118
+ expect(orphan.props.root).toBe(ROOT);
119
+ expect(orphan.props.estate).toBe("stateless-e2e-block");
120
+ // No body: the listing carries an identity and a type, never arguments.
121
+ expect(orphan.props.body).toBeUndefined();
122
+ expect(factsOf(built, "aws_s3_bucket.data")).toEqual({
123
+ status: "orphan",
124
+ ownership: "owned",
125
+ arn: "arn:aws:s3:::tofu-stateless-e2e-block-data",
126
+ tags: { "tofu-address": "aws_s3_bucket.data", "tofu-estate": "stateless-e2e-block" },
127
+ });
128
+ });
129
+
130
+ it("leaves a declared resource the account does not hold out of the request, and names it", () => {
131
+ const built = liveSide();
132
+ // Both were declared and never applied; the plan reports them ABSENT.
133
+ expect(built.absent).toEqual([
134
+ key("aws_cloudwatch_log_group.never_applied"),
135
+ key("aws_security_group_rule.https"),
136
+ ]);
137
+ for (const name of built.absent) {
138
+ expect(built.request.entityNames).not.toContain(name);
139
+ // Not `unpredicted` either: the contract has no reason meaning "the
140
+ // account does not hold it", and calling it one would report a
141
+ // successful read as a failed one.
142
+ expect(Object.prototype.hasOwnProperty.call(built.unpredicted, name)).toBe(false);
143
+ expect(Object.prototype.hasOwnProperty.call(built.sources, name)).toBe(false);
144
+ }
145
+ expect(declaredSide().request.entityNames).toEqual(expect.arrayContaining(built.absent));
146
+ });
147
+
148
+ it("carries the identity, the ARN and the region each document states", () => {
149
+ const built = liveSide();
150
+ expect(factsOf(built, "aws_vpc.main")).toEqual({
151
+ status: "bound",
152
+ ownership: "owned",
153
+ identity: "vpc-dc8685c5",
154
+ arn: "arn:aws:ec2:us-east-1:000000000000:vpc/vpc-dc8685c5",
155
+ region: "us-east-1",
156
+ });
157
+ // A global ARN names no region, and none is invented for it.
158
+ expect(factsOf(built, "aws_s3_bucket.data").region).toBeUndefined();
159
+ });
160
+
161
+ it("distinguishes bound, adoptable and foreign rather than calling them all present", () => {
162
+ const built = liveSide();
163
+ expect(factsOf(built, "aws_subnet.app").status).toBe("bound");
164
+ // Content-matched by the estate sweep: a live VPC at the declared cidr,
165
+ // carrying no marker.
166
+ expect(factsOf(built, "aws_vpc.adoptable")).toMatchObject({ status: "adoptable", ownership: "unknown" });
167
+ expect(factsOf(built, "aws_cloudwatch_log_group.adoptable")).toMatchObject({
168
+ status: "adoptable",
169
+ identity: "/stateless-e2e-block/adoptable",
170
+ });
171
+ // Another estate's, and still in the account: the prediction says whose.
172
+ expect(factsOf(built, "aws_cloudwatch_log_group.held_elsewhere")).toMatchObject({
173
+ status: "foreign",
174
+ ownership: "foreign",
175
+ heldBy: "other-estate",
176
+ });
177
+ });
178
+
179
+ it("withholds what is not this estate's when --owned was asked for", () => {
180
+ const built = terraformBehaviourRequest({
181
+ ...common,
182
+ entityNames,
183
+ entities,
184
+ owned: true,
185
+ live: [{ root: ROOT, listing: LISTING, plan: PLAN }],
186
+ });
187
+ for (const address of ["aws_vpc.adoptable", "aws_cloudwatch_log_group.adoptable", "aws_cloudwatch_log_group.held_elsewhere"]) {
188
+ const entry = built.unpredicted[key(address)];
189
+ expect(entry?.reason, address).toBe("filtered");
190
+ expect(entry?.detail, address).toContain("--owned");
191
+ expect(built.request.entityNames).not.toContain(key(address));
192
+ }
193
+ // The estate's own are unaffected.
194
+ expect(built.request.entityNames).toContain(key("aws_vpc.main"));
195
+ expect(built.request.entityNames).toContain(key("aws_s3_bucket.data"));
196
+ });
197
+
198
+ it("keeps a count block one entity and states its instances", () => {
199
+ // The request's unit is the entity the caller asked about. `aws_eip.pool`
200
+ // is one name in one build, and the two slots the account holds ride as a
201
+ // fact rather than as two entities nobody asked about.
202
+ const built = liveSide();
203
+ expect(built.request.entityNames.filter((n) => n.startsWith(key("aws_eip.pool")))).toEqual([
204
+ key("aws_eip.pool"),
205
+ ]);
206
+ expect(factsOf(built, "aws_eip.pool").instances).toEqual(["aws_eip.pool[0]", "aws_eip.pool[1]"]);
207
+ });
208
+
209
+ it("leaves a block that is not a resource alone, on both sides alike", () => {
210
+ // A `terraform` settings block, a provider configuration and the estate
211
+ // sidecar are not things an account holds, so the account says nothing
212
+ // about them and neither side invents a verdict.
213
+ for (const address of ["terraform", "provider.aws", "live"]) {
214
+ const live = liveSide().request.entities.get(key(address))!;
215
+ const declared = declaredSide().request.entities.get(key(address))!;
216
+ expect(live, address).toEqual(declared);
217
+ }
218
+ });
219
+ });
220
+
221
+ describe("the delta between the two sides", () => {
222
+ it("is membership, and is exactly the drift the recording introduced", () => {
223
+ const declared = new Set(declaredSide().request.entityNames);
224
+ const live = new Set(liveSide().request.entityNames);
225
+ expect([...live].filter((n) => !declared.has(n))).toEqual([key("aws_s3_bucket.data")]);
226
+ expect([...declared].filter((n) => !live.has(n)).sort()).toEqual([
227
+ key("aws_cloudwatch_log_group.never_applied"),
228
+ key("aws_security_group_rule.https"),
229
+ ]);
230
+ });
231
+
232
+ it("is not a difference in how the two graphs were assembled", () => {
233
+ // The third review comment on #2360: until both sides produce containment,
234
+ // a "one zone lost" verdict differs between them for a reason that is not
235
+ // drift. One producer runs the same catalog over both, so the subnet's and
236
+ // the security group's membership of the VPC is on each.
237
+ expect(liveSide().request.edgeCoverage.containmentEdges).toEqual(
238
+ declaredSide().request.edgeCoverage.containmentEdges,
239
+ );
240
+ expect(declaredSide().request.edgeCoverage.containmentEdges).toEqual([
241
+ { from: key("aws_security_group.main"), to: key("aws_vpc.main"), kind: "ref", viaAttr: "vpc_id" },
242
+ { from: key("aws_subnet.app"), to: key("aws_vpc.main"), kind: "ref", viaAttr: "vpc_id" },
243
+ ]);
244
+ });
245
+
246
+ it("loses the one reference edge, because the resource that made it was never applied", () => {
247
+ // Not a defect in the reconstruction: `aws_security_group_rule.https` is
248
+ // ABSENT, so the account holds nothing to have a reference.
249
+ expect(liveSide().request.edges).toEqual([]);
250
+ expect(declaredSide().request.edges).toHaveLength(1);
251
+ });
252
+ });
253
+
254
+ describe("edgeCoverage states what was reconstructed", () => {
255
+ it("claims complete only when nothing is missing", () => {
256
+ expect(edgeCoverageOf({ dangling: [], containmentEdges: [] }, [])).toEqual({
257
+ verdict: "complete",
258
+ containmentEdges: [],
259
+ });
260
+ });
261
+
262
+ it("is partial, with the list, when a reference left the estate", () => {
263
+ const dangling = [{ from: "web", path: "subnet_id", value: "subnet-gone", targetKind: "aws_subnet" }];
264
+ const coverage = edgeCoverageOf({ dangling, containmentEdges: [] }, []);
265
+ expect(coverage.verdict).toBe("partial");
266
+ // Carried through unflattened: `from` is what says whose argument leaves.
267
+ expect(coverage.dangling).toEqual(dangling);
268
+ });
269
+
270
+ it("is partial, naming the kinds, when nothing was looked for", () => {
271
+ const coverage = edgeCoverageOf({ dangling: [], containmentEdges: [] }, ["aws_zebra", "aws_aardvark"]);
272
+ expect(coverage.verdict).toBe("partial");
273
+ expect(coverage.unresolvedKinds).toEqual(["aws_aardvark", "aws_zebra"]);
274
+ });
275
+
276
+ it("counts a kind with no rule, and does not count one known to reference nothing", () => {
277
+ // The two causes of "no rule" that should not read alike: nobody has
278
+ // written one, and there is nothing to write one about.
279
+ expect(isUnresolvedKind("aws_kinesis_stream")).toBe(true);
280
+ expect(isUnresolvedKind("aws_vpc")).toBe(false);
281
+ expect(REFERENCES_NOTHING.has("aws_vpc")).toBe(true);
282
+ for (const kind of REFERENCES_NOTHING) {
283
+ expect(
284
+ TERRAFORM_REFERENCE_CATALOG.refs.some((r) => r.from === kind),
285
+ `${kind} is listed as referencing nothing and has a rule`,
286
+ ).toBe(false);
287
+ }
288
+ });
289
+
290
+ it("says complete for this estate, because every kind in it was looked at", () => {
291
+ for (const built of [declaredSide(), liveSide()]) {
292
+ expect(built.request.edgeCoverage.verdict, JSON.stringify(built.request.edgeCoverage)).toBe("complete");
293
+ }
294
+ });
295
+ });
296
+
297
+ describe("the pieces the two sides share", () => {
298
+ it("reads a block address off an instance address", () => {
299
+ expect(blockAddressOf("aws_eip.pool[0]")).toBe("aws_eip.pool");
300
+ expect(blockAddressOf('aws_subnet.this["a"]')).toBe("aws_subnet.this");
301
+ expect(blockAddressOf("aws_vpc.main")).toBe("aws_vpc.main");
302
+ });
303
+
304
+ it("reads a region off an ARN, and nothing off a global one", () => {
305
+ expect(regionOfArn("arn:aws:ec2:us-east-1:000000000000:vpc/vpc-dc8685c5")).toBe("us-east-1");
306
+ expect(regionOfArn("arn:aws:s3:::tofu-stateless-e2e-block-data")).toBeUndefined();
307
+ expect(regionOfArn("not-an-arn")).toBeUndefined();
308
+ });
309
+
310
+ it("substitutes a reference only when one reference explains the whole value", () => {
311
+ const references = [
312
+ { to: "net/aws_vpc.main", viaAttr: "vpc_id", toAttr: "id" },
313
+ { to: "net/aws_subnet.app", viaAttr: "subnet_id", toAttr: "id" },
314
+ ];
315
+ const addressOf = (to: string): string | undefined =>
316
+ ({ "net/aws_vpc.main": "aws_vpc.main", "net/aws_subnet.app": "aws_subnet.app" })[to];
317
+ const resolve = (to: string): string | undefined =>
318
+ ({ "net/aws_vpc.main": "vpc-dc8685c5", "net/aws_subnet.app": "subnet-b870b7fc" })[to];
319
+
320
+ expect(
321
+ substituteReferences(
322
+ { vpc_id: "${aws_vpc.main.id}", subnet_id: "${aws_subnet.app.id}", cidr_block: "10.0.0.0/16" },
323
+ references,
324
+ addressOf,
325
+ resolve,
326
+ ),
327
+ ).toEqual({ vpc_id: "vpc-dc8685c5", subnet_id: "subnet-b870b7fc", cidr_block: "10.0.0.0/16" });
328
+
329
+ // A composed value is not an identifier, and a reference whose target the
330
+ // account does not hold stays put — which is what makes it come back from
331
+ // the resolver as dangling rather than silently vanishing.
332
+ expect(
333
+ substituteReferences(
334
+ { vpc_id: "${aws_vpc.main.id}-suffix", subnet_id: "${aws_subnet.gone.id}" },
335
+ [...references, { to: "net/aws_subnet.gone", viaAttr: "subnet_id", toAttr: "id" }],
336
+ (to) => (to === "net/aws_subnet.gone" ? "aws_subnet.gone" : addressOf(to)),
337
+ resolve,
338
+ ),
339
+ ).toEqual({ vpc_id: "${aws_vpc.main.id}-suffix", subnet_id: "${aws_subnet.gone.id}" });
340
+ });
341
+
342
+ it("substitutes inside a nested block, under the argument that owns it", () => {
343
+ expect(
344
+ substituteReferences(
345
+ { vpc_config: [{ subnet_ids: ["${aws_subnet.app.id}"] }] },
346
+ [{ to: "net/aws_subnet.app", viaAttr: "vpc_config", toAttr: "id" }],
347
+ () => "aws_subnet.app",
348
+ () => "subnet-b870b7fc",
349
+ ),
350
+ ).toEqual({ vpc_config: [{ subnet_ids: ["subnet-b870b7fc"] }] });
351
+ });
352
+ });