Skip to content

Commit 0050eaf

Browse files
committed
feat: Write roles fingerprints to /var/log/sysroles.jsonl
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> fix: Update sr_fingerprint task calls to use new structured parameters 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.
1 parent 5ecfa0f commit 0050eaf

6 files changed

Lines changed: 763 additions & 36 deletions

File tree

library/sr_fingerprint.py

Lines changed: 298 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,148 @@
77
DOCUMENTATION = """
88
---
99
module: sr_fingerprint
10-
short_description: Write a message string to syslog using Ansible C(module.log) function.
10+
short_description: Write role fingerprint data to syslog and optionally to a JSONL log file
1111
description:
12-
- Writes the given string to the system log using Ansible C(module.log) function.
12+
- Collects role fingerprint data into a canonical record and writes it to
13+
syslog using Ansible C(module.log) as C(key=value) pairs.
14+
- Optionally appends the same record as a JSON line to a log file
15+
(one JSON object per line, JSONL format), by default
16+
C(/var/log/sysroles.jsonl).
17+
- Playbook variables are not available inside modules automatically. Roles
18+
pass C(role_name), C(role_path), C(ansible_play_hosts_all),
19+
C(distribution), and C(distribution_version) from the task.
20+
- C(ansible_check_mode) is collected from the module execution context.
1321
- Intended for role-internal or diagnostic use.
1422
author: Rich Megginson (@richm)
1523
options:
16-
sr_message:
17-
description: Text to record in syslog.
24+
status:
25+
description: Role execution status.
1826
type: str
1927
required: true
28+
choices:
29+
- begin
30+
- success
31+
write_log_file:
32+
description: >-
33+
If C(true), append fingerprint data to the JSONL log file.
34+
Defaults to C(false).
35+
type: bool
36+
default: false
37+
log_file:
38+
description: >-
39+
Path to the JSONL log file. A lock sidecar (C(<log_file>.lock))
40+
is created next to the log file for cross-process safety.
41+
type: path
42+
default: /var/log/sysroles.jsonl
43+
max_log_size:
44+
description: >-
45+
Maximum log file size in bytes. When appending a new record
46+
would exceed this limit, the oldest records are removed first.
47+
Set to C(0) to disable trimming.
48+
type: int
49+
default: 2000000
50+
role_name:
51+
description: Name of the role, typically C({{ role_name }}).
52+
type: str
53+
required: true
54+
role_path:
55+
description: Path to the role, typically C({{ role_path }}).
56+
type: path
57+
required: true
58+
ansible_play_hosts_all:
59+
description: >-
60+
All hosts in the play, typically C({{ ansible_play_hosts_all }}).
61+
Used to derive C(play_hosts_number).
62+
type: list
63+
elements: str
64+
required: true
65+
distribution:
66+
description: >-
67+
OS distribution name, typically
68+
C({{ ansible_facts["distribution"] }}).
69+
type: str
70+
default: ""
71+
distribution_version:
72+
description: >-
73+
OS distribution version, typically
74+
C({{ ansible_facts["distribution_version"] }}).
75+
type: str
76+
default: ""
2077
"""
2178

2279
EXAMPLES = """
23-
- name: Record a fingerprint message in syslog
80+
- name: Record role begin fingerprint to syslog only (not log file)
81+
sr_fingerprint:
82+
status: begin
83+
role_name: bootloader
84+
role_path: "{{ role_path }}"
85+
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
86+
distribution: "{{ ansible_facts['distribution'] }}"
87+
distribution_version: "{{ ansible_facts['distribution_version'] }}"
88+
write_log_file: false
89+
90+
- name: Record role success fingerprint
2491
sr_fingerprint:
25-
sr_message: "system_role:ROLENAME"
92+
status: success
93+
role_name: bootloader
94+
role_path: "{{ role_path }}"
95+
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
96+
distribution: "{{ ansible_facts['distribution'] }}"
97+
distribution_version: "{{ ansible_facts['distribution_version'] }}"
98+
write_log_file: true
2699
"""
27100

28-
RETURN = r""" # """
101+
RETURN = r"""
102+
fingerprint:
103+
description: The fingerprint record written to syslog and optionally to the log file.
104+
returned: always
105+
type: dict
106+
sample:
107+
date: "2026-08-03T10:15:00+02:00"
108+
role_name: network
109+
role_path: /usr/share/ansible/roles/linux-system-roles.network
110+
status: success
111+
ansible_version: "2.16.3"
112+
managed_node_distro: RedHat-9.4
113+
play_hosts_number: 3
114+
ansible_check_mode: false
115+
message:
116+
description: Informational message shown in check mode.
117+
returned: check mode
118+
type: str
119+
sample: "Check mode: message not logged - [date=... role_name=...]"
120+
jsonl_row:
121+
description: The JSON line that would be appended to the log file.
122+
returned: check mode and O(write_log_file=true)
123+
type: str
124+
log_file:
125+
description: Path to the log file that would be written.
126+
returned: check mode and O(write_log_file=true)
127+
type: str
128+
"""
29129

