Skip to content

feat(export): render managed state as OpenTofu HCL and import blocks - #173

Merged
2000game merged 2 commits into
mainfrom
export-tf-renderer
Sep 17, 2026
Merged

2000game merged 2 commits into
mainfrom
export-tf-renderer

Conversation

@2000game

Copy link
Copy Markdown
Member

ct export tf turns ct-state..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.


Stack created with GitHub Stacks CLI • Give Feedback 💬

`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
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
2000game marked this pull request as ready for review September 17, 2026 14:25
@2000game
2000game merged commit 6697150 into main Sep 17, 2026
3 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant