From 954f8a2bb7bd39c2a67863e6141d449f64d21b27 Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 12:31:10 -0400 Subject: [PATCH 01/10] Add inventory module migrated from awx.awx/ansible.controller (AAP-91390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Shape 1 CRUD module with copy_from, instance_groups/input_inventories associations, and constructed inventory support. - Add manage_associations/manage_sub_resource/copy_resource SDK methods to base_client/platform_manager/direct_client/rpc_client (shared infra, first use in this collection) — ported from PR #228's job_template work, with lookup_endpoint values corrected to full /api/controller/v2/ paths (PR #228 passed bare resource names, which would have resolved against the Gateway instead of Controller). - Add a `controller` action_groups entry to meta/runtime.yml (previously gateway-only), and generalize test_completeness.py's meta/runtime.yml check to scan every action_groups entry instead of just "gateway". - Extend tools/mock_gateway_server.py with /api/controller/v2/ routing, generic association/copy sub-endpoints, and a controller-side organizations lookup that reuses the Gateway's org store (shared ID space, matching real AAP). - Unit tests for the transform mixin; a 3-connection-mode Molecule scenario covering create/idempotency/update/rename/copy_from/associations/ constructed-inventory/delete; a live-API integration test target. Co-Authored-By: Claude Sonnet 5 --- changelogs/fragments/aap_91390_inventory.yml | 4 + .../molecule/inventory_mock/cleanup.yml | 142 ++++++++ .../molecule/inventory_mock/converge.yml | 321 ++++++++++++++++++ .../molecule/inventory_mock/inventory.yml | 15 + .../molecule/inventory_mock/molecule.yml | 34 ++ extensions/molecule/inventory_mock/verify.yml | 104 ++++++ meta/runtime.yml | 2 + plugins/action/inventory.py | 146 ++++++++ plugins/modules/inventory.py | 191 +++++++++++ .../plugin_utils/ansible_models/inventory.py | 42 +++ plugins/plugin_utils/api/v1/inventory.py | 165 +++++++++ .../plugin_utils/manager/platform_manager.py | 146 ++++++++ plugins/plugin_utils/manager/rpc_client.py | 27 ++ plugins/plugin_utils/platform/base_client.py | 73 ++++ .../plugin_utils/platform/direct_client.py | 161 +++++++++ .../targets/inventory_test/meta/main.yml | 4 + .../targets/inventory_test/tasks/main.yml | 184 ++++++++++ tests/test_completeness.py | 8 +- .../plugin_utils/api/v1/test_inventory.py | 112 ++++++ tools/mock_gateway_server.py | 184 +++++++++- 20 files changed, 2060 insertions(+), 5 deletions(-) create mode 100644 changelogs/fragments/aap_91390_inventory.yml create mode 100644 extensions/molecule/inventory_mock/cleanup.yml create mode 100644 extensions/molecule/inventory_mock/converge.yml create mode 100644 extensions/molecule/inventory_mock/inventory.yml create mode 100644 extensions/molecule/inventory_mock/molecule.yml create mode 100644 extensions/molecule/inventory_mock/verify.yml create mode 100644 plugins/action/inventory.py create mode 100644 plugins/modules/inventory.py create mode 100644 plugins/plugin_utils/ansible_models/inventory.py create mode 100644 plugins/plugin_utils/api/v1/inventory.py create mode 100644 tests/integration/targets/inventory_test/meta/main.yml create mode 100644 tests/integration/targets/inventory_test/tasks/main.yml create mode 100644 tests/unit/plugins/plugin_utils/api/v1/test_inventory.py diff --git a/changelogs/fragments/aap_91390_inventory.yml b/changelogs/fragments/aap_91390_inventory.yml new file mode 100644 index 00000000..ed19f823 --- /dev/null +++ b/changelogs/fragments/aap_91390_inventory.yml @@ -0,0 +1,4 @@ +minor_changes: + - inventory - add module migrated from awx.awx/ansible.controller, including copy_from, + instance_groups/input_inventories associations, and constructed inventory support + (https://issues.redhat.com/browse/AAP-91390). diff --git a/extensions/molecule/inventory_mock/cleanup.yml b/extensions/molecule/inventory_mock/cleanup.yml new file mode 100644 index 00000000..69302639 --- /dev/null +++ b/extensions/molecule/inventory_mock/cleanup.yml @@ -0,0 +1,142 @@ +--- +# Cleanup: delete inventories/organizations created by converge (local, http direct, http persistent). +- name: Cleanup — delete inventory and organization (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org Inventory Local" + molecule_inv_name: "Molecule Test Inventory Local" + tasks: + - name: Delete constructed inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}-Constructed" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Delete copied inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}-Copy" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Delete renamed inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}-Renamed" + organization: "{{ molecule_org_name }}" + state: absent + register: delete_result_local + failed_when: false + vars: + ansible_connection: local + + - name: Assert inventory removed or already absent (connection local) + ansible.builtin.assert: + that: delete_result_local is not failed + fail_msg: "Cleanup: failed to delete inventory {{ molecule_inv_name }}-Renamed." + vars: + ansible_connection: local + + - name: Delete organization (connection local) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete inventory and organization (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org Inventory HTTP Direct" + molecule_inv_name: "Molecule Test Inventory HTTP Direct" + tasks: + - name: Delete inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + register: delete_result_http_direct + failed_when: false + + - name: Assert inventory removed or already absent (http direct) + ansible.builtin.assert: + that: delete_result_http_direct is not failed + fail_msg: "Cleanup: failed to delete inventory {{ molecule_inv_name }}." + + - name: Delete organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete inventory and organization (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org Inventory HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory HTTP Persistent" + tasks: + - name: Delete inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + register: delete_result_http_persistent + failed_when: false + + - name: Assert inventory removed or already absent (http persistent) + ansible.builtin.assert: + that: delete_result_http_persistent is not failed + fail_msg: "Cleanup: failed to delete inventory {{ molecule_inv_name }}." + + - name: Delete organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local +... diff --git a/extensions/molecule/inventory_mock/converge.yml b/extensions/molecule/inventory_mock/converge.yml new file mode 100644 index 00000000..f3cac6dc --- /dev/null +++ b/extensions/molecule/inventory_mock/converge.yml @@ -0,0 +1,321 @@ +--- +# Converge: inventory create, idempotency, update, rename, copy_from, instance_groups +# association, and delete against the mock Gateway/Controller. +# Play 1: health check runs on controller (connection: local); platform connection cannot run uri. +- name: Ensure mock Gateway is reachable + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + tasks: + - name: Wait for mock Gateway health endpoint + ansible.builtin.uri: + url: "{{ gateway_hostname }}/health" + method: GET + status_code: 200 + register: health + retries: 12 + delay: 5 + until: health.status == 200 + vars: + ansible_connection: local + + - name: Ensure /tmp/ap directory exists + ansible.builtin.file: + path: /tmp/ap + state: directory + mode: "0755" + vars: + ansible_connection: local + + - name: Create manager survive flag (Molecule keeps manager alive across phases) + ansible.builtin.file: + path: /tmp/ap/.survive + state: touch + mode: "0600" + vars: + ansible_connection: local + +# Play 2: full feature coverage (connection local) — create, idempotency, update, rename, +# copy_from, instance_groups association, kind=constructed, delete. +- name: Converge — inventory (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org Inventory Local" + molecule_inv_name: "Molecule Test Inventory Local" + tasks: + - name: Create organization + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_local + vars: + ansible_connection: local + + - name: Seed a fake instance group directly against the mock Controller + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/instance_groups/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "molecule-ig-local" + status_code: 201 + register: ig_local + vars: + ansible_connection: local + + - name: Create inventory + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_local.name }}" + description: "Created by Molecule inventory_mock (connection local)" + variables: + foo: bar + instance_groups: + - "molecule-ig-local" + register: create_result_local + vars: + ansible_connection: local + + - name: Assert create changed (connection local) + ansible.builtin.assert: + that: + - create_result_local is changed + - create_result_local.inventory.id is defined + - create_result_local.inventory.name == molecule_inv_name + - create_result_local.inventory.variables.foo == "bar" + fail_msg: "Create (local) should report changed. create_result_local={{ create_result_local }}" + vars: + ansible_connection: local + + - name: Run again idempotency (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_local.name }}" + description: "Created by Molecule inventory_mock (connection local)" + variables: + foo: bar + register: idem_result_local + vars: + ansible_connection: local + + - name: Assert idempotent run did not change (connection local) + ansible.builtin.assert: + that: idem_result_local is not changed + fail_msg: "Idempotent run (local) should not report changed. idem_result_local={{ idem_result_local }}" + vars: + ansible_connection: local + + - name: Update inventory description (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_local.name }}" + description: "Updated by Molecule inventory_mock (connection local)" + register: update_result_local + vars: + ansible_connection: local + + - name: Assert update changed (connection local) + ansible.builtin.assert: + that: update_result_local is changed + fail_msg: "Update (local) should report changed. update_result_local={{ update_result_local }}" + vars: + ansible_connection: local + + - name: Rename inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + new_name: "{{ molecule_inv_name }}-Renamed" + organization: "{{ org_local.name }}" + register: rename_result_local + vars: + ansible_connection: local + + - name: Assert rename changed and preserved id (connection local) + ansible.builtin.assert: + that: + - rename_result_local is changed + - rename_result_local.id == create_result_local.id + fail_msg: "Rename (local) should keep the same id. rename_result_local={{ rename_result_local }}" + vars: + ansible_connection: local + + - name: Copy inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}-Copy" + copy_from: "{{ molecule_inv_name }}-Renamed" + organization: "{{ org_local.name }}" + register: copy_result_local + vars: + ansible_connection: local + + - name: Assert copy created a distinct inventory (connection local) + ansible.builtin.assert: + that: + - copy_result_local is changed + - copy_result_local.id is defined + - copy_result_local.id != create_result_local.id + fail_msg: "Copy (local) should create a new inventory. copy_result_local={{ copy_result_local }}" + vars: + ansible_connection: local + + - name: Create a constructed inventory with input_inventories (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}-Constructed" + organization: "{{ org_local.name }}" + kind: constructed + input_inventories: + - "{{ molecule_inv_name }}-Renamed" + register: constructed_result_local + vars: + ansible_connection: local + + - name: Assert constructed inventory created (connection local) + ansible.builtin.assert: + that: + - constructed_result_local is changed + - constructed_result_local.inventory.kind == "constructed" + fail_msg: "Constructed create (local) failed. constructed_result_local={{ constructed_result_local }}" + vars: + ansible_connection: local + + - name: Re-apply constructed inventory (idempotent on input_inventories, connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}-Constructed" + organization: "{{ org_local.name }}" + kind: constructed + input_inventories: + - "{{ molecule_inv_name }}-Renamed" + register: constructed_idem_local + vars: + ansible_connection: local + + - name: Assert no change on re-apply (connection local) + ansible.builtin.assert: + that: constructed_idem_local is not changed + fail_msg: "Constructed re-apply (local) should be idempotent. constructed_idem_local={{ constructed_idem_local }}" + vars: + ansible_connection: local + +# Play 3: basic CRUD parity (http direct). +- name: Converge — inventory (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org Inventory HTTP Direct" + molecule_inv_name: "Molecule Test Inventory HTTP Direct" + tasks: + - name: Create organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_direct + + - name: Create inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_direct.name }}" + description: "Created by Molecule inventory_mock (http direct)" + register: create_result_http_direct + + - name: Assert create changed (http direct) + ansible.builtin.assert: + that: + - create_result_http_direct is changed + - create_result_http_direct.inventory.id is defined + fail_msg: "Create (http direct) should report changed. create_result_http_direct={{ create_result_http_direct }}" + + - name: Run again idempotency (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_direct.name }}" + description: "Created by Molecule inventory_mock (http direct)" + register: idem_result_http_direct + + - name: Assert idempotent run did not change (http direct) + ansible.builtin.assert: + that: idem_result_http_direct is not changed + fail_msg: "Idempotent run (http direct) should not report changed. idem_result_http_direct={{ idem_result_http_direct }}" + + - name: Update inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_direct.name }}" + description: "Updated by Molecule inventory_mock (http direct)" + register: update_result_http_direct + + - name: Assert update changed (http direct) + ansible.builtin.assert: + that: update_result_http_direct is changed + fail_msg: "Update (http direct) should report changed. update_result_http_direct={{ update_result_http_direct }}" + +# Play 4: basic CRUD parity (http persistent). +- name: Converge — inventory (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org Inventory HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory HTTP Persistent" + tasks: + - name: Create organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_persistent + + - name: Create inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_persistent.name }}" + description: "Created by Molecule inventory_mock (http persistent)" + register: create_result_http_persistent + + - name: Assert create changed (http persistent) + ansible.builtin.assert: + that: + - create_result_http_persistent is changed + - create_result_http_persistent.inventory.id is defined + fail_msg: "Create (http persistent) should report changed. create_result_http_persistent={{ create_result_http_persistent }}" + + - name: Run again idempotency (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_persistent.name }}" + description: "Created by Molecule inventory_mock (http persistent)" + register: idem_result_http_persistent + + - name: Assert idempotent run did not change (http persistent) + ansible.builtin.assert: + that: idem_result_http_persistent is not changed + fail_msg: "Idempotent run (http persistent) should not report changed. idem_result_http_persistent={{ idem_result_http_persistent }}" + + - name: Update inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_persistent.name }}" + description: "Updated by Molecule inventory_mock (http persistent)" + register: update_result_http_persistent + + - name: Assert update changed (http persistent) + ansible.builtin.assert: + that: update_result_http_persistent is changed + fail_msg: "Update (http persistent) should report changed. update_result_http_persistent={{ update_result_http_persistent }}" +... diff --git a/extensions/molecule/inventory_mock/inventory.yml b/extensions/molecule/inventory_mock/inventory.yml new file mode 100644 index 00000000..46dcac3b --- /dev/null +++ b/extensions/molecule/inventory_mock/inventory.yml @@ -0,0 +1,15 @@ +--- +# inventory_mock scenario: use scenario inventory so we can mix connection types. +# First play (health check) uses connection: local; other plays use ansible.platform.http. +# Mock gateway vars are set here and in play vars. Structure matches shared inventory for parse compatibility. +all: + vars: + ansible_connection: local + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + children: + gateway_under_test: + hosts: + localhost: {} diff --git a/extensions/molecule/inventory_mock/molecule.yml b/extensions/molecule/inventory_mock/molecule.yml new file mode 100644 index 00000000..a5037f36 --- /dev/null +++ b/extensions/molecule/inventory_mock/molecule.yml @@ -0,0 +1,34 @@ +--- +# Scenario: test ansible.platform.inventory against the mock Gateway/Controller server (no real AAP). +# Requires the mock to be running (e.g. "molecule test --all" or default scenario create). +driver: + name: default + +platforms: + - name: localhost + +# Use scenario inventory (connection: local + gateway vars). Converge: play 2 direct, play 3 persistent, play 4 connection local. +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + +provisioner: + name: ansible + playbooks: + converge: converge.yml + verify: verify.yml + cleanup: cleanup.yml + config_options: + defaults: + collections_path: "${MOLECULE_SCENARIO_DIRECTORY}/../../../../../../" + # Write verbose messages (vv/vvvv) to ANSIBLE_LOG_PATH even without -v on the terminal. + log_verbosity: 4 + +scenario: + test_sequence: + - converge + - verify + - cleanup +... diff --git a/extensions/molecule/inventory_mock/verify.yml b/extensions/molecule/inventory_mock/verify.yml new file mode 100644 index 00000000..953edc0a --- /dev/null +++ b/extensions/molecule/inventory_mock/verify.yml @@ -0,0 +1,104 @@ +--- +# Verify: all three connection scenarios (local, http direct, http persistent). +- name: Verify — inventory created with connection local (mock) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org Inventory Local" + molecule_inv_name: "Molecule Test Inventory Local" + tasks: + - name: Get inventory (state exists, connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}-Renamed" + organization: "{{ molecule_org_name }}" + state: exists + register: exists_result_local + vars: + ansible_connection: local + + - name: Assert inventory was found (connection local) + ansible.builtin.assert: + that: + - exists_result_local is not failed + - exists_result_local.get('exists') | default(false) | bool + - exists_result_local.get('inventory') is defined + fail_msg: "Verify: inventory {{ molecule_inv_name }}-Renamed not found (connection local)." + vars: + ansible_connection: local + + - name: Assert description was updated before rename (connection local) + ansible.builtin.assert: + that: exists_result_local.inventory.description == "Updated by Molecule inventory_mock (connection local)" + fail_msg: "Verify: inventory (local) description was not updated." + vars: + ansible_connection: local + +- name: Verify — inventory created with http direct (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org Inventory HTTP Direct" + molecule_inv_name: "Molecule Test Inventory HTTP Direct" + tasks: + - name: Get inventory (state exists, http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: exists + register: exists_result_http_direct + + - name: Assert inventory was found (http direct) + ansible.builtin.assert: + that: + - exists_result_http_direct is not failed + - exists_result_http_direct.get('exists') | default(false) | bool + fail_msg: "Verify: inventory {{ molecule_inv_name }} not found (http direct)." + + - name: Assert description updated (http direct) + ansible.builtin.assert: + that: exists_result_http_direct.inventory.description == "Updated by Molecule inventory_mock (http direct)" + fail_msg: "Verify: inventory (http direct) description was not updated." + +- name: Verify — inventory created with http persistent (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org Inventory HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory HTTP Persistent" + tasks: + - name: Get inventory (state exists, http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: exists + register: exists_result_http_persistent + + - name: Assert inventory was found (http persistent) + ansible.builtin.assert: + that: + - exists_result_http_persistent is not failed + - exists_result_http_persistent.get('exists') | default(false) | bool + fail_msg: "Verify: inventory {{ molecule_inv_name }} not found (http persistent)." + + - name: Assert description updated (http persistent) + ansible.builtin.assert: + that: exists_result_http_persistent.inventory.description == "Updated by Molecule inventory_mock (http persistent)" + fail_msg: "Verify: inventory (http persistent) description was not updated." +... diff --git a/meta/runtime.yml b/meta/runtime.yml index 482f9c26..35026fbc 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -24,4 +24,6 @@ action_groups: - token - ui_plugin_route - user + controller: + - inventory ... diff --git a/plugins/action/inventory.py b/plugins/action/inventory.py new file mode 100644 index 00000000..ffd77f68 --- /dev/null +++ b/plugins/action/inventory.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) 2026, Ansible Platform Collection Contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Action plugin for ansible.platform.inventory module. + +Migrated from awx.awx/ansible.controller inventory module. Uses Pattern C +(custom run override) due to: + - Association fields (instance_groups, input_inventories) + - Copy operation (copy_from) + +Note: the legacy module also rejected changing an existing inventory's +``kind`` from a regular inventory to ``smart`` client-side, purely as a +friendlier error than whatever the API itself returns. That client-side +guard is not reproduced here — Controller's own validation on the API call +is treated as sufficient — since it is not a resource field with a value +of its own. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from typing import Any + +from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.inventory import AnsibleInventory + +logger = logging.getLogger(__name__) + +_ASSOCIATION_FIELDS = ( + "instance_groups", + "input_inventories", +) + +_INVENTORY_BASE_PATH = "/api/controller/v2/inventories" + +_ASSOCIATION_MAP = { + "instance_groups": ("/api/controller/v2/instance_groups/", "name"), + "input_inventories": ("/api/controller/v2/inventories/", "name"), +} + + +class ActionModule(BaseResourceActionPlugin): + """Action plugin for inventory module.""" + + MODULE_NAME = "inventory" + MODEL_CLASS = AnsibleInventory + LOOKUP_FIELD = "name" + + _WRITE_ONLY_FIELDS = frozenset( + { + "copy_from", + "instance_groups", + "input_inventories", + } + ) + + def _build_ansible_data(self, resource: Any, validated_params: dict, operation: str) -> dict: + """Build ansible_data from explicitly-provided task parameters only.""" + data = {k: getattr(resource, k) for k in validated_params if hasattr(resource, k)} + if getattr(resource, "id", None) is not None: + data["id"] = resource.id + return data + + def run(self, tmp: object = None, task_vars: dict = None) -> dict: + """Run the inventory action plugin. + + Extends the base run() to handle: + - copy_from: Copy an existing inventory before applying changes + - Association fields: instance_groups, input_inventories + + All HTTP calls are delegated to the SDK layer (PlatformService / DirectHTTPClient). + """ + copy_from = self._task.args.pop("copy_from", None) + state = self._task.args.get("state", "present") + + association_data = {} + for field in _ASSOCIATION_FIELDS: + val = self._task.args.pop(field, None) + if val is not None: + association_data[field] = val + + if copy_from and state not in ("absent", "deleted"): + result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) + self._task_vars = task_vars or {} + + try: + manager, facts_to_set = self._get_or_spawn_manager(task_vars or {}) + self._client = manager + if facts_to_set: + result["ansible_facts"] = facts_to_set + result["_ansible_facts_cacheable"] = True + + copied = manager.copy_resource( + self.MODULE_NAME, + copy_from, + self._task.args.get("name"), + _INVENTORY_BASE_PATH, + ) + + if copied and copied.get("id"): + # No need to inject an "id" into self._task.args here — "id" isn't a + # declared module option (would fail argspec validation on this second + # call), and the copy already has the target name, so a plain re-run + # finds it naturally via LOOKUP_FIELD (name) + organization. + result = super().run(tmp, task_vars) + # copy_resource() always creates a new resource — that's a change even + # if the follow-up update-with-remaining-params finds nothing left to + # change and would otherwise report changed=False on its own. + result["changed"] = True + else: + result.update(changed=True, failed=False, **{self.MODULE_NAME: copied or {}}) + + except Exception as exc: + result.update(changed=False, failed=True, msg=str(exc)) + return result + else: + result = super().run(tmp, task_vars) + + if result.get("failed"): + return result + + inventory_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") + + if inventory_id and state not in ("absent", "deleted", "exists"): + manager = self._client + if manager: + for field, (lookup_ep, lookup_field) in _ASSOCIATION_MAP.items(): + desired = association_data.get(field) + if desired is not None: + changed = manager.manage_associations( + _INVENTORY_BASE_PATH, + inventory_id, + field, + desired, + lookup_ep, + lookup_field, + ) + if changed: + result["changed"] = True + + return result diff --git a/plugins/modules/inventory.py b/plugins/modules/inventory.py new file mode 100644 index 00000000..dfa1bd99 --- /dev/null +++ b/plugins/modules/inventory.py @@ -0,0 +1,191 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2017, Wayne Witzel III +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This module is implemented as an action plugin. +# See plugins/action/inventory.py for the implementation. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: inventory +author: Red Hat (@RedHatOfficial) +short_description: Create, update, or destroy Automation Platform Controller inventories +description: + - Create, update, or destroy Automation Platform Controller inventories. +version_added: "3.0.0" + +options: + name: + description: + - The name to use for the inventory. + required: true + type: str + + new_name: + description: + - Setting this option will change the existing name (looked up via the name field). + type: str + + copy_from: + description: + - Name or ID to copy the inventory from. + - This will copy an existing inventory and change any parameters supplied. + - The new inventory name will be the one provided in the C(name) parameter. + - The organization parameter is not used in this, to facilitate copy from one organization to another. + type: str + + description: + description: + - The description to use for the inventory. + type: str + + organization: + description: + - Organization name, ID, or named URL the inventory belongs to. + required: true + type: str + + variables: + description: + - Inventory variables. + type: dict + + kind: + description: + - The kind field. Cannot be modified after created. + choices: ["", "smart", "constructed"] + type: str + + host_filter: + description: + - The host_filter field. Only useful when C(kind=smart). + type: str + + opa_query_path: + description: + - The Open Policy Agent query path used to evaluate this inventory's policy. + type: str + + instance_groups: + description: + - List of Instance Group names, IDs, or named URLs for this inventory to run on. + type: list + elements: str + + input_inventories: + description: + - List of Inventory names, IDs, or named URLs to use as input for a Constructed Inventory. + - Only used when C(kind=constructed). + type: list + elements: str + + prevent_instance_group_fallback: + description: + - Prevent falling back to instance groups set on the organization. + type: bool + + state: + description: + - Desired state of the inventory. + - C(present) ensures the inventory exists (create or update); idempotent. + - C(absent) removes the inventory; idempotent if already absent. + - C(exists) reads and returns the current inventory (no change). + type: str + choices: ['present', 'absent', 'exists', 'enforced'] + default: 'present' + +extends_documentation_fragment: + - ansible.platform.state + - ansible.platform.auth + +seealso: + - module: ansible.controller.inventory + - module: awx.awx.inventory +""" + +EXAMPLES = """ +- name: Add inventory + ansible.platform.inventory: + name: "Foo Inventory" + description: "Our Foo Cloud Servers" + organization: "Bar Org" + state: present + +- name: Copy inventory + ansible.platform.inventory: + name: Copy Foo Inventory + copy_from: Default Inventory + description: "Our Foo Cloud Servers" + organization: Foo + state: present + +- name: Add inventory with instance groups + ansible.platform.inventory: + name: Foo Inventory + organization: Bar Org + instance_groups: + - group-a + - group-b + +# You can create and modify constructed inventories by creating an inventory +# of kind "constructed" and then editing the automatically generated inventory +# source for that inventory. +- name: Add constructed inventory with two existing input inventories + ansible.platform.inventory: + name: My Constructed Inventory + organization: Default + kind: constructed + input_inventories: + - "West Datacenter" + - "East Datacenter" + +- name: Check whether an inventory exists (no change) + ansible.platform.inventory: + name: Foo Inventory + organization: Bar Org + state: exists + +- name: Delete an inventory + ansible.platform.inventory: + name: Foo Inventory + organization: Bar Org + state: absent +... +""" + +RETURN = """ +changed: + description: Whether the inventory was created, updated, or deleted. + returned: always + type: bool + +inventory: + description: > + The inventory resource as it exists after the operation. + Contains only the fields accepted as module input (argspec fields) plus C(id). + returned: when state is present, exists, or enforced + type: dict + contains: + id: + description: Numeric database ID of the inventory. + type: int + name: + description: Name of the inventory. + type: str + organization: + description: ID of the organization the inventory belongs to. + type: int + kind: + description: The kind of inventory. + type: str + variables: + description: Inventory variables. + type: dict +... +""" diff --git a/plugins/plugin_utils/ansible_models/inventory.py b/plugins/plugin_utils/ansible_models/inventory.py new file mode 100644 index 00000000..0d5b72d8 --- /dev/null +++ b/plugins/plugin_utils/ansible_models/inventory.py @@ -0,0 +1,42 @@ +""" +Ansible Inventory dataclass - user-facing stable interface. + +This dataclass represents the inventory as seen by Ansible playbooks. +Field names and types remain stable across API versions. + +instance_groups/input_inventories/copy_from are handled by the action plugin +(association sync / copy operation) — they are popped from ansible_data before +this dataclass is constructed, so they are not fields here. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class AnsibleInventory: + """Ansible representation of a Controller inventory.""" + + # Required / identity + name: str + + # Optional fields + # organization is required at the DOCUMENTATION/argspec level; it defaults + # to None here (not a bare positional field) so internal callers that + # build a partial instance for a lookup-only "find" (e.g. copy_resource()) + # don't need to supply it. Matches the AnsibleJobTemplate precedent. + organization: Optional[str] = None + new_name: Optional[str] = None + description: Optional[str] = None + kind: Optional[str] = None + host_filter: Optional[str] = None + variables: Optional[dict] = None + prevent_instance_group_fallback: Optional[bool] = None + opa_query_path: Optional[str] = None + state: str = "present" + + # Read-only fields (populated from API responses) + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None diff --git a/plugins/plugin_utils/api/v1/inventory.py b/plugins/plugin_utils/api/v1/inventory.py new file mode 100644 index 00000000..02ad549a --- /dev/null +++ b/plugins/plugin_utils/api/v1/inventory.py @@ -0,0 +1,165 @@ +""" +API v1 Inventory dataclass and transform mixin. + +Handles transformations between Ansible format and the Controller API format +for inventory resources. +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ...ansible_models.inventory import AnsibleInventory +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + + +@dataclass +class APIInventory_v1: + """Wire format for Controller inventories.""" + + name: str + organization: Optional[int] = None + description: Optional[str] = None + kind: Optional[str] = None + host_filter: Optional[str] = None + variables: Optional[str] = None + prevent_instance_group_fallback: Optional[bool] = None + opa_query_path: Optional[str] = None + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + +class InventoryTransformMixin_v1(BaseTransformMixin): + """Transforms between AnsibleInventory and APIInventory_v1.""" + + @classmethod + def from_ansible_data(cls, ansible_instance: AnsibleInventory, context: TransformContext) -> APIInventory_v1: + """Forward: Ansible model -> API wire format.""" + params: Dict[str, Any] = { + "name": ansible_instance.new_name or ansible_instance.name, + } + + if ansible_instance.organization is not None: + params["organization"] = context.manager.lookup_resource_id("/api/controller/v2/organizations/", "name", ansible_instance.organization) + + for field in ("description", "kind", "host_filter", "prevent_instance_group_fallback", "opa_query_path"): + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + # Convert variables dict to JSON string for the API + if ansible_instance.variables is not None: + if isinstance(ansible_instance.variables, dict): + params["variables"] = json.dumps(ansible_instance.variables) + else: + params["variables"] = str(ansible_instance.variables) + + # Read-only from API (for building the {id} URL path param in execute) + for field in ("id", "created", "modified", "url"): + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + return APIInventory_v1(**params) + + @classmethod + def from_api(cls, api_data: Dict[str, Any], context: TransformContext) -> AnsibleInventory: + """Reverse: API response -> Ansible model.""" + raw_vars = api_data.get("variables") + variables: Optional[dict] = None + if isinstance(raw_vars, dict): + variables = raw_vars + elif isinstance(raw_vars, str) and raw_vars.strip(): + try: + variables = json.loads(raw_vars) + except ValueError: + variables = None + + org = api_data.get("organization") + + return AnsibleInventory( + id=api_data.get("id"), + name=api_data.get("name", ""), + organization=str(org) if org is not None else "", + description=api_data.get("description"), + kind=api_data.get("kind"), + host_filter=api_data.get("host_filter"), + variables=variables, + prevent_instance_group_fallback=api_data.get("prevent_instance_group_fallback"), + opa_query_path=api_data.get("opa_query_path"), + created=api_data.get("created"), + modified=api_data.get("modified"), + url=api_data.get("url"), + ) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + fields = [ + "name", + "description", + "organization", + "kind", + "host_filter", + "variables", + "prevent_instance_group_fallback", + "opa_query_path", + ] + + return { + "create": EndpointOperation( + path="/api/controller/v2/inventories/", + method="POST", + fields=fields, + required_for="create", + order=1, + ), + "update": EndpointOperation( + path="/api/controller/v2/inventories/{id}/", + method="PATCH", + fields=fields, + path_params=["id"], + required_for="update", + order=1, + ), + "delete": EndpointOperation( + path="/api/controller/v2/inventories/{id}/", + method="DELETE", + fields=[], + path_params=["id"], + required_for="delete", + order=1, + ), + "get": EndpointOperation( + path="/api/controller/v2/inventories/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + "list": EndpointOperation( + path="/api/controller/v2/inventories/", + method="GET", + fields=[], + required_for="find", + order=1, + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + return "name" + + @classmethod + def get_find_list_query_params(cls, ansible_data: APIInventory_v1) -> Dict[str, Any]: + """Scope name lookups by organization — inventory names are only unique per-org.""" + org_id = getattr(ansible_data, "organization", None) + if org_id is not None: + return {"organization": org_id} + return {} diff --git a/plugins/plugin_utils/manager/platform_manager.py b/plugins/plugin_utils/manager/platform_manager.py index 4cf2593b..8cb7d024 100644 --- a/plugins/plugin_utils/manager/platform_manager.py +++ b/plugins/plugin_utils/manager/platform_manager.py @@ -1049,6 +1049,152 @@ def _sort_operations(self, operations: Dict) -> list: return sorted_ops + def manage_associations( + self, + base_path: str, + resource_id: int, + association_field: str, + desired_items: list, + lookup_endpoint: str, + lookup_field: str, + ) -> bool: + """Sync an association sub-endpoint: compare current vs desired, associate/disassociate.""" + self.record_activity() + + resolved_ids = [] + for item in desired_items: + if str(item).isdigit(): + resolved_ids.append(int(item)) + else: + rid = self.lookup_resource_id(lookup_endpoint, lookup_field, str(item)) + if rid is None: + raise ValueError("Could not find %s entry with %s='%s'" % (lookup_endpoint, lookup_field, item)) + resolved_ids.append(rid) + + assoc_url = self._build_url("%s/%s/%s/" % (base_path, resource_id, association_field)) + try: + response = self.session.get(assoc_url, timeout=self.request_timeout, verify=self.requests_verify) + current_data = response.json() if response.status_code == 200 else {} + current_ids = [item["id"] for item in current_data.get("results", [])] + except Exception: + current_ids = [] + + changed = False + errors = [] + + for item_id in resolved_ids: + if item_id not in current_ids: + try: + self.session.post( + assoc_url, + json={"id": item_id, "associate": True}, + timeout=self.request_timeout, + verify=self.requests_verify, + ) + changed = True + except Exception as exc: + errors.append("Failed to associate %s %s: %s" % (association_field, item_id, exc)) + + for item_id in current_ids: + if item_id not in resolved_ids: + try: + self.session.post( + assoc_url, + json={"id": item_id, "disassociate": True}, + timeout=self.request_timeout, + verify=self.requests_verify, + ) + changed = True + except Exception as exc: + errors.append("Failed to disassociate %s %s: %s" % (association_field, item_id, exc)) + + if errors: + raise ValueError("; ".join(errors)) + + return changed + + def manage_sub_resource(self, base_path: str, resource_id: int, sub_path: str, data: Optional[dict] = None) -> bool: + """Manage a secondary sub-endpoint (GET/compare/POST or DELETE).""" + self.record_activity() + + if data is None: + return False + + spec_url = self._build_url("%s/%s/%s/" % (base_path, resource_id, sub_path)) + + if data == {}: + response = self.session.delete(spec_url, timeout=self.request_timeout, verify=self.requests_verify) + return response.status_code in (200, 204) + + try: + current_response = self.session.get(spec_url, timeout=self.request_timeout, verify=self.requests_verify) + current_data = current_response.json() if current_response.status_code == 200 else None + except Exception: + current_data = None + + if data != current_data: + response = self.session.post( + spec_url, + json=data, + timeout=self.request_timeout, + verify=self.requests_verify, + ) + if response.status_code not in (200, 201): + error_msg = "Unknown error" + if response.text: + try: + error_msg = response.json().get("error", response.text) + except Exception: + error_msg = response.text + raise ValueError("Failed to update %s: %s" % (sub_path, error_msg)) + return True + + return False + + def copy_resource(self, module_name: str, source_name_or_id: str, new_name: str, copy_endpoint_path: str) -> dict: + """Copy a resource via its /copy/ sub-endpoint.""" + self.record_activity() + + source = None + last_error = None + try: + source = self.execute( + operation="find", + module_name=module_name, + ansible_data_dict={"name": source_name_or_id}, + ) + except Exception as exc: + last_error = exc + + if not source or not source.get("id"): + if str(source_name_or_id).isdigit(): + try: + source = self.execute( + operation="find", + module_name=module_name, + ansible_data_dict={"id": int(source_name_or_id), "name": str(source_name_or_id)}, + ) + except Exception as exc: + last_error = exc + + if not source or not source.get("id"): + msg = "Could not find %s '%s' to copy from" % (module_name, source_name_or_id) + if last_error: + msg += ": %s" % last_error + raise ValueError(msg) + + copy_url = self._build_url("%s/%s/copy/" % (copy_endpoint_path, source["id"])) + response = self.session.post( + copy_url, + json={"name": new_name}, + timeout=self.request_timeout, + verify=self.requests_verify, + ) + if response.status_code in (200, 201): + return response.json() + else: + raise ValueError("Failed to copy %s: %s" % (module_name, response.text or "Unknown error")) + def lookup_org_ids(self, org_names: list) -> list: """ Convert organization names to IDs. diff --git a/plugins/plugin_utils/manager/rpc_client.py b/plugins/plugin_utils/manager/rpc_client.py index ba29ba5a..879ba1cc 100644 --- a/plugins/plugin_utils/manager/rpc_client.py +++ b/plugins/plugin_utils/manager/rpc_client.py @@ -131,6 +131,33 @@ def search_api(self, endpoint: str, query_params: Optional[dict] = None, return_ """ return self.service_proxy.search_api(endpoint, query_params or {}, return_all, max_objects) + def manage_associations( + self, + base_path: str, + resource_id: int, + association_field: str, + desired_items: list, + lookup_endpoint: str, + lookup_field: str, + ) -> bool: + """Sync an association sub-endpoint via the manager process.""" + return self.service_proxy.manage_associations( + base_path, + resource_id, + association_field, + desired_items, + lookup_endpoint, + lookup_field, + ) + + def manage_sub_resource(self, base_path: str, resource_id: int, sub_path: str, data: Optional[dict] = None) -> bool: + """Manage a secondary sub-endpoint resource via the manager process.""" + return self.service_proxy.manage_sub_resource(base_path, resource_id, sub_path, data) + + def copy_resource(self, module_name: str, source_name_or_id: str, new_name: str, copy_endpoint_path: str) -> dict: + """Copy a resource via the manager process.""" + return self.service_proxy.copy_resource(module_name, source_name_or_id, new_name, copy_endpoint_path) + def shutdown_manager(self) -> dict: """ Request manager to shutdown gracefully. diff --git a/plugins/plugin_utils/platform/base_client.py b/plugins/plugin_utils/platform/base_client.py index e43c9dea..62eef137 100644 --- a/plugins/plugin_utils/platform/base_client.py +++ b/plugins/plugin_utils/platform/base_client.py @@ -126,6 +126,79 @@ def execute(self, operation: str, module_name: str, ansible_data_dict: dict) -> """ pass + def manage_associations( + self, + base_path: str, + resource_id: int, + association_field: str, + desired_items: list, + lookup_endpoint: str, + lookup_field: str, + ) -> bool: + """ + Sync an association sub-endpoint for a resource. + + Compares the desired list of associated items against the current + associations and performs associate/disassociate operations as needed. + + Args: + base_path: API base path for the resource (e.g. '/api/controller/v2/job_templates') + resource_id: ID of the parent resource + association_field: Sub-endpoint name (e.g. 'credentials', 'labels') + desired_items: List of names or IDs to associate + lookup_endpoint: API endpoint for resolving names (e.g. 'credentials') + lookup_field: Field to filter by when resolving (e.g. 'name') + + Returns: + True if any associations were changed, False otherwise + """ + raise NotImplementedError("%s must implement manage_associations()" % type(self).__name__) + + def manage_sub_resource( + self, + base_path: str, + resource_id: int, + sub_path: str, + data: Optional[dict] = None, + ) -> bool: + """ + Manage a secondary sub-endpoint resource (e.g. survey_spec). + + Compares the desired data against the current state and updates if + different. An empty dict signals deletion of the sub-resource. + + Args: + base_path: API base path for the parent resource + resource_id: ID of the parent resource + sub_path: Sub-endpoint path (e.g. 'survey_spec') + data: Desired state. Empty dict {} means delete. + + Returns: + True if the sub-resource was changed, False otherwise + """ + raise NotImplementedError("%s must implement manage_sub_resource()" % type(self).__name__) + + def copy_resource( + self, + module_name: str, + source_name_or_id: str, + new_name: str, + copy_endpoint_path: str, + ) -> dict: + """ + Copy a resource via its /copy/ sub-endpoint. + + Args: + module_name: Module name for find lookup (e.g. 'job_template') + source_name_or_id: Name or ID of the source resource to copy + new_name: Name for the new (copied) resource + copy_endpoint_path: API base path (e.g. '/api/controller/v2/job_templates') + + Returns: + dict: The copied resource data from the API response + """ + raise NotImplementedError("%s must implement copy_resource()" % type(self).__name__) + def lookup_organization_ids(self, names: list) -> list: """ Lookup organization IDs from names (shared helper). diff --git a/plugins/plugin_utils/platform/direct_client.py b/plugins/plugin_utils/platform/direct_client.py index b56fdce9..e20165ee 100644 --- a/plugins/plugin_utils/platform/direct_client.py +++ b/plugins/plugin_utils/platform/direct_client.py @@ -982,6 +982,167 @@ def lookup_organization_names(self, ids: list) -> list: # This should use the cache to avoid repeated lookups pass + def manage_associations( + self, + base_path: str, + resource_id: int, + association_field: str, + desired_items: list, + lookup_endpoint: str, + lookup_field: str, + ) -> bool: + """Sync an association sub-endpoint: compare current vs desired, associate/disassociate.""" + if not self._authenticated: + self._authenticate() + self._authenticated = True + + if self.api_version is None: + try: + self.api_version = self._detect_api_version() + except Exception: + self.api_version = "1" + + resolved_ids = [] + for item in desired_items: + if str(item).isdigit(): + resolved_ids.append(int(item)) + else: + rid = self.lookup_resource_id(lookup_endpoint, lookup_field, str(item)) + if rid is None: + raise ValueError("Could not find %s entry with %s='%s'" % (lookup_endpoint, lookup_field, item)) + resolved_ids.append(rid) + + assoc_url = self._build_url("%s/%s/%s/" % (base_path, resource_id, association_field)) + try: + response = self._make_request("get", assoc_url, operation="manage_associations", resource=association_field) + response_body = response.read() + current_data = json.loads(response_body) if response_body else {} + current_ids = [item["id"] for item in current_data.get("results", [])] + except Exception: + current_ids = [] + + changed = False + errors = [] + + for item_id in resolved_ids: + if item_id not in current_ids: + try: + self._make_request( + "post", + assoc_url, + operation="associate", + resource=association_field, + json={"id": item_id, "associate": True}, + ) + changed = True + except Exception as exc: + errors.append("Failed to associate %s %s: %s" % (association_field, item_id, exc)) + + for item_id in current_ids: + if item_id not in resolved_ids: + try: + self._make_request( + "post", + assoc_url, + operation="disassociate", + resource=association_field, + json={"id": item_id, "disassociate": True}, + ) + changed = True + except Exception as exc: + errors.append("Failed to disassociate %s %s: %s" % (association_field, item_id, exc)) + + if errors: + raise ValueError("; ".join(errors)) + + return changed + + def manage_sub_resource(self, base_path: str, resource_id: int, sub_path: str, data: Optional[dict] = None) -> bool: + """Manage a secondary sub-endpoint (GET/compare/POST or DELETE).""" + if not self._authenticated: + self._authenticate() + self._authenticated = True + + if self.api_version is None: + try: + self.api_version = self._detect_api_version() + except Exception: + self.api_version = "1" + + if data is None: + return False + + spec_url = self._build_url("%s/%s/%s/" % (base_path, resource_id, sub_path)) + + if data == {}: + self._make_request("delete", spec_url, operation="delete_sub_resource", resource=sub_path) + return True + + try: + current_response = self._make_request("get", spec_url, operation="get_sub_resource", resource=sub_path) + current_body = current_response.read() + current_data = json.loads(current_body) if current_body else None + except Exception: + current_data = None + + if data != current_data: + response = self._make_request( + "post", + spec_url, + operation="update_sub_resource", + resource=sub_path, + json=data, + ) + status = getattr(response, "status", getattr(response, "code", 0)) + if status not in (200, 201): + response_body = response.read() if hasattr(response, "read") else "" + raise ValueError("Failed to update %s: %s" % (sub_path, response_body or "Unknown error")) + return True + + return False + + def copy_resource(self, module_name: str, source_name_or_id: str, new_name: str, copy_endpoint_path: str) -> dict: + """Copy a resource via its /copy/ sub-endpoint.""" + source = None + last_error = None + try: + source = self.execute( + operation="find", + module_name=module_name, + ansible_data_dict={"name": source_name_or_id}, + ) + except Exception as exc: + last_error = exc + + if not source or not source.get("id"): + if str(source_name_or_id).isdigit(): + try: + source = self.execute( + operation="find", + module_name=module_name, + ansible_data_dict={"id": int(source_name_or_id), "name": str(source_name_or_id)}, + ) + except Exception as exc: + last_error = exc + + if not source or not source.get("id"): + msg = "Could not find %s '%s' to copy from" % (module_name, source_name_or_id) + if last_error: + msg += ": %s" % last_error + raise ValueError(msg) + + copy_url = self._build_url("%s/%s/copy/" % (copy_endpoint_path, source["id"])) + response = self._make_request( + "post", + copy_url, + operation="copy_resource", + resource=module_name, + json={"name": new_name}, + ) + response_body = response.read() + result = json.loads(response_body) if response_body else {} + return result + def direct_request(self, method: str, path: str, data=None) -> dict: """ Make a raw authenticated HTTP request and return parsed JSON. diff --git a/tests/integration/targets/inventory_test/meta/main.yml b/tests/integration/targets/inventory_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/inventory_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/inventory_test/tasks/main.yml b/tests/integration/targets/inventory_test/tasks/main.yml new file mode 100644 index 00000000..2a467d7b --- /dev/null +++ b/tests/integration/targets/inventory_test/tasks/main.yml @@ -0,0 +1,184 @@ +--- +- name: Generate a test ID + ansible.builtin.set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Preset vars + ansible.builtin.set_fact: + name_prefix: "AAP-Collection-Test-Inventory-{{ test_id }}" + +- name: Run Test + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | bool }}" + + block: + # ----------------------------- + - name: Create Organization 1 + ansible.platform.organization: + name: "{{ name_prefix }}-Organization-1" + register: org1 + # ---------------------------- + + - name: Create inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-1" + organization: "{{ org1.name }}" + description: "Our Foo Cloud Servers" + variables: + foo: bar + register: inv1 + + - name: Assert creation changed + ansible.builtin.assert: + that: + - inv1 is changed + - inv1.inventory.name is defined + - inv1.id is defined + + - name: Check idempotency (re-apply, expect no change) + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-1" + organization: "{{ org1.name }}" + description: "Our Foo Cloud Servers" + variables: + foo: bar + register: inv1_idempotent + + - name: Assert no change on re-apply + ansible.builtin.assert: + that: + - inv1_idempotent is not changed + - inv1_idempotent.id == inv1.id + + - name: Update inventory description + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-1" + organization: "{{ org1.name }}" + description: "Updated description" + register: inv1_updated + + - name: Assert update changed + ansible.builtin.assert: + that: + - inv1_updated is changed + - inv1_updated.id == inv1.id + + - name: Check exists returns true + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-1" + organization: "{{ org1.name }}" + state: exists + register: inv1_exists + + - name: Assert exists is true and no change + ansible.builtin.assert: + that: + - inv1_exists.exists + - inv1_exists is not changed + + - name: Rename inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-1" + new_name: "{{ name_prefix }}-Inventory-1-Renamed" + organization: "{{ org1.name }}" + register: inv1_renamed + + - name: Assert rename changed + ansible.builtin.assert: + that: + - inv1_renamed is changed + - inv1_renamed.id == inv1.id + + - name: Copy inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-Copy" + copy_from: "{{ name_prefix }}-Inventory-1-Renamed" + organization: "{{ org1.name }}" + register: inv_copy + + - name: Assert copy created a distinct inventory + ansible.builtin.assert: + that: + - inv_copy is changed + - inv_copy.id is defined + - inv_copy.id != inv1.id + + - name: Create a constructed inventory with input_inventories + ansible.platform.inventory: + name: "{{ name_prefix }}-Constructed" + organization: "{{ org1.name }}" + kind: constructed + input_inventories: + - "{{ name_prefix }}-Inventory-1-Renamed" + register: inv_constructed + + - name: Assert constructed inventory created + ansible.builtin.assert: + that: + - inv_constructed is changed + - inv_constructed.kind == "constructed" + + - name: Re-apply constructed inventory (idempotent on input_inventories) + ansible.platform.inventory: + name: "{{ name_prefix }}-Constructed" + organization: "{{ org1.name }}" + kind: constructed + input_inventories: + - "{{ name_prefix }}-Inventory-1-Renamed" + register: inv_constructed_idempotent + + - name: Assert no change on re-apply + ansible.builtin.assert: + that: + - inv_constructed_idempotent is not changed + + - name: Delete inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-1-Renamed" + organization: "{{ org1.name }}" + state: absent + register: inv1_deleted + + - name: Assert delete changed + ansible.builtin.assert: + that: + - inv1_deleted is changed + + - name: Delete inventory again (idempotent) + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-1-Renamed" + organization: "{{ org1.name }}" + state: absent + register: inv1_deleted_again + + - name: Assert repeat delete is a no-op + ansible.builtin.assert: + that: + - inv1_deleted_again is not changed + + always: + - name: Delete constructed inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Constructed" + organization: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 + + - name: Delete copied inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory-Copy" + organization: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 + + - name: Delete Organization 1 + ansible.platform.organization: + name: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 +... diff --git a/tests/test_completeness.py b/tests/test_completeness.py index b982fd19..ec49c4b6 100755 --- a/tests/test_completeness.py +++ b/tests/test_completeness.py @@ -79,14 +79,18 @@ def test_meta_runtime(): meta_data = yaml.load(meta_data_string, Loader=yaml.Loader) - action_groups = meta_data.get("action_groups", {}).get("gateway", []) + # Modules extending ansible.platform.auth must be in *some* action_groups entry + # (gateway, controller, etc.) so module_defaults works regardless of which + # service backs them — check the union of every group, not just "gateway". + all_groups = meta_data.get("action_groups", {}) + action_groups = [module for group_modules in all_groups.values() for module in group_modules] needs_to_be_removed = list(set(action_groups) - set(needs_grouping)) needs_to_be_added = list(set(needs_grouping) - set(action_groups)) needs_to_be_removed.sort() needs_to_be_added.sort() - group = "action-groups.gateway" + group = "action-groups.*" if needs_to_be_removed: print( cause_error( diff --git a/tests/unit/plugins/plugin_utils/api/v1/test_inventory.py b/tests/unit/plugins/plugin_utils/api/v1/test_inventory.py new file mode 100644 index 00000000..8e69060e --- /dev/null +++ b/tests/unit/plugins/plugin_utils/api/v1/test_inventory.py @@ -0,0 +1,112 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Tests for the inventory v1 transform mixin (AAP-91390).""" + +from __future__ import absolute_import, division, print_function + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +_COLLECTIONS_PARENT = str(Path(__file__).resolve().parent.parent.parent.parent.parent.parent.parent.parent) +if _COLLECTIONS_PARENT not in sys.path: + sys.path.insert(0, _COLLECTIONS_PARENT) + +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.inventory import ( # noqa: E402 + AnsibleInventory, +) +from ansible_collections.ansible.platform.plugins.plugin_utils.api.v1.inventory import ( # noqa: E402 + APIInventory_v1, + InventoryTransformMixin_v1, +) + + +def _make_context(lookup_return=1): + manager = MagicMock() + manager.lookup_resource_id.return_value = lookup_return + context = MagicMock() + context.manager = manager + return context + + +class TestInventoryTransform(unittest.TestCase): + def test_from_ansible_data_resolves_organization_via_controller_endpoint(self): + ansible = AnsibleInventory(name="Foo Inventory", organization="Bar Org") + context = _make_context(lookup_return=5) + + api = InventoryTransformMixin_v1.from_ansible_data(ansible, context) + + context.manager.lookup_resource_id.assert_called_once_with("/api/controller/v2/organizations/", "name", "Bar Org") + self.assertEqual(api.organization, 5) + self.assertEqual(api.name, "Foo Inventory") + + def test_from_ansible_data_uses_new_name_when_set(self): + ansible = AnsibleInventory(name="Old Name", new_name="New Name", organization="Bar Org") + context = _make_context() + + api = InventoryTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.name, "New Name") + + def test_from_ansible_data_serializes_variables_dict_to_json(self): + ansible = AnsibleInventory(name="Foo", organization="Bar", variables={"key": "value"}) + context = _make_context() + + api = InventoryTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.variables, '{"key": "value"}') + + def test_from_ansible_data_omits_unset_optional_fields(self): + ansible = AnsibleInventory(name="Foo", organization="Bar") + context = _make_context() + + api = InventoryTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertIsNone(api.kind) + self.assertIsNone(api.host_filter) + self.assertIsNone(api.variables) + self.assertIsNone(api.opa_query_path) + + def test_from_api_round_trips_variables_json_string(self): + ansible = InventoryTransformMixin_v1.from_api( + { + "id": 10, + "name": "Foo", + "organization": 5, + "variables": '{"key": "value"}', + }, + _make_context(), + ) + + self.assertEqual(ansible.id, 10) + self.assertEqual(ansible.organization, "5") + self.assertEqual(ansible.variables, {"key": "value"}) + + def test_from_api_handles_missing_variables(self): + ansible = InventoryTransformMixin_v1.from_api({"id": 1, "name": "Foo", "organization": 2}, _make_context()) + self.assertIsNone(ansible.variables) + + def test_get_endpoint_operations_use_controller_paths(self): + ops = InventoryTransformMixin_v1.get_endpoint_operations() + for op_name in ("create", "list"): + self.assertTrue(ops[op_name].path.startswith("/api/controller/v2/inventories")) + self.assertEqual(ops["get"].path, "/api/controller/v2/inventories/{id}/") + + def test_get_lookup_field_is_name(self): + self.assertEqual(InventoryTransformMixin_v1.get_lookup_field(), "name") + + def test_get_find_list_query_params_scopes_by_organization(self): + api_data = APIInventory_v1(name="Foo", organization=5) + params = InventoryTransformMixin_v1.get_find_list_query_params(api_data) + self.assertEqual(params, {"organization": 5}) + + def test_get_find_list_query_params_empty_without_organization(self): + api_data = APIInventory_v1(name="Foo", organization=None) + params = InventoryTransformMixin_v1.get_find_list_query_params(api_data) + self.assertEqual(params, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mock_gateway_server.py b/tools/mock_gateway_server.py index 5d25175f..10b17447 100644 --- a/tools/mock_gateway_server.py +++ b/tools/mock_gateway_server.py @@ -53,6 +53,8 @@ def __init__( start_id: int = 2000, patch_fields: Optional[List[str]] = None, post_only_fields: Optional[Dict[str, Callable[[], Any]]] = None, + url_prefix: Optional[str] = None, + associations: Optional[List[str]] = None, ): self.lock = threading.Lock() self.resource_name = resource_name @@ -61,9 +63,19 @@ def __init__( # post_only_fields: generated on POST, returned in create response, never stored. # Simulates API-generated secrets like client_secret that are only visible once. self.post_only_fields: Dict[str, Callable[[], Any]] = post_only_fields or {} + # url_prefix: full base path (e.g. "/api/controller/v2/inventories/") used to + # build the "url" field and copies. None = legacy gateway pattern built from `version`. + self.url_prefix: Optional[str] = url_prefix + # associations: sub-endpoint names this resource supports (e.g. "instance_groups"). + self.associations: List[str] = associations or [] self._next_id = start_id self._items: Dict[int, Dict[str, Any]] = {} + def _build_url(self, version: str, item_id: int) -> str: + if self.url_prefix: + return f"{self.url_prefix}{item_id}/" + return f"/api/gateway/v{version}/{self.resource_name}/{item_id}/" + def create(self, version: str, payload: Dict[str, Any]) -> Dict[str, Any]: with self.lock: for rf in self.required_fields: @@ -75,7 +87,7 @@ def create(self, version: str, payload: Dict[str, Any]) -> Dict[str, Any]: "id": item_id, "created": _now_iso(), "modified": _now_iso(), - "url": f"/api/gateway/v{version}/{self.resource_name}/{item_id}/", + "url": self._build_url(version, item_id), } item.update({k: v for k, v in payload.items() if v is not None}) self._items[item_id] = item @@ -86,6 +98,48 @@ def create(self, version: str, payload: Dict[str, Any]) -> Dict[str, Any]: response[field_name] = generator() return response + def get_associations(self, item_id: int, field: str) -> List[int]: + with self.lock: + if item_id not in self._items: + raise KeyError("not found") + return list(self._items[item_id].get(f"_assoc_{field}", [])) + + def set_association(self, item_id: int, field: str, ref_id: Any, associate: bool) -> bool: + with self.lock: + if item_id not in self._items: + raise KeyError("not found") + item = dict(self._items[item_id]) + key = f"_assoc_{field}" + current = set(item.get(key, [])) + changed = False + if associate and ref_id not in current: + current.add(ref_id) + changed = True + elif not associate and ref_id in current: + current.discard(ref_id) + changed = True + item[key] = sorted(current) + item["modified"] = _now_iso() + self._items[item_id] = item + return changed + + def copy_item(self, item_id: int, version: str, new_name: str) -> Dict[str, Any]: + with self.lock: + if item_id not in self._items: + raise KeyError("not found") + if not new_name: + raise ValueError("name is required for copy") + new_id = self._next_id + self._next_id += 1 + new_item = dict(self._items[item_id]) + new_item["id"] = new_id + new_item["name"] = new_name + new_item["created"] = _now_iso() + new_item["modified"] = _now_iso() + new_item["url"] = self._build_url(version, new_id) + self._items[new_id] = new_item + return dict(new_item) + def list_items(self, filters: Optional[Dict[str, str]] = None) -> Dict[str, Any]: with self.lock: items = list(self._items.values()) @@ -191,6 +245,31 @@ class Store: # Generic resource stores (keyed by endpoint name) _resources: Dict[str, GenericResource] = field(default_factory=dict) + # Generic Controller-side resource stores (/api/controller/v2/{name}/). + # Organizations are handled separately (see _route_controller) — Controller + # mirrors the Gateway's org table via a shared ID space, so it reuses + # orgs_by_id/orgs_by_name rather than a second independent store. + _controller_resources: Dict[str, GenericResource] = field(default_factory=dict) + + def _init_controller_resources(self) -> None: + """Create Controller-side (/api/controller/v2/) generic resource stores.""" + self._controller_resources["instance_groups"] = GenericResource( + resource_name="instance_groups", + required_fields=["name"], + start_id=100, + url_prefix="/api/controller/v2/instance_groups/", + ) + self._controller_resources["inventories"] = GenericResource( + resource_name="inventories", + required_fields=["name", "organization"], + start_id=5000, + url_prefix="/api/controller/v2/inventories/", + associations=["instance_groups", "input_inventories"], + ) + + def controller_resource(self, name: str) -> Optional[GenericResource]: + return self._controller_resources.get(name) + def _init_resources(self) -> None: """Create all generic resource stores with appropriate config.""" # Applications: client_secret returned on POST only, never on GET/PATCH. @@ -543,14 +622,20 @@ def _parse_json_body(self) -> Dict[str, Any]: def _handle_generic_resource(self, resource_name: str, parts: list, version: str, qs: Dict[str, list]) -> bool: """ - Handle CRUD for any generic resource. + Handle CRUD for any generic Gateway resource. Returns True if the request was handled, False otherwise. """ store = self.store.resource(resource_name) if store is None: return False + return self._handle_generic_resource_store(store, parts, version, qs) - # List / Create: /api/gateway/vX/{resource}/ + def _handle_generic_resource_store(self, store: "GenericResource", parts: list, version: str, qs: Dict[str, list]) -> bool: + """ + Handle list/create/get/patch/delete for any generic resource store. + Returns True if the request was handled, False otherwise. + """ + # List / Create: /api/{service}/vX/{resource}/ if len(parts) == 4: if self.command == "GET": filters = {k: v[0] for k, v in qs.items() if v} @@ -595,6 +680,94 @@ def _handle_generic_resource(self, resource_name: str, parts: list, version: str return False + # ------------------------------------------------------------------ + # Controller router (/api/controller/v2/...) + # ------------------------------------------------------------------ + + def _route_controller(self, parts: list, qs: Dict[str, list]) -> None: + if len(parts) < 3 or parts[2] != "v2": + self._send_json(404, {"detail": "Not Found"}) + return + + resource = parts[3] if len(parts) >= 4 else None + + # Organizations: Controller mirrors the Gateway's org table via a shared + # ID space (single unified-auth source of truth), so reuse that store + # instead of a second independent one. + if resource == "organizations": + if len(parts) == 4 and self.command == "GET": + name = (qs.get("name") or [None])[0] + self._send_json(200, self.store.list_orgs(name=name)) + return + if len(parts) == 5: + try: + org_id = int(parts[4]) + except ValueError: + self._send_json(404, {"detail": "Not Found"}) + return + if self.command == "GET": + try: + self._send_json(200, self.store.get_org(org_id)) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + self._send_json(404, {"detail": "Not Found"}) + return + + store = self.store.controller_resource(resource) if resource else None + if store is None: + self._send_json(404, {"detail": "Not Found"}) + return + + # Association / copy sub-endpoint: /api/controller/v2/{resource}/{id}/{field}/ + if len(parts) == 6: + try: + item_id = int(parts[4]) + except ValueError: + self._send_json(404, {"detail": "Not Found"}) + return + field = parts[5] + + if field == "copy": + if self.command == "POST": + try: + payload = self._parse_json_body() + copied = store.copy_item(item_id, "2", payload.get("name")) + self._send_json(201, copied) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + except ValueError as e: + self._send_json(400, {"detail": str(e)}) + return + self._send_json(404, {"detail": "Not Found"}) + return + + if field in store.associations: + if self.command == "GET": + try: + ids = store.get_associations(item_id, field) + self._send_json(200, {"count": len(ids), "results": [{"id": i} for i in ids]}) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + if self.command == "POST": + try: + payload = self._parse_json_body() + ref_id = payload.get("id") + associate = bool(payload.get("associate")) and not payload.get("disassociate") + store.set_association(item_id, field, ref_id, associate) + self._send_empty(204) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + + self._send_json(404, {"detail": "Not Found"}) + return + + if self._handle_generic_resource_store(store, parts, "2", qs): + return + self._send_json(404, {"detail": "Not Found"}) + # ------------------------------------------------------------------ # Main router # ------------------------------------------------------------------ @@ -632,6 +805,10 @@ def _route(self) -> None: parts = [p for p in path.split("/") if p] + if len(parts) >= 2 and parts[0] == "api" and parts[1] == "controller": + self._route_controller(parts, qs) + return + if len(parts) < 3 or parts[0] != "api" or parts[1] != "gateway": self._send_json(404, {"detail": "Not Found"}) return @@ -847,6 +1024,7 @@ def main() -> int: store = Store() store._init_resources() + store._init_controller_resources() store.seed_defaults() MockGatewayHandler.store = store From 3fd1c68f3ca31c9d15d1cb882939992a4487123f Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 13:28:12 -0400 Subject: [PATCH 02/10] Add host module migrated from awx.awx/ansible.controller (AAP-91390) Shape 1 CRUD module, Pattern A (no associations/copy). Depends on inventory for name->id lookup and inventory-scoped name uniqueness. - New module, action plugin, transform mixin, Ansible model. - Register under meta/runtime.yml action_groups.controller. - Register hosts as a generic Controller resource in the mock server. - Unit tests, 3-connection-mode Molecule scenario, integration test target. Co-Authored-By: Claude Sonnet 5 --- changelogs/fragments/aap_91390_host.yml | 3 + extensions/molecule/host_mock/cleanup.yml | 150 +++++++++++ extensions/molecule/host_mock/converge.yml | 253 ++++++++++++++++++ extensions/molecule/host_mock/inventory.yml | 15 ++ extensions/molecule/host_mock/molecule.yml | 34 +++ extensions/molecule/host_mock/verify.yml | 106 ++++++++ meta/runtime.yml | 1 + plugins/action/host.py | 14 + plugins/modules/host.py | 133 +++++++++ plugins/plugin_utils/ansible_models/host.py | 35 +++ plugins/plugin_utils/api/v1/host.py | 159 +++++++++++ .../targets/host_test/meta/main.yml | 4 + .../targets/host_test/tasks/main.yml | 123 +++++++++ .../plugins/plugin_utils/api/v1/test_host.py | 100 +++++++ tools/mock_gateway_server.py | 6 + 15 files changed, 1136 insertions(+) create mode 100644 changelogs/fragments/aap_91390_host.yml create mode 100644 extensions/molecule/host_mock/cleanup.yml create mode 100644 extensions/molecule/host_mock/converge.yml create mode 100644 extensions/molecule/host_mock/inventory.yml create mode 100644 extensions/molecule/host_mock/molecule.yml create mode 100644 extensions/molecule/host_mock/verify.yml create mode 100644 plugins/action/host.py create mode 100644 plugins/modules/host.py create mode 100644 plugins/plugin_utils/ansible_models/host.py create mode 100644 plugins/plugin_utils/api/v1/host.py create mode 100644 tests/integration/targets/host_test/meta/main.yml create mode 100644 tests/integration/targets/host_test/tasks/main.yml create mode 100644 tests/unit/plugins/plugin_utils/api/v1/test_host.py diff --git a/changelogs/fragments/aap_91390_host.yml b/changelogs/fragments/aap_91390_host.yml new file mode 100644 index 00000000..28ddc0b7 --- /dev/null +++ b/changelogs/fragments/aap_91390_host.yml @@ -0,0 +1,3 @@ +minor_changes: + - host - add module migrated from awx.awx/ansible.controller + (https://issues.redhat.com/browse/AAP-91390). diff --git a/extensions/molecule/host_mock/cleanup.yml b/extensions/molecule/host_mock/cleanup.yml new file mode 100644 index 00000000..401ec003 --- /dev/null +++ b/extensions/molecule/host_mock/cleanup.yml @@ -0,0 +1,150 @@ +--- +# Cleanup: delete hosts/inventories/organizations created by converge. +- name: Cleanup — delete host, inventory, organization (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org Host Local" + molecule_inv_name: "Molecule Test Inventory Host Local" + molecule_host_name: "molecule-host-local" + tasks: + - name: Delete host (connection local) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + register: delete_result_local + failed_when: false + vars: + ansible_connection: local + + - name: Assert host removed or already absent (connection local) + ansible.builtin.assert: + that: delete_result_local is not failed + fail_msg: "Cleanup: failed to delete host {{ molecule_host_name }}." + vars: + ansible_connection: local + + - name: Delete inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Delete organization (connection local) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete host, inventory, organization (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org Host HTTP Direct" + molecule_inv_name: "Molecule Test Inventory Host HTTP Direct" + molecule_host_name: "molecule-host-http-direct" + tasks: + - name: Delete host (http direct) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + register: delete_result_http_direct + failed_when: false + + - name: Assert host removed or already absent (http direct) + ansible.builtin.assert: + that: delete_result_http_direct is not failed + fail_msg: "Cleanup: failed to delete host {{ molecule_host_name }}." + + - name: Delete inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Delete organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete host, inventory, organization (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org Host HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory Host HTTP Persistent" + molecule_host_name: "molecule-host-http-persistent" + tasks: + - name: Delete host (http persistent) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + register: delete_result_http_persistent + failed_when: false + + - name: Assert host removed or already absent (http persistent) + ansible.builtin.assert: + that: delete_result_http_persistent is not failed + fail_msg: "Cleanup: failed to delete host {{ molecule_host_name }}." + + - name: Delete inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Delete organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local +... diff --git a/extensions/molecule/host_mock/converge.yml b/extensions/molecule/host_mock/converge.yml new file mode 100644 index 00000000..1841cc72 --- /dev/null +++ b/extensions/molecule/host_mock/converge.yml @@ -0,0 +1,253 @@ +--- +# Converge: host create, idempotency, update, delete against the mock Gateway/Controller. +# Play 1: health check runs on controller (connection: local); platform connection cannot run uri. +- name: Ensure mock Gateway is reachable + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + tasks: + - name: Wait for mock Gateway health endpoint + ansible.builtin.uri: + url: "{{ gateway_hostname }}/health" + method: GET + status_code: 200 + register: health + retries: 12 + delay: 5 + until: health.status == 200 + vars: + ansible_connection: local + + - name: Ensure /tmp/ap directory exists + ansible.builtin.file: + path: /tmp/ap + state: directory + mode: "0755" + vars: + ansible_connection: local + + - name: Create manager survive flag (Molecule keeps manager alive across phases) + ansible.builtin.file: + path: /tmp/ap/.survive + state: touch + mode: "0600" + vars: + ansible_connection: local + +# Play 2: full feature coverage (connection local) — create, idempotency, update, delete. +- name: Converge — host (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org Host Local" + molecule_inv_name: "Molecule Test Inventory Host Local" + molecule_host_name: "molecule-host-local" + tasks: + - name: Create organization + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_local + vars: + ansible_connection: local + + - name: Create inventory + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_local.name }}" + register: inv_local + vars: + ansible_connection: local + + - name: Create host + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_local.name }}" + description: "Created by Molecule host_mock (connection local)" + variables: + example_var: 123 + register: create_result_local + vars: + ansible_connection: local + + - name: Assert create changed (connection local) + ansible.builtin.assert: + that: + - create_result_local is changed + - create_result_local.host.id is defined + - create_result_local.host.name == molecule_host_name + fail_msg: "Create (local) should report changed. create_result_local={{ create_result_local }}" + vars: + ansible_connection: local + + - name: Run again idempotency (connection local) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_local.name }}" + description: "Created by Molecule host_mock (connection local)" + variables: + example_var: 123 + register: idem_result_local + vars: + ansible_connection: local + + - name: Assert idempotent run did not change (connection local) + ansible.builtin.assert: + that: idem_result_local is not changed + fail_msg: "Idempotent run (local) should not report changed. idem_result_local={{ idem_result_local }}" + vars: + ansible_connection: local + + - name: Update and disable host (connection local) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_local.name }}" + description: "Updated by Molecule host_mock (connection local)" + enabled: false + register: update_result_local + vars: + ansible_connection: local + + - name: Assert update changed (connection local) + ansible.builtin.assert: + that: + - update_result_local is changed + - update_result_local.host.enabled == false + fail_msg: "Update (local) should report changed. update_result_local={{ update_result_local }}" + vars: + ansible_connection: local + +# Play 3: basic CRUD parity (http direct). +- name: Converge — host (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org Host HTTP Direct" + molecule_inv_name: "Molecule Test Inventory Host HTTP Direct" + molecule_host_name: "molecule-host-http-direct" + tasks: + - name: Create organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_direct + + - name: Create inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_direct.name }}" + register: inv_http_direct + + - name: Create host (http direct) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_http_direct.name }}" + description: "Created by Molecule host_mock (http direct)" + register: create_result_http_direct + + - name: Assert create changed (http direct) + ansible.builtin.assert: + that: + - create_result_http_direct is changed + - create_result_http_direct.host.id is defined + fail_msg: "Create (http direct) should report changed. create_result_http_direct={{ create_result_http_direct }}" + + - name: Run again idempotency (http direct) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_http_direct.name }}" + description: "Created by Molecule host_mock (http direct)" + register: idem_result_http_direct + + - name: Assert idempotent run did not change (http direct) + ansible.builtin.assert: + that: idem_result_http_direct is not changed + fail_msg: "Idempotent run (http direct) should not report changed. idem_result_http_direct={{ idem_result_http_direct }}" + + - name: Update host (http direct) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_http_direct.name }}" + description: "Updated by Molecule host_mock (http direct)" + register: update_result_http_direct + + - name: Assert update changed (http direct) + ansible.builtin.assert: + that: update_result_http_direct is changed + fail_msg: "Update (http direct) should report changed. update_result_http_direct={{ update_result_http_direct }}" + +# Play 4: basic CRUD parity (http persistent). +- name: Converge — host (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org Host HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory Host HTTP Persistent" + molecule_host_name: "molecule-host-http-persistent" + tasks: + - name: Create organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_persistent + + - name: Create inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_persistent.name }}" + register: inv_http_persistent + + - name: Create host (http persistent) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_http_persistent.name }}" + description: "Created by Molecule host_mock (http persistent)" + register: create_result_http_persistent + + - name: Assert create changed (http persistent) + ansible.builtin.assert: + that: + - create_result_http_persistent is changed + - create_result_http_persistent.host.id is defined + fail_msg: "Create (http persistent) should report changed. create_result_http_persistent={{ create_result_http_persistent }}" + + - name: Run again idempotency (http persistent) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_http_persistent.name }}" + description: "Created by Molecule host_mock (http persistent)" + register: idem_result_http_persistent + + - name: Assert idempotent run did not change (http persistent) + ansible.builtin.assert: + that: idem_result_http_persistent is not changed + fail_msg: "Idempotent run (http persistent) should not report changed. idem_result_http_persistent={{ idem_result_http_persistent }}" + + - name: Update host (http persistent) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ inv_http_persistent.name }}" + description: "Updated by Molecule host_mock (http persistent)" + register: update_result_http_persistent + + - name: Assert update changed (http persistent) + ansible.builtin.assert: + that: update_result_http_persistent is changed + fail_msg: "Update (http persistent) should report changed. update_result_http_persistent={{ update_result_http_persistent }}" +... diff --git a/extensions/molecule/host_mock/inventory.yml b/extensions/molecule/host_mock/inventory.yml new file mode 100644 index 00000000..f1568f7a --- /dev/null +++ b/extensions/molecule/host_mock/inventory.yml @@ -0,0 +1,15 @@ +--- +# host_mock scenario: use scenario inventory so we can mix connection types. +# First play (health check) uses connection: local; other plays use ansible.platform.http. +# Mock gateway vars are set here and in play vars. Structure matches shared inventory for parse compatibility. +all: + vars: + ansible_connection: local + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + children: + gateway_under_test: + hosts: + localhost: {} diff --git a/extensions/molecule/host_mock/molecule.yml b/extensions/molecule/host_mock/molecule.yml new file mode 100644 index 00000000..aece3415 --- /dev/null +++ b/extensions/molecule/host_mock/molecule.yml @@ -0,0 +1,34 @@ +--- +# Scenario: test ansible.platform.host against the mock Gateway/Controller server (no real AAP). +# Requires the mock to be running (e.g. "molecule test --all" or default scenario create). +driver: + name: default + +platforms: + - name: localhost + +# Use scenario inventory (connection: local + gateway vars). Converge: play 2 direct, play 3 persistent, play 4 connection local. +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + +provisioner: + name: ansible + playbooks: + converge: converge.yml + verify: verify.yml + cleanup: cleanup.yml + config_options: + defaults: + collections_path: "${MOLECULE_SCENARIO_DIRECTORY}/../../../../../../" + # Write verbose messages (vv/vvvv) to ANSIBLE_LOG_PATH even without -v on the terminal. + log_verbosity: 4 + +scenario: + test_sequence: + - converge + - verify + - cleanup +... diff --git a/extensions/molecule/host_mock/verify.yml b/extensions/molecule/host_mock/verify.yml new file mode 100644 index 00000000..1d10c9b6 --- /dev/null +++ b/extensions/molecule/host_mock/verify.yml @@ -0,0 +1,106 @@ +--- +# Verify: all three connection scenarios (local, http direct, http persistent). +- name: Verify — host created with connection local (mock) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_inv_name: "Molecule Test Inventory Host Local" + molecule_host_name: "molecule-host-local" + tasks: + - name: Get host (state exists, connection local) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ molecule_inv_name }}" + state: exists + register: exists_result_local + vars: + ansible_connection: local + + - name: Assert host was found (connection local) + ansible.builtin.assert: + that: + - exists_result_local is not failed + - exists_result_local.get('exists') | default(false) | bool + - exists_result_local.get('host') is defined + fail_msg: "Verify: host {{ molecule_host_name }} not found (connection local)." + vars: + ansible_connection: local + + - name: Assert host disabled and description updated (connection local) + ansible.builtin.assert: + that: + - exists_result_local.host.description == "Updated by Molecule host_mock (connection local)" + - exists_result_local.host.enabled == false + fail_msg: "Verify: host (local) was not updated as expected." + vars: + ansible_connection: local + +- name: Verify — host created with http direct (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_inv_name: "Molecule Test Inventory Host HTTP Direct" + molecule_host_name: "molecule-host-http-direct" + tasks: + - name: Get host (state exists, http direct) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ molecule_inv_name }}" + state: exists + register: exists_result_http_direct + + - name: Assert host was found (http direct) + ansible.builtin.assert: + that: + - exists_result_http_direct is not failed + - exists_result_http_direct.get('exists') | default(false) | bool + fail_msg: "Verify: host {{ molecule_host_name }} not found (http direct)." + + - name: Assert description updated (http direct) + ansible.builtin.assert: + that: exists_result_http_direct.host.description == "Updated by Molecule host_mock (http direct)" + fail_msg: "Verify: host (http direct) description was not updated." + +- name: Verify — host created with http persistent (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_inv_name: "Molecule Test Inventory Host HTTP Persistent" + molecule_host_name: "molecule-host-http-persistent" + tasks: + - name: Get host (state exists, http persistent) + ansible.platform.host: + name: "{{ molecule_host_name }}" + inventory: "{{ molecule_inv_name }}" + state: exists + register: exists_result_http_persistent + + - name: Assert host was found (http persistent) + ansible.builtin.assert: + that: + - exists_result_http_persistent is not failed + - exists_result_http_persistent.get('exists') | default(false) | bool + fail_msg: "Verify: host {{ molecule_host_name }} not found (http persistent)." + + - name: Assert description updated (http persistent) + ansible.builtin.assert: + that: exists_result_http_persistent.host.description == "Updated by Molecule host_mock (http persistent)" + fail_msg: "Verify: host (http persistent) description was not updated." +... diff --git a/meta/runtime.yml b/meta/runtime.yml index 35026fbc..6dd2f1df 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -25,5 +25,6 @@ action_groups: - ui_plugin_route - user controller: + - host - inventory ... diff --git a/plugins/action/host.py b/plugins/action/host.py new file mode 100644 index 00000000..41265dc4 --- /dev/null +++ b/plugins/action/host.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# (c) 2026, Ansible Platform Collection Contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.host import AnsibleHost + + +class ActionModule(BaseResourceActionPlugin): + MODULE_NAME = "host" + MODEL_CLASS = AnsibleHost diff --git a/plugins/modules/host.py b/plugins/modules/host.py new file mode 100644 index 00000000..c5bdf537 --- /dev/null +++ b/plugins/modules/host.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2017, Wayne Witzel III +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This module is implemented as an action plugin. +# See plugins/action/host.py for the implementation. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: host +author: Red Hat (@RedHatOfficial) +short_description: Create, update, or destroy Automation Platform Controller hosts +description: + - Create, update, or destroy Automation Platform Controller hosts. +version_added: "3.0.0" + +options: + name: + description: + - The name to use for the host. + required: true + type: str + + new_name: + description: + - Setting this option will change the existing name (looked up via the name field). + type: str + + description: + description: + - The description to use for the host. + type: str + + inventory: + description: + - Inventory name, ID, or named URL the host should be made a member of. + required: true + type: str + + enabled: + description: + - If the host should be enabled. + type: bool + + instance_id: + description: + - The instance ID for cloud-provided hosts. + type: str + + variables: + description: + - Variables to use for the host. + type: dict + + state: + description: + - Desired state of the host. + - C(present) ensures the host exists (create or update); idempotent. + - C(absent) removes the host; idempotent if already absent. + - C(exists) reads and returns the current host (no change). + type: str + choices: ['present', 'absent', 'exists', 'enforced'] + default: 'present' + +extends_documentation_fragment: + - ansible.platform.state + - ansible.platform.auth + +seealso: + - module: ansible.controller.host + - module: awx.awx.host +""" + +EXAMPLES = """ +- name: Add host + ansible.platform.host: + name: localhost + description: "Local Host" + inventory: "Local Inventory" + state: present + variables: + example_var: 123 + +- name: Check whether a host exists (no change) + ansible.platform.host: + name: localhost + inventory: "Local Inventory" + state: exists + +- name: Delete a host + ansible.platform.host: + name: localhost + inventory: "Local Inventory" + state: absent +... +""" + +RETURN = """ +changed: + description: Whether the host was created, updated, or deleted. + returned: always + type: bool + +host: + description: > + The host resource as it exists after the operation. + Contains only the fields accepted as module input (argspec fields) plus C(id). + returned: when state is present, exists, or enforced + type: dict + contains: + id: + description: Numeric database ID of the host. + type: int + name: + description: Name of the host. + type: str + inventory: + description: ID of the inventory the host belongs to. + type: int + enabled: + description: Whether the host is enabled. + type: bool + variables: + description: Host variables. + type: dict +... +""" diff --git a/plugins/plugin_utils/ansible_models/host.py b/plugins/plugin_utils/ansible_models/host.py new file mode 100644 index 00000000..d256d8f9 --- /dev/null +++ b/plugins/plugin_utils/ansible_models/host.py @@ -0,0 +1,35 @@ +""" +Ansible Host dataclass - user-facing stable interface. + +This dataclass represents the host as seen by Ansible playbooks. +Field names and types remain stable across API versions. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class AnsibleHost: + """Ansible representation of a Controller host.""" + + # Required / identity + name: str + + # Optional fields + # inventory is required at the DOCUMENTATION/argspec level; defaults to None + # here so internal callers building a partial instance for a lookup don't + # need to supply it (matches AnsibleInventory's organization precedent). + inventory: Optional[str] = None + new_name: Optional[str] = None + description: Optional[str] = None + enabled: Optional[bool] = None + instance_id: Optional[str] = None + variables: Optional[dict] = None + state: str = "present" + + # Read-only fields (populated from API responses) + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None diff --git a/plugins/plugin_utils/api/v1/host.py b/plugins/plugin_utils/api/v1/host.py new file mode 100644 index 00000000..581bbf95 --- /dev/null +++ b/plugins/plugin_utils/api/v1/host.py @@ -0,0 +1,159 @@ +""" +API v1 Host dataclass and transform mixin. + +Handles transformations between Ansible format and the Controller API format +for host resources. +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ...ansible_models.host import AnsibleHost +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + + +@dataclass +class APIHost_v1: + """Wire format for Controller hosts.""" + + name: str + inventory: Optional[int] = None + description: Optional[str] = None + enabled: Optional[bool] = None + instance_id: Optional[str] = None + variables: Optional[str] = None + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + +class HostTransformMixin_v1(BaseTransformMixin): + """Transforms between AnsibleHost and APIHost_v1.""" + + @classmethod + def from_ansible_data(cls, ansible_instance: AnsibleHost, context: TransformContext) -> APIHost_v1: + """Forward: Ansible model -> API wire format.""" + params: Dict[str, Any] = { + "name": ansible_instance.new_name or ansible_instance.name, + } + + if ansible_instance.inventory is not None: + params["inventory"] = context.manager.lookup_resource_id("/api/controller/v2/inventories/", "name", ansible_instance.inventory) + + for field in ("description", "enabled", "instance_id"): + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + # Convert variables dict to JSON string for the API + if ansible_instance.variables is not None: + if isinstance(ansible_instance.variables, dict): + params["variables"] = json.dumps(ansible_instance.variables) + else: + params["variables"] = str(ansible_instance.variables) + + # Read-only from API (for building the {id} URL path param in execute) + for field in ("id", "created", "modified", "url"): + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + return APIHost_v1(**params) + + @classmethod + def from_api(cls, api_data: Dict[str, Any], context: TransformContext) -> AnsibleHost: + """Reverse: API response -> Ansible model.""" + raw_vars = api_data.get("variables") + variables: Optional[dict] = None + if isinstance(raw_vars, dict): + variables = raw_vars + elif isinstance(raw_vars, str) and raw_vars.strip(): + try: + variables = json.loads(raw_vars) + except ValueError: + variables = None + + inventory = api_data.get("inventory") + + return AnsibleHost( + id=api_data.get("id"), + name=api_data.get("name", ""), + inventory=str(inventory) if inventory is not None else "", + description=api_data.get("description"), + enabled=api_data.get("enabled"), + instance_id=api_data.get("instance_id"), + variables=variables, + created=api_data.get("created"), + modified=api_data.get("modified"), + url=api_data.get("url"), + ) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + fields = [ + "name", + "description", + "inventory", + "enabled", + "instance_id", + "variables", + ] + + return { + "create": EndpointOperation( + path="/api/controller/v2/hosts/", + method="POST", + fields=fields, + required_for="create", + order=1, + ), + "update": EndpointOperation( + path="/api/controller/v2/hosts/{id}/", + method="PATCH", + fields=fields, + path_params=["id"], + required_for="update", + order=1, + ), + "delete": EndpointOperation( + path="/api/controller/v2/hosts/{id}/", + method="DELETE", + fields=[], + path_params=["id"], + required_for="delete", + order=1, + ), + "get": EndpointOperation( + path="/api/controller/v2/hosts/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + "list": EndpointOperation( + path="/api/controller/v2/hosts/", + method="GET", + fields=[], + required_for="find", + order=1, + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + return "name" + + @classmethod + def get_find_list_query_params(cls, ansible_data: APIHost_v1) -> Dict[str, Any]: + """Scope name lookups by inventory — host names are only unique per-inventory.""" + inventory_id = getattr(ansible_data, "inventory", None) + if inventory_id is not None: + return {"inventory": inventory_id} + return {} diff --git a/tests/integration/targets/host_test/meta/main.yml b/tests/integration/targets/host_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/host_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/host_test/tasks/main.yml b/tests/integration/targets/host_test/tasks/main.yml new file mode 100644 index 00000000..ab883803 --- /dev/null +++ b/tests/integration/targets/host_test/tasks/main.yml @@ -0,0 +1,123 @@ +--- +- name: Generate a test ID + ansible.builtin.set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Preset vars + ansible.builtin.set_fact: + name_prefix: "AAP-Collection-Test-Host-{{ test_id }}" + +- name: Run Test + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | bool }}" + + block: + - name: Create Organization + ansible.platform.organization: + name: "{{ name_prefix }}-Organization" + register: org1 + + - name: Create Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + register: inv1 + + - name: Create host + ansible.platform.host: + name: "{{ name_prefix }}-Test-Host" + inventory: "{{ inv1.name }}" + variables: + example_var: 123 + register: created_host + + - name: Assert creation changed + ansible.builtin.assert: + that: + - created_host is changed + - created_host.host.name is defined + - created_host.host.variables.example_var == 123 + + - name: Check idempotency (re-apply, expect no change) + ansible.platform.host: + name: "{{ name_prefix }}-Test-Host" + inventory: "{{ inv1.name }}" + variables: + example_var: 123 + register: idempotent_host + + - name: Assert no change on re-apply + ansible.builtin.assert: + that: + - idempotent_host is not changed + + - name: Update host description and disable it + ansible.platform.host: + name: "{{ name_prefix }}-Test-Host" + inventory: "{{ inv1.name }}" + description: "Updated" + enabled: false + register: updated_host + + - name: Assert update changed + ansible.builtin.assert: + that: + - updated_host is changed + - updated_host.host.id == created_host.host.id + + - name: Check exists returns true + ansible.platform.host: + name: "{{ name_prefix }}-Test-Host" + inventory: "{{ inv1.name }}" + state: exists + register: exists_check + + - name: Assert exists is true and no change + ansible.builtin.assert: + that: + - exists_check.exists + - exists_check is not changed + + - name: Delete host + ansible.platform.host: + name: "{{ name_prefix }}-Test-Host" + inventory: "{{ inv1.name }}" + state: absent + register: deleted_host + + - name: Assert delete changed + ansible.builtin.assert: + that: + - deleted_host is changed + + - name: Delete host again (idempotent) + ansible.platform.host: + name: "{{ name_prefix }}-Test-Host" + inventory: "{{ inv1.name }}" + state: absent + register: deleted_host_again + + - name: Assert repeat delete is a no-op + ansible.builtin.assert: + that: + - deleted_host_again is not changed + + always: + - name: Delete Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 + + - name: Delete Organization + ansible.platform.organization: + name: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 +... diff --git a/tests/unit/plugins/plugin_utils/api/v1/test_host.py b/tests/unit/plugins/plugin_utils/api/v1/test_host.py new file mode 100644 index 00000000..520f17c4 --- /dev/null +++ b/tests/unit/plugins/plugin_utils/api/v1/test_host.py @@ -0,0 +1,100 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Tests for the host v1 transform mixin (AAP-91390).""" + +from __future__ import absolute_import, division, print_function + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +_COLLECTIONS_PARENT = str(Path(__file__).resolve().parent.parent.parent.parent.parent.parent.parent.parent) +if _COLLECTIONS_PARENT not in sys.path: + sys.path.insert(0, _COLLECTIONS_PARENT) + +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.host import ( # noqa: E402 + AnsibleHost, +) +from ansible_collections.ansible.platform.plugins.plugin_utils.api.v1.host import ( # noqa: E402 + APIHost_v1, + HostTransformMixin_v1, +) + + +def _make_context(lookup_return=1): + manager = MagicMock() + manager.lookup_resource_id.return_value = lookup_return + context = MagicMock() + context.manager = manager + return context + + +class TestHostTransform(unittest.TestCase): + def test_from_ansible_data_resolves_inventory_via_controller_endpoint(self): + ansible = AnsibleHost(name="localhost", inventory="Local Inventory") + context = _make_context(lookup_return=7) + + api = HostTransformMixin_v1.from_ansible_data(ansible, context) + + context.manager.lookup_resource_id.assert_called_once_with("/api/controller/v2/inventories/", "name", "Local Inventory") + self.assertEqual(api.inventory, 7) + self.assertEqual(api.name, "localhost") + + def test_from_ansible_data_uses_new_name_when_set(self): + ansible = AnsibleHost(name="old", new_name="new", inventory="Local Inventory") + context = _make_context() + + api = HostTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.name, "new") + + def test_from_ansible_data_serializes_variables_dict_to_json(self): + ansible = AnsibleHost(name="localhost", inventory="Local Inventory", variables={"key": "value"}) + context = _make_context() + + api = HostTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.variables, '{"key": "value"}') + + def test_from_ansible_data_includes_id_for_update_url(self): + ansible = AnsibleHost(name="localhost", inventory="Local Inventory", id=42) + context = _make_context() + + api = HostTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.id, 42) + + def test_from_api_round_trips_variables_json_string(self): + ansible = HostTransformMixin_v1.from_api( + {"id": 10, "name": "localhost", "inventory": 7, "variables": '{"key": "value"}'}, + _make_context(), + ) + + self.assertEqual(ansible.id, 10) + self.assertEqual(ansible.inventory, "7") + self.assertEqual(ansible.variables, {"key": "value"}) + + def test_get_endpoint_operations_use_controller_paths(self): + ops = HostTransformMixin_v1.get_endpoint_operations() + for op_name in ("create", "list"): + self.assertTrue(ops[op_name].path.startswith("/api/controller/v2/hosts")) + self.assertEqual(ops["get"].path, "/api/controller/v2/hosts/{id}/") + + def test_get_lookup_field_is_name(self): + self.assertEqual(HostTransformMixin_v1.get_lookup_field(), "name") + + def test_get_find_list_query_params_scopes_by_inventory(self): + api_data = APIHost_v1(name="localhost", inventory=7) + params = HostTransformMixin_v1.get_find_list_query_params(api_data) + self.assertEqual(params, {"inventory": 7}) + + def test_get_find_list_query_params_empty_without_inventory(self): + api_data = APIHost_v1(name="localhost", inventory=None) + params = HostTransformMixin_v1.get_find_list_query_params(api_data) + self.assertEqual(params, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mock_gateway_server.py b/tools/mock_gateway_server.py index 10b17447..bbc38217 100644 --- a/tools/mock_gateway_server.py +++ b/tools/mock_gateway_server.py @@ -266,6 +266,12 @@ def _init_controller_resources(self) -> None: url_prefix="/api/controller/v2/inventories/", associations=["instance_groups", "input_inventories"], ) + self._controller_resources["hosts"] = GenericResource( + resource_name="hosts", + required_fields=["name", "inventory"], + start_id=6000, + url_prefix="/api/controller/v2/hosts/", + ) def controller_resource(self, name: str) -> Optional[GenericResource]: return self._controller_resources.get(name) From 90039f068ed54d0067e8a305f2301d785e0a6891 Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 13:34:53 -0400 Subject: [PATCH 03/10] Add inventory_source module migrated from awx.awx/ansible.controller (AAP-91390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shape 1 CRUD module, Pattern C (notification_templates_started/success/error associations, no copy_from). Depends on inventory for name->id lookup and inventory-scoped name uniqueness. - New module, action plugin, transform mixin, Ansible model. - Drops the legacy organization option (disambiguation-only, no direct API field; not carried over since lookup_resource_id only supports a single filter field) and custom_virtualenv (no longer supported by the API) — both documented in the module's notes. - Register under meta/runtime.yml action_groups.controller. - Register inventory_sources, credentials, execution_environments, projects, and notification_templates as generic Controller resources in the mock server. - Unit tests, 3-connection-mode Molecule scenario, integration test target. Co-Authored-By: Claude Sonnet 5 --- .../fragments/aap_91390_inventory_source.yml | 4 + .../inventory_source_mock/cleanup.yml | 150 +++++++++ .../inventory_source_mock/converge.yml | 285 ++++++++++++++++++ .../inventory_source_mock/inventory.yml | 15 + .../inventory_source_mock/molecule.yml | 34 +++ .../molecule/inventory_source_mock/verify.yml | 96 ++++++ meta/runtime.yml | 1 + plugins/action/inventory_source.py | 96 ++++++ plugins/modules/inventory_source.py | 234 ++++++++++++++ .../ansible_models/inventory_source.py | 53 ++++ .../plugin_utils/api/v1/inventory_source.py | 228 ++++++++++++++ .../inventory_source_test/meta/main.yml | 4 + .../inventory_source_test/tasks/main.yml | 127 ++++++++ .../api/v1/test_inventory_source.py | 112 +++++++ tools/mock_gateway_server.py | 31 ++ 15 files changed, 1470 insertions(+) create mode 100644 changelogs/fragments/aap_91390_inventory_source.yml create mode 100644 extensions/molecule/inventory_source_mock/cleanup.yml create mode 100644 extensions/molecule/inventory_source_mock/converge.yml create mode 100644 extensions/molecule/inventory_source_mock/inventory.yml create mode 100644 extensions/molecule/inventory_source_mock/molecule.yml create mode 100644 extensions/molecule/inventory_source_mock/verify.yml create mode 100644 plugins/action/inventory_source.py create mode 100644 plugins/modules/inventory_source.py create mode 100644 plugins/plugin_utils/ansible_models/inventory_source.py create mode 100644 plugins/plugin_utils/api/v1/inventory_source.py create mode 100644 tests/integration/targets/inventory_source_test/meta/main.yml create mode 100644 tests/integration/targets/inventory_source_test/tasks/main.yml create mode 100644 tests/unit/plugins/plugin_utils/api/v1/test_inventory_source.py diff --git a/changelogs/fragments/aap_91390_inventory_source.yml b/changelogs/fragments/aap_91390_inventory_source.yml new file mode 100644 index 00000000..cac506ba --- /dev/null +++ b/changelogs/fragments/aap_91390_inventory_source.yml @@ -0,0 +1,4 @@ +minor_changes: + - inventory_source - add module migrated from awx.awx/ansible.controller, including + notification_templates_started/success/error associations + (https://issues.redhat.com/browse/AAP-91390). diff --git a/extensions/molecule/inventory_source_mock/cleanup.yml b/extensions/molecule/inventory_source_mock/cleanup.yml new file mode 100644 index 00000000..f6dc0c5c --- /dev/null +++ b/extensions/molecule/inventory_source_mock/cleanup.yml @@ -0,0 +1,150 @@ +--- +# Cleanup: delete inventory sources/inventories/organizations created by converge. +- name: Cleanup — delete inventory_source, inventory, organization (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org InvSource Local" + molecule_inv_name: "Molecule Test Inventory InvSource Local" + molecule_source_name: "molecule-source-local" + tasks: + - name: Delete inventory source (connection local) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + register: delete_result_local + failed_when: false + vars: + ansible_connection: local + + - name: Assert inventory source removed or already absent (connection local) + ansible.builtin.assert: + that: delete_result_local is not failed + fail_msg: "Cleanup: failed to delete inventory source {{ molecule_source_name }}." + vars: + ansible_connection: local + + - name: Delete inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Delete organization (connection local) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete inventory_source, inventory, organization (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org InvSource HTTP Direct" + molecule_inv_name: "Molecule Test Inventory InvSource HTTP Direct" + molecule_source_name: "molecule-source-http-direct" + tasks: + - name: Delete inventory source (http direct) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + register: delete_result_http_direct + failed_when: false + + - name: Assert inventory source removed or already absent (http direct) + ansible.builtin.assert: + that: delete_result_http_direct is not failed + fail_msg: "Cleanup: failed to delete inventory source {{ molecule_source_name }}." + + - name: Delete inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Delete organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete inventory_source, inventory, organization (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org InvSource HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory InvSource HTTP Persistent" + molecule_source_name: "molecule-source-http-persistent" + tasks: + - name: Delete inventory source (http persistent) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + register: delete_result_http_persistent + failed_when: false + + - name: Assert inventory source removed or already absent (http persistent) + ansible.builtin.assert: + that: delete_result_http_persistent is not failed + fail_msg: "Cleanup: failed to delete inventory source {{ molecule_source_name }}." + + - name: Delete inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Delete organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local +... diff --git a/extensions/molecule/inventory_source_mock/converge.yml b/extensions/molecule/inventory_source_mock/converge.yml new file mode 100644 index 00000000..1bec11df --- /dev/null +++ b/extensions/molecule/inventory_source_mock/converge.yml @@ -0,0 +1,285 @@ +--- +# Converge: inventory_source create, idempotency, update, notification_templates +# association, and delete against the mock Gateway/Controller. +- name: Ensure mock Gateway is reachable + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + tasks: + - name: Wait for mock Gateway health endpoint + ansible.builtin.uri: + url: "{{ gateway_hostname }}/health" + method: GET + status_code: 200 + register: health + retries: 12 + delay: 5 + until: health.status == 200 + vars: + ansible_connection: local + + - name: Ensure /tmp/ap directory exists + ansible.builtin.file: + path: /tmp/ap + state: directory + mode: "0755" + vars: + ansible_connection: local + + - name: Create manager survive flag (Molecule keeps manager alive across phases) + ansible.builtin.file: + path: /tmp/ap/.survive + state: touch + mode: "0600" + vars: + ansible_connection: local + +# Play 2: full feature coverage (connection local) — create, idempotency, update, +# notification_templates association, delete. +- name: Converge — inventory_source (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org InvSource Local" + molecule_inv_name: "Molecule Test Inventory InvSource Local" + molecule_source_name: "molecule-source-local" + tasks: + - name: Create organization + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_local + vars: + ansible_connection: local + + - name: Create inventory + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_local.name }}" + register: inv_local + vars: + ansible_connection: local + + - name: Seed a fake notification template directly against the mock Controller + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/notification_templates/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "molecule-notif-local" + status_code: 201 + register: notif_local + vars: + ansible_connection: local + + - name: Create inventory source + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_local.name }}" + source: scm + source_path: "inventory.yml" + overwrite: true + notification_templates_started: + - "molecule-notif-local" + register: create_result_local + vars: + ansible_connection: local + + - name: Assert create changed (connection local) + ansible.builtin.assert: + that: + - create_result_local is changed + - create_result_local.inventory_source.id is defined + - create_result_local.inventory_source.source == "scm" + fail_msg: "Create (local) should report changed. create_result_local={{ create_result_local }}" + vars: + ansible_connection: local + + - name: Run again idempotency (connection local) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_local.name }}" + source: scm + source_path: "inventory.yml" + overwrite: true + notification_templates_started: + - "molecule-notif-local" + register: idem_result_local + vars: + ansible_connection: local + + - name: Assert idempotent run did not change (connection local) + ansible.builtin.assert: + that: idem_result_local is not changed + fail_msg: "Idempotent run (local) should not report changed. idem_result_local={{ idem_result_local }}" + vars: + ansible_connection: local + + - name: Update inventory source (connection local) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_local.name }}" + source: scm + source_path: "inventory.yml" + overwrite: false + update_on_launch: true + notification_templates_started: + - "molecule-notif-local" + register: update_result_local + vars: + ansible_connection: local + + - name: Assert update changed (connection local) + ansible.builtin.assert: + that: + - update_result_local is changed + - update_result_local.inventory_source.overwrite == false + - update_result_local.inventory_source.update_on_launch == true + fail_msg: "Update (local) should report changed. update_result_local={{ update_result_local }}" + vars: + ansible_connection: local + +# Play 3: basic CRUD parity (http direct). +- name: Converge — inventory_source (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org InvSource HTTP Direct" + molecule_inv_name: "Molecule Test Inventory InvSource HTTP Direct" + molecule_source_name: "molecule-source-http-direct" + tasks: + - name: Create organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_direct + + - name: Create inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_direct.name }}" + register: inv_http_direct + + - name: Create inventory source (http direct) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_direct.name }}" + source: scm + source_path: "inventory.yml" + register: create_result_http_direct + + - name: Assert create changed (http direct) + ansible.builtin.assert: + that: + - create_result_http_direct is changed + - create_result_http_direct.inventory_source.id is defined + fail_msg: "Create (http direct) should report changed. create_result_http_direct={{ create_result_http_direct }}" + + - name: Run again idempotency (http direct) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_direct.name }}" + source: scm + source_path: "inventory.yml" + register: idem_result_http_direct + + - name: Assert idempotent run did not change (http direct) + ansible.builtin.assert: + that: idem_result_http_direct is not changed + fail_msg: "Idempotent run (http direct) should not report changed. idem_result_http_direct={{ idem_result_http_direct }}" + + - name: Update inventory source (http direct) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_direct.name }}" + source: scm + source_path: "inventory.yml" + update_on_launch: true + register: update_result_http_direct + + - name: Assert update changed (http direct) + ansible.builtin.assert: + that: update_result_http_direct is changed + fail_msg: "Update (http direct) should report changed. update_result_http_direct={{ update_result_http_direct }}" + +# Play 4: basic CRUD parity (http persistent). +- name: Converge — inventory_source (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org InvSource HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory InvSource HTTP Persistent" + molecule_source_name: "molecule-source-http-persistent" + tasks: + - name: Create organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_persistent + + - name: Create inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_persistent.name }}" + register: inv_http_persistent + + - name: Create inventory source (http persistent) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_persistent.name }}" + source: scm + source_path: "inventory.yml" + register: create_result_http_persistent + + - name: Assert create changed (http persistent) + ansible.builtin.assert: + that: + - create_result_http_persistent is changed + - create_result_http_persistent.inventory_source.id is defined + fail_msg: "Create (http persistent) should report changed. create_result_http_persistent={{ create_result_http_persistent }}" + + - name: Run again idempotency (http persistent) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_persistent.name }}" + source: scm + source_path: "inventory.yml" + register: idem_result_http_persistent + + - name: Assert idempotent run did not change (http persistent) + ansible.builtin.assert: + that: idem_result_http_persistent is not changed + fail_msg: "Idempotent run (http persistent) should not report changed. idem_result_http_persistent={{ idem_result_http_persistent }}" + + - name: Update inventory source (http persistent) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_persistent.name }}" + source: scm + source_path: "inventory.yml" + update_on_launch: true + register: update_result_http_persistent + + - name: Assert update changed (http persistent) + ansible.builtin.assert: + that: update_result_http_persistent is changed + fail_msg: "Update (http persistent) should report changed. update_result_http_persistent={{ update_result_http_persistent }}" +... diff --git a/extensions/molecule/inventory_source_mock/inventory.yml b/extensions/molecule/inventory_source_mock/inventory.yml new file mode 100644 index 00000000..5bbc1ac3 --- /dev/null +++ b/extensions/molecule/inventory_source_mock/inventory.yml @@ -0,0 +1,15 @@ +--- +# inventory_source_mock scenario: use scenario inventory so we can mix connection types. +# First play (health check) uses connection: local; other plays use ansible.platform.http. +# Mock gateway vars are set here and in play vars. Structure matches shared inventory for parse compatibility. +all: + vars: + ansible_connection: local + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + children: + gateway_under_test: + hosts: + localhost: {} diff --git a/extensions/molecule/inventory_source_mock/molecule.yml b/extensions/molecule/inventory_source_mock/molecule.yml new file mode 100644 index 00000000..39239820 --- /dev/null +++ b/extensions/molecule/inventory_source_mock/molecule.yml @@ -0,0 +1,34 @@ +--- +# Scenario: test ansible.platform.inventory_source against the mock Gateway/Controller server (no real AAP). +# Requires the mock to be running (e.g. "molecule test --all" or default scenario create). +driver: + name: default + +platforms: + - name: localhost + +# Use scenario inventory (connection: local + gateway vars). Converge: play 2 direct, play 3 persistent, play 4 connection local. +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + +provisioner: + name: ansible + playbooks: + converge: converge.yml + verify: verify.yml + cleanup: cleanup.yml + config_options: + defaults: + collections_path: "${MOLECULE_SCENARIO_DIRECTORY}/../../../../../../" + # Write verbose messages (vv/vvvv) to ANSIBLE_LOG_PATH even without -v on the terminal. + log_verbosity: 4 + +scenario: + test_sequence: + - converge + - verify + - cleanup +... diff --git a/extensions/molecule/inventory_source_mock/verify.yml b/extensions/molecule/inventory_source_mock/verify.yml new file mode 100644 index 00000000..34c608bf --- /dev/null +++ b/extensions/molecule/inventory_source_mock/verify.yml @@ -0,0 +1,96 @@ +--- +# Verify: all three connection scenarios (local, http direct, http persistent). +- name: Verify — inventory_source created with connection local (mock) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_inv_name: "Molecule Test Inventory InvSource Local" + molecule_source_name: "molecule-source-local" + tasks: + - name: Get inventory source (state exists, connection local) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: exists + register: exists_result_local + vars: + ansible_connection: local + + - name: Assert inventory source was found (connection local) + ansible.builtin.assert: + that: + - exists_result_local is not failed + - exists_result_local.get('exists') | default(false) | bool + - exists_result_local.get('inventory_source') is defined + fail_msg: "Verify: inventory source {{ molecule_source_name }} not found (connection local)." + vars: + ansible_connection: local + + - name: Assert updated fields persisted (connection local) + ansible.builtin.assert: + that: + - exists_result_local.inventory_source.overwrite == false + - exists_result_local.inventory_source.update_on_launch == true + fail_msg: "Verify: inventory source (local) was not updated as expected." + vars: + ansible_connection: local + +- name: Verify — inventory_source created with http direct (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_inv_name: "Molecule Test Inventory InvSource HTTP Direct" + molecule_source_name: "molecule-source-http-direct" + tasks: + - name: Get inventory source (state exists, http direct) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: exists + register: exists_result_http_direct + + - name: Assert inventory source was found (http direct) + ansible.builtin.assert: + that: + - exists_result_http_direct is not failed + - exists_result_http_direct.get('exists') | default(false) | bool + fail_msg: "Verify: inventory source {{ molecule_source_name }} not found (http direct)." + +- name: Verify — inventory_source created with http persistent (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_inv_name: "Molecule Test Inventory InvSource HTTP Persistent" + molecule_source_name: "molecule-source-http-persistent" + tasks: + - name: Get inventory source (state exists, http persistent) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: exists + register: exists_result_http_persistent + + - name: Assert inventory source was found (http persistent) + ansible.builtin.assert: + that: + - exists_result_http_persistent is not failed + - exists_result_http_persistent.get('exists') | default(false) | bool + fail_msg: "Verify: inventory source {{ molecule_source_name }} not found (http persistent)." +... diff --git a/meta/runtime.yml b/meta/runtime.yml index 6dd2f1df..5f0f216d 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -27,4 +27,5 @@ action_groups: controller: - host - inventory + - inventory_source ... diff --git a/plugins/action/inventory_source.py b/plugins/action/inventory_source.py new file mode 100644 index 00000000..7b760369 --- /dev/null +++ b/plugins/action/inventory_source.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) 2026, Ansible Platform Collection Contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Action plugin for ansible.platform.inventory_source module. + +Migrated from awx.awx/ansible.controller inventory_source module. Uses +Pattern C (custom run override) due to association fields +(notification_templates_started/success/error). +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from typing import Any + +from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.inventory_source import AnsibleInventorySource + +logger = logging.getLogger(__name__) + +_ASSOCIATION_FIELDS = ( + "notification_templates_started", + "notification_templates_success", + "notification_templates_error", +) + +_INVENTORY_SOURCE_BASE_PATH = "/api/controller/v2/inventory_sources" + +_ASSOCIATION_MAP = { + "notification_templates_started": ("/api/controller/v2/notification_templates/", "name"), + "notification_templates_success": ("/api/controller/v2/notification_templates/", "name"), + "notification_templates_error": ("/api/controller/v2/notification_templates/", "name"), +} + + +class ActionModule(BaseResourceActionPlugin): + """Action plugin for inventory_source module.""" + + MODULE_NAME = "inventory_source" + MODEL_CLASS = AnsibleInventorySource + LOOKUP_FIELD = "name" + + _WRITE_ONLY_FIELDS = frozenset(_ASSOCIATION_FIELDS) + + def _build_ansible_data(self, resource: Any, validated_params: dict, operation: str) -> dict: + """Build ansible_data from explicitly-provided task parameters only.""" + data = {k: getattr(resource, k) for k in validated_params if hasattr(resource, k)} + if getattr(resource, "id", None) is not None: + data["id"] = resource.id + return data + + def run(self, tmp: object = None, task_vars: dict = None) -> dict: + """Run the inventory_source action plugin. + + Extends the base run() to sync notification_templates_started/success/error + association fields after standard CRUD. All HTTP calls are delegated to the + SDK layer (PlatformService / DirectHTTPClient). + """ + state = self._task.args.get("state", "present") + + association_data = {} + for field in _ASSOCIATION_FIELDS: + val = self._task.args.pop(field, None) + if val is not None: + association_data[field] = val + + result = super().run(tmp, task_vars) + + if result.get("failed"): + return result + + source_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") + + if source_id and state not in ("absent", "deleted", "exists"): + manager = self._client + if manager: + for field, (lookup_ep, lookup_field) in _ASSOCIATION_MAP.items(): + desired = association_data.get(field) + if desired is not None: + changed = manager.manage_associations( + _INVENTORY_SOURCE_BASE_PATH, + source_id, + field, + desired, + lookup_ep, + lookup_field, + ) + if changed: + result["changed"] = True + + return result diff --git a/plugins/modules/inventory_source.py b/plugins/modules/inventory_source.py new file mode 100644 index 00000000..4a1ab4a3 --- /dev/null +++ b/plugins/modules/inventory_source.py @@ -0,0 +1,234 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2018, Adrien Fleury +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This module is implemented as an action plugin. +# See plugins/action/inventory_source.py for the implementation. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: inventory_source +author: Red Hat (@RedHatOfficial) +short_description: Create, update, or destroy Automation Platform Controller inventory sources +description: + - Create, update, or destroy Automation Platform Controller inventory sources. +version_added: "3.0.0" + +options: + name: + description: + - The name to use for the inventory source. + required: true + type: str + + new_name: + description: + - Setting this option will change the existing name (looked up via the name field). + type: str + + description: + description: + - The description to use for the inventory source. + type: str + + inventory: + description: + - Inventory name, ID, or named URL the source should be made a member of. + required: true + type: str + + source: + description: + - The source to use for this inventory source. + - Required when creating a new inventory source. + choices: ["scm", "ec2", "gce", "azure_rm", "vmware", "satellite6", "openstack", "rhv", "controller", "insights", "terraform", + "openshift_virtualization"] + type: str + + source_path: + description: + - For an SCM based inventory source, the source path points to the file within the repo to use as an inventory. + type: str + + source_vars: + description: + - The variables or environment fields to apply to this source type. + type: dict + + enabled_var: + description: + - The variable to use to determine enabled state, e.g. C(status.power_state). + type: str + + enabled_value: + description: + - Value when the host is considered enabled, e.g. C(powered_on). + type: str + + host_filter: + description: + - If specified, only hosts that match this regular expression will be imported. + type: str + + limit: + description: + - Enter host, group, or pattern match. + type: str + + credential: + description: + - Credential name, ID, or named URL to use for the source. + type: str + + execution_environment: + description: + - Execution Environment name, ID, or named URL to use for the source. + type: str + + overwrite: + description: + - Delete child groups and hosts not found in source. + type: bool + + overwrite_vars: + description: + - Override vars in child groups and hosts with those from the external source. + type: bool + + timeout: + description: + - The amount of time (in seconds) to run before the task is canceled. + type: int + + verbosity: + description: + - The verbosity level to run this inventory source under. + type: int + choices: [0, 1, 2] + + update_on_launch: + description: + - Refresh inventory data from its source each time a job is run. + type: bool + + update_cache_timeout: + description: + - Time in seconds to consider an inventory sync to be current. + type: int + + source_project: + description: + - Project name, ID, or named URL to use as source with the C(scm) option. + type: str + + scm_branch: + description: + - Inventory source SCM branch. + - Project must have branch override enabled. + type: str + + notification_templates_started: + description: + - List of notification template names to send notifications to on start. + type: list + elements: str + + notification_templates_success: + description: + - List of notification template names to send notifications to on success. + type: list + elements: str + + notification_templates_error: + description: + - List of notification template names to send notifications to on error. + type: list + elements: str + + state: + description: + - Desired state of the inventory source. + - C(present) ensures the inventory source exists (create or update); idempotent. + - C(absent) removes the inventory source; idempotent if already absent. + - C(exists) reads and returns the current inventory source (no change). + type: str + choices: ['present', 'absent', 'exists', 'enforced'] + default: 'present' + +extends_documentation_fragment: + - ansible.platform.state + - ansible.platform.auth + +notes: + - The legacy C(organization) option (used only to disambiguate C(inventory)/C(source_project) + name lookups when names collide across organizations) is not carried over — name lookups + resolve globally. Use a unique name, or the numeric ID, if this is a concern. + - The legacy C(custom_virtualenv) option is not carried over — Controller's API no longer + supports per-inventory-source custom virtualenvs. + +seealso: + - module: ansible.controller.inventory_source + - module: awx.awx.inventory_source +""" + +EXAMPLES = """ +- name: Add an inventory source + ansible.platform.inventory_source: + name: "source-inventory" + description: Source for inventory + inventory: previously-created-inventory + source: scm + credential: previously-created-credential + source_project: previously-created-project + overwrite: true + update_on_launch: true + source_vars: + private: false + +- name: Check whether an inventory source exists (no change) + ansible.platform.inventory_source: + name: "source-inventory" + inventory: previously-created-inventory + state: exists + +- name: Delete an inventory source + ansible.platform.inventory_source: + name: "source-inventory" + inventory: previously-created-inventory + state: absent +... +""" + +RETURN = """ +changed: + description: Whether the inventory source was created, updated, or deleted. + returned: always + type: bool + +inventory_source: + description: > + The inventory source resource as it exists after the operation. + Contains only the fields accepted as module input (argspec fields) plus C(id). + returned: when state is present, exists, or enforced + type: dict + contains: + id: + description: Numeric database ID of the inventory source. + type: int + name: + description: Name of the inventory source. + type: str + inventory: + description: ID of the inventory the source belongs to. + type: int + source: + description: The source type. + type: str +... +""" diff --git a/plugins/plugin_utils/ansible_models/inventory_source.py b/plugins/plugin_utils/ansible_models/inventory_source.py new file mode 100644 index 00000000..a155f062 --- /dev/null +++ b/plugins/plugin_utils/ansible_models/inventory_source.py @@ -0,0 +1,53 @@ +""" +Ansible InventorySource dataclass - user-facing stable interface. + +This dataclass represents the inventory source as seen by Ansible playbooks. +Field names and types remain stable across API versions. + +notification_templates_started/success/error are handled by the action plugin +(association sync) — they are popped from ansible_data before this dataclass +is constructed, so they are not fields here. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class AnsibleInventorySource: + """Ansible representation of a Controller inventory source.""" + + # Required / identity + name: str + + # Optional fields + # inventory is required at the DOCUMENTATION/argspec level; defaults to None + # here so internal callers building a partial instance for a lookup don't + # need to supply it (matches AnsibleInventory's organization precedent). + inventory: Optional[str] = None + new_name: Optional[str] = None + description: Optional[str] = None + source: Optional[str] = None + source_path: Optional[str] = None + source_vars: Optional[dict] = None + scm_branch: Optional[str] = None + credential: Optional[str] = None + enabled_var: Optional[str] = None + enabled_value: Optional[str] = None + host_filter: Optional[str] = None + overwrite: Optional[bool] = None + overwrite_vars: Optional[bool] = None + timeout: Optional[int] = None + verbosity: Optional[int] = None + limit: Optional[str] = None + execution_environment: Optional[str] = None + update_on_launch: Optional[bool] = None + update_cache_timeout: Optional[int] = None + source_project: Optional[str] = None + state: str = "present" + + # Read-only fields (populated from API responses) + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None diff --git a/plugins/plugin_utils/api/v1/inventory_source.py b/plugins/plugin_utils/api/v1/inventory_source.py new file mode 100644 index 00000000..d5fe9259 --- /dev/null +++ b/plugins/plugin_utils/api/v1/inventory_source.py @@ -0,0 +1,228 @@ +""" +API v1 InventorySource dataclass and transform mixin. + +Handles transformations between Ansible format and the Controller API format +for inventory source resources. +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ...ansible_models.inventory_source import AnsibleInventorySource +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + +_NAME_LOOKUP_FIELDS = ( + ("inventory", "/api/controller/v2/inventories/"), + ("credential", "/api/controller/v2/credentials/"), + ("execution_environment", "/api/controller/v2/execution_environments/"), + ("source_project", "/api/controller/v2/projects/"), +) + +_DIRECT_FIELDS = ( + "description", + "source", + "source_path", + "scm_branch", + "enabled_var", + "enabled_value", + "host_filter", + "overwrite", + "overwrite_vars", + "timeout", + "verbosity", + "limit", + "update_on_launch", + "update_cache_timeout", +) + + +@dataclass +class APIInventorySource_v1: + """Wire format for Controller inventory sources.""" + + name: str + inventory: Optional[int] = None + description: Optional[str] = None + source: Optional[str] = None + source_path: Optional[str] = None + source_vars: Optional[str] = None + scm_branch: Optional[str] = None + credential: Optional[int] = None + enabled_var: Optional[str] = None + enabled_value: Optional[str] = None + host_filter: Optional[str] = None + overwrite: Optional[bool] = None + overwrite_vars: Optional[bool] = None + timeout: Optional[int] = None + verbosity: Optional[int] = None + limit: Optional[str] = None + execution_environment: Optional[int] = None + update_on_launch: Optional[bool] = None + update_cache_timeout: Optional[int] = None + source_project: Optional[int] = None + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + +class InventorySourceTransformMixin_v1(BaseTransformMixin): + """Transforms between AnsibleInventorySource and APIInventorySource_v1.""" + + @classmethod + def from_ansible_data(cls, ansible_instance: AnsibleInventorySource, context: TransformContext) -> APIInventorySource_v1: + """Forward: Ansible model -> API wire format.""" + params: Dict[str, Any] = { + "name": ansible_instance.new_name or ansible_instance.name, + } + + for field, endpoint in _NAME_LOOKUP_FIELDS: + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = context.manager.lookup_resource_id(endpoint, "name", value) + + for field in _DIRECT_FIELDS: + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + # Convert source_vars dict to JSON string for the API + if ansible_instance.source_vars is not None: + if isinstance(ansible_instance.source_vars, dict): + params["source_vars"] = json.dumps(ansible_instance.source_vars) + else: + params["source_vars"] = str(ansible_instance.source_vars) + + # Read-only from API (for building the {id} URL path param in execute) + for field in ("id", "created", "modified", "url"): + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + return APIInventorySource_v1(**params) + + @classmethod + def from_api(cls, api_data: Dict[str, Any], context: TransformContext) -> AnsibleInventorySource: + """Reverse: API response -> Ansible model.""" + raw_vars = api_data.get("source_vars") + source_vars: Optional[dict] = None + if isinstance(raw_vars, dict): + source_vars = raw_vars + elif isinstance(raw_vars, str) and raw_vars.strip(): + try: + source_vars = json.loads(raw_vars) + except ValueError: + source_vars = None + + def _str_or_none(val: Any) -> Optional[str]: + return str(val) if val is not None else None + + return AnsibleInventorySource( + id=api_data.get("id"), + name=api_data.get("name", ""), + inventory=_str_or_none(api_data.get("inventory")), + description=api_data.get("description"), + source=api_data.get("source"), + source_path=api_data.get("source_path"), + source_vars=source_vars, + scm_branch=api_data.get("scm_branch"), + credential=_str_or_none(api_data.get("credential")), + enabled_var=api_data.get("enabled_var"), + enabled_value=api_data.get("enabled_value"), + host_filter=api_data.get("host_filter"), + overwrite=api_data.get("overwrite"), + overwrite_vars=api_data.get("overwrite_vars"), + timeout=api_data.get("timeout"), + verbosity=api_data.get("verbosity"), + limit=api_data.get("limit"), + execution_environment=_str_or_none(api_data.get("execution_environment")), + update_on_launch=api_data.get("update_on_launch"), + update_cache_timeout=api_data.get("update_cache_timeout"), + source_project=_str_or_none(api_data.get("source_project")), + created=api_data.get("created"), + modified=api_data.get("modified"), + url=api_data.get("url"), + ) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + fields = [ + "name", + "description", + "inventory", + "source", + "source_path", + "source_vars", + "scm_branch", + "credential", + "enabled_var", + "enabled_value", + "host_filter", + "overwrite", + "overwrite_vars", + "timeout", + "verbosity", + "limit", + "execution_environment", + "update_on_launch", + "update_cache_timeout", + "source_project", + ] + + return { + "create": EndpointOperation( + path="/api/controller/v2/inventory_sources/", + method="POST", + fields=fields, + required_for="create", + order=1, + ), + "update": EndpointOperation( + path="/api/controller/v2/inventory_sources/{id}/", + method="PATCH", + fields=fields, + path_params=["id"], + required_for="update", + order=1, + ), + "delete": EndpointOperation( + path="/api/controller/v2/inventory_sources/{id}/", + method="DELETE", + fields=[], + path_params=["id"], + required_for="delete", + order=1, + ), + "get": EndpointOperation( + path="/api/controller/v2/inventory_sources/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + "list": EndpointOperation( + path="/api/controller/v2/inventory_sources/", + method="GET", + fields=[], + required_for="find", + order=1, + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + return "name" + + @classmethod + def get_find_list_query_params(cls, ansible_data: APIInventorySource_v1) -> Dict[str, Any]: + """Scope name lookups by inventory — source names are only unique per-inventory.""" + inventory_id = getattr(ansible_data, "inventory", None) + if inventory_id is not None: + return {"inventory": inventory_id} + return {} diff --git a/tests/integration/targets/inventory_source_test/meta/main.yml b/tests/integration/targets/inventory_source_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/inventory_source_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/inventory_source_test/tasks/main.yml b/tests/integration/targets/inventory_source_test/tasks/main.yml new file mode 100644 index 00000000..d4fef280 --- /dev/null +++ b/tests/integration/targets/inventory_source_test/tasks/main.yml @@ -0,0 +1,127 @@ +--- +- name: Generate a test ID + ansible.builtin.set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Preset vars + ansible.builtin.set_fact: + name_prefix: "AAP-Collection-Test-InventorySource-{{ test_id }}" + +- name: Run Test + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | bool }}" + + block: + - name: Create Organization + ansible.platform.organization: + name: "{{ name_prefix }}-Organization" + register: org1 + + - name: Create Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + register: inv1 + + - name: Create inventory source + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + source: scm + source_path: "inventory.yml" + overwrite: true + register: created_source + + - name: Assert creation changed + ansible.builtin.assert: + that: + - created_source is changed + - created_source.inventory_source.name is defined + - created_source.inventory_source.overwrite == true + + - name: Check idempotency (re-apply, expect no change) + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + source: scm + source_path: "inventory.yml" + overwrite: true + register: idempotent_source + + - name: Assert no change on re-apply + ansible.builtin.assert: + that: + - idempotent_source is not changed + + - name: Update inventory source + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + source: scm + source_path: "inventory.yml" + overwrite: false + update_on_launch: true + register: updated_source + + - name: Assert update changed + ansible.builtin.assert: + that: + - updated_source is changed + - updated_source.inventory_source.id == created_source.inventory_source.id + + - name: Check exists returns true + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + state: exists + register: exists_check + + - name: Assert exists is true and no change + ansible.builtin.assert: + that: + - exists_check.exists + - exists_check is not changed + + - name: Delete inventory source + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + state: absent + register: deleted_source + + - name: Assert delete changed + ansible.builtin.assert: + that: + - deleted_source is changed + + - name: Delete inventory source again (idempotent) + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + state: absent + register: deleted_source_again + + - name: Assert repeat delete is a no-op + ansible.builtin.assert: + that: + - deleted_source_again is not changed + + always: + - name: Delete Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 + + - name: Delete Organization + ansible.platform.organization: + name: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 +... diff --git a/tests/unit/plugins/plugin_utils/api/v1/test_inventory_source.py b/tests/unit/plugins/plugin_utils/api/v1/test_inventory_source.py new file mode 100644 index 00000000..6a7924ff --- /dev/null +++ b/tests/unit/plugins/plugin_utils/api/v1/test_inventory_source.py @@ -0,0 +1,112 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Tests for the inventory_source v1 transform mixin (AAP-91390).""" + +from __future__ import absolute_import, division, print_function + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +_COLLECTIONS_PARENT = str(Path(__file__).resolve().parent.parent.parent.parent.parent.parent.parent.parent) +if _COLLECTIONS_PARENT not in sys.path: + sys.path.insert(0, _COLLECTIONS_PARENT) + +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.inventory_source import ( # noqa: E402 + AnsibleInventorySource, +) +from ansible_collections.ansible.platform.plugins.plugin_utils.api.v1.inventory_source import ( # noqa: E402 + APIInventorySource_v1, + InventorySourceTransformMixin_v1, +) + + +def _make_context(lookup_returns=None, default=1): + manager = MagicMock() + if lookup_returns is not None: + + def _side_effect(endpoint, field, value): + return lookup_returns.get((endpoint, value), default) + + manager.lookup_resource_id.side_effect = _side_effect + else: + manager.lookup_resource_id.return_value = default + context = MagicMock() + context.manager = manager + return context + + +class TestInventorySourceTransform(unittest.TestCase): + def test_from_ansible_data_resolves_all_fk_fields(self): + ansible = AnsibleInventorySource( + name="src", + inventory="Demo Inventory", + source="scm", + credential="Demo Credential", + execution_environment="Default EE", + source_project="Demo Project", + ) + lookups = { + ("/api/controller/v2/inventories/", "Demo Inventory"): 10, + ("/api/controller/v2/credentials/", "Demo Credential"): 20, + ("/api/controller/v2/execution_environments/", "Default EE"): 30, + ("/api/controller/v2/projects/", "Demo Project"): 40, + } + context = _make_context(lookup_returns=lookups) + + api = InventorySourceTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.inventory, 10) + self.assertEqual(api.credential, 20) + self.assertEqual(api.execution_environment, 30) + self.assertEqual(api.source_project, 40) + self.assertEqual(api.source, "scm") + + def test_from_ansible_data_uses_new_name_when_set(self): + ansible = AnsibleInventorySource(name="old", new_name="new", inventory="inv") + context = _make_context() + + api = InventorySourceTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.name, "new") + + def test_from_ansible_data_serializes_source_vars_dict_to_json(self): + ansible = AnsibleInventorySource(name="src", inventory="inv", source_vars={"private": False}) + context = _make_context() + + api = InventorySourceTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.source_vars, '{"private": false}') + + def test_from_ansible_data_includes_id_for_update_url(self): + ansible = AnsibleInventorySource(name="src", inventory="inv", id=99) + context = _make_context() + + api = InventorySourceTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.id, 99) + + def test_from_api_round_trips_source_vars_json_string(self): + ansible = InventorySourceTransformMixin_v1.from_api( + {"id": 1, "name": "src", "inventory": 10, "source_vars": '{"private": false}'}, + _make_context(), + ) + + self.assertEqual(ansible.inventory, "10") + self.assertEqual(ansible.source_vars, {"private": False}) + + def test_get_endpoint_operations_use_controller_paths(self): + ops = InventorySourceTransformMixin_v1.get_endpoint_operations() + for op_name in ("create", "list"): + self.assertTrue(ops[op_name].path.startswith("/api/controller/v2/inventory_sources")) + + def test_get_find_list_query_params_scopes_by_inventory(self): + api_data = APIInventorySource_v1(name="src", inventory=10) + params = InventorySourceTransformMixin_v1.get_find_list_query_params(api_data) + self.assertEqual(params, {"inventory": 10}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mock_gateway_server.py b/tools/mock_gateway_server.py index bbc38217..9bc6cfeb 100644 --- a/tools/mock_gateway_server.py +++ b/tools/mock_gateway_server.py @@ -272,6 +272,37 @@ def _init_controller_resources(self) -> None: start_id=6000, url_prefix="/api/controller/v2/hosts/", ) + self._controller_resources["credentials"] = GenericResource( + resource_name="credentials", + required_fields=["name"], + start_id=7000, + url_prefix="/api/controller/v2/credentials/", + ) + self._controller_resources["execution_environments"] = GenericResource( + resource_name="execution_environments", + required_fields=["name"], + start_id=8000, + url_prefix="/api/controller/v2/execution_environments/", + ) + self._controller_resources["projects"] = GenericResource( + resource_name="projects", + required_fields=["name"], + start_id=9000, + url_prefix="/api/controller/v2/projects/", + ) + self._controller_resources["notification_templates"] = GenericResource( + resource_name="notification_templates", + required_fields=["name"], + start_id=10000, + url_prefix="/api/controller/v2/notification_templates/", + ) + self._controller_resources["inventory_sources"] = GenericResource( + resource_name="inventory_sources", + required_fields=["name", "inventory"], + start_id=11000, + url_prefix="/api/controller/v2/inventory_sources/", + associations=["notification_templates_started", "notification_templates_success", "notification_templates_error"], + ) def controller_resource(self, name: str) -> Optional[GenericResource]: return self._controller_resources.get(name) From 0230713ab679295aa0c0ec82bcd8161f978212b9 Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 14:46:58 -0400 Subject: [PATCH 04/10] Add inventory_source_update module migrated from awx.awx/ansible.controller (AAP-91390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shape 2 launch resource (POST to an existing inventory_source's /update/ sub-action, then optionally poll the launched inventory_update job for completion) — same wait/poll SDK infrastructure as ad_hoc_command (DEFAULT_WAIT_TIMEOUT, WaitTimeoutError, _wait_for_resource_completion), ported into this branch since it didn't exist here yet. Also fixes two real, previously-latent bugs in the shared operation executor (_execute_operations in platform_manager.py and direct_client.py), found by actually running this module's Molecule scenario rather than trusting unit tests alone: - An EndpointOperation intentionally declared with fields=[] (a no-body launch trigger) was being silently skipped as "nothing to do" — every prior operation in the collection happened to have a non-empty fields list, so this never surfaced before. - path_params substitution only ever handled a param literally named "id"; any custom path param name (here, inventory_source_id) was left unsubstituted in the URL. Fixed to resolve by the param's own name. Also adds tests/unit/plugins/**/__init__.py — two new test files sharing a basename across directories (test_inventory_source_update.py) collided under pytest's rootdir-relative import without them. - New module, action plugin (adapted from the ad_hoc_command Shape 2 pattern), transform mixin, Ansible model. - Drops the legacy organization option (disambiguation-only, no direct API field), documented in the module's notes. - Register under meta/runtime.yml action_groups.controller. - Register inventory_updates as a generic Controller resource in the mock server, with a pending -> successful poll-advance lifecycle. - Unit tests (transform mixin, action plugin check_mode/WaitTimeoutError, and a regression test for the two _execute_operations bugs), 3-connection-mode Molecule scenario, integration test target. Co-Authored-By: Claude Sonnet 5 --- .../aap_91390_inventory_source_update.yml | 10 + .../inventory_source_update_mock/cleanup.yml | 130 ++++++++++ .../inventory_source_update_mock/converge.yml | 235 ++++++++++++++++++ .../inventory.yml | 15 ++ .../inventory_source_update_mock/molecule.yml | 34 +++ .../inventory_source_update_mock/verify.yml | 33 +++ meta/runtime.yml | 1 + plugins/action/inventory_source_update.py | 134 ++++++++++ plugins/modules/inventory_source_update.py | 104 ++++++++ .../ansible_models/inventory_source_update.py | 26 ++ .../api/v1/inventory_source_update.py | 101 ++++++++ .../plugin_utils/manager/platform_manager.py | 80 +++++- plugins/plugin_utils/platform/base_client.py | 19 ++ .../plugin_utils/platform/direct_client.py | 71 +++++- .../meta/main.yml | 4 + .../tasks/main.yml | 100 ++++++++ tests/unit/plugins/__init__.py | 0 tests/unit/plugins/action/__init__.py | 0 .../action/test_inventory_source_update.py | 106 ++++++++ tests/unit/plugins/connection/__init__.py | 0 tests/unit/plugins/plugin_utils/__init__.py | 0 .../unit/plugins/plugin_utils/api/__init__.py | 0 .../plugins/plugin_utils/api/v1/__init__.py | 0 .../api/v1/test_inventory_source_update.py | 86 +++++++ .../plugins/plugin_utils/manager/__init__.py | 0 .../test_execute_operations_launch_trigger.py | 128 ++++++++++ .../plugins/plugin_utils/platform/__init__.py | 0 tools/mock_gateway_server.py | 61 ++++- 28 files changed, 1469 insertions(+), 9 deletions(-) create mode 100644 changelogs/fragments/aap_91390_inventory_source_update.yml create mode 100644 extensions/molecule/inventory_source_update_mock/cleanup.yml create mode 100644 extensions/molecule/inventory_source_update_mock/converge.yml create mode 100644 extensions/molecule/inventory_source_update_mock/inventory.yml create mode 100644 extensions/molecule/inventory_source_update_mock/molecule.yml create mode 100644 extensions/molecule/inventory_source_update_mock/verify.yml create mode 100644 plugins/action/inventory_source_update.py create mode 100644 plugins/modules/inventory_source_update.py create mode 100644 plugins/plugin_utils/ansible_models/inventory_source_update.py create mode 100644 plugins/plugin_utils/api/v1/inventory_source_update.py create mode 100644 tests/integration/targets/inventory_source_update_test/meta/main.yml create mode 100644 tests/integration/targets/inventory_source_update_test/tasks/main.yml create mode 100644 tests/unit/plugins/__init__.py create mode 100644 tests/unit/plugins/action/__init__.py create mode 100644 tests/unit/plugins/action/test_inventory_source_update.py create mode 100644 tests/unit/plugins/connection/__init__.py create mode 100644 tests/unit/plugins/plugin_utils/__init__.py create mode 100644 tests/unit/plugins/plugin_utils/api/__init__.py create mode 100644 tests/unit/plugins/plugin_utils/api/v1/__init__.py create mode 100644 tests/unit/plugins/plugin_utils/api/v1/test_inventory_source_update.py create mode 100644 tests/unit/plugins/plugin_utils/manager/__init__.py create mode 100644 tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py create mode 100644 tests/unit/plugins/plugin_utils/platform/__init__.py diff --git a/changelogs/fragments/aap_91390_inventory_source_update.yml b/changelogs/fragments/aap_91390_inventory_source_update.yml new file mode 100644 index 00000000..8c56048a --- /dev/null +++ b/changelogs/fragments/aap_91390_inventory_source_update.yml @@ -0,0 +1,10 @@ +minor_changes: + - inventory_source_update - add module migrated from awx.awx/ansible.controller + to launch an inventory source update (sync) + (https://issues.redhat.com/browse/AAP-91390). +bugfixes: + - Fix the SDK layer's shared operation executor to actually call the API for + an endpoint operation intentionally declared with no request-body fields + (a launch-trigger sub-action), and to resolve any custom path parameter + name declared on an EndpointOperation instead of only ever substituting + a param literally named C(id). diff --git a/extensions/molecule/inventory_source_update_mock/cleanup.yml b/extensions/molecule/inventory_source_update_mock/cleanup.yml new file mode 100644 index 00000000..bd3ab87a --- /dev/null +++ b/extensions/molecule/inventory_source_update_mock/cleanup.yml @@ -0,0 +1,130 @@ +--- +# Cleanup: delete inventory sources/inventories/organizations created by converge. +- name: Cleanup — delete inventory_source, inventory, organization (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org InvSrcUpdate Local" + molecule_inv_name: "Molecule Test Inventory InvSrcUpdate Local" + molecule_source_name: "molecule-source-update-local" + tasks: + - name: Delete inventory source (connection local) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Delete inventory (connection local) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Delete organization (connection local) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + vars: + ansible_connection: local + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete inventory_source, inventory, organization (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org InvSrcUpdate HTTP Direct" + molecule_inv_name: "Molecule Test Inventory InvSrcUpdate HTTP Direct" + molecule_source_name: "molecule-source-update-http-direct" + tasks: + - name: Delete inventory source (http direct) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + failed_when: false + + - name: Delete inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Delete organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete inventory_source, inventory, organization (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org InvSrcUpdate HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory InvSrcUpdate HTTP Persistent" + molecule_source_name: "molecule-source-update-http-persistent" + tasks: + - name: Delete inventory source (http persistent) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: absent + failed_when: false + + - name: Delete inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Delete organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + state: absent + failed_when: false + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local +... diff --git a/extensions/molecule/inventory_source_update_mock/converge.yml b/extensions/molecule/inventory_source_update_mock/converge.yml new file mode 100644 index 00000000..bb20e5e3 --- /dev/null +++ b/extensions/molecule/inventory_source_update_mock/converge.yml @@ -0,0 +1,235 @@ +--- +# Converge: inventory_source_update launch (no wait), launch (wait), non-idempotency, +# and check_mode against the mock Gateway/Controller. +- name: Ensure mock Gateway is reachable + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + tasks: + - name: Wait for mock Gateway health endpoint + ansible.builtin.uri: + url: "{{ gateway_hostname }}/health" + method: GET + status_code: 200 + register: health + retries: 12 + delay: 5 + until: health.status == 200 + vars: + ansible_connection: local + + - name: Ensure /tmp/ap directory exists + ansible.builtin.file: + path: /tmp/ap + state: directory + mode: "0755" + vars: + ansible_connection: local + + - name: Create manager survive flag (Molecule keeps manager alive across phases) + ansible.builtin.file: + path: /tmp/ap/.survive + state: touch + mode: "0600" + vars: + ansible_connection: local + +# Play 2: full feature coverage (connection local) — launch without wait, launch with +# wait, non-idempotency, check_mode. +- name: Converge — inventory_source_update (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_org_name: "Molecule Test Org InvSrcUpdate Local" + molecule_inv_name: "Molecule Test Inventory InvSrcUpdate Local" + molecule_source_name: "molecule-source-update-local" + tasks: + - name: Create organization + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_local + vars: + ansible_connection: local + + - name: Create inventory + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_local.name }}" + register: inv_local + vars: + ansible_connection: local + + - name: Create inventory source + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_local.name }}" + source: scm + source_path: "inventory.yml" + register: source_local + vars: + ansible_connection: local + + - name: Check mode launch (must not call the API) + ansible.platform.inventory_source_update: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_local.name }}" + check_mode: true + register: check_mode_result_local + vars: + ansible_connection: local + + - name: Assert check_mode launch reports changed with no id (connection local) + ansible.builtin.assert: + that: + - check_mode_result_local is changed + - check_mode_result_local.id is none + fail_msg: "Check mode (local) should report changed with no id. check_mode_result_local={{ check_mode_result_local }}" + vars: + ansible_connection: local + + - name: Launch update without waiting (connection local) + ansible.platform.inventory_source_update: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_local.name }}" + register: launch_result_local + vars: + ansible_connection: local + + - name: Assert launch changed and pending (connection local) + ansible.builtin.assert: + that: + - launch_result_local is changed + - launch_result_local.id is defined + - launch_result_local.status == "pending" + fail_msg: "Launch (local) should report changed and pending. launch_result_local={{ launch_result_local }}" + vars: + ansible_connection: local + + - name: Launch update again, waiting for completion (connection local) + ansible.platform.inventory_source_update: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_local.name }}" + wait: true + interval: 0.2 + timeout: 30 + register: wait_result_local + vars: + ansible_connection: local + + - name: Assert wait launch resolved and is a distinct id (non-idempotent, connection local) + ansible.builtin.assert: + that: + - wait_result_local is changed + - wait_result_local.status == "successful" + - wait_result_local.id != launch_result_local.id + fail_msg: "Wait launch (local) should resolve to successful with a new id. wait_result_local={{ wait_result_local }}" + vars: + ansible_connection: local + +# Play 3: connection-mode parity (http direct) — launch with wait. +- name: Converge — inventory_source_update (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_org_name: "Molecule Test Org InvSrcUpdate HTTP Direct" + molecule_inv_name: "Molecule Test Inventory InvSrcUpdate HTTP Direct" + molecule_source_name: "molecule-source-update-http-direct" + tasks: + - name: Create organization (http direct) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_direct + + - name: Create inventory (http direct) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_direct.name }}" + register: inv_http_direct + + - name: Create inventory source (http direct) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_direct.name }}" + source: scm + source_path: "inventory.yml" + register: source_http_direct + + - name: Launch update, waiting for completion (http direct) + ansible.platform.inventory_source_update: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_direct.name }}" + wait: true + interval: 0.2 + timeout: 30 + register: wait_result_http_direct + + - name: Assert wait launch resolved (http direct) + ansible.builtin.assert: + that: + - wait_result_http_direct is changed + - wait_result_http_direct.status == "successful" + fail_msg: "Wait launch (http direct) should resolve to successful. wait_result_http_direct={{ wait_result_http_direct }}" + +# Play 4: connection-mode parity (http persistent) — launch with wait. +- name: Converge — inventory_source_update (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_org_name: "Molecule Test Org InvSrcUpdate HTTP Persistent" + molecule_inv_name: "Molecule Test Inventory InvSrcUpdate HTTP Persistent" + molecule_source_name: "molecule-source-update-http-persistent" + tasks: + - name: Create organization (http persistent) + ansible.platform.organization: + name: "{{ molecule_org_name }}" + register: org_http_persistent + + - name: Create inventory (http persistent) + ansible.platform.inventory: + name: "{{ molecule_inv_name }}" + organization: "{{ org_http_persistent.name }}" + register: inv_http_persistent + + - name: Create inventory source (http persistent) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_persistent.name }}" + source: scm + source_path: "inventory.yml" + register: source_http_persistent + + - name: Launch update, waiting for completion (http persistent) + ansible.platform.inventory_source_update: + name: "{{ molecule_source_name }}" + inventory: "{{ inv_http_persistent.name }}" + wait: true + interval: 0.2 + timeout: 30 + register: wait_result_http_persistent + + - name: Assert wait launch resolved (http persistent) + ansible.builtin.assert: + that: + - wait_result_http_persistent is changed + - wait_result_http_persistent.status == "successful" + fail_msg: "Wait launch (http persistent) should resolve to successful. wait_result_http_persistent={{ wait_result_http_persistent }}" +... diff --git a/extensions/molecule/inventory_source_update_mock/inventory.yml b/extensions/molecule/inventory_source_update_mock/inventory.yml new file mode 100644 index 00000000..58f71a47 --- /dev/null +++ b/extensions/molecule/inventory_source_update_mock/inventory.yml @@ -0,0 +1,15 @@ +--- +# inventory_source_update_mock scenario: use scenario inventory so we can mix connection types. +# First play (health check) uses connection: local; other plays use ansible.platform.http. +# Mock gateway vars are set here and in play vars. Structure matches shared inventory for parse compatibility. +all: + vars: + ansible_connection: local + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + children: + gateway_under_test: + hosts: + localhost: {} diff --git a/extensions/molecule/inventory_source_update_mock/molecule.yml b/extensions/molecule/inventory_source_update_mock/molecule.yml new file mode 100644 index 00000000..1ba0d2e8 --- /dev/null +++ b/extensions/molecule/inventory_source_update_mock/molecule.yml @@ -0,0 +1,34 @@ +--- +# Scenario: test ansible.platform.inventory_source_update against the mock Gateway/Controller server (no real AAP). +# Requires the mock to be running (e.g. "molecule test --all" or default scenario create). +driver: + name: default + +platforms: + - name: localhost + +# Use scenario inventory (connection: local + gateway vars). Converge: play 2 direct, play 3 persistent, play 4 connection local. +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + +provisioner: + name: ansible + playbooks: + converge: converge.yml + verify: verify.yml + cleanup: cleanup.yml + config_options: + defaults: + collections_path: "${MOLECULE_SCENARIO_DIRECTORY}/../../../../../../" + # Write verbose messages (vv/vvvv) to ANSIBLE_LOG_PATH even without -v on the terminal. + log_verbosity: 4 + +scenario: + test_sequence: + - converge + - verify + - cleanup +... diff --git a/extensions/molecule/inventory_source_update_mock/verify.yml b/extensions/molecule/inventory_source_update_mock/verify.yml new file mode 100644 index 00000000..04df0f2c --- /dev/null +++ b/extensions/molecule/inventory_source_update_mock/verify.yml @@ -0,0 +1,33 @@ +--- +# Verify: the inventory sources created by converge are still present (they are +# CRUD resources; inventory_source_update itself has no persistent identity to verify). +- name: Verify — inventory source still exists (connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_inv_name: "Molecule Test Inventory InvSrcUpdate Local" + molecule_source_name: "molecule-source-update-local" + tasks: + - name: Get inventory source (state exists, connection local) + ansible.platform.inventory_source: + name: "{{ molecule_source_name }}" + inventory: "{{ molecule_inv_name }}" + state: exists + register: exists_result_local + vars: + ansible_connection: local + + - name: Assert inventory source still exists (connection local) + ansible.builtin.assert: + that: + - exists_result_local is not failed + - exists_result_local.get('exists') | default(false) | bool + fail_msg: "Verify: inventory source {{ molecule_source_name }} not found (connection local)." + vars: + ansible_connection: local +... diff --git a/meta/runtime.yml b/meta/runtime.yml index 5f0f216d..bfb1301e 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -28,4 +28,5 @@ action_groups: - host - inventory - inventory_source + - inventory_source_update ... diff --git a/plugins/action/inventory_source_update.py b/plugins/action/inventory_source_update.py new file mode 100644 index 00000000..bda28f48 --- /dev/null +++ b/plugins/action/inventory_source_update.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) 2026, Ansible Platform Collection Contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Action plugin for ansible.platform.inventory_source_update module. + +Launches an inventory source update (sync) via Controller. This is not a CRUD +resource — every invocation launches a new update. Waiting for completion is +handled by PlatformService/DirectHTTPClient.execute() (see platform_manager.py +and direct_client.py) so that non-Ansible SDK consumers get the same wait +semantics — this action plugin only launches and forwards the result. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import dataclasses + +from ansible.errors import AnsibleError +from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.inventory_source_update import AnsibleInventorySourceUpdate +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.base_client import WaitTimeoutError + + +class ActionModule(BaseResourceActionPlugin): + """Action plugin for launching inventory source updates.""" + + MODULE_NAME = "inventory_source_update" + MODEL_CLASS = AnsibleInventorySourceUpdate + + def _build_ansible_data(self, resource, validated_params, operation): + """Forward wait/interval/timeout so manager.execute() can poll for us. + + These are not AnsibleInventorySourceUpdate fields — PlatformService/ + DirectHTTPClient pop them off the dict before constructing the dataclass. + """ + ansible_data = super()._build_ansible_data(resource, validated_params, operation) + ansible_data["wait"] = validated_params.get("wait", False) + ansible_data["interval"] = validated_params.get("interval", 2.0) + ansible_data["timeout"] = validated_params.get("timeout") + return ansible_data + + def run(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = {} + self._task_vars = task_vars + result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) + del tmp + + try: + doc = self._get_documentation() + argspec = self._build_argspec_from_docs(doc) if doc else None + if not argspec: + raise AnsibleError("Could not load DOCUMENTATION for inventory_source_update module") + + validated_input = self._validate_data(self._task.args.copy(), argspec, "input") + + manager, facts_to_set = self._get_or_spawn_manager(task_vars) + self._client = manager + if facts_to_set: + result["ansible_facts"] = facts_to_set + result["_ansible_facts_cacheable"] = True + + validated_params = validated_input.validated_parameters + + resource_data = {k: v for k, v in validated_params.items() if v is not None and k not in self._AUTH_PARAMS} + model_fields = {f.name for f in dataclasses.fields(self.MODEL_CLASS)} + resource = self.MODEL_CLASS(**{k: v for k, v in resource_data.items() if k in model_fields}) + ansible_data = self._build_ansible_data(resource, validated_params, "create") + + # Inventory source updates are never idempotent — every real run + # launches a new update — so check mode must not call manager.execute(). + if self._task.check_mode: + result.update( + { + "changed": True, + "failed": False, + "id": None, + "status": "pending", + "msg": "Check mode: inventory source update would be launched.", + } + ) + return result + + # manager.execute() launches the update and, when wait=True, polls + # for completion itself (PlatformService/DirectHTTPClient) — this + # action plugin never polls or sleeps. + launch_result = manager.execute( + operation="create", + module_name=self.MODULE_NAME, + ansible_data=ansible_data, + ) + + status = launch_result.get("status", "pending") + result.update( + { + "changed": True, + "id": launch_result.get("id"), + "status": status, + } + ) + + if status in ("error", "failed", "canceled"): + result["failed"] = True + result["msg"] = "Inventory source update %s finished with status: %s" % (launch_result.get("id"), status) + + except WaitTimeoutError as exc: + # The update was launched and is still running on Controller even + # though waiting for it gave up — preserve id/status so operators can + # still register/poll/cancel it from the task result. + last = exc.last_result + result.update( + { + "changed": True, + "failed": True, + "id": last.get("id"), + "status": last.get("status", "unknown"), + "msg": str(exc), + } + ) + + except Exception as exc: + import traceback as _tb + + self._display.vvv("Error in inventory_source_update action plugin: %s" % exc) + result["failed"] = True + result["msg"] = str(exc) + if self._display.verbosity >= 3: + result["exception"] = _tb.format_exc() + + return result diff --git a/plugins/modules/inventory_source_update.py b/plugins/modules/inventory_source_update.py new file mode 100644 index 00000000..a7a1feb6 --- /dev/null +++ b/plugins/modules/inventory_source_update.py @@ -0,0 +1,104 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2020, Bianca Henderson +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This module is implemented as an action plugin. +# See plugins/action/inventory_source_update.py for the implementation. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: inventory_source_update +author: Red Hat (@RedHatOfficial) +short_description: Launch an inventory source update (sync) +description: + - Launch an inventory source update (sync) on the Ansible Automation Platform controller. + - This module always launches a new inventory source update; it is not idempotent. +version_added: "3.0.0" + +options: + name: + description: + - The name of the inventory source to update. + required: true + type: str + aliases: + - inventory_source + + inventory: + description: + - Name or ID of the inventory that contains the inventory source to update. + required: true + type: str + + wait: + description: + - Wait for the update to complete. + default: false + type: bool + + interval: + description: + - The interval in seconds to request an update from the controller. + default: 2 + type: float + + timeout: + description: + - If waiting for the update to complete this will abort after this + amount of seconds. + - When C(wait=true) and this option is omitted, polling is capped at + 3600 seconds (1 hour). Set explicitly to use a different limit. + type: int + +extends_documentation_fragment: + - ansible.platform.auth + +notes: + - The legacy C(organization) option (used only to disambiguate the C(inventory) + name lookup when names collide across organizations) is not carried over — + name lookups resolve globally. Use a unique name, or the numeric ID, if this + is a concern. + +seealso: + - module: ansible.controller.inventory_source_update + - module: awx.awx.inventory_source_update +""" + +EXAMPLES = """ +- name: Update a single inventory source, waiting for it to finish + ansible.platform.inventory_source_update: + name: "Example Inventory Source" + inventory: "My Inventory" + wait: true + +- name: Launch an inventory source update without waiting + ansible.platform.inventory_source_update: + name: "Example Inventory Source" + inventory: "My Inventory" +... +""" + +RETURN = """ +id: + description: ID of the newly launched inventory update. + returned: success + type: int + sample: 86 +status: + description: + - Status of the launched inventory update. + - With C(wait=false) this is always C(pending) — the update has only just + been launched. With C(wait=true) this is the terminal status reported by + Controller once the update finishes; the task fails (C(failed=true)) if + that terminal status is C(error), C(failed), or C(canceled). + returned: success + type: str + sample: pending +... +""" diff --git a/plugins/plugin_utils/ansible_models/inventory_source_update.py b/plugins/plugin_utils/ansible_models/inventory_source_update.py new file mode 100644 index 00000000..e87421d9 --- /dev/null +++ b/plugins/plugin_utils/ansible_models/inventory_source_update.py @@ -0,0 +1,26 @@ +""" +Ansible InventorySourceUpdate dataclass - user-facing stable interface. + +This dataclass represents an inventory source update (sync) launch request as +seen by Ansible playbooks. Fields match the DOCUMENTATION options that are +sent to the API. The wait/interval/timeout parameters control polling in +PlatformService/DirectHTTPClient.execute() (platform_manager.py, +direct_client.py) — they are popped from the ansible_data dict before this +dataclass is constructed, so they are not fields here. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class AnsibleInventorySourceUpdate: + """Ansible representation of an inventory source update launch request.""" + + name: str + inventory: Optional[str] = None + + # Read-only fields from API response (of the launched inventory_update job) + id: Optional[int] = None + status: Optional[str] = None + finished: Optional[str] = None diff --git a/plugins/plugin_utils/api/v1/inventory_source_update.py b/plugins/plugin_utils/api/v1/inventory_source_update.py new file mode 100644 index 00000000..5f014063 --- /dev/null +++ b/plugins/plugin_utils/api/v1/inventory_source_update.py @@ -0,0 +1,101 @@ +""" +API v1 InventorySourceUpdate dataclass and transform mixin. + +Handles transformations between Ansible format and the Controller API format +for launching an inventory source update (sync). + +Launching an update is a POST to an existing inventory_source's /update/ +sub-action endpoint, not a generic resource create — so the "create" and +"get" operations target two different Controller resource types: + - create: POST /api/controller/v2/inventory_sources/{inventory_source_id}/update/ + - get (poll for completion): GET /api/controller/v2/inventory_updates/{id}/ +""" + +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ...ansible_models.inventory_source_update import AnsibleInventorySourceUpdate +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + + +@dataclass +class APIInventorySourceUpdate_v1: + """Wire format for launching/polling a Controller inventory source update.""" + + inventory_source_id: Optional[int] = None + id: Optional[int] = None + status: Optional[str] = None + finished: Optional[str] = None + + +class InventorySourceUpdateTransformMixin_v1(BaseTransformMixin): + """Transforms between AnsibleInventorySourceUpdate and APIInventorySourceUpdate_v1.""" + + @classmethod + def from_ansible_data(cls, ansible_instance: AnsibleInventorySourceUpdate, context: TransformContext) -> APIInventorySourceUpdate_v1: + """Forward: Ansible model -> API wire format. + + If ``ansible_instance.id`` is already set (a poll of an in-flight + inventory_update, via _wait_for_resource_completion's replace()), reuse + it directly for the "get" operation's {id} path param. Otherwise this is + the initial launch: resolve the target inventory_source's id — scoped by + inventory, since inventory_source names are only unique per-inventory — + by reusing InventorySourceTransformMixin_v1's own find logic rather than + reimplementing composite name+inventory lookup here. + """ + if ansible_instance.id is not None: + return APIInventorySourceUpdate_v1(id=ansible_instance.id) + + find_data: Dict[str, Any] = {"name": ansible_instance.name} + if ansible_instance.inventory is not None: + inventory_id = context.manager.lookup_resource_id("/api/controller/v2/inventories/", "name", ansible_instance.inventory) + if inventory_id is not None: + find_data["inventory"] = str(inventory_id) + + found = context.manager.execute(operation="find", module_name="inventory_source", ansible_data_dict=find_data) + if not found or not found.get("id"): + raise ValueError("Could not find inventory_source '%s' in inventory '%s'" % (ansible_instance.name, ansible_instance.inventory)) + + return APIInventorySourceUpdate_v1(inventory_source_id=found["id"]) + + @classmethod + def from_api(cls, api_data: Dict[str, Any], context: TransformContext) -> AnsibleInventorySourceUpdate: + """Reverse: API response (the launched/polled inventory_update) -> Ansible model.""" + inventory = api_data.get("inventory") + + return AnsibleInventorySourceUpdate( + id=api_data.get("id"), + name=api_data.get("name", ""), + inventory=str(inventory) if inventory is not None else None, + status=api_data.get("status"), + finished=api_data.get("finished"), + ) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + return { + "create": EndpointOperation( + path="/api/controller/v2/inventory_sources/{inventory_source_id}/update/", + method="POST", + fields=[], + path_params=["inventory_source_id"], + required_for="create", + order=1, + ), + "get": EndpointOperation( + path="/api/controller/v2/inventory_updates/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + return "name" diff --git a/plugins/plugin_utils/manager/platform_manager.py b/plugins/plugin_utils/manager/platform_manager.py index 8cb7d024..fff255b0 100644 --- a/plugins/plugin_utils/manager/platform_manager.py +++ b/plugins/plugin_utils/manager/platform_manager.py @@ -10,7 +10,7 @@ import logging import threading import time -from dataclasses import asdict +from dataclasses import asdict, fields, is_dataclass, replace from multiprocessing.managers import BaseManager from socketserver import ThreadingMixIn from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple @@ -19,7 +19,7 @@ if TYPE_CHECKING: import requests -from ..platform.base_client import BaseAPIClient +from ..platform.base_client import DEFAULT_WAIT_TIMEOUT, BaseAPIClient, WaitTimeoutError from ..platform.config import GatewayConfig from ..platform.credential_manager import get_credential_manager from ..platform.exceptions import AuthenticationError @@ -507,6 +507,19 @@ def execute(self, operation: str, module_name: str, ansible_data_dict: dict) -> include_nulls = ansible_data_dict.pop("_platform_enforced", False) AnsibleClass, APIClass, MixinClass = self.loader.load_classes_for_module(module_name, self.api_version) + + # Pop launch-command wait/poll directives (e.g. ad_hoc_command, + # inventory_source_update) — these are control flags for this method, not + # fields on the resource dataclass. Only pop a name the target dataclass + # doesn't itself declare, so a module with a genuine field of the same name + # (e.g. job_template's own `timeout`) keeps it. + ansible_field_names = {f.name for f in fields(AnsibleClass)} + wait = ansible_data_dict.pop("wait", False) if "wait" not in ansible_field_names else False + wait_interval = ansible_data_dict.pop("interval", 2.0) if "interval" not in ansible_field_names else 2.0 + wait_timeout = ansible_data_dict.pop("timeout", None) if "timeout" not in ansible_field_names else None + if wait and wait_timeout is None: + wait_timeout = DEFAULT_WAIT_TIMEOUT + ansible_instance = AnsibleClass(**ansible_data_dict) context = TransformContext( manager=self, session=self.session, cache=self.cache, api_version=self.api_version, operation=operation, include_nulls_for_update=include_nulls @@ -515,6 +528,8 @@ def execute(self, operation: str, module_name: str, ansible_data_dict: dict) -> try: if operation == "create": result = self._create_resource(ansible_instance, MixinClass, context) + if wait: + result = self._wait_for_resource_completion(result, ansible_instance, MixinClass, context, module_name, wait_interval, wait_timeout) elif operation == "update": result = self._update_resource(ansible_instance, MixinClass, context) elif operation == "delete": @@ -566,6 +581,50 @@ def _create_resource(self, ansible_data: Any, mixin_class: type, context: dict) return {"changed": True} + def _wait_for_resource_completion( + self, + result: dict, + ansible_instance: Any, + mixin_class: type, + context: "TransformContext", + module_name: str, + interval: float, + timeout: Optional[float], + ) -> dict: + """Poll a just-launched resource until the API reports it finished. + + For launch-style resources (e.g. ad_hoc_command, inventory_source_update) + the create operation only starts an async job; the mixin's from_api() must + populate a truthy "finished" field once the job completes for this to + terminate. Shared by PlatformService and DirectHTTPClient so wait/interval/ + timeout behave the same regardless of connection mode — action plugins + never poll themselves. + + Raises: + WaitTimeoutError: If timeout is exceeded before the resource finishes. + Carries the last poll result so callers can still report id/status. + """ + if result.get("finished") or result.get("event_processing_finished") or result.get("id") is None: + return result + + find_instance = replace(ansible_instance, id=result["id"]) if is_dataclass(ansible_instance) else ansible_instance + start = time.monotonic() + + while True: + result = self._find_resource(find_instance, mixin_class, context) + if result.get("finished") or result.get("event_processing_finished"): + return result + + elapsed = time.monotonic() - start + if timeout is not None and elapsed >= timeout: + raise WaitTimeoutError( + "Timed out waiting for %s %s to complete after %s seconds (status: %s)" + % (module_name, result.get("id"), timeout, result.get("status", "unknown")), + last_result=result, + ) + + time.sleep(interval) + def _update_resource(self, ansible_data: Any, mixin_class: type, context: dict) -> dict: """ Update resource with transformation. @@ -969,17 +1028,28 @@ def _execute_operations(self, operations: Dict, api_data: Any, context: dict, re if getattr(endpoint_op, "flatten_body", False) and len(request_data) == 1: request_data = next(iter(request_data.values())) - if not request_data: + # Skip only when the operation actually declares body fields but none of + # them ended up populated (e.g. an unused optional secondary endpoint). + # An operation deliberately declared with fields=[] is a no-body launch + # trigger (e.g. inventory_source_update's POST .../update/) and must + # still fire even though request_data is empty. + if not request_data and endpoint_op.fields: logger.debug("Skipping %s - no data", op_name) continue path = endpoint_op.path if endpoint_op.path_params: + # Check the running multi-op `results` first (e.g. an "id" produced + # by a prior op in this same chain), then fall back to the matching + # attribute on api_data — by the param's own name, not hardcoded to + # "id", so custom path params like inventory_source_id (a + # launch-trigger sub-action, not the resource's own id) resolve + # correctly too. for param in endpoint_op.path_params: if param in results: path = path.replace(f"{{{param}}}", str(results[param])) - elif param == "id" and "id" in api_data_dict: - path = path.replace(f"{{{param}}}", str(api_data_dict["id"])) + elif param in api_data_dict and api_data_dict[param] is not None: + path = path.replace(f"{{{param}}}", str(api_data_dict[param])) url = self._build_url(path) diff --git a/plugins/plugin_utils/platform/base_client.py b/plugins/plugin_utils/platform/base_client.py index 62eef137..cef138cf 100644 --- a/plugins/plugin_utils/platform/base_client.py +++ b/plugins/plugin_utils/platform/base_client.py @@ -15,6 +15,25 @@ logger = logging.getLogger(__name__) +# Default ceiling (seconds) for launch-command wait/poll loops (e.g. ad_hoc_command, +# inventory_source_update) when the caller sets wait=True but does not supply an +# explicit timeout. Prevents indefinite polling in +# PlatformService/DirectHTTPClient._wait_for_resource_completion(). +DEFAULT_WAIT_TIMEOUT = 3600.0 + + +class WaitTimeoutError(ValueError): + """Raised when a launch-command wait/poll loop exceeds its timeout. + + Carries the last poll result so callers (e.g. action plugins) can still + report the launched resource's id/status instead of losing it — the + resource keeps running on the server even though waiting for it gave up. + """ + + def __init__(self, message: str, last_result: Optional[Dict[str, Any]] = None): + super().__init__(message) + self.last_result = last_result or {} + class BaseAPIClient(ABC): """ diff --git a/plugins/plugin_utils/platform/direct_client.py b/plugins/plugin_utils/platform/direct_client.py index e20165ee..f180afdf 100644 --- a/plugins/plugin_utils/platform/direct_client.py +++ b/plugins/plugin_utils/platform/direct_client.py @@ -11,6 +11,8 @@ import logging import re import threading +import time +from dataclasses import fields, is_dataclass, replace from typing import Any, Dict, Optional from urllib.parse import urlparse @@ -20,7 +22,7 @@ # Use Ansible's HTTP client instead of requests library for better worker process compatibility from ansible.module_utils.urls import ConnectionError, Request, SSLValidationError -from .base_client import BaseAPIClient +from .base_client import DEFAULT_WAIT_TIMEOUT, BaseAPIClient, WaitTimeoutError from .config import GatewayConfig from .credential_manager import get_credential_manager from .exceptions import APIError, AuthenticationError @@ -595,6 +597,18 @@ def execute(self, operation: str, module_name: str, ansible_data_dict=None, **kw # Pop action-only flags before building dataclass (action sets _platform_enforced for enforced state) include_nulls = ansible_data_dict.pop("_platform_enforced", False) + # Pop launch-command wait/poll directives (e.g. ad_hoc_command, + # inventory_source_update) — these are control flags for this method, not + # fields on the resource dataclass. Only pop a name the target dataclass + # doesn't itself declare, so a module with a genuine field of the same name + # (e.g. job_template's own `timeout`) keeps it. + ansible_field_names = {f.name for f in fields(AnsibleClass)} + wait = ansible_data_dict.pop("wait", False) if "wait" not in ansible_field_names else False + wait_interval = ansible_data_dict.pop("interval", 2.0) if "interval" not in ansible_field_names else 2.0 + wait_timeout = ansible_data_dict.pop("timeout", None) if "timeout" not in ansible_field_names else None + if wait and wait_timeout is None: + wait_timeout = DEFAULT_WAIT_TIMEOUT + # Reconstruct Ansible dataclass ansible_instance = AnsibleClass(**ansible_data_dict) @@ -607,6 +621,8 @@ def execute(self, operation: str, module_name: str, ansible_data_dict=None, **kw try: if operation == "create": result = self._create_resource(ansible_instance, MixinClass, context) + if wait: + result = self._wait_for_resource_completion(result, ansible_instance, MixinClass, context, module_name, wait_interval, wait_timeout) elif operation == "update": result = self._update_resource(ansible_instance, MixinClass, context) elif operation == "delete": @@ -652,6 +668,50 @@ def _create_resource(self, ansible_data: Any, mixin_class: type, context: Transf return {"changed": True} + def _wait_for_resource_completion( + self, + result: dict, + ansible_instance: Any, + mixin_class: type, + context: TransformContext, + module_name: str, + interval: float, + timeout: Optional[float], + ) -> dict: + """Poll a just-launched resource until the API reports it finished. + + For launch-style resources (e.g. ad_hoc_command, inventory_source_update) + the create operation only starts an async job; the mixin's from_api() must + populate a truthy "finished" field once the job completes for this to + terminate. Shared by PlatformService and DirectHTTPClient so wait/interval/ + timeout behave the same regardless of connection mode — action plugins + never poll themselves. + + Raises: + WaitTimeoutError: If timeout is exceeded before the resource finishes. + Carries the last poll result so callers can still report id/status. + """ + if result.get("finished") or result.get("event_processing_finished") or result.get("id") is None: + return result + + find_instance = replace(ansible_instance, id=result["id"]) if is_dataclass(ansible_instance) else ansible_instance + start = time.monotonic() + + while True: + result = self._find_resource(find_instance, mixin_class, context) + if result.get("finished") or result.get("event_processing_finished"): + return result + + elapsed = time.monotonic() - start + if timeout is not None and elapsed >= timeout: + raise WaitTimeoutError( + "Timed out waiting for %s %s to complete after %s seconds (status: %s)" + % (module_name, result.get("id"), timeout, result.get("status", "unknown")), + last_result=result, + ) + + time.sleep(interval) + def _update_resource(self, ansible_data: Any, mixin_class: type, context: TransformContext) -> dict: """Update resource with transformation.""" # Get the resource ID (not required for singleton resources) @@ -906,9 +966,14 @@ def _execute_operations(self, operations: Dict, api_data: Any, context: Transfor url = endpoint_op.path logger.info("DirectHTTPClient: Building URL for %s: %s", endpoint_op, url) if endpoint_op.path_params: - # Replace path parameters + # Replace path parameters. Check the running multi-op `results` first + # (e.g. an "id" produced by a prior op in this same chain), then fall + # back to the matching attribute on api_data — by the param's own + # name, not hardcoded to "id", so custom path params like + # inventory_source_id (a launch-trigger sub-action, not the + # resource's own id) resolve correctly too. for param in endpoint_op.path_params: - param_value = results.get("id") or getattr(api_data, "id", None) + param_value = results.get(param) if param in results else getattr(api_data, param, None) if param_value: url = url.replace(f"{{{param}}}", str(param_value)) logger.info("DirectHTTPClient: URL after replacing path parameters: %s", url) diff --git a/tests/integration/targets/inventory_source_update_test/meta/main.yml b/tests/integration/targets/inventory_source_update_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/inventory_source_update_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/inventory_source_update_test/tasks/main.yml b/tests/integration/targets/inventory_source_update_test/tasks/main.yml new file mode 100644 index 00000000..5c4211c5 --- /dev/null +++ b/tests/integration/targets/inventory_source_update_test/tasks/main.yml @@ -0,0 +1,100 @@ +--- +- name: Generate a test ID + ansible.builtin.set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Preset vars + ansible.builtin.set_fact: + name_prefix: "AAP-Collection-Test-InvSrcUpdate-{{ test_id }}" + +- name: Run Test + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | bool }}" + + block: + - name: Create Organization + ansible.platform.organization: + name: "{{ name_prefix }}-Organization" + register: org1 + + - name: Create Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + register: inv1 + + - name: Create inventory source + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + source: scm + source_path: "inventory.yml" + register: source1 + + - name: Check mode launch (must not actually launch) + ansible.platform.inventory_source_update: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + check_mode: true + register: check_mode_result + + - name: Assert check mode reports changed with no id + ansible.builtin.assert: + that: + - check_mode_result is changed + - check_mode_result.id is none + + - name: Launch an inventory source update, waiting for completion + ansible.platform.inventory_source_update: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + wait: true + interval: 1 + timeout: 120 + register: launch_result + + - name: Assert the update launched and completed + ansible.builtin.assert: + that: + - launch_result is changed + - launch_result.id is defined + - launch_result.status in ("successful", "failed", "error", "canceled") + + - name: Launch again without waiting (non-idempotent — always launches) + ansible.platform.inventory_source_update: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + register: launch_result_again + + - name: Assert the second launch is a distinct update + ansible.builtin.assert: + that: + - launch_result_again is changed + - launch_result_again.id != launch_result.id + + always: + - name: Delete Inventory Source + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Test-Source" + inventory: "{{ inv1.name }}" + state: absent + when: inv1 is defined and "id" in inv1 + + - name: Delete Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 + + - name: Delete Organization + ansible.platform.organization: + name: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 +... diff --git a/tests/unit/plugins/__init__.py b/tests/unit/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/action/__init__.py b/tests/unit/plugins/action/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/action/test_inventory_source_update.py b/tests/unit/plugins/action/test_inventory_source_update.py new file mode 100644 index 00000000..1748af28 --- /dev/null +++ b/tests/unit/plugins/action/test_inventory_source_update.py @@ -0,0 +1,106 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit tests for the inventory_source_update action plugin (check_mode, wait-timeout handling).""" + +from __future__ import absolute_import, division, print_function + +import unittest +from unittest.mock import MagicMock, patch + +from ansible.plugins.action import ActionBase +from ansible_collections.ansible.platform.plugins.action.inventory_source_update import ActionModule +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.base_client import WaitTimeoutError + + +def _make_action(check_mode=False): + """ActionModule with __init__ bypassed; only the attributes run() touches are set.""" + action = ActionModule.__new__(ActionModule) + action._task = MagicMock() + action._task.check_mode = check_mode + action._task.args = { + "name": "Example Inventory Source", + "inventory": "My Inventory", + } + action._display = MagicMock() + action._display.verbosity = 0 + return action + + +DOC = """ +--- +module: inventory_source_update +options: + name: {type: str, required: true, aliases: [inventory_source]} + inventory: {type: str, required: true} + wait: {type: bool, default: false} + interval: {type: float, default: 2.0} + timeout: {type: int} +""" + + +class TestCheckMode(unittest.TestCase): + def test_check_mode_does_not_launch(self): + action = _make_action(check_mode=True) + mock_manager = MagicMock() + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["changed"]) + self.assertFalse(result.get("failed", False)) + self.assertIsNone(result["id"]) + mock_manager.execute.assert_not_called() + + def test_normal_mode_still_launches(self): + action = _make_action(check_mode=False) + mock_manager = MagicMock() + mock_manager.execute.return_value = {"id": 5, "status": "pending"} + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["changed"]) + self.assertEqual(result["id"], 5) + mock_manager.execute.assert_called_once() + + +class TestWaitTimeoutHandling(unittest.TestCase): + def test_timeout_preserves_id_and_status(self): + action = _make_action(check_mode=False) + mock_manager = MagicMock() + mock_manager.execute.side_effect = WaitTimeoutError( + "Timed out waiting for inventory_source_update 9 to complete after 5 seconds (status: pending)", + last_result={"id": 9, "status": "pending"}, + ) + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["failed"]) + self.assertEqual(result["id"], 9) + self.assertEqual(result["status"], "pending") + + def test_terminal_failed_status_sets_failed_and_keeps_id(self): + action = _make_action(check_mode=False) + mock_manager = MagicMock() + mock_manager.execute.return_value = {"id": 3, "status": "failed"} + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["failed"]) + self.assertEqual(result["id"], 3) + self.assertEqual(result["status"], "failed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/connection/__init__.py b/tests/unit/plugins/connection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/plugin_utils/__init__.py b/tests/unit/plugins/plugin_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/plugin_utils/api/__init__.py b/tests/unit/plugins/plugin_utils/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/plugin_utils/api/v1/__init__.py b/tests/unit/plugins/plugin_utils/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/plugin_utils/api/v1/test_inventory_source_update.py b/tests/unit/plugins/plugin_utils/api/v1/test_inventory_source_update.py new file mode 100644 index 00000000..71ed6512 --- /dev/null +++ b/tests/unit/plugins/plugin_utils/api/v1/test_inventory_source_update.py @@ -0,0 +1,86 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Tests for the inventory_source_update v1 transform mixin (AAP-91390).""" + +from __future__ import absolute_import, division, print_function + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +_COLLECTIONS_PARENT = str(Path(__file__).resolve().parent.parent.parent.parent.parent.parent.parent.parent) +if _COLLECTIONS_PARENT not in sys.path: + sys.path.insert(0, _COLLECTIONS_PARENT) + +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.inventory_source_update import ( # noqa: E402 + AnsibleInventorySourceUpdate, +) +from ansible_collections.ansible.platform.plugins.plugin_utils.api.v1.inventory_source_update import ( # noqa: E402 + InventorySourceUpdateTransformMixin_v1, +) + + +def _make_context(inventory_lookup_id=5, found_inventory_source=None): + manager = MagicMock() + manager.lookup_resource_id.return_value = inventory_lookup_id + manager.execute.return_value = found_inventory_source if found_inventory_source is not None else {"id": 42} + context = MagicMock() + context.manager = manager + return context + + +class TestInventorySourceUpdateTransform(unittest.TestCase): + def test_from_ansible_data_resolves_inventory_source_id_via_find(self): + ansible = AnsibleInventorySourceUpdate(name="src", inventory="Demo Inventory") + context = _make_context(inventory_lookup_id=5, found_inventory_source={"id": 42}) + + api = InventorySourceUpdateTransformMixin_v1.from_ansible_data(ansible, context) + + context.manager.lookup_resource_id.assert_called_once_with("/api/controller/v2/inventories/", "name", "Demo Inventory") + context.manager.execute.assert_called_once_with( + operation="find", + module_name="inventory_source", + ansible_data_dict={"name": "src", "inventory": "5"}, + ) + self.assertEqual(api.inventory_source_id, 42) + self.assertIsNone(api.id) + + def test_from_ansible_data_reuses_id_when_already_set(self): + """Second call, during a wait poll, must skip the find and just target {id}.""" + ansible = AnsibleInventorySourceUpdate(name="src", inventory="Demo Inventory", id=99) + context = _make_context() + + api = InventorySourceUpdateTransformMixin_v1.from_ansible_data(ansible, context) + + context.manager.execute.assert_not_called() + self.assertEqual(api.id, 99) + self.assertIsNone(api.inventory_source_id) + + def test_from_ansible_data_raises_when_inventory_source_not_found(self): + ansible = AnsibleInventorySourceUpdate(name="missing", inventory="Demo Inventory") + context = _make_context(found_inventory_source={}) + + with self.assertRaises(ValueError): + InventorySourceUpdateTransformMixin_v1.from_ansible_data(ansible, context) + + def test_from_api_maps_launch_response(self): + ansible = InventorySourceUpdateTransformMixin_v1.from_api( + {"id": 86, "name": "src", "inventory": 5, "status": "pending", "finished": None}, + _make_context(), + ) + + self.assertEqual(ansible.id, 86) + self.assertEqual(ansible.status, "pending") + self.assertEqual(ansible.inventory, "5") + + def test_get_endpoint_operations_target_update_and_inventory_updates_paths(self): + ops = InventorySourceUpdateTransformMixin_v1.get_endpoint_operations() + self.assertEqual(ops["create"].path, "/api/controller/v2/inventory_sources/{inventory_source_id}/update/") + self.assertEqual(ops["get"].path, "/api/controller/v2/inventory_updates/{id}/") + self.assertNotIn("list", ops) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/plugin_utils/manager/__init__.py b/tests/unit/plugins/plugin_utils/manager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py b/tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py new file mode 100644 index 00000000..7b41e936 --- /dev/null +++ b/tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py @@ -0,0 +1,128 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Regression tests for PlatformService._execute_operations (AAP-91390). + +Covers two bugs found migrating inventory_source_update, a launch-trigger +sub-action with no request body and a custom path param name: + 1. An operation declared with fields=[] (a deliberate no-body trigger) must + still fire — it must not be treated the same as an unused optional + secondary endpoint with nothing populated. + 2. path_params entries other than the literal name "id" must be resolved + from the matching attribute on api_data, not silently left unsubstituted. +""" + +from __future__ import absolute_import, division, print_function + +import unittest +from dataclasses import dataclass +from typing import Optional +from unittest.mock import MagicMock, patch + +from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import PlatformService +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.config import GatewayConfig +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.types import EndpointOperation + + +@dataclass +class _FakeLaunchAPIData: + resource_id: Optional[int] = None + id: Optional[int] = None + + +def _make_platform_service(base_url="https://gw.example.com"): + """PlatformService with network and credentials mocked.""" + mock_session = MagicMock() + mock_requests = MagicMock() + mock_requests.Session.return_value = mock_session + mock_store = MagicMock() + mock_store.get_auth_credentials.return_value = ("admin", "admin", None) + with patch("ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager.get_credential_manager") as mock_cred: + mock_cred.return_value.get_or_create_store.return_value = mock_store + with patch("ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager._get_requests") as mock_get_requests: + mock_get_requests.return_value = mock_requests + config = GatewayConfig(base_url=base_url, username="admin", password="admin", idle_timeout=30.0) + return PlatformService(config) + + +def _resp(payload, status_code=202): + r = MagicMock() + r.status_code = status_code + r.json.return_value = payload + r.raise_for_status.return_value = None + return r + + +class TestNoBodyLaunchTrigger(unittest.TestCase): + def setUp(self): + self.svc = _make_platform_service() + + def test_fields_empty_operation_still_calls_the_api(self): + """A launch trigger with fields=[] must not be skipped for "having no data".""" + operations = { + "create": EndpointOperation( + path="/api/controller/v2/inventory_sources/{resource_id}/update/", + method="POST", + fields=[], + path_params=["resource_id"], + required_for="create", + order=1, + ), + } + api_data = _FakeLaunchAPIData(resource_id=42) + launched = {"id": 100, "status": "pending"} + + with patch.object(self.svc.session, "request", return_value=_resp(launched)) as mock_request: + result = self.svc._execute_operations(operations, api_data, context={}, required_for="create") + + mock_request.assert_called_once() + called_url = mock_request.call_args[0][1] + self.assertIn("/api/controller/v2/inventory_sources/42/update/", called_url) + self.assertEqual(result, launched) + + def test_custom_path_param_name_is_substituted(self): + """path_params entries other than "id" must resolve from the matching api_data attribute.""" + operations = { + "create": EndpointOperation( + path="/api/controller/v2/inventory_sources/{resource_id}/update/", + method="POST", + fields=[], + path_params=["resource_id"], + required_for="create", + order=1, + ), + } + api_data = _FakeLaunchAPIData(resource_id=7) + + with patch.object(self.svc.session, "request", return_value=_resp({"id": 1})) as mock_request: + self.svc._execute_operations(operations, api_data, context={}, required_for="create") + + called_url = mock_request.call_args[0][1] + self.assertNotIn("{resource_id}", called_url) + self.assertIn("/7/update/", called_url) + + def test_optional_secondary_endpoint_with_no_data_is_still_skipped(self): + """An operation with real fields, none of which are populated, is still skipped.""" + operations = { + "create": EndpointOperation( + path="/api/controller/v2/widgets/", + method="POST", + fields=["name"], + required_for="create", + order=1, + ), + } + + @dataclass + class _EmptyData: + name: Optional[str] = None + + with patch.object(self.svc.session, "request") as mock_request: + result = self.svc._execute_operations(operations, _EmptyData(), context={}, required_for="create") + + mock_request.assert_not_called() + self.assertEqual(result, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/plugin_utils/platform/__init__.py b/tests/unit/plugins/plugin_utils/platform/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/mock_gateway_server.py b/tools/mock_gateway_server.py index 9bc6cfeb..4f3759de 100644 --- a/tools/mock_gateway_server.py +++ b/tools/mock_gateway_server.py @@ -303,6 +303,12 @@ def _init_controller_resources(self) -> None: url_prefix="/api/controller/v2/inventory_sources/", associations=["notification_templates_started", "notification_templates_success", "notification_templates_error"], ) + self._controller_resources["inventory_updates"] = GenericResource( + resource_name="inventory_updates", + required_fields=[], + start_id=12000, + url_prefix="/api/controller/v2/inventory_updates/", + ) def controller_resource(self, name: str) -> Optional[GenericResource]: return self._controller_resources.get(name) @@ -653,6 +659,29 @@ def _parse_json_body(self) -> Dict[str, Any]: return {} return json.loads(raw.decode("utf-8")) + def _advance_inventory_update_poll(self, store: "GenericResource", item_id: int) -> Dict[str, Any]: + """Advance an inventory update's pending -> successful lifecycle on each GET-by-id poll. + + Simulates a real Controller update staying pending across the first two + polls before resolving, so wait/poll loops in tests actually poll more + than once instead of the mock resolving synchronously on launch. + """ + with store.lock: + if item_id not in store._items: + raise KeyError("not found") + item = store._items[item_id] + + if item.get("finished"): + return dict(item) + + item["_polls"] = item.get("_polls", 0) + 1 + if item["_polls"] >= 2: + item["status"] = "successful" + item["finished"] = _now_iso() + item["modified"] = _now_iso() + + return dict(item) + # ------------------------------------------------------------------ # Generic CRUD helper # ------------------------------------------------------------------ @@ -696,7 +725,10 @@ def _handle_generic_resource_store(self, store: "GenericResource", parts: list, return True if self.command == "GET": try: - self._send_json(200, store.get(item_id)) + if store.resource_name == "inventory_updates": + self._send_json(200, self._advance_inventory_update_poll(store, item_id)) + else: + self._send_json(200, store.get(item_id)) except KeyError: self._send_json(404, {"detail": "Not Found"}) return True @@ -779,6 +811,33 @@ def _route_controller(self, parts: list, qs: Dict[str, list]) -> None: self._send_json(404, {"detail": "Not Found"}) return + # inventory_sources/{id}/update/: launch a new inventory_updates job. + # Stays pending across the first two GET-by-id polls (see + # _advance_inventory_update_poll) so wait/poll loops actually poll + # more than once, instead of the mock resolving synchronously. + if field == "update" and resource == "inventory_sources": + if self.command == "POST": + try: + source = store.get(item_id) + except KeyError: + self._send_json(404, {"detail": "Not Found"}) + return + updates_store = self.store.controller_resource("inventory_updates") + launched = updates_store.create( + "2", + { + "name": source.get("name"), + "inventory": source.get("inventory"), + "status": "pending", + "finished": None, + "_polls": 0, + }, + ) + self._send_json(202, launched) + return + self._send_json(404, {"detail": "Not Found"}) + return + if field in store.associations: if self.command == "GET": try: From 183a79b78fc83926fa97cc7f353a007a9557806a Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 14:53:31 -0400 Subject: [PATCH 05/10] Add schedule module migrated from awx.awx/ansible.controller (AAP-91390) Shape 1 CRUD module, Pattern C (credentials/labels/instance_groups associations, no copy_from). Depends on unified_job_template for name->id lookup and unified_job_template-scoped name uniqueness. - New module, action plugin, transform mixin, Ansible model. - Drops the legacy organization option (disambiguation-only, no direct API field), documented in the module's notes. - Register under meta/runtime.yml action_groups.controller. - Register schedules, unified_job_templates, and labels as generic Controller resources in the mock server. - Unit tests, 3-connection-mode Molecule scenario, integration test target (uses an inventory_source as the schedulable unified_job_template, since job_template isn't part of this migration batch). Co-Authored-By: Claude Sonnet 5 --- changelogs/fragments/aap_91390_schedule.yml | 4 + extensions/molecule/schedule_mock/cleanup.yml | 104 +++++++ .../molecule/schedule_mock/converge.yml | 265 ++++++++++++++++++ .../molecule/schedule_mock/inventory.yml | 15 + .../molecule/schedule_mock/molecule.yml | 34 +++ extensions/molecule/schedule_mock/verify.yml | 94 +++++++ meta/runtime.yml | 1 + plugins/action/schedule.py | 96 +++++++ plugins/modules/schedule.py | 220 +++++++++++++++ .../plugin_utils/ansible_models/schedule.py | 52 ++++ plugins/plugin_utils/api/v1/schedule.py | 206 ++++++++++++++ .../targets/schedule_test/meta/main.yml | 4 + .../targets/schedule_test/tasks/main.yml | 137 +++++++++ .../plugin_utils/api/v1/test_schedule.py | 109 +++++++ tools/mock_gateway_server.py | 19 ++ 15 files changed, 1360 insertions(+) create mode 100644 changelogs/fragments/aap_91390_schedule.yml create mode 100644 extensions/molecule/schedule_mock/cleanup.yml create mode 100644 extensions/molecule/schedule_mock/converge.yml create mode 100644 extensions/molecule/schedule_mock/inventory.yml create mode 100644 extensions/molecule/schedule_mock/molecule.yml create mode 100644 extensions/molecule/schedule_mock/verify.yml create mode 100644 plugins/action/schedule.py create mode 100644 plugins/modules/schedule.py create mode 100644 plugins/plugin_utils/ansible_models/schedule.py create mode 100644 plugins/plugin_utils/api/v1/schedule.py create mode 100644 tests/integration/targets/schedule_test/meta/main.yml create mode 100644 tests/integration/targets/schedule_test/tasks/main.yml create mode 100644 tests/unit/plugins/plugin_utils/api/v1/test_schedule.py diff --git a/changelogs/fragments/aap_91390_schedule.yml b/changelogs/fragments/aap_91390_schedule.yml new file mode 100644 index 00000000..e1bd5c95 --- /dev/null +++ b/changelogs/fragments/aap_91390_schedule.yml @@ -0,0 +1,4 @@ +minor_changes: + - schedule - add module migrated from awx.awx/ansible.controller, including + credentials/labels/instance_groups associations + (https://issues.redhat.com/browse/AAP-91390). diff --git a/extensions/molecule/schedule_mock/cleanup.yml b/extensions/molecule/schedule_mock/cleanup.yml new file mode 100644 index 00000000..ffdc2ff8 --- /dev/null +++ b/extensions/molecule/schedule_mock/cleanup.yml @@ -0,0 +1,104 @@ +--- +# Cleanup: delete schedules created by converge (local, http direct, http persistent). +- name: Cleanup — delete schedule (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_ujt_name: "molecule-ujt-local" + molecule_schedule_name: "molecule-schedule-local" + tasks: + - name: Delete schedule (connection local) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + state: absent + register: delete_result_local + failed_when: false + vars: + ansible_connection: local + + - name: Assert schedule removed or already absent (connection local) + ansible.builtin.assert: + that: delete_result_local is not failed + fail_msg: "Cleanup: failed to delete schedule {{ molecule_schedule_name }}." + vars: + ansible_connection: local + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete schedule (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_ujt_name: "molecule-ujt-http-direct" + molecule_schedule_name: "molecule-schedule-http-direct" + tasks: + - name: Delete schedule (http direct) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + state: absent + register: delete_result_http_direct + failed_when: false + + - name: Assert schedule removed or already absent (http direct) + ansible.builtin.assert: + that: delete_result_http_direct is not failed + fail_msg: "Cleanup: failed to delete schedule {{ molecule_schedule_name }}." + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete schedule (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_ujt_name: "molecule-ujt-http-persistent" + molecule_schedule_name: "molecule-schedule-http-persistent" + tasks: + - name: Delete schedule (http persistent) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + state: absent + register: delete_result_http_persistent + failed_when: false + + - name: Assert schedule removed or already absent (http persistent) + ansible.builtin.assert: + that: delete_result_http_persistent is not failed + fail_msg: "Cleanup: failed to delete schedule {{ molecule_schedule_name }}." + + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local +... diff --git a/extensions/molecule/schedule_mock/converge.yml b/extensions/molecule/schedule_mock/converge.yml new file mode 100644 index 00000000..356309a8 --- /dev/null +++ b/extensions/molecule/schedule_mock/converge.yml @@ -0,0 +1,265 @@ +--- +# Converge: schedule create, idempotency, update, credentials/labels/instance_groups +# association, and delete against the mock Gateway/Controller. +- name: Ensure mock Gateway is reachable + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + tasks: + - name: Wait for mock Gateway health endpoint + ansible.builtin.uri: + url: "{{ gateway_hostname }}/health" + method: GET + status_code: 200 + register: health + retries: 12 + delay: 5 + until: health.status == 200 + vars: + ansible_connection: local + + - name: Ensure /tmp/ap directory exists + ansible.builtin.file: + path: /tmp/ap + state: directory + mode: "0755" + vars: + ansible_connection: local + + - name: Create manager survive flag (Molecule keeps manager alive across phases) + ansible.builtin.file: + path: /tmp/ap/.survive + state: touch + mode: "0600" + vars: + ansible_connection: local + +# Play 2: full feature coverage (connection local) — create, idempotency, update, +# associations, delete. +- name: Converge — schedule (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_ujt_name: "molecule-ujt-local" + molecule_schedule_name: "molecule-schedule-local" + tasks: + - name: Seed a fake unified_job_template directly against the mock Controller + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/unified_job_templates/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "{{ molecule_ujt_name }}" + status_code: 201 + register: ujt_local + vars: + ansible_connection: local + + - name: Seed a fake credential directly against the mock Controller + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/credentials/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "molecule-cred-local" + status_code: 201 + register: cred_local + vars: + ansible_connection: local + + - name: Create schedule + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + credentials: + - "molecule-cred-local" + register: create_result_local + vars: + ansible_connection: local + + - name: Assert create changed (connection local) + ansible.builtin.assert: + that: + - create_result_local is changed + - create_result_local.schedule.id is defined + - create_result_local.schedule.name == molecule_schedule_name + fail_msg: "Create (local) should report changed. create_result_local={{ create_result_local }}" + vars: + ansible_connection: local + + - name: Run again idempotency (connection local) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + credentials: + - "molecule-cred-local" + register: idem_result_local + vars: + ansible_connection: local + + - name: Assert idempotent run did not change (connection local) + ansible.builtin.assert: + that: idem_result_local is not changed + fail_msg: "Idempotent run (local) should not report changed. idem_result_local={{ idem_result_local }}" + vars: + ansible_connection: local + + - name: Update schedule (disable it, connection local) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + enabled: false + register: update_result_local + vars: + ansible_connection: local + + - name: Assert update changed (connection local) + ansible.builtin.assert: + that: + - update_result_local is changed + - update_result_local.schedule.enabled == false + fail_msg: "Update (local) should report changed. update_result_local={{ update_result_local }}" + vars: + ansible_connection: local + +# Play 3: basic CRUD parity (http direct). +- name: Converge — schedule (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_ujt_name: "molecule-ujt-http-direct" + molecule_schedule_name: "molecule-schedule-http-direct" + tasks: + - name: Seed a fake unified_job_template (http direct) + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/unified_job_templates/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "{{ molecule_ujt_name }}" + status_code: 201 + register: ujt_http_direct + + - name: Create schedule (http direct) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + register: create_result_http_direct + + - name: Assert create changed (http direct) + ansible.builtin.assert: + that: + - create_result_http_direct is changed + - create_result_http_direct.schedule.id is defined + fail_msg: "Create (http direct) should report changed. create_result_http_direct={{ create_result_http_direct }}" + + - name: Run again idempotency (http direct) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + register: idem_result_http_direct + + - name: Assert idempotent run did not change (http direct) + ansible.builtin.assert: + that: idem_result_http_direct is not changed + fail_msg: "Idempotent run (http direct) should not report changed. idem_result_http_direct={{ idem_result_http_direct }}" + + - name: Update schedule (http direct) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + enabled: false + register: update_result_http_direct + + - name: Assert update changed (http direct) + ansible.builtin.assert: + that: update_result_http_direct is changed + fail_msg: "Update (http direct) should report changed. update_result_http_direct={{ update_result_http_direct }}" + +# Play 4: basic CRUD parity (http persistent). +- name: Converge — schedule (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_ujt_name: "molecule-ujt-http-persistent" + molecule_schedule_name: "molecule-schedule-http-persistent" + tasks: + - name: Seed a fake unified_job_template (http persistent) + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/unified_job_templates/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "{{ molecule_ujt_name }}" + status_code: 201 + register: ujt_http_persistent + + - name: Create schedule (http persistent) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + register: create_result_http_persistent + + - name: Assert create changed (http persistent) + ansible.builtin.assert: + that: + - create_result_http_persistent is changed + - create_result_http_persistent.schedule.id is defined + fail_msg: "Create (http persistent) should report changed. create_result_http_persistent={{ create_result_http_persistent }}" + + - name: Run again idempotency (http persistent) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + register: idem_result_http_persistent + + - name: Assert idempotent run did not change (http persistent) + ansible.builtin.assert: + that: idem_result_http_persistent is not changed + fail_msg: "Idempotent run (http persistent) should not report changed. idem_result_http_persistent={{ idem_result_http_persistent }}" + + - name: Update schedule (http persistent) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + enabled: false + register: update_result_http_persistent + + - name: Assert update changed (http persistent) + ansible.builtin.assert: + that: update_result_http_persistent is changed + fail_msg: "Update (http persistent) should report changed. update_result_http_persistent={{ update_result_http_persistent }}" +... diff --git a/extensions/molecule/schedule_mock/inventory.yml b/extensions/molecule/schedule_mock/inventory.yml new file mode 100644 index 00000000..1904122c --- /dev/null +++ b/extensions/molecule/schedule_mock/inventory.yml @@ -0,0 +1,15 @@ +--- +# schedule_mock scenario: use scenario inventory so we can mix connection types. +# First play (health check) uses connection: local; other plays use ansible.platform.http. +# Mock gateway vars are set here and in play vars. Structure matches shared inventory for parse compatibility. +all: + vars: + ansible_connection: local + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + children: + gateway_under_test: + hosts: + localhost: {} diff --git a/extensions/molecule/schedule_mock/molecule.yml b/extensions/molecule/schedule_mock/molecule.yml new file mode 100644 index 00000000..f7279cbf --- /dev/null +++ b/extensions/molecule/schedule_mock/molecule.yml @@ -0,0 +1,34 @@ +--- +# Scenario: test ansible.platform.schedule against the mock Gateway/Controller server (no real AAP). +# Requires the mock to be running (e.g. "molecule test --all" or default scenario create). +driver: + name: default + +platforms: + - name: localhost + +# Use scenario inventory (connection: local + gateway vars). Converge: play 2 direct, play 3 persistent, play 4 connection local. +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + +provisioner: + name: ansible + playbooks: + converge: converge.yml + verify: verify.yml + cleanup: cleanup.yml + config_options: + defaults: + collections_path: "${MOLECULE_SCENARIO_DIRECTORY}/../../../../../../" + # Write verbose messages (vv/vvvv) to ANSIBLE_LOG_PATH even without -v on the terminal. + log_verbosity: 4 + +scenario: + test_sequence: + - converge + - verify + - cleanup +... diff --git a/extensions/molecule/schedule_mock/verify.yml b/extensions/molecule/schedule_mock/verify.yml new file mode 100644 index 00000000..65d8683a --- /dev/null +++ b/extensions/molecule/schedule_mock/verify.yml @@ -0,0 +1,94 @@ +--- +# Verify: all three connection scenarios (local, http direct, http persistent). +- name: Verify — schedule created with connection local (mock) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_ujt_name: "molecule-ujt-local" + molecule_schedule_name: "molecule-schedule-local" + tasks: + - name: Get schedule (state exists, connection local) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + state: exists + register: exists_result_local + vars: + ansible_connection: local + + - name: Assert schedule was found (connection local) + ansible.builtin.assert: + that: + - exists_result_local is not failed + - exists_result_local.get('exists') | default(false) | bool + - exists_result_local.get('schedule') is defined + fail_msg: "Verify: schedule {{ molecule_schedule_name }} not found (connection local)." + vars: + ansible_connection: local + + - name: Assert schedule was disabled by the update (connection local) + ansible.builtin.assert: + that: exists_result_local.schedule.enabled == false + fail_msg: "Verify: schedule (local) was not disabled as expected." + vars: + ansible_connection: local + +- name: Verify — schedule created with http direct (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_ujt_name: "molecule-ujt-http-direct" + molecule_schedule_name: "molecule-schedule-http-direct" + tasks: + - name: Get schedule (state exists, http direct) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + state: exists + register: exists_result_http_direct + + - name: Assert schedule was found (http direct) + ansible.builtin.assert: + that: + - exists_result_http_direct is not failed + - exists_result_http_direct.get('exists') | default(false) | bool + fail_msg: "Verify: schedule {{ molecule_schedule_name }} not found (http direct)." + +- name: Verify — schedule created with http persistent (mock) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_ujt_name: "molecule-ujt-http-persistent" + molecule_schedule_name: "molecule-schedule-http-persistent" + tasks: + - name: Get schedule (state exists, http persistent) + ansible.platform.schedule: + name: "{{ molecule_schedule_name }}" + unified_job_template: "{{ molecule_ujt_name }}" + state: exists + register: exists_result_http_persistent + + - name: Assert schedule was found (http persistent) + ansible.builtin.assert: + that: + - exists_result_http_persistent is not failed + - exists_result_http_persistent.get('exists') | default(false) | bool + fail_msg: "Verify: schedule {{ molecule_schedule_name }} not found (http persistent)." +... diff --git a/meta/runtime.yml b/meta/runtime.yml index bfb1301e..dadeb5c2 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -29,4 +29,5 @@ action_groups: - inventory - inventory_source - inventory_source_update + - schedule ... diff --git a/plugins/action/schedule.py b/plugins/action/schedule.py new file mode 100644 index 00000000..aa8bc2e2 --- /dev/null +++ b/plugins/action/schedule.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) 2026, Ansible Platform Collection Contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Action plugin for ansible.platform.schedule module. + +Migrated from awx.awx/ansible.controller schedule module. Uses Pattern C +(custom run override) due to association fields (credentials, labels, +instance_groups). +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from typing import Any + +from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.schedule import AnsibleSchedule + +logger = logging.getLogger(__name__) + +_ASSOCIATION_FIELDS = ( + "credentials", + "labels", + "instance_groups", +) + +_SCHEDULE_BASE_PATH = "/api/controller/v2/schedules" + +_ASSOCIATION_MAP = { + "credentials": ("/api/controller/v2/credentials/", "name"), + "labels": ("/api/controller/v2/labels/", "name"), + "instance_groups": ("/api/controller/v2/instance_groups/", "name"), +} + + +class ActionModule(BaseResourceActionPlugin): + """Action plugin for schedule module.""" + + MODULE_NAME = "schedule" + MODEL_CLASS = AnsibleSchedule + LOOKUP_FIELD = "name" + + _WRITE_ONLY_FIELDS = frozenset(_ASSOCIATION_FIELDS) + + def _build_ansible_data(self, resource: Any, validated_params: dict, operation: str) -> dict: + """Build ansible_data from explicitly-provided task parameters only.""" + data = {k: getattr(resource, k) for k in validated_params if hasattr(resource, k)} + if getattr(resource, "id", None) is not None: + data["id"] = resource.id + return data + + def run(self, tmp: object = None, task_vars: dict = None) -> dict: + """Run the schedule action plugin. + + Extends the base run() to sync credentials/labels/instance_groups + association fields after standard CRUD. All HTTP calls are delegated to + the SDK layer (PlatformService / DirectHTTPClient). + """ + state = self._task.args.get("state", "present") + + association_data = {} + for field in _ASSOCIATION_FIELDS: + val = self._task.args.pop(field, None) + if val is not None: + association_data[field] = val + + result = super().run(tmp, task_vars) + + if result.get("failed"): + return result + + schedule_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") + + if schedule_id and state not in ("absent", "deleted", "exists"): + manager = self._client + if manager: + for field, (lookup_ep, lookup_field) in _ASSOCIATION_MAP.items(): + desired = association_data.get(field) + if desired is not None: + changed = manager.manage_associations( + _SCHEDULE_BASE_PATH, + schedule_id, + field, + desired, + lookup_ep, + lookup_field, + ) + if changed: + result["changed"] = True + + return result diff --git a/plugins/modules/schedule.py b/plugins/modules/schedule.py new file mode 100644 index 00000000..70866647 --- /dev/null +++ b/plugins/modules/schedule.py @@ -0,0 +1,220 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2020, John Westcott IV +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This module is implemented as an action plugin. +# See plugins/action/schedule.py for the implementation. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: schedule +author: Red Hat (@RedHatOfficial) +short_description: Create, update, or destroy Automation Platform Controller schedules +description: + - Create, update, or destroy Automation Platform Controller schedules. +version_added: "3.0.0" + +options: + name: + description: + - Name of this schedule. + required: true + type: str + + new_name: + description: + - Setting this option will change the existing name (looked up via the name field). + type: str + + description: + description: + - Optional description of this schedule. + type: str + + rrule: + description: + - A value representing the schedule's iCal recurrence rule. + - Required when creating a new schedule. + type: str + + unified_job_template: + description: + - Name, ID, or named URL of unified job template to schedule. + - Used to look up an already existing schedule, and required when creating a new one. + type: str + + execution_environment: + description: + - Execution Environment name, ID, or named URL applied as a prompt, assuming the job template prompts for execution environment. + type: str + + extra_data: + description: + - Specify C(extra_vars) for the template. + type: dict + + forks: + description: + - Forks applied as a prompt, assuming the job template prompts for forks. + type: int + + instance_groups: + description: + - List of Instance Group names, IDs, or named URLs applied as a prompt, assuming the job template prompts for instance groups. + type: list + elements: str + + inventory: + description: + - Inventory name, ID, or named URL applied as a prompt, assuming the job template prompts for inventory. + type: str + + job_slice_count: + description: + - Job Slice Count applied as a prompt, assuming the job template prompts for job slice count. + type: int + + labels: + description: + - List of label names applied as a prompt, assuming the job template prompts for labels. + type: list + elements: str + + credentials: + description: + - List of credential names, IDs, or named URLs applied as a prompt, assuming the job template prompts for credentials. + type: list + elements: str + + scm_branch: + description: + - Branch to use in the job run. Project default used if blank. Only allowed if the project's C(allow_override) field is set to true. + type: str + + timeout: + description: + - Timeout applied as a prompt, assuming the job template prompts for timeout. + type: int + + job_type: + description: + - The job type to use for the job template. + type: str + choices: ['run', 'check'] + + job_tags: + description: + - Comma separated list of the tags to use for the job template. + type: str + + skip_tags: + description: + - Comma separated list of the tags to skip for the job template. + type: str + + limit: + description: + - A host pattern to further constrain the list of hosts managed or affected by the playbook. + type: str + + diff_mode: + description: + - Enable diff mode for the job template. + type: bool + + verbosity: + description: + - Control the output level Ansible produces as the playbook runs. + type: int + choices: [0, 1, 2, 3, 4, 5] + + enabled: + description: + - Enables processing of this schedule. + type: bool + + state: + description: + - Desired state of the schedule. + - C(present) ensures the schedule exists (create or update); idempotent. + - C(absent) removes the schedule; idempotent if already absent. + - C(exists) reads and returns the current schedule (no change). + type: str + choices: ['present', 'absent', 'exists', 'enforced'] + default: 'present' + +extends_documentation_fragment: + - ansible.platform.state + - ansible.platform.auth + +notes: + - The legacy C(organization) option (used only to disambiguate the C(unified_job_template) + name lookup when names collide across organizations) is not carried over — name lookups + resolve globally. Use a unique name, or the numeric ID, if this is a concern. + +seealso: + - module: ansible.controller.schedule + - module: awx.awx.schedule +""" + +EXAMPLES = """ +- name: Build a schedule for Demo Job Template + ansible.platform.schedule: + name: "Demo Schedule" + unified_job_template: "Demo Job Template" + rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + +- name: Build the same schedule using the rrule plugin + ansible.platform.schedule: + name: "Demo Schedule" + unified_job_template: "Demo Job Template" + rrule: "{{ query('awx.awx.schedule_rrule', 'week', start_date='2019-12-19 13:05:51') | first }}" + +- name: Add credentials and labels applied as prompts + ansible.platform.schedule: + name: "Demo Schedule" + unified_job_template: "Demo Job Template" + rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + credentials: + - Demo Credential + labels: + - Demo Label + +- name: Delete a schedule + ansible.platform.schedule: + name: "Demo Schedule" + unified_job_template: "Demo Job Template" + state: absent +... +""" + +RETURN = """ +changed: + description: Whether the schedule was created, updated, or deleted. + returned: always + type: bool + +schedule: + description: > + The schedule resource as it exists after the operation. + Contains only the fields accepted as module input (argspec fields) plus C(id). + returned: when state is present, exists, or enforced + type: dict + contains: + id: + description: Numeric database ID of the schedule. + type: int + name: + description: Name of the schedule. + type: str + rrule: + description: The schedule's iCal recurrence rule. + type: str +... +""" diff --git a/plugins/plugin_utils/ansible_models/schedule.py b/plugins/plugin_utils/ansible_models/schedule.py new file mode 100644 index 00000000..3c7e99dc --- /dev/null +++ b/plugins/plugin_utils/ansible_models/schedule.py @@ -0,0 +1,52 @@ +""" +Ansible Schedule dataclass - user-facing stable interface. + +This dataclass represents the schedule as seen by Ansible playbooks. +Field names and types remain stable across API versions. + +credentials/labels/instance_groups are handled by the action plugin +(association sync) — they are popped from ansible_data before this dataclass +is constructed, so they are not fields here. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class AnsibleSchedule: + """Ansible representation of a Controller schedule.""" + + # Required / identity + name: str + + # Optional fields + new_name: Optional[str] = None + rrule: Optional[str] = None + description: Optional[str] = None + # unified_job_template is required at the DOCUMENTATION/argspec level for + # create; defaults to None here so internal callers building a partial + # instance for a lookup don't need to supply it (matches + # AnsibleInventory's organization precedent). + unified_job_template: Optional[str] = None + execution_environment: Optional[str] = None + extra_data: Optional[dict] = None + forks: Optional[int] = None + inventory: Optional[str] = None + job_slice_count: Optional[int] = None + timeout: Optional[int] = None + scm_branch: Optional[str] = None + job_type: Optional[str] = None + job_tags: Optional[str] = None + skip_tags: Optional[str] = None + limit: Optional[str] = None + diff_mode: Optional[bool] = None + verbosity: Optional[int] = None + enabled: Optional[bool] = None + state: str = "present" + + # Read-only fields (populated from API responses) + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None diff --git a/plugins/plugin_utils/api/v1/schedule.py b/plugins/plugin_utils/api/v1/schedule.py new file mode 100644 index 00000000..07c226d6 --- /dev/null +++ b/plugins/plugin_utils/api/v1/schedule.py @@ -0,0 +1,206 @@ +""" +API v1 Schedule dataclass and transform mixin. + +Handles transformations between Ansible format and the Controller API format +for schedule resources. +""" + +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ...ansible_models.schedule import AnsibleSchedule +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + +_NAME_LOOKUP_FIELDS = ( + ("inventory", "/api/controller/v2/inventories/"), + ("execution_environment", "/api/controller/v2/execution_environments/"), + ("unified_job_template", "/api/controller/v2/unified_job_templates/"), +) + +_DIRECT_FIELDS = ( + "description", + "rrule", + "forks", + "job_slice_count", + "timeout", + "scm_branch", + "job_type", + "job_tags", + "skip_tags", + "limit", + "diff_mode", + "verbosity", + "enabled", +) + + +@dataclass +class APISchedule_v1: + """Wire format for Controller schedules.""" + + name: str + rrule: Optional[str] = None + description: Optional[str] = None + unified_job_template: Optional[int] = None + execution_environment: Optional[int] = None + extra_data: Optional[dict] = None + forks: Optional[int] = None + inventory: Optional[int] = None + job_slice_count: Optional[int] = None + timeout: Optional[int] = None + scm_branch: Optional[str] = None + job_type: Optional[str] = None + job_tags: Optional[str] = None + skip_tags: Optional[str] = None + limit: Optional[str] = None + diff_mode: Optional[bool] = None + verbosity: Optional[int] = None + enabled: Optional[bool] = None + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + +class ScheduleTransformMixin_v1(BaseTransformMixin): + """Transforms between AnsibleSchedule and APISchedule_v1.""" + + @classmethod + def from_ansible_data(cls, ansible_instance: AnsibleSchedule, context: TransformContext) -> APISchedule_v1: + """Forward: Ansible model -> API wire format.""" + params: Dict[str, Any] = { + "name": ansible_instance.new_name or ansible_instance.name, + } + + for field, endpoint in _NAME_LOOKUP_FIELDS: + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = context.manager.lookup_resource_id(endpoint, "name", value) + + for field in _DIRECT_FIELDS: + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + if ansible_instance.extra_data is not None: + params["extra_data"] = ansible_instance.extra_data + + # Read-only from API (for building the {id} URL path param in execute) + for field in ("id", "created", "modified", "url"): + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + return APISchedule_v1(**params) + + @classmethod + def from_api(cls, api_data: Dict[str, Any], context: TransformContext) -> AnsibleSchedule: + """Reverse: API response -> Ansible model.""" + + def _str_or_none(val: Any) -> Optional[str]: + return str(val) if val is not None else None + + return AnsibleSchedule( + id=api_data.get("id"), + name=api_data.get("name", ""), + rrule=api_data.get("rrule"), + description=api_data.get("description"), + unified_job_template=_str_or_none(api_data.get("unified_job_template")), + execution_environment=_str_or_none(api_data.get("execution_environment")), + extra_data=api_data.get("extra_data"), + forks=api_data.get("forks"), + inventory=_str_or_none(api_data.get("inventory")), + job_slice_count=api_data.get("job_slice_count"), + timeout=api_data.get("timeout"), + scm_branch=api_data.get("scm_branch"), + job_type=api_data.get("job_type"), + job_tags=api_data.get("job_tags"), + skip_tags=api_data.get("skip_tags"), + limit=api_data.get("limit"), + diff_mode=api_data.get("diff_mode"), + verbosity=api_data.get("verbosity"), + enabled=api_data.get("enabled"), + created=api_data.get("created"), + modified=api_data.get("modified"), + url=api_data.get("url"), + ) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + fields = [ + "name", + "description", + "rrule", + "unified_job_template", + "execution_environment", + "extra_data", + "forks", + "inventory", + "job_slice_count", + "timeout", + "scm_branch", + "job_type", + "job_tags", + "skip_tags", + "limit", + "diff_mode", + "verbosity", + "enabled", + ] + + return { + "create": EndpointOperation( + path="/api/controller/v2/schedules/", + method="POST", + fields=fields, + required_for="create", + order=1, + ), + "update": EndpointOperation( + path="/api/controller/v2/schedules/{id}/", + method="PATCH", + fields=fields, + path_params=["id"], + required_for="update", + order=1, + ), + "delete": EndpointOperation( + path="/api/controller/v2/schedules/{id}/", + method="DELETE", + fields=[], + path_params=["id"], + required_for="delete", + order=1, + ), + "get": EndpointOperation( + path="/api/controller/v2/schedules/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + "list": EndpointOperation( + path="/api/controller/v2/schedules/", + method="GET", + fields=[], + required_for="find", + order=1, + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + return "name" + + @classmethod + def get_find_list_query_params(cls, ansible_data: APISchedule_v1) -> Dict[str, Any]: + """Scope name lookups by unified_job_template — schedule names are only unique per-template.""" + ujt_id = getattr(ansible_data, "unified_job_template", None) + if ujt_id is not None: + return {"unified_job_template": ujt_id} + return {} diff --git a/tests/integration/targets/schedule_test/meta/main.yml b/tests/integration/targets/schedule_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/schedule_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/schedule_test/tasks/main.yml b/tests/integration/targets/schedule_test/tasks/main.yml new file mode 100644 index 00000000..880480cf --- /dev/null +++ b/tests/integration/targets/schedule_test/tasks/main.yml @@ -0,0 +1,137 @@ +--- +- name: Generate a test ID + ansible.builtin.set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Preset vars + ansible.builtin.set_fact: + name_prefix: "AAP-Collection-Test-Schedule-{{ test_id }}" + +- name: Run Test + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | bool }}" + + block: + - name: Create Organization + ansible.platform.organization: + name: "{{ name_prefix }}-Organization" + register: org1 + + - name: Create Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + register: inv1 + + # An inventory_source is itself a schedulable unified_job_template — used + # here instead of a job_template (not part of this migration batch) so the + # test stays self-contained. + - name: Create inventory source (schedulable unified_job_template) + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Source" + inventory: "{{ inv1.name }}" + source: scm + source_path: "inventory.yml" + register: source1 + + - name: Create schedule + ansible.platform.schedule: + name: "{{ name_prefix }}-Schedule" + unified_job_template: "{{ source1.inventory_source.name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + register: created_schedule + + - name: Assert creation changed + ansible.builtin.assert: + that: + - created_schedule is changed + - created_schedule.schedule.name is defined + + - name: Check idempotency (re-apply, expect no change) + ansible.platform.schedule: + name: "{{ name_prefix }}-Schedule" + unified_job_template: "{{ source1.inventory_source.name }}" + rrule: "DTSTART:20991219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1" + register: idempotent_schedule + + - name: Assert no change on re-apply + ansible.builtin.assert: + that: + - idempotent_schedule is not changed + + - name: Update schedule (disable it) + ansible.platform.schedule: + name: "{{ name_prefix }}-Schedule" + unified_job_template: "{{ source1.inventory_source.name }}" + enabled: false + register: updated_schedule + + - name: Assert update changed + ansible.builtin.assert: + that: + - updated_schedule is changed + - updated_schedule.schedule.id == created_schedule.schedule.id + + - name: Check exists returns true + ansible.platform.schedule: + name: "{{ name_prefix }}-Schedule" + unified_job_template: "{{ source1.inventory_source.name }}" + state: exists + register: exists_check + + - name: Assert exists is true and no change + ansible.builtin.assert: + that: + - exists_check.exists + - exists_check is not changed + + - name: Delete schedule + ansible.platform.schedule: + name: "{{ name_prefix }}-Schedule" + unified_job_template: "{{ source1.inventory_source.name }}" + state: absent + register: deleted_schedule + + - name: Assert delete changed + ansible.builtin.assert: + that: + - deleted_schedule is changed + + - name: Delete schedule again (idempotent) + ansible.platform.schedule: + name: "{{ name_prefix }}-Schedule" + unified_job_template: "{{ source1.inventory_source.name }}" + state: absent + register: deleted_schedule_again + + - name: Assert repeat delete is a no-op + ansible.builtin.assert: + that: + - deleted_schedule_again is not changed + + always: + - name: Delete Inventory Source + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Source" + inventory: "{{ name_prefix }}-Inventory" + state: absent + when: inv1 is defined and "id" in inv1 + + - name: Delete Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 + + - name: Delete Organization + ansible.platform.organization: + name: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 +... diff --git a/tests/unit/plugins/plugin_utils/api/v1/test_schedule.py b/tests/unit/plugins/plugin_utils/api/v1/test_schedule.py new file mode 100644 index 00000000..eb9daee0 --- /dev/null +++ b/tests/unit/plugins/plugin_utils/api/v1/test_schedule.py @@ -0,0 +1,109 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Tests for the schedule v1 transform mixin (AAP-91390).""" + +from __future__ import absolute_import, division, print_function + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +_COLLECTIONS_PARENT = str(Path(__file__).resolve().parent.parent.parent.parent.parent.parent.parent.parent) +if _COLLECTIONS_PARENT not in sys.path: + sys.path.insert(0, _COLLECTIONS_PARENT) + +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.schedule import ( # noqa: E402 + AnsibleSchedule, +) +from ansible_collections.ansible.platform.plugins.plugin_utils.api.v1.schedule import ( # noqa: E402 + APISchedule_v1, + ScheduleTransformMixin_v1, +) + + +def _make_context(lookup_returns=None, default=1): + manager = MagicMock() + if lookup_returns is not None: + + def _side_effect(endpoint, field, value): + return lookup_returns.get((endpoint, value), default) + + manager.lookup_resource_id.side_effect = _side_effect + else: + manager.lookup_resource_id.return_value = default + context = MagicMock() + context.manager = manager + return context + + +class TestScheduleTransform(unittest.TestCase): + def test_from_ansible_data_resolves_all_fk_fields(self): + ansible = AnsibleSchedule( + name="sched", + rrule="DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1", + unified_job_template="Demo Job Template", + inventory="Demo Inventory", + execution_environment="Default EE", + ) + lookups = { + ("/api/controller/v2/unified_job_templates/", "Demo Job Template"): 10, + ("/api/controller/v2/inventories/", "Demo Inventory"): 20, + ("/api/controller/v2/execution_environments/", "Default EE"): 30, + } + context = _make_context(lookup_returns=lookups) + + api = ScheduleTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.unified_job_template, 10) + self.assertEqual(api.inventory, 20) + self.assertEqual(api.execution_environment, 30) + self.assertEqual(api.rrule, "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1") + + def test_from_ansible_data_uses_new_name_when_set(self): + ansible = AnsibleSchedule(name="old", new_name="new") + context = _make_context() + + api = ScheduleTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.name, "new") + + def test_from_ansible_data_passes_extra_data_dict_through(self): + ansible = AnsibleSchedule(name="sched", extra_data={"foo": "bar"}) + context = _make_context() + + api = ScheduleTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.extra_data, {"foo": "bar"}) + + def test_from_ansible_data_includes_id_for_update_url(self): + ansible = AnsibleSchedule(name="sched", id=55) + context = _make_context() + + api = ScheduleTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.id, 55) + + def test_from_api_maps_fields(self): + ansible = ScheduleTransformMixin_v1.from_api( + {"id": 1, "name": "sched", "unified_job_template": 10, "inventory": 20}, + _make_context(), + ) + + self.assertEqual(ansible.unified_job_template, "10") + self.assertEqual(ansible.inventory, "20") + + def test_get_endpoint_operations_use_controller_paths(self): + ops = ScheduleTransformMixin_v1.get_endpoint_operations() + for op_name in ("create", "list"): + self.assertTrue(ops[op_name].path.startswith("/api/controller/v2/schedules")) + + def test_get_find_list_query_params_scopes_by_unified_job_template(self): + api_data = APISchedule_v1(name="sched", unified_job_template=10) + params = ScheduleTransformMixin_v1.get_find_list_query_params(api_data) + self.assertEqual(params, {"unified_job_template": 10}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mock_gateway_server.py b/tools/mock_gateway_server.py index 4f3759de..6e50293f 100644 --- a/tools/mock_gateway_server.py +++ b/tools/mock_gateway_server.py @@ -309,6 +309,25 @@ def _init_controller_resources(self) -> None: start_id=12000, url_prefix="/api/controller/v2/inventory_updates/", ) + self._controller_resources["unified_job_templates"] = GenericResource( + resource_name="unified_job_templates", + required_fields=["name"], + start_id=13000, + url_prefix="/api/controller/v2/unified_job_templates/", + ) + self._controller_resources["labels"] = GenericResource( + resource_name="labels", + required_fields=["name"], + start_id=14000, + url_prefix="/api/controller/v2/labels/", + ) + self._controller_resources["schedules"] = GenericResource( + resource_name="schedules", + required_fields=["name"], + start_id=15000, + url_prefix="/api/controller/v2/schedules/", + associations=["credentials", "labels", "instance_groups"], + ) def controller_resource(self, name: str) -> Optional[GenericResource]: return self._controller_resources.get(name) From 0ac28f30dbef2a5d86256c7457d120eac1bd6190 Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 15:10:33 -0400 Subject: [PATCH 06/10] Add job_launch module migrated from awx.awx/ansible.controller (AAP-91390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shape 2 launch resource (POST to an existing job_template's /launch/ sub-action, then optionally poll the launched job for completion) — same wait/poll SDK infrastructure as ad_hoc_command/inventory_source_update. Resolves the job_template by name via unified_job_templates rather than a job_template-specific endpoint, since this collection doesn't ship a job_template CRUD module (excluded from this batch — see PR #228). Refines the _execute_operations fix from the inventory_source_update commit: that fix gated the "still call the API with an empty body" case on the operation's fields list being empty, which correctly handled inventory_source_update's true no-body trigger but broke job_launch's launch (real optional fields like extra_vars/limit, all simply unset on the common "just launch it" call). The correct, final gate is `depends_on` — matching DirectHTTPClient's already-correct behavior exactly: skip only a *secondary* operation (depends_on set) with nothing to send; a *primary* operation always fires. Verified inventory_source_update, schedule, host, inventory, and inventory_source Molecule scenarios all still pass under the corrected gate. - New module, action plugin (adapted from the ad_hoc_command Shape 2 pattern), transform mixin, Ansible model. - Drops the legacy organization option and the client-side ask_*_on_launch prompt validation, documented in the module's notes. - Register under meta/runtime.yml action_groups.controller. - Register job_templates and jobs as generic Controller resources in the mock server, with a pending -> successful poll-advance lifecycle shared with inventory_updates. Teach the mock's unified_job_templates GET (list) to union job_templates/inventory_sources/its own dedicated store, mirroring real Controller (a polymorphic view sharing IDs, not a separate table) — needed so a resolved unified_job_template id is actually launchable. - Unit tests (transform mixin, action plugin check_mode/WaitTimeoutError, updated _execute_operations regression coverage), 3-connection-mode Molecule scenario, integration test target (uses an inventory_source as the launchable unified_job_template, same as schedule_test). Co-Authored-By: Claude Sonnet 5 --- changelogs/fragments/aap_91390_job_launch.yml | 10 + .../molecule/job_launch_mock/cleanup.yml | 14 ++ .../molecule/job_launch_mock/converge.yml | 217 ++++++++++++++++++ .../molecule/job_launch_mock/inventory.yml | 15 ++ .../molecule/job_launch_mock/molecule.yml | 34 +++ .../molecule/job_launch_mock/verify.yml | 28 +++ meta/runtime.yml | 1 + plugins/action/job_launch.py | 134 +++++++++++ plugins/modules/job_launch.py | 209 +++++++++++++++++ .../plugin_utils/ansible_models/job_launch.py | 42 ++++ plugins/plugin_utils/api/v1/job_launch.py | 185 +++++++++++++++ .../plugin_utils/manager/platform_manager.py | 14 +- .../targets/job_launch_test/meta/main.yml | 4 + .../targets/job_launch_test/tasks/main.yml | 100 ++++++++ tests/unit/plugins/action/test_job_launch.py | 104 +++++++++ .../plugin_utils/api/v1/test_job_launch.py | 125 ++++++++++ .../test_execute_operations_launch_trigger.py | 71 ++++-- tools/mock_gateway_server.py | 67 +++++- 18 files changed, 1343 insertions(+), 31 deletions(-) create mode 100644 changelogs/fragments/aap_91390_job_launch.yml create mode 100644 extensions/molecule/job_launch_mock/cleanup.yml create mode 100644 extensions/molecule/job_launch_mock/converge.yml create mode 100644 extensions/molecule/job_launch_mock/inventory.yml create mode 100644 extensions/molecule/job_launch_mock/molecule.yml create mode 100644 extensions/molecule/job_launch_mock/verify.yml create mode 100644 plugins/action/job_launch.py create mode 100644 plugins/modules/job_launch.py create mode 100644 plugins/plugin_utils/ansible_models/job_launch.py create mode 100644 plugins/plugin_utils/api/v1/job_launch.py create mode 100644 tests/integration/targets/job_launch_test/meta/main.yml create mode 100644 tests/integration/targets/job_launch_test/tasks/main.yml create mode 100644 tests/unit/plugins/action/test_job_launch.py create mode 100644 tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py diff --git a/changelogs/fragments/aap_91390_job_launch.yml b/changelogs/fragments/aap_91390_job_launch.yml new file mode 100644 index 00000000..cc4cb638 --- /dev/null +++ b/changelogs/fragments/aap_91390_job_launch.yml @@ -0,0 +1,10 @@ +minor_changes: + - job_launch - add module migrated from awx.awx/ansible.controller to launch + an Ansible job template (https://issues.redhat.com/browse/AAP-91390). +bugfixes: + - Fix the SDK layer's shared operation executor to still call the API for a + primary create/launch operation when all of its optional body fields + happen to be unset (e.g. job_launch with no prompt overrides) — it was + only correctly distinguishing this from an unused optional secondary + endpoint (like an association or sub-resource) in one of the two + connection-mode implementations. diff --git a/extensions/molecule/job_launch_mock/cleanup.yml b/extensions/molecule/job_launch_mock/cleanup.yml new file mode 100644 index 00000000..ac5116e6 --- /dev/null +++ b/extensions/molecule/job_launch_mock/cleanup.yml @@ -0,0 +1,14 @@ +--- +# Cleanup: job_launch and its seeded job_templates/jobs are mock-only artifacts +# with no module-managed lifecycle — nothing to delete beyond the manager +# survive flags that keep the persistent connection manager alive across phases. +- name: Cleanup — remove manager survive flag (connection local) + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Remove manager survive flag (signals manager to shut down) + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent +... diff --git a/extensions/molecule/job_launch_mock/converge.yml b/extensions/molecule/job_launch_mock/converge.yml new file mode 100644 index 00000000..44eb374f --- /dev/null +++ b/extensions/molecule/job_launch_mock/converge.yml @@ -0,0 +1,217 @@ +--- +# Converge: job_launch launch (no wait), launch (wait), non-idempotency, +# and check_mode against the mock Gateway/Controller. +- name: Ensure mock Gateway is reachable + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + tasks: + - name: Wait for mock Gateway health endpoint + ansible.builtin.uri: + url: "{{ gateway_hostname }}/health" + method: GET + status_code: 200 + register: health + retries: 12 + delay: 5 + until: health.status == 200 + vars: + ansible_connection: local + + - name: Ensure /tmp/ap directory exists + ansible.builtin.file: + path: /tmp/ap + state: directory + mode: "0755" + vars: + ansible_connection: local + + - name: Create manager survive flag (Molecule keeps manager alive across phases) + ansible.builtin.file: + path: /tmp/ap/.survive + state: touch + mode: "0600" + vars: + ansible_connection: local + +# Play 2: full feature coverage (connection local) — launch without wait, launch with +# wait, non-idempotency, extra_vars/credentials, check_mode. +- name: Converge — job_launch (mock, connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + molecule_jt_name: "molecule-jt-local" + tasks: + - name: Seed a fake job_template directly against the mock Controller + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/job_templates/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "{{ molecule_jt_name }}" + status_code: 201 + register: jt_local + vars: + ansible_connection: local + + - name: Seed a fake credential directly against the mock Controller + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/credentials/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "molecule-cred-local" + status_code: 201 + register: cred_local + vars: + ansible_connection: local + + - name: Check mode launch (must not call the API) + ansible.platform.job_launch: + name: "{{ molecule_jt_name }}" + check_mode: true + register: check_mode_result_local + vars: + ansible_connection: local + + - name: Assert check_mode launch reports changed with no id (connection local) + ansible.builtin.assert: + that: + - check_mode_result_local is changed + - check_mode_result_local.id is none + fail_msg: "Check mode (local) should report changed with no id. check_mode_result_local={{ check_mode_result_local }}" + vars: + ansible_connection: local + + - name: Launch job without waiting, with extra_vars and credentials (connection local) + ansible.platform.job_launch: + name: "{{ molecule_jt_name }}" + extra_vars: + foo: bar + credentials: + - "molecule-cred-local" + register: launch_result_local + vars: + ansible_connection: local + + - name: Assert launch changed and pending (connection local) + ansible.builtin.assert: + that: + - launch_result_local is changed + - launch_result_local.id is defined + - launch_result_local.status == "pending" + fail_msg: "Launch (local) should report changed and pending. launch_result_local={{ launch_result_local }}" + vars: + ansible_connection: local + + - name: Launch job again, waiting for completion (connection local) + ansible.platform.job_launch: + name: "{{ molecule_jt_name }}" + wait: true + interval: 0.2 + timeout: 30 + register: wait_result_local + vars: + ansible_connection: local + + - name: Assert wait launch resolved and is a distinct id (non-idempotent, connection local) + ansible.builtin.assert: + that: + - wait_result_local is changed + - wait_result_local.status == "successful" + - wait_result_local.id != launch_result_local.id + fail_msg: "Wait launch (local) should resolve to successful with a new id. wait_result_local={{ wait_result_local }}" + vars: + ansible_connection: local + +# Play 3: connection-mode parity (http direct) — launch with wait. +- name: Converge — job_launch (mock, http direct) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: false + molecule_jt_name: "molecule-jt-http-direct" + tasks: + - name: Seed a fake job_template (http direct) + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/job_templates/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "{{ molecule_jt_name }}" + status_code: 201 + register: jt_http_direct + + - name: Launch job, waiting for completion (http direct) + ansible.platform.job_launch: + name: "{{ molecule_jt_name }}" + wait: true + interval: 0.2 + timeout: 30 + register: wait_result_http_direct + + - name: Assert wait launch resolved (http direct) + ansible.builtin.assert: + that: + - wait_result_http_direct is changed + - wait_result_http_direct.status == "successful" + fail_msg: "Wait launch (http direct) should resolve to successful. wait_result_http_direct={{ wait_result_http_direct }}" + +# Play 4: connection-mode parity (http persistent) — launch with wait. +- name: Converge — job_launch (mock, http persistent) + hosts: localhost + connection: ansible.platform.http + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + ansible_platform_use_persistent_connection: true + molecule_jt_name: "molecule-jt-http-persistent" + tasks: + - name: Seed a fake job_template (http persistent) + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/job_templates/" + method: POST + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + body_format: json + body: + name: "{{ molecule_jt_name }}" + status_code: 201 + register: jt_http_persistent + + - name: Launch job, waiting for completion (http persistent) + ansible.platform.job_launch: + name: "{{ molecule_jt_name }}" + wait: true + interval: 0.2 + timeout: 30 + register: wait_result_http_persistent + + - name: Assert wait launch resolved (http persistent) + ansible.builtin.assert: + that: + - wait_result_http_persistent is changed + - wait_result_http_persistent.status == "successful" + fail_msg: "Wait launch (http persistent) should resolve to successful. wait_result_http_persistent={{ wait_result_http_persistent }}" +... diff --git a/extensions/molecule/job_launch_mock/inventory.yml b/extensions/molecule/job_launch_mock/inventory.yml new file mode 100644 index 00000000..b9f94363 --- /dev/null +++ b/extensions/molecule/job_launch_mock/inventory.yml @@ -0,0 +1,15 @@ +--- +# job_launch_mock scenario: use scenario inventory so we can mix connection types. +# First play (health check) uses connection: local; other plays use ansible.platform.http. +# Mock gateway vars are set here and in play vars. Structure matches shared inventory for parse compatibility. +all: + vars: + ansible_connection: local + gateway_hostname: "http://127.0.0.1:8000" + gateway_username: "mock" + gateway_password: "testpass" + gateway_validate_certs: false + children: + gateway_under_test: + hosts: + localhost: {} diff --git a/extensions/molecule/job_launch_mock/molecule.yml b/extensions/molecule/job_launch_mock/molecule.yml new file mode 100644 index 00000000..917d2de7 --- /dev/null +++ b/extensions/molecule/job_launch_mock/molecule.yml @@ -0,0 +1,34 @@ +--- +# Scenario: test ansible.platform.job_launch against the mock Gateway/Controller server (no real AAP). +# Requires the mock to be running (e.g. "molecule test --all" or default scenario create). +driver: + name: default + +platforms: + - name: localhost + +# Use scenario inventory (connection: local + gateway vars). Converge: play 2 direct, play 3 persistent, play 4 connection local. +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + +provisioner: + name: ansible + playbooks: + converge: converge.yml + verify: verify.yml + cleanup: cleanup.yml + config_options: + defaults: + collections_path: "${MOLECULE_SCENARIO_DIRECTORY}/../../../../../../" + # Write verbose messages (vv/vvvv) to ANSIBLE_LOG_PATH even without -v on the terminal. + log_verbosity: 4 + +scenario: + test_sequence: + - converge + - verify + - cleanup +... diff --git a/extensions/molecule/job_launch_mock/verify.yml b/extensions/molecule/job_launch_mock/verify.yml new file mode 100644 index 00000000..7a3eccf6 --- /dev/null +++ b/extensions/molecule/job_launch_mock/verify.yml @@ -0,0 +1,28 @@ +--- +# Verify: job_launch has no persistent identity of its own to check (Shape 2, +# launch-only) — confirm the seeded job_template is still reachable instead. +- name: Verify — seeded job_template still exists (connection local) + hosts: localhost + connection: local + gather_facts: false + vars: + gateway_hostname: "http://127.0.0.1:8000" + tasks: + - name: Get job_template by name (connection local) + ansible.builtin.uri: + url: "{{ gateway_hostname }}/api/controller/v2/job_templates/?name=molecule-jt-local" + method: GET + headers: + Authorization: "Basic bW9jazp0ZXN0cGFzcw==" + status_code: 200 + register: jt_check + vars: + ansible_connection: local + + - name: Assert job_template found (connection local) + ansible.builtin.assert: + that: jt_check.json.count == 1 + fail_msg: "Verify: seeded job_template molecule-jt-local not found." + vars: + ansible_connection: local +... diff --git a/meta/runtime.yml b/meta/runtime.yml index dadeb5c2..e941ae22 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -29,5 +29,6 @@ action_groups: - inventory - inventory_source - inventory_source_update + - job_launch - schedule ... diff --git a/plugins/action/job_launch.py b/plugins/action/job_launch.py new file mode 100644 index 00000000..b098c9e7 --- /dev/null +++ b/plugins/action/job_launch.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) 2026, Ansible Platform Collection Contributors +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Action plugin for ansible.platform.job_launch module. + +Launches a job template via Controller. This is not a CRUD resource — every +invocation launches a new job. Waiting for completion is handled by +PlatformService/DirectHTTPClient.execute() (see platform_manager.py and +direct_client.py) so that non-Ansible SDK consumers get the same wait +semantics — this action plugin only launches and forwards the result. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import dataclasses + +from ansible.errors import AnsibleError +from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.job_launch import AnsibleJobLaunch +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.base_client import WaitTimeoutError + + +class ActionModule(BaseResourceActionPlugin): + """Action plugin for launching jobs.""" + + MODULE_NAME = "job_launch" + MODEL_CLASS = AnsibleJobLaunch + + def _build_ansible_data(self, resource, validated_params, operation): + """Forward wait/interval/timeout so manager.execute() can poll for us. + + These are not AnsibleJobLaunch fields — PlatformService/DirectHTTPClient + pop them off the dict before constructing the dataclass. + """ + ansible_data = super()._build_ansible_data(resource, validated_params, operation) + ansible_data["wait"] = validated_params.get("wait", False) + ansible_data["interval"] = validated_params.get("interval", 2.0) + ansible_data["timeout"] = validated_params.get("timeout") + return ansible_data + + def run(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = {} + self._task_vars = task_vars + result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) + del tmp + + try: + doc = self._get_documentation() + argspec = self._build_argspec_from_docs(doc) if doc else None + if not argspec: + raise AnsibleError("Could not load DOCUMENTATION for job_launch module") + + validated_input = self._validate_data(self._task.args.copy(), argspec, "input") + + manager, facts_to_set = self._get_or_spawn_manager(task_vars) + self._client = manager + if facts_to_set: + result["ansible_facts"] = facts_to_set + result["_ansible_facts_cacheable"] = True + + validated_params = validated_input.validated_parameters + + resource_data = {k: v for k, v in validated_params.items() if v is not None and k not in self._AUTH_PARAMS} + model_fields = {f.name for f in dataclasses.fields(self.MODEL_CLASS)} + resource = self.MODEL_CLASS(**{k: v for k, v in resource_data.items() if k in model_fields}) + ansible_data = self._build_ansible_data(resource, validated_params, "create") + + # Jobs are never idempotent — every real run launches a new job — so + # check mode must not call manager.execute() at all. + if self._task.check_mode: + result.update( + { + "changed": True, + "failed": False, + "id": None, + "status": "pending", + "msg": "Check mode: job would be launched.", + } + ) + return result + + # manager.execute() launches the job and, when wait=True, polls for + # completion itself (PlatformService/DirectHTTPClient) — this action + # plugin never polls or sleeps. + launch_result = manager.execute( + operation="create", + module_name=self.MODULE_NAME, + ansible_data=ansible_data, + ) + + status = launch_result.get("status", "pending") + result.update( + { + "changed": True, + "id": launch_result.get("id"), + "status": status, + } + ) + + if status in ("error", "failed", "canceled"): + result["failed"] = True + result["msg"] = "Job %s finished with status: %s" % (launch_result.get("id"), status) + + except WaitTimeoutError as exc: + # The job was launched and is still running on Controller even + # though waiting for it gave up — preserve id/status so operators can + # still register/poll/cancel it from the task result. + last = exc.last_result + result.update( + { + "changed": True, + "failed": True, + "id": last.get("id"), + "status": last.get("status", "unknown"), + "msg": str(exc), + } + ) + + except Exception as exc: + import traceback as _tb + + self._display.vvv("Error in job_launch action plugin: %s" % exc) + result["failed"] = True + result["msg"] = str(exc) + if self._display.verbosity >= 3: + result["exception"] = _tb.format_exc() + + return result diff --git a/plugins/modules/job_launch.py b/plugins/modules/job_launch.py new file mode 100644 index 00000000..e994d21a --- /dev/null +++ b/plugins/modules/job_launch.py @@ -0,0 +1,209 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# Copyright: (c) 2017, Wayne Witzel III +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# This module is implemented as an action plugin. +# See plugins/action/job_launch.py for the implementation. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: job_launch +author: Red Hat (@RedHatOfficial) +short_description: Launch an Ansible job +description: + - Launch an Ansible Automation Platform Controller job. + - This module always creates a new job execution; it is not idempotent. +version_added: "3.0.0" + +options: + name: + description: + - Name of the job template to use. + required: true + type: str + aliases: + - job_template + + job_type: + description: + - Job type to use for the job, only used if prompt for job_type is set. + choices: ['run', 'check'] + type: str + + inventory: + description: + - Inventory name, ID, or named URL to use for the job, only used if prompt for inventory is set. + type: str + + credentials: + description: + - Credential names, IDs, or named URLs to use for the job, only used if prompt for credential is set. + type: list + elements: str + aliases: + - credential + + extra_vars: + description: + - extra_vars to use for the job template. + - C(ask_extra_vars) needs to be set to C(true) on the job template for this to take effect. + type: dict + + limit: + description: + - Limit to use for the job template. + type: str + + tags: + description: + - Specific tags to use from the playbook. + type: list + elements: str + + scm_branch: + description: + - A specific branch of the SCM project to run the template on. + - This is only applicable if the project allows for branch override. + type: str + + skip_tags: + description: + - Specific tags to skip from the playbook. + type: list + elements: str + + verbosity: + description: + - Verbosity level for this job run. + type: int + choices: [0, 1, 2, 3, 4, 5] + + diff_mode: + description: + - Show the changes made by Ansible tasks where supported. + type: bool + + credential_passwords: + description: + - Passwords for credentials which are set to prompt on launch. + type: dict + + execution_environment: + description: + - Execution environment name, ID, or named URL to use for the job, only used if prompt for execution environment is set. + type: str + + forks: + description: + - Forks to use for the job, only used if prompt for forks is set. + type: int + + instance_groups: + description: + - Instance group names, IDs, or named URLs to use for the job, only used if prompt for instance groups is set. + type: list + elements: str + + job_slice_count: + description: + - Job slice count to use for the job, only used if prompt for job slice count is set. + type: int + + labels: + description: + - Label names to use for the job, only used if prompt for labels is set. + type: list + elements: str + + job_timeout: + description: + - Timeout to use for the job, only used if prompt for timeout is set. + - This parameter is sent through the API to the job. + type: int + + wait: + description: + - Wait for the job to complete. + default: false + type: bool + + interval: + description: + - The interval in seconds to request an update from the controller. + default: 2 + type: float + + timeout: + description: + - If waiting for the job to complete this will abort after this + amount of seconds. + - When C(wait=true) and this option is omitted, polling is capped at + 3600 seconds (1 hour). Set explicitly to use a different limit. + type: int + +extends_documentation_fragment: + - ansible.platform.auth + +notes: + - The legacy C(organization) option (used only to disambiguate the job template + name lookup when names collide across organizations) is not carried over — + name lookups resolve globally via C(unified_job_templates). Use a unique name, + or the numeric ID, if this is a concern. + - The legacy client-side validation that rejects prompt fields the job template + does not allow to be overridden (its C(ask_*_on_launch) flags) is not + reproduced — Controller's own API validation on the launch call is treated as + sufficient. + +seealso: + - module: ansible.controller.job_launch + - module: awx.awx.job_launch +""" + +EXAMPLES = """ +- name: Launch a job + ansible.platform.job_launch: + name: "My Job Template" + register: job + +- name: Launch a job template with extra_vars, waiting for it to finish + ansible.platform.job_launch: + name: "My Job Template" + extra_vars: + var1: "My First Variable" + var2: "My Second Variable" + wait: true + +- name: Launch a job with inventory and credentials + ansible.platform.job_launch: + name: "My Job Template" + inventory: "My Inventory" + credentials: + - "My Credential" + - "Supplementary Credential" +... +""" + +RETURN = """ +id: + description: ID of the newly launched job. + returned: success + type: int + sample: 86 +status: + description: + - Status of the launched job. + - With C(wait=false) this is always C(pending) — the job has only just + been launched. With C(wait=true) this is the terminal status reported by + Controller once the job finishes; the task fails (C(failed=true)) if + that terminal status is C(error), C(failed), or C(canceled). + returned: success + type: str + sample: pending +... +""" diff --git a/plugins/plugin_utils/ansible_models/job_launch.py b/plugins/plugin_utils/ansible_models/job_launch.py new file mode 100644 index 00000000..a7e188f0 --- /dev/null +++ b/plugins/plugin_utils/ansible_models/job_launch.py @@ -0,0 +1,42 @@ +""" +Ansible JobLaunch dataclass - user-facing stable interface. + +This dataclass represents a job launch request as seen by Ansible playbooks. +Fields match the DOCUMENTATION options that are sent to the API. The +wait/interval/timeout parameters control polling in +PlatformService/DirectHTTPClient.execute() (platform_manager.py, +direct_client.py) — they are popped from the ansible_data dict before this +dataclass is constructed, so they are not fields here. +""" + +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class AnsibleJobLaunch: + """Ansible representation of a job template launch request.""" + + name: str + job_type: Optional[str] = None + inventory: Optional[str] = None + credentials: Optional[List[str]] = None + extra_vars: Optional[dict] = None + limit: Optional[str] = None + tags: Optional[List[str]] = None + scm_branch: Optional[str] = None + skip_tags: Optional[List[str]] = None + verbosity: Optional[int] = None + diff_mode: Optional[bool] = None + credential_passwords: Optional[dict] = None + execution_environment: Optional[str] = None + forks: Optional[int] = None + instance_groups: Optional[List[str]] = None + job_slice_count: Optional[int] = None + labels: Optional[List[str]] = None + job_timeout: Optional[int] = None + + # Read-only fields from API response (of the launched job) + id: Optional[int] = None + status: Optional[str] = None + finished: Optional[str] = None diff --git a/plugins/plugin_utils/api/v1/job_launch.py b/plugins/plugin_utils/api/v1/job_launch.py new file mode 100644 index 00000000..03198336 --- /dev/null +++ b/plugins/plugin_utils/api/v1/job_launch.py @@ -0,0 +1,185 @@ +""" +API v1 JobLaunch dataclass and transform mixin. + +Handles transformations between Ansible format and the Controller API format +for launching a job template. + +Launching a job is a POST to an existing job_template's /launch/ sub-action +endpoint, not a generic resource create — so the "create" and "get" operations +target two different Controller resource types: + - create: POST /api/controller/v2/job_templates/{job_template_id}/launch/ + - get (poll for completion): GET /api/controller/v2/jobs/{id}/ + +The job_template itself is resolved via /api/controller/v2/unified_job_templates/ +by name rather than a job_template-specific endpoint, since this collection does +not (yet) ship a job_template CRUD module to depend on. +""" + +import logging +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from ...ansible_models.job_launch import AnsibleJobLaunch +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + +_NAME_LOOKUP_FIELDS = ( + ("inventory", "/api/controller/v2/inventories/"), + ("execution_environment", "/api/controller/v2/execution_environments/"), +) + +_NAME_LOOKUP_LIST_FIELDS = ( + ("credentials", "/api/controller/v2/credentials/"), + ("labels", "/api/controller/v2/labels/"), + ("instance_groups", "/api/controller/v2/instance_groups/"), +) + +_DIRECT_FIELDS = ( + "job_type", + "limit", + "scm_branch", + "verbosity", + "diff_mode", + "extra_vars", + "credential_passwords", + "forks", + "job_slice_count", +) + + +@dataclass +class APIJobLaunch_v1: + """Wire format for launching/polling a Controller job.""" + + job_template_id: Optional[int] = None + job_type: Optional[str] = None + inventory: Optional[int] = None + credentials: Optional[List[int]] = None + extra_vars: Optional[dict] = None + limit: Optional[str] = None + job_tags: Optional[str] = None + scm_branch: Optional[str] = None + skip_tags: Optional[str] = None + verbosity: Optional[int] = None + diff_mode: Optional[bool] = None + credential_passwords: Optional[dict] = None + execution_environment: Optional[int] = None + forks: Optional[int] = None + instance_groups: Optional[List[int]] = None + job_slice_count: Optional[int] = None + labels: Optional[List[int]] = None + timeout: Optional[int] = None + id: Optional[int] = None + status: Optional[str] = None + finished: Optional[str] = None + + +class JobLaunchTransformMixin_v1(BaseTransformMixin): + """Transforms between AnsibleJobLaunch and APIJobLaunch_v1.""" + + @classmethod + def from_ansible_data(cls, ansible_instance: AnsibleJobLaunch, context: TransformContext) -> APIJobLaunch_v1: + """Forward: Ansible model -> API wire format. + + If ``ansible_instance.id`` is already set (a poll of an in-flight job, + via _wait_for_resource_completion's replace()), reuse it directly for + the "get" operation's {id} path param. Otherwise this is the initial + launch: resolve the target job_template's id via unified_job_templates + (by name) for the "create" operation's {job_template_id} path param. + """ + if ansible_instance.id is not None: + return APIJobLaunch_v1(id=ansible_instance.id) + + job_template_id = context.manager.lookup_resource_id("/api/controller/v2/unified_job_templates/", "name", ansible_instance.name) + if job_template_id is None: + raise ValueError("Unable to find job template by name '%s'" % ansible_instance.name) + + params: Dict[str, Any] = {"job_template_id": job_template_id} + + for field, endpoint in _NAME_LOOKUP_FIELDS: + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = context.manager.lookup_resource_id(endpoint, "name", value) + + for field, endpoint in _NAME_LOOKUP_LIST_FIELDS: + values = getattr(ansible_instance, field, None) + if values is not None: + params[field] = [context.manager.lookup_resource_id(endpoint, "name", v) for v in values] + + for field in _DIRECT_FIELDS: + value = getattr(ansible_instance, field, None) + if value is not None: + params[field] = value + + # Comma-separated on the wire, list on the Ansible side. + if ansible_instance.tags is not None: + params["job_tags"] = ",".join(ansible_instance.tags) + if ansible_instance.skip_tags is not None: + params["skip_tags"] = ",".join(ansible_instance.skip_tags) + + # job_timeout is renamed to the wire field "timeout" (not to be confused + # with the wait-poll "timeout" control flag, which never reaches here). + if ansible_instance.job_timeout is not None: + params["timeout"] = ansible_instance.job_timeout + + return APIJobLaunch_v1(**params) + + @classmethod + def from_api(cls, api_data: Dict[str, Any], context: TransformContext) -> AnsibleJobLaunch: + """Reverse: API response (the launched/polled job) -> Ansible model.""" + inventory = api_data.get("inventory") + + return AnsibleJobLaunch( + id=api_data.get("id"), + name=api_data.get("name", ""), + inventory=str(inventory) if inventory is not None else None, + status=api_data.get("status"), + finished=api_data.get("finished"), + ) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + launch_fields = [ + "job_type", + "inventory", + "credentials", + "extra_vars", + "limit", + "job_tags", + "scm_branch", + "skip_tags", + "verbosity", + "diff_mode", + "credential_passwords", + "execution_environment", + "forks", + "instance_groups", + "job_slice_count", + "labels", + "timeout", + ] + + return { + "create": EndpointOperation( + path="/api/controller/v2/job_templates/{job_template_id}/launch/", + method="POST", + fields=launch_fields, + path_params=["job_template_id"], + required_for="create", + order=1, + ), + "get": EndpointOperation( + path="/api/controller/v2/jobs/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + } + + @classmethod + def get_lookup_field(cls) -> str: + return "name" diff --git a/plugins/plugin_utils/manager/platform_manager.py b/plugins/plugin_utils/manager/platform_manager.py index fff255b0..2ca21a05 100644 --- a/plugins/plugin_utils/manager/platform_manager.py +++ b/plugins/plugin_utils/manager/platform_manager.py @@ -1028,12 +1028,14 @@ def _execute_operations(self, operations: Dict, api_data: Any, context: dict, re if getattr(endpoint_op, "flatten_body", False) and len(request_data) == 1: request_data = next(iter(request_data.values())) - # Skip only when the operation actually declares body fields but none of - # them ended up populated (e.g. an unused optional secondary endpoint). - # An operation deliberately declared with fields=[] is a no-body launch - # trigger (e.g. inventory_source_update's POST .../update/) and must - # still fire even though request_data is empty. - if not request_data and endpoint_op.fields: + # Skip only secondary (dependent) operations that have no data to send + # (matches DirectHTTPClient._execute_operations). A primary operation + # (no depends_on) must always fire even with an empty body — either it + # deliberately has fields=[] (a no-body launch trigger, e.g. + # inventory_source_update's POST .../update/), or it has optional + # fields that all happen to be unset on this call (e.g. job_launch + # with no prompt overrides — the launch must still happen). + if endpoint_op.depends_on and not request_data: logger.debug("Skipping %s - no data", op_name) continue diff --git a/tests/integration/targets/job_launch_test/meta/main.yml b/tests/integration/targets/job_launch_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/job_launch_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/job_launch_test/tasks/main.yml b/tests/integration/targets/job_launch_test/tasks/main.yml new file mode 100644 index 00000000..e2ee34eb --- /dev/null +++ b/tests/integration/targets/job_launch_test/tasks/main.yml @@ -0,0 +1,100 @@ +--- +- name: Generate a test ID + ansible.builtin.set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" + when: test_id is not defined + +- name: Preset vars + ansible.builtin.set_fact: + name_prefix: "AAP-Collection-Test-JobLaunch-{{ test_id }}" + +- name: Run Test + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | bool }}" + + block: + - name: Create Organization + ansible.platform.organization: + name: "{{ name_prefix }}-Organization" + register: org1 + + - name: Create Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + register: inv1 + + # An inventory_source is itself a launchable unified_job_template — used + # here instead of a job_template (not part of this migration batch) so the + # test stays self-contained. + - name: Create inventory source (launchable unified_job_template) + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Source" + inventory: "{{ inv1.name }}" + source: scm + source_path: "inventory.yml" + register: source1 + + - name: Check mode launch (must not actually launch) + ansible.platform.job_launch: + name: "{{ source1.inventory_source.name }}" + check_mode: true + register: check_mode_result + + - name: Assert check mode reports changed with no id + ansible.builtin.assert: + that: + - check_mode_result is changed + - check_mode_result.id is none + + - name: Launch a job, waiting for completion + ansible.platform.job_launch: + name: "{{ source1.inventory_source.name }}" + wait: true + interval: 1 + timeout: 120 + register: launch_result + + - name: Assert the job launched and completed + ansible.builtin.assert: + that: + - launch_result is changed + - launch_result.id is defined + - launch_result.status in ("successful", "failed", "error", "canceled") + + - name: Launch again without waiting (non-idempotent — always launches) + ansible.platform.job_launch: + name: "{{ source1.inventory_source.name }}" + register: launch_result_again + + - name: Assert the second launch is a distinct job + ansible.builtin.assert: + that: + - launch_result_again is changed + - launch_result_again.id != launch_result.id + + always: + - name: Delete Inventory Source + ansible.platform.inventory_source: + name: "{{ name_prefix }}-Source" + inventory: "{{ name_prefix }}-Inventory" + state: absent + when: inv1 is defined and "id" in inv1 + + - name: Delete Inventory + ansible.platform.inventory: + name: "{{ name_prefix }}-Inventory" + organization: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 + + - name: Delete Organization + ansible.platform.organization: + name: "{{ org1.name }}" + state: absent + when: org1 is defined and "id" in org1 +... diff --git a/tests/unit/plugins/action/test_job_launch.py b/tests/unit/plugins/action/test_job_launch.py new file mode 100644 index 00000000..6e6c3859 --- /dev/null +++ b/tests/unit/plugins/action/test_job_launch.py @@ -0,0 +1,104 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit tests for the job_launch action plugin (check_mode, wait-timeout handling).""" + +from __future__ import absolute_import, division, print_function + +import unittest +from unittest.mock import MagicMock, patch + +from ansible.plugins.action import ActionBase +from ansible_collections.ansible.platform.plugins.action.job_launch import ActionModule +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.base_client import WaitTimeoutError + + +def _make_action(check_mode=False): + """ActionModule with __init__ bypassed; only the attributes run() touches are set.""" + action = ActionModule.__new__(ActionModule) + action._task = MagicMock() + action._task.check_mode = check_mode + action._task.args = { + "name": "Demo Job Template", + } + action._display = MagicMock() + action._display.verbosity = 0 + return action + + +DOC = """ +--- +module: job_launch +options: + name: {type: str, required: true, aliases: [job_template]} + wait: {type: bool, default: false} + interval: {type: float, default: 2.0} + timeout: {type: int} +""" + + +class TestCheckMode(unittest.TestCase): + def test_check_mode_does_not_launch(self): + action = _make_action(check_mode=True) + mock_manager = MagicMock() + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["changed"]) + self.assertFalse(result.get("failed", False)) + self.assertIsNone(result["id"]) + mock_manager.execute.assert_not_called() + + def test_normal_mode_still_launches(self): + action = _make_action(check_mode=False) + mock_manager = MagicMock() + mock_manager.execute.return_value = {"id": 5, "status": "pending"} + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["changed"]) + self.assertEqual(result["id"], 5) + mock_manager.execute.assert_called_once() + + +class TestWaitTimeoutHandling(unittest.TestCase): + def test_timeout_preserves_id_and_status(self): + action = _make_action(check_mode=False) + mock_manager = MagicMock() + mock_manager.execute.side_effect = WaitTimeoutError( + "Timed out waiting for job_launch 9 to complete after 5 seconds (status: pending)", + last_result={"id": 9, "status": "pending"}, + ) + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["failed"]) + self.assertEqual(result["id"], 9) + self.assertEqual(result["status"], "pending") + + def test_terminal_failed_status_sets_failed_and_keeps_id(self): + action = _make_action(check_mode=False) + mock_manager = MagicMock() + mock_manager.execute.return_value = {"id": 3, "status": "failed"} + + with patch.object(ActionBase, "run", return_value={}): + with patch.object(action, "_get_documentation", return_value=DOC): + with patch.object(action, "_get_or_spawn_manager", return_value=(mock_manager, None)): + result = action.run(task_vars={}) + + self.assertTrue(result["failed"]) + self.assertEqual(result["id"], 3) + self.assertEqual(result["status"], "failed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py b/tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py new file mode 100644 index 00000000..4a7ccf47 --- /dev/null +++ b/tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py @@ -0,0 +1,125 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Tests for the job_launch v1 transform mixin (AAP-91390).""" + +from __future__ import absolute_import, division, print_function + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +_COLLECTIONS_PARENT = str(Path(__file__).resolve().parent.parent.parent.parent.parent.parent.parent.parent) +if _COLLECTIONS_PARENT not in sys.path: + sys.path.insert(0, _COLLECTIONS_PARENT) + +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.job_launch import ( # noqa: E402 + AnsibleJobLaunch, +) +from ansible_collections.ansible.platform.plugins.plugin_utils.api.v1.job_launch import ( # noqa: E402 + JobLaunchTransformMixin_v1, +) + + +def _make_context(lookup_returns=None, default=1): + manager = MagicMock() + if lookup_returns is not None: + + def _side_effect(endpoint, field, value): + return lookup_returns.get((endpoint, value), default) + + manager.lookup_resource_id.side_effect = _side_effect + else: + manager.lookup_resource_id.return_value = default + context = MagicMock() + context.manager = manager + return context + + +class TestJobLaunchTransform(unittest.TestCase): + def test_from_ansible_data_resolves_job_template_via_unified_job_templates(self): + ansible = AnsibleJobLaunch(name="Demo Job Template") + context = _make_context(lookup_returns={("/api/controller/v2/unified_job_templates/", "Demo Job Template"): 9}) + + api = JobLaunchTransformMixin_v1.from_ansible_data(ansible, context) + + context.manager.lookup_resource_id.assert_called_once_with("/api/controller/v2/unified_job_templates/", "name", "Demo Job Template") + self.assertEqual(api.job_template_id, 9) + + def test_from_ansible_data_reuses_id_when_already_set(self): + """Second call, during a wait poll, must skip the job_template lookup and just target {id}.""" + ansible = AnsibleJobLaunch(name="Demo Job Template", id=55) + context = _make_context() + + api = JobLaunchTransformMixin_v1.from_ansible_data(ansible, context) + + context.manager.lookup_resource_id.assert_not_called() + self.assertEqual(api.id, 55) + self.assertIsNone(api.job_template_id) + + def test_from_ansible_data_resolves_fk_lists(self): + ansible = AnsibleJobLaunch( + name="Demo Job Template", + credentials=["Demo Credential", "Second Credential"], + labels=["Demo Label"], + instance_groups=["Demo IG"], + ) + lookups = { + ("/api/controller/v2/unified_job_templates/", "Demo Job Template"): 9, + ("/api/controller/v2/credentials/", "Demo Credential"): 20, + ("/api/controller/v2/credentials/", "Second Credential"): 21, + ("/api/controller/v2/labels/", "Demo Label"): 30, + ("/api/controller/v2/instance_groups/", "Demo IG"): 40, + } + context = _make_context(lookup_returns=lookups) + + api = JobLaunchTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.credentials, [20, 21]) + self.assertEqual(api.labels, [30]) + self.assertEqual(api.instance_groups, [40]) + + def test_from_ansible_data_converts_tag_lists_to_comma_strings(self): + ansible = AnsibleJobLaunch(name="Demo Job Template", tags=["a", "b"], skip_tags=["c"]) + context = _make_context() + + api = JobLaunchTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.job_tags, "a,b") + self.assertEqual(api.skip_tags, "c") + + def test_from_ansible_data_renames_job_timeout_to_timeout(self): + ansible = AnsibleJobLaunch(name="Demo Job Template", job_timeout=600) + context = _make_context() + + api = JobLaunchTransformMixin_v1.from_ansible_data(ansible, context) + + self.assertEqual(api.timeout, 600) + + def test_from_ansible_data_raises_when_job_template_not_found(self): + ansible = AnsibleJobLaunch(name="Missing Template") + context = _make_context(default=None) + + with self.assertRaises(ValueError): + JobLaunchTransformMixin_v1.from_ansible_data(ansible, context) + + def test_from_api_maps_launch_response(self): + ansible = JobLaunchTransformMixin_v1.from_api( + {"id": 86, "name": "Demo Job Template", "inventory": 5, "status": "pending"}, + _make_context(), + ) + + self.assertEqual(ansible.id, 86) + self.assertEqual(ansible.status, "pending") + self.assertEqual(ansible.inventory, "5") + + def test_get_endpoint_operations_target_launch_and_jobs_paths(self): + ops = JobLaunchTransformMixin_v1.get_endpoint_operations() + self.assertEqual(ops["create"].path, "/api/controller/v2/job_templates/{job_template_id}/launch/") + self.assertEqual(ops["get"].path, "/api/controller/v2/jobs/{id}/") + self.assertNotIn("list", ops) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py b/tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py index 7b41e936..61bedc34 100644 --- a/tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py +++ b/tests/unit/plugins/plugin_utils/manager/test_execute_operations_launch_trigger.py @@ -3,11 +3,16 @@ """Regression tests for PlatformService._execute_operations (AAP-91390). -Covers two bugs found migrating inventory_source_update, a launch-trigger -sub-action with no request body and a custom path param name: - 1. An operation declared with fields=[] (a deliberate no-body trigger) must - still fire — it must not be treated the same as an unused optional - secondary endpoint with nothing populated. +Covers two bugs found migrating inventory_source_update and job_launch, both +launch-trigger sub-actions: + 1. A *primary* operation (no depends_on) must always fire, even when its + computed request body is empty — either because it's deliberately + declared with fields=[] (a no-body trigger, e.g. inventory_source_update's + POST .../update/), or because it has optional fields that all happen to + be unset on this call (e.g. job_launch with no prompt overrides — the + launch must still happen). Only a *secondary* operation (depends_on set, + e.g. an optional survey_spec sub-endpoint) is skipped when it has nothing + to send — matching DirectHTTPClient's already-correct behavior. 2. path_params entries other than the literal name "id" must be resolved from the matching attribute on api_data, not silently left unsubstituted. """ @@ -101,8 +106,8 @@ def test_custom_path_param_name_is_substituted(self): self.assertNotIn("{resource_id}", called_url) self.assertIn("/7/update/", called_url) - def test_optional_secondary_endpoint_with_no_data_is_still_skipped(self): - """An operation with real fields, none of which are populated, is still skipped.""" + def test_dependent_secondary_endpoint_with_no_data_is_still_skipped(self): + """A secondary op (depends_on set) with nothing populated is skipped — e.g. an unused optional survey_spec sub-endpoint.""" operations = { "create": EndpointOperation( path="/api/controller/v2/widgets/", @@ -111,17 +116,57 @@ def test_optional_secondary_endpoint_with_no_data_is_still_skipped(self): required_for="create", order=1, ), + "survey_spec": EndpointOperation( + path="/api/controller/v2/widgets/{id}/survey_spec/", + method="POST", + fields=["survey_spec"], + path_params=["id"], + required_for="create", + depends_on="create", + order=2, + ), } @dataclass - class _EmptyData: - name: Optional[str] = None + class _WidgetData: + name: Optional[str] = "demo" + survey_spec: Optional[dict] = None + id: Optional[int] = None - with patch.object(self.svc.session, "request") as mock_request: - result = self.svc._execute_operations(operations, _EmptyData(), context={}, required_for="create") + with patch.object(self.svc.session, "request", return_value=_resp({"id": 1, "name": "demo"})) as mock_request: + result = self.svc._execute_operations(operations, _WidgetData(), context={}, required_for="create") + + # Only the primary "create" call fires; the dependent "survey_spec" op is + # skipped because it has no data and depends_on a prior op. + mock_request.assert_called_once() + self.assertEqual(result, {"id": 1, "name": "demo"}) - mock_request.assert_not_called() - self.assertEqual(result, {}) + def test_primary_operation_with_no_optional_fields_set_still_fires(self): + """job_launch with no prompt overrides: fields is non-empty but all unset — the launch must still happen.""" + operations = { + "create": EndpointOperation( + path="/api/controller/v2/job_templates/{job_template_id}/launch/", + method="POST", + fields=["extra_vars", "limit"], + path_params=["job_template_id"], + required_for="create", + order=1, + ), + } + + @dataclass + class _LaunchData: + job_template_id: Optional[int] = None + extra_vars: Optional[dict] = None + limit: Optional[str] = None + + api_data = _LaunchData(job_template_id=9) + + with patch.object(self.svc.session, "request", return_value=_resp({"id": 100, "status": "pending"})) as mock_request: + result = self.svc._execute_operations(operations, api_data, context={}, required_for="create") + + mock_request.assert_called_once() + self.assertEqual(result, {"id": 100, "status": "pending"}) if __name__ == "__main__": diff --git a/tools/mock_gateway_server.py b/tools/mock_gateway_server.py index 6e50293f..c7fc5362 100644 --- a/tools/mock_gateway_server.py +++ b/tools/mock_gateway_server.py @@ -328,6 +328,18 @@ def _init_controller_resources(self) -> None: url_prefix="/api/controller/v2/schedules/", associations=["credentials", "labels", "instance_groups"], ) + self._controller_resources["job_templates"] = GenericResource( + resource_name="job_templates", + required_fields=["name"], + start_id=16000, + url_prefix="/api/controller/v2/job_templates/", + ) + self._controller_resources["jobs"] = GenericResource( + resource_name="jobs", + required_fields=[], + start_id=17000, + url_prefix="/api/controller/v2/jobs/", + ) def controller_resource(self, name: str) -> Optional[GenericResource]: return self._controller_resources.get(name) @@ -648,6 +660,13 @@ class MockGatewayHandler(BaseHTTPRequestHandler): store: Store reported_api_version: str + # (source_resource, sub_action) -> target resource store name for + # launch-trigger sub-actions (POST {source}/{id}/{sub_action}/). + _LAUNCH_SUB_ACTIONS = { + ("inventory_sources", "update"): "inventory_updates", + ("job_templates", "launch"): "jobs", + } + def log_message(self, fmt: str, *args) -> None: return # suppress per-request noise @@ -678,12 +697,14 @@ def _parse_json_body(self) -> Dict[str, Any]: return {} return json.loads(raw.decode("utf-8")) - def _advance_inventory_update_poll(self, store: "GenericResource", item_id: int) -> Dict[str, Any]: - """Advance an inventory update's pending -> successful lifecycle on each GET-by-id poll. + def _advance_launch_job_poll(self, store: "GenericResource", item_id: int) -> Dict[str, Any]: + """Advance a launched job's pending -> successful lifecycle on each GET-by-id poll. - Simulates a real Controller update staying pending across the first two - polls before resolving, so wait/poll loops in tests actually poll more - than once instead of the mock resolving synchronously on launch. + Used for both inventory_updates (inventory_source's /update/) and jobs + (job_template's /launch/). Simulates a real Controller job staying pending + across the first two polls before resolving, so wait/poll loops in tests + actually poll more than once instead of the mock resolving synchronously + on launch. """ with store.lock: if item_id not in store._items: @@ -744,8 +765,8 @@ def _handle_generic_resource_store(self, store: "GenericResource", parts: list, return True if self.command == "GET": try: - if store.resource_name == "inventory_updates": - self._send_json(200, self._advance_inventory_update_poll(store, item_id)) + if store.resource_name in ("inventory_updates", "jobs"): + self._send_json(200, self._advance_launch_job_poll(store, item_id)) else: self._send_json(200, store.get(item_id)) except KeyError: @@ -802,6 +823,25 @@ def _route_controller(self, parts: list, qs: Dict[str, list]) -> None: self._send_json(404, {"detail": "Not Found"}) return + # unified_job_templates: real Controller is a polymorphic view over + # job_templates/inventory_sources/projects/workflow_job_templates sharing + # their IDs, not a separate table. GET (list, by name) unions those + # concrete stores plus the dedicated unified_job_templates store itself + # (kept for tests that only need an arbitrary launchable-looking fixture, + # e.g. schedule's unified_job_template field, with no real /launch/ + # sub-action behind it). POST still creates directly in the dedicated + # store, for that same fixture use case. + if resource == "unified_job_templates" and len(parts) == 4 and self.command == "GET": + name = (qs.get("name") or [None])[0] + combined = [] + for backing in ("unified_job_templates", "job_templates", "inventory_sources"): + backing_store = self.store.controller_resource(backing) + if backing_store is None: + continue + combined.extend(backing_store.list_items({"name": name} if name else None)["results"]) + self._send_json(200, {"count": len(combined), "results": combined}) + return + store = self.store.controller_resource(resource) if resource else None if store is None: self._send_json(404, {"detail": "Not Found"}) @@ -830,19 +870,22 @@ def _route_controller(self, parts: list, qs: Dict[str, list]) -> None: self._send_json(404, {"detail": "Not Found"}) return - # inventory_sources/{id}/update/: launch a new inventory_updates job. + # Launch-trigger sub-actions: POST {resource}/{id}/{field}/ creates a + # new job-like item in a separate store (e.g. inventory_sources' + # /update/ -> inventory_updates, job_templates' /launch/ -> jobs). # Stays pending across the first two GET-by-id polls (see - # _advance_inventory_update_poll) so wait/poll loops actually poll + # _advance_launch_job_poll) so wait/poll loops actually poll # more than once, instead of the mock resolving synchronously. - if field == "update" and resource == "inventory_sources": + launch_target = self._LAUNCH_SUB_ACTIONS.get((resource, field)) + if launch_target: if self.command == "POST": try: source = store.get(item_id) except KeyError: self._send_json(404, {"detail": "Not Found"}) return - updates_store = self.store.controller_resource("inventory_updates") - launched = updates_store.create( + target_store = self.store.controller_resource(launch_target) + launched = target_store.create( "2", { "name": source.get("name"), From 37ea1cfad65f797b843642bca5939de4a4de83ed Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 16:31:56 -0400 Subject: [PATCH 07/10] Fix ansible-lint failure: exclude new scenarios' inventory.yml files Molecule inventory.yml files are dicts (all: {vars, children}), not playbooks, but ansible-lint misclassifies them as such based on the filename. Every prior scenario's inventory.yml is already listed in .ansible-lint's exclude_paths individually; the 6 new scenarios from this PR were missing. Co-Authored-By: Claude Sonnet 5 --- .ansible-lint | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.ansible-lint b/.ansible-lint index c50e47f1..b579c645 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -9,5 +9,11 @@ exclude_paths: - 'extensions/molecule/inventory.yml' - 'extensions/molecule/organization_mock/inventory.yml' - 'extensions/molecule/users_mock/inventory.yml' + - 'extensions/molecule/inventory_mock/inventory.yml' + - 'extensions/molecule/host_mock/inventory.yml' + - 'extensions/molecule/inventory_source_mock/inventory.yml' + - 'extensions/molecule/inventory_source_update_mock/inventory.yml' + - 'extensions/molecule/schedule_mock/inventory.yml' + - 'extensions/molecule/job_launch_mock/inventory.yml' use_default_rules: true ... From 1d4dfacc4dab712ca22a62386ead3651a31ac2dc Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 16:50:04 -0400 Subject: [PATCH 08/10] Address pr-review skill findings: test and document the new SDK methods The connection-manager-review checklist (from PR #244's pr-review skill) flagged two gaps in the manage_associations/manage_sub_resource/copy_resource and launch/wait infrastructure added in this PR: - No isolated unit tests: these were only exercised indirectly through Molecule and action-plugin flows, never with a directly mocked session. Add 13 unit tests covering association diffing (resolve/associate/ disassociate/idempotent-no-op/lookup-failure), manage_sub_resource (no-op/delete/update/idempotent/error), and copy_resource (name lookup, ID-based fallback, not-found). - No architecture doc update: document all three generic methods plus the launch/wait mechanism (wait/interval/timeout popping, DEFAULT_WAIT_TIMEOUT, WaitTimeoutError) in docs/03-sdk-architecture.md's RPC Interface section. Co-Authored-By: Claude Sonnet 5 --- docs/03-sdk-architecture.md | 58 +++++ .../test_manage_associations_copy_resource.py | 199 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py diff --git a/docs/03-sdk-architecture.md b/docs/03-sdk-architecture.md index adefb424..22a8967b 100644 --- a/docs/03-sdk-architecture.md +++ b/docs/03-sdk-architecture.md @@ -525,6 +525,64 @@ class LookupModule(LookupBase): This allows dynamic lookups (e.g., "find all users whose username contains 'admin'") without spawning additional processes or triggering SSL fork safety issues. +### Generic Methods for Associations, Copy, and Launch/Wait + +Beyond `execute()` and `search_api()`, three more generic methods live on +`BaseAPIClient` (and are implemented identically in `PlatformService` and +`DirectHTTPClient`, with thin `ManagerRPCClient` wrappers) so that association +sub-endpoints, secondary sub-resources, and copy operations never require an +action plugin to touch `manager.session`/HTTP directly: + +```python +def manage_associations(self, base_path, resource_id, association_field, + desired_items, lookup_endpoint, lookup_field) -> bool: + """Sync an association sub-endpoint (e.g. a resource's instance_groups). + + Resolves desired_items (names or IDs) to integer IDs, diffs against the + current association list, and issues associate/disassociate POSTs for + the difference. Returns True if anything changed. + """ + +def manage_sub_resource(self, base_path, resource_id, sub_path, data=None) -> bool: + """GET/compare/POST a secondary sub-resource (e.g. survey_spec). + + data=None is a no-op; data=={} DELETEs the sub-resource; otherwise POSTs + only if the current value differs. Returns True if changed. + """ + +def copy_resource(self, module_name, source_name_or_id, new_name, + copy_endpoint_path) -> dict: + """POST to a resource's /copy/ sub-endpoint. + + Finds the source via execute('find', ...), falling back to an ID-based + lookup, then POSTs {'name': new_name} to {copy_endpoint_path}/{id}/copy/. + Returns the copied resource's raw API response. + """ +``` + +See `plugins/action/inventory.py` for a full Pattern C example combining +`copy_resource` (for `copy_from`) and `manage_associations` (for +`instance_groups`/`input_inventories`). + +**Launch/wait** (Shape 2 resources like `ad_hoc_command`, `job_launch`, +`inventory_source_update`) is handled inside `execute()` itself rather than as +a separate method: `wait`/`interval`/`timeout` are popped off the incoming +`ansible_data` dict before the resource dataclass is built (only when the +target dataclass doesn't declare a field of that name — so a resource with a +genuine `timeout` field, e.g. `job_template`, keeps it), and — when +`wait=True` — `_wait_for_resource_completion()` polls the newly-created +resource via `_find_resource()` until `from_api()` reports a truthy +`finished` (or `event_processing_finished`), raising `WaitTimeoutError` +(carrying the last poll result, so the caller can still report `id`/`status`) +if `timeout` elapses first. `DEFAULT_WAIT_TIMEOUT` (3600s) applies when +`wait=True` but no `timeout` was given. + +Adding a new generic method follows the same four-layer rule as everything +else in this SDK: `base_client.py` (abstract, raises `NotImplementedError`) → +`platform_manager.py` → `direct_client.py` → `rpc_client.py`. Skipping the +`rpc_client.py` wrapper is the most common miss — the method works in direct +mode but silently isn't reachable from the action plugin in persistent mode. + --- ## SECTION 7: Directory Structure diff --git a/tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py b/tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py new file mode 100644 index 00000000..4ff8975f --- /dev/null +++ b/tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py @@ -0,0 +1,199 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit tests for PlatformService.manage_associations/manage_sub_resource/copy_resource (AAP-91390). + +These are shared SDK-layer methods used by inventory (copy_from, +instance_groups/input_inventories), inventory_source and schedule +(associations) — previously only exercised indirectly via Molecule and +action-plugin flows, never with a directly mocked session. +""" + +from __future__ import absolute_import, division, print_function + +import unittest +from unittest.mock import MagicMock, patch + +from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import PlatformService +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.config import GatewayConfig + + +def _make_platform_service(base_url="https://gw.example.com"): + """PlatformService with network and credentials mocked.""" + mock_session = MagicMock() + mock_requests = MagicMock() + mock_requests.Session.return_value = mock_session + mock_store = MagicMock() + mock_store.get_auth_credentials.return_value = ("admin", "admin", None) + with patch("ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager.get_credential_manager") as mock_cred: + mock_cred.return_value.get_or_create_store.return_value = mock_store + with patch("ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager._get_requests") as mock_get_requests: + mock_get_requests.return_value = mock_requests + config = GatewayConfig(base_url=base_url, username="admin", password="admin", idle_timeout=30.0) + return PlatformService(config) + + +def _resp(payload=None, status_code=200): + r = MagicMock() + r.status_code = status_code + r.text = "" if payload is None else str(payload) + r.json.return_value = payload if payload is not None else {} + return r + + +class TestManageAssociations(unittest.TestCase): + def setUp(self): + self.svc = _make_platform_service() + + def test_resolves_names_via_lookup_resource_id(self): + with patch.object(self.svc, "lookup_resource_id", return_value=42) as mock_lookup: + with patch.object(self.svc.session, "get", return_value=_resp({"results": []})): + with patch.object(self.svc.session, "post", return_value=_resp({}, status_code=204)): + changed = self.svc.manage_associations( + "/api/controller/v2/inventories", + 1, + "instance_groups", + ["Demo Group"], + "/api/controller/v2/instance_groups/", + "name", + ) + + mock_lookup.assert_called_once_with("/api/controller/v2/instance_groups/", "name", "Demo Group") + self.assertTrue(changed) + + def test_digit_string_items_skip_lookup(self): + with patch.object(self.svc, "lookup_resource_id") as mock_lookup: + with patch.object(self.svc.session, "get", return_value=_resp({"results": [{"id": 5}]})): + changed = self.svc.manage_associations( + "/api/controller/v2/inventories", + 1, + "instance_groups", + ["5"], + "/api/controller/v2/instance_groups/", + "name", + ) + + mock_lookup.assert_not_called() + self.assertFalse(changed) # already associated, no change + + def test_associates_missing_and_disassociates_removed(self): + with patch.object(self.svc, "lookup_resource_id", return_value=None): + with patch.object(self.svc.session, "get", return_value=_resp({"results": [{"id": 5}]})): + with patch.object(self.svc.session, "post", return_value=_resp({}, status_code=204)) as mock_post: + changed = self.svc.manage_associations( + "/api/controller/v2/inventories", + 1, + "instance_groups", + ["10"], # digit: keep 10, drop existing 5 + "/api/controller/v2/instance_groups/", + "name", + ) + + self.assertTrue(changed) + calls = [c.kwargs["json"] for c in mock_post.call_args_list] + self.assertIn({"id": 10, "associate": True}, calls) + self.assertIn({"id": 5, "disassociate": True}, calls) + + def test_no_change_when_desired_matches_current(self): + with patch.object(self.svc.session, "get", return_value=_resp({"results": [{"id": 5}]})): + with patch.object(self.svc.session, "post") as mock_post: + changed = self.svc.manage_associations( + "/api/controller/v2/inventories", + 1, + "instance_groups", + ["5"], + "/api/controller/v2/instance_groups/", + "name", + ) + + mock_post.assert_not_called() + self.assertFalse(changed) + + def test_raises_when_name_lookup_fails(self): + with patch.object(self.svc, "lookup_resource_id", return_value=None): + with self.assertRaises(ValueError): + self.svc.manage_associations( + "/api/controller/v2/inventories", + 1, + "instance_groups", + ["Missing Group"], + "/api/controller/v2/instance_groups/", + "name", + ) + + +class TestManageSubResource(unittest.TestCase): + def setUp(self): + self.svc = _make_platform_service() + + def test_data_none_is_a_noop(self): + with patch.object(self.svc.session, "get") as mock_get, patch.object(self.svc.session, "post") as mock_post: + changed = self.svc.manage_sub_resource("/api/controller/v2/job_templates", 1, "survey_spec", data=None) + + mock_get.assert_not_called() + mock_post.assert_not_called() + self.assertFalse(changed) + + def test_empty_dict_deletes(self): + with patch.object(self.svc.session, "delete", return_value=_resp(status_code=204)) as mock_delete: + changed = self.svc.manage_sub_resource("/api/controller/v2/job_templates", 1, "survey_spec", data={}) + + mock_delete.assert_called_once() + self.assertTrue(changed) + + def test_posts_when_data_differs_from_current(self): + with patch.object(self.svc.session, "get", return_value=_resp({"name": "old"})): + with patch.object(self.svc.session, "post", return_value=_resp({}, status_code=200)) as mock_post: + changed = self.svc.manage_sub_resource("/api/controller/v2/job_templates", 1, "survey_spec", data={"name": "new"}) + + mock_post.assert_called_once() + self.assertTrue(changed) + + def test_no_post_when_data_matches_current(self): + with patch.object(self.svc.session, "get", return_value=_resp({"name": "same"})): + with patch.object(self.svc.session, "post") as mock_post: + changed = self.svc.manage_sub_resource("/api/controller/v2/job_templates", 1, "survey_spec", data={"name": "same"}) + + mock_post.assert_not_called() + self.assertFalse(changed) + + def test_raises_on_post_failure(self): + with patch.object(self.svc.session, "get", return_value=_resp({"name": "old"})): + with patch.object(self.svc.session, "post", return_value=_resp({"detail": "bad request"}, status_code=400)): + with self.assertRaises(ValueError): + self.svc.manage_sub_resource("/api/controller/v2/job_templates", 1, "survey_spec", data={"name": "new"}) + + +class TestCopyResource(unittest.TestCase): + def setUp(self): + self.svc = _make_platform_service() + + def test_finds_by_name_and_posts_to_copy_endpoint(self): + with patch.object(self.svc, "execute", return_value={"id": 100}) as mock_execute: + with patch.object(self.svc.session, "post", return_value=_resp({"id": 200, "name": "Copy"}, status_code=201)) as mock_post: + result = self.svc.copy_resource("inventory", "Source Inventory", "Copy", "/api/controller/v2/inventories") + + mock_execute.assert_called_once_with(operation="find", module_name="inventory", ansible_data_dict={"name": "Source Inventory"}) + called_url = mock_post.call_args[0][0] + self.assertIn("/api/controller/v2/inventories/100/copy/", called_url) + self.assertEqual(mock_post.call_args.kwargs["json"], {"name": "Copy"}) + self.assertEqual(result, {"id": 200, "name": "Copy"}) + + def test_falls_back_to_id_based_lookup_when_name_lookup_fails(self): + with patch.object(self.svc, "execute", side_effect=[ValueError("not found"), {"id": 100}]) as mock_execute: + with patch.object(self.svc.session, "post", return_value=_resp({"id": 200}, status_code=201)): + result = self.svc.copy_resource("inventory", "100", "Copy", "/api/controller/v2/inventories") + + self.assertEqual(mock_execute.call_count, 2) + second_call_kwargs = mock_execute.call_args_list[1].kwargs + self.assertEqual(second_call_kwargs["ansible_data_dict"], {"id": 100, "name": "100"}) + self.assertEqual(result, {"id": 200}) + + def test_raises_when_source_not_found(self): + with patch.object(self.svc, "execute", side_effect=ValueError("not found")): + with self.assertRaises(ValueError): + self.svc.copy_resource("inventory", "Missing", "Copy", "/api/controller/v2/inventories") + + +if __name__ == "__main__": + unittest.main() From 155da5a71cd439aa565217ead0774ce168bd6b2d Mon Sep 17 00:00:00 2001 From: jessicamack Date: Mon, 14 Sep 2026 18:36:34 -0400 Subject: [PATCH 09/10] Address CodeRabbit review: association safety, check_mode, FK lookup fixes - manage_associations/manage_sub_resource now propagate GET/POST/DELETE failures instead of silently treating them as success or no-op - inventory copy_from is now idempotent; inventory/inventory_source/schedule validate association list fields and skip mutating syncs under check_mode - job_launch resolves job_template via /job_templates/ directly, avoiding an id collision with workflow_job_templates on /unified_job_templates/ - DirectHTTPClient.lookup_resource_id no longer double-prefixes absolute /api/ paths passed by Controller-routed FK lookups Co-Authored-By: Claude Sonnet 5 --- plugins/action/inventory.py | 68 +++++++++++++----- plugins/action/inventory_source.py | 17 ++++- plugins/action/schedule.py | 17 ++++- plugins/plugin_utils/api/v1/job_launch.py | 20 ++++-- .../plugin_utils/manager/platform_manager.py | 24 ++++--- .../plugin_utils/platform/direct_client.py | 24 ++++--- .../plugin_utils/api/v1/test_job_launch.py | 8 ++- .../test_manage_associations_copy_resource.py | 38 ++++++++++ .../test_direct_client_lookup_resource_id.py | 72 +++++++++++++++++++ 9 files changed, 241 insertions(+), 47 deletions(-) create mode 100644 tests/unit/plugins/plugin_utils/platform/test_direct_client_lookup_resource_id.py diff --git a/plugins/action/inventory.py b/plugins/action/inventory.py index ffd77f68..c5bc83ad 100644 --- a/plugins/action/inventory.py +++ b/plugins/action/inventory.py @@ -82,6 +82,17 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: for field in _ASSOCIATION_FIELDS: val = self._task.args.pop(field, None) if val is not None: + # Popped before BaseResourceActionPlugin.run() validates self._task.args, + # so the documented list/elements:str constraint never runs on these — + # restore the minimum check that protects manage_associations() from + # silently iterating a wrong-typed value (e.g. a bare string) character by + # character instead of failing clearly. + if not isinstance(val, list): + return { + "changed": False, + "failed": True, + "msg": "argument '%s' is of type %s and we were unable to convert to a list" % (field, type(val).__name__), + } association_data[field] = val if copy_from and state not in ("absent", "deleted"): @@ -95,25 +106,42 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: result["ansible_facts"] = facts_to_set result["_ansible_facts_cacheable"] = True - copied = manager.copy_resource( - self.MODULE_NAME, - copy_from, - self._task.args.get("name"), - _INVENTORY_BASE_PATH, - ) - - if copied and copied.get("id"): - # No need to inject an "id" into self._task.args here — "id" isn't a - # declared module option (would fail argspec validation on this second - # call), and the copy already has the target name, so a plain re-run - # finds it naturally via LOOKUP_FIELD (name) + organization. + # Idempotency: copy_from should only ever seed a brand-new resource. If + # a resource with the target name (scoped by organization) already + # exists, skip copy_resource and fall through to a normal find-or-update + # run instead — otherwise every re-run would create another copy. + existing = None + try: + existing = manager.execute( + operation="find", + module_name=self.MODULE_NAME, + ansible_data={"name": self._task.args.get("name"), "organization": self._task.args.get("organization")}, + ) + except Exception: + existing = None + + if existing and existing.get("id"): result = super().run(tmp, task_vars) - # copy_resource() always creates a new resource — that's a change even - # if the follow-up update-with-remaining-params finds nothing left to - # change and would otherwise report changed=False on its own. - result["changed"] = True else: - result.update(changed=True, failed=False, **{self.MODULE_NAME: copied or {}}) + copied = manager.copy_resource( + self.MODULE_NAME, + copy_from, + self._task.args.get("name"), + _INVENTORY_BASE_PATH, + ) + + if copied and copied.get("id"): + # No need to inject an "id" into self._task.args here — "id" isn't a + # declared module option (would fail argspec validation on this second + # call), and the copy already has the target name, so a plain re-run + # finds it naturally via LOOKUP_FIELD (name) + organization. + result = super().run(tmp, task_vars) + # copy_resource() always creates a new resource — that's a change even + # if the follow-up update-with-remaining-params finds nothing left to + # change and would otherwise report changed=False on its own. + result["changed"] = True + else: + result.update(changed=True, failed=False, **{self.MODULE_NAME: copied or {}}) except Exception as exc: result.update(changed=False, failed=True, msg=str(exc)) @@ -126,7 +154,11 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: inventory_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") - if inventory_id and state not in ("absent", "deleted", "exists"): + # check_mode: base_action.py's own create/update short-circuit happens before + # this point, but for an *update* to an already-existing resource it still + # returns the real inventory_id — skip the association sync entirely so + # check_mode never issues real associate/disassociate writes. + if inventory_id and state not in ("absent", "deleted", "exists") and not self._task.check_mode: manager = self._client if manager: for field, (lookup_ep, lookup_field) in _ASSOCIATION_MAP.items(): diff --git a/plugins/action/inventory_source.py b/plugins/action/inventory_source.py index 7b760369..293f8538 100644 --- a/plugins/action/inventory_source.py +++ b/plugins/action/inventory_source.py @@ -67,6 +67,17 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: for field in _ASSOCIATION_FIELDS: val = self._task.args.pop(field, None) if val is not None: + # Popped before BaseResourceActionPlugin.run() validates self._task.args, + # so the documented list/elements:str constraint never runs on these — + # restore the minimum check that protects manage_associations() from + # silently iterating a wrong-typed value (e.g. a bare string) character by + # character instead of failing clearly. + if not isinstance(val, list): + return { + "changed": False, + "failed": True, + "msg": "argument '%s' is of type %s and we were unable to convert to a list" % (field, type(val).__name__), + } association_data[field] = val result = super().run(tmp, task_vars) @@ -76,7 +87,11 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: source_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") - if source_id and state not in ("absent", "deleted", "exists"): + # check_mode: base_action.py's own create/update short-circuit happens before + # this point, but for an *update* to an already-existing resource it still + # returns the real source_id — skip the association sync entirely so + # check_mode never issues real associate/disassociate writes. + if source_id and state not in ("absent", "deleted", "exists") and not self._task.check_mode: manager = self._client if manager: for field, (lookup_ep, lookup_field) in _ASSOCIATION_MAP.items(): diff --git a/plugins/action/schedule.py b/plugins/action/schedule.py index aa8bc2e2..46af123d 100644 --- a/plugins/action/schedule.py +++ b/plugins/action/schedule.py @@ -67,6 +67,17 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: for field in _ASSOCIATION_FIELDS: val = self._task.args.pop(field, None) if val is not None: + # Popped before BaseResourceActionPlugin.run() validates self._task.args, + # so the documented list/elements:str constraint never runs on these — + # restore the minimum check that protects manage_associations() from + # silently iterating a wrong-typed value (e.g. a bare string) character by + # character instead of failing clearly. + if not isinstance(val, list): + return { + "changed": False, + "failed": True, + "msg": "argument '%s' is of type %s and we were unable to convert to a list" % (field, type(val).__name__), + } association_data[field] = val result = super().run(tmp, task_vars) @@ -76,7 +87,11 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: schedule_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") - if schedule_id and state not in ("absent", "deleted", "exists"): + # check_mode: base_action.py's own create/update short-circuit happens before + # this point, but for an *update* to an already-existing resource it still + # returns the real schedule_id — skip the association sync entirely so + # check_mode never issues real associate/disassociate writes. + if schedule_id and state not in ("absent", "deleted", "exists") and not self._task.check_mode: manager = self._client if manager: for field, (lookup_ep, lookup_field) in _ASSOCIATION_MAP.items(): diff --git a/plugins/plugin_utils/api/v1/job_launch.py b/plugins/plugin_utils/api/v1/job_launch.py index 03198336..ad54173d 100644 --- a/plugins/plugin_utils/api/v1/job_launch.py +++ b/plugins/plugin_utils/api/v1/job_launch.py @@ -10,9 +10,12 @@ - create: POST /api/controller/v2/job_templates/{job_template_id}/launch/ - get (poll for completion): GET /api/controller/v2/jobs/{id}/ -The job_template itself is resolved via /api/controller/v2/unified_job_templates/ -by name rather than a job_template-specific endpoint, since this collection does -not (yet) ship a job_template CRUD module to depend on. +The job_template itself is resolved directly via /api/controller/v2/job_templates/ +by name (not unified_job_templates, which also returns other unified job template +types sharing the same name — see from_ansible_data's docstring). This collection +does not (yet) ship a job_template CRUD module to depend on, but the underlying +Controller list endpoint exists regardless of whether this collection has a +module wrapping it. """ import logging @@ -86,13 +89,18 @@ def from_ansible_data(cls, ansible_instance: AnsibleJobLaunch, context: Transfor If ``ansible_instance.id`` is already set (a poll of an in-flight job, via _wait_for_resource_completion's replace()), reuse it directly for the "get" operation's {id} path param. Otherwise this is the initial - launch: resolve the target job_template's id via unified_job_templates - (by name) for the "create" operation's {job_template_id} path param. + launch: resolve the target job_template's id directly via the + job_templates endpoint (not unified_job_templates, which also returns + workflow_job_templates/inventory_sources/projects sharing the same + name — lookup_resource_id takes the first match regardless of type, + so a same-named workflow job template would resolve to the wrong id + and 404 against the job_template-specific /launch/ endpoint) for the + "create" operation's {job_template_id} path param. """ if ansible_instance.id is not None: return APIJobLaunch_v1(id=ansible_instance.id) - job_template_id = context.manager.lookup_resource_id("/api/controller/v2/unified_job_templates/", "name", ansible_instance.name) + job_template_id = context.manager.lookup_resource_id("/api/controller/v2/job_templates/", "name", ansible_instance.name) if job_template_id is None: raise ValueError("Unable to find job template by name '%s'" % ansible_instance.name) diff --git a/plugins/plugin_utils/manager/platform_manager.py b/plugins/plugin_utils/manager/platform_manager.py index 2ca21a05..5a3b0906 100644 --- a/plugins/plugin_utils/manager/platform_manager.py +++ b/plugins/plugin_utils/manager/platform_manager.py @@ -1143,13 +1143,15 @@ def manage_associations( raise ValueError("Could not find %s entry with %s='%s'" % (lookup_endpoint, lookup_field, item)) resolved_ids.append(rid) + # Let GET failures (auth, network, non-2xx, JSON parsing) propagate instead + # of silently treating them as "no current associations" — that would make + # the disassociate loop below a silent no-op, leaving stale associations + # in place while reporting success. assoc_url = self._build_url("%s/%s/%s/" % (base_path, resource_id, association_field)) - try: - response = self.session.get(assoc_url, timeout=self.request_timeout, verify=self.requests_verify) - current_data = response.json() if response.status_code == 200 else {} - current_ids = [item["id"] for item in current_data.get("results", [])] - except Exception: - current_ids = [] + response = self.session.get(assoc_url, timeout=self.request_timeout, verify=self.requests_verify) + response.raise_for_status() + current_data = response.json() + current_ids = [item["id"] for item in current_data.get("results", [])] changed = False errors = [] @@ -1157,12 +1159,13 @@ def manage_associations( for item_id in resolved_ids: if item_id not in current_ids: try: - self.session.post( + resp = self.session.post( assoc_url, json={"id": item_id, "associate": True}, timeout=self.request_timeout, verify=self.requests_verify, ) + resp.raise_for_status() changed = True except Exception as exc: errors.append("Failed to associate %s %s: %s" % (association_field, item_id, exc)) @@ -1170,12 +1173,13 @@ def manage_associations( for item_id in current_ids: if item_id not in resolved_ids: try: - self.session.post( + resp = self.session.post( assoc_url, json={"id": item_id, "disassociate": True}, timeout=self.request_timeout, verify=self.requests_verify, ) + resp.raise_for_status() changed = True except Exception as exc: errors.append("Failed to disassociate %s %s: %s" % (association_field, item_id, exc)) @@ -1196,7 +1200,9 @@ def manage_sub_resource(self, base_path: str, resource_id: int, sub_path: str, d if data == {}: response = self.session.delete(spec_url, timeout=self.request_timeout, verify=self.requests_verify) - return response.status_code in (200, 204) + if response.status_code not in (200, 204): + raise ValueError("Failed to delete %s: %s" % (sub_path, response.text or "Unknown error")) + return True try: current_response = self.session.get(spec_url, timeout=self.request_timeout, verify=self.requests_verify) diff --git a/plugins/plugin_utils/platform/direct_client.py b/plugins/plugin_utils/platform/direct_client.py index f180afdf..14e07c2a 100644 --- a/plugins/plugin_utils/platform/direct_client.py +++ b/plugins/plugin_utils/platform/direct_client.py @@ -518,8 +518,13 @@ def lookup_resource_id(self, endpoint: str, lookup_field: str, lookup_value: str self.api_version = "1" self.session.headers.update({"X-API-Version": str(self.api_version)}) - # Build the URL: /api/gateway/v{version}/{endpoint}/?{lookup_field}={lookup_value} - api_path = f"/api/gateway/v{self.api_version}/{endpoint}/" + # Callers may pass a full API path (e.g. "/api/controller/v2/inventories/") + # to resolve FKs on non-Gateway components; only bare resource names + # (e.g. "authenticators") get the Gateway prefix. + if endpoint.startswith("/api/"): + api_path = endpoint if endpoint.endswith("/") else f"{endpoint}/" + else: + api_path = f"/api/gateway/v{self.api_version}/{endpoint}/" url = self._build_url(api_path, {lookup_field: lookup_value}) response = self._make_request("GET", url, operation="lookup", resource=endpoint) @@ -1077,14 +1082,15 @@ def manage_associations( raise ValueError("Could not find %s entry with %s='%s'" % (lookup_endpoint, lookup_field, item)) resolved_ids.append(rid) + # Let GET failures (auth, network, non-2xx, JSON parsing) propagate instead + # of silently treating them as "no current associations" — that would make + # the disassociate loop below a silent no-op, leaving stale associations + # in place while reporting success. assoc_url = self._build_url("%s/%s/%s/" % (base_path, resource_id, association_field)) - try: - response = self._make_request("get", assoc_url, operation="manage_associations", resource=association_field) - response_body = response.read() - current_data = json.loads(response_body) if response_body else {} - current_ids = [item["id"] for item in current_data.get("results", [])] - except Exception: - current_ids = [] + response = self._make_request("get", assoc_url, operation="manage_associations", resource=association_field) + response_body = response.read() + current_data = json.loads(response_body) if response_body else {} + current_ids = [item["id"] for item in current_data.get("results", [])] changed = False errors = [] diff --git a/tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py b/tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py index 4a7ccf47..386bbd84 100644 --- a/tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py +++ b/tests/unit/plugins/plugin_utils/api/v1/test_job_launch.py @@ -38,13 +38,15 @@ def _side_effect(endpoint, field, value): class TestJobLaunchTransform(unittest.TestCase): - def test_from_ansible_data_resolves_job_template_via_unified_job_templates(self): + def test_from_ansible_data_resolves_job_template_via_job_templates_endpoint(self): + """Must use job_templates directly, not unified_job_templates — a same-named + workflow_job_template would otherwise resolve to the wrong id and 404.""" ansible = AnsibleJobLaunch(name="Demo Job Template") - context = _make_context(lookup_returns={("/api/controller/v2/unified_job_templates/", "Demo Job Template"): 9}) + context = _make_context(lookup_returns={("/api/controller/v2/job_templates/", "Demo Job Template"): 9}) api = JobLaunchTransformMixin_v1.from_ansible_data(ansible, context) - context.manager.lookup_resource_id.assert_called_once_with("/api/controller/v2/unified_job_templates/", "name", "Demo Job Template") + context.manager.lookup_resource_id.assert_called_once_with("/api/controller/v2/job_templates/", "name", "Demo Job Template") self.assertEqual(api.job_template_id, 9) def test_from_ansible_data_reuses_id_when_already_set(self): diff --git a/tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py b/tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py index 4ff8975f..872ed320 100644 --- a/tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py +++ b/tests/unit/plugins/plugin_utils/manager/test_manage_associations_copy_resource.py @@ -14,6 +14,7 @@ import unittest from unittest.mock import MagicMock, patch +import requests from ansible_collections.ansible.platform.plugins.plugin_utils.manager.platform_manager import PlatformService from ansible_collections.ansible.platform.plugins.plugin_utils.platform.config import GatewayConfig @@ -38,6 +39,13 @@ def _resp(payload=None, status_code=200): r.status_code = status_code r.text = "" if payload is None else str(payload) r.json.return_value = payload if payload is not None else {} + # Match real requests.Response.raise_for_status() semantics — a plain + # MagicMock would otherwise never raise, silently defeating any test of + # the raise_for_status() calls added to manage_associations. + if status_code >= 400: + r.raise_for_status.side_effect = requests.HTTPError("%s error" % status_code, response=r) + else: + r.raise_for_status.return_value = None return r @@ -121,6 +129,36 @@ def test_raises_when_name_lookup_fails(self): "name", ) + def test_get_failure_propagates_instead_of_silently_treated_as_empty(self): + """A read failure must not be swallowed into 'no current associations' — + that would make disassociation of anything currently associated a silent no-op.""" + with patch.object(self.svc.session, "get", return_value=_resp(status_code=500)): + with self.assertRaises(requests.HTTPError): + self.svc.manage_associations( + "/api/controller/v2/inventories", + 1, + "instance_groups", + ["5"], + "/api/controller/v2/instance_groups/", + "name", + ) + + def test_failed_associate_post_raises_instead_of_reporting_changed(self): + """requests.Session.post() does not raise on 4xx/5xx by itself — without + raise_for_status() a rejected associate would be reported as a success.""" + with patch.object(self.svc, "lookup_resource_id", return_value=42): + with patch.object(self.svc.session, "get", return_value=_resp({"results": []})): + with patch.object(self.svc.session, "post", return_value=_resp(status_code=400)): + with self.assertRaises(ValueError): + self.svc.manage_associations( + "/api/controller/v2/inventories", + 1, + "instance_groups", + ["Demo Group"], + "/api/controller/v2/instance_groups/", + "name", + ) + class TestManageSubResource(unittest.TestCase): def setUp(self): diff --git a/tests/unit/plugins/plugin_utils/platform/test_direct_client_lookup_resource_id.py b/tests/unit/plugins/plugin_utils/platform/test_direct_client_lookup_resource_id.py new file mode 100644 index 00000000..ef93ae50 --- /dev/null +++ b/tests/unit/plugins/plugin_utils/platform/test_direct_client_lookup_resource_id.py @@ -0,0 +1,72 @@ +# (c) 2026 Red Hat Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Regression tests for DirectHTTPClient.lookup_resource_id absolute-path handling (AAP-91390). + +PlatformService.lookup_resource_id already supports being called with either a +bare resource name (e.g. "organizations", Gateway-prefixed) or a full API path +(e.g. "/api/controller/v2/organizations/", used as-is) — every FK lookup added +in this batch (inventory's organization, host's inventory, etc.) relies on the +latter. DirectHTTPClient.lookup_resource_id previously always applied the +Gateway prefix regardless, silently producing a malformed double-prefixed URL +for every Controller-routed lookup in direct connection mode. +""" + +from __future__ import absolute_import, division, print_function + +import unittest +from unittest.mock import MagicMock, patch + +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.config import GatewayConfig +from ansible_collections.ansible.platform.plugins.plugin_utils.platform.direct_client import DirectHTTPClient + + +def _make_direct_client(base_url="https://gw.example.com"): + """DirectHTTPClient with credential storage mocked; no real network access.""" + mock_store = MagicMock() + mock_store.namespace.namespace_id = "ns" + mock_store.get_auth_credentials.return_value = ("admin", "admin", None) + with patch("ansible_collections.ansible.platform.plugins.plugin_utils.platform.direct_client.get_credential_manager") as mock_cred: + mock_cred.return_value.get_or_create_store.return_value = mock_store + config = GatewayConfig(base_url=base_url, username="admin", password="admin", idle_timeout=30.0) + return DirectHTTPClient(config) + + +def _fake_response(payload): + resp = MagicMock() + resp.read.return_value = __import__("json").dumps(payload).encode("utf-8") + return resp + + +class TestDirectClientLookupResourceId(unittest.TestCase): + def setUp(self): + self.client = _make_direct_client() + self.client.api_version = "1" # skip version detection + + def test_bare_resource_name_gets_gateway_prefix(self): + with patch.object(self.client, "_make_request", return_value=_fake_response({"results": [{"id": 5}]})) as mock_request: + rid = self.client.lookup_resource_id("organizations", "name", "Default") + + called_url = mock_request.call_args[0][1] + self.assertIn("/api/gateway/v1/organizations/", called_url) + self.assertEqual(rid, 5) + + def test_absolute_controller_path_is_used_as_is(self): + with patch.object(self.client, "_make_request", return_value=_fake_response({"results": [{"id": 7}]})) as mock_request: + rid = self.client.lookup_resource_id("/api/controller/v2/organizations/", "name", "Default") + + called_url = mock_request.call_args[0][1] + self.assertIn("/api/controller/v2/organizations/", called_url) + self.assertNotIn("/api/gateway/", called_url) + self.assertEqual(rid, 7) + + def test_absolute_path_without_trailing_slash_gets_one_added(self): + with patch.object(self.client, "_make_request", return_value=_fake_response({"results": [{"id": 9}]})) as mock_request: + self.client.lookup_resource_id("/api/controller/v2/inventories", "name", "Demo") + + called_url = mock_request.call_args[0][1] + self.assertIn("/api/controller/v2/inventories/", called_url) + + +if __name__ == "__main__": + unittest.main() From 91aef499d83e44a7d590b9d7e6e749ad57e6f324 Mon Sep 17 00:00:00 2001 From: jessicamack Date: Tue, 15 Sep 2026 16:24:57 -0400 Subject: [PATCH 10/10] Refactor job_launch, inventory_source_update, and inventory action plugins to use _prepare_action/_build_resource Follows PR #245's extraction of common action-plugin setup, eliminating duplicated argspec/validation/manager-spawn boilerplate in these three plugins. Co-Authored-By: Claude Sonnet 5 --- plugins/action/inventory.py | 15 ++++---- plugins/action/inventory_source_update.py | 43 ++++++++++------------- plugins/action/job_launch.py | 43 ++++++++++------------- 3 files changed, 45 insertions(+), 56 deletions(-) diff --git a/plugins/action/inventory.py b/plugins/action/inventory.py index c5bc83ad..a052dc32 100644 --- a/plugins/action/inventory.py +++ b/plugins/action/inventory.py @@ -96,15 +96,14 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: association_data[field] = val if copy_from and state not in ("absent", "deleted"): - result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) - self._task_vars = task_vars or {} - + # Preparation can fail before _prepare_action() returns a result; + # keep a valid Ansible result available so the except block below + # does not mask the original validation/connection error. + result = {} try: - manager, facts_to_set = self._get_or_spawn_manager(task_vars or {}) - self._client = manager - if facts_to_set: - result["ansible_facts"] = facts_to_set - result["_ansible_facts_cacheable"] = True + prepared = self._prepare_action(tmp, task_vars) + result = prepared["result"] + manager = prepared["manager"] # Idempotency: copy_from should only ever seed a brand-new resource. If # a resource with the target name (scoped by organization) already diff --git a/plugins/action/inventory_source_update.py b/plugins/action/inventory_source_update.py index bda28f48..e28ba203 100644 --- a/plugins/action/inventory_source_update.py +++ b/plugins/action/inventory_source_update.py @@ -19,7 +19,6 @@ import dataclasses -from ansible.errors import AnsibleError from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.inventory_source_update import AnsibleInventorySourceUpdate from ansible_collections.ansible.platform.plugins.plugin_utils.platform.base_client import WaitTimeoutError @@ -31,6 +30,15 @@ class ActionModule(BaseResourceActionPlugin): MODULE_NAME = "inventory_source_update" MODEL_CLASS = AnsibleInventorySourceUpdate + def _build_resource(self, resource_data: dict): + """Filter resource_data to AnsibleInventorySourceUpdate fields. + + The launch argspec includes control params (wait/interval/timeout) + that are not AnsibleInventorySourceUpdate fields. + """ + model_fields = {f.name for f in dataclasses.fields(self.MODEL_CLASS)} + return self.MODEL_CLASS(**{k: v for k, v in resource_data.items() if k in model_fields}) + def _build_ansible_data(self, resource, validated_params, operation): """Forward wait/interval/timeout so manager.execute() can poll for us. @@ -44,31 +52,18 @@ def _build_ansible_data(self, resource, validated_params, operation): return ansible_data def run(self, tmp=None, task_vars=None): - if task_vars is None: - task_vars = {} - self._task_vars = task_vars - result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) - del tmp - + # Preparation can fail before _prepare_action() returns a result; keep + # a valid Ansible result available so the exception handler below does + # not mask the original validation, documentation, or connection error. + result = {} try: - doc = self._get_documentation() - argspec = self._build_argspec_from_docs(doc) if doc else None - if not argspec: - raise AnsibleError("Could not load DOCUMENTATION for inventory_source_update module") - - validated_input = self._validate_data(self._task.args.copy(), argspec, "input") - - manager, facts_to_set = self._get_or_spawn_manager(task_vars) - self._client = manager - if facts_to_set: - result["ansible_facts"] = facts_to_set - result["_ansible_facts_cacheable"] = True - - validated_params = validated_input.validated_parameters + prepared = self._prepare_action(tmp, task_vars) + result = prepared["result"] + validated_params = prepared["validated_params"] + resource_data = prepared["resource_data"] + manager = prepared["manager"] - resource_data = {k: v for k, v in validated_params.items() if v is not None and k not in self._AUTH_PARAMS} - model_fields = {f.name for f in dataclasses.fields(self.MODEL_CLASS)} - resource = self.MODEL_CLASS(**{k: v for k, v in resource_data.items() if k in model_fields}) + resource = self._build_resource(resource_data) ansible_data = self._build_ansible_data(resource, validated_params, "create") # Inventory source updates are never idempotent — every real run diff --git a/plugins/action/job_launch.py b/plugins/action/job_launch.py index b098c9e7..14bc2015 100644 --- a/plugins/action/job_launch.py +++ b/plugins/action/job_launch.py @@ -19,7 +19,6 @@ import dataclasses -from ansible.errors import AnsibleError from ansible_collections.ansible.platform.plugins.action.base_action import BaseResourceActionPlugin from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.job_launch import AnsibleJobLaunch from ansible_collections.ansible.platform.plugins.plugin_utils.platform.base_client import WaitTimeoutError @@ -31,6 +30,15 @@ class ActionModule(BaseResourceActionPlugin): MODULE_NAME = "job_launch" MODEL_CLASS = AnsibleJobLaunch + def _build_resource(self, resource_data: dict): + """Filter resource_data to AnsibleJobLaunch fields before construction. + + The launch argspec includes control params (wait/interval/timeout) + that are not AnsibleJobLaunch fields. + """ + model_fields = {f.name for f in dataclasses.fields(self.MODEL_CLASS)} + return self.MODEL_CLASS(**{k: v for k, v in resource_data.items() if k in model_fields}) + def _build_ansible_data(self, resource, validated_params, operation): """Forward wait/interval/timeout so manager.execute() can poll for us. @@ -44,31 +52,18 @@ def _build_ansible_data(self, resource, validated_params, operation): return ansible_data def run(self, tmp=None, task_vars=None): - if task_vars is None: - task_vars = {} - self._task_vars = task_vars - result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) - del tmp - + # Preparation can fail before _prepare_action() returns a result; keep + # a valid Ansible result available so the exception handler below does + # not mask the original validation, documentation, or connection error. + result = {} try: - doc = self._get_documentation() - argspec = self._build_argspec_from_docs(doc) if doc else None - if not argspec: - raise AnsibleError("Could not load DOCUMENTATION for job_launch module") - - validated_input = self._validate_data(self._task.args.copy(), argspec, "input") - - manager, facts_to_set = self._get_or_spawn_manager(task_vars) - self._client = manager - if facts_to_set: - result["ansible_facts"] = facts_to_set - result["_ansible_facts_cacheable"] = True - - validated_params = validated_input.validated_parameters + prepared = self._prepare_action(tmp, task_vars) + result = prepared["result"] + validated_params = prepared["validated_params"] + resource_data = prepared["resource_data"] + manager = prepared["manager"] - resource_data = {k: v for k, v in validated_params.items() if v is not None and k not in self._AUTH_PARAMS} - model_fields = {f.name for f in dataclasses.fields(self.MODEL_CLASS)} - resource = self.MODEL_CLASS(**{k: v for k, v in resource_data.items() if k in model_fields}) + resource = self._build_resource(resource_data) ansible_data = self._build_ansible_data(resource, validated_params, "create") # Jobs are never idempotent — every real run launches a new job — so