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..514c4df --- /dev/null +++ b/src/application/operations/export-tf.ts @@ -0,0 +1,131 @@ +/** Orchestration for `ct export tf`: state in, .tf files out. */ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { assertLabelsUnique, hclLabel, hclType, renderResource } from "../../export/hcl.js"; +import { renderImports, type ImportTarget } from "../../export/imports.js"; +import { EXPORTABLE_TYPES, fileForType, OWNED_FILES } from "../../export/layout.js"; +import { renderVersions } from "../../export/provider.js"; +import { loadState } from "../../state/state.js"; +import type { CtWarning, 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 }[]; + /** Managed types the provider has no mapping for yet, with how many were skipped. */ + skipped: { type: string; count: number }[]; +} + +export type ExportTfResult = OperationResult; + +/** + * Sort key for both the resource blocks and the import blocks. + * + * The two files MUST agree, and both must be byte-stable across runs: state is + * a JSON object, so its iteration order shifts when a resource is re-keyed + * (`upsert` re-inserts at the end) and integer-like keys sort ahead of the rest + * regardless of insertion order. Plain byte order, never `localeCompare` — + * locale collation puts "a-b" after "a_b" while bytes put it before, so a + * locale-sorted golden cannot pin what is written. + */ +function address(type: string, key: string): string { + return `${hclType(type)}.${hclLabel(key)}`; +} + +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); + + // An unknown --only value used to filter everything out, write an empty + // imports.tf over a good one and exit 0 saying "0 resources exported". + const unknown = (request.only ?? []).filter((type) => !EXPORTABLE_TYPES.includes(type)); + if (unknown.length > 0) { + throw new Error( + `export: unknown resource type(s) ${unknown.map((t) => `"${t}"`).join(", ")}. ` + + `Exportable types: ${EXPORTABLE_TYPES.join(", ")}.`, + ); + } + const types = request.only?.length ? request.only : EXPORTABLE_TYPES; + + const rows = Object.values(state.resources); + const selected = rows.filter((r) => types.includes(r.type)); + assertLabelsUnique(selected); + + // Types the export has no provider mapping for are NOT the same as types the + // user filtered out with --only: the first is a gap the user has to be told + // about, because their `tofu plan` would propose creating every one of them. + const skippedCounts = new Map(); + for (const resource of rows) { + if (EXPORTABLE_TYPES.includes(resource.type)) continue; + skippedCounts.set(resource.type, (skippedCounts.get(resource.type) ?? 0) + 1); + } + const skipped = [...skippedCounts] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([type, count]) => ({ type, count })); + + const byFile = new Map(); + const imports: ImportTarget[] = []; + const relabelled: { key: string; label: string }[] = []; + + for (const resource of [...selected].sort((a, b) => { + const left = address(a.type, a.key); + const right = address(b.type, b.key); + return left < right ? -1 : left > right ? 1 : 0; + })) { + const block = renderResource(resource.type, resource.key, resource.fields, { + preventDestroy: resource.preventDestroy, + }); + 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 < b ? -1 : a > b ? 1 : 0))) { + await writeFile(join(outDir, file), blocks.join("\n"), "utf8"); + written.push(file); + } + await writeFile(join(outDir, "imports.tf"), renderImports(imports), "utf8"); + written.push("imports.tf"); + await writeFile(join(outDir, "versions.tf"), renderVersions(), "utf8"); + written.push("versions.tf"); + + // Prune only what this command owns, and only what it did not just write. + for (const file of OWNED_FILES) { + if (written.includes(file)) continue; + await rm(join(outDir, file), { force: true }); + } + + const warnings: CtWarning[] = skipped.map(({ type, count }) => ({ + code: "EXPORT_TYPE_UNSUPPORTED", + message: + `${count} ${type} resource(s) were NOT exported: terraform-provider-churchtools has no ` + + `${type} resource yet. \`tofu plan\` will propose creating them — keep managing them with ct until then.`, + details: { type, count }, + })); + + return { + operation: "export-tf", + project, + value: { files: written, imported: imports.length, relabelled, skipped }, + warnings, + }; +} diff --git a/src/commands/export-tf.ts b/src/commands/export-tf.ts new file mode 100644 index 0000000..42ee9ed --- /dev/null +++ b/src/commands/export-tf.ts @@ -0,0 +1,37 @@ +import { Command } from "commander"; +import { runExportTf } from "../application/operations/export-tf.js"; +import { info, warn } 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, warnings } = 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`); + // Printed last, after the success line, so the gap is the final thing on + // screen rather than scrolled off above a list of written files. + for (const w of warnings) warn(w.message); + }); + + 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..542e49f --- /dev/null +++ b/src/export/hcl.ts @@ -0,0 +1,133 @@ +/** + * 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 "" "