77DOCUMENTATION = """
88---
99module: 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
1111description:
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.
1422author: Rich Megginson (@richm)
1523options:
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
2279EXAMPLES = """
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
30130from ansible .module_utils .basic import AnsibleModule
31131
32132import 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
35154def _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
83357def main ():
0 commit comments