Skip to content

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:

Terminal window
cd examples/terraform-carve-out
./demo.sh

The rest of this page walks the same steps individually.

Terminal window
# 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 use
npm install --save-dev @cdktf/hcl2json

Run chant via npx chant (or add node_modules/.bin to your PATH).

Terminal window
cd examples/terraform-carve-out
chant carve advise --from ./terraform

The 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)”
Terminal window
chant carve emit --from ./terraform --select aws_s3_bucket.assets \
--state ./terraform/terraform.tfstate --output ./carveout

This 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 -> VersioningConfiguration
import { 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_versioning sub-resource folded into the bucket’s own VersioningConfiguration — 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 --select is 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.

Build the scaffolded project:

Terminal window
chant build ./carveout/src --lexicon aws

It 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:

Terminal window
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.)

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:

Terminal window
chant carve bridge --from ./terraform --output ./carveout

This 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.

Terminal window
chant carve apply --from ./terraform --output ./carveout --env prod --stack assets --write-source

carve 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:

Terminal window
terraform state rm aws_s3_bucket.assets # stop managing it; does NOT destroy
terraform plan && terraform apply # apply the bridge patch to survivors

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:

Terminal window
chant carve advise --from ./kubernetes
CLEAN 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 map

kubernetes_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.

Terminal window
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.legacy ranks 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.
  • --env is refused; --state is the path. A live import filters by native type, and there is no type to filter by until the body is read.
  • carve bridge refuses a manifest. kubernetes_manifest identifies itself by manifest.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-shaped kubernetes_resource (#2034). Carve a manifest nothing else references, or repoint the survivors by hand.
  • carve apply --write-source is refused. The tag stamp belongs to a native constructor call. carve apply without the flag graduates normally, in the vocabulary of the target: ownership as app.kubernetes.io/managed-by + chant.intentius.io/{stack,env} labels, and a kubectl apply -f in the runbook instead of a CloudFormation deploy.

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 advise and carve 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/terraform
    chant carve bridge --from /path/to/your/terraform --select <address> --output ./carveout
  • carve emit and carve 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_manifest emits from state, any kind and any CRD, as shown above. The typed kubernetes_* resources are ranked but not emitted, --env is refused, and bridge refuses the manifest types (#2034). GCP and Azure are not carved yet.

See the carve CLI reference for every flag.