From c298eb7fbd1e9f597dbd073c346fdffbdd112256 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 17 Sep 2026 17:19:04 +0200 Subject: [PATCH] fix(export): let the consumer own versions.tf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ct export tf` owned versions.tf unconditionally — written every run, and pruned when unwritten. That made the file impossible to own, which matters because it is the one generated file a consumer has a real reason to own: - A provider VERSION CONSTRAINT lives inside required_providers and has nowhere else to go, so pinning was impossible. provider.ts already said pinning belongs in the consumer's repo; the code contradicted it. - A consumer who also keeps a backend block there loses it SILENTLY on the next export, and `tofu init` then falls back to local state while the real state sits in the configured backend. Reproduced against ct-structure's stack. `--no-versions` opts out. The file is then neither written nor pruned — dropping it from the owned set matters, because pruning would delete the consumer's file outright, which is worse than overwriting it: nothing would hint at why it vanished. Default is unchanged, so a first-time migration still gets a runnable root module. --- src/application/operations/export-tf.ts | 26 ++++++++++-- src/commands/export-tf.ts | 6 +++ src/export/provider.ts | 8 +++- tests/export/export-tf.test.ts | 56 ++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/application/operations/export-tf.ts b/src/application/operations/export-tf.ts index 514c4df..58fdfd5 100644 --- a/src/application/operations/export-tf.ts +++ b/src/application/operations/export-tf.ts @@ -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 { @@ -105,11 +119,17 @@ export async function runExportTf(request: ExportTfRequest): Promise f !== "versions.tf"); + for (const file of owned) { if (written.includes(file)) continue; await rm(join(outDir, file), { force: true }); } diff --git a/src/commands/export-tf.ts b/src/commands/export-tf.ts index 42ee9ed..d49c60d 100644 --- a/src/commands/export-tf.ts +++ b/src/commands/export-tf.ts @@ -7,6 +7,7 @@ interface ExportTfOptions { env?: string; only?: string[]; out: string; + versions: boolean; } export function exportCommand(): Command { @@ -16,12 +17,17 @@ export function exportCommand(): Command { .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") + .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) { diff --git a/src/export/provider.ts b/src/export/provider.ts index df232a8..e29d6cd 100644 --- a/src/export/provider.ts +++ b/src/export/provider.ts @@ -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 {", diff --git a/tests/export/export-tf.test.ts b/tests/export/export-tf.test.ts index a3ca1bc..8125f50 100644 --- a/tests/export/export-tf.test.ts +++ b/tests/export/export-tf.test.ts @@ -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"; @@ -44,6 +44,11 @@ async function exportInto(rows: Record, only?: string[]) { return runExportTf({ cwd: dir, statePath: "ct-state.json", outDir: "tofu", only }); } +async function exportKeepingVersions(rows: Record) { + 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({ @@ -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"); + }); +});