Skip to content

feat(converge): --only <member> scopes plan/apply/destroy (#306) - #324

Merged
ChrisonSimtian merged 1 commit into
mainfrom
feat/converge-only-filter
Jul 28, 2026
Merged

feat(converge): --only <member> scopes plan/apply/destroy (#306)#324
ChrisonSimtian merged 1 commit into
mainfrom
feat/converge-only-filter

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Closes #306.

converge was all-or-nothing across a stack. --only <member>[,<member>…] scopes plan, apply
and destroy
to named members.

converge stacks/SmartHome  --only podman-host --apply
converge stacks/monitoring --only prometheus,grafana
converge stacks/Media      --only bazarr --destroy          # scoping matters most here

The hazard, reproduced

An unscoped plan on SmartHome today:

▸ leapmotor-mate  (ctid 4100 …)
    state: DRIFT (CT exists; 1 field(s) differ)
      drift: tags: desired …;iot;…;smart-home ≠ live …
VM members (1) — via ProxmoxSharp:
▸ homeassistant  (vmid 2000, node hpe-01) — SetConfig

SetConfig on VM 2000 — the adopted, stack.yaml-documented describe-only member — plus a
drift-fix on an unrelated container mid-migration. Scoped:

Converge APPLY — stack 'SmartHome'  (1 member(s), dependency order)
--only podman-host — 1 member(s) selected, 6 skipped.
▸ podman-host  (ctid 6004, app 'podman', node hpe-01)
    NOCHANGE: podman host current (marker 77d6206ae286)
Apply summary — 0 applied, 1 no-change, 0 skipped, 0 failed.

Verified live. The VM section never runs at all.

What it replaces

Podman phases 1 (#285) and 2a (#302) were both applied from a hand-copied stack directory
stack.yaml plus the one member — to dodge exactly the above. That workaround silently drops
dependsOn ordering and cross-member context, and has to be rebuilt correctly every time. The
plan doc now points at --only instead.

Design

Filtering happens after load and topological ordering, never before. A selected member keeps
its merged stack defaults and its position in the full dependency order; the only thing that
changes is which members get acted on. VM members are filtered too — the adopted member that
motivated #306 is a VM, so a filter that only narrowed the LXC side would be useless.

Two guards, both deliberate:

  • An unknown name is fatal — exit 2, nothing touched. Silently converging nothing on a typo
    would print 0 applied and exit 0, i.e. read as success. That is the worst available outcome
    for a flag whose entire purpose is making apply safer. The error lists the stack's real members.
  • A dependsOn outside the selection is verified, not trusted. converge needs a --only <member> filter — applying a stack touches adopted describe-only members #306 weighed implicitly pulling
    dependencies in against failing loudly. Loud won: pulling them in would mean --only writes to
    members the operator did not name, which is the exact surprise it exists to prevent. The run
    checks the dependency exists live and refuses that member otherwise — before any work on it, so
    there is no half-applied state.
--only bazarr — 1 member(s) selected, 15 skipped.
  dependsOn outside the selection: radarr, sonarr (must already be converged)

One subtlety

ConvergeContext.ByName is still built from the whole stack, not the selection. Service-derived
secrets and the forgejo-runner create-vars resolve dependencies by name, so a scoped apply must
still be able to reach a dependency it is not itself converging. Narrowing that map alongside the
iteration list would have broken secret derivation in a way no existing test covers.

Tests

23 new (163 total, all passing.) MemberSelectionTests covers parsing (--only a,b,
--only=a,b, repetition, --only --apply not selecting a member literally named --apply),
resolution (ordering preserved, VM filtering, case sensitivity, unknown/empty rejection) and
dependency reporting. ConvergeOnlyApplyTests drives ApplyAsync over a throwaway stack dir with
a fake exec to cover the guard: fires when the dependency is absent, and — the regression that
would make --only useless — does not fire when it is present.

Scope

This scopes the symptom. The root cause is that adopted members cannot declare themselves
unmanaged, so they resurface as perpetual drift in every plan. The complementary shape-level
manage: describe-only marker is noted in #306 and stays open.

converge was all-or-nothing across a stack, which is a hazard for any stack
holding an adopted member. Adopted guests reappear as perpetual drift, so an
apply aimed at one new member also wanted to write to them: on SmartHome an
unscoped plan proposes SetConfig on VM 2000 (Home Assistant, documented
describe-only) plus a tag drift-fix on CT 4100.

Podman phases 1 (#285) and 2a (#302) both dodged that by applying from a
hand-copied stack directory holding stack.yaml plus the one member — which
silently drops dependsOn ordering and cross-member context, and has to be
rebuilt correctly every time. --only replaces it.

  converge stacks/SmartHome --only podman-host --apply
  converge stacks/monitoring --only prometheus,grafana

Filtering happens AFTER load and topological ordering, never before, so a
selected member keeps its merged stack defaults and its position in the full
dependency order; the only thing that changes is which members get acted on.
It narrows destroy too — arguably where scoping matters most, since the
unscoped form takes out every CT in the stack.

Two guards, both chosen deliberately:

  - An unknown name is FATAL (exit 2, nothing touched). Converging nothing on
    a typo would report "0 applied" and exit 0, i.e. read as success — the
    worst possible outcome for a flag whose purpose is making apply safer.
  - A dependsOn left OUTSIDE the selection is being assumed already converged,
    so that gets verified rather than trusted. #306 weighed implicitly pulling
    dependencies in against failing loudly; pulling them in would mean --only
    writes to members the operator did not name, which is the exact surprise it
    exists to prevent.

One subtlety worth naming: the ConvergeContext byName map is still built from
the WHOLE stack, not the selection. Service-derived secrets and the
forgejo-runner create-vars resolve dependencies by name, so a scoped apply must
still be able to reach a dependency it is not itself converging.

Verified live against SmartHome: `--only podman-host --apply` reports
1 selected / 6 skipped and NOCHANGE, and the VM section never runs at all.

This scopes the symptom, not the cause — adopted members still have no way to
declare themselves unmanaged. A shape-level `manage: describe-only` marker is
the complementary fix and stays open.

23 new tests (163 total).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

converge needs a --only <member> filter — applying a stack touches adopted describe-only members

1 participant