Skip to content

feat: Write roles fingerprints to /var/log/sysroles.jsonl [citest_skip] - #221

Merged
richm merged 2 commits into
mainfrom
fingerprint-write-to-file
Aug 6, 2026
Merged

richm merged 2 commits into
mainfrom
fingerprint-write-to-file

Conversation

@spetrosi

@spetrosi spetrosi commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Feature: Write roles fingerprints to /var/log/sysroles.jsonl [citest_skip]

Reason: By default logs are printed to rsyslog. This change adds a possibility to write logs to a file on the system for the downstream users.

Result: For the upstream, this makes rsyslog log message more detailed. For the downstream - also writes logs to /var/log/sysroles.jsonl

@spetrosi
spetrosi requested a review from richm as a code owner August 6, 2026 12:51
@spetrosi spetrosi self-assigned this Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Ignore keyword(s) in the title.

⛔ Ignored keywords (1)
  • [citest_skip]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 851b07ea-c46b-43b3-8f90-066bc540116b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

The module now collects structured role fingerprints instead of caller-provided messages. It formats syslog fields, optionally writes JSONL records with locking and trimming, and supports check-mode results. Unit tests cover formatting, persistence, validation, and failures.

Fingerprint logging

Layer / File(s) Summary
Fingerprint contract and formatting
library/sr_fingerprint.py, tests/unit/test_sr_fingerprint.py
The module defines required role and execution fields, canonical formatting, distribution and host-count helpers, and type-preserving JSONL output.
JSONL persistence and trimming
library/sr_fingerprint.py, tests/unit/test_sr_fingerprint.py
The module creates parent directories, locks writes, appends records, trims old records by size, and preserves file metadata. Tests cover these behaviors.
Module execution and validation
library/sr_fingerprint.py, tests/unit/test_sr_fingerprint.py
The handler validates arguments, supports check mode, returns log details, and converts write failures into fail_json. Tests cover validation and timestamp formatting.

Suggested reviewers: richm

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description Format ⚠️ Warning The description includes Reason, Result, and a valid Signed-off-by line, but it lacks the required Enhancement: or Feature: section. Add an Enhancement: or Feature: section that briefly describes the change, while retaining the existing required sections.
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses the required Conventional Commits format with the valid type "feat" and clearly describes the fingerprint logging change.
Description check ✅ Passed The description explains the feature, reason, and result, but it uses "Feature" instead of "Enhancement" and omits the issue tracker section.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
tests/unit/test_sr_fingerprint.py (4)

117-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for the remaining _format_fingerprint_key_value branches.

The tests cover the space-quoting branch. Three branches have no coverage:

  • A value that contains ", which exercises text.replace('"', '""').
  • A value that contains =, which is the third trigger character.
  • A partial distro pair, such as ("RedHat", ""), which returns "unknown".

The escaping branch is the part of the format that is most likely to change. A test pins the chosen convention.

💚 Proposed tests
     def test_get_managed_node_distro_missing(self):
         self.assertEqual(sr_fingerprint._get_managed_node_distro("", ""), "unknown")
 
+    def test_get_managed_node_distro_partial(self):
+        self.assertEqual(
+            sr_fingerprint._get_managed_node_distro("RedHat", ""), "unknown"
+        )
+        self.assertEqual(sr_fingerprint._get_managed_node_distro("", "9.4"), "unknown")
+
+    def test_format_fingerprint_key_value_escapes_quotes(self):
+        pair = sr_fingerprint._format_fingerprint_key_value("role_name", 'a"b')
+        self.assertEqual(pair, 'role_name="a""b"')
+
+    def test_format_fingerprint_key_value_quotes_equals(self):
+        pair = sr_fingerprint._format_fingerprint_key_value("role_name", "a=b")
+        self.assertEqual(pair, 'role_name="a=b"')
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_sr_fingerprint.py` around lines 117 - 140, Add unit tests in
the existing fingerprint test class covering the remaining
_format_fingerprint_key_value branches: assert embedded double quotes are
escaped by doubling them, assert values containing “=” use the quoted
representation, and assert _get_managed_node_distro("RedHat", "") returns
"unknown".

352-381: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an end-to-end test for the successful write path.

The tests cover _write_jsonl_log alone, the check-mode handler paths, and the write-failure path. No test runs _handle_fingerprint with check_mode=False and write_log_file: True against a real path.

That combination is the primary path the PR adds. A test should assert that the file receives one parseable JSON row, and that module.logged contains the key=value syslog line.

💚 Proposed test
    def test_handle_fingerprint_writes_log_and_syslog(self):
        with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
            log_path = tmp.name
        module = _FakeModule(
            {
                "status": "success",
                "write_log_file": True,
                "log_file": log_path,
                "max_log_size": 2000000,
                "role_name": "systemd",
                "role_path": "/usr/share/ansible/roles/linux-system-roles.systemd",
                "ansible_play_hosts_all": ["host1", "host2"],
                "distribution": "RedHat",
                "distribution_version": "9.4",
            },
            check_mode=False,
        )
        try:
            with self.assertRaises(_ExitJsonException) as ctx:
                sr_fingerprint._handle_fingerprint(module)
            result = ctx.exception.kwargs
            self.assertFalse(result["changed"])
            self.assertEqual(result["fingerprint"]["status"], "success")

            self.assertEqual(len(module.logged), 1)
            self.assertIn("role_name=systemd", module.logged[0])
            self.assertIn("play_hosts_number=2", module.logged[0])

            with open(log_path, "r") as log_fd:
                lines = log_fd.read().splitlines()
            self.assertEqual(len(lines), 1)
            self.assertEqual(json.loads(lines[0]), result["fingerprint"])
        finally:
            _cleanup_log(log_path)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_sr_fingerprint.py` around lines 352 - 381, Add an end-to-end
