diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..261d5991 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,130 @@ +# Contributing to ansible.platform + +Thank you for contributing to `ansible.platform`. This guide covers everything you need to get started. + +--- + +## Development Setup + +```bash +# Clone the repository +git clone https://github.com/ansible/ansible.platform.git +cd ansible.platform + +# Install development dependencies +pip install -r requirements.txt --break-system-packages + +# Sync the installed copy from the source tree (required before running tests) +make sync-installed + +# Run unit tests +PYTHONPATH=collections python -m pytest tests/unit/ -v + +# Run molecule mock scenarios +cd extensions && molecule test -s organization_mock +``` + +--- + +## Adding a New Resource + +The collection uses a code generator to scaffold new resources from the OpenAPI spec. See [docs/07-adding-resources.md](docs/07-adding-resources.md) for the complete walkthrough. + +Quick start: + +```bash +# Dry-run to preview generated files +python tools/generate_resource.py \ + --tag \ + --spec ../aap-openapi-specs/2.6/gateway.json \ + --dry-run + +# Generate for real +python tools/generate_resource.py \ + --tag \ + --spec ../aap-openapi-specs/2.6/gateway.json +``` + +Every new resource requires: +- Generated files reviewed and transform mixin completed +- Molecule mock scenario added to `extensions/molecule/_mock/` +- Molecule scenario registered in `.github/workflows/molecule-mock.yml` +- `ansible-doc ansible.platform.` passes without errors + +--- + +## Testing + +### Unit tests + +```bash +# Sync installed copy first (important — tests run against collections/) +make sync-installed + +PYTHONPATH=collections python -m pytest tests/unit/ -v +``` + +### Molecule mock tests + +```bash +cd extensions + +# Single scenario +molecule test -s organization_mock + +# All scenarios +molecule test --all +``` + +### Linting + +```bash +# Python style +ruff check plugins/ tests/ + +# Type checking +mypy plugins/ + +# Docstring validation +pydoclint plugins/ +``` + +--- + +## PR Guidelines + +See [GOVERNANCE.md](GOVERNANCE.md) for full approval requirements. In brief: + +- **Bug fixes and docs:** 1 steward team approver, CI must pass +- **New resources:** 1 approver + new molecule scenario required +- **Breaking changes or API-version-specific changes:** 2 approvers + PDT review + +Add the `needs-pdt-review` label if your change touches argspec options, state machine behavior, or AAP API version dependencies. + +--- + +## CaC Engagement + +`ansible.platform` is a foundational dependency for CaC (Content as Code) validated content in the `infra.*` namespace. + +**If you are a CaC content author** writing `infra.*` roles or playbooks, see [docs/11-cac-operator-guide.md](docs/11-cac-operator-guide.md) for how to consume this collection. + +**If you find a compatibility issue** between `ansible.platform` and an `infra.*` collection, please open a GitHub issue with the label `infra-compat` and tag `@sean-m-sullivan` or `@djdanielsson`. + +**Slack:** Join `#wg-ansible-platform-collection` on Red Hat Ansible Community Slack for real-time discussion, integration questions, and release coordination. + +**Before each minor release** the CaC liaison reviews `docs/11-cac-operator-guide.md` for accuracy. If you are a CaC maintainer and notice a documentation gap, open a PR directly — doc-only PRs are welcome and require only 1 approver. + +--- + +## Known API Limitations + +See [docs/known-api-issues.md](docs/known-api-issues.md) for documented AAP API limitations that affect this collection and their current workarounds. + +--- + +## Questions? + +- GitHub Issues: `ansible/ansible.platform` +- Slack: `#wg-ansible-platform-collection` +- Governance and escalation: see [GOVERNANCE.md](GOVERNANCE.md) diff --git a/Makefile b/Makefile index 68f0e70a..b35c6071 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ PYTHON_VERSION: .PHONY: PYTHON_VERSION clean git_hooks_config \ check_ruff check_mypy check_pydoclint \ - collection-install collection-test collection-docs \ + collection-install sync-installed collection-test collection-docs \ collection-lint collection-sanity collection-test-completeness \ collection-test-integration-check \ collection-test-local collection-test-http-direct collection-test-http-persistent \ @@ -46,6 +46,17 @@ check_pydoclint: collection-install: ansible-galaxy collection install . --force +## Sync plugins/ source tree into the local installed copy (collections/). +## Run this after editing any plugin before running unit tests, which execute +## against the installed copy in collections/ rather than the source plugins/. +## This target is the fix for "unexpected keyword argument" errors caused by +## stale installed code diverging from the source tree. +sync-installed: + rsync -av --delete plugins/ \ + collections/ansible_collections/ansible/platform/plugins/ + rsync -av --delete meta/ \ + collections/ansible_collections/ansible/platform/meta/ + ## Run the collection sanity tests collection-sanity: collection-install cd /tmp/collections/ansible_collections/ansible/platform && \ diff --git a/docs/11-cac-operator-guide.md b/docs/11-cac-operator-guide.md new file mode 100644 index 00000000..2aa2a39f --- /dev/null +++ b/docs/11-cac-operator-guide.md @@ -0,0 +1,307 @@ +# ansible.platform — Operator and CaC Author Guide + +This guide is for **CaC content authors** writing `infra.*` roles or playbooks, and **AAP operators** managing an AAP instance using Ansible. If you are contributing to the collection itself, see [07-adding-resources.md](07-adding-resources.md). + +--- + +## Prerequisites + +| Requirement | Notes | +|---|---| +| AAP 2.6 or later | Gateway endpoint must be reachable from where Ansible runs | +| `ansible.platform` collection | `ansible-galaxy collection install ansible.platform` | +| Service account or OAuth token | Created in AAP → Access → Users or via `settings.py` bootstrap | +| Python 3.10+ | Required by the collection | + +--- + +## Inventory and Credential Setup + +The collection needs two pieces of information at runtime: where the gateway is and how to authenticate. + +### Option 1 — `ansible.platform.http` connection plugin (recommended for long playbooks) + +```ini +# inventory/hosts +[aap_gateway] +aap.example.com + +[aap_gateway:vars] +ansible_connection=ansible.platform.http +gateway_hostname=https://aap.example.com +gateway_oauth_token={{ lookup('env', 'AAP_OAUTH_TOKEN') }} +``` + +This connection plugin keeps a persistent manager process alive across all tasks in a play, which is significantly faster than spawning a new HTTPS session per task. + +### Option 2 — `connection: local` with `gateway_*` task vars (simple / CI use) + +```yaml +- name: Manage AAP resources + hosts: localhost + connection: local + vars: + gateway_hostname: https://aap.example.com + gateway_oauth_token: "{{ lookup('env', 'AAP_OAUTH_TOKEN') }}" + tasks: + - name: Ensure org exists + ansible.platform.organization: + name: MyOrg + state: present +``` + +Each task in this mode creates a fresh HTTPS connection. Fine for short plays or CI pipelines where the overhead is acceptable. + +### Credentials — What the collection accepts + +| Variable | Description | Required | +|---|---|---| +| `gateway_hostname` | Base URL of the AAP gateway, e.g. `https://aap.example.com` | Yes | +| `gateway_oauth_token` | OAuth2 bearer token | One of these | +| `gateway_username` + `gateway_password` | Basic auth (service account) | One of these | +| `gateway_verify_ssl` | Set `false` to skip cert validation (dev only) | No, default `true` | + +**Best practice:** Store credentials in an Ansible Vault file or pull them from AAP's own credential store via `ansible.builtin.lookup('env', ...)`. Never hardcode tokens in playbooks committed to source control. + +--- + +## Writing Your First Playbook + +A complete, idempotent playbook that creates an organization, a team, and assigns a user: + +```yaml +--- +- name: Bootstrap AAP organizations and teams + hosts: localhost + connection: local + vars: + gateway_hostname: "{{ lookup('env', 'AAP_HOSTNAME') }}" + gateway_oauth_token: "{{ lookup('env', 'AAP_TOKEN') }}" + + tasks: + - name: Ensure platform organization exists + ansible.platform.organization: + name: "Platform Engineering" + description: "Manages platform tooling and AAP itself" + state: present + register: org_result + + - name: Ensure platform team exists + ansible.platform.team: + name: "Platform Admins" + organization: "Platform Engineering" + description: "Admins for the Platform Engineering org" + state: present + + - name: Ensure service account user exists + ansible.platform.user: + username: "svc-platform-bot" + first_name: "Platform" + last_name: "Bot" + email: "platform-bot@example.com" + is_superuser: false + state: present + + - name: Assign user to team + ansible.platform.team_member: + team: "Platform Admins" + user: "svc-platform-bot" + state: present +``` + +Run it: + +```bash +export AAP_HOSTNAME=https://aap.example.com +export AAP_TOKEN= +ansible-playbook bootstrap_aap.yml +``` + +Run it again — nothing changes. Every task is idempotent. + +--- + +## Understanding Idempotency + +The collection guarantees idempotency through its four states: + +| State | What it does | `changed` if... | +|---|---|---| +| `present` | Creates if missing, updates if different | Object was created or any field changed | +| `absent` | Deletes if it exists | Object existed and was deleted | +| `exists` | Fails if the object does not exist; never modifies it | Never changes; use for conditional assertions | +| `enforced` | Like `present` but also removes child objects not in the task | Child objects were removed | + +**Safe to run repeatedly in pipelines.** A fully converged environment produces zero `changed` tasks. + +### What triggers `changed: true` + +The collection compares the desired state from your task arguments against the current state returned by the AAP API. If any declared field differs, the API is called and `changed` is set. + +**Exception — write-only fields:** Some fields (e.g., `password`, `client_secret`) are never returned by the API for security reasons. The collection cannot detect changes to these fields after initial creation. If you update a password and re-run, the task will report `changed: false` even though a change was made. This is a known AAP API limitation. + +--- + +## Error Handling Patterns + +### Check if a resource exists before acting on it + +```yaml +- name: Check if organization exists + ansible.platform.organization: + name: "MyOrg" + state: exists + register: org_check + failed_when: false # don't fail the play if it doesn't exist + +- name: Create org if it was missing + ansible.platform.organization: + name: "MyOrg" + description: "Created by bootstrap playbook" + state: present + when: org_check.failed +``` + +### Handle expected failures gracefully + +```yaml +- name: Remove user from team (may already be removed) + ansible.platform.team_member: + team: "Platform Admins" + user: "departed-user" + state: absent + register: remove_result + failed_when: + - remove_result.failed + - '"Not found" not in remove_result.msg' +``` + +--- + +## Reference-by-Name Convention + +All relationship fields use **names, not numeric IDs**. The collection resolves names to IDs internally before calling the API. + +```yaml +# Correct — use name +- ansible.platform.team: + name: "Platform Admins" + organization: "Platform Engineering" # name, not organization_id: 42 + state: present + +# Wrong — do not use internal IDs +- ansible.platform.team: + name: "Platform Admins" + organization_id: 42 # this field doesn't exist + state: present +``` + +This means your playbooks are portable across AAP instances where the numeric IDs will differ. + +--- + +## Connection Modes and Performance + +### When to use the `ansible.platform.http` persistent connection + +- Playbooks with **10 or more AAP tasks** +- Roles that run in a loop over many objects +- Any scenario where startup latency is noticeable + +The persistent connection mode spawns a single manager process per play that stays alive across all tasks. Startup cost (~1–2 seconds) is paid once. Idle timeout defaults to 3600 seconds. + +```ini +[aap_gateway:vars] +ansible_connection=ansible.platform.http +# Optional: tune idle timeout (seconds, 0 = never timeout) +persistent_manager_idle_timeout=1800 +``` + +### When ephemeral (per-task) mode is fine + +- Short playbooks with fewer than 5 AAP tasks +- CI pipelines running occasionally +- Tasks where you want strict isolation between operations + +No configuration needed for ephemeral mode — `connection: local` gives you ephemeral behavior automatically. + +--- + +## Integration with infra.* Collections + +`ansible.platform` is the low-level resource management collection. If you are using an `infra.*` validated content collection that manages AAP (such as `infra.platform` or `infra.aap_configuration`), that collection likely uses `ansible.platform` under the hood. + +**Declaring the dependency in your role or collection:** + +```yaml +# meta/requirements.yml +collections: + - name: ansible.platform + version: ">=2.6.0" +``` + +**Mixing direct `ansible.platform` tasks with `infra.*` roles:** +This is fully supported. The `infra.*` role and your direct tasks share the same connection and manager process if both run in the same play against the same inventory host. + +**Reporting integration issues:** +If you find a behavior difference between what `ansible.platform` documents and what you observe when called through an `infra.*` role, please open an issue on the `ansible.platform` repository and tag it `infra-compat`. See also [CONTRIBUTING.md](../CONTRIBUTING.md). + +--- + +## Troubleshooting + +### `gateway_hostname` not set / connection refused + +``` +TASK [ansible.platform.organization] **** +fatal: [localhost]: FAILED! => {"msg": "gateway_hostname is required"} +``` + +Make sure `gateway_hostname` is set either as a task var, host var, or environment variable `GATEWAY_HOSTNAME`. + +### SSL certificate errors + +``` +fatal: [localhost]: FAILED! => {"msg": "SSL: CERTIFICATE_VERIFY_FAILED"} +``` + +For development against self-signed certificates: set `gateway_verify_ssl: false`. For production: ensure your AAP gateway certificate is signed by a CA trusted by the Python environment running Ansible. + +### `changed: true` on every run for the same task + +Most common cause: a field is set in your task that the API returns in a different format. For example, if you pass `description: ""` (empty string) but the API returns `null` for unset descriptions, the collection sees a diff on every run. + +Fix: omit optional fields from your task rather than setting them to empty strings. Let the API default apply. + +### Task takes 5–10 seconds to start + +You are likely using ephemeral mode (`connection: local`) and the manager process startup cost is showing. Switch to `ansible.platform.http` persistent connection for multi-task playbooks. + +--- + +## Quick Reference + +### Supported states + +| State | Use when... | +|---|---| +| `present` | You want the object to exist with these attributes | +| `absent` | You want the object removed | +| `exists` | You want to assert the object exists without changing it | +| `enforced` | You want the object to exist AND remove any children not in your task | + +### Environment variables + +| Variable | Description | +|---|---| +| `GATEWAY_HOSTNAME` | Fallback for `gateway_hostname` | +| `GATEWAY_OAUTH_TOKEN` | Fallback for `gateway_oauth_token` | +| `GATEWAY_USERNAME` | Fallback for `gateway_username` | +| `GATEWAY_PASSWORD` | Fallback for `gateway_password` | +| `GATEWAY_VERIFY_SSL` | Fallback for `gateway_verify_ssl` | + +### Getting help + +- GitHub Issues: `ansible/ansible.platform` +- Slack: `#wg-ansible-platform-collection` on Red Hat Ansible Community Slack +- CaC integration questions: tag `@sean-m-sullivan` or `@djdanielsson` in issues diff --git a/extensions/molecule/authenticator_user_mock/cleanup.yml b/extensions/molecule/authenticator_user_mock/cleanup.yml new file mode 100644 index 00000000..d17ac742 --- /dev/null +++ b/extensions/molecule/authenticator_user_mock/cleanup.yml @@ -0,0 +1,101 @@ +--- +- name: Cleanup — delete authenticator_users (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 + tasks: + - name: Delete authenticator_user (connection local) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-local" + authenticator: "molecule-mock-authenticator" + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs }}" + state: absent + register: delete_result_local + failed_when: false + vars: + ansible_connection: local + + - name: Assert authenticator_user removed (connection local) + ansible.builtin.assert: + that: delete_result_local is not failed + fail_msg: "Cleanup: failed to delete authenticator_user (connection local)." + vars: + ansible_connection: local + + - name: Remove manager survive flag + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete authenticator_users (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 + tasks: + - name: Delete authenticator_user (http direct) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hd" + authenticator: "molecule-mock-authenticator" + state: absent + register: delete_result_http_direct + failed_when: false + + - name: Assert authenticator_user removed (http direct) + ansible.builtin.assert: + that: delete_result_http_direct is not failed + fail_msg: "Cleanup: failed to delete authenticator_user (http direct)." + + - name: Remove manager survive flag + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local + +- name: Cleanup — delete authenticator_users (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 + tasks: + - name: Delete authenticator_user (http persistent) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hp" + authenticator: "molecule-mock-authenticator" + state: absent + register: delete_result_http_persistent + failed_when: false + + - name: Assert authenticator_user removed (http persistent) + ansible.builtin.assert: + that: delete_result_http_persistent is not failed + fail_msg: "Cleanup: failed to delete authenticator_user (http persistent)." + + - name: Remove manager survive flag + ansible.builtin.file: + path: /tmp/ap/.survive + state: absent + vars: + ansible_connection: local +... diff --git a/extensions/molecule/authenticator_user_mock/converge.yml b/extensions/molecule/authenticator_user_mock/converge.yml new file mode 100644 index 00000000..914f1ec8 --- /dev/null +++ b/extensions/molecule/authenticator_user_mock/converge.yml @@ -0,0 +1,167 @@ +--- +- 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 + +- name: Converge — authenticator_user (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 + tasks: + - name: Move authenticator user to local authenticator (connection local) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-local" + authenticator: "molecule-mock-authenticator" + keep_memberships: false + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs }}" + state: present + register: create_result_local + vars: + ansible_connection: local + + - name: Assert create changed (connection local) + ansible.builtin.assert: + that: + - create_result_local is changed + fail_msg: "Expected authenticator_user to be created/changed. Got: {{ create_result_local }}" + vars: + ansible_connection: local + + - name: Idempotency — re-run authenticator_user (connection local) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-local" + authenticator: "molecule-mock-authenticator" + keep_memberships: false + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs }}" + state: present + register: idem_result_local + vars: + ansible_connection: local + + - name: Assert idempotency (connection local) + ansible.builtin.assert: + that: + - idem_result_local is not changed + fail_msg: "Expected no change on second run. Got: {{ idem_result_local }}" + vars: + ansible_connection: local + +- name: Converge — authenticator_user (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 + tasks: + - name: Move authenticator user (http direct) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hd" + authenticator: "molecule-mock-authenticator" + keep_memberships: false + state: present + register: create_result_http_direct + + - name: Assert create changed (http direct) + ansible.builtin.assert: + that: + - create_result_http_direct is changed + fail_msg: "Expected changed on create (http direct). Got: {{ create_result_http_direct }}" + + - name: Idempotency — re-run authenticator_user (http direct) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hd" + authenticator: "molecule-mock-authenticator" + keep_memberships: false + state: present + register: idem_result_http_direct + + - name: Assert idempotency (http direct) + ansible.builtin.assert: + that: + - idem_result_http_direct is not changed + fail_msg: "Expected no change on second run (http direct). Got: {{ idem_result_http_direct }}" + +- name: Converge — authenticator_user (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 + tasks: + - name: Move authenticator user (http persistent) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hp" + authenticator: "molecule-mock-authenticator" + keep_memberships: false + state: present + register: create_result_http_persistent + + - name: Assert create changed (http persistent) + ansible.builtin.assert: + that: + - create_result_http_persistent is changed + fail_msg: "Expected changed on create (http persistent). Got: {{ create_result_http_persistent }}" + + - name: Idempotency — re-run authenticator_user (http persistent) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hp" + authenticator: "molecule-mock-authenticator" + keep_memberships: false + state: present + register: idem_result_http_persistent + + - name: Assert idempotency (http persistent) + ansible.builtin.assert: + that: + - idem_result_http_persistent is not changed + fail_msg: "Expected no change on second run (http persistent). Got: {{ idem_result_http_persistent }}" diff --git a/extensions/molecule/authenticator_user_mock/molecule.yml b/extensions/molecule/authenticator_user_mock/molecule.yml new file mode 100644 index 00000000..026b27c8 --- /dev/null +++ b/extensions/molecule/authenticator_user_mock/molecule.yml @@ -0,0 +1,31 @@ +--- +driver: + name: default + +platforms: + - name: localhost + +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/authenticator_user_mock/verify.yml b/extensions/molecule/authenticator_user_mock/verify.yml new file mode 100644 index 00000000..db486750 --- /dev/null +++ b/extensions/molecule/authenticator_user_mock/verify.yml @@ -0,0 +1,83 @@ +--- +- name: Verify — authenticator_user 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 + tasks: + - name: Check authenticator_user exists (connection local) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-local" + authenticator: "molecule-mock-authenticator" + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs }}" + state: exists + register: exists_result_local + vars: + ansible_connection: local + + - name: Assert authenticator_user found (connection local) + ansible.builtin.assert: + that: + - exists_result_local is not failed + - exists_result_local.get('exists') | default(false) | bool + fail_msg: "Verify: authenticator_user not found (connection local)." + vars: + ansible_connection: local + +- name: Verify — authenticator_user exists (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 + tasks: + - name: Check authenticator_user exists (http direct) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hd" + authenticator: "molecule-mock-authenticator" + state: exists + register: exists_result_http_direct + + - name: Assert authenticator_user 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: authenticator_user not found (http direct)." + +- name: Verify — authenticator_user exists (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 + tasks: + - name: Check authenticator_user exists (http persistent) + ansible.platform.authenticator_user: + authenticator_user_id: "molecule-mock-auth-user-hp" + authenticator: "molecule-mock-authenticator" + state: exists + register: exists_result_http_persistent + + - name: Assert authenticator_user 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: authenticator_user not found (http persistent)." +... diff --git a/plugins/callback/platform_manager_cleanup.py b/plugins/callback/platform_manager_cleanup.py new file mode 100644 index 00000000..defee288 --- /dev/null +++ b/plugins/callback/platform_manager_cleanup.py @@ -0,0 +1,189 @@ +#!/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) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import base64 +import json +import os +import signal +import tempfile +import time +from pathlib import Path + +from ansible.plugins.callback import CallbackBase + +DOCUMENTATION = r""" +name: platform_manager_cleanup +short_description: Gracefully shut down ansible.platform manager processes after each play +description: + - Terminates persistent manager subprocesses that were spawned by the + ansible.platform connection/action plugins once the play they belong to + has finished. + - Reads companion C(.meta) files written next to each Unix socket to + discover which processes are still alive, so it works correctly even when + the action plugin that spawned the manager is in a different worker fork. + - Zero configuration required. C(CALLBACK_NEEDS_ENABLED = False) causes + Ansible to auto-load this plugin from the collection without any entry in + C(ansible.cfg). +type: notification +requirements: [] +options: {} +""" + +CALLBACK_TYPE = "notification" +# False = Ansible auto-loads this plugin from the collection with zero +# user configuration. No callbacks_enabled entry in ansible.cfg required. +CALLBACK_NEEDS_ENABLED = False +CALLBACK_VERSION = 2.0 +CALLBACK_NAME = "ansible.platform.platform_manager_cleanup" + +# Socket directory used by the platform connection plugin and action plugin. +_SOCKET_DIR = Path(tempfile.gettempdir()) / "ansible_platform" + + +class CallbackModule(CallbackBase): + """Graceful platform manager cleanup on play-end.""" + + CALLBACK_TYPE = CALLBACK_TYPE + CALLBACK_NEEDS_ENABLED = False + CALLBACK_VERSION = CALLBACK_VERSION + CALLBACK_NAME = CALLBACK_NAME + + # ------------------------------------------------------------------ # + # Ansible hooks # + # ------------------------------------------------------------------ # + + def v2_playbook_on_play_end(self, play): + """Called by Ansible in the main process when a play finishes.""" + self._shutdown_all_managers() + + def v2_playbook_on_stats(self, stats): + """ + Belt-and-suspenders: also clean up at the very end of the playbook + in case v2_playbook_on_play_end was not fired (e.g. play was skipped). + """ + self._shutdown_all_managers() + + # ------------------------------------------------------------------ # + # Internal helpers # + # ------------------------------------------------------------------ # + + def _shutdown_all_managers(self): + """Scan the socket directory for .meta files and shut down each manager.""" + if not _SOCKET_DIR.exists(): + return + + meta_files = list(_SOCKET_DIR.glob("*.meta")) + if not meta_files: + return + + self._display.vv(f"[platform_manager_cleanup] Found {len(meta_files)} manager(s) to shut down") + + for meta_path in meta_files: + self._shutdown_one(meta_path) + + def _shutdown_one(self, meta_path: Path): + """Shut down the manager described by *meta_path* and remove both files.""" + try: + meta = json.loads(meta_path.read_text()) + except Exception as exc: + self._display.vvvv(f"[platform_manager_cleanup] Cannot read {meta_path}: {exc}") + _safe_unlink(meta_path) + return + + pid = meta.get("pid") + authkey_b64 = meta.get("authkey_b64") + socket_path = str(meta_path).removesuffix(".meta") + + if not pid: + self._display.vvvv(f"[platform_manager_cleanup] No PID in {meta_path}, skipping") + _safe_unlink(meta_path) + return + + # Check whether the process is still alive. + if not _pid_alive(pid): + self._display.vvvv(f"[platform_manager_cleanup] Manager PID {pid} already gone") + _safe_unlink(meta_path) + _safe_unlink(Path(socket_path)) + return + + self._display.vv(f"[platform_manager_cleanup] Shutting down manager PID={pid} socket={socket_path}") + + # 1. Try graceful RPC shutdown first (manager handles it cleanly). + if authkey_b64 and Path(socket_path).exists(): + try: + self._rpc_shutdown(socket_path, authkey_b64) + except Exception as exc: + self._display.vvvv(f"[platform_manager_cleanup] RPC shutdown failed: {exc}") + + # 2. Wait up to 5 s for the process to exit on its own. + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _pid_alive(pid): + break + time.sleep(0.1) + + # 3. Force-terminate if still running. + if _pid_alive(pid): + self._display.vvvv(f"[platform_manager_cleanup] Manager PID {pid} still alive, sending SIGTERM") + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + time.sleep(1) + + if _pid_alive(pid): + self._display.warning(f"[platform_manager_cleanup] Manager PID {pid} did not stop, sending SIGKILL") + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + # 4. Clean up files. + _safe_unlink(meta_path) + _safe_unlink(Path(socket_path)) + self._display.vvvv(f"[platform_manager_cleanup] Manager PID {pid} cleaned up") + + def _rpc_shutdown(self, socket_path: str, authkey_b64: str): + """Send a graceful shutdown RPC to the manager.""" + from ansible_collections.ansible.platform.plugins.plugin_utils.manager.rpc_client import ( + ManagerRPCClient, + ) + + authkey = base64.b64decode(authkey_b64) + client = ManagerRPCClient("", socket_path, authkey) + try: + client.shutdown_manager() + finally: + client.close() + + +# ------------------------------------------------------------------ # +# Module-level helpers # +# ------------------------------------------------------------------ # + + +def _pid_alive(pid: int) -> bool: + """Return True if *pid* is still a running process.""" + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + # Process exists but owned by another user — treat as alive. + return True + + +def _safe_unlink(path: Path): + """Remove *path* silently if it exists.""" + try: + path.unlink(missing_ok=True) + except Exception: + pass diff --git a/tests/integration/targets/infra_compat_test/meta/main.yml b/tests/integration/targets/infra_compat_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/infra_compat_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/infra_compat_test/tasks/main.yml b/tests/integration/targets/infra_compat_test/tasks/main.yml new file mode 100644 index 00000000..5fc26fff --- /dev/null +++ b/tests/integration/targets/infra_compat_test/tasks/main.yml @@ -0,0 +1,206 @@ +--- +# infra.* Compatibility Integration Test +# +# PURPOSE +# ------- +# Validates that ansible.platform works correctly as a dependency of infra.* +# validated content collections. Intended to be run during testathons with a +# live AAP 2.6+ instance where an infra.* collection is also installed. +# +# PREREQUISITES +# ------------- +# 1. ansible.platform collection installed (this collection) +# 2. At least one infra.* collection installed that depends on ansible.platform +# - Candidate: infra.platform or infra.aap_configuration (confirm with @sean-m-sullivan) +# 3. Live AAP 2.6+ gateway reachable +# 4. gateway_hostname / gateway_username / gateway_password set in integration_config.yml +# +# TO RUN +# ------ +# ansible-test integration infra_compat_test -v +# +# WHAT IT TESTS +# ------------- +# Round 1 — Direct ansible.platform tasks +# Create org, team, user via ansible.platform modules directly. Confirms the +# collection works standalone before testing infra.* layering. +# +# Round 2 — infra.* role invocation (requires infra.* collection installed) +# Invoke an infra.* role that internally uses ansible.platform. Confirms +# that the infra.* → ansible.platform dependency chain works end-to-end. +# +# Round 3 — Idempotency check +# Re-run both rounds. Everything should report changed=false. +# +# TESTATHON NOTES +# --------------- +# - Skip Round 2 if infra.* is not installed: set infra_compat_skip_infra_role=true +# - Document which infra.* collection + version was tested in the testathon notes +# - If Round 2 fails, open a GitHub issue with label infra-compat + +- name: Generate unique test ID to avoid collision with existing data + ansible.builtin.set_fact: + test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=12') }}" + when: test_id is not defined + +- name: Set test resource names + ansible.builtin.set_fact: + test_org_name: "infra-compat-test-org-{{ test_id }}" + test_team_name: "infra-compat-test-team-{{ test_id }}" + test_user_name: "infra-compat-test-user-{{ test_id }}" + +- name: "ROUND 1 — Direct ansible.platform tasks" + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | default(true) | bool }}" + block: + - name: Create test organization + ansible.platform.organization: + name: "{{ test_org_name }}" + description: "Created by infra_compat_test — safe to delete" + state: present + register: r_org + + - name: Assert organization created + ansible.builtin.assert: + that: + - r_org is changed + - r_org.organization.name == test_org_name + fail_msg: "Organization creation failed: {{ r_org }}" + + - name: Create test team inside organization + ansible.platform.team: + name: "{{ test_team_name }}" + organization: "{{ test_org_name }}" + state: present + register: r_team + + - name: Assert team created + ansible.builtin.assert: + that: + - r_team is changed + - r_team.team.name == test_team_name + fail_msg: "Team creation failed: {{ r_team }}" + + - name: Create test user + ansible.platform.user: + username: "{{ test_user_name }}" + first_name: "Infra" + last_name: "CompatTest" + email: "{{ test_user_name }}@example.com" + password: "CompatTest1234!" + state: present + register: r_user + + - name: Assert user created + ansible.builtin.assert: + that: + - r_user is changed + - r_user.user.username == test_user_name + fail_msg: "User creation failed: {{ r_user }}" + + - name: Assign user to team + ansible.platform.team_member: + team: "{{ test_team_name }}" + user: "{{ test_user_name }}" + state: present + register: r_member + + - name: Assert team membership created + ansible.builtin.assert: + that: + - r_member is changed + fail_msg: "Team membership failed: {{ r_member }}" + + - name: "ROUND 1 idempotency — re-run all tasks, expect no changes" + block: + - name: Re-create organization (should be no-op) + ansible.platform.organization: + name: "{{ test_org_name }}" + description: "Created by infra_compat_test — safe to delete" + state: present + register: r_org_idem + + - name: Re-create team (should be no-op) + ansible.platform.team: + name: "{{ test_team_name }}" + organization: "{{ test_org_name }}" + state: present + register: r_team_idem + + - name: Assert idempotency + ansible.builtin.assert: + that: + - r_org_idem is not changed + - r_team_idem is not changed + fail_msg: "Idempotency failure — resources changed on second run" + +- name: "ROUND 2 — infra.* role invocation (skipped if infra_compat_skip_infra_role=true)" + when: not (infra_compat_skip_infra_role | default(false) | bool) + block: + # NOTE: Replace the role name below with the actual infra.* role confirmed + # during testathon coordination with @sean-m-sullivan. + # Placeholder: infra.platform.config or infra.aap_configuration.organizations + - name: Check if infra.* collection is installed + ansible.builtin.command: ansible-galaxy collection list + register: r_galaxy_list + changed_when: false + + - name: Skip Round 2 if infra.* not installed + ansible.builtin.meta: end_play + when: "'infra.' not in r_galaxy_list.stdout" + + # TODO (testathon): Replace this task with the actual infra.* role invocation. + # Example (update role name after confirming with CaC team): + # + # - name: Run infra.* role that uses ansible.platform internally + # ansible.builtin.include_role: + # name: infra.aap_configuration.organizations + # vars: + # aap_organizations: + # - name: "{{ test_org_name }}-via-infra" + # description: "Created via infra.* role" + # + - name: Placeholder — replace during testathon + ansible.builtin.debug: + msg: > + Round 2 placeholder. During testathon, replace this task with the + actual infra.* role call. See task comments above for guidance. + Contact @sean-m-sullivan to confirm which infra.* collection + role to use. + +- name: "CLEANUP — Remove test resources" + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | default(true) | bool }}" + block: + - name: Remove test team membership + ansible.platform.team_member: + team: "{{ test_team_name }}" + user: "{{ test_user_name }}" + state: absent + ignore_errors: true # noqa: ignore-errors + + - name: Remove test user + ansible.platform.user: + username: "{{ test_user_name }}" + state: absent + ignore_errors: true # noqa: ignore-errors + + - name: Remove test team + ansible.platform.team: + name: "{{ test_team_name }}" + organization: "{{ test_org_name }}" + state: absent + ignore_errors: true # noqa: ignore-errors + + - name: Remove test organization + ansible.platform.organization: + name: "{{ test_org_name }}" + state: absent + ignore_errors: true # noqa: ignore-errors