├── .ansible-lint ├── .editorconfig ├── .github ├── auto-merge.yml ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── galaxy.yml │ ├── main.yml │ └── release-drafter.yml ├── .gitignore ├── .yamllint ├── README.md ├── callback_plugins └── actionable.py ├── defaults └── main.yml ├── meta └── main.yml ├── molecule └── default │ ├── INSTALL.rst │ ├── converge.yml │ ├── molecule.yml │ ├── roles │ └── ansible-drift │ └── verify.yml ├── tasks └── main.yml └── templates ├── ansible-drift.j2 └── systemd ├── ansible-drift.service.j2 └── ansible-drift.timer.j2 /.ansible-lint: -------------------------------------------------------------------------------- 1 | --- 2 | exclude_paths: 3 | - molecule/ 4 | - .github/ 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{yml,yaml}] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/auto-merge.yml: -------------------------------------------------------------------------------- 1 | - match: 2 | dependency_type: all 3 | update_type: "semver:minor" 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | time: "09:00" 9 | timezone: "Europe/Berlin" 10 | ignore: 11 | - dependency-name: "*" 12 | update-types: ["version-update:semver-patch"] 13 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: '$RESOLVED_VERSION' 3 | tag-template: '$RESOLVED_VERSION' 4 | categories: 5 | - title: '🚀 Features' 6 | labels: 7 | - 'feature' 8 | - 'enhancement' 9 | - title: '🐛 Bug Fixes' 10 | labels: 11 | - 'fix' 12 | - 'bugfix' 13 | - 'bug' 14 | - title: '🧹 Maintenance' 15 | labels: 16 | - 'chore' 17 | - 'dependencies' 18 | version-resolver: 19 | major: 20 | labels: 21 | - 'feature' 22 | minor: 23 | labels: 24 | - 'enhancement' 25 | patch: 26 | labels: 27 | - 'fix' 28 | - 'bugfix' 29 | - 'bug' 30 | - 'chore' 31 | - 'dependencies' 32 | default: patch 33 | template: | 34 | ## Changes 35 | 36 | $CHANGES 37 | 38 | **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...$RESOLVED_VERSION 39 | -------------------------------------------------------------------------------- /.github/workflows/galaxy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ansible Galaxy 3 | on: 4 | push: 5 | branches: 6 | - main 7 | release: 8 | 9 | jobs: 10 | galaxy: 11 | name: Ansible Galaxy 12 | uses: systemli/github-ansible-workflow/.github/workflows/ansible-galaxy-workflow.yaml@main 13 | secrets: 14 | galaxy-token: ${{ secrets.galaxy_api_key }} 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Integration 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - ".*" 10 | - ".github/**" 11 | - "README.md" 12 | 13 | jobs: 14 | integration: 15 | name: Integration 16 | uses: systemli/github-ansible-workflow/.github/workflows/ansible-integration-workflow.yaml@v1.2.0 17 | with: 18 | distros: '[ "debian12" ]' 19 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | release: 11 | name: Update Release 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - name: Publish Release 15 | uses: release-drafter/release-drafter@v6 16 | with: 17 | publish: false 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | callback_plugins/actionable.pyc 2 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | # Based on ansible-lint config 3 | extends: default 4 | 5 | rules: 6 | braces: 7 | max-spaces-inside: 1 8 | level: error 9 | brackets: 10 | max-spaces-inside: 1 11 | level: error 12 | colons: 13 | max-spaces-after: -1 14 | level: error 15 | commas: 16 | max-spaces-after: -1 17 | level: error 18 | comments: disable 19 | comments-indentation: disable 20 | document-start: disable 21 | empty-lines: 22 | max: 3 23 | level: error 24 | hyphens: 25 | level: error 26 | indentation: disable 27 | key-duplicates: enable 28 | line-length: disable 29 | new-line-at-end-of-file: disable 30 | new-lines: 31 | type: unix 32 | trailing-spaces: disable 33 | truthy: disable 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ansible-drift 2 | 3 | [![Build Status](https://github.com/systemli/ansible-drift/workflows/Integration/badge.svg?branch=main)](https://github.com/systemli/ansible-drift/actions?query=workflow%3AIntegration) 4 | [![Ansible Galaxy](http://img.shields.io/badge/ansible--galaxy-drift-blue.svg)](https://galaxy.ansible.com/systemli/drift) 5 | 6 | ansible-drift will send mails showing your configration drift from a specified playbook. 7 | The script can be run interactively or via cron and update your git repo if necessary. 8 | Each host in hostlist will be checked separately. 9 | 10 | ## Role Variables 11 | 12 | ``` 13 | # a bash compatible list of hosts you want to check 14 | drift_hostlist: "{{ groups['all']|sort|join(' ') }}" 15 | 16 | # send mails to 17 | drift_receiver: root 18 | 19 | # run as 20 | drift_user: ansible 21 | 22 | # define playbook to be regularly executed 23 | # drift_playbook: 24 | 25 | # define a git branch to pull 26 | # drift_branch: "origin main" 27 | drift_branch: "" 28 | 29 | ``` 30 | 31 | ## Download 32 | 33 | Download latest release with `ansible-galaxy` 34 | 35 | ``` 36 | $ ansible-galaxy install systemli.drift 37 | ``` 38 | 39 | ## Example Playbook 40 | 41 | ``` 42 | - hosts: servers 43 | roles: 44 | - systemli.drift 45 | vars: 46 | drift_playbook: /home/ansible/ansible/site.yml 47 | ``` 48 | 49 | Testing & Development 50 | --------------------- 51 | 52 | Tests 53 | ----- 54 | 55 | For developing and testing the role we use Github Actions, Molecule, and Vagrant. On the local environment you can easily test the role with 56 | 57 | Run local tests with: 58 | 59 | ``` 60 | molecule test 61 | ``` 62 | 63 | Requires Molecule, Vagrant and `python-vagrant` to be installed.For developing and testing the role we use Travis CI, Molecule and Vagrant. On the local environment you can easily test the role with 64 | 65 | 66 | 67 | ## License 68 | 69 | GPL 70 | 71 | ## Author Information 72 | 73 | https://www.systemli.org 74 | -------------------------------------------------------------------------------- /callback_plugins/actionable.py: -------------------------------------------------------------------------------- 1 | # (c) 2012-2014, Michael DeHaan 2 | # (c) 2017 Ansible Project 3 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 4 | 5 | from __future__ import (absolute_import, division, print_function) 6 | __metaclass__ = type 7 | 8 | DOCUMENTATION = ''' 9 | name: actionable 10 | type: stdout 11 | short_description: actionable Ansible screen output 12 | version_added: historical 13 | description: 14 | - This is the actionable output callback for ansible-playbook. 15 | extends_documentation_fragment: 16 | - default_callback 17 | - result_format_callback 18 | requirements: 19 | - set as stdout in configuration 20 | ''' 21 | 22 | 23 | from ansible import constants as C 24 | from ansible import context 25 | from ansible.playbook.task_include import TaskInclude 26 | from ansible.plugins.callback import CallbackBase 27 | from ansible.utils.color import colorize, hostcolor 28 | from ansible.utils.fqcn import add_internal_fqcns 29 | 30 | 31 | class CallbackModule(CallbackBase): 32 | 33 | ''' 34 | This is the actionable callback interface, which simply prints actionable 35 | messages to stdout when new callback events are received. 36 | ''' 37 | 38 | CALLBACK_VERSION = 2.0 39 | CALLBACK_TYPE = 'stdout' 40 | CALLBACK_NAME = 'actionable' 41 | 42 | def __init__(self): 43 | 44 | self._play = None 45 | self._last_task_banner = None 46 | self._last_task_name = None 47 | self._task_type_cache = {} 48 | super(CallbackModule, self).__init__() 49 | 50 | def v2_runner_on_failed(self, result, ignore_errors=False): 51 | 52 | host_label = self.host_label(result) 53 | self._clean_results(result._result, result._task.action) 54 | 55 | if self._last_task_banner != result._task._uuid: 56 | self._print_task_banner(result._task) 57 | 58 | self._handle_exception(result._result, use_stderr=self.get_option('display_failed_stderr')) 59 | self._handle_warnings(result._result) 60 | 61 | if result._task.loop and 'results' in result._result: 62 | self._process_items(result) 63 | 64 | else: 65 | if self._display.verbosity < 2 and self.get_option('show_task_path_on_failure'): 66 | self._print_task_path(result._task) 67 | msg = "fatal: [%s]: FAILED! => %s" % (host_label, self._dump_results(result._result)) 68 | self._display.display(msg, color=C.COLOR_ERROR, stderr=self.get_option('display_failed_stderr')) 69 | 70 | if ignore_errors: 71 | self._display.display("...ignoring", color=C.COLOR_SKIP) 72 | 73 | def v2_runner_on_ok(self, result): 74 | 75 | host_label = self.host_label(result) 76 | 77 | if result._result.get('changed', False): 78 | if self._last_task_banner != result._task._uuid: 79 | self._print_task_banner(result._task) 80 | 81 | msg = "changed: [%s]" % (host_label,) 82 | color = C.COLOR_CHANGED 83 | 84 | else: 85 | if not self.get_option('display_ok_hosts'): 86 | return 87 | 88 | if self._last_task_banner != result._task._uuid: 89 | self._print_task_banner(result._task) 90 | 91 | msg = "ok: [%s]" % (host_label,) 92 | color = C.COLOR_OK 93 | 94 | self._handle_warnings(result._result) 95 | 96 | if result._task.loop and 'results' in result._result: 97 | self._process_items(result) 98 | else: 99 | self._clean_results(result._result, result._task.action) 100 | 101 | if self._run_is_verbose(result): 102 | msg += " => %s" % (self._dump_results(result._result),) 103 | self._display.display(msg, color=color) 104 | 105 | def v2_runner_on_skipped(self, result): 106 | pass 107 | 108 | def v2_runner_on_unreachable(self, result): 109 | if self._last_task_banner != result._task._uuid: 110 | self._print_task_banner(result._task) 111 | 112 | host_label = self.host_label(result) 113 | msg = "fatal: [%s]: UNREACHABLE! => %s" % (host_label, self._dump_results(result._result)) 114 | self._display.display(msg, color=C.COLOR_UNREACHABLE, stderr=self.get_option('display_failed_stderr')) 115 | 116 | if result._task.ignore_unreachable: 117 | self._display.display("...ignoring", color=C.COLOR_SKIP) 118 | 119 | def v2_playbook_on_no_hosts_matched(self): 120 | pass 121 | 122 | def v2_playbook_on_no_hosts_remaining(self): 123 | pass 124 | 125 | def v2_playbook_on_task_start(self, task, is_conditional): 126 | pass 127 | 128 | def _task_start(self, task, prefix=None): 129 | # Cache output prefix for task if provided 130 | # This is needed to properly display 'RUNNING HANDLER' and similar 131 | # when hiding skipped/ok task results 132 | if prefix is not None: 133 | self._task_type_cache[task._uuid] = prefix 134 | 135 | self._last_task_name = task.get_name().strip() 136 | 137 | # Display the task banner immediately if we're not doing any filtering based on task result 138 | if self.get_option('display_skipped_hosts') and self.get_option('display_ok_hosts'): 139 | self._print_task_banner(task) 140 | 141 | def _print_task_banner(self, task): 142 | # args can be specified as no_log in several places: in the task or in 143 | # the argument spec. We can check whether the task is no_log but the 144 | # argument spec can't be because that is only run on the target 145 | # machine and we haven't run it thereyet at this time. 146 | # 147 | # So we give people a config option to affect display of the args so 148 | # that they can secure this if they feel that their stdout is insecure 149 | # (shoulder surfing, logging stdout straight to a file, etc). 150 | args = '' 151 | if not task.no_log and C.DISPLAY_ARGS_TO_STDOUT: 152 | args = u', '.join(u'%s=%s' % a for a in task.args.items()) 153 | args = u' %s' % args 154 | 155 | prefix = self._task_type_cache.get(task._uuid, 'TASK') 156 | 157 | # Use cached task name 158 | task_name = self._last_task_name 159 | if task_name is None: 160 | task_name = task.get_name().strip() 161 | 162 | checkmsg = "" 163 | 164 | self._display.banner(u"%s [%s%s]%s" % (prefix, task_name, args, checkmsg)) 165 | 166 | self._last_task_banner = task._uuid 167 | 168 | def v2_playbook_on_cleanup_task_start(self, task): 169 | pass 170 | 171 | def v2_playbook_on_handler_task_start(self, task): 172 | pass 173 | 174 | def v2_runner_on_start(self, host, task): 175 | pass 176 | 177 | def v2_playbook_on_play_start(self, play): 178 | pass 179 | 180 | def v2_on_file_diff(self, result): 181 | if result._task.loop and 'results' in result._result: 182 | for res in result._result['results']: 183 | if 'diff' in res and res['diff'] and res.get('changed', False): 184 | diff = self._get_diff(res['diff']) 185 | if diff: 186 | if self._last_task_banner != result._task._uuid: 187 | self._print_task_banner(result._task) 188 | self._display.display(diff) 189 | elif 'diff' in result._result and result._result['diff'] and result._result.get('changed', False): 190 | diff = self._get_diff(result._result['diff']) 191 | if diff: 192 | if self._last_task_banner != result._task._uuid: 193 | self._print_task_banner(result._task) 194 | self._display.display(diff) 195 | 196 | def v2_runner_item_on_ok(self, result): 197 | 198 | host_label = self.host_label(result) 199 | if isinstance(result._task, TaskInclude): 200 | return 201 | elif result._result.get('changed', False): 202 | if self._last_task_banner != result._task._uuid: 203 | self._print_task_banner(result._task) 204 | 205 | msg = 'changed' 206 | color = C.COLOR_CHANGED 207 | else: 208 | if not self.get_option('display_ok_hosts'): 209 | return 210 | 211 | if self._last_task_banner != result._task._uuid: 212 | self._print_task_banner(result._task) 213 | 214 | msg = 'ok' 215 | color = C.COLOR_OK 216 | 217 | msg = "%s: [%s] => (item=%s)" % (msg, host_label, self._get_item_label(result._result)) 218 | self._clean_results(result._result, result._task.action) 219 | self._display.display(msg, color=color) 220 | 221 | def v2_runner_item_on_failed(self, result): 222 | if self._last_task_banner != result._task._uuid: 223 | self._print_task_banner(result._task) 224 | 225 | host_label = self.host_label(result) 226 | self._clean_results(result._result, result._task.action) 227 | self._handle_exception(result._result, use_stderr=self.get_option('display_failed_stderr')) 228 | 229 | msg = "failed: [%s]" % (host_label,) 230 | self._handle_warnings(result._result) 231 | self._display.display( 232 | msg + " (item=%s) => %s" % (self._get_item_label(result._result), self._dump_results(result._result)), 233 | color=C.COLOR_ERROR, 234 | stderr=self.get_option('display_failed_stderr') 235 | ) 236 | 237 | def v2_runner_item_on_skipped(self, result): 238 | pass 239 | 240 | def v2_playbook_on_include(self, included_file): 241 | pass 242 | 243 | def v2_playbook_on_stats(self, stats): 244 | pass 245 | 246 | def v2_playbook_on_start(self, playbook): 247 | pass 248 | 249 | def v2_runner_retry(self, result): 250 | task_name = result.task_name or result._task 251 | host_label = self.host_label(result) 252 | msg = "FAILED - RETRYING: [%s]: %s (%d retries left)." % (host_label, task_name, result._result['retries'] - result._result['attempts']) 253 | if self._run_is_verbose(result, verbosity=2): 254 | msg += "Result was: %s" % self._dump_results(result._result) 255 | self._display.display(msg, color=C.COLOR_DEBUG) 256 | 257 | def v2_runner_on_async_poll(self, result): 258 | pass 259 | 260 | def v2_runner_on_async_ok(self, result): 261 | pass 262 | 263 | def v2_runner_on_async_failed(self, result): 264 | host = result._host.get_name() 265 | 266 | # Attempt to get the async job ID. If the job does not finish before the 267 | # async timeout value, the ID may be within the unparsed 'async_result' dict. 268 | jid = result._result.get('ansible_job_id') 269 | if not jid and 'async_result' in result._result: 270 | jid = result._result['async_result'].get('ansible_job_id') 271 | self._display.display("ASYNC FAILED on %s: jid=%s" % (host, jid), color=C.COLOR_DEBUG) 272 | 273 | def v2_playbook_on_notify(self, handler, host): 274 | pass 275 | -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # defaults file for drift 3 | 4 | # a bash compatible list of hosts you want to check 5 | drift_hostlist: "\n {{ groups['all']|sort|join('\n ') }}\n" 6 | 7 | # send mails to 8 | drift_receiver: root 9 | 10 | # run as 11 | drift_user: ansible 12 | 13 | # define playbook to be regularly executed 14 | # drift_playbook: 15 | 16 | # define a git branch to pull 17 | # drift_branch: "origin master" 18 | drift_branch: "" 19 | 20 | # exlucded tags as a comma separated list drift_excluded_tags: "first_tag,second_tag" 21 | drift_excluded_tags: "" 22 | -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | galaxy_info: 3 | role_name: drift 4 | author: systemli 5 | description: Create mails showing your configuration drift from an ansible playbook 6 | company: systemli.org 7 | license: GPLv3 8 | min_ansible_version: "2.14" 9 | galaxy_tags: 10 | - ansible 11 | - configuration 12 | - drift 13 | platforms: 14 | - name: Debian 15 | versions: 16 | - bookworm 17 | dependencies: [] 18 | -------------------------------------------------------------------------------- /molecule/default/INSTALL.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | Vagrant driver installation guide 3 | ******* 4 | 5 | Requirements 6 | ============ 7 | 8 | * Vagrant 9 | * Virtualbox, Parallels, VMware Fusion, VMware Workstation or VMware Desktop 10 | 11 | Install 12 | ======= 13 | 14 | Please refer to the `Virtual environment`_ documentation for installation best 15 | practices. If not using a virtual environment, please consider passing the 16 | widely recommended `'--user' flag`_ when invoking ``pip``. 17 | 18 | .. _Virtual environment: https://virtualenv.pypa.io/en/latest/ 19 | .. _'--user' flag: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site 20 | 21 | .. code-block:: bash 22 | 23 | $ pip install 'molecule[vagrant]' 24 | -------------------------------------------------------------------------------- /molecule/default/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | become: true 5 | roles: 6 | - role: ansible-drift 7 | -------------------------------------------------------------------------------- /molecule/default/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | driver: 5 | name: vagrant 6 | provider: 7 | name: virtualbox 8 | lint: | 9 | set -e 10 | yamllint . 11 | ansible-lint 12 | platforms: 13 | - name: instance 14 | box: debian/bookworm64 15 | provisioner: 16 | become: true 17 | -------------------------------------------------------------------------------- /molecule/default/roles/ansible-drift: -------------------------------------------------------------------------------- 1 | ../../.. -------------------------------------------------------------------------------- /molecule/default/verify.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This is an example playbook to execute Ansible tests. 3 | 4 | - name: Verify 5 | hosts: all 6 | gather_facts: false 7 | tasks: 8 | - name: Example assertion 9 | assert: 10 | that: true 11 | -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Copy ansible-drift 3 | ansible.builtin.template: 4 | src: ansible-drift.j2 5 | dest: /usr/local/bin/ansible-drift 6 | owner: root 7 | group: root 8 | mode: "0755" 9 | 10 | - name: Ensure user is present 11 | ansible.builtin.user: 12 | name: "{{ drift_user }}" 13 | 14 | - name: Make sure we have .ssh dir 15 | ansible.builtin.file: 16 | path: /home/{{ drift_user }}/.ssh 17 | state: directory 18 | owner: "{{ drift_user }}" 19 | group: "{{ drift_user }}" 20 | mode: "0700" 21 | 22 | - name: Make sure we have ssh private key 23 | ansible.builtin.copy: 24 | content: "{{ drift_ssh_private_key }}" 25 | dest: /home/{{ drift_user }}/.ssh/id_rsa 26 | owner: "{{ drift_user }}" 27 | group: "{{ drift_user }}" 28 | mode: "0600" 29 | when: drift_ssh_private_key|d() 30 | 31 | - name: Make sure we have ssh public key 32 | ansible.builtin.copy: 33 | content: "{{ drift_ssh_public_key }}\n" 34 | dest: /home/{{ drift_user }}/.ssh/id_rsa.pub 35 | owner: "{{ drift_user }}" 36 | group: "{{ drift_user }}" 37 | mode: "0644" 38 | when: drift_ssh_private_key|d() 39 | 40 | - name: Remove cron job 41 | ansible.builtin.file: 42 | path: /etc/cron.d/ansible-drift 43 | state: absent 44 | 45 | - name: Copy systemd service and timer configs 46 | ansible.builtin.template: 47 | dest: "/etc/systemd/system/{{ item }}" 48 | mode: "0644" 49 | src: "systemd/{{ item }}.j2" 50 | loop: 51 | - ansible-drift.service 52 | - ansible-drift.timer 53 | when: drift_playbook|d() 54 | 55 | - name: Enable systemd timer 56 | ansible.builtin.systemd_service: 57 | daemon_reload: true 58 | enabled: true 59 | name: ansible-drift.timer 60 | state: started 61 | when: 62 | - drift_playbook|d() 63 | - not ansible_check_mode 64 | 65 | - name: Make sure we have .gnupg dir 66 | ansible.builtin.file: 67 | path: /home/{{ drift_user }}/.gnupg 68 | state: directory 69 | owner: "{{ drift_user }}" 70 | group: "{{ drift_user }}" 71 | mode: "0700" 72 | 73 | - name: Cache gpg credentials for long time 74 | ansible.builtin.copy: 75 | content: | 76 | # cache credentials 400 days 77 | default-cache-ttl 34560000 78 | max-cache-ttl 34560000 79 | dest: /home/{{ drift_user }}/.gnupg/gpg-agent.conf 80 | owner: "{{ drift_user }}" 81 | group: "{{ drift_user }}" 82 | mode: "0644" 83 | 84 | - name: Don't kill gpg-agent after logout 85 | ansible.builtin.command: loginctl enable-linger {{ drift_user }} 86 | args: 87 | creates: /var/lib/systemd/linger/{{ drift_user }} 88 | tags: 89 | - molecule-notest 90 | -------------------------------------------------------------------------------- /templates/ansible-drift.j2: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # set shell variables 4 | hostlist="{{ drift_hostlist }}" 5 | receiver="{{ drift_receiver }}" 6 | branch="{{ drift_branch }}" 7 | message="has drifted away" 8 | excluded_tags={{ drift_excluded_tags }} 9 | 10 | # set environment variable 11 | export ANSIBLE_DEPRECATION_WARNINGS=no 12 | export ANSIBLE_COMMAND_WARNINGS=no 13 | export ANSIBLE_DISPLAY_OK_HOSTS=no 14 | export ANSIBLE_DISPLAY_SKIPPED_HOSTS=no 15 | export ANSIBLE_STDOUT_CALLBACK=actionable 16 | 17 | # check argument and construct vars 18 | if [ $# -ne 1 ]; then 19 | echo "usage: ansible-drift playbook.yml" 20 | exit 1 21 | fi 22 | 23 | dir="$(dirname $1)" 24 | playbook="$(basename $1)" 25 | 26 | cd "$dir" || exit 1 27 | 28 | # make sure we're on the latest commit 29 | if [ -n "$branch" ]; then 30 | git pull --ff-only $branch 31 | git submodule update --init --recursive 32 | 33 | if [ -f requirements.yml ]; then 34 | ansible-galaxy install --ignore-errors -r requirements.yml --force 35 | fi 36 | 37 | message="$message from $branch" 38 | fi 39 | 40 | # test every host individually 41 | # if there is drift send it via mail 42 | 43 | for h in $hostlist; do 44 | body="$(ansible-playbook --flush-cache $playbook --limit $h --check --diff{% if drift_excluded_tags|length > 0 %} --skip-tags $excluded_tags{% endif %})" 45 | if [ -n "$body" ]; then 46 | echo "$body" | mail -s "[ansible-drift] $h $message" $receiver 47 | fi 48 | done 49 | -------------------------------------------------------------------------------- /templates/systemd/ansible-drift.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | After=network-online.target 3 | Description=ansible-drift 4 | Wants=network-online.target 5 | 6 | [Service] 7 | ExecStart=/usr/local/bin/ansible-drift {{ drift_playbook }} 8 | Type=simple 9 | User={{ drift_user }} 10 | -------------------------------------------------------------------------------- /templates/systemd/ansible-drift.timer.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ansible-drift 3 | 4 | [Timer] 5 | OnCalendar=*-*-* 05:00:00 6 | Persistent=True 7 | 8 | [Install] 9 | WantedBy=timers.target 10 | --------------------------------------------------------------------------------