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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ code, and reconcile it against the ChurchTools API with Terraform-style
> resources that are _explicitly_ declared or adopted. Everything else is
> invisible: never shown, never changed, never proposed for deletion.

> **Frozen.** The TypeScript config DSL receives bugfixes only and is removed
> in ct-cli 5.0. Its successor is `terraform-provider-churchtools`, an
> OpenTofu/Terraform provider that replaces the state file with tfstate and the
> logical-key resolver with native resource references. Its repository is not
> public yet; this note will link it at the provider's first release.
>
> Migrating needs no re-adoption for the resource types the provider already
> covers: `ct export tf` generates HCL plus `import` blocks from your existing
> state, so campuses, group types, departments, person statuses and comment
> viewers import at their current ids and plan clean. Your keys carry across
> (keys that are not valid HCL identifiers are relabelled, and the command
> reports every one). Config comments do not: the export reads state, and
> comments live in the TypeScript source. Groups, group roles, security levels,
> age groups, target groups and relationship types have no provider resource
> yet — `ct export tf` names them and their counts, and they stay with ct until
> the provider covers them.

## Why

A ChurchTools instance's structure is normally maintained by clicking. That
Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Writing the config

> **Frozen.** The TypeScript config DSL receives bugfixes only and is removed
> in ct-cli 5.0. Its successor is `terraform-provider-churchtools`, an
> OpenTofu/Terraform provider that replaces the state file with tfstate and the
> logical-key resolver with native resource references. Its repository is not
> public yet; this note will link it at the provider's first release.
>
> Migrating needs no re-adoption for the resource types the provider already
> covers: `ct export tf` generates HCL plus `import` blocks from your existing
> state, so campuses, group types, departments, person statuses and comment
> viewers import at their current ids and plan clean. Your keys carry across
> (keys that are not valid HCL identifiers are relabelled, and the command
> reports every one). Config comments do not: the export reads state, and
> comments live in the TypeScript source. Groups, group roles, security levels,
> age groups, target groups and relationship types have no provider resource
> yet — `ct export tf` names them and their counts, and they stay with ct until
> the provider covers them.

The desired state lives in a config file (default `ct.config.ts`) that
default-exports a function receiving the DSL:

Expand Down
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);
}
39 changes: 39 additions & 0 deletions src/config/deprecation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* The TypeScript config DSL is FROZEN (#tier-0 migration): bugfixes only, no
* new features, removed in ct-cli 5.0.
*
* Its successor is the OpenTofu provider `terraform-provider-churchtools`,
* which replaces the state file with tfstate and the logical-key resolver with
* native resource references. `ct export tf` generates HCL plus import blocks
* from existing state, so migrating needs no re-adoption.
*
* 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.
*/

let warned = false;

/** Test-only: reset the once-per-process latch. */
export function __resetDeprecationWarning(): void {
warned = false;
}

/**
* Warned once per process rather than once per resource: a per-resource
* warning on a 265-resource config is noise nobody reads.
*
* stderr, never stdout — stdout carries `--json` payloads that CI gates parse.
*/
export function warnConfigDeprecated(env: NodeJS.ProcessEnv = process.env): void {
// Any non-empty value suppresses, the NO_COLOR convention: a CI author who
// writes CT_NO_DEPRECATION_WARNING=true means it, and silently ignoring that
// spelling is the kind of thing nobody debugs — they just live with the noise.
if (warned || (env.CT_NO_DEPRECATION_WARNING ?? "").trim() !== "") return;
warned = true;
process.stderr.write(
"warning: the TypeScript config DSL is frozen and will be removed in ct-cli 5.0. " +
"Migrate to terraform-provider-churchtools — run `ct export tf` to generate HCL and " +
"import blocks from your existing state. Set CT_NO_DEPRECATION_WARNING=1 to silence this.\n",
);
}
2 changes: 2 additions & 0 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { access } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { createJiti } from "jiti";
import { warnConfigDeprecated } from "./deprecation.js";
import { evaluateConfig, type ConfigModule } from "./context.js";
import type { DesiredResource } from "../engine/types.js";
import type { DesiredPermission } from "../permissions/types.js";
Expand Down Expand Up @@ -36,6 +37,7 @@ export async function loadConfig(
`Create it — it must default-export a function (ct) => { ... }.`,
);
}
warnConfigDeprecated();
const jiti = createJiti(import.meta.url, { moduleCache: false });
const mod = await jiti.import<ConfigModule>(resolved, { default: true });
if (typeof mod !== "function") {
Expand Down
Loading
Loading