Skip to content

feat: richm fprint - #233

Closed
richm wants to merge 3 commits into
linux-system-roles:mainfrom
richm:richm-fprint
Closed

feat: richm fprint#233
richm wants to merge 3 commits into
linux-system-roles:mainfrom
richm:richm-fprint

Conversation

@richm

@richm richm commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
  • feat: Write roles fingerprints to /var/log/sysroles.jsonl
  • fix: Update sr_fingerprint task calls to use new structured parameters
  • feat: suggested fixes for sr_fingerprint log file

Summary by CodeRabbit

  • New Features

    • Added structured role fingerprints with status, role, host, distribution, and version details.
    • Added optional JSONL file logging with configurable file location and size limits.
    • Added check-mode reporting without writing log files.
    • Added a setting to enable fingerprint log-file output.
  • Bug Fixes

    • Added validation and clear errors for invalid log sizes and log-writing failures.

spetrosi and others added 3 commits August 5, 2026 18:07
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>
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>
Signed-off-by: Rich Megginson <rmeggins@redhat.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

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: 28ef8062-839d-4480-bc26-914fe34001cc

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:

  • ✅ Review completed - (🔄 Check again to review again)

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.

@richm richm changed the title richm fprint feat: richm fprint Aug 5, 2026
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.82143% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.17%. Comparing base (63d7c0c) to head (5e3421a).
⚠️ Report is 136 commits behind head on main.

Files with missing lines Patch % Lines
library/sr_fingerprint.py 84.82% 17 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #233      +/-   ##
==========================================
+ Coverage   78.43%   87.17%   +8.74%     
==========================================
  Files           2        3       +1     
  Lines         255      468     +213     
==========================================
+ Hits          200      408     +208     
- Misses         55       60       +5     
Flag Coverage Δ
sanity ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread library/sr_fingerprint.py
os.fchmod(fd, stat.S_IMODE(orig_stat.st_mode))
try:
os.fchown(fd, orig_stat.st_uid, orig_stat.st_gid)
except OSError:
Comment thread library/sr_fingerprint.py
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
def _cleanup_log(log_file):
for path in (log_file, log_file + ".lock"):
try:
os.unlink(path)

@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: 5

🤖 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 `@defaults/main.yml`:
- Around line 12-13: Add a README.md entry for the user-facing variable
bootloader_write_log_file, documenting its boolean type, default value of false,
and effect on writing logs to sysroles.jsonl. Include the required warning that
role metadata may be stored on disk when secure logging is enabled.

In `@library/sr_fingerprint.py`:
- Around line 191-232: Update _write_jsonl_log to calculate the required
reduction as cur_size + len(new_line) - max_size, and pass that value to
_trim_log_file instead of len(new_line). Apply trimming whenever max_size is
exceeded, including an empty file with an oversized record, while preserving
normal writes when the limit is not exceeded.

In `@tasks/set_vars.yml`:
- Around line 15-16: Guard the distribution fact lookups in both fingerprint
tasks with an empty-string default: update tasks/set_vars.yml lines 15-16 and
tasks/main.yml lines 186-187 for the begin and success fingerprint tasks,
respectively. Apply the default to both distribution and distribution_version
while preserving the existing module parameter flow.

In `@tests/tests_default.yml`:
- Around line 39-58: Make the test rerunnable by removing
/var/log/sysroles.jsonl and its .lock sidecar before the “Run with default
parameters” task. Wrap that role execution and its journal/log assertions in a
block with an always cleanup section that removes both files, sets failed_when:
false, and applies the tests::cleanup tag.

In `@tests/unit/test_sr_fingerprint.py`:
- Around line 131-140: Collapse the wrapped role_path assignment in
test_format_fingerprint_syslog_quotes_values_with_spaces into a single-line
assignment, preserving the existing string value and test behavior.
🪄 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: c7429eb6-14ba-4ca2-a593-9577f736e054

📥 Commits

Reviewing files that changed from the base of the PR and between 5ecfa0f and 5e3421a.

📒 Files selected for processing (6)
  • defaults/main.yml
  • library/sr_fingerprint.py
  • tasks/main.yml
  • tasks/set_vars.yml
  • tests/tests_default.yml
  • tests/unit/test_sr_fingerprint.py

