Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ jobs:
- name: Add optional features - azure-rm, google and remote-execution
run: |
./foremanctl deploy --add-feature azure-rm --add-feature google --add-feature remote-execution
- name: Add optional feature - foreman-ansible
run: |
./foremanctl deploy --add-feature foreman-ansible
- name: Run tests
run: |
./forge test --pytest-args="--certificate-source=${{ matrix.certificate_source }} --database-mode=${{ matrix.database }}"
Expand Down Expand Up @@ -229,6 +232,9 @@ jobs:
- name: Add optional features - azure-rm, google and remote-execution
run: |
./foremanctl deploy --add-feature azure-rm --add-feature google --add-feature remote-execution
- name: Add optional feature - foreman-ansible
run: |
./foremanctl deploy --add-feature foreman-ansible
Comment on lines 232 to +237
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we combine this 2 steps and enable foreman-ansible feature along with others?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - I can do that for efficiency.

- name: Stop services
run:
vagrant ssh quadlet -- sudo systemctl stop foreman.target
Expand Down
9 changes: 9 additions & 0 deletions src/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ remote-execution:
hammer: foreman_remote_execution
dependencies:
- dynflow
foreman-ansible:
description: Ansible plugin for Foreman
foreman:
plugin_name: foreman_ansible
foreman_proxy:
plugin_name: ansible
hammer: foreman_ansible
dependencies:
- dynflow
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see remote_execution as dependecy of smart proxy ansible plugin too. https://github.com/theforeman/smart_proxy_ansible/blob/master/smart_proxy_ansible.gemspec#L32C31-L32C63, so by the metadata defination, should we add

remote-execution:
  internal: true
  foreman_proxy:
    plugin_name: remote_execution_ssh

here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remote-execution is defined already at line 23 and isn't an internal feature. The question is whether we need those dependencies at this level defined in order to orchestrate the right pieces. Let me add some tests for this first and see what that tells me.

Copy link
Copy Markdown
Contributor

@arvind4501 arvind4501 Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, i was thinking about the scenario where remote-execution is not enabled(or explictely disabled via --remove-feature remote-execution), so which wil leave /etc/foreman-proxy/settings.d/remote_execution_ssh.yml not deployed(or deployed as enabled: false ) and remote execution being not available, in that case we might have a broker ansible feature

dynflow:
internal: true
foreman_proxy:
Expand Down
22 changes: 22 additions & 0 deletions src/roles/foreman_proxy/tasks/feature/ansible.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
- name: Create ansible.env secret
containers.podman.podman_secret:
state: present
name: foreman-proxy-ansible-env
data: "{{ lookup('ansible.builtin.template', 'ansible.env.j2') }}"
notify:
- Restart Foreman Proxy
- Refresh Foreman Proxy

- name: Mount ansible.env secret
ansible.builtin.copy:
dest: /etc/containers/systemd/foreman-proxy.container.d/ansible-env.conf
content: |
[Container]
Secret=foreman-proxy-ansible-env,type=mount,target=/etc/foreman-proxy/ansible.env
mode: '0644'
owner: root
group: root
notify:
- Restart Foreman Proxy
- Refresh Foreman Proxy
13 changes: 13 additions & 0 deletions src/roles/foreman_proxy/templates/ansible.env.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export ANSIBLE_CALLBACK_WHITELIST="theforeman.foreman.foreman"
export ANSIBLE_CALLBACKS_ENABLED="theforeman.foreman.foreman"
export ANSIBLE_LOCAL_TEMP="/tmp"
export ANSIBLE_HOST_KEY_CHECKING="False"
export ANSIBLE_ROLES_PATH="/etc/ansible/roles:/usr/share/ansible/roles"
export ANSIBLE_COLLECTIONS_PATHS="/etc/ansible/collections:/usr/share/ansible/collections"

export FOREMAN_URL="{{ foreman_url }}"
export FOREMAN_SSL_CERT="/etc/foreman-proxy/foreman_ssl_cert.pem"
export FOREMAN_SSL_KEY="/etc/foreman-proxy/foreman_ssl_key.pem"
export FOREMAN_SSL_VERIFY="/etc/foreman-proxy/foreman_ssl_ca.pem"

export ANSIBLE_SSH_ARGS="-C -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
5 changes: 5 additions & 0 deletions src/roles/foreman_proxy/templates/settings.d/ansible.yml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
:enabled: {{ feature_enabled }}
:ansible_dir: /usr/share/foreman-proxy
:working_dir: /tmp
:ansible_environment_file: /etc/foreman-proxy/ansible.env
2 changes: 1 addition & 1 deletion tests/features_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def test_foremanctl_features():
for noise in ['PLAY [', 'TASK [', 'ok:', 'changed:', 'PLAY RECAP']:
assert noise not in result.stdout, f"Ansible output not suppressed: found '{noise}'"

for feature in ['foreman', 'foreman-proxy', 'azure-rm']:
for feature in ['foreman', 'foreman-proxy', 'azure-rm', 'foreman-ansible']:
assert feature in result.stdout, f"Expected feature '{feature}' in output"

def test_foremanctl_features_list_enabled():
Expand Down
83 changes: 83 additions & 0 deletions tests/foreman_ansible_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import json
import pytest

FOREMAN_PROXY_PORT = 8443
ROLE_NAME = "theforeman.foremanctltest"


def test_foreman_ansible_plugin_installed(foremanapi):
plugins = [plugin['name'] for plugin in foremanapi.list('plugins')]
assert 'foreman_ansible' in plugins


def test_foreman_proxy_ansible_feature(server, certificates, server_fqdn):
cmd = server.run(f"curl --cacert {certificates['ca_certificate']} --silent https://{server_fqdn}:{FOREMAN_PROXY_PORT}/features")
assert cmd.succeeded
features = json.loads(cmd.stdout)
assert "ansible" in features


def test_import_ansible_role(ansible_role, server):
role_list = server.run(f"hammer --output csv --no-headers ansible roles list --search='name={ansible_role}'")
assert role_list.succeeded
assert ansible_role in role_list.stdout


@pytest.fixture(scope="module")
def ansible_proxy_id(foremanapi):
proxies = foremanapi.list('smart_proxies')
for proxy in proxies:
if any(f['name'] == 'Ansible' for f in proxy.get('features', [])):
return proxy['id']
pytest.skip("No smart proxy with Ansible feature found")


@pytest.fixture(scope="module")
def ansible_role(server, foremanapi, ansible_proxy_id):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to run in a way that the result ends up inside the proxy container, otherwise ansible will never see the roles

setup = server.run(f"mkdir -p /etc/ansible/roles/{ROLE_NAME}/tasks")
assert setup.succeeded

write = server.run(f"echo '- command: uptime' > /etc/ansible/roles/{ROLE_NAME}/tasks/main.yml")
assert write.succeeded

fetch = server.run(f"hammer ansible roles fetch --proxy-id {ansible_proxy_id}")
assert fetch.succeeded

sync = server.run(f"hammer ansible roles sync --proxy-id {ansible_proxy_id} --role-names {ROLE_NAME}")
assert sync.succeeded

for task in foremanapi.list('foreman_tasks', search='label ~ SyncRolesAndVariables and state != stopped'):
foremanapi.wait_for_task(task)

yield ROLE_NAME


def test_run_ansible_role(ansible_role, foremanapi, server, server_fqdn):
assign = server.run(f"hammer host ansible-roles assign --name {server_fqdn} --ansible-roles {ansible_role}")
assert assign.succeeded
assert 'Ansible roles were assigned to the host' in assign.stdout

play = server.run(f"hammer host ansible-roles play --name {server_fqdn}")
assert play.succeeded
assert 'Ansible roles are being played.' in play.stdout

tasks = foremanapi.list('foreman_tasks', search='label = Actions::RemoteExecution::RunHostsJob')
for task in tasks:
foremanapi.wait_for_task(task)

report = server.run(f"hammer --output csv --no-headers config-report list --search 'host={server_fqdn} origin=Ansible'")
assert report.succeeded
assert server_fqdn in report.stdout
assert 'Ansible' in report.stdout


def test_run_command_via_ansible(foremanapi, server_fqdn):
templates = foremanapi.list('job_templates', search='name = "Run Command - Ansible Default"')
job = foremanapi.create('job_invocations', {
'job_template_id': templates[0]['id'],
'inputs': {'command': 'uptime'},
'search_query': f'name = {server_fqdn}',
'targeting_type': 'static_query',
})
task = foremanapi.wait_for_task(job['task'])
assert task['result'] == 'success'
Comment on lines +55 to +83
Copy link
Copy Markdown
Member

@evgeni evgeni Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these need to go to tests/client_test.py (well, it doesn't have to, but so far all client-related tests are there) and executed on the client, not the server. the server does not exist in Foreman as an entity and even if it would it would not have the ssh keys set up.

see

def test_foreman_rex(client_environment, activation_key, organization, foremanapi, client, client_fqdn):
client.run('dnf install -y subscription-manager')
rcmd = foremanapi.create('registration_commands', {'organization_id': organization['id'], 'insecure': True, 'activation_keys': [activation_key['name']], 'force': True})
client.run_test(rcmd['registration_command'])
job = foremanapi.create('job_invocations', {'feature': 'run_script', 'inputs': {'command': 'uptime'}, 'search_query': f'name = {client_fqdn}', 'targeting_type': 'static_query'})
task = foremanapi.wait_for_task(job['task'])
assert task['result'] == 'success'
foremanapi.delete('hosts', {'id': client_fqdn})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I had:

def test_foreman_ansible_rex(client_environment, activation_key, organization, foremanapi, client, client_fqdn):
    client.run('dnf install -y subscription-manager')
    rcmd = foremanapi.create('registration_commands', {'organization_id': organization['id'], 'insecure': True, 'activation_keys': [activation_key['name']], 'force': True})
    client.run_test(rcmd['registration_command'])
    job = foremanapi.create('job_invocations', {'job_template': 'Run Command - Ansible Default', 'inputs': {'command': 'uptime'}, 'search_query': f'name = {client_fqdn}', 'targeting_type': 'static_query'})
    task = foremanapi.wait_for_task(job['task'])
    assert task['result'] == 'success'
    foremanapi.delete('hosts', {'id': client_fqdn})

Loading