Skip to content

feat(config): ruleset-only ct.dynamicGroup for groups ct no longer owns #202

Closed

Description

@2000game

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.fold skips any desired group that is not in state.resources.
  • src/config/context.ts:417-445 (desugarDynamic) — dynamic only exists on a ct.group declaration.

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" },
  },
});
  • dynamic takes exactly the three forms ct.group({ dynamic }) takes today (reuse desugarDynamic); true still 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 for group_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 in ct state list, and has no delete or destroy path. Dropping the declaration means "stop managing the ruleset", exactly like dropping dynamic from a ct.group today (desired-side opt-in, synthetic.ts:572-574). It never demotes the group to none, never deletes the ruleset, never touches the group row.
  • Plan items render as an update / no-op on the group (dynamic field 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-stereotype warnings (synthetic.ts:600-625), status-only flips skip the ruleset PUT, ct apply --refresh still refreshes per group.
  • Refused at config load: ct.dynamicGroup on a key that is also declared with ct.group (one owner per ruleset), and two ct.dynamicGroup for the same group.

Also needed for the cutover

  1. Bare-string group scopes resolve through the id map, or say so. src/permissions/scope.ts:382-391 resolves a bare-string scope entry (scope: ["example_group"]) from managed state only, and reresolveTuple (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.
  2. ct state rm guard knows the id map. application/operations/state.ts:96-105 treats 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.
  3. ct refresh <group> and ct adopt group --with-dynamic look 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 a ct.dynamicGroup snippet in that case, not a ct.group.
  4. ct coverage reports 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 to ct.group (context.ts:696-710 requires a declared parent). Groups owned by OpenTofu get their hierarchy from a future provider resource, not from ct.
  • allowDuplicateName: a create-time flag for POST /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) plans No changes against a host where the ruleset already matches, and a changed ruleset plans as one dynamic update.
  • Removing every ct.group declaration and every group state entry, while adding the equivalent ct.dynamicGroup declarations, plans as a no-op: no create, no delete, no dynamic change.
  • Removing a ct.dynamicGroup declaration plans as a no-op and writes nothing.
  • Tests pin the rendered plan for each case (CONTRIBUTING: "plan must stay honest").

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageUnsorted intake — decide in the weekly sweep

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions