diff --git a/cfbs.json b/cfbs.json index 372655b5..d828a317 100644 --- a/cfbs.json +++ b/cfbs.json @@ -439,12 +439,56 @@ } ] }, + "run-ansible-playbooks": { + "description": "Run Ansible playbooks", + "subdirectory": "management/run-ansible-playbooks", + "dependencies": ["promise-type-ansible"], + "steps": [ + "copy main.cf services/cfbs/modules/run-ansible-playbooks/main.cf", + "policy_files services/cfbs/modules/run-ansible-playbooks/main.cf", + "bundles run_ansible_playbooks:main", + "input ./input.json def.json" + ], + "input": [ + { + "type": "list", + "variable": "playbooks", + "namespace": "run_ansible_playbooks", + "bundle": "main", + "label": "Playbooks", + "subtype": [ + { + "key": "path", + "type": "string", + "label": "Path", + "question": "Playbooks to run" + }, + { + "key": "condition", + "type": "string", + "label": "Condition", + "question": "Condition for when to run", + "default": "any" + }, + { + "key": "ifelapsed", + "type": "string", + "label": "ifelapsed", + "question": "Number of minutes between playbook assessments", + "default": "5" + } + ], + "while": "Do you want to specify more playbooks to be run?" + } + ] + }, "promise-type-ansible": { "description": "Promise type to manage systemd services.", "subdirectory": "promise-types/ansible", "dependencies": ["library-for-promise-types-in-python"], "steps": [ "copy ansible_promise.py modules/promises/", + "copy ansible_promise.sh modules/promises/", "append enable.cf services/init.cf" ] }, diff --git a/management/run-ansible-playbooks/README.md b/management/run-ansible-playbooks/README.md new file mode 100644 index 00000000..236d2d8f --- /dev/null +++ b/management/run-ansible-playbooks/README.md @@ -0,0 +1,40 @@ +This module enables the running of Ansible playbooks in masterfiles. +Playbook paths are specified based on input from Build in Mission Portal or `cfbs input`. + +**Note:** Each playbook must be stored in masterfiles. +You can achieve this by copying them into a subdirectory in your cfbs project (e.g. `playbooks/`), followed by: + +``` +$ cfbs add ./playbooks/ +WARNING: Did not find any bundles to add to bundlesequence +Added module: ./playbooks/ +``` + +Don't mind the warning. +After building and deploying your project the playbooks will end up in `$(sys.inputdir)/services/cfbs/playbooks/`. + +**Note:** The playbooks will be distributed and run locally with root-privilege (uid=0). + +**Note:** Ansible must be installed on the hosts. +You can use a module to install Ansible *_(See [install-ansible](https://build.cfengine.com/modules/install-ansible/) build module)_*: +``` +cfbs add install-ansible +``` + +**Usage:** +- `path` - The playbook path. + Must be absolute, e.g. `$(sys.inputdir)/services/cfbs/playbooks/playbook.yaml`. +- `condition` - Condition for running the playbook. + Use a class expression (e.g., `linux|bsd`). + Defaults to `any`. +- `ifelapsed` - Minimum number of minutes between each run. + Defaults to 5 minutes. + +## Contribute + +Feel free to open pull requests to expand this documentation, add features or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/projects/CFE). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/management/run-ansible-playbooks/main.cf b/management/run-ansible-playbooks/main.cf new file mode 100644 index 00000000..ec29ceb3 --- /dev/null +++ b/management/run-ansible-playbooks/main.cf @@ -0,0 +1,61 @@ +body file control +{ + namespace => "run_ansible_playbooks"; +} + +body action ifelapsed(minutes) +{ + ifelapsed => "$(minutes)"; +} + +bundle agent playbook(playbook, inventory) +{ + ansible: + "$(playbook)" inventory => "$(inventory)"; +} + +bundle agent main +{ + classes: + "enable" if => isvariable("playbooks"); + + vars: + "i" slist => getindices("playbooks"); + "inventory" string => "$(sys.statedir)/run-ansible-playbooks/inventory.ini"; + + files: + enable:: + "$(with)/." + create => "true", + with => dirname("$(inventory)"); + + "$(inventory)" + content => "[local]$(const.n)localhost ansible_connection=local", + comment => concat( + "Prevents ansible from complaining about a missing inventory file ", + "and causes the playbook to be run on the local host." + ); + + methods: + enable:: + "playbooks[$(i)]" + usebundle => playbook("$(playbooks[$(i)][path])", "$(inventory)"), + if => "$(playbooks[$(i)][condition])", + action => ifelapsed("$(playbooks[$(i)][ifelapsed])"), + comment => concat( + "Run playbook '$(playbooks[$(i)][path])' ", + "if condition '$(playbooks[$(i)][condition])' ", + "every $(playbooks[$(i)][ifelapsed]) minutes" + ); +} + +body file control +{ + namespace => "default"; +} + +bundle agent __main__ +{ + methods: + "run_ansible_playbooks:main"; +} diff --git a/promise-types/ansible/README.md b/promise-types/ansible/README.md index c3a375ea..2a141a04 100644 --- a/promise-types/ansible/README.md +++ b/promise-types/ansible/README.md @@ -17,6 +17,10 @@ bundle agent main * Ansible >= 2.8.0 +The promise type runs under the Python interpreter of the pipx virtualenv that +the `install-ansible` module creates, falling back to `/usr/bin/python3` when +Ansible was installed some other way. + ## Attributes | Name | Type | Description | Mandatory | Default | diff --git a/promise-types/ansible/ansible_promise.py b/promise-types/ansible/ansible_promise.py index 5fcd619c..a127dbd6 100644 --- a/promise-types/ansible/ansible_promise.py +++ b/promise-types/ansible/ansible_promise.py @@ -104,6 +104,11 @@ def must_be_absolute(v): self.add_attribute("private_key_file", str, validator=must_be_absolute) self.add_attribute("remote_user", str, default="root") + # Standard CFEngine promise attribute, forwarded to the promise module + # by the agent instead of being consumed by it. Declared so that it is + # accepted rather than rejected as an unknown attribute. + self.add_attribute("comment", str) + def prepare_promiser_and_attributes(self, promiser, attributes): safe_promiser = promiser.replace(",", "_") return (safe_promiser, attributes) diff --git a/promise-types/ansible/ansible_promise.sh b/promise-types/ansible/ansible_promise.sh new file mode 100644 index 00000000..1bf18f46 --- /dev/null +++ b/promise-types/ansible/ansible_promise.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Wrapper choosing the Python interpreter that runs ansible_promise.py. +# +# The [install-ansible](https://build.cfengine.com/modules/install-ansible/) +# build module installs Ansible with `pipx install --global`, which places it in +# an isolated virtualenv under /opt/pipx/venvs. The system interpreter cannot +# import Ansible from there, so prefer the virtualenv's interpreter and fall +# back to /usr/bin/python3 for hosts where Ansible was installed some other way. + +module="$(dirname "$0")/ansible_promise.py" + +can_import_ansible() { + if [ ! -x "$1" ]; then + return 1 + fi + "$1" -c "import ansible" >/dev/null 2>&1 +} + +# The ansible command is a Python console script, so its shebang names the +# interpreter it was installed for. Reading it finds the right virtualenv no +# matter where pipx put it, and whether ansible or ansible-core was installed. +ansible_bin="$(command -v ansible)" +if [ -n "$ansible_bin" ]; then + python="$(sed -n '1s|^#! *\([^ ]*\).*|\1|p' "$ansible_bin")" + if can_import_ansible "$python"; then + exec "$python" "$module" "$@" + fi +fi + +# Default locations, in case the command above is missing from the PATH that +# cf-agent inherited. +for python in /opt/pipx/venvs/ansible/bin/python /opt/pipx/venvs/ansible-core/bin/python; do + if can_import_ansible "$python"; then + exec "$python" "$module" "$@" + fi +done + +exec /usr/bin/python3 "$module" "$@" diff --git a/promise-types/ansible/enable.cf b/promise-types/ansible/enable.cf index bc4b7245..c90ae776 100644 --- a/promise-types/ansible/enable.cf +++ b/promise-types/ansible/enable.cf @@ -1,6 +1,6 @@ promise agent ansible # @brief Define ansible promise type { - path => "$(sys.workdir)/modules/promises/ansible_promise.py"; - interpreter => "/usr/bin/python3"; + path => "$(sys.workdir)/modules/promises/ansible_promise.sh"; + interpreter => "/bin/sh"; } diff --git a/promise-types/ansible/example.cf b/promise-types/ansible/example.cf index 29f54287..779975cd 100644 --- a/promise-types/ansible/example.cf +++ b/promise-types/ansible/example.cf @@ -1,8 +1,8 @@ promise agent ansible # @brief Define ansible promise type { - path => "$(sys.workdir)/modules/promises/ansible_promise.py"; - interpreter => "/usr/bin/python3"; + path => "$(sys.workdir)/modules/promises/ansible_promise.sh"; + interpreter => "/bin/sh"; } bundle agent main diff --git a/tests/deploy/05-run-ansible-playbooks-test.sh b/tests/deploy/05-run-ansible-playbooks-test.sh new file mode 100755 index 00000000..852e3725 --- /dev/null +++ b/tests/deploy/05-run-ansible-playbooks-test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# assumes deploy.sh has already run adjacent to this file + +set -ex +thisdir="$(cd "$(dirname "$0")" && pwd)" +cd "$thisdir" + +sudo cf-agent -Kd -Ddata:install_ansible --bundle install_ansible +ansible --version + +markerdir=$(mktemp -d) +trap 'sudo rm -rf "$markerdir"' EXIT +marker="$markerdir/marker" + +# The playbook touches a marker file so we can tell that it actually ran +mkdir -p playbooks +cat >playbooks/playbook.yaml <