From 8eef862a80eef6f8b7aefb6038ec04b60ac17b55 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 16 Sep 2026 11:53:59 +0200 Subject: [PATCH 1/2] feat(export): render managed state as OpenTofu HCL and import blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/application/contracts.ts | 2 +- src/application/operations/export-tf.ts | 67 ++++++++++++++++++++++ src/commands/export-tf.ts | 34 +++++++++++ src/export/hcl.ts | 75 +++++++++++++++++++++++++ src/export/imports.ts | 23 ++++++++ src/export/layout.ts | 16 ++++++ src/index.ts | 2 + tests/export/hcl.test.ts | 70 +++++++++++++++++++++++ 8 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/application/operations/export-tf.ts create mode 100644 src/commands/export-tf.ts create mode 100644 src/export/hcl.ts create mode 100644 src/export/imports.ts create mode 100644 src/export/layout.ts create mode 100644 tests/export/hcl.test.ts diff --git a/src/application/contracts.ts b/src/application/contracts.ts index 38904ca..508c5a0 100644 --- a/src/application/contracts.ts +++ b/src/application/contracts.ts @@ -3,7 +3,7 @@ export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; export type OperationName = - "plan" | "apply" | "coverage" | "adopt" | "state" | "refresh" | "destroy" | "auth"; + "plan" | "apply" | "coverage" | "adopt" | "state" | "refresh" | "destroy" | "auth" | "export-tf"; /** Common project selection accepted by CLI and, later, HTTP adapters. */ export interface ProjectRequest { diff --git a/src/application/operations/export-tf.ts b/src/application/operations/export-tf.ts new file mode 100644 index 0000000..47d0792 --- /dev/null +++ b/src/application/operations/export-tf.ts @@ -0,0 +1,67 @@ +/** Orchestration for `ct export tf`: state in, .tf files out. */ +import { mkdir, writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { renderResource } from "../../export/hcl.js"; +import { renderImports, type ImportTarget } from "../../export/imports.js"; +import { EXPORTABLE_TYPES, fileForType } from "../../export/layout.js"; +import { loadState } from "../../state/state.js"; +import type { OperationResult, ProjectRequest } from "../contracts.js"; +import { resolveProject } from "../project.js"; + +export interface ExportTfRequest extends ProjectRequest { + only?: string[]; + outDir: string; +} + +export interface ExportTfValue { + files: string[]; + imported: number; + /** Keys that could not be used verbatim as an HCL identifier. */ + relabelled: { key: string; label: string }[]; +} + +export type ExportTfResult = OperationResult; + +export async function runExportTf(request: ExportTfRequest): Promise { + // resolveProject gives the env profile's state path AND the host the state + // must belong to; loadState asserts that pairing, so an export can never + // silently mix instances. No network and no auth: this reads state only. + const project = await resolveProject(request); + const state = await loadState(project.statePath, project.host); + const types = request.only?.length ? request.only : EXPORTABLE_TYPES; + + const byFile = new Map(); + const imports: ImportTarget[] = []; + const relabelled: { key: string; label: string }[] = []; + + for (const resource of Object.values(state.resources)) { + if (!types.includes(resource.type)) continue; + const block = renderResource(resource.type, resource.key, resource.fields); + const file = fileForType(resource.type); + byFile.set(file, [...(byFile.get(file) ?? []), block]); + imports.push({ type: resource.type, key: resource.key, id: resource.id }); + + // The label the renderer actually produced, read back out of the block so + // the report cannot drift from what was written. + const label = block.split('"')[3]; + if (label !== undefined && label !== resource.key) relabelled.push({ key: resource.key, label }); + } + + const outDir = isAbsolute(request.outDir) ? request.outDir : join(project.cwd, request.outDir); + await mkdir(outDir, { recursive: true }); + + const written: string[] = []; + for (const [file, blocks] of [...byFile].sort(([a], [b]) => a.localeCompare(b))) { + await writeFile(join(outDir, file), blocks.sort().join("\n"), "utf8"); + written.push(file); + } + await writeFile(join(outDir, "imports.tf"), renderImports(imports), "utf8"); + written.push("imports.tf"); + + return { + operation: "export-tf", + project, + value: { files: written, imported: imports.length, relabelled }, + warnings: [], + }; +} diff --git a/src/commands/export-tf.ts b/src/commands/export-tf.ts new file mode 100644 index 0000000..a31d982 --- /dev/null +++ b/src/commands/export-tf.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import { runExportTf } from "../application/operations/export-tf.js"; +import { info } from "../ui.js"; + +interface ExportTfOptions { + state?: string; + env?: string; + only?: string[]; + out: string; +} + +export function exportCommand(): Command { + const tf = new Command("tf") + .description("Render the managed state as OpenTofu HCL plus import blocks") + .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--only ", "restrict to these resource types (e.g. campus group-type)") + .option("-o, --out ", "output directory", "tofu") + .action(async (opts: ExportTfOptions) => { + const { value } = await runExportTf({ + statePath: opts.state, + environment: opts.env, + only: opts.only, + outDir: opts.out, + }); + for (const file of value.files) info(`wrote ${opts.out}/${file}`); + for (const r of value.relabelled) { + info(`relabelled "${r.key}" -> "${r.label}" (HCL references must be identifiers)`); + } + info(`${value.imported} resources exported`); + }); + + return new Command("export").description("Export managed state to other formats").addCommand(tf); +} diff --git a/src/export/hcl.ts b/src/export/hcl.ts new file mode 100644 index 0000000..009292d --- /dev/null +++ b/src/export/hcl.ts @@ -0,0 +1,75 @@ +/** + * HCL rendering — PURE. No filesystem, no network, no state loading. + * + * Kept pure for the same reason `src/coverage/report.ts` is: the golden export + * test has to run without touching disk, and a renderer that reads files + * cannot be property-tested against committed fixtures. + */ + +/** + * ct-cli resource type -> Terraform resource type. + * + * The `churchtools_` prefix is not decoration: Terraform resolves a resource's + * provider from the segment before the first underscore, so a bare `campus` + * type would send it looking for a provider named `campus`. (HCL also has no + * top-level custom blocks — every resource is `resource "" "