Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/application/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
131 changes: 131 additions & 0 deletions src/application/operations/export-tf.ts
Original file line number Diff line number Diff line change
@@ -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<ExportTfValue>;

/**
* 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<ExportTfResult> {
// 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<string, number>();
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<string, string[]>();
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,
};
}
37 changes: 37 additions & 0 deletions src/commands/export-tf.ts
Original file line number Diff line number Diff line change
@@ -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 <path>", "state file (or set CT_STATE)")
.option("-e, --env <name>", "environment profile from ct.envs.json (host + state + token)")
.option("--only <types...>", "restrict to these resource types (e.g. campus group-type)")
.option("-o, --out <dir>", "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);
}
133 changes: 133 additions & 0 deletions src/export/hcl.ts
Original file line number Diff line number Diff line change
@@ -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 "<type>" "<label>"`.)
*/
const HCL_TYPE: Record<string, string> = {
campus: "churchtools_campus",
"group-type": "churchtools_group_type",
department: "churchtools_department",
"person-status": "churchtools_person_status",
"comment-viewer": "churchtools_comment_viewer",
};

export function hclType(ctType: string): string {
const mapped = HCL_TYPE[ctType];
if (!mapped) throw new Error(`export: no Terraform resource type mapped for "${ctType}"`);
return mapped;
}

/**
* CT field name -> HCL attribute name. The provider exposes snake_case
* attributes (Terraform convention); ct-cli's state carries CT's camelCase.
*/
const HCL_ATTR: Record<string, string> = {
nameTranslated: "name_translated",
isMember: "is_member",
isSearchable: "is_searchable",
sortKey: "sort_key",
securityLevelId: "security_level_id",
};

export function hclAttr(field: string): string {
return HCL_ATTR[field] ?? field;
}

/**
* Make a ct-cli key referenceable in HCL.
*
* ct keys are already slugs, but a REFERENCE (`churchtools_group.3_groupactive`)
* must be a valid identifier, and `!3 GroupActive` derives a key starting with a
* digit. Such keys get a `g_` prefix; the mapping is reported by the export
* command so nothing renames silently.
*
* The mapping is MANY-TO-ONE (`a.b` and `a_b` both label `a_b`), and keys are
* user-settable via `--key`, so callers that render more than one resource of a
* type must run `assertLabelsUnique` over them — two blocks sharing an address
* is a `tofu` parse error, and the second import silently targets the first.
*/
export function hclLabel(key: string): string {
const safe = key.replace(/[^A-Za-z0-9_-]/g, "_");
return /^[0-9-]/.test(safe) ? `g_${safe}` : safe;
}

/**
* Throw if two keys of the same type collapse onto one HCL label.
*
* Detected here rather than left to `tofu`: the export knows both keys and can
* say which two to rename, while `tofu` only reports a duplicate address.
*/
export function assertLabelsUnique(rows: readonly { type: string; key: string }[]): void {
const seen = new Map<string, string>();
for (const row of rows) {
const address = `${hclType(row.type)}.${hclLabel(row.key)}`;
const first = seen.get(address);
if (first !== undefined && first !== row.key) {
throw new Error(
`export: keys "${first}" and "${row.key}" both render as ${address}. ` +
`Re-key one of them (ct adopt --key) before exporting.`,
);
}
seen.set(address, row.key);
}
}

/**
* Quote a string as an HCL template literal.
*
* `JSON.stringify` escapes JSON metacharacters, not HCL's interpolation
* markers: a CT name containing `${` or `%{` would otherwise be parsed as an
* interpolation — a plan-time error, or silently the wrong value. HCL escapes
* those by doubling the sigil.
*/
export function hclString(value: string): string {
// Function replacers, not replacement strings: "$${" in a replacement string
// means an escaped `$` followed by `{` — i.e. exactly the input, a silent no-op.
return JSON.stringify(value)
.replace(/\$\{/g, () => "$${")
.replace(/%\{/g, () => "%%{");
}

function renderValue(value: unknown): string {
if (typeof value === "string") return hclString(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
return hclString(JSON.stringify(value));
}

export interface RenderOptions {
/**
* Mirror of the state's `preventDestroy`. Destroy protection is the one piece
* of state that is not a managed field, and dropping it in the export would
* migrate a protected resource into an unprotected one — `ct destroy` refuses
* it, `tofu destroy` would not.
*/
preventDestroy?: boolean;
}

/** Render one resource block. Null/undefined fields are omitted, never emitted as `null`. */
export function renderResource(
ctType: string,
key: string,
fields: Record<string, unknown>,
options: RenderOptions = {},
): string {
const type = hclType(ctType);
const entries = Object.entries(fields)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [hclAttr(k), v] as const);
const width = Math.max(0, ...entries.map(([k]) => k.length));
const lines = entries.map(([k, v]) => ` ${k.padEnd(width)} = ${renderValue(v)}`);
const lifecycle = options.preventDestroy ? ["", " lifecycle {", " prevent_destroy = true", " }"] : [];
return [`resource "${type}" "${hclLabel(key)}" {`, ...lines, ...lifecycle, "}", ""].join("\n");
}
23 changes: 23 additions & 0 deletions src/export/imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Import-block rendering — PURE. */
import { hclLabel, hclType } from "./hcl.js";

export interface ImportTarget {
type: string;
key: string;
id: number;
}

/**
* `id` is stringified explicitly rather than passed through any truthiness
* path: a campus can legitimately carry id 0, and a truthiness test would
* silently fail to import exactly that one resource.
*/
export function renderImports(targets: readonly ImportTarget[]): string {
return targets
.map((t) =>
["import {", ` to = ${hclType(t.type)}.${hclLabel(t.key)}`, ` id = "${String(t.id)}"`, "}", ""].join(
"\n",
),
)
.join("\n");
}
26 changes: 26 additions & 0 deletions src/export/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Which generated file each resource type lands in — PURE. */
const FILE_BY_TYPE: Record<string, string> = {
campus: "campuses.tf",
"group-type": "group-types.tf",
department: "departments.tf",
"person-status": "person-statuses.tf",
"comment-viewer": "comment-viewers.tf",
};

export function fileForType(ctType: string): string {
const file = FILE_BY_TYPE[ctType];
if (!file) throw new Error(`export: no output file mapped for resource type "${ctType}"`);
return file;
}

export const EXPORTABLE_TYPES = Object.keys(FILE_BY_TYPE);

/**
* Every file the export owns and may therefore overwrite or delete.
*
* The export prunes the ones it did not write this run: a `campuses.tf` left
* over from an earlier export (or from `--only`) keeps managing resources that
* are no longer in the exported set, and `tofu plan` then proposes creating
* what already exists. Nothing outside this list is ever touched.
*/
export const OWNED_FILES = [...Object.values(FILE_BY_TYPE), "imports.tf", "versions.tf"];
23 changes: 23 additions & 0 deletions src/export/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** The generated root module's provider requirement — PURE. */

/**
* Registry address of the successor provider. Emitted so the output directory
* is a runnable root module: without a `required_providers` block `tofu init`
* resolves `churchtools_*` to `registry.opentofu.org/hashicorp/churchtools` and
* fails, which is the first thing a migrating user would hit.
*/
export const PROVIDER_SOURCE = "eqrm/churchtools";

/** No version constraint: the provider is pre-1.0 and pinning belongs in the user's repo. */
export function renderVersions(): string {
return [
"terraform {",
" required_providers {",
" churchtools = {",
` source = "${PROVIDER_SOURCE}"`,
" }",
" }",
"}",
"",
].join("\n");
}
Loading
Loading