feat(export): render managed state as OpenTofu HCL and import blocks - #173
Merged
Merged
Conversation
`ct export tf` turns ct-state.<env>.json into .tf files plus import blocks, so a migration to the OpenTofu provider needs no re-adoption: curated keys and comments carry across and ids come from state. Rendering is pure (no fs, no network), matching src/coverage/report.ts — the golden test has to run against fixtures without touching disk. Resource types are churchtools_-prefixed: Terraform resolves a resource's provider from the segment before the first underscore, so a bare `campus` type would resolve to a provider named `campus`. Ids are stringified explicitly with no truthiness path anywhere. The Mainz campus is id 0, and dropping it would silently fail to import exactly one resource.
2000game
added this pull request to stack #175
September 16, 2026 09:54
Review of #173 turned up six ways `ct export tf` produced output that looked right and planned wrong. Each is now a failing-then-passing test in tests/export/export-tf.test.ts, which drives runExportTf end to end instead of re-implementing its loop. - Six managed types (group, group-role, security-level, age-group, target-group, relationship-type) were skipped in silence. They are counted, reported as EXPORT_TYPE_UNSUPPORTED warnings and printed after the success line. Warn, not fail: the export is still useful for the five mapped types, and a hard error would make the command unusable on every real estate. - imports.tf was written in raw state order while the .tf files were sorted, so re-keying one resource reshuffled the file. Both now sort on the same HCL address, by bytes — localeCompare disagrees with byte order on `-` vs `_`. - Stale files were never pruned: `--only campus` truncated imports.tf but left group-types.tf managing resources no longer in the exported set. The export now prunes exactly the files it owns and nothing else. - An unknown --only value filtered everything out, overwrote imports.tf with an empty string and exited 0. It is now refused up front. - Two keys could collapse onto one HCL label (`a.b` and `a_b`), producing a duplicate address; assertLabelsUnique names both keys instead. - `${` and `%{` in a CT name were emitted unescaped into a quoted template. Also carries preventDestroy across as `lifecycle { prevent_destroy = true }` — destroy protection lives in state, not in managedFields, so the export dropped it and would have migrated a protected resource into an unprotected one — and emits versions.tf, without which `tofu init` resolves churchtools_* to registry.opentofu.org/hashicorp/churchtools and fails. Comments no longer name a real campus; this repo is public.
2000game
marked this pull request as ready for review
September 17, 2026 14:25
2000game
added a commit
that referenced
this pull request
Sep 17, 2026
* feat(export): render managed state as OpenTofu HCL and import blocks `ct export tf` turns ct-state.<env>.json into .tf files plus import blocks, so a migration to the OpenTofu provider needs no re-adoption: curated keys and comments carry across and ids come from state. Rendering is pure (no fs, no network), matching src/coverage/report.ts — the golden test has to run against fixtures without touching disk. Resource types are churchtools_-prefixed: Terraform resolves a resource's provider from the segment before the first underscore, so a bare `campus` type would resolve to a provider named `campus`. Ids are stringified explicitly with no truthiness path anywhere. The Mainz campus is id 0, and dropping it would silently fail to import exactly one resource. * test(export): byte-stable golden snapshots over a synthetic state The fixture is synthetic deliberately: this repo is public, so no live campus, Bereich or status name belongs in it. The proof that the export matches a real estate runs in the private config repo, where that state already lives, as part of the migration gate. What the fixture does carry is every shape the renderer can trip on — id 0, a key that is not a valid HCL identifier (!3 Aktiv -> g_3_aktiv), all five resource types, and every camelCase field that maps to a snake_case attribute. * fix(export): make the generated root module honest and stable Review of #173 turned up six ways `ct export tf` produced output that looked right and planned wrong. Each is now a failing-then-passing test in tests/export/export-tf.test.ts, which drives runExportTf end to end instead of re-implementing its loop. - Six managed types (group, group-role, security-level, age-group, target-group, relationship-type) were skipped in silence. They are counted, reported as EXPORT_TYPE_UNSUPPORTED warnings and printed after the success line. Warn, not fail: the export is still useful for the five mapped types, and a hard error would make the command unusable on every real estate. - imports.tf was written in raw state order while the .tf files were sorted, so re-keying one resource reshuffled the file. Both now sort on the same HCL address, by bytes — localeCompare disagrees with byte order on `-` vs `_`. - Stale files were never pruned: `--only campus` truncated imports.tf but left group-types.tf managing resources no longer in the exported set. The export now prunes exactly the files it owns and nothing else. - An unknown --only value filtered everything out, overwrote imports.tf with an empty string and exited 0. It is now refused up front. - Two keys could collapse onto one HCL label (`a.b` and `a_b`), producing a duplicate address; assertLabelsUnique names both keys instead. - `${` and `%{` in a CT name were emitted unescaped into a quoted template. Also carries preventDestroy across as `lifecycle { prevent_destroy = true }` — destroy protection lives in state, not in managedFields, so the export dropped it and would have migrated a protected resource into an unprotected one — and emits versions.tf, without which `tofu init` resolves churchtools_* to registry.opentofu.org/hashicorp/churchtools and fails. Comments no longer name a real campus; this repo is public. * test(export): byte-stable golden snapshots over a synthetic state The fixture is synthetic deliberately: this repo is public, so no live campus, Bereich or status name belongs in it. The proof that the export matches a real estate runs in the private config repo, where that state already lives, as part of the migration gate. What the fixture does carry is every shape the renderer can trip on — id 0, a key that is not a valid HCL identifier (!3 Aktiv -> g_3_aktiv), all five resource types, and every camelCase field that maps to a snake_case attribute. * test(export): drive the golden through runExportTf, not a copy of it The golden re-implemented the export loop and sorted its rows with localeCompare, so the snapshot pinned a string no generated file contained — and missed that imports.tf was written in raw state order. It now runs `runExportTf` into a temp dir and snapshots the files on disk, one snapshot per file, so the ordering it pins is the ordering shipped. The "routes each type to its own file" assertion compared filename SETS, which stayed green if two types swapped files; it now asserts each file's contents. The fixture gains the shapes the renderer trips on and the old one had no cover for: a name carrying an HCL interpolation marker, a destroy-protected resource, and a managed type the provider cannot represent yet (group), which exercises the skip warning.
2000game
added a commit
that referenced
this pull request
Sep 17, 2026
…er (#176) * feat(export): render managed state as OpenTofu HCL and import blocks `ct export tf` turns ct-state.<env>.json into .tf files plus import blocks, so a migration to the OpenTofu provider needs no re-adoption: curated keys and comments carry across and ids come from state. Rendering is pure (no fs, no network), matching src/coverage/report.ts — the golden test has to run against fixtures without touching disk. Resource types are churchtools_-prefixed: Terraform resolves a resource's provider from the segment before the first underscore, so a bare `campus` type would resolve to a provider named `campus`. Ids are stringified explicitly with no truthiness path anywhere. The Mainz campus is id 0, and dropping it would silently fail to import exactly one resource. * test(export): byte-stable golden snapshots over a synthetic state The fixture is synthetic deliberately: this repo is public, so no live campus, Bereich or status name belongs in it. The proof that the export matches a real estate runs in the private config repo, where that state already lives, as part of the migration gate. What the fixture does carry is every shape the renderer can trip on — id 0, a key that is not a valid HCL identifier (!3 Aktiv -> g_3_aktiv), all five resource types, and every camelCase field that maps to a snake_case attribute. * feat(config): freeze the TypeScript DSL, point at the OpenTofu provider Bugfixes only, no new features, removed in ct-cli 5.0. The successor is terraform-provider-churchtools, which replaces the state file with tfstate and the logical-key resolver with native resource references. Soft deprecation was the alternative and is the trap: it means carrying the grant engine, the key resolver and preserveUnknown in TypeScript and Go simultaneously, forever, for a format the estate no longer uses. Warned once per process, not once per resource — a per-resource warning on a 265-resource config is noise nobody reads. stderr, never stdout, because stdout carries the --json payloads CI gates parse. Suppressible with CT_NO_DEPRECATION_WARNING=1 so CI logs stay readable. * fix(export): make the generated root module honest and stable Review of #173 turned up six ways `ct export tf` produced output that looked right and planned wrong. Each is now a failing-then-passing test in tests/export/export-tf.test.ts, which drives runExportTf end to end instead of re-implementing its loop. - Six managed types (group, group-role, security-level, age-group, target-group, relationship-type) were skipped in silence. They are counted, reported as EXPORT_TYPE_UNSUPPORTED warnings and printed after the success line. Warn, not fail: the export is still useful for the five mapped types, and a hard error would make the command unusable on every real estate. - imports.tf was written in raw state order while the .tf files were sorted, so re-keying one resource reshuffled the file. Both now sort on the same HCL address, by bytes — localeCompare disagrees with byte order on `-` vs `_`. - Stale files were never pruned: `--only campus` truncated imports.tf but left group-types.tf managing resources no longer in the exported set. The export now prunes exactly the files it owns and nothing else. - An unknown --only value filtered everything out, overwrote imports.tf with an empty string and exited 0. It is now refused up front. - Two keys could collapse onto one HCL label (`a.b` and `a_b`), producing a duplicate address; assertLabelsUnique names both keys instead. - `${` and `%{` in a CT name were emitted unescaped into a quoted template. Also carries preventDestroy across as `lifecycle { prevent_destroy = true }` — destroy protection lives in state, not in managedFields, so the export dropped it and would have migrated a protected resource into an unprotected one — and emits versions.tf, without which `tofu init` resolves churchtools_* to registry.opentofu.org/hashicorp/churchtools and fails. Comments no longer name a real campus; this repo is public. * test(export): byte-stable golden snapshots over a synthetic state The fixture is synthetic deliberately: this repo is public, so no live campus, Bereich or status name belongs in it. The proof that the export matches a real estate runs in the private config repo, where that state already lives, as part of the migration gate. What the fixture does carry is every shape the renderer can trip on — id 0, a key that is not a valid HCL identifier (!3 Aktiv -> g_3_aktiv), all five resource types, and every camelCase field that maps to a snake_case attribute. * test(export): drive the golden through runExportTf, not a copy of it The golden re-implemented the export loop and sorted its rows with localeCompare, so the snapshot pinned a string no generated file contained — and missed that imports.tf was written in raw state order. It now runs `runExportTf` into a temp dir and snapshots the files on disk, one snapshot per file, so the ordering it pins is the ordering shipped. The "routes each type to its own file" assertion compared filename SETS, which stayed green if two types swapped files; it now asserts each file's contents. The fixture gains the shapes the renderer trips on and the old one had no cover for: a name carrying an HCL interpolation marker, a destroy-protected resource, and a managed type the provider cannot represent yet (group), which exercises the skip warning. * feat(config): freeze the TypeScript DSL, point at the OpenTofu provider Bugfixes only, no new features, removed in ct-cli 5.0. The successor is terraform-provider-churchtools, which replaces the state file with tfstate and the logical-key resolver with native resource references. Soft deprecation was the alternative and is the trap: it means carrying the grant engine, the key resolver and preserveUnknown in TypeScript and Go simultaneously, forever, for a format the estate no longer uses. Warned once per process, not once per resource — a per-resource warning on a 265-resource config is noise nobody reads. stderr, never stdout, because stdout carries the --json payloads CI gates parse. Suppressible with CT_NO_DEPRECATION_WARNING=1 so CI logs stay readable. * fix(config): say what the migration actually delivers The freeze banner promised "the first `tofu plan` is a no-op" and that "keys and comments carry across". Neither held: `ct export tf` covers 5 of the 11 managed types, and comments live in the TypeScript source while the export reads state. It now names the types the provider covers, says groups and the rest stay with ct, and says comments do not survive. The provider repository is private while this repo is public, so the link 404'd for every outside reader — the successor is named, and the link waits for its first release. CT_NO_DEPRECATION_WARNING now suppresses on any non-empty value (the NO_COLOR convention), not only the exact string "1". The deprecation test moves to tests/config/ next to the code it covers and gains the one assertion that was missing: deleting the call in loadConfig used to leave the whole file green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ct export tfturns ct-state..json into .tf files plus importblocks, so a migration to the OpenTofu provider needs no re-adoption:
curated keys and comments carry across and ids come from state.
Rendering is pure (no fs, no network), matching src/coverage/report.ts —
the golden test has to run against fixtures without touching disk.
Resource types are churchtools_-prefixed: Terraform resolves a
resource's provider from the segment before the first underscore, so a
bare
campustype would resolve to a provider namedcampus.Ids are stringified explicitly with no truthiness path anywhere. The
Mainz campus is id 0, and dropping it would silently fail to import
exactly one resource.
Stack created with GitHub Stacks CLI • Give Feedback 💬