@intentius/chant-lexicon-terraform 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/behaviour/index.d.ts +4 -0
- package/dist/behaviour/index.d.ts.map +1 -0
- package/dist/behaviour/predict.d.ts +112 -0
- package/dist/behaviour/predict.d.ts.map +1 -0
- package/dist/behaviour/reference-catalog.d.ts +79 -0
- package/dist/behaviour/reference-catalog.d.ts.map +1 -0
- package/dist/behaviour/request.d.ts +178 -0
- package/dist/behaviour/request.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +51 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/behaviour/index.ts +29 -0
- package/src/behaviour/predict.test.ts +381 -0
- package/src/behaviour/predict.ts +225 -0
- package/src/behaviour/reference-catalog.ts +185 -0
- package/src/behaviour/request.test.ts +352 -0
- package/src/behaviour/request.ts +622 -0
- package/src/describe-resources.ts +15 -6
- package/src/index.ts +17 -0
|
@@ -0,0 +1,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
|
+
});
|