30130
from ansible.module_utils.basic import AnsibleModule
31131

32132
import datetime
133+
import errno
134+
import fcntl
135+
import json
136+
import os
137+
import stat
138+
import tempfile
139+
140+
FINGERPRINT_FIELDS = (
141+
"date",
142+
"role_name",
143+
"role_path",
144+
"status",
145+
"ansible_version",
146+
"managed_node_distro",
147+
"play_hosts_number",
148+
"ansible_check_mode",
149+
)
150+
151+
FINGERPRINT_SYSLOG_SEPARATOR = " "
33152

34153

35154
def _local_iso8601_no_microseconds():
@@ -51,33 +170,188 @@ def _local_iso8601_no_microseconds():
51170
return datetime.datetime.now(utc).astimezone().replace(microsecond=0).isoformat()
52171

53172

54-
def run_module():
55-
module_args = dict(
56-
sr_message=dict(type="str", required=True),
57-
)
173+
def _ensure_parent_dir(path):
174+
parent = os.path.dirname(path)
175+
if not parent:
176+
return
177+
if os.path.isdir(parent):
178+
return
179+
try:
180+
os.makedirs(parent)
181+
except OSError as exc:
182+
if exc.errno != errno.EEXIST or not os.path.isdir(parent):
183+
raise
58184

59-
module = AnsibleModule(
60-
argument_spec=module_args,
61-
supports_check_mode=True,
62-
)
63185

64-
log_message = "%s %s" % (
65-
module.params["sr_message"],
66-
_local_iso8601_no_microseconds(),
67-
)
186+
def _format_fingerprint_jsonl(record):
187+
"""Format the canonical fingerprint record as a single JSON line."""
188+
return json.dumps(record, separators=(",", ":"), sort_keys=False)
189+
190+
191+
def _trim_log_file(log_file, size_needed):
192+
"""Remove oldest records until the file can accommodate size_needed bytes."""
193+
with open(log_file, "r") as log_fd:
194+
lines = log_fd.readlines()
195+
size_removed = 0
196+
while lines and size_removed < size_needed:
197+
size_removed += len(lines.pop(0))
198+
orig_stat = os.stat(log_file)
199+
dir_name = os.path.dirname(log_file) or "."
200+
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
201+
try:
202+
os.fchmod(fd, stat.S_IMODE(orig_stat.st_mode))
203+
try:
204+
os.fchown(fd, orig_stat.st_uid, orig_stat.st_gid)
205+
except OSError:
206+
pass
207+
with os.fdopen(fd, "w") as tmp_fd:
208+
tmp_fd.writelines(lines)
209+
tmp_fd.flush()
210+
os.fsync(tmp_fd.fileno())
211+
os.rename(tmp_path, log_file)
212+
except BaseException:
213+
try:
214+
os.unlink(tmp_path)
215+
except OSError:
216+
pass
217+
raise
218+
219+
220+
def _write_jsonl_log(log_file, record, max_size=0):
221+
_ensure_parent_dir(log_file)
222+
new_line = _format_fingerprint_jsonl(record) + "\n"
223+
lock_path = log_file + ".lock"
224+
lock_fd = open(lock_path, "w")
225+
try:
226+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
227+
try:
228+
cur_size = os.path.getsize(log_file)
229+
except OSError:
230+
cur_size = 0
231+
if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
232+
_trim_log_file(log_file, len(new_line))
233+
with open(log_file, "a") as log_fd:
234+
log_fd.write(new_line)
235+
finally:
236+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
237+
lock_fd.close()
238+
239+
240+
def _get_managed_node_distro(distribution, distribution_version):
241+
if distribution and distribution_version:
242+
return "%s-%s" % (distribution, distribution_version)
243+
return "unknown"
244+
245+
246+
def _get_play_hosts_number(play_hosts_all):
247+
return len(play_hosts_all)
248+
249+
250+
def _get_ansible_version(module):
251+
version = getattr(module, "ansible_version", None)
252+
if version:
253+
return version
254+
return "unknown"
255+
256+
257+
def _get_check_mode(module):
258+
return bool(getattr(module, "check_mode", False))
259+
260+
261+
def _collect_fingerprint_record(module, status):
262+
"""Build the canonical fingerprint record used by all output formatters."""
263+
return {
264+
"date": _local_iso8601_no_microseconds(),
265+
"role_name": module.params["role_name"],
266+
"role_path": module.params["role_path"],
267+
"status": status,
268+
"ansible_version": _get_ansible_version(module),
269+
"managed_node_distro": _get_managed_node_distro(
270+
module.params["distribution"], module.params["distribution_version"]
271+
),
272+
"play_hosts_number": _get_play_hosts_number(
273+
module.params["ansible_play_hosts_all"]
274+
),
275+
"ansible_check_mode": _get_check_mode(module),
276+
}
277+
278+
279+
def _fingerprint_record_items(record):
280+
return [(field, record[field]) for field in FINGERPRINT_FIELDS]
281+
282+
283+
def _format_fingerprint_key_value(field, value):
284+
text = "" if value is None else str(value)
285+
if any(char in text for char in ' "='):
286+
return '%s="%s"' % (field, text.replace('"', '""'))
287+
return "%s=%s" % (field, text)
288+
289+
290+
def _format_fingerprint_syslog(record):
291+
"""Format the canonical fingerprint record as key=value syslog text."""
292+
pairs = [
293+
_format_fingerprint_key_value(field, value)
294+
for field, value in _fingerprint_record_items(record)
295+
]
296+
return FINGERPRINT_SYSLOG_SEPARATOR.join(pairs)
297+
298+
299+
def _handle_fingerprint(module):
300+
max_log_size = module.params["max_log_size"]
301+
if max_log_size < 0:
302+
module.fail_json(
303+
msg="max_log_size must be 0 or a positive integer, got %d" % max_log_size
304+
)
305+
306+
fingerprint_record = _collect_fingerprint_record(module, module.params["status"])
307+
log_message = _format_fingerprint_syslog(fingerprint_record)
68308

