Skip to content

Improve idempotence of the rule module - #1143

Draft
robin-checkmk wants to merge 5 commits into
develfrom
feature/rule-module-improve-idempotence
Draft

Improve idempotence of the rule module#1143
robin-checkmk wants to merge 5 commits into
develfrom
feature/rule-module-improve-idempotence

Conversation

@robin-checkmk

@robin-checkmk robin-checkmk commented Jul 15, 2026

Copy link
Copy Markdown
Member

This PR was LLM-assisted. We will test it thoroughly and possibly adapt it in the process.
Human review is definitely the decisive factor here, we will only merge it, if it is well understood and valid.

Pull request type

Try to limit your pull request to one type, submit multiple pull requests if needed.

Check the type of change your PR introduces:

  • Bugfix
  • Feature
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no API changes)
  • Build related changes
  • Documentation content changes
  • Other (describe what kind of change you performed):

What is the current behavior?

Describe the current behavior that you are modifying, or link to a relevant issue.

Issue Number: #186

What is the new behavior?

Describe the behavior or changes that are being added by this PR.

Implemented fixes

Fix Commit Verified behavior
F3 bogus rule_id c7955117 Fails with documented message; absent path stays green
F1 structural compare 993c3e0a echo (1)echo [1] detected + applied, then idempotent; tuple/list still tolerated
F2 label translation cb5bd3ad Old-syntax rule created once, unchanged on rerun, no duplicates; server-side conditions match translation (multi-label + is_not)
F4 move-on-create 616ae9d7 Default location: single "Rule created"; position: top still moves, idempotent after
F5 docs 967135e2 Examples switched to checkgroup_parameters:filesystem (valid on 2.3/2.4/2.5); notes on canonical format, masking, positions

F1. Replace the ()[] string hack with structural normalization

_raw_value_eval rewrote parens to brackets inside string literals too:
changing a command line from echo (1) to echo [1] was reported
changed=False and never applied (verified). The hack's original purpose is
gone — 2.3/2.4/2.5 all reject list-for-tuple input (400) and return tuples
byte-stably. Implemented fix (993c3e0a): literal_eval both sides, then
_tuples_to_lists() recursively converts tuples to lists (containers only,
strings untouched) before ==. Keeps tolerance for any historically lenient
patch level, no version gate, no false equality.

F2. Translate label conditions before comparing

Non-empty host_labels/service_labels are accepted but returned migrated to
*_label_groups — identically on 2.3/2.4/2.5. A playbook using the old syntax
never matched → duplicate rule per run (verified). Implemented fix
(cb5bd3ad): _migrate_label_conditions() translates the desired state on
≥ 2.3 before comparing and sending —
{key, operator: is|is_not, value}
{operator: "and", label_group: [{operator: "and"|"not", label: "key:value"}]},
all labels of one condition into a single group. The mapping was verified
against cmk.utils.labels.single_label_group_from_labels (identical in the
2.3, 2.4 and 2.5 trees) and live against the API.

F3. Nonexistent rule_id + state: present silently "succeeds"

Reported changed=False, "Rule already exists with the desired parameters."
instead of the documented failure. Two compounding defects: run_module read
module.params.get("rule_id") at the top level (always None — the param
lives under rule), making the failure branch dead code; and rule_id_found()
checked is not None while _get_rule_by_id returns {} on 404. Implemented
fix (c7955117): read the nested parameter and treat an empty current as not
found.

F4. Pointless move after every create with default location

New rule + default position: any_moving_needed() was True ("any" != "bottom") → extra move POST, audit-log entry, and confusing "Rule created,
Rule moved" message, although create already appends at the bottom. Implemented
fix (616ae9d7): for new rules only positions other than bottom/any
trigger a move (create already places the rule at the bottom of the target
folder, so the previous folder check was superfluous as well).

F5. Docs corrections

