diff --git a/docs/opentofu-migration.md b/docs/opentofu-migration.md index 505147c..8ad18fc 100644 --- a/docs/opentofu-migration.md +++ b/docs/opentofu-migration.md @@ -108,6 +108,10 @@ ct ids sync --env prod --tofu-state terraform.tfstate --dry-run ct ids list --env prod # what ct would resolve through ``` +Role definitions (`churchtools_group_role_definition`, tier 3) are read too, into +the `group-role` section, so `{ roleDef: "…" }` references keep resolving once the +roles leave ct's state. They are read-only here: `ct export tf` does not write them. + `sync` reports every id it adds, changes or drops, and **refuses to replace a populated map with an empty one** — the likeliest cause of an empty read is the wrong workspace, and overwriting 50 working ids with nothing would break every diff --git a/src/resolve/tfstate.ts b/src/resolve/tfstate.ts index 7840736..de3b940 100644 --- a/src/resolve/tfstate.ts +++ b/src/resolve/tfstate.ts @@ -16,9 +16,13 @@ import type { IdMapEntry } from "./idMap.js"; /** Terraform resource type → ct resource type, derived from the exporter's own table so the two * directions cannot drift. */ -const CT_TYPE_BY_HCL_TYPE: ReadonlyMap = new Map( - EXPORTABLE_TYPES.map((ctType) => [hclType(ctType), ctType] as const), -); +const CT_TYPE_BY_HCL_TYPE: ReadonlyMap = new Map([ + ...EXPORTABLE_TYPES.map((ctType) => [hclType(ctType), ctType] as const), + // Role definitions (`/group/roles`) are hand-written in HCL once tofu owns them, so ct never + // EXPORTS them (they are not in EXPORTABLE_TYPES); but a `role-def` reference still has to resolve + // to their ids after the entries leave ct's state. Read-only mapping: state → id map. + ["churchtools_group_role_definition", "group-role"] as const, +]); export interface TfStateReadResult { entries: IdMapEntry[]; diff --git a/tests/tofu-id-map.test.ts b/tests/tofu-id-map.test.ts index 9b53eef..a6d0427 100644 --- a/tests/tofu-id-map.test.ts +++ b/tests/tofu-id-map.test.ts @@ -105,6 +105,17 @@ describe("Resolver", () => { expect(await resolver.resolve(ref.campus("mainz"), "site")).toBe(0); }); + it("resolves a role definition once it left ct's state (tier 3)", async () => { + // `roleDef` reads type "group-role"; after `ct state rm group-role ` only the map knows it. + const map = parseIdMap( + renderIdMap(HOST, [{ type: "group-role", key: "team_member", id: 7 }], "ct ids sync"), + ".ct/ids.json", + HOST, + ); + const resolver = new Resolver({ client: client as never, state, desired: [], host: HOST, idMap: map }); + expect(await resolver.resolve(ref.roleDef("team_member"), "site")).toBe(7); + }); + it("still prefers ct's own state over the map", async () => { // ct never stops trusting what it owns: a resource still in ct's state is ct's, and a stale map // entry must not overrule it. @@ -197,6 +208,31 @@ describe("readTfState", () => { expect(serial).toBe(12); }); + it("maps churchtools_group_role_definition to the group-role type", () => { + const { entries, unmapped } = readTfState({ + resources: [ + { + mode: "managed", + type: "churchtools_group_role_definition", + name: "team_member", + instances: [{ attributes: { id: "7" } }], + }, + // for_each blocks stay unmapped, as for every other type. + { + mode: "managed", + type: "churchtools_group_role_definition", + name: "bulk", + instances: [ + { index_key: "a", attributes: { id: "1" } }, + { index_key: "b", attributes: { id: "2" } }, + ], + }, + ], + }); + expect(entries).toEqual([{ type: "group-role", key: "team_member", id: 7 }]); + expect(unmapped).toEqual([]); + }); + it("takes the label as the key when no previous map recorded a relabelling", () => { const { entries } = readTfState(tfstate); expect(entries.map((e) => e.key)).toEqual(["status_unbekannt", "g_3_groupactive"]);