Repository navigation
feat(config): ruleset-only ct.dynamicGroup for groups ct no longer owns #202
Description
Problem
Once OpenTofu (churchtools_group in terraform-provider-churchtools) owns a set of groups, the groups have to leave ct's config and state: a key held both in ct's state and in .ct/ids.<host>.json is a duplicate, and consumers that flatten both into one keyed map refuse it.
But today the only way to manage a dynamic group's ruleset is the dynamic field on ct.group, and the ruleset fold only looks at groups in ct's state:
src/engine/synthetic.ts:553-556—dynamicField.foldskips any desired group that is not instate.resources.src/config/context.ts:417-445(desugarDynamic) —dynamiconly exists on act.groupdeclaration.
So removing the ct.group declarations silently ends ruleset management for every auto-group. The provider deliberately has no ruleset (resource_group.go:20-27 defers it to a future churchtools_dynamic_group), and porting engine/dynamic.ts to Go is not planned for this step.
Proposal
A ruleset-only declaration that names an existing group by key and manages nothing but its dynamic status and ruleset:
ct.dynamicGroup({ group: "example_autogroup", dynamic: true });
ct.dynamicGroup({
group: "example_paused",
dynamic: {
status: "manual",
ruleset: { ref: "./rulesets/example_paused.json" },
},
});dynamictakes exactly the three formsct.group({ dynamic })takes today (reusedesugarDynamic);truestill means./rulesets/<group>.json.- The group id resolves like any other group ref: ct's state first, then the id map (
resolver.ts:282-300, as fix(resolve): resolve a group_role's group through the id map #201 did forgroup_role). A key that resolves nowhere is a plan-time error, never a create. - No state entry of its own. The declaration attaches to a group ct does not own, so it must not write
ct-state.<env>.json, must not appear inct state list, and has no delete or destroy path. Dropping the declaration means "stop managing the ruleset", exactly like droppingdynamicfrom act.grouptoday (desired-side opt-in,synthetic.ts:572-574). It never demotes the group tonone, never deletes the ruleset, never touches the group row. - Plan items render as an update / no-op on the group (
dynamicfield change only), with the existing safety rails kept: unreadable ruleset or status degrades to fetch-failed (plan degrades into a WRONG plan under HTTP 429 instead of aborting #126), unportable-id and dropped-stereotypewarnings (synthetic.ts:600-625), status-only flips skip the ruleset PUT,ct apply --refreshstill refreshes per group. - Refused at config load:
ct.dynamicGroupon a key that is also declared withct.group(one owner per ruleset), and twoct.dynamicGroupfor the same group.
Also needed for the cutover
- Bare-string group scopes resolve through the id map, or say so.
src/permissions/scope.ts:382-391resolves a bare-string scope entry (scope: ["example_group"]) from managed state only, andreresolveTuple(scope.ts:415-425) re-reads state after apply. After the groups leave state, such grants fail to resolve. Either fall back to the id map (as typed{ group: "…" }refs already do through the resolver), or fail with a hint to write{ group: "…" }instead. The typed form already works, so the error-with-hint variant is enough. ct state rmguard knows the id map.application/operations/state.ts:96-105treats every group ref as state-only, so removing a group that the id map already resolves is refused and needs--force. A key present in the id map is not state-only.ct refresh <group>andct adopt group --with-dynamiclook groups up in state only (operations/refresh.ts:54,operations/adopt-group.ts:108). Both should accept an id-map group so a ruleset can still be re-captured and refreshed after the cutover; adopt should write act.dynamicGroupsnippet in that case, not act.group.ct coveragereports id-map groups as "unmanaged" (coverage/report.ts:164-210). Label them as owned elsewhere (id map), so the report stays readable.
Out of scope
parents: managed hierarchy stays tied toct.group(context.ts:696-710requires a declared parent). Groups owned by OpenTofu get their hierarchy from a future provider resource, not from ct.allowDuplicateName: a create-time flag forPOST /groups; ct never creates these groups any more.
Acceptance
- A config that declares a group only through
ct.dynamicGroup(group in the id map, not in state) plansNo changesagainst a host where the ruleset already matches, and a changed ruleset plans as onedynamicupdate. - Removing every
ct.groupdeclaration and every group state entry, while adding the equivalentct.dynamicGroupdeclarations, plans as a no-op: no create, no delete, nodynamicchange. - Removing a
ct.dynamicGroupdeclaration plans as a no-op and writes nothing. - Tests pin the rendered plan for each case (CONTRIBUTING: "plan must stay honest").
Activity