@intentius/chant 0.18.36 → 0.19.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/commands/carve-apply.d.ts +30 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -0
- package/dist/cli/commands/carve-bridge.d.ts +32 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -0
- package/dist/cli/commands/carve-emit.d.ts +58 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -0
- package/dist/cli/commands/carve.d.ts +29 -0
- package/dist/cli/commands/carve.d.ts.map +1 -0
- package/dist/cli/handlers/carve-apply.d.ts +9 -0
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -0
- package/dist/cli/handlers/carve-bridge.d.ts +10 -0
- package/dist/cli/handlers/carve-bridge.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts +11 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -0
- package/dist/cli/handlers/carve.d.ts +12 -0
- package/dist/cli/handlers/carve.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +32 -0
- package/dist/terraform/adopt-state.d.ts.map +1 -0
- package/dist/terraform/aws-resources.d.ts +49 -0
- package/dist/terraform/aws-resources.d.ts.map +1 -0
- package/dist/terraform/bridge.d.ts +65 -0
- package/dist/terraform/bridge.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +66 -0
- package/dist/terraform/carve.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +43 -0
- package/dist/terraform/graduate.d.ts.map +1 -0
- package/dist/terraform/graph.d.ts +24 -0
- package/dist/terraform/graph.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +33 -0
- package/dist/terraform/parse.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +50 -0
- package/dist/terraform/score.d.ts.map +1 -0
- package/dist/terraform/state.d.ts +42 -0
- package/dist/terraform/state.d.ts.map +1 -0
- package/dist/terraform/tier-map.d.ts +38 -0
- package/dist/terraform/tier-map.d.ts.map +1 -0
- package/dist/terraform/types.d.ts +66 -0
- package/dist/terraform/types.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/cli/commands/carve-apply.test.ts +82 -0
- package/src/cli/commands/carve-apply.ts +106 -0
- package/src/cli/commands/carve-bridge.test.ts +97 -0
- package/src/cli/commands/carve-bridge.ts +134 -0
- package/src/cli/commands/carve-emit-state.test.ts +114 -0
- package/src/cli/commands/carve-emit.test.ts +158 -0
- package/src/cli/commands/carve-emit.ts +181 -0
- package/src/cli/commands/carve.test.ts +104 -0
- package/src/cli/commands/carve.ts +123 -0
- package/src/cli/handlers/carve-apply.ts +34 -0
- package/src/cli/handlers/carve-bridge.ts +33 -0
- package/src/cli/handlers/carve-emit.ts +51 -0
- package/src/cli/handlers/carve.ts +44 -0
- package/src/cli/main.ts +48 -0
- package/src/cli/registry.ts +12 -0
- package/src/terraform/__fixtures__/advise.test.ts +51 -0
- package/src/terraform/__fixtures__/carve-arc.test.ts +77 -0
- package/src/terraform/__fixtures__/sample-estate/main.tf +58 -0
- package/src/terraform/adopt-state.test.ts +64 -0
- package/src/terraform/adopt-state.ts +79 -0
- package/src/terraform/aws-resources.test.ts +74 -0
- package/src/terraform/aws-resources.ts +154 -0
- package/src/terraform/bridge.test.ts +118 -0
- package/src/terraform/bridge.ts +164 -0
- package/src/terraform/carve.test.ts +97 -0
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/graduate.test.ts +49 -0
- package/src/terraform/graduate.ts +78 -0
- package/src/terraform/graph.test.ts +137 -0
- package/src/terraform/graph.ts +176 -0
- package/src/terraform/parse.test.ts +56 -0
- package/src/terraform/parse.ts +87 -0
- package/src/terraform/score.test.ts +140 -0
- package/src/terraform/score.ts +148 -0
- package/src/terraform/state.test.ts +76 -0
- package/src/terraform/state.ts +87 -0
- package/src/terraform/tier-map.ts +60 -0
- package/src/terraform/types.ts +69 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { AWS_CARVE_TYPES, awsCarveType, applyAwsMapper } from "./aws-resources";
|
|
3
|
+
import { TIER_MAP } from "./tier-map";
|
|
4
|
+
import { canAdoptFromState } from "./adopt-state";
|
|
5
|
+
|
|
6
|
+
describe("AWS carve-out table", () => {
|
|
7
|
+
test("advise and emit cover exactly the same AWS types (no cliff)", () => {
|
|
8
|
+
// Every AWS type the advisor ranks (tier map) must be emittable, and vice
|
|
9
|
+
// versa. Non-AWS entries (kubernetes_manifest) are excluded.
|
|
10
|
+
const tierAws = Object.keys(TIER_MAP).filter((t) => t.startsWith("aws_")).sort();
|
|
11
|
+
const emitAws = AWS_CARVE_TYPES.map((t) => t.tfType).sort();
|
|
12
|
+
expect(tierAws).toEqual(emitAws);
|
|
13
|
+
for (const t of emitAws) expect(canAdoptFromState(t)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("covers the common carve targets across families", () => {
|
|
17
|
+
const types = new Set(AWS_CARVE_TYPES.map((t) => t.tfType));
|
|
18
|
+
for (const t of [
|
|
19
|
+
"aws_s3_bucket", "aws_iam_role", "aws_iam_policy", "aws_dynamodb_table",
|
|
20
|
+
"aws_lambda_function", "aws_sns_topic", "aws_sqs_queue", "aws_kms_key",
|
|
21
|
+
"aws_secretsmanager_secret", "aws_ssm_parameter", "aws_ecr_repository",
|
|
22
|
+
"aws_vpc", "aws_subnet", "aws_security_group", "aws_route53_zone",
|
|
23
|
+
"aws_cloudwatch_log_group",
|
|
24
|
+
]) {
|
|
25
|
+
expect(types.has(t)).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
expect(AWS_CARVE_TYPES.length).toBeGreaterThanOrEqual(20);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("every constructor name is unique and non-empty", () => {
|
|
31
|
+
const ctors = AWS_CARVE_TYPES.map((t) => t.ctor);
|
|
32
|
+
expect(ctors.every((c) => c.length > 0)).toBe(true);
|
|
33
|
+
expect(new Set(ctors).size).toBe(ctors.length);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("applyAwsMapper", () => {
|
|
38
|
+
test("renames fields to CloudFormation props and reshapes tags", () => {
|
|
39
|
+
const { props, mappedKeys } = applyAwsMapper(awsCarveType("aws_s3_bucket")!, {
|
|
40
|
+
id: "b", bucket: "my-bucket", tags: { Env: "prod" }, force_destroy: false,
|
|
41
|
+
});
|
|
42
|
+
expect(props).toEqual({ BucketName: "my-bucket", Tags: [{ Key: "Env", Value: "prod" }] });
|
|
43
|
+
expect(mappedKeys).toContain("bucket");
|
|
44
|
+
expect(mappedKeys).toContain("tags");
|
|
45
|
+
expect(mappedKeys).not.toContain("force_destroy"); // stays unmapped
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("parses a JSON-string policy into an object", () => {
|
|
49
|
+
const { props } = applyAwsMapper(awsCarveType("aws_iam_role")!, {
|
|
50
|
+
name: "r",
|
|
51
|
+
assume_role_policy: '{"Version":"2012-10-17","Statement":[]}',
|
|
52
|
+
managed_policy_arns: ["arn:aws:iam::aws:policy/X"],
|
|
53
|
+
});
|
|
54
|
+
expect(props.RoleName).toBe("r");
|
|
55
|
+
expect(props.AssumeRolePolicyDocument).toEqual({ Version: "2012-10-17", Statement: [] });
|
|
56
|
+
expect(props.ManagedPolicyArns).toEqual(["arn:aws:iam::aws:policy/X"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("wraps an instance-profile role into a list", () => {
|
|
60
|
+
const { props } = applyAwsMapper(awsCarveType("aws_iam_instance_profile")!, { name: "p", role: "my-role" });
|
|
61
|
+
expect(props).toEqual({ InstanceProfileName: "p", Roles: ["my-role"] });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a malformed JSON policy is preserved as a string, not dropped", () => {
|
|
65
|
+
const { props } = applyAwsMapper(awsCarveType("aws_iam_policy")!, { name: "p", policy: "not json {" });
|
|
66
|
+
expect(props.PolicyDocument).toBe("not json {");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("omits absent attributes", () => {
|
|
70
|
+
const { props } = applyAwsMapper(awsCarveType("aws_subnet")!, { vpc_id: "vpc-1", cidr_block: "10.0.1.0/24" });
|
|
71
|
+
expect(props).toEqual({ VpcId: "vpc-1", CidrBlock: "10.0.1.0/24" });
|
|
72
|
+
expect(props).not.toHaveProperty("AvailabilityZone");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AWS carve-out resource table — the single source of truth for which AWS
|
|
3
|
+
* Terraform types chant can carve, and how.
|
|
4
|
+
*
|
|
5
|
+
* Both the advisor (tier map / scoring) and `carve emit --state` derive from
|
|
6
|
+
* this one list, so advise and emit cover exactly the same AWS types: advise
|
|
7
|
+
* never ranks a resource emit cannot produce, and emit never claims a type
|
|
8
|
+
* advise did not score. Adding a type here lights it up for both at once.
|
|
9
|
+
*
|
|
10
|
+
* Each entry maps the common, high-confidence Terraform attributes to their
|
|
11
|
+
* CloudFormation property names (chant AWS constructors take CFN PascalCase
|
|
12
|
+
* props). Attributes without a mapping here are preserved in a reference
|
|
13
|
+
* comment on emit, never dropped — a curated seed, not a full TF↔CFN transform.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A Terraform attribute → CloudFormation property mapping. */
|
|
17
|
+
type FieldSpec = string | { prop: string; transform: (v: unknown) => unknown };
|
|
18
|
+
|
|
19
|
+
export interface AwsCarveType {
|
|
20
|
+
/** Terraform resource type, e.g. `aws_s3_bucket`. */
|
|
21
|
+
tfType: string;
|
|
22
|
+
/** Native-spec map tier: 1 clean 1:1, 2 some reshaping, 3 heavy composite. */
|
|
23
|
+
tier: 1 | 2 | 3;
|
|
24
|
+
/** CloudFormation type, e.g. `AWS::S3::Bucket`. */
|
|
25
|
+
nativeType: string;
|
|
26
|
+
/** chant AWS lexicon constructor, e.g. `Bucket` (verified against generated exports). */
|
|
27
|
+
ctor: string;
|
|
28
|
+
/** The HCL attribute carrying the physical name (for the live-import hint / graph identity). */
|
|
29
|
+
identityAttr?: string;
|
|
30
|
+
/** Terraform attribute → CFN property mappings. */
|
|
31
|
+
fields: Record<string, FieldSpec>;
|
|
32
|
+
/** Map the Terraform `tags` map to CloudFormation `Tags` list. */
|
|
33
|
+
tags?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const AWS_LEXICON_IMPORT = "@intentius/chant-lexicon-aws";
|
|
37
|
+
|
|
38
|
+
/** Parse a Terraform JSON-string attribute (IAM policy docs) into an object. */
|
|
39
|
+
const asJson = (v: unknown): unknown => {
|
|
40
|
+
if (typeof v !== "string") return v;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(v);
|
|
43
|
+
} catch {
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const json = (prop: string): FieldSpec => ({ prop, transform: asJson });
|
|
48
|
+
|
|
49
|
+
export const AWS_CARVE_TYPES: AwsCarveType[] = [
|
|
50
|
+
// ── Storage & data ──
|
|
51
|
+
{ tfType: "aws_s3_bucket", tier: 1, nativeType: "AWS::S3::Bucket", ctor: "Bucket", identityAttr: "bucket",
|
|
52
|
+
fields: { bucket: "BucketName" }, tags: true },
|
|
53
|
+
{ tfType: "aws_dynamodb_table", tier: 2, nativeType: "AWS::DynamoDB::Table", ctor: "Table", identityAttr: "name",
|
|
54
|
+
fields: { name: "TableName", billing_mode: "BillingMode" }, tags: true },
|
|
55
|
+
{ tfType: "aws_efs_file_system", tier: 1, nativeType: "AWS::EFS::FileSystem", ctor: "EFSFileSystem",
|
|
56
|
+
fields: { encrypted: "Encrypted", performance_mode: "PerformanceMode", throughput_mode: "ThroughputMode" }, tags: true },
|
|
57
|
+
|
|
58
|
+
// ── Messaging ──
|
|
59
|
+
{ tfType: "aws_sns_topic", tier: 1, nativeType: "AWS::SNS::Topic", ctor: "Topic", identityAttr: "name",
|
|
60
|
+
fields: { name: "TopicName", display_name: "DisplayName", fifo_topic: "FifoTopic" }, tags: true },
|
|
61
|
+
{ tfType: "aws_sqs_queue", tier: 1, nativeType: "AWS::SQS::Queue", ctor: "Queue", identityAttr: "name",
|
|
62
|
+
fields: { name: "QueueName", visibility_timeout_seconds: "VisibilityTimeout", fifo_queue: "FifoQueue", delay_seconds: "DelaySeconds" }, tags: true },
|
|
63
|
+
|
|
64
|
+
// ── IAM ──
|
|
65
|
+
{ tfType: "aws_iam_role", tier: 2, nativeType: "AWS::IAM::Role", ctor: "Role", identityAttr: "name",
|
|
66
|
+
fields: { name: "RoleName", assume_role_policy: json("AssumeRolePolicyDocument"), managed_policy_arns: "ManagedPolicyArns", description: "Description", path: "Path" }, tags: true },
|
|
67
|
+
{ tfType: "aws_iam_policy", tier: 1, nativeType: "AWS::IAM::ManagedPolicy", ctor: "ManagedPolicy", identityAttr: "name",
|
|
68
|
+
fields: { name: "ManagedPolicyName", policy: json("PolicyDocument"), description: "Description", path: "Path" } },
|
|
69
|
+
{ tfType: "aws_iam_instance_profile", tier: 1, nativeType: "AWS::IAM::InstanceProfile", ctor: "InstanceProfile", identityAttr: "name",
|
|
70
|
+
fields: { name: "InstanceProfileName", path: "Path", role: { prop: "Roles", transform: (v) => (v === undefined ? v : [v]) } } },
|
|
71
|
+
|
|
72
|
+
// ── Registry, keys, secrets, config ──
|
|
73
|
+
{ tfType: "aws_ecr_repository", tier: 1, nativeType: "AWS::ECR::Repository", ctor: "ECRRepository", identityAttr: "name",
|
|
74
|
+
fields: { name: "RepositoryName", image_tag_mutability: "ImageTagMutability" }, tags: true },
|
|
75
|
+
{ tfType: "aws_kms_key", tier: 2, nativeType: "AWS::KMS::Key", ctor: "KmsKey",
|
|
76
|
+
fields: { description: "Description", enable_key_rotation: "EnableKeyRotation", deletion_window_in_days: "PendingWindowInDays", is_enabled: "Enabled" }, tags: true },
|
|
77
|
+
{ tfType: "aws_secretsmanager_secret", tier: 1, nativeType: "AWS::SecretsManager::Secret", ctor: "Secret", identityAttr: "name",
|
|
78
|
+
fields: { name: "Name", description: "Description", kms_key_id: "KmsKeyId" }, tags: true },
|
|
79
|
+
{ tfType: "aws_ssm_parameter", tier: 1, nativeType: "AWS::SSM::Parameter", ctor: "SsmParameter", identityAttr: "name",
|
|
80
|
+
fields: { name: "Name", type: "Type", value: "Value", tier: "Tier", description: "Description" } },
|
|
81
|
+
|
|
82
|
+
// ── Compute ──
|
|
83
|
+
{ tfType: "aws_lambda_function", tier: 2, nativeType: "AWS::Lambda::Function", ctor: "Function", identityAttr: "function_name",
|
|
84
|
+
fields: { function_name: "FunctionName", runtime: "Runtime", handler: "Handler", memory_size: "MemorySize", timeout: "Timeout", description: "Description", architectures: "Architectures" }, tags: true },
|
|
85
|
+
{ tfType: "aws_ecs_service", tier: 3, nativeType: "AWS::ECS::Service", ctor: "EcsService", identityAttr: "name",
|
|
86
|
+
fields: { name: "ServiceName", cluster: "Cluster", desired_count: "DesiredCount", launch_type: "LaunchType", task_definition: "TaskDefinition" }, tags: true },
|
|
87
|
+
|
|
88
|
+
// ── Networking ──
|
|
89
|
+
{ tfType: "aws_vpc", tier: 1, nativeType: "AWS::EC2::VPC", ctor: "Vpc",
|
|
90
|
+
fields: { cidr_block: "CidrBlock", enable_dns_support: "EnableDnsSupport", enable_dns_hostnames: "EnableDnsHostnames", instance_tenancy: "InstanceTenancy" }, tags: true },
|
|
91
|
+
{ tfType: "aws_subnet", tier: 1, nativeType: "AWS::EC2::Subnet", ctor: "Subnet",
|
|
92
|
+
fields: { vpc_id: "VpcId", cidr_block: "CidrBlock", availability_zone: "AvailabilityZone", map_public_ip_on_launch: "MapPublicIpOnLaunch" }, tags: true },
|
|
93
|
+
{ tfType: "aws_security_group", tier: 2, nativeType: "AWS::EC2::SecurityGroup", ctor: "SecurityGroup", identityAttr: "name",
|
|
94
|
+
fields: { description: "GroupDescription", name: "GroupName", vpc_id: "VpcId" }, tags: true },
|
|
95
|
+
{ tfType: "aws_route_table", tier: 2, nativeType: "AWS::EC2::RouteTable", ctor: "RouteTable",
|
|
96
|
+
fields: { vpc_id: "VpcId" }, tags: true },
|
|
97
|
+
{ tfType: "aws_internet_gateway", tier: 1, nativeType: "AWS::EC2::InternetGateway", ctor: "InternetGateway",
|
|
98
|
+
fields: {}, tags: true },
|
|
99
|
+
{ tfType: "aws_eip", tier: 1, nativeType: "AWS::EC2::EIP", ctor: "EIP",
|
|
100
|
+
fields: { domain: "Domain", instance: "InstanceId" }, tags: true },
|
|
101
|
+
{ tfType: "aws_nat_gateway", tier: 2, nativeType: "AWS::EC2::NatGateway", ctor: "NatGateway",
|
|
102
|
+
fields: { subnet_id: "SubnetId", allocation_id: "AllocationId", connectivity_type: "ConnectivityType" }, tags: true },
|
|
103
|
+
|
|
104
|
+
// ── DNS & observability ──
|
|
105
|
+
{ tfType: "aws_route53_zone", tier: 1, nativeType: "AWS::Route53::HostedZone", ctor: "HostedZone", identityAttr: "name",
|
|
106
|
+
fields: { name: "Name", comment: "HostedZoneConfig" } },
|
|
107
|
+
{ tfType: "aws_cloudwatch_log_group", tier: 1, nativeType: "AWS::Logs::LogGroup", ctor: "LogGroup", identityAttr: "name",
|
|
108
|
+
fields: { name: "LogGroupName", retention_in_days: "RetentionInDays", kms_key_id: "KmsKeyId" }, tags: true },
|
|
109
|
+
{ tfType: "aws_cloudwatch_metric_alarm", tier: 2, nativeType: "AWS::CloudWatch::Alarm", ctor: "Alarm", identityAttr: "alarm_name",
|
|
110
|
+
fields: { alarm_name: "AlarmName", comparison_operator: "ComparisonOperator", metric_name: "MetricName", namespace: "Namespace", threshold: "Threshold", evaluation_periods: "EvaluationPeriods", period: "Period", statistic: "Statistic", alarm_description: "AlarmDescription" }, tags: true },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const BY_TYPE = new Map(AWS_CARVE_TYPES.map((t) => [t.tfType, t]));
|
|
114
|
+
|
|
115
|
+
export function awsCarveType(tfType: string): AwsCarveType | undefined {
|
|
116
|
+
return BY_TYPE.get(tfType);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** TF `tags` map → CloudFormation `Tags` list of {Key, Value}. */
|
|
120
|
+
function tagsToCfn(tags: unknown): Array<{ Key: string; Value: unknown }> | undefined {
|
|
121
|
+
if (!tags || typeof tags !== "object" || Array.isArray(tags)) return undefined;
|
|
122
|
+
const entries = Object.entries(tags as Record<string, unknown>);
|
|
123
|
+
return entries.length ? entries.map(([Key, Value]) => ({ Key, Value })) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Apply an entry's field mappings to a resource's Terraform attributes, yielding
|
|
128
|
+
* CloudFormation properties and the set of attribute keys that were consumed
|
|
129
|
+
* (so the caller can report the rest as unmapped).
|
|
130
|
+
*/
|
|
131
|
+
export function applyAwsMapper(
|
|
132
|
+
entry: AwsCarveType,
|
|
133
|
+
attrs: Record<string, unknown>,
|
|
134
|
+
): { props: Record<string, unknown>; mappedKeys: string[] } {
|
|
135
|
+
const props: Record<string, unknown> = {};
|
|
136
|
+
const mappedKeys: string[] = ["id", "arn"]; // identity/computed attrs, ignored not dropped
|
|
137
|
+
for (const [tfAttr, spec] of Object.entries(entry.fields)) {
|
|
138
|
+
const value = attrs[tfAttr];
|
|
139
|
+
if (value === undefined) continue;
|
|
140
|
+
if (typeof spec === "string") {
|
|
141
|
+
props[spec] = value;
|
|
142
|
+
} else {
|
|
143
|
+
const t = spec.transform(value);
|
|
144
|
+
if (t !== undefined) props[spec.prop] = t;
|
|
145
|
+
}
|
|
146
|
+
mappedKeys.push(tfAttr);
|
|
147
|
+
}
|
|
148
|
+
if (entry.tags) {
|
|
149
|
+
const tags = tagsToCfn(attrs.tags);
|
|
150
|
+
if (tags) props.Tags = tags;
|
|
151
|
+
mappedKeys.push("tags");
|
|
152
|
+
}
|
|
153
|
+
return { props, mappedKeys };
|
|
154
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { buildGraph } from "./graph";
|
|
3
|
+
import { boundaryReport } from "./carve";
|
|
4
|
+
import { generateBridge, type CarvedIdentity } from "./bridge";
|
|
5
|
+
import type { Hcl2JsonTree } from "./types";
|
|
6
|
+
|
|
7
|
+
const workedExample: Hcl2JsonTree = {
|
|
8
|
+
resource: {
|
|
9
|
+
aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
|
|
10
|
+
aws_lambda_function: {
|
|
11
|
+
api: [
|
|
12
|
+
{
|
|
13
|
+
function_name: "myapp-api",
|
|
14
|
+
environment: {
|
|
15
|
+
variables: {
|
|
16
|
+
ASSETS_BUCKET: "${aws_s3_bucket.assets.bucket}",
|
|
17
|
+
ASSETS_ARN: "${aws_s3_bucket.assets.arn}",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// The original surviving Terraform text (bare HCL references, as authored).
|
|
27
|
+
const API_TF = `resource "aws_lambda_function" "api" {
|
|
28
|
+
function_name = "myapp-api"
|
|
29
|
+
environment {
|
|
30
|
+
variables = {
|
|
31
|
+
ASSETS_BUCKET = aws_s3_bucket.assets.bucket
|
|
32
|
+
ASSETS_ARN = aws_s3_bucket.assets.arn
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const identities = new Map<string, CarvedIdentity>([
|
|
39
|
+
["aws_s3_bucket.assets", { attr: "bucket", value: "myapp-assets-prod" }],
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
describe("generateBridge — inbound (data-source rewrite)", () => {
|
|
43
|
+
test("adds a data source for the carved bucket", () => {
|
|
44
|
+
const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
45
|
+
const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
|
|
46
|
+
|
|
47
|
+
expect(plan.dataSources).toHaveLength(1);
|
|
48
|
+
expect(plan.dataSources[0]).toMatchObject({ address: "aws_s3_bucket.assets", type: "aws_s3_bucket", name: "assets" });
|
|
49
|
+
expect(plan.dataSources[0].hcl).toBe(
|
|
50
|
+
'data "aws_s3_bucket" "assets" {\n bucket = "myapp-assets-prod"\n}',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rewrites survivor references to the data source", () => {
|
|
55
|
+
const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
56
|
+
const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
|
|
57
|
+
|
|
58
|
+
const rewrite = plan.rewrites.find((r) => r.path === "api.tf")!;
|
|
59
|
+
expect(rewrite.changed).toBe(true);
|
|
60
|
+
expect(rewrite.rewritten).toContain("data.aws_s3_bucket.assets.bucket");
|
|
61
|
+
expect(rewrite.rewritten).toContain("data.aws_s3_bucket.assets.arn");
|
|
62
|
+
// The bare references are gone.
|
|
63
|
+
expect(rewrite.rewritten).not.toMatch(/(?<!data\.)\baws_s3_bucket\.assets\.bucket/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not double-prefix an already-rewritten reference (idempotent)", () => {
|
|
67
|
+
const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
68
|
+
const already = API_TF.replace(/aws_s3_bucket\.assets/g, "data.aws_s3_bucket.assets");
|
|
69
|
+
const plan = generateBridge(report, [{ path: "api.tf", content: already }], identities);
|
|
70
|
+
const rewrite = plan.rewrites.find((r) => r.path === "api.tf")!;
|
|
71
|
+
expect(rewrite.changed).toBe(false);
|
|
72
|
+
expect(rewrite.rewritten).not.toContain("data.data.aws_s3_bucket");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("does not touch the carved resource's own declaration (quoted labels)", () => {
|
|
76
|
+
const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
77
|
+
const decl = `resource "aws_s3_bucket" "assets" {\n bucket = "myapp-assets-prod"\n}\n`;
|
|
78
|
+
const plan = generateBridge(report, [{ path: "bucket.tf", content: decl }], identities);
|
|
79
|
+
const rewrite = plan.rewrites.find((r) => r.path === "bucket.tf")!;
|
|
80
|
+
// `resource "aws_s3_bucket" "assets"` uses quoted labels, not the dotted
|
|
81
|
+
// token, so it is left alone.
|
|
82
|
+
expect(rewrite.rewritten).toContain('resource "aws_s3_bucket" "assets"');
|
|
83
|
+
expect(rewrite.changed).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("unknown identity emits a TODO data source instead of a wrong value", () => {
|
|
87
|
+
const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
88
|
+
const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], new Map());
|
|
89
|
+
expect(plan.dataSources[0].hcl).toContain("# TODO");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("generateBridge — outbound (deferred inputs)", () => {
|
|
94
|
+
test("records outbound edges as deferred deploy-time inputs", () => {
|
|
95
|
+
const tree: Hcl2JsonTree = {
|
|
96
|
+
resource: {
|
|
97
|
+
aws_sns_topic: { alerts: [{ name: "a" }] },
|
|
98
|
+
aws_sqs_queue: { dlq: [{ name: "d", redrive: "${aws_sns_topic.alerts.arn}" }] },
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const report = boundaryReport(buildGraph(tree), "aws_sqs_queue.dlq")!;
|
|
102
|
+
const plan = generateBridge(report, [], new Map());
|
|
103
|
+
expect(plan.dataSources).toEqual([]); // no inbound → no survivor patch
|
|
104
|
+
expect(plan.deferredInputs).toHaveLength(1);
|
|
105
|
+
expect(plan.deferredInputs[0]).toMatchObject({ survivor: "aws_sns_topic.alerts", carved: "aws_sqs_queue.dlq" });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("generateBridge — runbook", () => {
|
|
110
|
+
test("is reversible and observe-first, with the state-rm and import steps", () => {
|
|
111
|
+
const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
112
|
+
const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
|
|
113
|
+
expect(plan.runbook).toContain("terraform state rm aws_s3_bucket.assets");
|
|
114
|
+
expect(plan.runbook).toContain("does NOT destroy");
|
|
115
|
+
expect(plan.runbook).toContain("terraform import aws_s3_bucket.assets");
|
|
116
|
+
expect(plan.runbook).toMatch(/reversible/i);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boundary bridging for the strangler-fig carve (#197) — the centerpiece.
|
|
3
|
+
*
|
|
4
|
+
* Carving a resource cuts edges. This generates the edits to the Terraform you
|
|
5
|
+
* LEAVE BEHIND so its plan stays valid once the resource is chant-owned:
|
|
6
|
+
*
|
|
7
|
+
* - inbound edge → the survivor loses its reference to the carved resource.
|
|
8
|
+
* Bridge: add a `data` source for the (now chant-managed) resource and
|
|
9
|
+
* rewrite the survivor's `type.name.attr` references to `data.type.name.attr`.
|
|
10
|
+
* Required immediately, or `terraform plan` errors on the dangling ref.
|
|
11
|
+
*
|
|
12
|
+
* - outbound edge → the carved resource read a value from a survivor. That
|
|
13
|
+
* value must enter chant from outside synthesis, as a deploy-time input.
|
|
14
|
+
* Deferred until apply; here we only record the provenance.
|
|
15
|
+
*
|
|
16
|
+
* Pure text transform over the original `.tf` content — no wasm, no shell, no
|
|
17
|
+
* mutation. The command layer writes the results and (optionally) diffs them
|
|
18
|
+
* into a git-applyable patch. A carve never destroys or recreates; the runbook
|
|
19
|
+
* is reversible via `terraform import`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { CarveReport } from "./carve";
|
|
23
|
+
|
|
24
|
+
export interface CarvedIdentity {
|
|
25
|
+
/** The HCL identity attribute, e.g. `bucket` or `name`. */
|
|
26
|
+
attr?: string;
|
|
27
|
+
/** Its literal value, e.g. `myapp-assets-prod`. */
|
|
28
|
+
value?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DataSourceBlock {
|
|
32
|
+
/** Carved resource address the data source stands in for. */
|
|
33
|
+
address: string;
|
|
34
|
+
type: string;
|
|
35
|
+
name: string;
|
|
36
|
+
hcl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FileRewrite {
|
|
40
|
+
path: string;
|
|
41
|
+
original: string;
|
|
42
|
+
rewritten: string;
|
|
43
|
+
changed: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DeferredInput {
|
|
47
|
+
survivor: string;
|
|
48
|
+
carved: string;
|
|
49
|
+
attrs: string[];
|
|
50
|
+
note: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BridgePlan {
|
|
54
|
+
target: string;
|
|
55
|
+
dataSources: DataSourceBlock[];
|
|
56
|
+
rewrites: FileRewrite[];
|
|
57
|
+
deferredInputs: DeferredInput[];
|
|
58
|
+
runbook: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `aws_s3_bucket.assets` → a regex matching that reference head, not already `data.`-prefixed. */
|
|
62
|
+
function referenceRegex(address: string): RegExp {
|
|
63
|
+
const escaped = address.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
// (?<!data\.) — do not double-prefix an existing data source reference.
|
|
65
|
+
return new RegExp(`(?<!data\\.)\\b${escaped}\\b`, "g");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dataSourceHcl(type: string, name: string, identity: CarvedIdentity): string {
|
|
69
|
+
const body =
|
|
70
|
+
identity.attr && identity.value !== undefined
|
|
71
|
+
? ` ${identity.attr} = ${JSON.stringify(identity.value)}`
|
|
72
|
+
: ` # TODO: identify the resource (its name/id was interpolated in the source)`;
|
|
73
|
+
return `data ${JSON.stringify(type)} ${JSON.stringify(name)} {\n${body}\n}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the bridge plan for a carve.
|
|
78
|
+
*
|
|
79
|
+
* @param report the boundary report (from `boundaryReport`)
|
|
80
|
+
* @param files every `.tf` file in the estate, as { path, content }
|
|
81
|
+
* @param identities physical identity per carved address, for the data sources
|
|
82
|
+
*/
|
|
83
|
+
export function generateBridge(
|
|
84
|
+
report: CarveReport,
|
|
85
|
+
files: Array<{ path: string; content: string }>,
|
|
86
|
+
identities: Map<string, CarvedIdentity>,
|
|
87
|
+
): BridgePlan {
|
|
88
|
+
// Carved resources that something still depends on need a data source.
|
|
89
|
+
const carvedWithInbound = new Map<string, { type: string; name: string }>();
|
|
90
|
+
for (const e of report.inbound) {
|
|
91
|
+
const [type, ...rest] = e.carved.split(".");
|
|
92
|
+
carvedWithInbound.set(e.carved, { type, name: rest.join(".") });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const dataSources: DataSourceBlock[] = [...carvedWithInbound.entries()].map(([address, { type, name }]) => ({
|
|
96
|
+
address,
|
|
97
|
+
type,
|
|
98
|
+
name,
|
|
99
|
+
hcl: dataSourceHcl(type, name, identities.get(address) ?? {}),
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
// Rewrite every survivor reference to the carved resources into a data ref.
|
|
103
|
+
const rewrites: FileRewrite[] = files.map(({ path, content }) => {
|
|
104
|
+
let rewritten = content;
|
|
105
|
+
for (const address of carvedWithInbound.keys()) {
|
|
106
|
+
rewritten = rewritten.replace(referenceRegex(address), `data.${address}`);
|
|
107
|
+
}
|
|
108
|
+
return { path, original: content, rewritten, changed: rewritten !== content };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const deferredInputs: DeferredInput[] = report.outbound.map((e) => ({
|
|
112
|
+
survivor: e.survivor,
|
|
113
|
+
carved: e.carved,
|
|
114
|
+
attrs: e.attrs,
|
|
115
|
+
note: `${e.carved} reads ${e.survivor}.${e.attrs.join(", ")} — supply this as a deploy-time input to the carved component at apply.`,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
target: report.target,
|
|
120
|
+
dataSources,
|
|
121
|
+
rewrites,
|
|
122
|
+
deferredInputs,
|
|
123
|
+
runbook: buildRunbook(report, dataSources, deferredInputs),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The reversible, observe-first handoff runbook (#197). */
|
|
128
|
+
function buildRunbook(report: CarveReport, dataSources: DataSourceBlock[], deferred: DeferredInput[]): string {
|
|
129
|
+
const carvedAddrs = report.carveSet.map((m) => m.address);
|
|
130
|
+
const L: string[] = [];
|
|
131
|
+
L.push(`# Carve-out: ${report.target} → chant [observe-first, reversible]`);
|
|
132
|
+
L.push("");
|
|
133
|
+
L.push(`Peelability ${report.peelability}. Carves ${carvedAddrs.length} Terraform resource(s) into chant, leaving the live resource untouched.`);
|
|
134
|
+
L.push("");
|
|
135
|
+
L.push("## 1. Review the emitted chant source");
|
|
136
|
+
L.push(" (produced by `chant carve emit` — confirm it builds to a spec-true template)");
|
|
137
|
+
L.push("");
|
|
138
|
+
L.push("## 2. Stop Terraform managing the resource (does NOT destroy it)");
|
|
139
|
+
L.push(` terraform state rm ${carvedAddrs.join(" ")}`);
|
|
140
|
+
L.push("");
|
|
141
|
+
L.push("## 3. Confirm no destroy, then patch the survivors");
|
|
142
|
+
L.push(" terraform plan # expect 0 to destroy; refs to the carved resource now error");
|
|
143
|
+
if (dataSources.length) {
|
|
144
|
+
L.push(" # apply the generated bridge patch — it adds these data sources and rewires references:");
|
|
145
|
+
for (const ds of dataSources) L.push(` # data.${ds.type}.${ds.name} (for ${ds.address})`);
|
|
146
|
+
} else {
|
|
147
|
+
L.push(" # no inbound edges — no survivor patch needed.");
|
|
148
|
+
}
|
|
149
|
+
L.push(" terraform plan # expect: in-place updates to the survivors only");
|
|
150
|
+
L.push(" terraform apply");
|
|
151
|
+
L.push("");
|
|
152
|
+
L.push("## 4. Adopt the live resource into chant (now an orphan)");
|
|
153
|
+
L.push(" chant carve emit --from <tf-dir> --select " + report.target + " --env <env> # already done if you ran emit");
|
|
154
|
+
L.push(" # then apply the ownership marker so chant owns it (carve apply, next step)");
|
|
155
|
+
if (deferred.length) {
|
|
156
|
+
L.push("");
|
|
157
|
+
L.push("## Deferred deploy-time inputs (wire these when you turn the dial to apply)");
|
|
158
|
+
for (const d of deferred) L.push(` - ${d.note}`);
|
|
159
|
+
}
|
|
160
|
+
L.push("");
|
|
161
|
+
L.push("## Rollback (any time before apply-graduation)");
|
|
162
|
+
L.push(` terraform import ${report.target} <physical-id> # back under Terraform`);
|
|
163
|
+
return L.join("\n");
|
|
164
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { buildGraph } from "./graph";
|
|
3
|
+
import { resolveCarveSet, boundaryReport } from "./carve";
|
|
4
|
+
import type { Hcl2JsonTree } from "./types";
|
|
5
|
+
|
|
6
|
+
// #197 worked example: a bucket a Lambda reads, with a versioning sub-resource.
|
|
7
|
+
const workedExample: Hcl2JsonTree = {
|
|
8
|
+
resource: {
|
|
9
|
+
aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
|
|
10
|
+
aws_s3_bucket_versioning: {
|
|
11
|
+
assets: [{ bucket: "${aws_s3_bucket.assets.id}", versioning_configuration: { status: "Enabled" } }],
|
|
12
|
+
},
|
|
13
|
+
aws_lambda_function: {
|
|
14
|
+
api: [
|
|
15
|
+
{
|
|
16
|
+
environment: {
|
|
17
|
+
variables: {
|
|
18
|
+
ASSETS_BUCKET: "${aws_s3_bucket.assets.bucket}",
|
|
19
|
+
ASSETS_ARN: "${aws_s3_bucket.assets.arn}",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("resolveCarveSet", () => {
|
|
29
|
+
test("selected node plus its folded sub-resources", () => {
|
|
30
|
+
const set = resolveCarveSet(buildGraph(workedExample), "aws_s3_bucket.assets");
|
|
31
|
+
expect(set.map((m) => m.address).sort()).toEqual([
|
|
32
|
+
"aws_s3_bucket.assets",
|
|
33
|
+
"aws_s3_bucket_versioning.assets",
|
|
34
|
+
]);
|
|
35
|
+
const folded = set.find((m) => m.address === "aws_s3_bucket_versioning.assets");
|
|
36
|
+
expect(folded?.foldedInto).toBe("aws_s3_bucket.assets");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("empty for an unknown address", () => {
|
|
40
|
+
expect(resolveCarveSet(buildGraph(workedExample), "aws_s3_bucket.nope")).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("boundaryReport", () => {
|
|
45
|
+
test("classifies the Lambda inbound edge; folds the versioning internal edge away", () => {
|
|
46
|
+
const report = boundaryReport(buildGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
47
|
+
expect(report.target).toBe("aws_s3_bucket.assets");
|
|
48
|
+
expect(report.peelability).toBe(88);
|
|
49
|
+
expect(report.reversible).toBe(true);
|
|
50
|
+
|
|
51
|
+
// The only boundary edge is the Lambda → bucket inbound; the versioning →
|
|
52
|
+
// bucket edge is internal to the carve set and dropped.
|
|
53
|
+
expect(report.inbound).toEqual([
|
|
54
|
+
{
|
|
55
|
+
direction: "inbound",
|
|
56
|
+
survivor: "aws_lambda_function.api",
|
|
57
|
+
carved: "aws_s3_bucket.assets",
|
|
58
|
+
attrs: ["arn", "bucket"],
|
|
59
|
+
bridge: "tf-data-source",
|
|
60
|
+
required: "immediately",
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
expect(report.outbound).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("outbound edges become deferred inputs", () => {
|
|
67
|
+
const tree: Hcl2JsonTree = {
|
|
68
|
+
resource: {
|
|
69
|
+
aws_sns_topic: { alerts: [{ name: "a" }] },
|
|
70
|
+
aws_sqs_queue: { dlq: [{ name: "d", redrive: "${aws_sns_topic.alerts.arn}" }] },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
// Carve the queue: it depends on the topic (a survivor) → outbound/deferred.
|
|
74
|
+
const report = boundaryReport(buildGraph(tree), "aws_sqs_queue.dlq")!;
|
|
75
|
+
expect(report.inbound).toEqual([]);
|
|
76
|
+
expect(report.outbound).toEqual([
|
|
77
|
+
{
|
|
78
|
+
direction: "outbound",
|
|
79
|
+
survivor: "aws_sns_topic.alerts",
|
|
80
|
+
carved: "aws_sqs_queue.dlq",
|
|
81
|
+
attrs: ["arn"],
|
|
82
|
+
bridge: "deferred-input",
|
|
83
|
+
required: "at-apply",
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("unsupported type is flagged in diagnostics", () => {
|
|
89
|
+
const tree: Hcl2JsonTree = { resource: { random_pet: { n: [{ length: 2 }] } } };
|
|
90
|
+
const report = boundaryReport(buildGraph(tree), "random_pet.n")!;
|
|
91
|
+
expect(report.diagnostics.join(" ")).toMatch(/no known native mapping/i);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("null for an unknown target", () => {
|
|
95
|
+
expect(boundaryReport(buildGraph(workedExample), "aws_s3_bucket.nope")).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { buildGraph } from "./graph";
|
|
3
|
+
import { boundaryReport } from "./carve";
|
|
4
|
+
import { graduationPlan, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
|
|
5
|
+
import type { Hcl2JsonTree } from "./types";
|
|
6
|
+
|
|
7
|
+
const bucketTree: Hcl2JsonTree = {
|
|
8
|
+
resource: {
|
|
9
|
+
aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] },
|
|
10
|
+
aws_lambda_function: { api: [{ x: "${aws_s3_bucket.assets.arn}" }] },
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("graduationPlan", () => {
|
|
15
|
+
test("resolves the ownership marker + tags (chant-owned)", () => {
|
|
16
|
+
const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
|
|
17
|
+
const plan = graduationPlan(report, { stack: "assets", env: "prod" });
|
|
18
|
+
|
|
19
|
+
expect(plan.marker).toEqual({ stack: "assets", env: "prod" });
|
|
20
|
+
expect(plan.ownershipTags).toEqual({
|
|
21
|
+
[DEFAULT_TAG_OWNERSHIP_KEYS.managedBy]: "chant",
|
|
22
|
+
[DEFAULT_TAG_OWNERSHIP_KEYS.stack]: "assets",
|
|
23
|
+
[DEFAULT_TAG_OWNERSHIP_KEYS.env]: "prod",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("stack defaults to the resource's local name", () => {
|
|
28
|
+
const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
|
|
29
|
+
const plan = graduationPlan(report);
|
|
30
|
+
expect(plan.marker.stack).toBe("assets");
|
|
31
|
+
expect(plan.ownershipTags).not.toHaveProperty(DEFAULT_TAG_OWNERSHIP_KEYS.env); // no env → omitted
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("runbook is reversible and BYOL (import rollback, no chant-runs-apply)", () => {
|
|
35
|
+
const report = boundaryReport(buildGraph(bucketTree), "aws_s3_bucket.assets")!;
|
|
36
|
+
const plan = graduationPlan(report, { env: "prod" });
|
|
37
|
+
const runbook = plan.steps.join("\n");
|
|
38
|
+
expect(runbook).toMatch(/terraform import aws_s3_bucket\.assets/);
|
|
39
|
+
expect(runbook).toMatch(/cloudformation deploy|ApplyOp/);
|
|
40
|
+
expect(runbook).toMatch(/lifecycle diff --live/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("warns when outbound edges leave deferred inputs to wire", () => {
|
|
44
|
+
// Carve the Lambda: it depends on the bucket (survivor) → outbound/deferred.
|
|
45
|
+
const report = boundaryReport(buildGraph(bucketTree), "aws_lambda_function.api")!;
|
|
46
|
+
const plan = graduationPlan(report, { env: "prod" });
|
|
47
|
+
expect(plan.warnings.join(" ")).toMatch(/deferred deploy-time input/i);
|
|
48
|
+
});
|
|
49
|
+
});
|