Skip to content

Migrating from HCL

terradart migrate turns a Terraform source tree (*.tf and *.tf.json) into a TerraDart package: one Stack class per module directory, a tf-out/ tree that mirrors the source, and a leftover sidecar beside every synthesized main.tf.json holding, verbatim, each block the curated factories do not cover yet. Resource addresses are preserved, so terradart plan against your existing state reports No changes — you migrate one resource at a time, at your own pace, with no big-bang rewrite.

It reads files only. It never runs Terraform, never reads or writes state, never writes into the source tree, and never writes outside the output directory.

Terminal window
dart pub global activate terradart_cli

terradart lands in ~/.pub-cache/bin; add that directory to your PATH if dart pub global activate asks you to. Migration runs before a Dart project exists, so a global install is enough — there is no pubspec.yaml to find. The only prerequisite is a Dart SDK, which the migrated package needs anyway.

From a checkout: dart run bin/terradart.dart migrate --help in packages/terradart_cli.

Terminal window
terradart migrate --version

The generated package needs the Dart SDK (dart pub get, terradart plan) and, for a state Terraform wrote, Terraform on your PATH for the plan (The engine); the migrator itself needs neither. The terradart-migrate executable still runs the same flags and prints that it is deprecated.

Your existing state was written by Terraform, so the generated pubspec.yaml keeps terradart plan and apply on Terraform:

terradart:
engine: terraform

Without it, terradart would run the tofu on your PATH, or download OpenTofu — and OpenTofu rewrites the state for itself at its first apply, after which Terraform may not read it back. When the tree was run with OpenTofu (a .tofu file, or a .terraform.lock.hcl naming registry.opentofu.org), the section says engine: tofu instead.

To move the state to OpenTofu, change it to engine: tofu (or delete the section): the next apply rewrites the state for OpenTofu. Either way, terradart plan, apply and destroy look at the state before they run, and warn when the other engine wrote it. Without the section, where terradart picks the engine by itself, they also ask first on a terminal, and without one stop with the --engine flag that decides (The terradart command).

--report runs the whole migration in memory and writes nothing — no --out, no files, no terraform:

Terminal window
terradart migrate --report --dir infra

It lists every resource and data type in the tree with how many of its blocks translate, how many stay in Terraform, and the curated factory it maps to — or not in any catalog. Because it is the migration itself, not a catalog lookup, a type with a factory can still show kept blocks: a non-literal count, a sensitive literal, an argument with no Dart parameter. Every kept block is listed with its reason, and a module call whose source is outside the tree (a registry or git module) is listed as not scanned, since its resources are not counted. A literal count / for_each counts once per instance; a child module counts once however many times it is called. --dir defaults to the current directory, and --json prints the same report as JSON.

terradart migrate 0.x.y --report: infra
37 of 42 resource and data blocks translate (88%); 19 of 20 types have a curated factory. Nothing was written.
Types (20):
google_storage_bucket [resource] x6: 5 translate, 1 kept -> GoogleStorageBucket (terradart_google/storage)
acme_widget [resource] x1: not in any catalog
...
Terminal window
terradart migrate --dir infra --out infra_dart

Every directory under --dir that holds .tf / .tf.json files is a module (hidden directories such as .terraform are skipped). Given a tree like

infra/
├── envs/
│ ├── dev/ main.tf backend.tf terraform.tfvars
│ └── prod/ main.tf backend.tf terraform.tfvars
└── modules/
└── network/ main.tf variables.tf outputs.tf

infra_dart/ holds:

PathContent
pubspec.yamllockstep pins on terradart_core and the provider packages the Stacks use, and the engine terradart runs the existing state with (The engine)
bin/infra.dartsynthesizes every Stack. One Stack calls runStack; --merge-envs calls runEnvironments, so terradart plan --env <name> runs one environment
lib/dev_stack.dart, lib/prod_stack.dart, lib/network_stack.dartone Stack per module directory (dev → DevStack)
lib/network_module.dartone typed ModuleCall wrapper per local module directory a module block calls (modules/network → NetworkModule), from its variable and output blocks
tf-out/envs/dev/, tf-out/envs/prod/, tf-out/modules/network/each module’s Terraform directory, mirroring the source so source = "../../modules/network" keeps resolving: main.tf.json (written by synth) next to the sidecar files, plus terraform.tfvars, *.auto.tfvars and .terraform.lock.hcl copied from the source
MIGRATION.mdthe report: every module, every block that stays in Terraform with its reason and file, warnings, and how dev and prod differ

A single-module --dir synthesizes into tf-out/ directly. The summary on stdout says what happened per directory; --json prints the same report as JSON.

terradart migrate 0.x.y: infra → infra_dart (infra)
modules: 3 (2 roots, 1 child); migrated 41 blocks, kept 5
envs/dev: DevStack — 17 migrated, 2 kept → tf-out/envs/dev
envs/prod: ProdStack — 19 migrated, 3 kept → tf-out/envs/prod
modules/network: NetworkStack — 5 migrated, 0 kept → tf-out/modules/network
Report: infra_dart/MIGRATION.md
Next: cd infra_dart && dart pub get && terradart synth
then terradart plan --env dev
Terminal window
cd infra_dart
dart pub get
terradart plan # one Stack: synth, init, plan

A tree of several roots (the envs/dev and envs/prod layout above) synthesizes every directory from one bin/infra.dart. Name the one to plan with --env: terradart runs the entry point, then init and plan in the tf-out/ directory of that name.

Terminal window
terradart plan --env dev # No changes. Your infrastructure matches the configuration.

With --merge-envs, the generated entry point calls runEnvironments, and --env dev selects that member of the generated Env enum.

Terraform merges every file in a directory, so the synthesized main.tf.json and the sidecar files form the same module the source was. The Stack carries the backend (GcsBackend, S3Backend, LocalBackend; any other backend stays in backend.tf as written), so the init that terradart plan runs in the new directory connects to the same remote state. With a local backend, copy terraform.tfstate into the new directory first — the migrator never copies state. terraform.tfvars and *.auto.tfvars are copied; other *.tfvars files are listed in MIGRATION.md; pass one after -- (terradart plan --env dev -- -var-file=extra.tfvars).

A plan that is not empty means a block was translated differently from how it was written. Nothing has been applied: compare that resource in main.tf.json with the original and open an issue with both.

Translation is resource-atomic: a resource becomes a factory call only when every one of its arguments translates. One untranslatable argument keeps the whole block in Terraform, verbatim, preceded by the reason:

# terradart-migrate: count = var.workers is not a literal number; only a literal count is unrolled into resources with fixed addresses
resource "google_pubsub_subscription" "workers" {
count = var.workers
name = "workers-${count.index}"
topic = google_pubsub_topic.events.name
}

The sidecar is split the way Terraform code usually is:

  • terradart_leftover.tf — resources, data sources, module calls the Stack cannot express, and moved blocks whose target stays here too;
  • backend.tf — terraform { } settings the Stack does not own: a backend without a TerraDart type, cloud, and the required_providers entries of providers that have no factory;
  • variables.tf, locals.tf, outputs.tf — the variables that did not become variable<T>(...), the locals entries that stay in Terraform, and the outputs that did not become addOutput.

Nothing the Stack owns is repeated (its required_providers, its provider configurations — aliased ones included — its backend, the variables it declares), because Terraform rejects those twice. A provider "google" { alias = "eu" } block becomes a second GoogleProvider(alias: 'eu', ...) on the Stack, and a resource’s provider = google.eu becomes provider: googleEuProvider on its factory, where final googleEuProvider = addProvider(GoogleProvider(alias: 'eu', ...)) registers that configuration (provider = google-beta on a GA type is translated the same way, as googleBetaProvider). Expressions the migrator cannot type — templates, function calls, conditionals, local.x — become TfArg.expression(...), verbatim, on any argument. A timeouts { ... } block becomes const TfTimeouts(create: Duration(minutes: 30), ...) on the factory, a bare terraform.workspace becomes TfArg.workspace<T>(), and a partial backend (backend "gcs" {}, whose values come from terraform init -backend-config) becomes the typed backend with those parameters left out. A module block becomes addModule(...): a call whose source points at a directory in the scanned tree gets a typed wrapper generated from that module’s variable and output blocks (CloudRunModule('cloud_run_bff', source: '../modules/cloud_run', name: TfArg.literal('app-bff')), and bff.serviceName wherever a resource attribute goes), while registry, git and out-of-tree sources become a bare ModuleCall with source / version verbatim and an untyped inputs map — either way the address keeps its module.<name>. prefix. A literal count or for_each is unrolled into one resource per instance — google_pubsub_topic.t[0] becomes google_pubsub_topic.t_0, google_pubsub_topic.t["eu"] becomes google_pubsub_topic.t_eu — with count.index / each.key / each.value substituted, every reference in the module (indexed, splat or bare) pointed at the new addresses, and a moved entry per instance (Stack.addMoved) so the plan shows moves, never a destroy and create; the module’s own moved blocks follow their targets into the Stack. What keeps a block in Terraform today: a type outside the four curated catalogs, a count / for_each that is not a literal (its instance set cannot be known without evaluating it) or one on a module call (whose instances are addressed module.x[0]), a module call with a computed source or an input the called module does not declare, dynamic / provisioner, a timeouts key that is not a Terraform operation or whose value is not a duration string (or is finer than a microsecond), a provider = x.alias the module does not configure (a child module that does not configure it declares configuration_aliases instead; a provider block inside the child stays in Terraform, with the resource that selects it), an argument with no Dart parameter, an expression inside a typed list, a sensitive literal (never copied into Dart), and depends_on on a resource that stays in Terraform. MIGRATION.md lists every kept block with its reason and file, and every unrolled one with its new addresses.

A block’s own leading comments come across with it: the #, // or /* */ lines above a resource, data or module block are written as // lines above the add(...) it became, so the documentation lives where the code now does. A count / for_each block documents its unrolled instances once, above the first. The migrator’s own # terradart-migrate: reasons are not carried — they are annotations, not prose, and a sidecar migrated again is full of them. A merged Stack (--merge-envs) carries none: its bodies are lined up statement by statement across environments that may document the same block differently.

The migrator does the mechanical part once; what it kept is yours to port, by hand or with a coding agent (the agent skill describes the same loop):

  1. Pick a block from MIGRATION.md whose reason you can resolve — an argument the migrator had no typed slot for, a depends_on on a block you have since ported, a literal locals entry that could be a Dart final.
  2. Write it in the Stack with the same localName, so its address does not change, and delete it from the sidecar — and the addExternalBlock('<address>') line the migrator wrote for it when the Stack reads it (synth accepts a reference to a block it does not hold only once it is declared external). A local leaves locals.tf only once nothing still in the sidecar reads it.
  3. terradart synth (or dart run bin/infra.dart), then terradart plan — with --env <name> when the package was migrated with --merge-envs. No changes means the port is faithful; anything else is the diff to fix in Dart.

A later catalog release may cover a type that is not in any catalog today: terradart migrate --report over a copy of the sidecar files lists what translates now.

The migrator infers the role of each directory:

  • a directory some module block’s ./ or ../ source points at is a child, migrated in child-module mode: providers are registered without configuration (synth emits only required_providers), an alias the child selects without configuring is registered with addConfigurationAlias (synth emits configuration_aliases), variable becomes variable<T>(...), output becomes addOutput, and a provider configuration or backend found there stays in the sidecar. The caller’s module block becomes addModule(...), whose source still points at the child’s directory in the mirrored tf-out/ tree, so plan addresses keep their module.<name>. prefix;
  • every other directory is a root; roots sharing a parent directory (envs/dev, envs/prod) are environment siblings. Each keeps its own Stack, Terraform directory and backend; MIGRATION.md reports the addresses they share, the ones only some of them declare, and the arguments that differ. --merge-envs folds the group into one Stack instead (below);
  • a directory where nothing translates — no curated resource and no known provider — gets no Stack and stays Terraform; its sidecar files are its whole output.

--roots <dir> forces a directory to be a root even when a module block references it; --env-dirs <dir> names the environments explicitly. Both are relative to --dir and repeatable.

envs/dev and envs/prod are usually the same configuration with a handful of different values. Migrated one Stack each, that duplication carries straight into Dart. --merge-envs folds each group into one Stack instead:

lib/app_stack.dart
import 'package:terradart_google/provider.dart';
import 'package:terradart_google/storage.dart';
import 'env.dart';
final class AppStack extends Stack {
AppStack({required this.env})
: super(
providers: [const GoogleProvider()],
backend: GcsBackend(bucket: env.backendBucket, prefix: env.backendPrefix),
) {
add(GoogleStorageBucket(
'assets',
name: .literal(env.assetsName), // "app-dev-assets" / "app-prod-assets"
location: .literal('ASIA-NORTHEAST1'),
));
if (env.isProd) {
add(GoogleStorageBucket(
'backups',
name: .literal('app-prod-backups'),
location: .literal('ASIA-NORTHEAST1'),
));
}
}
final Env env;
}

lib/env.dart holds the generated enum — one member per root, carrying its path (tf-out/<path>), every value the roots disagree on, and a flag per group of blocks only some of them declare (shortened here):

lib/env.dart
enum Env {
dev(
path: 'dev',
assetsName: 'app-dev-assets',
backendBucket: 'app-dev-tfstate',
backendPrefix: 'infra/dev',
),
prod(
path: 'prod',
assetsName: 'app-prod-assets',
backendBucket: 'app-prod-tfstate',
backendPrefix: 'infra/prod',
isProd: true,
);
const Env({
required this.path,
required this.assetsName,
required this.backendBucket,
required this.backendPrefix,
this.isProd = false,
});
final String path;
final String assetsName;
final String backendBucket;
final String backendPrefix;
/// True in prod: blocks only that environment declares.
final bool isProd;
}

dart run bin/infra.dart and terradart synth write every environment into its own tf-out/ directory. The generated bin/infra.dart calls runEnvironments over the Env enum, with dir set to tf-out/<path>, so terradart plan --env dev plans that environment and dart run bin/infra.dart --env dev writes only it. Omitting --env writes every environment, and terradart plan then asks which one to run. A value lifts when every root writes it as a plain scalar: a resource argument, a module call input, a variable default or description, a provider argument, a backend argument. The constant is typed as the argument takes it, so an enum-valued one is a typed member (storageClass: env.assetsStorageClass, with BucketStorageClass.nearline on the enum). Anything else — a reference, a nested block, a list, an interpolated string, a sensitive variable’s default (never copied into Dart), a different provider or backend, a different block order — keeps one Stack per root, with the reason in MIGRATION.md. What merging never changes is the plan: the fixture gate proves the merged Stack synthesizes, per environment, exactly the JSON the separate Stacks did.

--lift-workspace turns terraform.workspace into a workspace parameter on the Stack: a bare reference becomes .literal(workspace), a template around it becomes Dart interpolation (.literal('orders-$workspace')), and one inside a list or map becomes the value. terradart plan --workspace prod and dart run bin/infra.dart --workspace prod then synthesize for that workspace by name (the default name is default), and terradart selects it after init. It is opt-in because it moves the decision: the JSON names a workspace instead of leaving ${terraform.workspace} for terraform workspace select, so it is faithful for the workspace it names and only that one. A template mixing the workspace with another reference stays a Terraform expression, with a warning naming it.

FlagMeaning
--reportReport what a migration would translate and keep, per type, and write nothing. Takes no --out; --dir defaults to the current directory.
--dir <tree>The Terraform source tree to migrate. Never written.
--out <package>Where the Dart package goes. Must not exist or be empty unless --force is given; nothing is written outside it.
--name <name>The package name and the root Stack class. Defaults to the base name of --dir.
--roots <dir>Treat <dir> as a root module even when a module block references it.
--env-dirs <dir>Root directories that are environments of one deployment.
--merge-envsFold each group of environment siblings into one Stack taking a generated Env enum. bin/infra.dart calls runEnvironments, so terradart plan --env <name> runs one environment.
--lift-workspaceTurn terraform.workspace into a workspace parameter on the Stack.
--jsonPrint the report as JSON instead of the summary.
--forceWrite into a non-empty --out, overwriting only the files the migrator generates.

Exit codes follow sysexits: 0 success, 64 usage, 65 unreadable input or no Terraform files, 70 internal error, 73 output directory not empty or not writable.

terradart migrate is the command. The pipeline stays a library in terradart_migrate: scanModuleTree reads the tree, migrateTree produces every file, migrateModule migrates one module. See the package README for the API and the conversion rules, and the migrator design for how the migration manifests tie the recipe to the generated factories.

The blockers above shrink as the runtime grows. Progress is tracked on the migrator epic.