Implemented (967135e2):

  • The EXAMPLES used {'levels': (80.0, 90.0)} for memory_percentage_used,
    which 2.5 rejects with 400 — switched to checkgroup_parameters:filesystem,
    whose value format is valid on 2.3/2.4/2.5 (verified).
  • New notes: document that value_raw must be the target version's
    canonical form
    (GUI "Export rule for API" is the reliable source) and may
    need manual migration after a Checkmk upgrade; that secret-bearing rules
    cannot be compared reliably (see J1); and that only position: any is a
    stable location claim (see J2).

Minor code smell noted, NOT addressed: _normalize_rule shallow-copies and
pops from nested dicts, mutating self.desired/self.current in place —
latent trap (F2's in-place translation follows the same existing pattern).

Other information

Any other information that is important to this PR, e.g screenshots of how the component looks before and after the change.

Judgement calls

J1. Secret masking (the hard one)

Every read masks secrets as '******' on all versions → with rule_id the
module edits every run; without it, _get_rule_id never matches → duplicate
rule every run
. Version specifics (all verified, stable within branches):

2.3 2.4 2.5
Explicit-pw syntax accepted legacy only legacy and new (silently migrated) new only
uuid element n/a regenerated on every GET (released behavior, p31+p32) preserved, stable
Stored-pw byte-stable form ('store', 'id') (…'stored_password', ('id', '')) (…'stored_password', ('id', '******'))

2.4 trap: legacy syntax is accepted-but-migrated (structure changes) → never
equal, even beyond masking. 2.3/2.5 reject the foreign syntax loudly instead.

Proposed fix — needs a decision:

  • Mask-aware compare: ignore the secret and uuid elements of password nodes
    (uuid because of 2.4), compare store-ids literally; apply the same rule in
    _get_rule_id. Never send read-back values — PUTting a masked value is
    accepted (200) and would store a literal ****** as the password.
  • Add update_secrets: always | on_create (Ansible's update_password
    pattern). Open decision: default on_create = idempotent out of the box
    but a changed secret in the playbook is NOT rolled out unless something else
    changes; default always = today's behavior (changed every run) for
    secret-bearing rules unless the user opts in.
  • Interim documented workaround (verified): use the password store and write the
    version's byte-stable form from the table above. Patch-stable but per-major —
    the code fix obsoletes it.

J2. Relative positioning cannot be made idempotent

position: bottom/top/before/after are claims about global folder order; any
later rule (even from the same play) invalidates them. This is inherent
semantics, not a defect. Decision: document-only, or additionally warn at
runtime when a positional spec is used, or attempt convergence detection
(likely not worth it). position: any + folder is the stable pattern.

With state=present and a rule_id that does not exist, the module
reported an unchanged success instead of the documented failure.
run_module read rule_id from the wrong parameter level (always None,
leaving the failure branch dead), and rule_id_found() treated the empty
dict returned for HTTP 404 as a found rule.
The parens-to-brackets translation in _raw_value_eval also rewrote
string contents, so a real change like 'echo (1)' to 'echo [1]' was
reported as unchanged and never applied. Evaluate value_raw as-is and
recursively convert tuples to lists on both sides before comparing:
same tuple/list tolerance, but string contents stay untouched.
Checkmk 2.3 and newer migrate non-empty host_labels/service_labels
conditions to host_label_groups/service_label_groups server-side, so a
playbook using the old syntax never matched the stored rule and created
a new rule on every run. Apply the same translation (verified against
cmk.utils.labels.single_label_group_from_labels) to the desired state
before comparing and sending.
Creating a rule already places it at the bottom of the target folder,
but _moving_needed() treated the default position "any" (and any
non-root folder) as requiring a move, causing an extra API call, an
audit log entry and a misleading "Rule moved" message on every
create.
Replace the memory_percentage_used examples, whose value format is
rejected by Checkmk 2.5, with checkgroup_parameters:filesystem, which
works on all supported versions. Document that value_raw must use the
target version's canonical format, that secret-bearing rules cannot be
compared reliably due to API-side masking, and that relative positions
are order-dependent claims.
@robin-checkmk robin-checkmk self-assigned this Jul 15, 2026
@github-actions github-actions Bot added the module:rule This affects the rule module label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module:rule This affects the rule module

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant