Carve a resource out of Terraform
This tutorial carves a single resource out of a Terraform estate into native chant, incrementally and reversibly. You never carve the whole estate — small, cleanly-mappable pieces move; the long tail stays in Terraform.
The whole flow runs offline — no cloud account, no Terraform binary, no
state backend. It works against the bundled estate and its state file. The one
genuinely live part (terraform state rm / apply) is shown but not required
to follow along.
The runnable estate is in
examples/terraform-carve-out.
To watch the steps run end to end:
cd examples/terraform-carve-out./demo.shThe rest of this page walks the same steps individually.
Prerequisites
Section titled “Prerequisites”# the chant CLI and the AWS lexicon (the carve emit target)npm install --save-dev @intentius/chant @intentius/chant-lexicon-aws# the HCL parser the carve commands usenpm install --save-dev @cdktf/hcl2jsonRun chant via npx chant (or add node_modules/.bin to your PATH).
1. See what is cheap to carve
Section titled “1. See what is cheap to carve”cd examples/terraform-carve-outchant carve advise --from ./terraformThe estate is ranked into three bands:
CLEAN LEAF — carve now (6) 100 aws_cloudwatch_log_group.api -> AWS::Logs::LogGroup clean 1:1 native map, no boundary edges 96 aws_subnet.a -> AWS::EC2::Subnet 1 outbound (deferred input each) ... 88 aws_s3_bucket.assets -> AWS::S3::Bucket 1 inbound (data-source patch each) 81 aws_lambda_function.api -> AWS::Lambda::Function 1 outbound (deferred input each), tier 2 map
CARVABLE — has boundary work (1) 64 aws_vpc.main -> AWS::EC2::VPC 3 inbound (data-source patch each)
LEAVE IN TERRAFORM (1) 0 random_pet.suffix no known native mapping (unsupported provider/type)The bucket is a clean leaf held back one notch by the one Lambda that reads it.
The VPC is carvable, but three subnets hang off it. random_pet has no native
mapping, so it stays.
2. Adopt the bucket into chant source (offline, from state)
Section titled “2. Adopt the bucket into chant source (offline, from state)”chant carve emit --from ./terraform --select aws_s3_bucket.assets \ --state ./terraform/terraform.tfstate --output ./carveoutThis writes ./carveout/src/assets.ts and scaffolds ./carveout into a
buildable chant project (chant.config.ts, package.json, tsconfig.json):
// Adopted from Terraform state: aws_s3_bucket.assets -> AWS::S3::Bucket// Properties mapped from Terraform attributes (CloudFormation PascalCase).// Folded in aws_s3_bucket_versioning.assets -> VersioningConfigurationimport { Bucket } from "@intentius/chant-lexicon-aws";
export const assets = new Bucket({ BucketName: "myapp-assets-prod", Tags: [{"Key":"Team","Value":"web"},{"Key":"Env","Value":"prod"}], VersioningConfiguration: {"Status":"Enabled"},});
/* Unmapped Terraform attributes (reconcile to native props before building):{ "region": "us-east-1", "force_destroy": false}*/Three things happened here beyond the property mapping:
- The Terraform
aws_s3_bucket_versioningsub-resource folded into the bucket’s ownVersioningConfiguration— CloudFormation models it as one resource, so chant does too. - Attributes with no native spelling landed in the trailing comment instead of being dropped. You reconcile or delete them.
- Emit persisted a carve manifest
(
carveout/aws_s3_bucket-assets.carve.json). The later steps read the target from it, so--selectis only needed once.
A Terraform-managed resource is created through the provider API, not
CloudFormation, so it is not in any CFN stack — its resolved shape lives in the
state file. --state adopts straight from there, no cloud call. (A resource
already in a CloudFormation stack can be adopted live with --env <env>
instead.) The resource now sits at the observe position: emitted, reversible,
nothing applied.
3. Audit what you adopted
Section titled “3. Audit what you adopted”Build the scaffolded project:
chant build ./carveout/src --lexicon awsIt fails, and that is the point:
error: [assets] S3 bucket "assets" is missing PublicAccessBlockConfiguration — all public access should be blocked (aws)error: [assets] S3 bucket "assets" has no bucket policy denying non-TLS requests — add a Deny statement on aws:SecureTransport = false (aws)The emitted source is a faithful copy of what Terraform managed — including its missing security posture. chant’s post-synth audit refuses to build an S3 bucket that allows public access or plaintext transport. This audit is part of the value: chant tells you what is wrong with what you adopted, before you ever apply it.
Fix it in carveout/src/assets.ts — block public access on the bucket and add
a companion TLS-only policy:
import { Bucket, S3BucketPolicy, Ref } from "@intentius/chant-lexicon-aws";
export const assets = new Bucket({ BucketName: "myapp-assets-prod", Tags: [{"Key":"Team","Value":"web"},{"Key":"Env","Value":"prod"}], VersioningConfiguration: {"Status":"Enabled"}, PublicAccessBlockConfiguration: { BlockPublicAcls: true, BlockPublicPolicy: true, IgnorePublicAcls: true, RestrictPublicBuckets: true, },});
export const assetsTlsOnly = new S3BucketPolicy({ Bucket: Ref(assets), PolicyDocument: { Version: "2012-10-17", Statement: [ { Sid: "DenyInsecureTransport", Effect: "Deny", Principal: "*", Action: "s3:*", Resource: "*", Condition: { Bool: { "aws:SecureTransport": "false" } }, }, ], },});Now the build produces a valid CloudFormation template:
chant build ./carveout/src --lexicon aws(Delete the unmapped-attributes comment once you have reconciled it —
region is where the stack deploys; force_destroy was a Terraform-only
behavior with no CFN equivalent.)
4. Bridge the boundary
Section titled “4. Bridge the boundary”Carving the bucket cuts one edge: the Lambda reads it. Generate the edits to the surviving Terraform so its plan stays valid. The target comes from the carve manifest, so there is nothing to select:
chant carve bridge --from ./terraform --output ./carveoutThis writes to ./carveout/: a data "aws_s3_bucket" "assets" block, a
rewritten main.tf with the bucket’s own resource block (and its folded
versioning sub-resource) excised and the Lambda’s references rewired to
data.aws_s3_bucket.assets.*, a reversible runbook, and one git-applyable
patch carrying the whole edit
(git apply --directory=<terraform-dir> carveout/aws_s3_bucket-assets-bridge.patch
— the patch’s paths are relative to the Terraform dir, and --directory is
that dir relative to your repo root). Nothing in ./terraform changes.
--apply-rewrites edits the survivor .tf in place instead.
The excision matters: without it, the next terraform apply would re-create
the resource that terraform state rm released.
5. Graduate
Section titled “5. Graduate”chant carve apply --from ./terraform --output ./carveout --env prod --stack assets --write-sourcecarve apply resolves the ownership marker (chant:managed-by /
chant:stack / chant:env), prints the graduation runbook, and — with
--write-source — stamps the marker tags into the emitted source, so the
built template carries them. It makes no cloud call — the apply is whatever
lifecycle you brought.
The one genuinely live part, when you are ready to hand off for real:
terraform state rm aws_s3_bucket.assets # stop managing it; does NOT destroyterraform plan && terraform apply # apply the bridge patch to survivorsCarve a Kubernetes manifest
Section titled “Carve a Kubernetes manifest”The same five steps run against a Terraform estate that manages Kubernetes
objects. The example ships a second estate for it, in
examples/terraform-carve-out/kubernetes:
chant carve advise --from ./kubernetesCLEAN LEAF — carve now (3) 100 kubernetes_manifest.web_cert -> K8s::* clean 1:1 native map, no boundary edges 96 kubernetes_manifest.app_config -> K8s::* 1 outbound (deferred input each) 85 kubernetes_config_map.legacy -> K8s::Core::ConfigMap tier 2 map
CARVABLE — has boundary work (1) 73 kubernetes_namespace.web -> K8s::Core::Namespace 1 inbound (data-source patch each), tier 2 mapkubernetes_manifest maps to K8s::* rather than to one type because it has
no fixed kind: the body is the object, and its apiVersion/kind say what it
is. That is also why one carve rule covers every CRD — the second resource
below is a cert-manager Certificate, and nothing in the path is cert-manager
specific.
chant carve emit --from ./kubernetes --select kubernetes_manifest.web_cert \ --state ./kubernetes/terraform.tfstate --output ./carveout-k8s// Adopted from Terraform state: kubernetes_manifest.web_cert -> cert-manager.io/v1 Certificate// The Terraform type names no kind — apiVersion/kind come from the manifest// body in state, and the body is carried over verbatim.// Provider-behaviour attributes not part of the object: computed_fields.import { k8sManifest } from "@intentius/chant-lexicon-k8s";
export const web_cert = k8sManifest({ apiVersion: "cert-manager.io/v1", kind: "Certificate", metadata: { name: "web-tls", namespace: "web", }, spec: { secretName: "web-tls", dnsNames: [ "web.example.com", ], issuerRef: { name: "letsencrypt", kind: "ClusterIssuer", }, },});k8sManifest is the lexicon’s verbatim escape hatch: the props are the
manifest, so the build emits the object back unchanged and a kind the lexicon
ships no generated class for still carves. Build the scaffolded project and the
YAML is what Terraform was applying.
Four limits on this estate today:
- Emit adopts
kubernetes_manifest, not the typed provider resources.kubernetes_config_map.legacyranks tier 2 and is refused by emit on both paths: the object it applies lives in the provider’s own schema, and recovering it is a per-type reshaping. --envis refused;--stateis the path. A live import filters by native type, and there is no type to filter by until the body is read.carve bridgerefuses a manifest.kubernetes_manifestidentifies itself bymanifest.metadata.name, a path into nested blocks that a flat data-source body cannot express — and the provider has no manifest data source, only the differently-shapedkubernetes_resource(#2034). Carve a manifest nothing else references, or repoint the survivors by hand.carve apply --write-sourceis refused. The tag stamp belongs to a native constructor call.carve applywithout the flag graduates normally, in the vocabulary of the target: ownership asapp.kubernetes.io/managed-by+chant.intentius.io/{stack,env}labels, and akubectl apply -fin the runbook instead of a CloudFormation deploy.
Try it on your own estate
Section titled “Try it on your own estate”Point it at your own Terraform. Two of the five steps work on any tree today; the emit/apply half has narrower coverage, so it is worth knowing what to expect before you run them.
-
carve adviseandcarve bridge— any Terraform tree. Both are offline, read-only, and lexicon-agnostic. Run them against your real estate right now:Terminal window chant carve advise --from /path/to/your/terraformchant carve bridge --from /path/to/your/terraform --select <address> --output ./carveout -
carve emitandcarve apply— AWS. Emit produces native chant source for the AWS lexicon across ~80 common carve targets: S3, IAM (role / policy / instance profile / user / group), DynamoDB, SNS, SQS, Lambda (functions, permissions, layers, aliases, event source mappings), KMS, Secrets Manager, SSM, ECR, CloudWatch (log groups / alarms / event rules / dashboards), CloudTrail, Kinesis (streams / Firehose), Route 53 (zones / records), EFS (file systems / mount targets / access points), EC2 (instances, VPC, subnet, security group, route table, routes, internet/NAT/egress-only gateways, EIP, EBS volumes, launch templates, VPC endpoints, peering, flow logs, key pairs), autoscaling groups, ELBv2 (load balancers / target groups / listeners / rules), RDS (instances / clusters / subnet + parameter groups), ElastiCache, API Gateway (REST / HTTP, stages, deployments), Step Functions, ACM certificates, Cognito user pools, EKS (clusters / node groups), and ECS (clusters / services / task definitions). Advise and emit cover the same AWS types, so anything advise ranks, emit can produce. A type outside that set reports the supported list. -
carve emit— Kubernetes.kubernetes_manifestemits from state, any kind and any CRD, as shown above. The typedkubernetes_*resources are ranked but not emitted,--envis refused, and bridge refuses the manifest types (#2034). GCP and Azure are not carved yet.
See the carve CLI reference for every flag.