69309
if module.check_mode:
70-
module.exit_json(
310+
result = dict(
71311
changed=False,
72312
message="Check mode: message not logged - [%s]" % log_message,
313+
fingerprint=fingerprint_record,
73314
)
315+
if module.params["write_log_file"]:
316+
result["jsonl_row"] = _format_fingerprint_jsonl(fingerprint_record)
317+
result["log_file"] = module.params["log_file"]
318+
module.exit_json(**result)
74319

75320
module.log(log_message)
76321

77-
# we don't actually change anything, so we're not changed - writing a log message
78-
# is not considered a change
79-
# also, we don't want to report changed every time the role runs
80-
module.exit_json(changed=False)
322+
if module.params["write_log_file"]:
323+
log_file = module.params["log_file"]
324+
try:
325+
_write_jsonl_log(
326+
log_file, fingerprint_record, module.params["max_log_size"]
327+
)
328+
except (IOError, OSError) as exc:
329+
module.fail_json(
330+
msg="Failed to write fingerprint log file %s: %s" % (log_file, exc)
331+
)
332+
333+
module.exit_json(changed=False, fingerprint=fingerprint_record)
334+
335+
336+
def run_module():
337+
module_args = dict(
338+
status=dict(type="str", required=True, choices=["begin", "success"]),
339+
write_log_file=dict(type="bool", default=False),
340+
log_file=dict(type="path", default="/var/log/sysroles.jsonl"),
341+
max_log_size=dict(type="int", default=2000000),
342+
role_name=dict(type="str", required=True),
343+
role_path=dict(type="path", required=True),
344+
ansible_play_hosts_all=dict(type="list", elements="str", required=True),
345+
distribution=dict(type="str", default=""),
346+
distribution_version=dict(type="str", default=""),
347+
)
348+
349+
module = AnsibleModule(
350+
argument_spec=module_args,
351+
supports_check_mode=True,
352+
)
353+
354+
_handle_fingerprint(module)
81355

82356

83357
def main():

tasks/main.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@
179179

180180
- name: Record role success fingerprint
181181
sr_fingerprint:
182-
sr_message: >-
183-
success system_role:bootloader ansible_version={{ ansible_version.full }}
184-
{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_version'] }}
182+
status: success
183+
role_name: bootloader
184+
role_path: "{{ role_path }}"
185+
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
186+
distribution: "{{ ansible_facts['distribution'] }}"
187+
distribution_version: "{{ ansible_facts['distribution_version'] }}"
188+
write_log_file: "{{ __bootloader_write_log_file }}"

tasks/set_vars.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88

99
- name: Record role begin fingerprint
1010
sr_fingerprint:
11-
sr_message: >-
12-
begin system_role:bootloader ansible_version={{ ansible_version.full }}
13-
{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_version'] }}
11+
status: begin
12+
role_name: bootloader
13+
role_path: "{{ role_path }}"
14+
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
15+
distribution: "{{ ansible_facts['distribution'] }}"
16+
distribution_version: "{{ ansible_facts['distribution_version'] }}"
17+
write_log_file: "{{ __bootloader_write_log_file }}"
1418

1519
- name: Determine if system is ostree and set flag
1620
when: not __bootloader_is_ostree is defined

0 commit comments

Comments
 (0)