From 0ae24f8c8f27501dbea441ee87889c518515bad3 Mon Sep 17 00:00:00 2001 From: Lars Erik Wik Date: Thu, 3 Sep 2026 13:45:43 +0200 Subject: [PATCH 1/3] Allow the comment attribute on ansible promises The agent forwards the standard comment attribute to the promise module, but the module only accepted its own attributes and so failed validation with "Unknown attribute 'comment'". Declare it so that it is accepted. Ticket: CFE-4745 Signed-off-by: Lars Erik Wik --- promise-types/ansible/ansible_promise.py | 5 +++++ 1 file changed, 5 insertions(+) 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) From bdf35a06de5665675ed7965af59feffc2182a753 Mon Sep 17 00:00:00 2001 From: Lars Erik Wik Date: Thu, 3 Sep 2026 14:06:31 +0200 Subject: [PATCH 2/3] Run the ansible promise module under the pipx virtualenv The install-ansible module installs Ansible with pipx, into an isolated virtualenv that /usr/bin/python3 cannot import from. Instead run it through a wrapper that picks the virtualenv interpreter. Falls back to /usr/bin/python3 for backward compatibility. Ticket: CFE-4746 Signed-off-by: Lars Erik Wik --- cfbs.json | 1 + promise-types/ansible/README.md | 4 +++ promise-types/ansible/ansible_promise.sh | 38 ++++++++++++++++++++++++ promise-types/ansible/enable.cf | 4 +-- promise-types/ansible/example.cf | 4 +-- 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 promise-types/ansible/ansible_promise.sh diff --git a/cfbs.json b/cfbs.json index 372655b5..34d7adb4 100644 --- a/cfbs.json +++ b/cfbs.json @@ -445,6 +445,7 @@ "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/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.sh b/promise-types/ansible/ansible_promise.sh new file mode 100644 index 00000000..a8c61af2 --- /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 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 From 5650f3bc7aede525fedd4f1502dc29718728263c Mon Sep 17 00:00:00 2001 From: Lars Erik Wik Date: Wed, 2 Sep 2026 14:52:41 +0200 Subject: [PATCH 3/3] Added run-ansible-playbooks module Runs Ansible playbooks based on input from Build in Mission Portal or 'cfbs input'. Ticket: ENT-14364 Signed-off-by: Lars Erik Wik --- cfbs.json | 43 +++++++++++++ management/run-ansible-playbooks/README.md | 40 ++++++++++++ management/run-ansible-playbooks/main.cf | 61 +++++++++++++++++++ promise-types/ansible/ansible_promise.sh | 10 +-- tests/deploy/05-run-ansible-playbooks-test.sh | 50 +++++++++++++++ 5 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 management/run-ansible-playbooks/README.md create mode 100644 management/run-ansible-playbooks/main.cf create mode 100755 tests/deploy/05-run-ansible-playbooks-test.sh diff --git a/cfbs.json b/cfbs.json index 34d7adb4..d828a317 100644 --- a/cfbs.json +++ b/cfbs.json @@ -439,6 +439,49 @@ } ] }, + "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", 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/ansible_promise.sh b/promise-types/ansible/ansible_promise.sh index a8c61af2..1bf18f46 100644 --- a/promise-types/ansible/ansible_promise.sh +++ b/promise-types/ansible/ansible_promise.sh @@ -1,11 +1,11 @@ #!/bin/sh # Wrapper choosing the Python interpreter that runs ansible_promise.py. # -# The install-ansible 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. +# 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" 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 <