success-path test alongside
test_handle_fingerprint_write_failure_calls_fail_json that invokes
_handle_fingerprint with check_mode=False and write_log_file enabled using a
real temporary path. Assert the successful exit payload, one logged key=value
syslog line containing role_name and host count, and exactly one parseable JSON
row whose contents match the returned fingerprint; clean up the temporary log
file afterward.

195-272: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a trim test that starts with a file already above max_log_size.

The trim tests only exercise a file that is at or below the limit. They pass both with the current delta-based trim and with a target-based trim, so they do not pin the size bound.

The uncovered case is the defect that I raised on library/sr_fingerprint.py lines 191-217. Write many records with max_size=0, then write one record with a small max_size, and assert that the resulting file size is not greater than that max_size.

Both trim tests also assume that every record serializes to the same line_size. That holds only because each role_name is six characters. Add a comment, or assert the invariant, so a later change to the loop bound does not break the arithmetic silently.

💚 Proposed regression test
     def test_trim_disabled_when_zero(self):

Add this test alongside the existing trim tests:

    def test_trim_enforces_limit_when_file_starts_over_limit(self):
        with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
            log_file = tmp.name

        try:
            record = _sample_fingerprint_record()
            line_size = len(sr_fingerprint._format_fingerprint_jsonl(record) + "\n")
            # Grow the log well past the limit with trimming disabled.
            for _i in range(20):
                sr_fingerprint._write_jsonl_log(log_file, record, max_size=0)
            self.assertGreater(os.path.getsize(log_file), 5 * line_size)

            max_size = 5 * line_size
            sr_fingerprint._write_jsonl_log(log_file, record, max_size=max_size)

            self.assertLessEqual(os.path.getsize(log_file), max_size)
        finally:
            _cleanup_log(log_file)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_sr_fingerprint.py` around lines 195 - 272, Add a regression
test alongside the existing trim tests that writes many records with max_size=0,
verifies the file exceeds the intended limit, then writes one record with a
small max_size and asserts the resulting file size is at most that limit. Also
document or assert the equal serialized-line-size assumption used by the
existing role_name loops in test_trim_removes_oldest_lines and
test_trim_multiple_lines.

133-135: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the redundant line break from this assignment.

The parenthesized value is 89 characters and exceeds Black’s 88-character default line length, so this assignment should not create a wrapped Black-formatter diff.

♻️ Proposed change
     def test_format_fingerprint_syslog_quotes_values_with_spaces(self):
         record = _sample_fingerprint_record()
-        record["role_path"] = (
-            "/usr/share/ansible/roles/linux-system-roles.systemd extra"
-        )
+        record["role_path"] = "/usr/share/ansible/roles/linux-system-roles.systemd extra"
         message = sr_fingerprint._format_fingerprint_syslog(record)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_sr_fingerprint.py` around lines 133 - 135, In the test
