Skip to content

Carve a resource out of Terraform

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/tutorials/terraform-carve-out/.
In `examples/terraform-carve-out`, run `npx chant carve advise --from ./terraform`, then carve `aws_s3_bucket.assets` with `chant carve emit` from the state file as the page shows.
Fix the findings `npx chant build ./carveout/src --lexicon aws` reports, run `npx chant carve bridge --from ./terraform --output ./carveout`, and show me the build output and the patch it wrote.
Do not pass `--apply-rewrites`, and do not run `terraform state rm` or `terraform apply`.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

This tutorial carves a single resource out of a Terraform estate into native chant, incrementally and reversibly. Only small, cleanly mappable pieces move; the long tail stays in Terraform.

The whole flow runs offline against the bundled estate and its state file, with no cloud account, Terraform binary or state backend. 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 run all the steps at once:

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 @cdktn/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 went into 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 in no CFN stack; its resolved shape lives in the state file. --state adopts straight from there, with 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. chant tells you what is wrong with what you adopted before you apply 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; you apply the template through your own deploy process.

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.

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

A carved kubernetes_manifest reads back as a different type. The kubernetes provider ships no kubernetes_manifest data source, so carve bridge renders the provider’s generic read, data "kubernetes_resource", from the manifest’s own apiVersion, kind and metadata:

data "kubernetes_resource" "app_config" {
api_version = "v1"
kind = "ConfigMap"
metadata {
name = "app-config"
namespace = "apps"
}
}

Survivor references follow that data source’s attribute path. kubernetes_manifest.app_config.object.metadata.name becomes data.kubernetes_resource.app_config.object.metadata.name. The resource also exposes manifest, the body as declared, which the data source does not have, so a reference through manifest maps to object too: kubernetes_manifest.app_config.manifest.data.LOG_LEVEL becomes data.kubernetes_resource.app_config.object.data.LOG_LEVEL.

A manifest whose name is interpolated resolves no literal, so the data source comes out with a TODO for its body rather than a guessed name. The references are repointed either way, leaving one edit between you and a valid plan.

Point it at your own Terraform. Two of the five steps work on any tree today; the emit/apply half covers less, as the list below shows.

  • 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. It bridges too, through data "kubernetes_resource". The typed kubernetes_* resources are ranked but not emitted, and --env is refused. GCP and Azure are not carved yet.

See the carve CLI reference for every flag.

chant