Compare commits
19
Commits
b27d20fb52
...
main
+36
-2
@@ -39,12 +39,14 @@ reviews:
|
||||
mode: "warning"
|
||||
requirements: |
|
||||
PR title MUST follow Conventional Commits format:
|
||||
- Format: <type>: <description> or <type>!: <description> for breaking changes
|
||||
- Valid types: Refer to the 'type-enum' rule in .commitlintrc.js file for the complete list of allowed types
|
||||
- Format: <required type><optional (scope)><optional !>: <required description>
|
||||
- Valid types: Refer to https://github.com/linux-system-roles/auto-maintenance/blob/main/pr_title_lint.py#L23
|
||||
- Examples:
|
||||
- "feat: Add backup functionality"
|
||||
- "fix: Correct OSTree package installation"
|
||||
- "fix!: Remove deprecated variable (breaking change)"
|
||||
- "chore(formatting): Fix indentation in my_module.py"
|
||||
- "refactor(python)!: Use new python library thispy which has breaking api changes"
|
||||
|
||||
custom_checks:
|
||||
- mode: "warning"
|
||||
@@ -55,6 +57,8 @@ reviews:
|
||||
- Must contain "Reason:" section explaining why the change was needed
|
||||
- Must contain "Result:" section describing the outcome or impact
|
||||
- Can contain optional "Issue Tracker Tickets (Jira or BZ if any):" section
|
||||
- Must contain "Signed-off-by:" section with your name and email address - use git commit -s
|
||||
- Can contain optional "Assisted-by:" section with name of the AI coding assistant and models used
|
||||
|
||||
Example:
|
||||
```
|
||||
@@ -65,6 +69,34 @@ reviews:
|
||||
Result: Users can now set aide_secure_logging: false for debugging while maintaining secure defaults.
|
||||
|
||||
Issue Tracker Tickets (Jira or BZ if any): RHEL-12345
|
||||
|
||||
Signed-off-by: John Doe john.doe@example.com
|
||||
|
||||
Assisted-by: Fish 4.3 using model Swim 6.2
|
||||
```
|
||||
|
||||
For PRs that are bug fixes, you can use the following template:
|
||||
- Must contain "Cause:" section explaining the root cause of the bug
|
||||
- Must contain "Consequences:" section explaining the impact of the bug and how it appears to users
|
||||
- Must contain "Fix:" section explaining the fix for the bug and how it fixes the bug
|
||||
- Must contain "Result:" section describing the outcome or impact of the fix
|
||||
- Can contain optional "Issue Tracker Tickets (Jira or BZ if any):" section
|
||||
- Must contain "Signed-off-by:" section with your name and email address - use git commit -s
|
||||
- Can contain optional "Assisted-by:" section with name of the AI coding assistant and models used
|
||||
|
||||
Example:
|
||||
```
|
||||
Cause: The variable rolename_user_name was being checked for a `none` value but was not being checked for string length greater than 0 if a string.
|
||||
|
||||
Consequences: The role allowed empty user names to be configured which caused the daemon to report "User not found".
|
||||
|
||||
Fix: The variable rolename_user_name is now checked for string length if not `none`, and will report an error in that case if the length is 0.
|
||||
|
||||
Result: The role will not allow the user to provide an empty user name and will report an error if the rolename_user_name length is 0.
|
||||
|
||||
Signed-off-by: John Doe john.doe@example.com
|
||||
|
||||
Assisted-by: Fish 4.3 using model Swim 6.2
|
||||
```
|
||||
|
||||
path_instructions:
|
||||
@@ -180,6 +212,8 @@ reviews:
|
||||
- Tests should verify both success and failure scenarios
|
||||
- Use assert module to verify expected state after role execution
|
||||
- Include cleanup tasks to ensure tests are rerunnable
|
||||
- Tests should be run in a block with an always section that runs the test cleanup to ensure that the cleanup is always run.
|
||||
- The cleanup tasks should be tagged with `tests::cleanup` so that the cleanup can be skipped for debug purposes.
|
||||
- Tests should be idempotent - running twice should not cause failures
|
||||
- Example verification:
|
||||
```yaml
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
module.exports = {
|
||||
parserPreset: 'conventional-changelog-conventionalcommits',
|
||||
rules: {
|
||||
'body-leading-blank': [1, 'always'],
|
||||
'body-max-line-length': [2, 'always', 100],
|
||||
'footer-leading-blank': [1, 'always'],
|
||||
'footer-max-line-length': [2, 'always', 100],
|
||||
'header-max-length': [2, 'always', 100],
|
||||
'subject-case': [
|
||||
2,
|
||||
'never',
|
||||
['start-case', 'pascal-case', 'upper-case'],
|
||||
],
|
||||
'subject-empty': [2, 'never'],
|
||||
'subject-full-stop': [2, 'never', '.'],
|
||||
'type-case': [2, 'always', 'lower-case'],
|
||||
'type-empty': [2, 'never'],
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'docs',
|
||||
'feat',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'revert',
|
||||
'style',
|
||||
'test',
|
||||
'tests',
|
||||
],
|
||||
],
|
||||
},
|
||||
prompt: {
|
||||
questions: {
|
||||
type: {
|
||||
description: "Select the type of change that you're committing",
|
||||
enum: {
|
||||
feat: {
|
||||
description: 'A new feature',
|
||||
title: 'Features',
|
||||
emoji: '✨',
|
||||
},
|
||||
fix: {
|
||||
description: 'A bug fix',
|
||||
title: 'Bug Fixes',
|
||||
emoji: '🐛',
|
||||
},
|
||||
docs: {
|
||||
description: 'Documentation only changes',
|
||||
title: 'Documentation',
|
||||
emoji: '📚',
|
||||
},
|
||||
style: {
|
||||
description:
|
||||
'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
|
||||
title: 'Styles',
|
||||
emoji: '💎',
|
||||
},
|
||||
refactor: {
|
||||
description:
|
||||
'A code change that neither fixes a bug nor adds a feature',
|
||||
title: 'Code Refactoring',
|
||||
emoji: '📦',
|
||||
},
|
||||
perf: {
|
||||
description: 'A code change that improves performance',
|
||||
title: 'Performance Improvements',
|
||||
emoji: '🚀',
|
||||
},
|
||||
test: {
|
||||
description: 'Adding missing tests or correcting existing tests',
|
||||
title: 'Tests',
|
||||
emoji: '🚨',
|
||||
},
|
||||
tests: {
|
||||
description: 'Adding missing tests or correcting existing tests',
|
||||
title: 'Tests',
|
||||
emoji: '🚨',
|
||||
},
|
||||
build: {
|
||||
description:
|
||||
'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)',
|
||||
title: 'Builds',
|
||||
emoji: '🛠',
|
||||
},
|
||||
ci: {
|
||||
description:
|
||||
'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)',
|
||||
title: 'Continuous Integrations',
|
||||
emoji: '⚙️',
|
||||
},
|
||||
chore: {
|
||||
description: "Other changes that don't modify src or test files",
|
||||
title: 'Chores',
|
||||
emoji: '♻️',
|
||||
},
|
||||
revert: {
|
||||
description: 'Reverts a previous commit',
|
||||
title: 'Reverts',
|
||||
emoji: '🗑',
|
||||
},
|
||||
},
|
||||
},
|
||||
scope: {
|
||||
description:
|
||||
'What is the scope of this change (e.g. component or file name)',
|
||||
},
|
||||
subject: {
|
||||
description:
|
||||
'Write a short, imperative tense description of the change',
|
||||
},
|
||||
body: {
|
||||
description: 'Provide a longer description of the change',
|
||||
},
|
||||
isBreaking: {
|
||||
description: 'Are there any breaking changes?',
|
||||
},
|
||||
breakingBody: {
|
||||
description:
|
||||
'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself',
|
||||
},
|
||||
breaking: {
|
||||
description: 'Describe the breaking changes',
|
||||
},
|
||||
isIssueAffected: {
|
||||
description: 'Does this change affect any open issues?',
|
||||
},
|
||||
issuesBody: {
|
||||
description:
|
||||
'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself',
|
||||
},
|
||||
issues: {
|
||||
description: 'Add issue references (e.g. "fix #123", "re #123".)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -6,4 +6,4 @@ updates:
|
||||
schedule:
|
||||
interval: monthly
|
||||
commit-message:
|
||||
prefix: ci
|
||||
prefix: "ci: [citest_skip] "
|
||||
|
||||
@@ -44,10 +44,10 @@ jobs:
|
||||
- name: Install tox, tox-lsr
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.18.1"
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.20.1"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.versions.python }}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Install tox, tox-lsr
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.18.1"
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.20.1"
|
||||
|
||||
- name: Run ansible-plugin-scan
|
||||
run: |
|
||||
|
||||
@@ -47,10 +47,10 @@ jobs:
|
||||
- name: Install tox, tox-lsr
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.18.1"
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.20.1"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.versions.python }}
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install conventional-commit linter
|
||||
run: npm install @commitlint/config-conventional @commitlint/cli
|
||||
- name: Install pr_title_lint.py
|
||||
run: curl -o pr_title_lint.py https://raw.githubusercontent.com/linux-system-roles/auto-maintenance/main/pr_title_lint.py
|
||||
|
||||
- name: Run commitlint on PR title
|
||||
- name: Run pr_title_lint.py on PR title
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
# Echo from env variable to avoid bash errors with extra characters
|
||||
run: echo "$PR_TITLE" | npx commitlint --verbose
|
||||
run: python3 pr_title_lint.py "${PR_TITLE}"
|
||||
|
||||
@@ -30,8 +30,8 @@ jobs:
|
||||
# QEMU
|
||||
- { image: "centos-9", env: "qemu-ansible-core-2-16" }
|
||||
- { image: "centos-10", env: "qemu-ansible-core-2-17" }
|
||||
- { image: "fedora-42", env: "qemu-ansible-core-2-19" }
|
||||
- { image: "fedora-43", env: "qemu-ansible-core-2-20" }
|
||||
- { image: "fedora-44", env: "qemu-ansible-core-2-21" }
|
||||
- { image: "leap-15.6", env: "qemu-ansible-core-2-18" }
|
||||
|
||||
# container
|
||||
@@ -40,10 +40,11 @@ jobs:
|
||||
# broken on non-running dbus
|
||||
# - { image: "centos-10", env: "container-ansible-core-2-17" }
|
||||
- { image: "centos-10-bootc", env: "container-ansible-core-2-17" }
|
||||
- { image: "fedora-42", env: "container-ansible-core-2-17" }
|
||||
- { image: "fedora-43", env: "container-ansible-core-2-20" }
|
||||
- { image: "fedora-42-bootc", env: "container-ansible-core-2-17" }
|
||||
- { image: "fedora-44", env: "container-ansible-core-2-21" }
|
||||
- { image: "fedora-43-bootc", env: "container-ansible-core-2-20" }
|
||||
# ansible-core 2.21 cannot enable services using service module in bootc images
|
||||
- { image: "fedora-44-bootc", env: "container-ansible-core-2-20" }
|
||||
|
||||
env:
|
||||
TOX_ARGS: "--skip-tags tests::infiniband,tests::nvme,tests::scsi"
|
||||
@@ -110,29 +111,24 @@ jobs:
|
||||
python3 -m pip install --upgrade pip
|
||||
sudo apt update
|
||||
sudo apt install -y --no-install-recommends git ansible-core genisoimage qemu-system-x86
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.18.1"
|
||||
pip3 install "git+https://github.com/linux-system-roles/tox-lsr@3.20.1"
|
||||
|
||||
# HACK: Drop this when moving this workflow to 26.04 LTS
|
||||
- name: Update podman to 5.x for compatibility with bootc-image-builder's podman 5
|
||||
if: steps.check_platform.outputs.supported && endsWith(matrix.scenario.image, '-bootc')
|
||||
- name: Check for podman version 5 or higher
|
||||
id: check_podman_version
|
||||
if: steps.check_platform.outputs.supported
|
||||
run: |
|
||||
sed 's/noble/plucky/g' /etc/apt/sources.list.d/ubuntu.sources | sudo tee /etc/apt/sources.list.d/plucky.sources >/dev/null
|
||||
cat <<EOF | sudo tee /etc/apt/preferences.d/podman.pref >/dev/null
|
||||
Package: podman buildah golang-github-containers-common crun libgpgme11t64 libgpg-error0 golang-github-containers-image catatonit conmon containers-storage
|
||||
Pin: release n=plucky
|
||||
Pin-Priority: 991
|
||||
podman_version=$(podman version -f '{{.Client.Version}}')
|
||||
podman_major_version="${podman_version%%.*}"
|
||||
echo "Podman version: $podman_version"
|
||||
if [ "$podman_major_version" -lt 5 ]; then
|
||||
echo "need_podman_update=1" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "need_podman_update=0" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
Package: libsubid4 netavark passt aardvark-dns containernetworking-plugins libslirp0 slirp4netns
|
||||
Pin: release n=plucky
|
||||
Pin-Priority: 991
|
||||
|
||||
Package: *
|
||||
Pin: release n=plucky
|
||||
Pin-Priority: 400
|
||||
EOF
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y podman crun conmon containers-storage
|
||||
- name: Ensure use of podman 5
|
||||
if: steps.check_platform.outputs.supported && steps.check_podman_version.outputs.need_podman_update == 1
|
||||
uses: redhat-actions/podman-install@main
|
||||
|
||||
- name: Configure tox-lsr
|
||||
if: steps.check_platform.outputs.supported
|
||||
|
||||
@@ -72,8 +72,8 @@ jobs:
|
||||
meta_main=meta/main.yml
|
||||
# All Fedora are supported, add latest Fedora versions to supported_platforms
|
||||
if yq '.galaxy_info.galaxy_tags[]' "$meta_main" | grep -qi fedora$; then
|
||||
supported_platforms+=" Fedora-42"
|
||||
supported_platforms+=" Fedora-43"
|
||||
supported_platforms+=" Fedora-44"
|
||||
fi
|
||||
# Specific Fedora versions supported
|
||||
if yq '.galaxy_info.galaxy_tags[]' "$meta_main" | grep -qiP 'fedora\d+$'; then
|
||||
@@ -99,10 +99,10 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
# Ensure ansible version is a string!
|
||||
- platform: Fedora-42
|
||||
ansible_version: "2.19"
|
||||
- platform: Fedora-43
|
||||
ansible_version: "2.20"
|
||||
- platform: Fedora-44
|
||||
ansible_version: "2.21"
|
||||
- platform: CentOS-7-latest
|
||||
ansible_version: "2.9"
|
||||
- platform: CentOS-Stream-8
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
Changelog
|
||||
=========
|
||||
|
||||
[1.4.0] - 2026-08-06
|
||||
--------------------
|
||||
|
||||
### New Features
|
||||
|
||||
- feat: Write roles fingerprints to /var/log/sysroles.jsonl [citest_skip] (#100)
|
||||
|
||||
### Other Changes
|
||||
|
||||
- ci: bump actions/setup-python from 6 to 7 (#98)
|
||||
- ci: ensure dependabot updates do not invoke ci tests [citest_skip] (#99)
|
||||
|
||||
[1.3.2] - 2026-07-27
|
||||
--------------------
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: Better support for check mode (#93)
|
||||
|
||||
### Other Changes
|
||||
|
||||
- ci: use gha checkout v7, codecov v7 [citest_skip] (#90)
|
||||
- ci: Use our own pr_title_lint.py instead of NPM commitlint [citest_skip] (#91)
|
||||
- ci: bump actions/checkout from 6 to 7 (#92)
|
||||
- ci: bump tox-lsr version to 3.20.0 to fix tox 4.58 api breakage [citest_skip] (#94)
|
||||
- ci: Add support for Fedora 44 and drop Fedora 42 - use ansible-core 2.21 [citest_skip] (#96)
|
||||
|
||||
[1.3.1] - 2026-06-24
|
||||
--------------------
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ aide_init: false
|
||||
# Fetch db
|
||||
aide_fetch_db: false
|
||||
|
||||
# Upload db
|
||||
aide_upload_db: false
|
||||
|
||||
# Enable check database phase
|
||||
aide_check: false
|
||||
|
||||
|
||||
+303
-25
@@ -7,29 +7,148 @@ __metaclass__ = type
|
||||
DOCUMENTATION = """
|
||||
---
|
||||
module: sr_fingerprint
|
||||
short_description: Write a message string to syslog using Ansible C(module.log) function.
|
||||
short_description: Write role fingerprint data to syslog and optionally to a JSONL log file
|
||||
description:
|
||||
- Writes the given string to the system log using Ansible C(module.log) function.
|
||||
- Collects role fingerprint data into a canonical record and writes it to
|
||||
syslog using Ansible C(module.log) as C(key=value) pairs.
|
||||
- Optionally appends the same record as a JSON line to a log file
|
||||
(one JSON object per line, JSONL format), by default
|
||||
C(/var/log/sysroles.jsonl).
|
||||
- Playbook variables are not available inside modules automatically. Roles
|
||||
pass C(role_name), C(role_path), C(ansible_play_hosts_all),
|
||||
C(distribution), and C(distribution_version) from the task.
|
||||
- C(ansible_check_mode) is collected from the module execution context.
|
||||
- Intended for role-internal or diagnostic use.
|
||||
author: Rich Megginson (@richm)
|
||||
options:
|
||||
sr_message:
|
||||
description: Text to record in syslog.
|
||||
status:
|
||||
description: Role execution status.
|
||||
type: str
|
||||
required: true
|
||||
choices:
|
||||
- begin
|
||||
- success
|
||||
write_log_file:
|
||||
description: >-
|
||||
If C(true), append fingerprint data to the JSONL log file.
|
||||
Defaults to C(false).
|
||||
type: bool
|
||||
default: false
|
||||
log_file:
|
||||
description: >-
|
||||
Path to the JSONL log file. A lock sidecar (C(<log_file>.lock))
|
||||
is created next to the log file for cross-process safety.
|
||||
type: path
|
||||
default: /var/log/sysroles.jsonl
|
||||
max_log_size:
|
||||
description: >-
|
||||
Maximum log file size in bytes. When appending a new record
|
||||
would exceed this limit, the oldest records are removed first.
|
||||
Set to C(0) to disable trimming.
|
||||
type: int
|
||||
default: 2000000
|
||||
role_name:
|
||||
description: Name of the role, typically C({{ role_name }}).
|
||||
type: str
|
||||
required: true
|
||||
role_path:
|
||||
description: Path to the role, typically C({{ role_path }}).
|
||||
type: path
|
||||
required: true
|
||||
ansible_play_hosts_all:
|
||||
description: >-
|
||||
All hosts in the play, typically C({{ ansible_play_hosts_all }}).
|
||||
Used to derive C(play_hosts_number).
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
distribution:
|
||||
description: >-
|
||||
OS distribution name, typically
|
||||
C({{ ansible_facts["distribution"] }}).
|
||||
type: str
|
||||
default: ""
|
||||
distribution_version:
|
||||
description: >-
|
||||
OS distribution version, typically
|
||||
C({{ ansible_facts["distribution_version"] }}).
|
||||
type: str
|
||||
default: ""
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: Record a fingerprint message in syslog
|
||||
- name: Record role begin fingerprint to syslog only (not log file)
|
||||
sr_fingerprint:
|
||||
sr_message: "system_role:ROLENAME"
|
||||
status: begin
|
||||
role_name: bootloader
|
||||
role_path: "{{ role_path }}"
|
||||
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
|
||||
distribution: "{{ ansible_facts['distribution'] }}"
|
||||
distribution_version: "{{ ansible_facts['distribution_version'] }}"
|
||||
write_log_file: false
|
||||
|
||||
- name: Record role success fingerprint
|
||||
sr_fingerprint:
|
||||
status: success
|
||||
role_name: bootloader
|
||||
role_path: "{{ role_path }}"
|
||||
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
|
||||
distribution: "{{ ansible_facts['distribution'] }}"
|
||||
distribution_version: "{{ ansible_facts['distribution_version'] }}"
|
||||
write_log_file: true
|
||||
"""
|
||||
|
||||
RETURN = r""" # """
|
||||
RETURN = r"""
|
||||
fingerprint:
|
||||
description: The fingerprint record written to syslog and optionally to the log file.
|
||||
returned: always
|
||||
type: dict
|
||||
sample:
|
||||
date: "2026-08-03T10:15:00+02:00"
|
||||
role_name: network
|
||||
role_path: /usr/share/ansible/roles/linux-system-roles.network
|
||||
status: success
|
||||
ansible_version: "2.16.3"
|
||||
managed_node_distro: RedHat-9.4
|
||||
play_hosts_number: 3
|
||||
ansible_check_mode: false
|
||||
message:
|
||||
description: Informational message shown in check mode.
|
||||
returned: check mode
|
||||
type: str
|
||||
sample: "Check mode: message not logged - [date=... role_name=...]"
|
||||
jsonl_row:
|
||||
description: The JSON line that would be appended to the log file.
|
||||
returned: check mode and O(write_log_file=true)
|
||||
type: str
|
||||
log_file:
|
||||
description: Path to the log file that would be written.
|
||||
returned: check mode and O(write_log_file=true)
|
||||
type: str
|
||||
"""
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
import datetime
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
|
||||
FINGERPRINT_FIELDS = (
|
||||
"date",
|
||||
"role_name",
|
||||
"role_path",
|
||||
"status",
|
||||
"ansible_version",
|
||||
"managed_node_distro",
|
||||
"play_hosts_number",
|
||||
"ansible_check_mode",
|
||||
)
|
||||
|
||||
FINGERPRINT_SYSLOG_SEPARATOR = " "
|
||||
|
||||
|
||||
def _local_iso8601_no_microseconds():
|
||||
@@ -51,9 +170,184 @@ def _local_iso8601_no_microseconds():
|
||||
return datetime.datetime.now(utc).astimezone().replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def _ensure_parent_dir(path):
|
||||
parent = os.path.dirname(path)
|
||||
if not parent:
|
||||
return
|
||||
if os.path.isdir(parent):
|
||||
return
|
||||
try:
|
||||
os.makedirs(parent)
|
||||
except OSError as exc:
|
||||
# another process may have created the directory
|
||||
if exc.errno != errno.EEXIST or not os.path.isdir(parent):
|
||||
raise
|
||||
|
||||
|
||||
def _format_fingerprint_jsonl(record):
|
||||
"""Format the canonical fingerprint record as a single JSON line."""
|
||||
return json.dumps(record, separators=(",", ":"), sort_keys=False)
|
||||
|
||||
|
||||
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:
|
||||
# not running as root; keep default ownership
|
||||
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:
|
||||
# already removed or never created
|
||||
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:
|
||||
# file does not exist yet
|
||||
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))
|
||||
with open(log_file, "a") as log_fd:
|
||||
log_fd.write(new_line)
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
|
||||
|
||||
def _get_managed_node_distro(distribution, distribution_version):
|
||||
if distribution and distribution_version:
|
||||
return "%s-%s" % (distribution, distribution_version)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_play_hosts_number(play_hosts_all):
|
||||
return len(play_hosts_all)
|
||||
|
||||
|
||||
def _get_ansible_version(module):
|
||||
version = getattr(module, "ansible_version", None)
|
||||
if version:
|
||||
return version
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_check_mode(module):
|
||||
return bool(getattr(module, "check_mode", False))
|
||||
|
||||
|
||||
def _collect_fingerprint_record(module, status):
|
||||
"""Build the canonical fingerprint record used by all output formatters."""
|
||||
return {
|
||||
"date": _local_iso8601_no_microseconds(),
|
||||
"role_name": module.params["role_name"],
|
||||
"role_path": module.params["role_path"],
|
||||
"status": status,
|
||||
"ansible_version": _get_ansible_version(module),
|
||||
"managed_node_distro": _get_managed_node_distro(
|
||||
module.params["distribution"], module.params["distribution_version"]
|
||||
),
|
||||
"play_hosts_number": _get_play_hosts_number(
|
||||
module.params["ansible_play_hosts_all"]
|
||||
),
|
||||
"ansible_check_mode": _get_check_mode(module),
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint_record_items(record):
|
||||
return [(field, record[field]) for field in FINGERPRINT_FIELDS]
|
||||
|
||||
|
||||
def _format_fingerprint_key_value(field, value):
|
||||
text = "" if value is None else str(value)
|
||||
if any(char in text for char in ' "='):
|
||||
return '%s="%s"' % (field, text.replace('"', '""'))
|
||||
return "%s=%s" % (field, text)
|
||||
|
||||
|
||||
def _format_fingerprint_syslog(record):
|
||||
"""Format the canonical fingerprint record as key=value syslog text."""
|
||||
pairs = [
|
||||
_format_fingerprint_key_value(field, value)
|
||||
for field, value in _fingerprint_record_items(record)
|
||||
]
|
||||
return FINGERPRINT_SYSLOG_SEPARATOR.join(pairs)
|
||||
|
||||
|
||||
def _handle_fingerprint(module):
|
||||
max_log_size = module.params["max_log_size"]
|
||||
if max_log_size < 0:
|
||||
module.fail_json(
|
||||
msg="max_log_size must be 0 or a positive integer, got %d" % max_log_size
|
||||
)
|
||||
|
||||
fingerprint_record = _collect_fingerprint_record(module, module.params["status"])
|
||||
log_message = _format_fingerprint_syslog(fingerprint_record)
|
||||
|
||||
if module.check_mode:
|
||||
result = dict(
|
||||
changed=False,
|
||||
message="Check mode: message not logged - [%s]" % log_message,
|
||||
fingerprint=fingerprint_record,
|
||||
)
|
||||
if module.params["write_log_file"]:
|
||||
result["jsonl_row"] = _format_fingerprint_jsonl(fingerprint_record)
|
||||
result["log_file"] = module.params["log_file"]
|
||||
module.exit_json(**result)
|
||||
|
||||
module.log(log_message)
|
||||
|
||||
if module.params["write_log_file"]:
|
||||
log_file = module.params["log_file"]
|
||||
try:
|
||||
_write_jsonl_log(
|
||||
log_file, fingerprint_record, module.params["max_log_size"]
|
||||
)
|
||||
except (IOError, OSError) as exc:
|
||||
module.fail_json(
|
||||
msg="Failed to write fingerprint log file %s: %s" % (log_file, exc)
|
||||
)
|
||||
|
||||
module.exit_json(changed=False, fingerprint=fingerprint_record)
|
||||
|
||||
|
||||
def run_module():
|
||||
module_args = dict(
|
||||
sr_message=dict(type="str", required=True),
|
||||
status=dict(type="str", required=True, choices=["begin", "success"]),
|
||||
write_log_file=dict(type="bool", default=False),
|
||||
log_file=dict(type="path", default="/var/log/sysroles.jsonl"),
|
||||
max_log_size=dict(type="int", default=2000000),
|
||||
role_name=dict(type="str", required=True),
|
||||
role_path=dict(type="path", required=True),
|
||||
ansible_play_hosts_all=dict(type="list", elements="str", required=True),
|
||||
distribution=dict(type="str", default=""),
|
||||
distribution_version=dict(type="str", default=""),
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
@@ -61,23 +355,7 @@ def run_module():
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
log_message = "%s %s" % (
|
||||
module.params["sr_message"],
|
||||
_local_iso8601_no_microseconds(),
|
||||
)
|
||||
|
||||
if module.check_mode:
|
||||
module.exit_json(
|
||||
changed=False,
|
||||
message="Check mode: message not logged - [%s]" % log_message,
|
||||
)
|
||||
|
||||
module.log(log_message)
|
||||
|
||||
# we don't actually change anything, so we're not changed - writing a log message
|
||||
# is not considered a change
|
||||
# also, we don't want to report changed every time the role runs
|
||||
module.exit_json(changed=False)
|
||||
_handle_fingerprint(module)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
+147
-122
@@ -10,137 +10,162 @@
|
||||
state: present
|
||||
use: "{{ (__aide_is_ostree | d(false)) |
|
||||
ternary('ansible.posix.rhel_rpm_ostree', omit) }}"
|
||||
register: __aide_install_packages
|
||||
|
||||
- name: Get AIDE version
|
||||
ansible.builtin.command:
|
||||
cmd: aide --version
|
||||
register: __aide_version_register
|
||||
changed_when: false
|
||||
|
||||
# assumes the version starts with a digit and goes to the end of the line
|
||||
- name: Set AIDE version
|
||||
set_fact:
|
||||
aide_version: "{{ __output | regex_search('(?m)^A[iI][dD][eE] (\\d.*)$', '\\1') | first }}"
|
||||
vars:
|
||||
__output: "{{ __aide_version_register.stdout if __aide_version_register.stdout | length > 0
|
||||
else __aide_version_register.stderr }}"
|
||||
|
||||
- name: Ensure required services are enabled and started
|
||||
ansible.builtin.service:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
enabled: true
|
||||
loop: "{{ __aide_services }}"
|
||||
|
||||
- name: Generate "/etc/{{ __aide_config }}"
|
||||
ansible.builtin.template:
|
||||
src: "{{ aide_config_template }}"
|
||||
dest: "/etc/{{ __aide_config }}"
|
||||
mode: "0400"
|
||||
when: aide_config_template is not none
|
||||
|
||||
# - name: Print Header
|
||||
# ansible.builtin.command: head /etc/aide.conf || true
|
||||
|
||||
- name: Initialize AIDE database
|
||||
when: aide_init | bool
|
||||
- name: Packages are installed
|
||||
# either run mode or check mode and no changes to packages
|
||||
when: not ansible_check_mode or (ansible_check_mode and not __aide_install_packages.changed)
|
||||
block:
|
||||
- name: Initialize AIDE database
|
||||
- name: Get AIDE version
|
||||
ansible.builtin.command:
|
||||
cmd: aide --init
|
||||
changed_when: true
|
||||
|
||||
- name: Copy AIDE reference database
|
||||
ansible.builtin.copy:
|
||||
remote_src: true
|
||||
src: "{{ __aide_db_new_name }}"
|
||||
dest: "{{ __aide_db_name }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0440"
|
||||
force: true
|
||||
when: not aide_fetch_db | bool
|
||||
|
||||
- name: Remove remote AIDE database file
|
||||
ansible.builtin.file:
|
||||
path: "{{ __aide_db_new_name }}"
|
||||
state: absent
|
||||
when: not aide_fetch_db | bool
|
||||
|
||||
- name: Fetch AIDE database
|
||||
when: aide_fetch_db | bool
|
||||
block:
|
||||
- name: Fetch AIDE database
|
||||
ansible.builtin.fetch:
|
||||
src: "{{ __aide_db_new_name }}"
|
||||
dest: "{{ aide_db_fetch_dir }}"
|
||||
|
||||
- name: Remove remote AIDE database file
|
||||
ansible.builtin.file:
|
||||
path: "{{ __aide_db_new_name }}"
|
||||
state: absent
|
||||
|
||||
- name: Check AIDE integrity
|
||||
when: aide_check | bool
|
||||
block:
|
||||
- name: Copy AIDE reference database
|
||||
ansible.builtin.copy:
|
||||
src: >-
|
||||
{{ aide_db_fetch_dir }}/{{ inventory_hostname }}{{ __aide_db_new_name }}
|
||||
dest: "{{ __aide_db_name }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0440"
|
||||
when: aide_fetch_db | bool
|
||||
|
||||
- name: Check against AIDE reference database
|
||||
ansible.builtin.command:
|
||||
cmd: aide --check
|
||||
cmd: aide --version
|
||||
check_mode: false
|
||||
register: __aide_version_register
|
||||
changed_when: false
|
||||
|
||||
- name: Update AIDE database and fetch it
|
||||
when: aide_update | bool
|
||||
block:
|
||||
- name: Update AIDE database
|
||||
ansible.builtin.command:
|
||||
cmd: aide --update
|
||||
register: __aide_update_result
|
||||
failed_when: __msg not in __aide_update_result.stdout
|
||||
changed_when: true
|
||||
# assumes the version starts with a digit and goes to the end of the line
|
||||
- name: Set AIDE version
|
||||
set_fact:
|
||||
aide_version: "{{ __output | regex_search('(?m)^A[iI][dD][eE] (\\d.*)$', '\\1') | first }}"
|
||||
vars:
|
||||
__msg: >-
|
||||
AIDE found NO differences between database and filesystem. Looks okay!!
|
||||
__output: "{{ __aide_version_register.stdout if __aide_version_register.stdout | length > 0
|
||||
else __aide_version_register.stderr }}"
|
||||
|
||||
- name: Ensure required services are enabled and started
|
||||
ansible.builtin.service:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
enabled: true
|
||||
loop: "{{ __aide_services }}"
|
||||
|
||||
- name: Generate "/etc/{{ __aide_config }}"
|
||||
ansible.builtin.template:
|
||||
src: "{{ aide_config_template }}"
|
||||
dest: "/etc/{{ __aide_config }}"
|
||||
mode: "0400"
|
||||
when: aide_config_template is not none
|
||||
|
||||
# - name: Print Header
|
||||
# ansible.builtin.command: head /etc/aide.conf || true
|
||||
|
||||
- name: Initialize AIDE database
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- aide_init | bool
|
||||
block:
|
||||
- name: Initialize AIDE database
|
||||
ansible.builtin.command:
|
||||
cmd: aide --init
|
||||
changed_when: true
|
||||
|
||||
- name: Copy AIDE reference database
|
||||
ansible.builtin.copy:
|
||||
remote_src: true
|
||||
src: "{{ __aide_db_new_name }}"
|
||||
dest: "{{ __aide_db_name }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0440"
|
||||
force: true
|
||||
when: not aide_fetch_db | bool
|
||||
|
||||
- name: Remove remote AIDE database file
|
||||
ansible.builtin.file:
|
||||
path: "{{ __aide_db_new_name }}"
|
||||
state: absent
|
||||
when: not aide_fetch_db | bool
|
||||
|
||||
- name: Fetch AIDE database
|
||||
ansible.builtin.fetch:
|
||||
src: "{{ __aide_db_new_name }}"
|
||||
dest: "{{ aide_db_fetch_dir }}"
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- aide_fetch_db | bool
|
||||
block:
|
||||
- name: Fetch AIDE database
|
||||
ansible.builtin.fetch:
|
||||
src: "{{ __aide_db_new_name }}"
|
||||
dest: "{{ aide_db_fetch_dir }}"
|
||||
|
||||
- name: Remove remote AIDE database file
|
||||
ansible.builtin.file:
|
||||
path: "{{ __aide_db_new_name }}"
|
||||
- name: Remove remote AIDE database file
|
||||
ansible.builtin.file:
|
||||
path: "{{ __aide_db_new_name }}"
|
||||
state: absent
|
||||
|
||||
- name: Check AIDE integrity
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- aide_check | bool
|
||||
block:
|
||||
- name: Copy AIDE reference database
|
||||
ansible.builtin.copy:
|
||||
src: >-
|
||||
{{ aide_db_fetch_dir }}/{{ inventory_hostname }}{{ __aide_db_new_name }}
|
||||
dest: "{{ __aide_db_name }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0440"
|
||||
when: aide_upload_db | bool
|
||||
|
||||
- name: Check against AIDE reference database
|
||||
ansible.builtin.command:
|
||||
cmd: aide --check
|
||||
register: __aide_check_result
|
||||
changed_when: __aide_check_result.rc > 1 and __aide_check_result.rc <= 7
|
||||
failed_when: __aide_check_result.rc >= 14
|
||||
|
||||
- name: Update AIDE database and fetch it
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- aide_update | bool
|
||||
block:
|
||||
- name: Update AIDE database
|
||||
ansible.builtin.command:
|
||||
cmd: aide --update
|
||||
register: __aide_update_result
|
||||
changed_when: __aide_update_result.rc > 1 and __aide_update_result.rc <= 7
|
||||
failed_when: __aide_update_result.rc >= 14
|
||||
|
||||
- name: Fetch AIDE database
|
||||
ansible.builtin.fetch:
|
||||
src: "{{ __aide_db_new_name }}"
|
||||
dest: "{{ aide_db_fetch_dir }}"
|
||||
|
||||
- name: Remove remote AIDE database file
|
||||
ansible.builtin.file:
|
||||
path: "{{ __aide_db_new_name }}"
|
||||
state: absent
|
||||
|
||||
- name: Update aide check cron configuration if necessary
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/crontab
|
||||
regexp: "^.* root {{ __aide_bin_path }} --check"
|
||||
line: "{{ aide_cron_interval }} root {{ __aide_bin_path }} --check"
|
||||
when:
|
||||
- aide_cron_check is not none
|
||||
- aide_cron_check | bool
|
||||
|
||||
- name: Remove aide check cron configuration if necessary
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/crontab
|
||||
state: absent
|
||||
regexp: "^.* root {{ __aide_bin_path }} --check"
|
||||
when:
|
||||
- aide_cron_check is not none
|
||||
- not aide_cron_check | bool
|
||||
|
||||
- name: Update aide check cron configuration if necessary
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/crontab
|
||||
regexp: "^.* root {{ __aide_bin_path }} --check"
|
||||
line: "{{ aide_cron_interval }} root {{ __aide_bin_path }} --check"
|
||||
when:
|
||||
- aide_cron_check is not none
|
||||
- aide_cron_check | bool
|
||||
- name: Fetch AIDE logs
|
||||
ansible.builtin.fetch:
|
||||
src: "/var/log/aide/aide.log"
|
||||
dest: "{{ aide_db_fetch_dir }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0640"
|
||||
|
||||
- name: Remove aide check cron configuration if necessary
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/crontab
|
||||
state: absent
|
||||
regexp: "^.* root {{ __aide_bin_path }} --check"
|
||||
when:
|
||||
- aide_cron_check is not none
|
||||
- not aide_cron_check | bool
|
||||
|
||||
- name: Record role success fingerprint
|
||||
sr_fingerprint:
|
||||
sr_message: >-
|
||||
success system_role:aide ansible_version={{ ansible_version.full }}
|
||||
{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_version'] }}
|
||||
- name: Record role success fingerprint
|
||||
sr_fingerprint:
|
||||
status: success
|
||||
role_name: aide
|
||||
role_path: "{{ role_path }}"
|
||||
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
|
||||
distribution: "{{ ansible_facts['distribution'] }}"
|
||||
distribution_version: "{{ ansible_facts['distribution_version'] }}"
|
||||
write_log_file: "{{ __aide_write_log_file }}"
|
||||
|
||||
+7
-3
@@ -7,9 +7,13 @@
|
||||
|
||||
- name: Record role begin fingerprint
|
||||
sr_fingerprint:
|
||||
sr_message: >-
|
||||
begin system_role:aide ansible_version={{ ansible_version.full }}
|
||||
{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_version'] }}
|
||||
status: begin
|
||||
role_name: aide
|
||||
role_path: "{{ role_path }}"
|
||||
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
|
||||
distribution: "{{ ansible_facts['distribution'] }}"
|
||||
distribution_version: "{{ ansible_facts['distribution_version'] }}"
|
||||
write_log_file: "{{ __aide_write_log_file }}"
|
||||
|
||||
- name: Determine if system is ostree and set flag
|
||||
when: not __aide_is_ostree is defined
|
||||
|
||||
+36
-6
@@ -15,22 +15,52 @@
|
||||
|
||||
- name: Run the role
|
||||
include_tasks: tasks/run_role_with_clear_facts.yml
|
||||
vars:
|
||||
__aide_write_log_file: true
|
||||
|
||||
# look for the exact module invocation, not some other message that might contain the string
|
||||
- name: Check system journal contains role fingerprints
|
||||
- name: Get fingerprint entries from journal
|
||||
ansible.builtin.shell:
|
||||
executable: /bin/bash
|
||||
cmd: >-
|
||||
set -eo pipefail;
|
||||
journalctl --since "{{ __journal_start_time }}" --no-pager |
|
||||
grep -v " Invoked with" | grep "sr_fingerprint.*begin system_role:aide" ||
|
||||
{ echo ERROR: BEGIN fingerprint not found; exit 1; };
|
||||
journalctl --since "{{ __journal_start_time }}" --no-pager |
|
||||
grep -v " Invoked with" | grep "sr_fingerprint.*success system_role:aide" ||
|
||||
{ echo ERROR: SUCCESS fingerprint not found; exit 1; }
|
||||
grep -v " Invoked with" |
|
||||
grep "sr_fingerprint.*role_name=aide"
|
||||
register: __register_journal_fingerprints
|
||||
changed_when: false
|
||||
when: __register_dev_log.stat.exists
|
||||
|
||||
- name: Check that the log file was written
|
||||
ansible.builtin.slurp:
|
||||
path: /var/log/sysroles.jsonl
|
||||
register: __register_log_file
|
||||
|
||||
- name: Verify log file and journal fingerprints
|
||||
when: __register_dev_log.stat.exists
|
||||
vars:
|
||||
__journal_lines: "{{ __register_journal_fingerprints.stdout_lines }}"
|
||||
__journal_begin: "{{ __journal_lines | select('search', 'status=begin') | list }}"
|
||||
__journal_success: "{{ __journal_lines | select('search', 'status=success') | list }}"
|
||||
__begin_date: "{{ (__journal_begin[0] | regex_search('date=([^ ]+)', '\\1'))[0] }}"
|
||||
__success_date: "{{ (__journal_success[0] | regex_search('date=([^ ]+)', '\\1'))[0] }}"
|
||||
__file_content: "{{ __register_log_file.content | b64decode }}"
|
||||
block:
|
||||
- name: Print contents of logs
|
||||
debug:
|
||||
var: item
|
||||
loop:
|
||||
- "{{ __file_content }}"
|
||||
- "{{ __journal_lines }}"
|
||||
|
||||
- name: Assert content is correct
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- __journal_begin | length > 0
|
||||
- __journal_success | length > 0
|
||||
- __begin_date in __file_content
|
||||
- __success_date in __file_content
|
||||
|
||||
- name: Check if the file exists
|
||||
ansible.builtin.stat:
|
||||
path: /etc/aide.conf
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2026, Red Hat, Inc.
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Unit tests for sr_fingerprint module helpers."""
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import sr_fingerprint
|
||||
|
||||
|
||||
class _ExitJsonException(Exception):
|
||||
def __init__(self, kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
class _FailJsonException(Exception):
|
||||
def __init__(self, kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
class _FakeModule(object):
|
||||
ansible_version = "2.16.3"
|
||||
|
||||
def __init__(self, params=None, check_mode=False):
|
||||
self.params = params or {}
|
||||
self.check_mode = check_mode
|
||||
self.logged = []
|
||||
|
||||
def log(self, msg):
|
||||
self.logged.append(msg)
|
||||
|
||||
def exit_json(self, **kwargs):
|
||||
raise _ExitJsonException(kwargs)
|
||||
|
||||
def fail_json(self, **kwargs):
|
||||
raise _FailJsonException(kwargs)
|
||||
|
||||
|
||||
def _cleanup_log(log_file):
|
||||
for path in (log_file, log_file + ".lock"):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
# file may not exist
|
||||
pass
|
||||
|
||||
|
||||
def _sample_fingerprint_record():
|
||||
return {
|
||||
"date": "2026-06-10T12:00:00+00:00",
|
||||
"role_name": "systemd",
|
||||
"role_path": "/usr/share/ansible/roles/linux-system-roles.systemd",
|
||||
"status": "begin",
|
||||
"ansible_version": "2.16.3",
|
||||
"managed_node_distro": "RedHat-9.4",
|
||||
"play_hosts_number": 3,
|
||||
"ansible_check_mode": False,
|
||||
}
|
||||
|
||||
|
||||
class TestSrFingerprint(unittest.TestCase):
|
||||
def test_fingerprint_fields_match_record_keys(self):
|
||||
record = _sample_fingerprint_record()
|
||||
self.assertEqual(set(sr_fingerprint.FINGERPRINT_FIELDS), set(record.keys()))
|
||||
|
||||
def test_format_fingerprint_syslog(self):
|
||||
record = _sample_fingerprint_record()
|
||||
message = sr_fingerprint._format_fingerprint_syslog(record)
|
||||
self.assertEqual(
|
||||
message,
|
||||
"date=2026-06-10T12:00:00+00:00 role_name=systemd "
|
||||
"role_path=/usr/share/ansible/roles/linux-system-roles.systemd status=begin "
|
||||
"ansible_version=2.16.3 managed_node_distro=RedHat-9.4 "
|
||||
"play_hosts_number=3 ansible_check_mode=False",
|
||||
)
|
||||
for field in sr_fingerprint.FINGERPRINT_FIELDS:
|
||||
self.assertIn("%s=" % field, message)
|
||||
|
||||
def test_format_fingerprint_jsonl(self):
|
||||
record = _sample_fingerprint_record()
|
||||
line = sr_fingerprint._format_fingerprint_jsonl(record)
|
||||
parsed = json.loads(line)
|
||||
self.assertEqual(parsed, record)
|
||||
|
||||
def test_collect_fingerprint_record_from_passed_inputs(self):
|
||||
module = _FakeModule(
|
||||
{
|
||||
"role_name": "systemd",
|
||||
"role_path": "/usr/share/ansible/roles/linux-system-roles.systemd",
|
||||
"ansible_play_hosts_all": ["host1", "host2", "host3"],
|
||||
"distribution": "RedHat",
|
||||
"distribution_version": "9.4",
|
||||
},
|
||||
check_mode=True,
|
||||
)
|
||||
record = sr_fingerprint._collect_fingerprint_record(module, "begin")
|
||||
self.assertEqual(record["role_name"], "systemd")
|
||||
self.assertEqual(
|
||||
record["role_path"], "/usr/share/ansible/roles/linux-system-roles.systemd"
|
||||
)
|
||||
self.assertEqual(record["managed_node_distro"], "RedHat-9.4")
|
||||
self.assertEqual(record["play_hosts_number"], 3)
|
||||
self.assertTrue(record["ansible_check_mode"])
|
||||
self.assertEqual(
|
||||
set(record.keys()),
|
||||
set(sr_fingerprint.FINGERPRINT_FIELDS),
|
||||
)
|
||||
|
||||
def test_get_managed_node_distro_from_params(self):
|
||||
distro = sr_fingerprint._get_managed_node_distro("Fedora", "42")
|
||||
self.assertEqual(distro, "Fedora-42")
|
||||
|
||||
def test_get_managed_node_distro_missing(self):
|
||||
self.assertEqual(sr_fingerprint._get_managed_node_distro("", ""), "unknown")
|
||||
|
||||
def test_get_play_hosts_number(self):
|
||||
self.assertEqual(
|
||||
sr_fingerprint._get_play_hosts_number(["a", "b"]),
|
||||
2,
|
||||
)
|
||||
self.assertEqual(sr_fingerprint._get_play_hosts_number([]), 0)
|
||||
|
||||
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_write_jsonl_log_appends_valid_json_lines(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
|
||||
log_file = tmp.name
|
||||
|
||||
try:
|
||||
record = _sample_fingerprint_record()
|
||||
sr_fingerprint._write_jsonl_log(log_file, record)
|
||||
sr_fingerprint._write_jsonl_log(log_file, record)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
lines = log_fd.read().splitlines()
|
||||
|
||||
self.assertEqual(len(lines), 2)
|
||||
for line in lines:
|
||||
parsed = json.loads(line)
|
||||
self.assertEqual(parsed, record)
|
||||
finally:
|
||||
_cleanup_log(log_file)
|
||||
|
||||
def test_write_jsonl_log_creates_parent_dir(self):
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
log_file = os.path.join(tmpdir, "subdir", "fingerprint.jsonl")
|
||||
|
||||
try:
|
||||
record = _sample_fingerprint_record()
|
||||
sr_fingerprint._write_jsonl_log(log_file, record)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
parsed = json.loads(log_fd.readline())
|
||||
self.assertEqual(parsed["role_name"], "systemd")
|
||||
finally:
|
||||
subdir = os.path.dirname(log_file)
|
||||
for name in os.listdir(subdir):
|
||||
os.unlink(os.path.join(subdir, name))
|
||||
os.rmdir(subdir)
|
||||
os.rmdir(tmpdir)
|
||||
|
||||
def test_write_jsonl_log_preserves_types(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
|
||||
log_file = tmp.name
|
||||
|
||||
try:
|
||||
record = _sample_fingerprint_record()
|
||||
sr_fingerprint._write_jsonl_log(log_file, record)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
parsed = json.loads(log_fd.readline())
|
||||
|
||||
self.assertIsInstance(parsed["play_hosts_number"], int)
|
||||
self.assertIsInstance(parsed["ansible_check_mode"], bool)
|
||||
finally:
|
||||
_cleanup_log(log_file)
|
||||
|
||||
def test_trim_removes_oldest_lines(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
|
||||
log_file = tmp.name
|
||||
|
||||
try:
|
||||
record = _sample_fingerprint_record()
|
||||
sample = dict(record, role_name="role_0")
|
||||
line_size = len(sr_fingerprint._format_fingerprint_jsonl(sample) + "\n")
|
||||
max_size = line_size * 5
|
||||
for _i in range(10):
|
||||
record_copy = dict(record, role_name="role_%d" % _i)
|
||||
sr_fingerprint._write_jsonl_log(
|
||||
log_file, record_copy, max_size=max_size
|
||||
)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
lines = log_fd.read().splitlines()
|
||||
|
||||
self.assertEqual(len(lines), 5)
|
||||
first = json.loads(lines[0])
|
||||
last = json.loads(lines[-1])
|
||||
self.assertEqual(first["role_name"], "role_5")
|
||||
self.assertEqual(last["role_name"], "role_9")
|
||||
finally:
|
||||
_cleanup_log(log_file)
|
||||
|
||||
def test_trim_multiple_lines(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
|
||||
log_file = tmp.name
|
||||
|
||||
try:
|
||||
record = _sample_fingerprint_record()
|
||||
sample = dict(record, role_name="role_0")
|
||||
line_size = len(sr_fingerprint._format_fingerprint_jsonl(sample) + "\n")
|
||||
# Log contains more than 3 lines.
|
||||
n_initial = 4
|
||||
max_size = line_size * n_initial
|
||||
for _i in range(n_initial):
|
||||
sr_fingerprint._write_jsonl_log(
|
||||
log_file,
|
||||
dict(record, role_name="role_%d" % _i),
|
||||
max_size=max_size,
|
||||
)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
initial_lines = log_fd.read().splitlines()
|
||||
self.assertEqual(len(initial_lines), n_initial)
|
||||
|
||||
# New record larger than one existing line (up to two) via a very
|
||||
# long role_path, so trim removes exactly two oldest records.
|
||||
base_record = dict(record, role_name="role_long", role_path="")
|
||||
base_size = len(
|
||||
sr_fingerprint._format_fingerprint_jsonl(base_record) + "\n"
|
||||
)
|
||||
path_len = 2 * line_size - base_size
|
||||
self.assertGreater(path_len, 0)
|
||||
long_path = "x" * path_len
|
||||
long_record = dict(record, role_name="role_long", role_path=long_path)
|
||||
new_line = sr_fingerprint._format_fingerprint_jsonl(long_record) + "\n"
|
||||
self.assertGreater(len(new_line), line_size)
|
||||
self.assertLessEqual(len(new_line), 2 * line_size)
|
||||
|
||||
sr_fingerprint._write_jsonl_log(log_file, long_record, max_size=max_size)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
lines = log_fd.read().splitlines()
|
||||
|
||||
# Exactly two oldest records removed; new record appended.
|
||||
self.assertEqual(len(lines), n_initial - 2 + 1)
|
||||
parsed = [json.loads(line) for line in lines]
|
||||
role_names = [entry["role_name"] for entry in parsed]
|
||||
self.assertEqual(role_names, ["role_2", "role_3", "role_long"])
|
||||
self.assertEqual(parsed[-1], long_record)
|
||||
self.assertEqual(parsed[-1]["role_path"], long_path)
|
||||
self.assertNotIn("role_0", role_names)
|
||||
self.assertNotIn("role_1", role_names)
|
||||
finally:
|
||||
_cleanup_log(log_file)
|
||||
|
||||
def test_trim_disabled_when_zero(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
|
||||
log_file = tmp.name
|
||||
|
||||
try:
|
||||
record = _sample_fingerprint_record()
|
||||
for _i in range(20):
|
||||
sr_fingerprint._write_jsonl_log(log_file, record, max_size=0)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
lines = log_fd.read().splitlines()
|
||||
|
||||
self.assertEqual(len(lines), 20)
|
||||
finally:
|
||||
_cleanup_log(log_file)
|
||||
|
||||
def test_trim_no_op_when_under_limit(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jsonl") as tmp:
|
||||
log_file = tmp.name
|
||||
|
||||
try:
|
||||
record = _sample_fingerprint_record()
|
||||
for _i in range(3):
|
||||
sr_fingerprint._write_jsonl_log(log_file, record, max_size=2000000)
|
||||
|
||||
with open(log_file, "r") as log_fd:
|
||||
lines = log_fd.read().splitlines()
|
||||
|
||||
self.assertEqual(len(lines), 3)
|
||||
finally:
|
||||
_cleanup_log(log_file)
|
||||
|
||||
def test_handle_fingerprint_check_mode_without_log_file(self):
|
||||
module = _FakeModule(
|
||||
{
|
||||
"status": "begin",
|
||||
"write_log_file": False,
|
||||
"max_log_size": 2000000,
|
||||
"role_name": "systemd",
|
||||
"role_path": "/usr/share/ansible/roles/linux-system-roles.systemd",
|
||||
"ansible_play_hosts_all": ["host1"],
|
||||
"distribution": "RedHat",
|
||||
"distribution_version": "9.4",
|
||||
},
|
||||
check_mode=True,
|
||||
)
|
||||
with self.assertRaises(_ExitJsonException) as ctx:
|
||||
sr_fingerprint._handle_fingerprint(module)
|
||||
result = ctx.exception.kwargs
|
||||
self.assertFalse(result["changed"])
|
||||
self.assertIn("Check mode", result["message"])
|
||||
self.assertIn("fingerprint", result)
|
||||
self.assertNotIn("jsonl_row", result)
|
||||
|
||||
def test_handle_fingerprint_check_mode_with_log_file(self):
|
||||
log_path = os.path.join(tempfile.gettempdir(), "test_sr_fingerprint.jsonl")
|
||||
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"],
|
||||
"distribution": "RedHat",
|
||||
"distribution_version": "9.4",
|
||||
},
|
||||
check_mode=True,
|
||||
)
|
||||
with self.assertRaises(_ExitJsonException) as ctx:
|
||||
sr_fingerprint._handle_fingerprint(module)
|
||||
result = ctx.exception.kwargs
|
||||
self.assertIn("jsonl_row", result)
|
||||
self.assertEqual(result["log_file"], log_path)
|
||||
parsed = json.loads(result["jsonl_row"])
|
||||
self.assertEqual(parsed["role_name"], "systemd")
|
||||
|
||||
def test_handle_fingerprint_write_failure_calls_fail_json(self):
|
||||
log_path = os.path.join(tempfile.gettempdir(), "test_write_fail.jsonl")
|
||||
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"],
|
||||
"distribution": "RedHat",
|
||||
"distribution_version": "9.4",
|
||||
},
|
||||
check_mode=False,
|
||||
)
|
||||
original = sr_fingerprint._write_jsonl_log
|
||||
|
||||
def _raise_ioerror(*args, **kwargs):
|
||||
raise IOError("disk full")
|
||||
|
||||
sr_fingerprint._write_jsonl_log = _raise_ioerror
|
||||
try:
|
||||
with self.assertRaises(_FailJsonException) as ctx:
|
||||
sr_fingerprint._handle_fingerprint(module)
|
||||
self.assertIn(
|
||||
"Failed to write fingerprint log file", ctx.exception.kwargs["msg"]
|
||||
)
|
||||
finally:
|
||||
sr_fingerprint._write_jsonl_log = original
|
||||
|
||||
def test_handle_fingerprint_rejects_negative_max_log_size(self):
|
||||
module = _FakeModule(
|
||||
{
|
||||
"status": "begin",
|
||||
"write_log_file": False,
|
||||
"max_log_size": -1,
|
||||
"role_name": "systemd",
|
||||
"role_path": "/usr/share/ansible/roles/linux-system-roles.systemd",
|
||||
"ansible_play_hosts_all": ["host1"],
|
||||
"distribution": "RedHat",
|
||||
"distribution_version": "9.4",
|
||||
},
|
||||
check_mode=False,
|
||||
)
|
||||
with self.assertRaises(_FailJsonException) as ctx:
|
||||
sr_fingerprint._handle_fingerprint(module)
|
||||
self.assertIn(
|
||||
"max_log_size must be 0 or a positive integer",
|
||||
ctx.exception.kwargs["msg"],
|
||||
)
|
||||
|
||||
def test_local_iso8601_no_microseconds_has_no_fraction(self):
|
||||
timestamp = sr_fingerprint._local_iso8601_no_microseconds()
|
||||
match = re.match(
|
||||
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:?\d{2}$", timestamp
|
||||
)
|
||||
self.assertIsNotNone(match)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -23,6 +23,8 @@ __aide_required_facts:
|
||||
__aide_required_facts_subsets: "{{ ['!all', '!min'] +
|
||||
__aide_required_facts }}"
|
||||
|
||||
__aide_write_log_file: false
|
||||
|
||||
# BEGIN - DO NOT EDIT THIS BLOCK - rh distros variables
|
||||
# Ansible distribution identifiers that the role treats like RHEL
|
||||
__aide_rh_distros:
|
||||
|
||||
Reference in New Issue
Block a user