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
26 changes: 23 additions & 3 deletions src/application/operations/export-tf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ import { resolveProject } from "../project.js";
export interface ExportTfRequest extends ProjectRequest {
only?: string[];
outDir: string;
/**
* Write (and own) `versions.tf`. Default true: the output is then a runnable
* root module, which is what a first-time migration needs.
*
* Set false when the consumer owns that file. A provider VERSION CONSTRAINT
* lives inside `required_providers` and has nowhere else to go, so owning
* the file unconditionally makes pinning impossible — and a consumer who
* puts a backend there loses it silently on the next export.
*
* When false, `versions.tf` also leaves the owned set: pruning deletes owned
* files this run did not write, which would otherwise delete the consumer's
* file outright — worse than overwriting it, because nothing hints at why.
*/
writeVersions?: boolean;
}

export interface ExportTfValue {
Expand Down Expand Up @@ -105,11 +119,17 @@ export async function runExportTf(request: ExportTfRequest): Promise<ExportTfRes
}
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");
const writeVersions = request.writeVersions ?? true;
if (writeVersions) {
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) {
// `versions.tf` drops out of the owned set entirely when the consumer owns
// it — pruning it would delete their file rather than leave it alone.
const owned = writeVersions ? OWNED_FILES : OWNED_FILES.filter((f) => f !== "versions.tf");
for (const file of owned) {
if (written.includes(file)) continue;
await rm(join(outDir, file), { force: true });
}
Expand Down
6 changes: 6 additions & 0 deletions src/commands/export-tf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface ExportTfOptions {
env?: string;
only?: string[];
out: string;
versions: boolean;
}

export function exportCommand(): Command {
Expand All @@ -16,12 +17,17 @@ export function exportCommand(): Command {
.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")
.option(
"--no-versions",
"do not write or prune versions.tf — use when your repo owns it (e.g. to pin a provider version)",
)
.action(async (opts: ExportTfOptions) => {
const { value, warnings } = await runExportTf({
statePath: opts.state,
environment: opts.env,
only: opts.only,
outDir: opts.out,
writeVersions: opts.versions,
});
for (const file of value.files) info(`wrote ${opts.out}/${file}`);
for (const r of value.relabelled) {
Expand Down
8 changes: 7 additions & 1 deletion src/export/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
*/
export const PROVIDER_SOURCE = "eqrm/churchtools";

/** No version constraint: the provider is pre-1.0 and pinning belongs in the user's repo. */
/**
* No version constraint: the provider is pre-1.0.
*
* To pin one, own the file: `ct export tf --no-versions` leaves `versions.tf`
* alone (it is neither written nor pruned), so the constraint can live in your
* repo alongside a backend block.
*/
export function renderVersions(): string {
return [
"terraform {",
Expand Down
56 changes: 55 additions & 1 deletion tests/export/export-tf.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -44,6 +44,11 @@ async function exportInto(rows: Record<string, Row>, only?: string[]) {
return runExportTf({ cwd: dir, statePath: "ct-state.json", outDir: "tofu", only });
}

async function exportKeepingVersions(rows: Record<string, Row>) {
await stateWith(rows);
return runExportTf({ cwd: dir, statePath: "ct-state.json", outDir: "tofu", writeVersions: false });
}

describe("runExportTf", () => {
it("warns about managed types the provider cannot represent yet", async () => {
const result = await exportInto({
Expand Down Expand Up @@ -157,3 +162,52 @@ describe("runExportTf", () => {
expect(await readFile(join(dir, "tofu", "campuses.tf"), "utf8")).toContain('"Campus $${var.x}"');
});
});

/**
* `versions.tf` is generated so the output is a runnable root module, which is
* right by default. But it is also the only file a consumer has a legitimate
* reason to own: a provider VERSION CONSTRAINT lives inside
* `required_providers`, and there is nowhere else to put one. Owning the file
* unconditionally therefore made pinning impossible — while the generated
* file's own comment says pinning belongs in the consumer's repo.
*/
describe("writeVersions: false", () => {
it("does not write versions.tf", async () => {
await exportKeepingVersions({ mainz: row("campus", "mainz", 0, { shorty: "MZ" }) });
const files = await readdir(join(dir, "tofu"));
expect(files).not.toContain("versions.tf");
});

it("leaves an existing versions.tf untouched rather than pruning it", async () => {
const pinned = [
"terraform {",
" required_providers {",
' churchtools = { source = "eqrm/churchtools", version = "~> 0.1" }',
" }",
"}",
"",
].join("\n");
await stateWith({ mainz: row("campus", "mainz", 0, { shorty: "MZ" }) });
const outDir = join(dir, "tofu");
await mkdir(outDir, { recursive: true });
await writeFile(join(outDir, "versions.tf"), pinned, "utf8");

await runExportTf({ cwd: dir, statePath: "ct-state.json", outDir: "tofu", writeVersions: false });

// Pruning deletes every OWNED file this run did not write. If versions.tf
// stayed owned while unwritten, the consumer's pin would be deleted — a
// worse outcome than overwriting it, because nothing would hint at why.
expect(await readFile(join(outDir, "versions.tf"), "utf8")).toBe(pinned);
});

it("does not report versions.tf among the written files", async () => {
const result = await exportKeepingVersions({ mainz: row("campus", "mainz", 0, { shorty: "MZ" }) });
expect(result.value.files).not.toContain("versions.tf");
});

it("still writes it by default", async () => {
await exportInto({ mainz: row("campus", "mainz", 0, { shorty: "MZ" }) });
const files = await readdir(join(dir, "tofu"));
expect(files).toContain("versions.tf");
});
});
Loading