From 5ca7f1ad0ee9576371525631336ca1806a27d684 Mon Sep 17 00:00:00 2001 From: thedoubl3j Date: Mon, 10 Aug 2026 19:41:15 -0400 Subject: [PATCH 1/3] Add job_template module migrated from awx.awx/ansible.controller Migrate the job_template module from the awx.awx collection to ansible.platform using the platform SDK pattern. This is the first controller-service module in the collection. Generated initial scaffolding via tools/generate_resource.py, then manually completed: - Transform mixin with FK resolution (inventory, project, execution_environment, webhook_credential) and org-scoped project lookup - Custom action plugin (Pattern C) for association sub-endpoints (credentials, labels, notification_templates, instance_groups), survey_spec secondary endpoint, and copy_from support - Module DOCUMENTATION with full field parity, aliases, and seealso references to ansible.controller and awx.awx - Controller action group added to runtime.yml - Integration test scaffold covering CRUD, idempotency, survey, and copy operations Assisted-By: Claude Opus 4.6 --- meta/runtime.yml | 2 + plugins/action/job_template.py | 336 +++++++++++++ plugins/modules/job_template.py | 365 +++++++++++++++ .../ansible_models/job_template.py | 76 +++ plugins/plugin_utils/api/v1/job_template.py | 441 ++++++++++++++++++ .../targets/job_template_test/meta/main.yml | 4 + .../targets/job_template_test/tasks/main.yml | 173 +++++++ 7 files changed, 1397 insertions(+) create mode 100644 plugins/action/job_template.py create mode 100644 plugins/modules/job_template.py create mode 100644 plugins/plugin_utils/ansible_models/job_template.py create mode 100644 plugins/plugin_utils/api/v1/job_template.py create mode 100644 tests/integration/targets/job_template_test/meta/main.yml create mode 100644 tests/integration/targets/job_template_test/tasks/main.yml diff --git a/meta/runtime.yml b/meta/runtime.yml index 482f9c26..a594d312 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -24,4 +24,6 @@ action_groups: - token - ui_plugin_route - user + controller: + - job_template ... diff --git a/plugins/action/job_template.py b/plugins/action/job_template.py new file mode 100644 index 00000000..438c338b --- /dev/null +++ b/plugins/action/job_template.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# (c) 2025, 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_template module. + +Migrated from awx.awx/ansible.controller job_template module. +Uses Pattern C (custom run override) due to: + - Association fields (credentials, labels, notification_templates, instance_groups) + - Secondary endpoint (survey_spec) + - Copy operation (copy_from) +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from typing import Any, List, Optional + +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_template import ( + AnsibleJobTemplate, +) + +logger = logging.getLogger(__name__) + +_ASSOCIATION_FIELDS = frozenset( + { + "credentials", + "labels", + "notification_templates_started", + "notification_templates_success", + "notification_templates_error", + "instance_groups", + } +) + +_EXTRA_TASK_FIELDS = _ASSOCIATION_FIELDS | frozenset( + { + "survey_spec", + "copy_from", + "organization", + } +) + + +class ActionModule(BaseResourceActionPlugin): + """Action plugin for job_template module.""" + + MODULE_NAME = "job_template" + MODEL_CLASS = AnsibleJobTemplate + LOOKUP_FIELD = "name" + + _WRITE_ONLY_FIELDS = frozenset( + { + "copy_from", + "survey_spec", + "credentials", + "labels", + "notification_templates_started", + "notification_templates_success", + "notification_templates_error", + "instance_groups", + } + ) + + def _build_ansible_data(self, resource: Any, validated_params: dict, operation: str) -> dict: + """Build ansible_data from explicitly-provided task parameters only. + + Prevents list fields (credentials, labels, etc.) defaulting to None + from being sent to the API. + """ + 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 _resolve_association_ids( + self, + manager, + endpoint: str, + lookup_field: str, + items: List[str], + ) -> List[int]: + """Resolve a list of names/IDs to integer IDs.""" + resolved = [] + for item in items: + if str(item).isdigit(): + resolved.append(int(item)) + else: + try: + item_id = manager.lookup_resource_id(endpoint, lookup_field, str(item)) + if item_id: + resolved.append(item_id) + else: + raise AnsibleError("Could not find %s entry with name '%s'" % (endpoint, item)) + except Exception as exc: + if "not found" in str(exc).lower(): + raise AnsibleError("Could not find %s entry with name '%s'" % (endpoint, item)) + raise + return resolved + + def _handle_associations(self, manager, jt_id: int, result: dict, write_only_data: dict) -> None: + """Manage association sub-endpoints for the job template. + + For each association field, resolve names to IDs and POST associate/disassociate + calls to the appropriate sub-endpoint. + """ + association_map = { + "credentials": ("credentials", "name"), + "labels": ("labels", "name"), + "notification_templates_started": ("notification_templates", "name"), + "notification_templates_success": ("notification_templates", "name"), + "notification_templates_error": ("notification_templates", "name"), + "instance_groups": ("instance_groups", "name"), + } + + for field, (endpoint, lookup_field) in association_map.items(): + desired_items = write_only_data.get(field) + if desired_items is None: + continue + + desired_ids = self._resolve_association_ids(manager, endpoint, lookup_field, desired_items) + + # Get current associations + assoc_endpoint = "/api/controller/v2/job_templates/%s/%s/" % (jt_id, field) + try: + current_response = manager.session.get( + manager._build_url(assoc_endpoint), + ) + current_data = current_response.json() if current_response.status_code == 200 else {} + current_results = current_data.get("results", []) + current_ids = [item["id"] for item in current_results] + except Exception: + current_ids = [] + + # Associate new items + for item_id in desired_ids: + if item_id not in current_ids: + try: + manager.session.post( + manager._build_url(assoc_endpoint), + json={"id": item_id, "associate": True}, + ) + result["changed"] = True + except Exception as exc: + logger.debug("Failed to associate %s %s: %s", field, item_id, exc) + + # Disassociate items not in desired list + for item_id in current_ids: + if item_id not in desired_ids: + try: + manager.session.post( + manager._build_url(assoc_endpoint), + json={"id": item_id, "disassociate": True}, + ) + result["changed"] = True + except Exception as exc: + logger.debug("Failed to disassociate %s %s: %s", field, item_id, exc) + + def _handle_survey_spec(self, manager, jt_id: int, result: dict, survey_spec: Optional[dict]) -> None: + """Manage the survey_spec secondary endpoint.""" + if survey_spec is None: + return + + spec_endpoint = "/api/controller/v2/job_templates/%s/survey_spec/" % jt_id + + if survey_spec == {}: + # Empty dict means delete the survey + try: + response = manager.session.delete(manager._build_url(spec_endpoint)) + if response.status_code in (200, 204): + result["changed"] = True + except Exception as exc: + raise AnsibleError("Failed to delete survey: %s" % str(exc)) + else: + # Check if survey already matches + try: + current_response = manager.session.get(manager._build_url(spec_endpoint)) + current_spec = current_response.json() if current_response.status_code == 200 else None + except Exception: + current_spec = None + + if survey_spec != current_spec: + try: + response = manager.session.post( + manager._build_url(spec_endpoint), + json=survey_spec, + ) + if response.status_code not in (200, 201): + error_msg = response.json().get("error", response.text) if response.text else "Unknown error" + raise AnsibleError("Failed to update survey: %s" % error_msg) + result["changed"] = True + except AnsibleError: + raise + except Exception as exc: + raise AnsibleError("Failed to update survey: %s" % str(exc)) + + def _handle_copy(self, manager, copy_from: str, name: str, result: dict) -> Optional[dict]: + """Handle copy_from: copy an existing job template. + + Returns the copied job template data dict, or None on failure. + """ + # Find the source job template + try: + source = manager.execute( + operation="find", + module_name=self.MODULE_NAME, + ansible_data={"name": copy_from}, + ) + except Exception: + source = None + + if not source or not source.get("id"): + # Try by ID + if str(copy_from).isdigit(): + try: + source = manager.execute( + operation="find", + module_name=self.MODULE_NAME, + ansible_data={"id": int(copy_from)}, + ) + except Exception: + source = None + + if not source or not source.get("id"): + raise AnsibleError("Could not find job template '%s' to copy from" % copy_from) + + copy_endpoint = "/api/controller/v2/job_templates/%s/copy/" % source["id"] + try: + response = manager.session.post( + manager._build_url(copy_endpoint), + json={"name": name}, + ) + if response.status_code in (200, 201): + result["changed"] = True + return response.json() + else: + raise AnsibleError("Failed to copy job template: %s" % (response.text or "Unknown error")) + except AnsibleError: + raise + except Exception as exc: + raise AnsibleError("Failed to copy job template: %s" % str(exc)) + + def run(self, tmp: object = None, task_vars: dict = None) -> dict: + """Run the job_template action plugin. + + Extends the base run() to handle: + - copy_from: Copy an existing job template before applying changes + - Association fields: credentials, labels, notification_templates, instance_groups + - survey_spec: Secondary endpoint for survey management + """ + if task_vars is None: + task_vars = {} + + # Capture extra fields before super().run() processes args + copy_from = self._task.args.get("copy_from") + survey_spec = self._task.args.get("survey_spec") + state = self._task.args.get("state", "present") + + # Capture association field values + association_data = {} + for field in _ASSOCIATION_FIELDS: + val = self._task.args.get(field) + if val is not None: + association_data[field] = val + + # Handle copy_from before the main CRUD + if copy_from and state not in ("absent", "deleted"): + # Remove copy_from from args so base doesn't see it + self._task.args.pop("copy_from", None) + + # We need manager access for the copy + result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) + self._task_vars = task_vars + + try: + 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 + + name = self._task.args.get("name") + copied = self._handle_copy(manager, copy_from, name, result) + + if copied and copied.get("id"): + # Now update the copied template with any additional params + self._task.args["id"] = copied["id"] + # Re-run through base to apply remaining params as an update + result = super().run(tmp, task_vars) + 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: + # Normal CRUD path + self._task.args.pop("copy_from", None) + result = super().run(tmp, task_vars) + + if result.get("failed"): + return result + + # Post-CRUD: handle associations and survey_spec + jt_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") + + if jt_id and state not in ("absent", "deleted", "exists"): + manager = self._client + if manager: + # Handle association fields + if association_data: + self._handle_associations(manager, jt_id, result, association_data) + + # Handle survey_spec + if survey_spec is not None: + self._handle_survey_spec(manager, jt_id, result, survey_spec) + + return result diff --git a/plugins/modules/job_template.py b/plugins/modules/job_template.py new file mode 100644 index 00000000..d601da17 --- /dev/null +++ b/plugins/modules/job_template.py @@ -0,0 +1,365 @@ +#!/usr/bin/python +# coding: utf-8 -*- +# (c) 2025, 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 + +DOCUMENTATION = """ +--- +module: job_template +author: "Ansible Platform Collection Contributors" +short_description: Create, update, or destroy job templates. +description: + - Create, update, or destroy job templates in Ansible Automation Platform. + - This module manages job templates via the Controller API. + - Migrated from the C(awx.awx) and C(ansible.controller) collections. +options: + name: + description: + - Name to use for the job template. + 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 job template from. + - This will copy an existing job template and change any parameters supplied. + - The new job template name will be the one provided in the name parameter. + - The organization parameter is not used in this, to facilitate copy from one organization to another. + - Provide the id or use the lookup plugin to provide the id if multiple job templates share the same name. + type: str + description: + description: + - Description to use for the job template. + type: str + job_type: + description: + - The job type to use for the job template. + choices: ["run", "check"] + type: str + inventory: + description: + - Name, ID, or named URL of the inventory to use for the job template. + type: str + organization: + description: + - Organization name, ID, or named URL the job template exists in. + - Used to help lookup the object, cannot be modified using this module. + - The Organization is inferred from the associated project. + - If not provided, will lookup by name only, which does not work with duplicates. + type: str + project: + description: + - Name, ID, or named URL of the project to use for the job template. + type: str + playbook: + description: + - Path to the playbook to use for the job template within the project provided. + type: str + credentials: + description: + - List of credential names, IDs, or named URLs to use for the job template. + type: list + elements: str + execution_environment: + description: + - Execution Environment name, ID, or named URL to use for the job template. + type: str + instance_groups: + description: + - List of Instance Group names, IDs, or named URLs for this job template to run on. + type: list + elements: str + forks: + description: + - The number of parallel or simultaneous processes to use while executing the playbook. + type: int + limit: + description: + - A host pattern to further constrain the list of hosts managed or affected by the playbook. + type: str + verbosity: + description: + - Control the output level Ansible produces as the playbook runs. + - 0 - Normal, 1 - Verbose, 2 - More Verbose, 3 - Debug, 4 - Connection Debug, 5 - WinRM Debug. + choices: [0, 1, 2, 3, 4, 5] + type: int + extra_vars: + description: + - Specify C(extra_vars) for the template. + type: dict + job_tags: + description: + - Comma separated list of the tags to use for the job template. + type: str + force_handlers: + description: + - Enable forcing playbook handlers to run even if a task fails. + type: bool + aliases: + - force_handlers_enabled + skip_tags: + description: + - Comma separated list of the tags to skip for the job template. + type: str + start_at_task: + description: + - Start the playbook at the task matching this name. + type: str + diff_mode: + description: + - Enable diff mode for the job template. + type: bool + aliases: + - diff_mode_enabled + use_fact_cache: + description: + - Enable use of fact caching for the job template. + type: bool + aliases: + - fact_caching_enabled + host_config_key: + description: + - Allow provisioning callbacks using this host config key. + type: str + ask_scm_branch_on_launch: + description: + - Prompt user for SCM branch on launch. + type: bool + ask_diff_mode_on_launch: + description: + - Prompt user to enable diff mode (show changes) to files when supported by modules. + type: bool + aliases: + - ask_diff_mode + ask_variables_on_launch: + description: + - Prompt user for extra_vars on launch. + type: bool + aliases: + - ask_extra_vars + ask_limit_on_launch: + description: + - Prompt user for a limit on launch. + type: bool + aliases: + - ask_limit + ask_tags_on_launch: + description: + - Prompt user for job tags on launch. + type: bool + aliases: + - ask_tags + ask_skip_tags_on_launch: + description: + - Prompt user for job tags to skip on launch. + type: bool + aliases: + - ask_skip_tags + ask_job_type_on_launch: + description: + - Prompt user for job type on launch. + type: bool + aliases: + - ask_job_type + ask_verbosity_on_launch: + description: + - Prompt user to choose a verbosity level on launch. + type: bool + aliases: + - ask_verbosity + ask_inventory_on_launch: + description: + - Prompt user for inventory on launch. + type: bool + aliases: + - ask_inventory + ask_credential_on_launch: + description: + - Prompt user for credential on launch. + type: bool + aliases: + - ask_credential + ask_execution_environment_on_launch: + description: + - Prompt user for execution environment on launch. + type: bool + aliases: + - ask_execution_environment + ask_forks_on_launch: + description: + - Prompt user for forks on launch. + type: bool + aliases: + - ask_forks + ask_instance_groups_on_launch: + description: + - Prompt user for instance groups on launch. + type: bool + aliases: + - ask_instance_groups + ask_job_slice_count_on_launch: + description: + - Prompt user for job slice count on launch. + type: bool + aliases: + - ask_job_slice_count + ask_labels_on_launch: + description: + - Prompt user for labels on launch. + type: bool + aliases: + - ask_labels + ask_timeout_on_launch: + description: + - Prompt user for timeout on launch. + type: bool + aliases: + - ask_timeout + survey_enabled: + description: + - Enable a survey on the job template. + type: bool + survey_spec: + description: + - JSON/YAML dict formatted survey definition. + type: dict + become_enabled: + description: + - Activate privilege escalation. + type: bool + allow_simultaneous: + description: + - Allow simultaneous runs of the job template. + type: bool + aliases: + - concurrent_jobs_enabled + timeout: + description: + - Maximum time in seconds to wait for a job to finish (server-side). + type: int + job_slice_count: + description: + - The number of jobs to slice into at runtime. + - Will cause the Job Template to launch a workflow if value is greater than 1. + type: int + webhook_service: + description: + - Service that webhook requests will be accepted from. + type: str + choices: + - '' + - 'github' + - 'gitlab' + - 'bitbucket_dc' + webhook_credential: + description: + - Personal Access Token for posting back the status to the service API. + type: str + scm_branch: + description: + - Branch to use in job run. Project default used if blank. + - Only allowed if project allow_override field is set to true. + type: str + labels: + description: + - The labels applied to this job template. + - Must be created with the labels module first. This will error if the label has not been created. + type: list + elements: str + notification_templates_started: + description: + - List of notification templates to send on start. + type: list + elements: str + notification_templates_success: + description: + - List of notification templates to send on success. + type: list + elements: str + notification_templates_error: + description: + - List of notification templates to send on error. + type: list + elements: str + prevent_instance_group_fallback: + description: + - Prevent falling back to instance groups set on the associated inventory or organization. + type: bool + opa_query_path: + description: + - The query path for the OPA policy to evaluate prior to job execution. + - The query path should be formatted as package/rule. + type: str + +extends_documentation_fragment: + - ansible.platform.state + - ansible.platform.auth + +seealso: + - module: ansible.controller.job_template + - module: awx.awx.job_template + +notes: + - This module is the ansible.platform equivalent of the C(awx.awx.job_template) + and C(ansible.controller.job_template) modules. + - JSON for survey_spec can be found in the API Documentation. +""" + + +EXAMPLES = """ +- name: Create a job template + ansible.platform.job_template: + name: "Ping" + job_type: "run" + organization: "Default" + inventory: "Local" + project: "Demo" + playbook: "ping.yml" + credentials: + - "Local" + - "2nd credential" + state: "present" + survey_enabled: true + survey_spec: "{{ lookup('file', 'my_survey.json') }}" + +- name: Add start notification to Job Template + ansible.platform.job_template: + name: "Ping" + notification_templates_started: + - Notification1 + - Notification2 + +- name: Copy a job template + ansible.platform.job_template: + name: "copy job template" + copy_from: "test job template" + job_type: "run" + inventory: "Copy Foo Inventory" + project: "test" + playbook: "hello_world.yml" + state: "present" + +- name: Delete a job template + ansible.platform.job_template: + name: "Ping" + state: "absent" + +- name: Check if a job template exists + ansible.platform.job_template: + name: "Ping" + state: "exists" +""" + +RETURN = """ +job_template: + description: The job_template resource data. + returned: always + type: dict +""" diff --git a/plugins/plugin_utils/ansible_models/job_template.py b/plugins/plugin_utils/ansible_models/job_template.py new file mode 100644 index 00000000..e2bd878d --- /dev/null +++ b/plugins/plugin_utils/ansible_models/job_template.py @@ -0,0 +1,76 @@ +""" +Ansible JobTemplate dataclass — user-facing stable interface. + +Auto-generated by tools/generate_resource.py from the platform OpenAPI spec. +Manually updated to support migration from awx.awx/ansible.controller. +""" + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + + +@dataclass +class AnsibleJobTemplate: + """Ansible representation of a platform job_template.""" + + name: str + new_name: Optional[str] = None + copy_from: Optional[str] = None + description: Optional[str] = None + job_type: Optional[str] = None + inventory: Optional[str] = None + project: Optional[str] = None + playbook: Optional[str] = None + scm_branch: Optional[str] = None + forks: Optional[int] = None + limit: Optional[str] = None + verbosity: Optional[int] = None + extra_vars: Optional[Dict[str, Any]] = None + job_tags: Optional[str] = None + force_handlers: Optional[bool] = None + skip_tags: Optional[str] = None + start_at_task: Optional[str] = None + timeout: Optional[int] = None + use_fact_cache: Optional[bool] = None + organization: Optional[str] = None + execution_environment: Optional[str] = None + host_config_key: Optional[str] = None + ask_scm_branch_on_launch: Optional[bool] = None + ask_diff_mode_on_launch: Optional[bool] = None + ask_variables_on_launch: Optional[bool] = None + ask_limit_on_launch: Optional[bool] = None + ask_tags_on_launch: Optional[bool] = None + ask_skip_tags_on_launch: Optional[bool] = None + ask_job_type_on_launch: Optional[bool] = None + ask_verbosity_on_launch: Optional[bool] = None + ask_inventory_on_launch: Optional[bool] = None + ask_credential_on_launch: Optional[bool] = None + ask_execution_environment_on_launch: Optional[bool] = None + ask_labels_on_launch: Optional[bool] = None + ask_forks_on_launch: Optional[bool] = None + ask_job_slice_count_on_launch: Optional[bool] = None + ask_timeout_on_launch: Optional[bool] = None + ask_instance_groups_on_launch: Optional[bool] = None + survey_enabled: Optional[bool] = None + survey_spec: Optional[Dict[str, Any]] = None + become_enabled: Optional[bool] = None + diff_mode: Optional[bool] = None + allow_simultaneous: Optional[bool] = None + job_slice_count: Optional[int] = None + webhook_service: Optional[str] = None + webhook_credential: Optional[str] = None + prevent_instance_group_fallback: Optional[bool] = None + opa_query_path: Optional[str] = None + credentials: Optional[List[str]] = None + labels: Optional[List[str]] = None + instance_groups: Optional[List[str]] = None + notification_templates_started: Optional[List[str]] = None + notification_templates_success: Optional[List[str]] = None + notification_templates_error: Optional[List[str]] = None + state: str = "present" + + # Read-only fields (populated from API) + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None diff --git a/plugins/plugin_utils/api/v1/job_template.py b/plugins/plugin_utils/api/v1/job_template.py new file mode 100644 index 00000000..39e867e9 --- /dev/null +++ b/plugins/plugin_utils/api/v1/job_template.py @@ -0,0 +1,441 @@ +""" +API v1 JobTemplate dataclass and transform mixin. + +Auto-generated by tools/generate_resource.py from the platform OpenAPI spec. +Manually updated to support migration from awx.awx/ansible.controller. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional, Union + +from ...platform.base_transform import BaseTransformMixin +from ...platform.types import EndpointOperation, TransformContext + +logger = logging.getLogger(__name__) + + +@dataclass +class APIJobTemplate_v1(BaseTransformMixin): + """API v1 representation of a controller job_template.""" + + name: Optional[str] = None + description: Optional[str] = None + job_type: Optional[str] = None + inventory: Optional[int] = None + project: Optional[int] = None + playbook: Optional[str] = None + scm_branch: Optional[str] = None + forks: Optional[int] = None + limit: Optional[str] = None + verbosity: Optional[int] = None + extra_vars: Optional[str] = None + job_tags: Optional[str] = None + force_handlers: Optional[bool] = None + skip_tags: Optional[str] = None + start_at_task: Optional[str] = None + timeout: Optional[int] = None + use_fact_cache: Optional[bool] = None + execution_environment: Optional[int] = None + host_config_key: Optional[str] = None + ask_scm_branch_on_launch: Optional[bool] = None + ask_diff_mode_on_launch: Optional[bool] = None + ask_variables_on_launch: Optional[bool] = None + ask_limit_on_launch: Optional[bool] = None + ask_tags_on_launch: Optional[bool] = None + ask_skip_tags_on_launch: Optional[bool] = None + ask_job_type_on_launch: Optional[bool] = None + ask_verbosity_on_launch: Optional[bool] = None + ask_inventory_on_launch: Optional[bool] = None + ask_credential_on_launch: Optional[bool] = None + ask_execution_environment_on_launch: Optional[bool] = None + ask_labels_on_launch: Optional[bool] = None + ask_forks_on_launch: Optional[bool] = None + ask_job_slice_count_on_launch: Optional[bool] = None + ask_timeout_on_launch: Optional[bool] = None + ask_instance_groups_on_launch: Optional[bool] = None + survey_enabled: Optional[bool] = None + become_enabled: Optional[bool] = None + diff_mode: Optional[bool] = None + allow_simultaneous: Optional[bool] = None + job_slice_count: Optional[int] = None + webhook_service: Optional[str] = None + webhook_credential: Optional[int] = None + prevent_instance_group_fallback: Optional[bool] = None + opa_query_path: Optional[str] = None + + # Read-only + id: Optional[int] = None + created: Optional[str] = None + modified: Optional[str] = None + url: Optional[str] = None + + +def _resolve_fk(manager, endpoint: str, lookup_field: str, value) -> Optional[int]: + """Resolve a name or id to an integer id.""" + if value is None: + return None + if str(value).isdigit(): + return int(value) + try: + return manager.lookup_resource_id(endpoint, lookup_field, str(value)) + except Exception: + return None + + +_SIMPLE_FIELDS = ( + "playbook", + "scm_branch", + "forks", + "limit", + "verbosity", + "job_tags", + "force_handlers", + "skip_tags", + "start_at_task", + "timeout", + "use_fact_cache", + "host_config_key", + "ask_scm_branch_on_launch", + "ask_diff_mode_on_launch", + "ask_variables_on_launch", + "ask_limit_on_launch", + "ask_tags_on_launch", + "ask_skip_tags_on_launch", + "ask_job_type_on_launch", + "ask_verbosity_on_launch", + "ask_inventory_on_launch", + "ask_credential_on_launch", + "ask_execution_environment_on_launch", + "ask_labels_on_launch", + "ask_forks_on_launch", + "ask_job_slice_count_on_launch", + "ask_timeout_on_launch", + "ask_instance_groups_on_launch", + "survey_enabled", + "become_enabled", + "diff_mode", + "allow_simultaneous", + "job_slice_count", + "webhook_service", + "prevent_instance_group_fallback", + "opa_query_path", +) + + +class JobTemplateTransformMixin_v1(BaseTransformMixin): + """Transform mixin for JobTemplate API v1.""" + + @classmethod + def from_ansible_data( + cls, + ansible_instance, + context: Union[TransformContext, Dict[str, Any]], + ) -> "APIJobTemplate_v1": + api_data: Dict[str, Any] = {} + manager = context.manager if isinstance(context, TransformContext) else context.get("manager") + op = getattr(context, "operation", None) if isinstance(context, TransformContext) else context.get("operation") + + name = getattr(ansible_instance, "name", None) + new_name = getattr(ansible_instance, "new_name", None) + job_type = getattr(ansible_instance, "job_type", None) + description = getattr(ansible_instance, "description", None) + + if op == "create": + api_data["name"] = new_name or name + elif op == "update": + if new_name is not None: + api_data["name"] = new_name + elif name is not None: + api_data["name"] = name + else: + if name is not None: + api_data["name"] = name + + if description is not None: + api_data["description"] = description + if job_type is not None: + api_data["job_type"] = job_type + + # Resolve reference fields (name → ID) + inventory = getattr(ansible_instance, "inventory", None) + if inventory is not None and manager: + resolved = _resolve_fk(manager, "inventories", "name", inventory) + if resolved is not None: + api_data["inventory"] = resolved + + project = getattr(ansible_instance, "project", None) + organization = getattr(ansible_instance, "organization", None) + if project is not None and manager: + if organization is not None: + org_id = _resolve_fk(manager, "organizations", "name", organization) + if org_id is not None: + try: + project_results = manager.execute( + operation="find", + module_name="project", + ansible_data={"name": project, "organization": org_id}, + ) + if project_results and project_results.get("id"): + api_data["project"] = project_results["id"] + except Exception: + resolved = _resolve_fk(manager, "projects", "name", project) + if resolved is not None: + api_data["project"] = resolved + else: + resolved = _resolve_fk(manager, "projects", "name", project) + if resolved is not None: + api_data["project"] = resolved + else: + resolved = _resolve_fk(manager, "projects", "name", project) + if resolved is not None: + api_data["project"] = resolved + + ee = getattr(ansible_instance, "execution_environment", None) + if ee is not None and manager: + resolved = _resolve_fk(manager, "execution_environments", "name", ee) + if resolved is not None: + api_data["execution_environment"] = resolved + + wh_cred = getattr(ansible_instance, "webhook_credential", None) + if wh_cred is not None and manager: + resolved = _resolve_fk(manager, "credentials", "name", wh_cred) + if resolved is not None: + api_data["webhook_credential"] = resolved + + # extra_vars: serialize dict to JSON string for the API + extra_vars = getattr(ansible_instance, "extra_vars", None) + if extra_vars is not None: + if isinstance(extra_vars, dict): + api_data["extra_vars"] = json.dumps(extra_vars) + else: + api_data["extra_vars"] = str(extra_vars) + + # Simple 1:1 fields + for field in _SIMPLE_FIELDS: + val = getattr(ansible_instance, field, None) + if val is not None: + api_data[field] = val + + # Read-only fields (pass through if present) + for ro in ("id", "created", "modified", "url"): + val = getattr(ansible_instance, ro, None) + if val is not None: + api_data[ro] = val + + return APIJobTemplate_v1(**api_data) + + @classmethod + def get_endpoint_operations(cls) -> Dict[str, EndpointOperation]: + return { + "create": EndpointOperation( + path="/api/controller/v2/job_templates/", + method="POST", + fields=[ + "name", + "description", + "job_type", + "inventory", + "project", + "playbook", + "scm_branch", + "forks", + "limit", + "verbosity", + "extra_vars", + "job_tags", + "force_handlers", + "skip_tags", + "start_at_task", + "timeout", + "use_fact_cache", + "execution_environment", + "host_config_key", + "ask_scm_branch_on_launch", + "ask_diff_mode_on_launch", + "ask_variables_on_launch", + "ask_limit_on_launch", + "ask_tags_on_launch", + "ask_skip_tags_on_launch", + "ask_job_type_on_launch", + "ask_verbosity_on_launch", + "ask_inventory_on_launch", + "ask_credential_on_launch", + "ask_execution_environment_on_launch", + "ask_labels_on_launch", + "ask_forks_on_launch", + "ask_job_slice_count_on_launch", + "ask_timeout_on_launch", + "ask_instance_groups_on_launch", + "survey_enabled", + "become_enabled", + "diff_mode", + "allow_simultaneous", + "job_slice_count", + "webhook_service", + "webhook_credential", + "prevent_instance_group_fallback", + "opa_query_path", + ], + required_for="create", + order=1, + ), + "update": EndpointOperation( + path="/api/controller/v2/job_templates/{id}/", + method="PATCH", + fields=[ + "name", + "description", + "job_type", + "inventory", + "project", + "playbook", + "scm_branch", + "forks", + "limit", + "verbosity", + "extra_vars", + "job_tags", + "force_handlers", + "skip_tags", + "start_at_task", + "timeout", + "use_fact_cache", + "execution_environment", + "host_config_key", + "ask_scm_branch_on_launch", + "ask_diff_mode_on_launch", + "ask_variables_on_launch", + "ask_limit_on_launch", + "ask_tags_on_launch", + "ask_skip_tags_on_launch", + "ask_job_type_on_launch", + "ask_verbosity_on_launch", + "ask_inventory_on_launch", + "ask_credential_on_launch", + "ask_execution_environment_on_launch", + "ask_labels_on_launch", + "ask_forks_on_launch", + "ask_job_slice_count_on_launch", + "ask_timeout_on_launch", + "ask_instance_groups_on_launch", + "survey_enabled", + "become_enabled", + "diff_mode", + "allow_simultaneous", + "job_slice_count", + "webhook_service", + "webhook_credential", + "prevent_instance_group_fallback", + "opa_query_path", + ], + path_params=["id"], + required_for="update", + order=1, + ), + "delete": EndpointOperation( + path="/api/controller/v2/job_templates/{id}/", + method="DELETE", + fields=[], + path_params=["id"], + required_for="delete", + order=1, + ), + "get": EndpointOperation( + path="/api/controller/v2/job_templates/{id}/", + method="GET", + fields=[], + path_params=["id"], + required_for="find", + order=1, + ), + "list": EndpointOperation( + path="/api/controller/v2/job_templates/", + 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) -> Dict[str, Any]: + """Extra query params for list find (organization scoping).""" + org = getattr(ansible_data, "organization", None) + if org is not None: + return {"organization": org} + return {} + + @classmethod + def from_api( + cls, + api_data: Dict[str, Any], + context: Union[TransformContext, Dict[str, Any]], + ): + from ...ansible_models.job_template import AnsibleJobTemplate + + # Deserialize extra_vars JSON string back to dict + extra_vars = api_data.get("extra_vars") + if extra_vars and isinstance(extra_vars, str): + try: + extra_vars = json.loads(extra_vars) + except (json.JSONDecodeError, ValueError): + pass + + return AnsibleJobTemplate( + name=api_data.get("name"), + description=api_data.get("description"), + job_type=api_data.get("job_type"), + inventory=api_data.get("inventory"), + project=api_data.get("project"), + playbook=api_data.get("playbook"), + scm_branch=api_data.get("scm_branch"), + forks=api_data.get("forks"), + limit=api_data.get("limit"), + verbosity=api_data.get("verbosity"), + extra_vars=extra_vars, + job_tags=api_data.get("job_tags"), + force_handlers=api_data.get("force_handlers"), + skip_tags=api_data.get("skip_tags"), + start_at_task=api_data.get("start_at_task"), + timeout=api_data.get("timeout"), + use_fact_cache=api_data.get("use_fact_cache"), + execution_environment=api_data.get("execution_environment"), + host_config_key=api_data.get("host_config_key"), + ask_scm_branch_on_launch=api_data.get("ask_scm_branch_on_launch"), + ask_diff_mode_on_launch=api_data.get("ask_diff_mode_on_launch"), + ask_variables_on_launch=api_data.get("ask_variables_on_launch"), + ask_limit_on_launch=api_data.get("ask_limit_on_launch"), + ask_tags_on_launch=api_data.get("ask_tags_on_launch"), + ask_skip_tags_on_launch=api_data.get("ask_skip_tags_on_launch"), + ask_job_type_on_launch=api_data.get("ask_job_type_on_launch"), + ask_verbosity_on_launch=api_data.get("ask_verbosity_on_launch"), + ask_inventory_on_launch=api_data.get("ask_inventory_on_launch"), + ask_credential_on_launch=api_data.get("ask_credential_on_launch"), + ask_execution_environment_on_launch=api_data.get("ask_execution_environment_on_launch"), + ask_labels_on_launch=api_data.get("ask_labels_on_launch"), + ask_forks_on_launch=api_data.get("ask_forks_on_launch"), + ask_job_slice_count_on_launch=api_data.get("ask_job_slice_count_on_launch"), + ask_timeout_on_launch=api_data.get("ask_timeout_on_launch"), + ask_instance_groups_on_launch=api_data.get("ask_instance_groups_on_launch"), + survey_enabled=api_data.get("survey_enabled"), + become_enabled=api_data.get("become_enabled"), + diff_mode=api_data.get("diff_mode"), + allow_simultaneous=api_data.get("allow_simultaneous"), + job_slice_count=api_data.get("job_slice_count"), + webhook_service=api_data.get("webhook_service"), + webhook_credential=api_data.get("webhook_credential"), + prevent_instance_group_fallback=api_data.get("prevent_instance_group_fallback"), + opa_query_path=api_data.get("opa_query_path"), + id=api_data.get("id"), + created=api_data.get("created"), + modified=api_data.get("modified"), + url=api_data.get("url"), + ) diff --git a/tests/integration/targets/job_template_test/meta/main.yml b/tests/integration/targets/job_template_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/job_template_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/job_template_test/tasks/main.yml b/tests/integration/targets/job_template_test/tasks/main.yml new file mode 100644 index 00000000..d022becc --- /dev/null +++ b/tests/integration/targets/job_template_test/tasks/main.yml @@ -0,0 +1,173 @@ +--- +# Integration tests for ansible.platform.job_template +# Covers: CRUD, idempotency, associations, survey_spec, copy_from + +- 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-JobTemplate-{{ 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: + # ---- Basic CRUD ---------------------------------------------------------- + + - name: Delete any pre-existing test job template (cleanup) + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + state: absent + failed_when: false + + - name: Create a job template + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + job_type: "run" + project: "Demo Project" + playbook: "hello_world.yml" + state: present + register: created_jt + + - name: Assert creation changed + ansible.builtin.assert: + that: + - created_jt is changed + - created_jt.job_template.name is defined + - created_jt.job_template.id is defined + + - name: Check idempotency (re-apply, expect no change) + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + job_type: "run" + project: "Demo Project" + playbook: "hello_world.yml" + state: present + register: idempotent_jt + + - name: Assert no change on re-apply + ansible.builtin.assert: + that: + - idempotent_jt is not changed + + # ---- Update -------------------------------------------------------------- + + - name: Update job template description + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + description: "Updated description" + state: present + register: updated_jt + + - name: Assert update changed + ansible.builtin.assert: + that: + - updated_jt is changed + + # ---- Exists check -------------------------------------------------------- + + - name: Check exists returns true + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + state: exists + register: exists_check + + - name: Assert exists is true + ansible.builtin.assert: + that: + - exists_check.exists + + - name: Check exists returns false for non-existent + ansible.platform.job_template: + name: "{{ name_prefix }}-NonExistent" + state: exists + register: not_exists_check + + - name: Assert exists is false + ansible.builtin.assert: + that: + - not not_exists_check.exists + + # ---- Survey spec --------------------------------------------------------- + + - name: Add survey to job template + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + survey_enabled: true + survey_spec: + name: "Test Survey" + description: "A test survey" + spec: + - question_name: "Test Question" + question_description: "A test question" + required: true + type: "text" + variable: "test_var" + default: "default_value" + state: present + register: survey_jt + + - name: Assert survey was applied + ansible.builtin.assert: + that: + - survey_jt is changed + + # ---- Copy ---------------------------------------------------------------- + + - name: Copy a job template + ansible.platform.job_template: + name: "{{ name_prefix }}-Copy" + copy_from: "{{ name_prefix }}-Test" + state: present + register: copied_jt + + - name: Assert copy changed + ansible.builtin.assert: + that: + - copied_jt is changed + + # ---- Delete -------------------------------------------------------------- + + - name: Delete the job template + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + state: absent + register: delete_jt + + - name: Assert delete changed + ansible.builtin.assert: + that: + - delete_jt is changed + + - name: Delete again (idempotency) + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + state: absent + register: delete_idem + + - name: Assert double-delete is a no-op + ansible.builtin.assert: + that: + - delete_idem is not changed + + always: + - name: Cleanup test job template + ansible.platform.job_template: + name: "{{ name_prefix }}-Test" + state: absent + failed_when: false + + - name: Cleanup copied job template + ansible.platform.job_template: + name: "{{ name_prefix }}-Copy" + state: absent + failed_when: false +... From 74860cf6ddbc8985121b115301c937bc24fa8fb8 Mon Sep 17 00:00:00 2001 From: thedoubl3j <24478650+thedoubl3j@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:34:20 -0400 Subject: [PATCH 2/3] Move HTTP calls from job_template action plugin into SDK layer All API work (associations, survey_spec, copy_from) now flows through PlatformService/DirectHTTPClient instead of direct manager.session calls in the action plugin, satisfying design principles and enabling MCP compatibility. Assisted-By: Claude Opus 4.6 --- plugins/action/job_template.py | 280 ++++-------------- .../plugin_utils/manager/platform_manager.py | 138 +++++++++ plugins/plugin_utils/manager/rpc_client.py | 27 ++ plugins/plugin_utils/platform/base_client.py | 73 +++++ .../plugin_utils/platform/direct_client.py | 154 ++++++++++ 5 files changed, 446 insertions(+), 226 deletions(-) diff --git a/plugins/action/job_template.py b/plugins/action/job_template.py index 438c338b..e064ed42 100644 --- a/plugins/action/job_template.py +++ b/plugins/action/job_template.py @@ -18,9 +18,8 @@ __metaclass__ = type import logging -from typing import Any, List, Optional +from typing import Any -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_template import ( AnsibleJobTemplate, @@ -28,24 +27,25 @@ logger = logging.getLogger(__name__) -_ASSOCIATION_FIELDS = frozenset( - { - "credentials", - "labels", - "notification_templates_started", - "notification_templates_success", - "notification_templates_error", - "instance_groups", - } +_ASSOCIATION_FIELDS = ( + "credentials", + "labels", + "notification_templates_started", + "notification_templates_success", + "notification_templates_error", + "instance_groups", ) -_EXTRA_TASK_FIELDS = _ASSOCIATION_FIELDS | frozenset( - { - "survey_spec", - "copy_from", - "organization", - } -) +_JT_BASE_PATH = "/api/controller/v2/job_templates" + +_ASSOCIATION_MAP = { + "credentials": ("credentials", "name"), + "labels": ("labels", "name"), + "notification_templates_started": ("notification_templates", "name"), + "notification_templates_success": ("notification_templates", "name"), + "notification_templates_error": ("notification_templates", "name"), + "instance_groups": ("instance_groups", "name"), +} class ActionModule(BaseResourceActionPlugin): @@ -79,173 +79,6 @@ def _build_ansible_data(self, resource: Any, validated_params: dict, operation: data["id"] = resource.id return data - def _resolve_association_ids( - self, - manager, - endpoint: str, - lookup_field: str, - items: List[str], - ) -> List[int]: - """Resolve a list of names/IDs to integer IDs.""" - resolved = [] - for item in items: - if str(item).isdigit(): - resolved.append(int(item)) - else: - try: - item_id = manager.lookup_resource_id(endpoint, lookup_field, str(item)) - if item_id: - resolved.append(item_id) - else: - raise AnsibleError("Could not find %s entry with name '%s'" % (endpoint, item)) - except Exception as exc: - if "not found" in str(exc).lower(): - raise AnsibleError("Could not find %s entry with name '%s'" % (endpoint, item)) - raise - return resolved - - def _handle_associations(self, manager, jt_id: int, result: dict, write_only_data: dict) -> None: - """Manage association sub-endpoints for the job template. - - For each association field, resolve names to IDs and POST associate/disassociate - calls to the appropriate sub-endpoint. - """ - association_map = { - "credentials": ("credentials", "name"), - "labels": ("labels", "name"), - "notification_templates_started": ("notification_templates", "name"), - "notification_templates_success": ("notification_templates", "name"), - "notification_templates_error": ("notification_templates", "name"), - "instance_groups": ("instance_groups", "name"), - } - - for field, (endpoint, lookup_field) in association_map.items(): - desired_items = write_only_data.get(field) - if desired_items is None: - continue - - desired_ids = self._resolve_association_ids(manager, endpoint, lookup_field, desired_items) - - # Get current associations - assoc_endpoint = "/api/controller/v2/job_templates/%s/%s/" % (jt_id, field) - try: - current_response = manager.session.get( - manager._build_url(assoc_endpoint), - ) - current_data = current_response.json() if current_response.status_code == 200 else {} - current_results = current_data.get("results", []) - current_ids = [item["id"] for item in current_results] - except Exception: - current_ids = [] - - # Associate new items - for item_id in desired_ids: - if item_id not in current_ids: - try: - manager.session.post( - manager._build_url(assoc_endpoint), - json={"id": item_id, "associate": True}, - ) - result["changed"] = True - except Exception as exc: - logger.debug("Failed to associate %s %s: %s", field, item_id, exc) - - # Disassociate items not in desired list - for item_id in current_ids: - if item_id not in desired_ids: - try: - manager.session.post( - manager._build_url(assoc_endpoint), - json={"id": item_id, "disassociate": True}, - ) - result["changed"] = True - except Exception as exc: - logger.debug("Failed to disassociate %s %s: %s", field, item_id, exc) - - def _handle_survey_spec(self, manager, jt_id: int, result: dict, survey_spec: Optional[dict]) -> None: - """Manage the survey_spec secondary endpoint.""" - if survey_spec is None: - return - - spec_endpoint = "/api/controller/v2/job_templates/%s/survey_spec/" % jt_id - - if survey_spec == {}: - # Empty dict means delete the survey - try: - response = manager.session.delete(manager._build_url(spec_endpoint)) - if response.status_code in (200, 204): - result["changed"] = True - except Exception as exc: - raise AnsibleError("Failed to delete survey: %s" % str(exc)) - else: - # Check if survey already matches - try: - current_response = manager.session.get(manager._build_url(spec_endpoint)) - current_spec = current_response.json() if current_response.status_code == 200 else None - except Exception: - current_spec = None - - if survey_spec != current_spec: - try: - response = manager.session.post( - manager._build_url(spec_endpoint), - json=survey_spec, - ) - if response.status_code not in (200, 201): - error_msg = response.json().get("error", response.text) if response.text else "Unknown error" - raise AnsibleError("Failed to update survey: %s" % error_msg) - result["changed"] = True - except AnsibleError: - raise - except Exception as exc: - raise AnsibleError("Failed to update survey: %s" % str(exc)) - - def _handle_copy(self, manager, copy_from: str, name: str, result: dict) -> Optional[dict]: - """Handle copy_from: copy an existing job template. - - Returns the copied job template data dict, or None on failure. - """ - # Find the source job template - try: - source = manager.execute( - operation="find", - module_name=self.MODULE_NAME, - ansible_data={"name": copy_from}, - ) - except Exception: - source = None - - if not source or not source.get("id"): - # Try by ID - if str(copy_from).isdigit(): - try: - source = manager.execute( - operation="find", - module_name=self.MODULE_NAME, - ansible_data={"id": int(copy_from)}, - ) - except Exception: - source = None - - if not source or not source.get("id"): - raise AnsibleError("Could not find job template '%s' to copy from" % copy_from) - - copy_endpoint = "/api/controller/v2/job_templates/%s/copy/" % source["id"] - try: - response = manager.session.post( - manager._build_url(copy_endpoint), - json={"name": name}, - ) - if response.status_code in (200, 201): - result["changed"] = True - return response.json() - else: - raise AnsibleError("Failed to copy job template: %s" % (response.text or "Unknown error")) - except AnsibleError: - raise - except Exception as exc: - raise AnsibleError("Failed to copy job template: %s" % str(exc)) - def run(self, tmp: object = None, task_vars: dict = None) -> dict: """Run the job_template action plugin. @@ -253,84 +86,79 @@ def run(self, tmp: object = None, task_vars: dict = None) -> dict: - copy_from: Copy an existing job template before applying changes - Association fields: credentials, labels, notification_templates, instance_groups - survey_spec: Secondary endpoint for survey management - """ - if task_vars is None: - task_vars = {} - # Capture extra fields before super().run() processes args - copy_from = self._task.args.get("copy_from") - survey_spec = self._task.args.get("survey_spec") + All HTTP calls are delegated to the SDK layer (PlatformService / DirectHTTPClient). + """ + copy_from = self._task.args.pop("copy_from", None) + survey_spec = self._task.args.pop("survey_spec", None) state = self._task.args.get("state", "present") - # Capture association field values association_data = {} for field in _ASSOCIATION_FIELDS: - val = self._task.args.get(field) + val = self._task.args.pop(field, None) if val is not None: association_data[field] = val - # Handle copy_from before the main CRUD if copy_from and state not in ("absent", "deleted"): - # Remove copy_from from args so base doesn't see it - self._task.args.pop("copy_from", None) - - # We need manager access for the copy result = super(BaseResourceActionPlugin, self).run(tmp, task_vars) - self._task_vars = task_vars + self._task_vars = task_vars or {} try: - manager, facts_to_set = self._get_or_spawn_manager(task_vars) + 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 - name = self._task.args.get("name") - copied = self._handle_copy(manager, copy_from, name, result) + copied = manager.copy_resource( + self.MODULE_NAME, + copy_from, + self._task.args.get("name"), + _JT_BASE_PATH, + ) if copied and copied.get("id"): - # Now update the copied template with any additional params self._task.args["id"] = copied["id"] - # Re-run through base to apply remaining params as an update result = super().run(tmp, task_vars) else: - result.update( - { - "changed": True, - "failed": False, - self.MODULE_NAME: copied or {}, - } - ) + result.update(changed=True, failed=False, **{self.MODULE_NAME: copied or {}}) except Exception as exc: - result.update( - { - "changed": False, - "failed": True, - "msg": str(exc), - } - ) + result.update(changed=False, failed=True, msg=str(exc)) return result else: - # Normal CRUD path - self._task.args.pop("copy_from", None) result = super().run(tmp, task_vars) if result.get("failed"): return result - # Post-CRUD: handle associations and survey_spec jt_id = result.get("id") or (result.get(self.MODULE_NAME, {}) or {}).get("id") if jt_id and state not in ("absent", "deleted", "exists"): manager = self._client if manager: - # Handle association fields - if association_data: - self._handle_associations(manager, jt_id, result, association_data) + for field, (lookup_ep, lookup_field) in _ASSOCIATION_MAP.items(): + desired = association_data.get(field) + if desired is not None: + changed = manager.manage_associations( + _JT_BASE_PATH, + jt_id, + field, + desired, + lookup_ep, + lookup_field, + ) + if changed: + result["changed"] = True - # Handle survey_spec if survey_spec is not None: - self._handle_survey_spec(manager, jt_id, result, survey_spec) + changed = manager.manage_sub_resource( + _JT_BASE_PATH, + jt_id, + "survey_spec", + survey_spec, + ) + if changed: + result["changed"] = True return result diff --git a/plugins/plugin_utils/manager/platform_manager.py b/plugins/plugin_utils/manager/platform_manager.py index 4cf2593b..91ea1a26 100644 --- a/plugins/plugin_utils/manager/platform_manager.py +++ b/plugins/plugin_utils/manager/platform_manager.py @@ -1049,6 +1049,144 @@ def _sort_operations(self, operations: Dict) -> list: return sorted_ops + def manage_associations( + self, + base_path, + resource_id, + association_field, + desired_items, + lookup_endpoint, + lookup_field, + ): + """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.verify_ssl) + 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 + + 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.verify_ssl, + ) + changed = True + except Exception as exc: + logger.debug("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.verify_ssl, + ) + changed = True + except Exception as exc: + logger.debug("Failed to disassociate %s %s: %s", association_field, item_id, exc) + + return changed + + def manage_sub_resource(self, base_path, resource_id, sub_path, data=None): + """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.verify_ssl) + return response.status_code in (200, 204) + + try: + current_response = self.session.get(spec_url, timeout=self.request_timeout, verify=self.verify_ssl) + 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.verify_ssl, + ) + 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, source_name_or_id, new_name, copy_endpoint_path): + """Copy a resource via its /copy/ sub-endpoint.""" + self.record_activity() + + source = None + try: + source = self.execute( + operation="find", + module_name=module_name, + ansible_data_dict={"name": source_name_or_id}, + ) + except Exception: + pass + + 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: + pass + + if not source or not source.get("id"): + raise ValueError("Could not find %s '%s' to copy from" % (module_name, source_name_or_id)) + + 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.verify_ssl, + ) + 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..c1078e91 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, + resource_id, + association_field, + desired_items, + lookup_endpoint, + lookup_field, + ): + """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, resource_id, sub_path, data=None): + """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, source_name_or_id, new_name, copy_endpoint_path): + """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..2dfb4070 100644 --- a/plugins/plugin_utils/platform/direct_client.py +++ b/plugins/plugin_utils/platform/direct_client.py @@ -982,6 +982,160 @@ def lookup_organization_names(self, ids: list) -> list: # This should use the cache to avoid repeated lookups pass + def manage_associations( + self, + base_path, + resource_id, + association_field, + desired_items, + lookup_endpoint, + lookup_field, + ): + """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 + + 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: + logger.debug("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: + logger.debug("Failed to disassociate %s %s: %s", association_field, item_id, exc) + + return changed + + def manage_sub_resource(self, base_path, resource_id, sub_path, data=None): + """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, source_name_or_id, new_name, copy_endpoint_path): + """Copy a resource via its /copy/ sub-endpoint.""" + + source = None + try: + source = self.execute( + operation="find", + module_name=module_name, + ansible_data_dict={"name": source_name_or_id}, + ) + except Exception: + pass + + 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: + pass + + if not source or not source.get("id"): + raise ValueError("Could not find %s '%s' to copy from" % (module_name, source_name_or_id)) + + 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. From 3a8a1280d51b1cfb85783dbd3b27795a1ae88df9 Mon Sep 17 00:00:00 2001 From: thedoubl3j <24478650+thedoubl3j@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:29:55 -0400 Subject: [PATCH 3/3] Fix CI failures, idempotency bug, and add unit tests for job_template - Fix author format in DOCUMENTATION to pass ansible-test sanity - Add YAML document end marker to fix ansible-lint violation - Add changelog fragment for PR #228 - Fix from_api() to return FK fields as strings so _should_update() correctly skips name-vs-ID comparisons (prevents false changed=True) - Improve error handling in manage_associations and copy_resource to surface failures instead of silently swallowing them - Add 30 unit tests covering transform mixin, FK resolution, extra_vars serialization, reverse transform, and endpoint operations Assisted-By: Claude Opus 4.6 --- changelogs/fragments/228-add-job-template.yml | 5 + plugins/modules/job_template.py | 3 +- plugins/plugin_utils/api/v1/job_template.py | 13 +- .../plugin_utils/manager/platform_manager.py | 23 +- .../plugin_utils/platform/direct_client.py | 23 +- .../plugins/plugin_utils/test_job_template.py | 281 ++++++++++++++++++ 6 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 changelogs/fragments/228-add-job-template.yml create mode 100644 tests/unit/plugins/plugin_utils/test_job_template.py diff --git a/changelogs/fragments/228-add-job-template.yml b/changelogs/fragments/228-add-job-template.yml new file mode 100644 index 00000000..741e4e19 --- /dev/null +++ b/changelogs/fragments/228-add-job-template.yml @@ -0,0 +1,5 @@ +--- +minor_changes: + - job_template - add job_template module migrated from awx.awx/ansible.controller + for managing job templates via the Controller API (ansible/ansible.platform#228). +... diff --git a/plugins/modules/job_template.py b/plugins/modules/job_template.py index d601da17..1e0cc0b8 100644 --- a/plugins/modules/job_template.py +++ b/plugins/modules/job_template.py @@ -10,7 +10,7 @@ DOCUMENTATION = """ --- module: job_template -author: "Ansible Platform Collection Contributors" +author: "Jake Jackson (@thedoubl3j)" short_description: Create, update, or destroy job templates. description: - Create, update, or destroy job templates in Ansible Automation Platform. @@ -310,6 +310,7 @@ - This module is the ansible.platform equivalent of the C(awx.awx.job_template) and C(ansible.controller.job_template) modules. - JSON for survey_spec can be found in the API Documentation. +... """ diff --git a/plugins/plugin_utils/api/v1/job_template.py b/plugins/plugin_utils/api/v1/job_template.py index 39e867e9..bc017b22 100644 --- a/plugins/plugin_utils/api/v1/job_template.py +++ b/plugins/plugin_utils/api/v1/job_template.py @@ -389,12 +389,17 @@ def from_api( except (json.JSONDecodeError, ValueError): pass + inv = api_data.get("inventory") + proj = api_data.get("project") + ee = api_data.get("execution_environment") + wh_cred = api_data.get("webhook_credential") + return AnsibleJobTemplate( name=api_data.get("name"), description=api_data.get("description"), job_type=api_data.get("job_type"), - inventory=api_data.get("inventory"), - project=api_data.get("project"), + inventory=str(inv) if inv is not None else None, + project=str(proj) if proj is not None else None, playbook=api_data.get("playbook"), scm_branch=api_data.get("scm_branch"), forks=api_data.get("forks"), @@ -407,7 +412,7 @@ def from_api( start_at_task=api_data.get("start_at_task"), timeout=api_data.get("timeout"), use_fact_cache=api_data.get("use_fact_cache"), - execution_environment=api_data.get("execution_environment"), + execution_environment=str(ee) if ee is not None else None, host_config_key=api_data.get("host_config_key"), ask_scm_branch_on_launch=api_data.get("ask_scm_branch_on_launch"), ask_diff_mode_on_launch=api_data.get("ask_diff_mode_on_launch"), @@ -431,7 +436,7 @@ def from_api( allow_simultaneous=api_data.get("allow_simultaneous"), job_slice_count=api_data.get("job_slice_count"), webhook_service=api_data.get("webhook_service"), - webhook_credential=api_data.get("webhook_credential"), + webhook_credential=str(wh_cred) if wh_cred is not None else None, prevent_instance_group_fallback=api_data.get("prevent_instance_group_fallback"), opa_query_path=api_data.get("opa_query_path"), id=api_data.get("id"), diff --git a/plugins/plugin_utils/manager/platform_manager.py b/plugins/plugin_utils/manager/platform_manager.py index 91ea1a26..a7af1d1c 100644 --- a/plugins/plugin_utils/manager/platform_manager.py +++ b/plugins/plugin_utils/manager/platform_manager.py @@ -1081,6 +1081,8 @@ def manage_associations( changed = False + errors = [] + for item_id in resolved_ids: if item_id not in current_ids: try: @@ -1092,7 +1094,7 @@ def manage_associations( ) changed = True except Exception as exc: - logger.debug("Failed to associate %s %s: %s", association_field, item_id, 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: @@ -1105,7 +1107,10 @@ def manage_associations( ) changed = True except Exception as exc: - logger.debug("Failed to disassociate %s %s: %s", association_field, item_id, exc) + errors.append("Failed to disassociate %s %s: %s" % (association_field, item_id, exc)) + + if errors: + raise ValueError("; ".join(errors)) return changed @@ -1152,14 +1157,15 @@ def copy_resource(self, module_name, source_name_or_id, new_name, copy_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: - pass + except Exception as exc: + last_error = exc if not source or not source.get("id"): if str(source_name_or_id).isdigit(): @@ -1169,11 +1175,14 @@ def copy_resource(self, module_name, source_name_or_id, new_name, copy_endpoint_ module_name=module_name, ansible_data_dict={"id": int(source_name_or_id), "name": str(source_name_or_id)}, ) - except Exception: - pass + except Exception as exc: + last_error = exc if not source or not source.get("id"): - raise ValueError("Could not find %s '%s' to copy from" % (module_name, source_name_or_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( diff --git a/plugins/plugin_utils/platform/direct_client.py b/plugins/plugin_utils/platform/direct_client.py index 2dfb4070..2f864b3d 100644 --- a/plugins/plugin_utils/platform/direct_client.py +++ b/plugins/plugin_utils/platform/direct_client.py @@ -1023,6 +1023,8 @@ def manage_associations( changed = False + errors = [] + for item_id in resolved_ids: if item_id not in current_ids: try: @@ -1035,7 +1037,7 @@ def manage_associations( ) changed = True except Exception as exc: - logger.debug("Failed to associate %s %s: %s", association_field, item_id, 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: @@ -1049,7 +1051,10 @@ def manage_associations( ) changed = True except Exception as exc: - logger.debug("Failed to disassociate %s %s: %s", association_field, item_id, exc) + errors.append("Failed to disassociate %s %s: %s" % (association_field, item_id, exc)) + + if errors: + raise ValueError("; ".join(errors)) return changed @@ -1101,14 +1106,15 @@ def copy_resource(self, module_name, source_name_or_id, new_name, copy_endpoint_ """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: - pass + except Exception as exc: + last_error = exc if not source or not source.get("id"): if str(source_name_or_id).isdigit(): @@ -1118,11 +1124,14 @@ def copy_resource(self, module_name, source_name_or_id, new_name, copy_endpoint_ module_name=module_name, ansible_data_dict={"id": int(source_name_or_id), "name": str(source_name_or_id)}, ) - except Exception: - pass + except Exception as exc: + last_error = exc if not source or not source.get("id"): - raise ValueError("Could not find %s '%s' to copy from" % (module_name, source_name_or_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( diff --git a/tests/unit/plugins/plugin_utils/test_job_template.py b/tests/unit/plugins/plugin_utils/test_job_template.py new file mode 100644 index 00000000..04abe04d --- /dev/null +++ b/tests/unit/plugins/plugin_utils/test_job_template.py @@ -0,0 +1,281 @@ +# (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 job_template v1 transform mixin. + +Covers: +- from_ansible_data: field mapping, FK resolution, extra_vars serialization +- from_api: reverse transform, FK fields returned as strings, extra_vars deserialization +- Endpoint operations: correct Controller API paths +""" + +from __future__ import absolute_import, division, print_function + +import json +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) +if _COLLECTIONS_PARENT not in sys.path: + sys.path.insert(0, _COLLECTIONS_PARENT) + +from ansible_collections.ansible.platform.plugins.plugin_utils.ansible_models.job_template import ( # noqa: E402 + AnsibleJobTemplate, +) +from ansible_collections.ansible.platform.plugins.plugin_utils.api.v1.job_template import ( # noqa: E402 + JobTemplateTransformMixin_v1, +) + + +class TestJobTemplateEndpointOperations(unittest.TestCase): + """Verify endpoint paths use the Controller API v2 prefix.""" + + def test_create_endpoint_path(self): + ops = JobTemplateTransformMixin_v1.get_endpoint_operations() + self.assertEqual(ops["create"].path, "/api/controller/v2/job_templates/") + self.assertEqual(ops["create"].method, "POST") + + def test_update_endpoint_path(self): + ops = JobTemplateTransformMixin_v1.get_endpoint_operations() + self.assertEqual(ops["update"].path, "/api/controller/v2/job_templates/{id}/") + self.assertEqual(ops["update"].method, "PATCH") + + def test_delete_endpoint_path(self): + ops = JobTemplateTransformMixin_v1.get_endpoint_operations() + self.assertEqual(ops["delete"].path, "/api/controller/v2/job_templates/{id}/") + self.assertEqual(ops["delete"].method, "DELETE") + + def test_list_endpoint_path(self): + ops = JobTemplateTransformMixin_v1.get_endpoint_operations() + self.assertEqual(ops["list"].path, "/api/controller/v2/job_templates/") + + def test_lookup_field_is_name(self): + self.assertEqual(JobTemplateTransformMixin_v1.get_lookup_field(), "name") + + +class TestJobTemplateFromAnsibleData(unittest.TestCase): + """Test Ansible model -> API model transformation.""" + + def test_basic_fields(self): + ansible = AnsibleJobTemplate(name="Test JT", description="A test", job_type="run") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "create"}) + self.assertEqual(api.name, "Test JT") + self.assertEqual(api.description, "A test") + self.assertEqual(api.job_type, "run") + + def test_create_uses_new_name(self): + ansible = AnsibleJobTemplate(name="Old", new_name="New") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "create"}) + self.assertEqual(api.name, "New") + + def test_update_uses_new_name(self): + ansible = AnsibleJobTemplate(name="Old", new_name="Renamed") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "update"}) + self.assertEqual(api.name, "Renamed") + + def test_update_without_new_name_keeps_name(self): + ansible = AnsibleJobTemplate(name="Original") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "update"}) + self.assertEqual(api.name, "Original") + + def test_simple_fields_pass_through(self): + ansible = AnsibleJobTemplate( + name="Test", + playbook="site.yml", + forks=10, + verbosity=2, + become_enabled=True, + diff_mode=False, + job_slice_count=3, + ) + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "create"}) + self.assertEqual(api.playbook, "site.yml") + self.assertEqual(api.forks, 10) + self.assertEqual(api.verbosity, 2) + self.assertIs(api.become_enabled, True) + self.assertIs(api.diff_mode, False) + self.assertEqual(api.job_slice_count, 3) + + def test_none_fields_omitted(self): + ansible = AnsibleJobTemplate(name="Test") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "create"}) + self.assertIsNone(api.inventory) + self.assertIsNone(api.project) + self.assertIsNone(api.playbook) + self.assertIsNone(api.execution_environment) + + def test_extra_vars_dict_serialized_to_json(self): + ansible = AnsibleJobTemplate(name="Test", extra_vars={"key": "value", "num": 42}) + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "create"}) + parsed = json.loads(api.extra_vars) + self.assertEqual(parsed, {"key": "value", "num": 42}) + + def test_extra_vars_non_dict_converted_to_string(self): + ansible = AnsibleJobTemplate(name="Test", extra_vars="key=value") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "create"}) + self.assertEqual(api.extra_vars, "key=value") + + def test_inventory_fk_resolved(self): + manager = MagicMock() + manager.lookup_resource_id.return_value = 42 + ansible = AnsibleJobTemplate(name="Test", inventory="My Inventory") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"manager": manager, "operation": "create"}) + self.assertEqual(api.inventory, 42) + manager.lookup_resource_id.assert_called_with("inventories", "name", "My Inventory") + + def test_project_fk_resolved(self): + manager = MagicMock() + manager.lookup_resource_id.return_value = 10 + ansible = AnsibleJobTemplate(name="Test", project="Demo Project") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"manager": manager, "operation": "create"}) + self.assertEqual(api.project, 10) + + def test_execution_environment_fk_resolved(self): + manager = MagicMock() + manager.lookup_resource_id.return_value = 5 + ansible = AnsibleJobTemplate(name="Test", execution_environment="Default EE") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"manager": manager, "operation": "create"}) + self.assertEqual(api.execution_environment, 5) + + def test_webhook_credential_fk_resolved(self): + manager = MagicMock() + manager.lookup_resource_id.return_value = 99 + ansible = AnsibleJobTemplate(name="Test", webhook_credential="GH Token") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"manager": manager, "operation": "create"}) + self.assertEqual(api.webhook_credential, 99) + + def test_numeric_string_fk_treated_as_id(self): + ansible = AnsibleJobTemplate(name="Test", inventory="42") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"manager": MagicMock(), "operation": "create"}) + self.assertEqual(api.inventory, 42) + + def test_no_manager_skips_fk_resolution(self): + ansible = AnsibleJobTemplate(name="Test", inventory="My Inventory") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"operation": "create"}) + self.assertIsNone(api.inventory) + + def test_org_scoped_project_lookup(self): + manager = MagicMock() + manager.lookup_resource_id.return_value = 1 + manager.execute.return_value = {"id": 10, "name": "Demo"} + ansible = AnsibleJobTemplate(name="Test", project="Demo", organization="Default") + api = JobTemplateTransformMixin_v1.from_ansible_data(ansible, {"manager": manager, "operation": "create"}) + self.assertEqual(api.project, 10) + manager.execute.assert_called_once() + + +class TestJobTemplateFromApi(unittest.TestCase): + """Test API response -> Ansible model reverse transformation.""" + + def _sample_api_data(self, **overrides): + data = { + "id": 1, + "name": "Test JT", + "description": "A test job template", + "job_type": "run", + "inventory": 42, + "project": 10, + "playbook": "site.yml", + "execution_environment": 5, + "webhook_credential": 99, + "forks": 0, + "verbosity": 0, + "extra_vars": '{"key": "value"}', + "created": "2026-01-01T00:00:00Z", + "modified": "2026-01-01T00:00:00Z", + "url": "/api/controller/v2/job_templates/1/", + } + data.update(overrides) + return data + + def test_basic_fields_mapped(self): + ansible = JobTemplateTransformMixin_v1.from_api(self._sample_api_data(), {}) + self.assertEqual(ansible.name, "Test JT") + self.assertEqual(ansible.description, "A test job template") + self.assertEqual(ansible.job_type, "run") + self.assertEqual(ansible.playbook, "site.yml") + self.assertEqual(ansible.id, 1) + + def test_fk_fields_returned_as_strings(self): + """FK fields must be strings so _should_update can compare name vs digit-string.""" + ansible = JobTemplateTransformMixin_v1.from_api(self._sample_api_data(), {}) + self.assertEqual(ansible.inventory, "42") + self.assertIsInstance(ansible.inventory, str) + self.assertEqual(ansible.project, "10") + self.assertIsInstance(ansible.project, str) + self.assertEqual(ansible.execution_environment, "5") + self.assertIsInstance(ansible.execution_environment, str) + self.assertEqual(ansible.webhook_credential, "99") + self.assertIsInstance(ansible.webhook_credential, str) + + def test_null_fk_fields_remain_none(self): + ansible = JobTemplateTransformMixin_v1.from_api( + self._sample_api_data(inventory=None, project=None, execution_environment=None, webhook_credential=None), + {}, + ) + self.assertIsNone(ansible.inventory) + self.assertIsNone(ansible.project) + self.assertIsNone(ansible.execution_environment) + self.assertIsNone(ansible.webhook_credential) + + def test_extra_vars_json_deserialized(self): + ansible = JobTemplateTransformMixin_v1.from_api( + self._sample_api_data(extra_vars='{"foo": "bar"}'), + {}, + ) + self.assertEqual(ansible.extra_vars, {"foo": "bar"}) + + def test_extra_vars_invalid_json_kept_as_string(self): + ansible = JobTemplateTransformMixin_v1.from_api( + self._sample_api_data(extra_vars="not-json"), + {}, + ) + self.assertEqual(ansible.extra_vars, "not-json") + + def test_extra_vars_empty_string_passed_through(self): + ansible = JobTemplateTransformMixin_v1.from_api( + self._sample_api_data(extra_vars=""), + {}, + ) + self.assertEqual(ansible.extra_vars, "") + + def test_boolean_fields_mapped(self): + ansible = JobTemplateTransformMixin_v1.from_api( + self._sample_api_data( + become_enabled=True, + diff_mode=False, + survey_enabled=True, + allow_simultaneous=False, + ), + {}, + ) + self.assertIs(ansible.become_enabled, True) + self.assertIs(ansible.diff_mode, False) + self.assertIs(ansible.survey_enabled, True) + self.assertIs(ansible.allow_simultaneous, False) + + def test_read_only_fields_mapped(self): + ansible = JobTemplateTransformMixin_v1.from_api(self._sample_api_data(), {}) + self.assertEqual(ansible.id, 1) + self.assertEqual(ansible.created, "2026-01-01T00:00:00Z") + self.assertEqual(ansible.url, "/api/controller/v2/job_templates/1/") + + +class TestJobTemplateQueryParams(unittest.TestCase): + """Test get_find_list_query_params for org-scoped lookups.""" + + def test_org_scoping(self): + ansible = AnsibleJobTemplate(name="Test", organization="Default") + params = JobTemplateTransformMixin_v1.get_find_list_query_params(ansible) + self.assertEqual(params, {"organization": "Default"}) + + def test_no_org_returns_empty(self): + ansible = AnsibleJobTemplate(name="Test") + params = JobTemplateTransformMixin_v1.get_find_list_query_params(ansible) + self.assertEqual(params, {}) + + +if __name__ == "__main__": + unittest.main()