assignment to record["role_path"], remove the unnecessary parenthesized line
break while preserving the exact string value; format it consistently with
Black’s expected output.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@library/sr_fingerprint.py`:
- Around line 191-217: Update the caller of _trim_log_file to pass the remaining
byte budget (max_log_size minus the incoming record size), and change
_trim_log_file to trim until the retained existing content fits that target
rather than removing only a delta. Preserve the file replacement behavior, and
update the related tests in test_sr_fingerprint.py to assert the maximum-size
contract, including already oversized files and a zero-size prior limit.

---

Nitpick comments:
In `@tests/unit/test_sr_fingerprint.py`:
- Around line 117-140: Add unit tests in the existing fingerprint test class
covering the remaining _format_fingerprint_key_value branches: assert embedded
double quotes are escaped by doubling them, assert values containing “=” use the
quoted representation, and assert _get_managed_node_distro("RedHat", "") returns
"unknown".
- Around line 352-381: Add an end-to-end success-path test alongside
test_handle_fingerprint_write_failure_calls_fail_json that invokes
_handle_fingerprint with check_mode=False and write_log_file enabled using a
real temporary path. Assert the successful exit payload, one logged key=value
syslog line containing role_name and host count, and exactly one parseable JSON
row whose contents match the returned fingerprint; clean up the temporary log
file afterward.
- Around line 195-272: Add a regression test alongside the existing trim tests
that writes many records with max_size=0, verifies the file exceeds the intended
limit, then writes one record with a small max_size and asserts the resulting
file size is at most that limit. Also document or assert the equal
serialized-line-size assumption used by the existing role_name loops in
test_trim_removes_oldest_lines and test_trim_multiple_lines.
- Around line 133-135: In the test assignment to record["role_path"], remove the
unnecessary parenthesized line break while preserving the exact string value;
format it consistently with Black’s expected output.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0708e83d-af80-493f-912a-119088d767fb

📥 Commits

Reviewing files that changed from the base of the PR and between 8971781 and e042a2d.

📒 Files selected for processing (2)
  • library/sr_fingerprint.py
  • tests/unit/test_sr_fingerprint.py

Comment thread library/sr_fingerprint.py
Comment on lines +191 to +217
def _trim_log_file(log_file, size_needed):
"""Remove oldest records until the file can accommodate size_needed bytes."""
with open(log_file, "r") as log_fd:
lines = log_fd.readlines()
size_removed = 0
while lines and size_removed < size_needed:
size_removed += len(lines.pop(0))
orig_stat = os.stat(log_file)
dir_name = os.path.dirname(log_file) or "."
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
try:
os.fchmod(fd, stat.S_IMODE(orig_stat.st_mode))
try:
os.fchown(fd, orig_stat.st_uid, orig_stat.st_gid)
except OSError:
pass
with os.fdopen(fd, "w") as tmp_fd:
tmp_fd.writelines(lines)
tmp_fd.flush()
os.fsync(tmp_fd.fileno())
os.rename(tmp_path, log_file)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Trimming does not enforce max_log_size when the file already exceeds it.

_trim_log_file removes only size_needed bytes, which the caller sets to len(new_line). This keeps the file at its current size, and it does not reduce the file to max_size.

If the file already exceeds max_log_size, the file stays above the limit forever. Each call then removes about one line and appends one line. Two supported configurations reach this state:

  • An operator lowers max_log_size between runs.
  • An operator runs with max_log_size: 0 first, then sets a limit later.

The documented contract is "Maximum log file size in bytes". Pass a target size instead of a delta, and trim until the file plus the new record fits.

🐛 Proposed fix: trim to a target size
-def _trim_log_file(log_file, size_needed):
-    """Remove oldest records until the file can accommodate size_needed bytes."""
+def _trim_log_file(log_file, size_allowed):
+    """Remove oldest records until the retained records fit in size_allowed bytes."""
     with open(log_file, "r") as log_fd:
         lines = log_fd.readlines()
-    size_removed = 0
-    while lines and size_removed < size_needed:
-        size_removed += len(lines.pop(0))
+    size_kept = sum(len(line) for line in lines)
+    while lines and size_kept > size_allowed:
+        size_kept -= len(lines.pop(0))
     orig_stat = os.stat(log_file)

Update the caller to pass the remaining budget:

         if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
-            _trim_log_file(log_file, len(new_line))
+            _trim_log_file(log_file, max_size - len(new_line))

Note that tests/unit/test_sr_fingerprint.py asserts the current delta behavior at lines 195-272. Update those tests with the fix.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 192-192: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(log_file, "r")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@library/sr_fingerprint.py` around lines 191 - 217, Update the caller of
_trim_log_file to pass the remaining byte budget (max_log_size minus the
incoming record size), and change _trim_log_file to trim until the retained
existing content fits that target rather than removing only a delta. Preserve
the file replacement behavior, and update the related tests in
test_sr_fingerprint.py to assert the maximum-size contract, including already
oversized files and a zero-size prior limit.

@spetrosi spetrosi changed the title feat: Write roles fingerprints to /var/log/sysroles.jsonl feat: Write roles fingerprints to /var/log/sysroles.jsonl [citest_skip] Aug 6, 2026
Feature: Write roles fingerprints to /var/log/sysroles.jsonl [citest_skip]

Reason: By default logs are printed to rsyslog. This change adds a possibility to write logs to a file on the system for the downstream users.

Result: For the upstream, this makes rsyslog log message more detailed. For the downstream - also writes logs to /var/log/sysroles.jsonl
Signed-off-by: Sergei Petrosian <spetrosi@redhat.com>
@spetrosi
spetrosi force-pushed the fingerprint-write-to-file branch from e042a2d to 889aca7 Compare August 6, 2026 15:13
The sr_fingerprint module was rewritten to accept structured parameters
(status, role_name, role_path, etc.) instead of a free-form sr_message.
Update the role tasks and tests to match the new module interface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@spetrosi

spetrosi commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

[citest]

@richm
richm merged commit 19eeb9d into main Aug 6, 2026
9 of 11 checks passed
@richm
richm deleted the fingerprint-write-to-file branch August 6, 2026 22:08
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.

2 participants