terradart_migrate 0.35.0
terradart_migrate: ^0.35.0 copied to clipboard
HCL → Dart migrator library behind terradart migrate. Turns Terraform modules into TerraDart Stacks via the curated catalogs.
Changelog #
0.35.0 - 2026-10-03 #
- No API changes. Lockstep release with the
terradartcommand's--jsonoutput, fixed exit codes,--no-input,--dry-run,terradart help <topic>and bundled agent skill.
0.34.0 - 2026-10-03 #
migrateTreewrites aterradart:section withengine: terraforminto the generatedpubspec.yaml—engine: tofuwhen a module directory holds a.tofufile or a.terraform.lock.hclnamingregistry.opentofu.org— so theterradartcommand keeps the existing state on the engine that wrote it (sourceEngine,renderPubspec(engine:)).
0.33.0 - 2026-10-02 #
bin/infra.dartfor one Stack callsrunStack.--merge-envscallsrunEnvironmentsover the generatedEnvenum (dir: (env) => 'tf-out/${env.path}'), soterradart plan --env <name>runs that environment. Several merged groups write every environment when--envis omitted, and callrunEnvironmentsfor the group a name selects.- The user command is
terradart migrate(terradart_cli). Theterradart-migrateexecutable still runs the same flags and prints that it is deprecated.
0.32.1 - 2026-10-02 #
- No API changes. Republishes the 0.32.0 workspace so
terradart_appwrite,terradart_cloudflare,terradart_awsandterradart_migratereach pub.dev; the 0.32.0 publish workflow stopped them at a wrapper-count check that also counted hand-written files, and now counts only generated wrappers (#877).
0.32.0 - 2026-10-02 #
- A migrated attribute reference is the plain getter (
labels: other.labels,addOutput('id', topic.id)), and an argument the manifest typesRefTo<R>— Magic ModulesResourceRefinputs, AWS IAM policy ARNs, Cloudflare user group members — is writtenx.ref. - A migrated IAM adjunct passes its parent as one reference (
service: api.ref) and leaves out thelocation/project/region/zonethe HCL reads off that same parent. An IAM grant ismember: sa.principalor a constructor of its kind (.user('a@example.com')); the manifest records these slots asprincipal. - Appwrite
permissionsare written asAppwritePermissions (.read(.any)), with.literal('...')for a string the roles do not spell. - An
outputwhose value is an object mapping each output's environment variable to that output ({ API_URL = google_cloud_run_v2_service.api.uri, ... }, a non-Stringone underjsonencode) becomesaddDartDefineOutput(name: ..., only: [...]), so a Stack that declares a define file round-trips. Any other object output staysaddOutput. - A child module that selects a provider alias (
provider = google.eu) with no provider block of its own migrates the resource and registers the alias withaddConfigurationAlias, so synth emitsconfiguration_aliases. A provider block inside the child still stays in Terraform, with any resource that selects it. A child that passes an alias it does not configure down to a nested module (providers = { google = google.eu }) declares that alias the same way. user_project_overridemigrates asuserProjectOverrideonGoogleProviderandGoogleBetaProvider. Any other non-credential provider argument the class does not model keeps that whole configuration in the sidecar; the Stack registers it withaddExternalProviderand emits no second provider block. Credentials stay dropped, including a nested block of that name (assume_role_with_web_identity, Googleexternal_credentials) and Appwriteapi_key/organization_api_key.- A migrated backend and
required_versionare Stack constructor arguments (super(requiredVersion: ...)), atimeoutsblock isconst TfTimeouts(create: Duration(minutes: 30))(a duration finer than a microsecond keeps the block in Terraform), and a variant of an empty block is written without an argument (.avroFormat()). - A migrated Stack imports only its provider barrels, which re-export
terradart_core, andGoogleProjectdata sources becomeDataGoogleProject. ReadshasTemplateSequence/templateVariableNamesfrompackage:terradart_core/internal.dart. See MIGRATING.md. - A provider configuration a block selects is registered with
addProvider(final googleEuProvider = addProvider(const GoogleProvider(alias: 'eu', ...))) and passed asprovider: googleEuProvider, in a module call'sprovidersmap too; unselected configurations stay insuper(providers: [...]). - A migrated
lifecycleis.new(...):ignore_changes = allisignoreChanges: .all, a list is.of([...]), a whole-resourcereplace_triggered_byentry is the Dart variable, andprecondition/postconditionblocks areconditions: [.pre(...), .post(...)]. - A migrated
variableblock isfinal x = variable<T>('x', ...):Tfollows itstype(string→String,number→num,list(string)→List<String>,map(...)→Map<String, ...>), and what Dart cannot say is spelled out (type: .set(.string),.object({...}),.any). An argument that takes aTfArg<T>reads the handle (location: region); an enum orRefToslot takes.arg(region). An undeclared reference isexternalVariable('x'), and atypethe migrator cannot read keeps the variable in the sidecar. - A migrated enum value is the member itself (
routingMode: .regional,[.http, .https]); a reference or an expression in an enum slot is.arg(...)/.expression(...). - Migrated factories and module calls pass the local name positionally (
GooglePubsubTopic('orders', ...)), and a generated module wrapper takessuper.localNamefirst. - A migrated data source is registered with
add(...). - A migrated
depends_onlists the Dart objects (dependsOn: [schema, api]). - A kept block the migrated Stack still reads is declared with
addExternalBlock('<address>'), which synth now requires for a reference to a block the Stack does not hold. - Migrated strings are plain
'...'; only a string holding$or\stays raw (r'${google_x.y.id}'). - A generated module wrapper forwards
ModuleCall's parameters as super parameters (required super.localName, required super.source, super.version, ...). - A merged Stack declares a block only some environments have as a nullable local (
final backups = env.isProd ? add(...) : null;), and a block that reads it tests it in its guard (if (env.isProd && backups != null)), instead oflate finalplus an assignment.Env.byNameisvalues.asNameMap()[name]. - A migrated Stack builds a helper nested inside another helper or inside a sealed variant with
.new(...)(.pushConfig(.new(pushEndpoint: ...))); a resource's own block arguments keep the class name.
0.31.0 - 2026-10-01 #
- A reference to an input attribute (
${google_pubsub_topic.x.labels}) becomes its<name>Refgetter (.ref(x.labelsRef)) instead ofTfRef.attribute<Object?>(x, r'labels'); the fallback remains for an attribute the wrapper has no getter for. The five migration manifests list the new getters. - A translated
outputblock becomesaddOutput(name, .ref(x.getter), description: ..., sensitive: ...)under its own name — any getter type, withTfRef.attribute<Object?>when there is none — instead ofaddExport+ResourceIdExport(whose Dart-identifier key neededterraformOutputName), and the Stack no longer callssetAppExportsOutputPath. - A migrated Stack uses Dart 3.10 dot shorthands wherever the argument has a static type:
.literal(...),.variable(...),.expression(...),.ref(...),.workspace(),RefTo's.literal(...)/.variable(...)/.arg(...), and an enum's.member(inside.literal(...), in a bareList<E>, and on anEnvfield). A bareModuleCall'sObject?-valuedinputsmap keeps the long form (TfArg.literal(...)). - A
referenceslot migrates a reference to a migrated block of the slot's type (or a data source reading that type) tox.ref, pinned (x.ref.pinned('id')) when it reads another attribute than the slot emits; a block of another type to an uncheckedRefTo.arg(...)with a warning; a string toRefTo.literal, a variable toRefTo.variable, other expressions toRefTo.expression. A list slot takes a literal list element by element and a whole-list value asTfArg.variable/TfArg.expression.MigrateSlotKind.referenceandMigrateSlot.attributeare new. MigrateHelper.shorthand: a sealed variant whose sealed type declares a factory constructor for it migrates to the dot shorthand.member(value)(code: .filename(.literal(r'f.zip')),replication: .auto()) instead of naming the variant class.- A migrated package's generated
pubspec.yamldeclaressdk: ^3.10.0(was^3.6.0), the minimum of theterradart_*packages it depends on. - An optional merged
sealedslot (the nullable at-most-one sealed argumentsterradart wrapderives) migrates to nothing when none of its keys is set, to the variant of the one that is set, and keeps a block that sets more than one in Terraform. - pub.dev: add
example/main.dart(migrateModuleon an inline module), dartdoc on every public member, and apubspec.yamldescription short enough for the pub.dev score (180 characters at most). No API changes.
0.30.0 - 2026-09-28 #
MigrateSlot.keyed: a helper slot typedMap<String, Helper>(anesting_mode: "map"block) migrates from an object of blocks, one helper per key. A*segment in a sensitive path matches every key.- Fix: a list-of-enum argument set to a reference to a whole list (
selected_regions = var.regions) migrated to aTfArg.variable/TfArg.refthe constructor'sList<...>parameter does not accept, so the Stack did not compile. It now stays in Terraform with the reason; a list literal still migrates one member per element. MigrateManifest.caseInsensitiveEnums: for a provider whose validators accept enum values in any case, a raw value that differs from exactly one member only in case ("AVRO"written"avro") migrates to that member instead of staying in Terraform, and the report warns that it synthesizes in the member's canonical case. The cloudflare manifest sets it, next to the lane's 579 new enums.--reportruns the migration in memory and prints, perresource/datatype, how many blocks translate and how many stay in Terraform (each with its reason), the factory each type maps to and themodulecalls whose source is outside the tree;--jsonprints it as JSON. It writes nothing.MigrationCoverage.of(project)is the library side. It replacesterradart-coverage.- Removed
--update,--in-place,--allow-todoand--inline-locals(each now exits 64), withrerunProject,planInPlace/writeInPlaceand theallowTodo/inlineLocalsparameters ofmigrateModule/migrateTree. Everything that stays in Terraform lands in the sidecar, soMigrationResult.sidecarandMigratedModule.sidecarare never null; the report JSON dropsallowTodo,planDiffersandtodos. - Published on pub.dev:
dart pub global activate terradart_migrateinstallsterradart-migrate. The release binaries and the Homebrew formula are gone, and so arerelease-binary.yml,tool/render_formula.dartandtool/render_to_file.dart.
0.29.0 - 2026-09-27 #
aws_*resources and data sources migrate toterradart_awsthrough its generated manifest (lib/src/manifest/aws.g.dart). Theprovider "aws"block translates toAwsProvider, nested settings included (default_tags,assume_role,ignore_tags,endpoints); credential arguments (access_key,secret_key,token,assume_role_with_web_identity) are dropped with a warning, never written into Dart.time_sleepmigrates toterradart_time(package:terradart_time/terradart_time.dart, aterradart_timedependency in the migrated pubspec) instead ofterradart_google, so a migrated AWS or Cloudflare module no longer depends on the Google package.googleExtrasMigrateManifestis nowtimeMigrateManifest(packageterradart_time), and thetimeprovider recipe reads the pin fromkTimeProviderVersionConstraintinstead of repeating it.
0.28.1 - 2026-09-13 #
- Fix: a passthrough slot whose parameter is a bare
Map/Listrather than aTfArg<Map<...>>—advancedExtraonSqlDatabaseInstanceSettings, the raw-map escape hatch a hand-written helper spreads into its block — was emitted asTfArg.literal({...}), so a migrated Stack that carried aninsights_configdid not compile while the report counted the resource as migrated. The emitter now honours the manifest'swrappedflag (the manifests already recorded it), shapes the payload to the parameter (a block written once reads as one object: aList<...>parameter gets a one-element list, aMap<...>parameter takes the single element of a one-object list) and types empty payloads from the manifest. The round-trip gate had never exercised a passthrough slot because no quickstart used one;cloud_sql_quickstartnow setsinsights_configthroughadvancedExtra, so it does.
0.28.0 - 2026-09-13 #
--inline-localsdeclares thelocalsentries whose value is a scalar literal — or a template made only of literal text and locals that are themselves inlined, resolved as a fixpoint — as Dartfinals in the Stack (final prefix = r'acme'; final bucketName = '$prefix-assets';), and rebuildslocals.tfso it keeps only the entries something that stays in Terraform still reads (a kept block, aterraformsetting, another local, or a${local.x}the Stack still emits as an expression); a local nothing in the Stack reads is never declared. A list, an object, a reference, a%{ ... }directive, or a name twolocalsblocks both declare stays, with its reason. Names come from the Stack's allocator after the blocks, types are carried (a bare${local.port}only fills a slot that takes a number), and--lift-workspacenow shares the samedartTemplaterewrite. Refused together with--merge-envs(#672).- Comment carry-over: the
#,//and/* */comments above aresource,dataormoduleblock are written as//lines above theadd(...)it became; acount/for_eachblock documents its unrolled instances once, above the first. Not carried: the migrator's own# terradart-migrate:annotations, and the bodies of a merged Stack (--merge-envscompares code, not prose) — which is why the comments are a field ofStackStatementrather than part of its text (#672). --in-placerewrites the tree under--dir, after the package is written, so its.tffiles keep only the blocks that stayed in Terraform: source ranges are cut rather than re-rendered, so every surviving line keeps its own bytes andgit diffis the review of the migration; a file whose every block became Dart is deleted, andterraform { }/localsblocks are split entry by entry the way the sidecar splits them. It refuses unless--diris inside a git working tree with nothing uncommitted (untracked files included), checked before anything —--outincluded — is written. Never touched, and listed in the report:*.tf.jsonfiles,movedblocks, and anything reached through a symbolic link.writeInPlaceenforces the same path allowlist aswriteRerun(#672).tool/migrate_fixture_gates.dartgains the inlined-locals gate (#672): theinline_localsfixture is migrated with and without the flag, both packages are planned, and the plans must match resource for resource.--update <package dir>re-runs the migrator over a package it already generated, for the day a catalog wave covers something that stayed in Terraform. It reads each Terraform directory's sidecar — every.tf/.tf.jsonthere except themain.tf.jsona Stack writes — migrates it against today's catalog, and writes what translates now aslib/<stack>.snippets.dart: an extension onStackwhose method body is exactly the statements to paste into the constructor, so the file compiles where it sits and the paste is mechanical. Beside it goesterradart_leftover.next.tf, the sidecar as it looks once they are pasted, andRERUN.mdwith the swap steps per directory. Resources, data sources, module calls and theirmovedentries are pasted; a provider configuration, a variable, an output and theterraformsettings are the Stack's own structure, so they stay in the sidecar instead of vanishing from it, and a reason comment is not stacked again on every re-run. Hidden directories are never entered, so the modulesterraform initleaves under.terraform/are neither read nor written into. Nothing else is written: the writer refuses any path that is not one of those three, so no Dart of yours is ever overwritten. A local the Stack already declares is reserved, so a pasted one never shadows it; a reference to a block the Stack owns stays a Terraform expression, since the re-run reads the sidecar, not your Dart (#669).tool/migrate_fixture_gates.dartgains the re-run gate (#669):config_tree/is migrated against a catalog withgoogle_storage_bucketremoved — the world before the wave that added it — then re-run against the full catalog. The five sidecar blocks must come back as snippets, the sidecar must shrink, every file the first run wrote must be byte-identical afterwards, and the Stack with the snippet pasted (the extension called on it) must synthesize exactly what a one-shot migration of the whole tree does. Re-running a fully migrated package must find nothing, which is also what proves the re-run never reads themain.tf.jsona Stack writes.--merge-envsfolds each group of sibling environment roots into one Stack taking a generatedEnvenum:AppStack({required Env env})inlib/<group>_stack.dartnext tolib/env.dart, one member per root (Env.dev,Env.prod) carrying itspath(tf-out/<path>) and every value the roots disagree on. A top-level argument two roots write differently — a resource argument, amodulecall input, avariabledefault or description, a provider argument, a backend argument — becomes a constant on the enum (name: TfArg.literal(env.assetsName),GcsBackend(bucket: env.backendBucket)), typed as the argument takes it: an enum-valued argument becomes a typed member (storageClass: TfArg.literal(env.assetsStorageClass),BucketStorageClass.nearline), andlib/env.dartimports the barrel that exports it. A block only some roots declare sits behind a flag (if (env.isProd) { ... }), and a guarded local another guarded block reads is declared ahead of itsif(late final GoogleStorageBucket backups;).bin/infra.dartwrites every environment, or the ones--env <name>picks — a name no group declares is a usage error, one only some groups declare selects those. A group that cannot be merged keeps one Stack per root and says why inMIGRATION.md: a value that differs but is not a plain scalar (a nested block, a list, a reference, an interpolated string), asensitivevariable whose default differs (never copied into Dart), different providers or backends, blocks declared in a different order, a root where nothing translates. The merged-environment gate intool/migrate_fixture_gates.dartproves the merged Stack synthesizes, per environment, exactly the JSON one Stack each did (#668).--lift-workspaceturnsterraform.workspaceinto aworkspaceparameter on the Stack, sodart run bin/infra.dart --workspace <name>synthesizes for one workspace by name instead of leaving${terraform.workspace}forterraform workspace select: a bare reference becomesTfArg.literal(workspace), a template around it becomes Dart interpolation (TfArg.literal('orders-$workspace')), and one inside a list or map becomes the value. A template mixing it with another reference is left as a Terraform expression, with a warning naming it. Off by default — the synthesized JSON then names a workspace rather than deferring to Terraform (#668).MigratedProject.merged(mergedin--json, an Environments section inMIGRATION.md) lists every environment group with its constants, flags and per-environment values — or the reason its roots stayed one Stack each.timeouts { ... }becomestimeouts: const TfTimeouts(create: '30m', ...)on the factory instead of keeping the block in Terraform; a bareterraform.workspacebecomesTfArg.workspace<T>()(inside a larger template it stays a verbatim expression); and a partial backend —backend "gcs" {}, or ans3block withoutbucket/key— becomes the typed backend with those parameters left out, forterraform init -backend-config. Still blockers, with a reason: atimeoutskey that is not a Terraform operation, a value that is not a duration string (a reference included — Terraform forbids one there), and atimeoutsblock that sets nothing (#671).moduleblocks becomeaddModule(...)instead of staying in the sidecar. A call whosesourcepoints at a directory in the scanned tree uses a generated typed wrapper — onelib/<module>_module.dartper local module directory, aModuleCallsubclass with a namedTfArgparameter pervariableblock (string/number/booltyped, everything elseObject?; required when the variable has nodefault) and aTfRef<String>getter peroutput(CloudRunModule(localName: 'cloud_run_bff', source: '../modules/cloud_run', name: TfArg.literal('app-bff')),bff.serviceName). A module that declares neither a variable nor an output has nothing to type, so its calls keep the bareModuleCall, as do registry, git and out-of-tree sources —sourceandversiontravel verbatim and the inputs as an untypedinputsmap.module.x.outreferences resolve to the call's Dart local wherever a resource attribute would (typed arguments, collections,depends_on,lifecycle, and single-attributeoutputs, which now become exports), and the call is ordered with the resources it passes values to and reads them from. Blockers, with a reason: acount/for_eachon the call (its instances are addressedmodule.x[0]), a non-literalsource, nosource, an input the module does not declare, aproviders = { ... }value naming an alias the module does not configure (or any alias inside a child module), and a directory that translates nothing else and registers no provider — a root that only calls modules is the exception, and gets a Stack withproviders: [](#665).- A literal
count/for_eachis unrolled into one resource (or data source) per instance instead of keeping the block in Terraform:google_pubsub_topic.t[0]becomesgoogle_pubsub_topic.t_0,google_pubsub_topic.t["eu"]becomesgoogle_pubsub_topic.t_eu(keys sanitized to Terraform names);count.index,each.keyandeach.valueare substituted in the instance bodies — in parsed expressions and in the text of raw ones — and every reference in the module is pointed at the new addresses:t[1].name→t_1.name,t[*].id→[t_0, t_1][*].id, a baret→ the tuple (count) or object (for_each) of instances,depends_on/replace_triggered_byentries spread per instance; blocks that stay in Terraform get the same rewrite in the sidecar. Amovedentry per resource instance (addMoved) carries the state, soterraform planshows moves only. Translation stays resource-atomic: one instance that cannot become Dart keeps the whole block as written, with the instance's reason. The module's ownmovedblocks becomeaddMovedwhen their target is migrated (a target that was unrolled yields one move per instance); those whose target stays in Terraform stay with it. Still blockers: acount/for_eachthat is not a literal (a variable, a conditional, a tuple), one declaring no instance, an instance name colliding with another block, a reference to an instance the block does not declare (#663). MigrationReport.expanded(ExpandedItem/ExpandedInstanceItem,expandedin--json) lists every unrolled block with its instances;renderTextandMIGRATION.mdshow them under "Unrolled".tool/migrate_moved_gates.dart(agent_verify.shfull mode, CImigrate moved gate): migratestest/fixtures/moved_state/, synthesizes it, and plans against the fixture's state of indexed instances (state.json, copied in asterraform.tfstate) — the plan must be moves only (#663).- Provider aliases are translated instead of keeping a resource in Terraform: every
provider "<name>" { alias = "x" ... }block becomes a second<Name>Provider(alias: 'x', ...)registration on the Stack,provider = <name>.xon a resource or data source becomesprovider: '<name>.x'on its factory, andprovider = google-betaon a GA type registersGoogleBetaProviderand selects it. Still blockers, with a reason:provider = <name>.xwhen the module has no suchaliasblock, and an alias inside a child module (which needsconfiguration_aliases) (#666). - Expressions the emitter cannot type — templates, function calls, conditionals,
local.x,module.x.y, references to kept blocks — becomeTfArg.expressionon everyTfArg-typed argument (number, bool, enum, list and sensitive arguments included) instead ofTfArg.literal(r'${...}')on string arguments only; thevar.<name>references inside them are declared like plain references. What still keeps a block in Terraform: an expression inside a typed Dart collection and on a bare (non-TfArg) parameter (#662). terradart-migrateCLI (bin/terradart_migrate.dart:--dir <tree> --out <package>): scans a Terraform source tree, infers roots, children and environment siblings (--roots,--env-dirsoverride), and writes one Dart package — a Stack per module directory, atf-out/tree mirroring the source somodulesources keep resolving, the leftover sidecar beside eachmain.tf.json(terradart_leftover.tfplusbackend.tf/variables.tf/locals.tf/outputs.tf, verbatim with a reason each), copies ofterraform.tfvars,*.auto.tfvarsand.terraform.lock.hcl, andMIGRATION.md. Child-module mode for referenced directories (bare providers,addVariable/ exports, provider configurations and backends kept); a directory where nothing translates gets no Stack and stays Terraform.--json,--allow-todo,--force; never writes into--dir; sysexits exit codes (#661).- Library:
scanModuleTree,migrateTree,migrateStack,buildSidecar,MigrationReport.providers;migrateModulegainschildModule/allowTodoand returns the sidecar files undertf-out/(#661). tool/migrate_fixture_gates.dart(agent_verify.shfull mode, CImigrate fixture gate): migrates the coverage fixtures, synthesizes the package and terraform-validates every directory;test/golden/pins their output (#661).- Distribution:
release-binary.ymlbuildsterradart-migrate(macOS arm64 / amd64, Linux amd64, Windows amd64) on everyv*tag andtool/render_to_file.dartrenders its Homebrew formula intonozomi-koborinai/homebrew-tap—brew install nozomi-koborinai/tap/terradart-migrate; website guide Migrating from HCL, README install sections (#664). migrateModule: aTfModule(tf.json or HCL viaterradart_hcl) in, a Dart package out —lib/<name>_stack.dart,bin/infra.dart,pubspec.yaml— plus aMigrationReportof what became Dart and what stays in Terraform with a reason. Resource-atomic translation following the migration manifests: literals, enums, nested helpers (single / repeated / exactly-one-of), passthrough maps, typed references and variables, verbatim expressions on string arguments,depends_on,lifecycle, backends, providers (the four packages plustime), variables and single-attribute outputs (#660).tool/migrate_roundtrip_gates.dartandtool/migrate_roundtrip_debt.yaml: the round-trip gate (synth(migrate(synth(S))) == synth(S)over every quickstart), wired intoagent_verify.shfull mode and CI (#660).- A hand-written
googleExtrasMigrateManifest(time_sleep→TimeSleep) rides along with the generated manifests inallMigrateManifests. - New package (
publish_to: none): the migration manifests of the four curated catalogs (googleMigrateManifest,googleBetaMigrateManifest,appwriteMigrateManifest,cloudflareMigrateManifest), generated byterradart wrap --migrate-manifestintolib/src/manifest/, with the hand-written runtime types (MigrateManifest,MigrateEntry,MigrateSlot,MigrateHelper,MigrateEnum,MigrateGetter) moved here from the provider packages, plusallMigrateManifests,manifestForPackageandfindMigrateEntry(#658).