Comment thread defaults/main.yml
Comment thread library/sr_fingerprint.py
Comment on lines +191 to +232
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


def _write_jsonl_log(log_file, record, max_size=0):
_ensure_parent_dir(log_file)
new_line = _format_fingerprint_jsonl(record) + "\n"
lock_path = log_file + ".lock"
lock_fd = open(lock_path, "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
cur_size = os.path.getsize(log_file)
except OSError:
cur_size = 0
if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
_trim_log_file(log_file, len(new_line))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Trim does not enforce max_log_size for an already oversized file.

_write_jsonl_log calls _trim_log_file(log_file, len(new_line)), and _trim_log_file stops after it removes len(new_line) bytes. The final size is then cur_size - removed + len(new_line), which stays at about cur_size. If cur_size already exceeds max_size, the file never returns to the limit.

Two paths reach that state:

  • A single record larger than max_size is written while the file is empty, because line 231 requires cur_size > 0.
  • The file was written earlier with a larger max_log_size or with trimming disabled.

Pass the required reduction instead of the new line size.

🔧 Proposed fix to trim down to the limit
-        if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
-            _trim_log_file(log_file, len(new_line))
+        if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
+            _trim_log_file(log_file, cur_size + len(new_line) - max_size)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
def _write_jsonl_log(log_file, record, max_size=0):
_ensure_parent_dir(log_file)
new_line = _format_fingerprint_jsonl(record) + "\n"
lock_path = log_file + ".lock"
lock_fd = open(lock_path, "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
cur_size = os.path.getsize(log_file)
except OSError:
cur_size = 0
if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
_trim_log_file(log_file, len(new_line))
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
def _write_jsonl_log(log_file, record, max_size=0):
_ensure_parent_dir(log_file)
new_line = _format_fingerprint_jsonl(record) + "\n"
lock_path = log_file + ".lock"
lock_fd = open(lock_path, "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
cur_size = os.path.getsize(log_file)
except OSError:
cur_size = 0
if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
_trim_log_file(log_file, cur_size + len(new_line) - max_size)
🧰 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)


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

(open-filename-from-request)


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

(open-filename-from-request)

🪛 GitHub Check: CodeQL

[notice] 205-205: Empty except
'except' clause does nothing but pass and there is no explanatory comment.


[notice] 215-215: Empty except
'except' clause does nothing but pass and there is no explanatory comment.

🤖 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 - 232, Update _write_jsonl_log to
calculate the required reduction as cur_size + len(new_line) - max_size, and
pass that value to _trim_log_file instead of len(new_line). Apply trimming
whenever max_size is exceeded, including an empty file with an oversized record,
while preserving normal writes when the limit is not exceeded.

Comment thread tasks/set_vars.yml
Comment on lines +15 to +16
distribution: "{{ ansible_facts['distribution'] }}"
distribution_version: "{{ ansible_facts['distribution_version'] }}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unguarded distribution fact lookups in both fingerprint tasks. Both sr_fingerprint tasks read ansible_facts['distribution'] and ansible_facts['distribution_version'] without a default. The setup task in tasks/set_vars.yml runs only when required facts are missing, so a pre-gathered fact subset that excludes distribution makes these lookups undefined and fails the task. The module already defaults both parameters to "".

  • tasks/set_vars.yml#L15-L16: add | d('') to both fact lookups in the begin fingerprint task.
  • tasks/main.yml#L186-L187: add | d('') to both fact lookups in the success fingerprint task.
📍 Affects 2 files
  • tasks/set_vars.yml#L15-L16 (this comment)
  • tasks/main.yml#L186-L187
🤖 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 `@tasks/set_vars.yml` around lines 15 - 16, Guard the distribution fact lookups
in both fingerprint tasks with an empty-string default: update
tasks/set_vars.yml lines 15-16 and tasks/main.yml lines 186-187 for the begin
and success fingerprint tasks, respectively. Apply the default to both
distribution and distribution_version while preserving the existing module
parameter flow.

Comment thread tests/tests_default.yml
Comment on lines +39 to +58
- name: Check that the log file was written
ansible.builtin.slurp:
path: /var/log/sysroles.jsonl
register: __register_log_file

- name: Show the log file contents
ansible.builtin.debug:
msg: "{{ __register_log_file.content | b64decode }}"

- name: Check contents of the log file
ansible.builtin.assert:
that:
- _log_items | length >= 2
- _log_items[0].role_name == _role_name
- _log_items[0].status == "begin"
- _log_items[1].role_name == _role_name
- _log_items[1].status == "success"
vars:
_log_items: "{{ (__register_log_file.content | b64decode).split('\n') | select | map('from_json') | list }}"
_role_name: bootloader

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add cleanup for /var/log/sysroles.jsonl and make the test rerunnable.

The test enables bootloader_write_log_file and then asserts on the first two records of /var/log/sysroles.jsonl. The file is never removed, so on a second run against the same host _log_items[0] and _log_items[1] are records from the earlier run. The assertions then pass without checking the current run.

Remove the log file and its .lock sidecar before the role runs, and place the assertions in a block with an always cleanup section tagged tests::cleanup.

🧪 Proposed test restructure
- name: Remove any previous fingerprint log file
  ansible.builtin.file:
    path: "{{ item }}"
    state: absent
  loop:
    - /var/log/sysroles.jsonl
    - /var/log/sysroles.jsonl.lock

Place that task before the "Run with default parameters" task, then wrap the role run and the checks:

- name: Run the role and verify fingerprints
  block:
    - name: Run with default parameters
      ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml
      vars:
        bootloader_write_log_file: true

    # existing journal and log file checks

  always:
    - name: Clean up the fingerprint log file
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop:
        - /var/log/sysroles.jsonl
        - /var/log/sysroles.jsonl.lock
      failed_when: false
      tags:
        - tests::cleanup

As per path instructions: "Tests should be run in a block with an always section that runs the test cleanup" and "The cleanup tasks should be tagged with tests::cleanup". Based on learnings, cleanup tasks in an always block use failed_when: false so cleanup failures do not fail the test run.

🤖 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/tests_default.yml` around lines 39 - 58, Make the test rerunnable by
removing /var/log/sysroles.jsonl and its .lock sidecar before the “Run with
default parameters” task. Wrap that role execution and its journal/log
assertions in a block with an always cleanup section that removes both files,
sets failed_when: false, and applies the tests::cleanup tag.

Sources: Path instructions, Learnings

Comment on lines +131 to +140
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"
)
message = sr_fingerprint._format_fingerprint_syslog(record)
self.assertIn(
'role_path="/usr/share/ansible/roles/linux-system-roles.systemd extra"',
message,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eo pipefail
pip install --quiet black >/dev/null 2>&1
fd -e py . library tests | xargs black --check --diff

Repository: linux-system-roles/bootloader

Length of output: 167


Collapse the wrapped role_path assignment before committing.

The value fits on one line under Black’s line-length limit, so Black will reformat this without the extra parentheses. Use this form to satisfy the Python/Black formatting requirement:

🎨 Proposed formatting fix
-        record["role_path"] = (
-            "/usr/share/ansible/roles/linux-system-roles.systemd extra"
-        )
+        record["role_path"] = "/usr/share/ansible/roles/linux-system-roles.systemd extra"

[simple_effort_and_high_reward]

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested 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"
)
message = sr_fingerprint._format_fingerprint_syslog(record)
self.assertIn(
'role_path="/usr/share/ansible/roles/linux-system-roles.systemd extra"',
message,
)
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"
message = sr_fingerprint._format_fingerprint_syslog(record)
self.assertIn(
'role_path="/usr/share/ansible/roles/linux-system-roles.systemd extra"',
message,
)
🤖 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 131 - 140, Collapse the
wrapped role_path assignment in
test_format_fingerprint_syslog_quotes_values_with_spaces into a single-line
assignment, preserving the existing string value and test behavior.

Source: Path instructions

@richm

richm commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

[citest]

@richm

richm commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

fixes incorporated

@richm richm closed this Aug 6, 2026
@richm
richm deleted the richm-fprint branch August 6, 2026 12:16
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.

3 participants