diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 09aad73..65f1de4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,3 +12,15 @@ updates: default-days: 7 schedule: interval: weekly + groups: + docker-dependencies: + patterns: ["*"] + - package-ecosystem: pip + directory: /tests/fixture/ansible + cooldown: + default-days: 7 + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml index 7cb4546..7100faa 100644 --- a/.github/workflows/__shared-ci.yml +++ b/.github/workflows/__shared-ci.yml @@ -36,9 +36,14 @@ jobs: contents: read pull-requests: write uses: $/.github/workflows/__test-actions-vm.yml + ansible: + permissions: + contents: read + pull-requests: write + uses: $/.github/workflows/__test-actions-ansible.yml checks: if: ${{ always() }} - needs: [tests, linter, vm] + needs: [tests, linter, vm, ansible] runs-on: ubuntu-latest steps: - name: Require all checks diff --git a/.github/workflows/__test-actions-ansible.yml b/.github/workflows/__test-actions-ansible.yml new file mode 100644 index 0000000..4798836 --- /dev/null +++ b/.github/workflows/__test-actions-ansible.yml @@ -0,0 +1,64 @@ +--- +name: Internal - Test Ansible action +on: + workflow_call: +permissions: {} +jobs: + scenarios: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + mode: [playbook, sanity] + result: [success, failure] + steps: + - name: Checkout fixture + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Prepare selected scenario without a consumer Makefile + env: + MODE: ${{ matrix.mode }} + RESULT: ${{ matrix.result }} + run: | + rm Makefile + if [ "$RESULT" = failure ] && [ "$MODE" = sanity ]; then + printf '%s\n' '---' 'invalid: [' > tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml + fi + - name: Test Ansible with upstream actions + id: test + continue-on-error: true + uses: $/actions/ansible/test + env: + FIXTURE_RESULT: ${{ matrix.result }} + with: + ansible-requirements: tests/fixture/ansible/requirements.txt + test-playbook: ${{ matrix.mode == 'playbook' && 'tests/fixture/ansible/valid.yml' || '' }} + collection: ${{ matrix.mode == 'sanity' && 'tests/fixture/ansible/ansible_collections/example/smoke' || '' }} + report-name: Ansible tests - ${{ matrix.mode }} - ${{ matrix.result }} + - name: Verify check outcomes and native reports + if: ${{ always() }} + env: + MODE: ${{ matrix.mode }} + EXPECTED: ${{ matrix.result }} + OUTCOME: ${{ steps.test.outcome }} + REPORTS: ${{ steps.test.outputs.report-files }} + PARSED: ${{ steps.test.outputs.parsed-files }} + ARTIFACT: ${{ steps.test.outputs.artifact-id }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + assert.equal(process.env.OUTCOME, process.env.EXPECTED); + const files = process.env.REPORTS.split('\n').filter(Boolean); + assert.ok(files.length > 0); + assert.deepEqual(JSON.parse(process.env.PARSED).sort(), files.sort()); + assert.ok(process.env.ARTIFACT); + assert.ok(files.every(file => file.endsWith('.xml'))); + const reports = files.map(file => fs.readFileSync(file, 'utf8')).join('\n'); + assert.equal(/<(?:failure|error)\b/.test(reports), process.env.EXPECTED === 'failure'); diff --git a/.gitignore b/.gitignore index fbe2612..d587502 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .cache/ .reports/ node_modules/ +tests/fixture/ansible/ansible_collections/*/*/tests/output/ .env *.tfstate* *.tfplan diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f833995..72a5e23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,15 @@ comments. Consumer workflows grant comment permissions and always schedule clean Unit tests exercise the action entrypoints and internal modules directly. Keep hosted boot checks alongside the mocked unit contracts. +`__test-actions-ansible.yml` exercises playbook tests and collection sanity success/failure +scenarios through maintained playbook execution and Ansible community testing actions. +It verifies native report publication after failures. Adjacent JavaScript only +validates inputs and collects reports; upstream actions own test execution, +collection Python setup and containers. Playbook tests use a temporary virtual +environment and the built-in JUnit callback. Consumers supply their Ansible Core pin, +Galaxy requirements and selected targets. Dependabot maintains upstream action +pins and the fixture's requirements file. Linting remains in the separate linter workflow. + ## Releases and dependencies Use Conventional Commits and a reviewed pull request. Publish semantic versions diff --git a/Makefile b/Makefile index ea484eb..2eadbf5 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,8 @@ ci: ## Execute local CI tasks docs: ## Generate action documentation npm exec --yes --package=@ci-dokumentor/cli@0.5.0 -- ci-dokumentor generate --source actions/vm/create/action.yml actions/vm/test/action.yml actions/vm/destroy/action.yml --repository git + # The new Ansible action has no released version for automatic usage examples. + npm exec --yes --package=@ci-dokumentor/cli@0.5.0 -- ci-dokumentor generate --source actions/ansible/test/action.yml --repository git --exclude-sections usage ############################# # Helpers diff --git a/README.md b/README.md index 57e2e21..8d6b781 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## Overview -GitHub Actions for testing infrastructure changes in disposable virtual machines. +GitHub Actions for validating Ansible and testing infrastructure changes in disposable virtual machines. Create a Lima and QEMU guest from your configuration, run your checks, publish test and coverage reports, and verify that the guest is removed afterward. @@ -24,6 +24,13 @@ can run several test steps against the same guest. ## Actions +### Ansible + +#### - [Test Ansible](actions/ansible/test/README.md) + +Execute test playbooks and first-party collection sanity through maintained +Ansible actions. Publish test summaries, annotations, PR comments and report artifacts. + ### Virtual machines #### - [Create VM](actions/vm/create/README.md) @@ -44,7 +51,11 @@ cleanup remains visible and preserves the runtime for a retry. ## Getting started -Use an ephemeral Linux x86-64 runner with KVM and network access. Check out your +For Ansible, check out your repository on a Linux runner. Pass its pinned +Ansible requirements file and test playbook or collection paths to +[ansible/test](actions/ansible/test/README.md). + +For VMs, use an ephemeral Linux x86-64 runner with KVM and network access. Check out your repository with `persist-credentials: false`, and provide a Lima YAML configuration and Bash test commands or a script. Checkout files are not mounted into the guest. diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md new file mode 100644 index 0000000..80185a8 --- /dev/null +++ b/actions/ansible/test/README.md @@ -0,0 +1,115 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hlY2stY2lyY2xlIiBjb2xvcj0icHVycGxlIj48cGF0aCBkPSJNMjIgMTEuMDhWMTJhMTAgMTAgMCAxIDEtNS45My05LjE0Ij48L3BhdGg+PHBvbHlsaW5lIHBvaW50cz0iMjIgNCAxMiAxNC4wMSA5IDExLjAxIj48L3BvbHlsaW5lPjwvc3ZnPg==) GitHub Action: Ansible - Test + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-ansible------test-blue?logo=github-actions)](https://github.com/marketplace/actions/ansible---test) +[![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-iac)](https://github.com/hoverkraft-tech/ci-github-iac/releases) +[![Stars](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-iac?style=social)](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-iac?style=social) + + + + +## Overview + +Run Ansible test playbooks and collection sanity with native reports, PR comments and artifacts. + +Run Ansible tests with maintained upstream actions and native JUnit reports. + +### Run tests + +Check out the consumer on a fresh Linux GitHub-hosted runner. Supply its existing +requirements file containing an exact `ansible-core==X.Y.Z` pin and either an +explicit `test-playbook` or one `collection`. Use a matrix for multiple targets. +Linting and playbook syntax checks belong in separate consumer jobs. + +Test playbooks are executed by +[dawidd6/action-ansible-playbook](https://github.com/dawidd6/action-ansible-playbook). +The pinned Ansible Core is installed in a temporary virtual environment using +the runner's Python; that Python must be supported by the selected Ansible Core. +Ansible's built-in JUnit callback writes task results, including failed assertions. +Provide test assertions and disposable targets, never a deployment playbook. +`inventory`, `ansible-config`, `collections-path` and `galaxy-requirements` +configure test execution. The action does not provision test environments. + +Collection sanity uses the maintained Ansible community +[ansible-test action](https://github.com/ansible-community/ansible-test-gh-action), +which owns Python setup, test containers and native JUnit generation. +Its Ansible Core Git tag comes from the same requirements pin. Coverage is +disabled for sanity checks. The selected upstream revision pins nested actions +to full commit SHAs. Source stays in the consumer's checked-out collection. +Sanity covers all supported Python versions unless `target-python-version` +selects the consumer's supported runtime. These are sanity results, not code coverage. + +Paths must stay inside the checkout and use letters, digits, dots, underscores, +hyphens and slashes. Each collection needs `galaxy.yml`. Use a fresh checkout per +collection job so native `tests/output/junit` contains only that run's reports. + +### Publish results + +Hoverkraft's `parse-ci-reports` publishes native JUnit as summaries, annotations +and a stable PR comment. Test output and task results appear in these reports; +use synthetic data suitable for the repository's visibility. The collection +action also publishes its own summary. + +Report collection runs after upstream failures, including when the upstream +action does not export its report output. Reports are uploaded with a unique +artifact name and seven-day retention, including after parsing or commenting +errors. Missing reports fail the action. Failed tests remain failures. No custom +test runner, Docker lifecycle or JUnit generator is maintained here. + +Grant `pull-requests: write` for comments. Fork and Dependabot comments are skipped. +Never run untrusted tests through `pull_request_target`. Use distinct report names +for matrix entries. + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------------- | +| **`ansible-requirements`** | Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. | **true** | - | +| **`test-playbook`** | Checkout-relative playbook containing test assertions. This playbook is executed. Supply test-playbook or collection, exclusively. | **false** | - | +| **`collection`** | One checked-out collection directory containing galaxy.yml. Use a matrix for multiple collections. | **false** | - | +| **`target-python-version`** | Optional Python major.minor for collection sanity checks. Empty tests all versions supported by Ansible Core. | **false** | - | +| **`inventory`** | Optional checkout-relative inventory file for test playbook execution. | **false** | - | +| **`ansible-config`** | Optional checkout-relative ansible.cfg file for test playbook execution. | **false** | - | +| **`collections-path`** | Optional checkout-relative first-party collection search directory containing ansible_collections. | **false** | - | +| **`galaxy-requirements`** | Optional checkout-relative Galaxy requirements file for third-party role and collection dependencies. | **false** | - | +| **`report-name`** | Stable summary and PR comment title, distinct for each test step or matrix entry. | **false** | `Ansible test results` | +| **`github-token`** | Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped. | **false** | `${{ github.token }}` | + + + + + + +## Outputs + +| **Output** | **Description** | +| ------------------ | -------------------------------------------------------------------------------------------- | +| **`report-files`** | Newline-separated absolute paths of completed native reports, including after check failure. | +| **`summary`** | Parsed validation summary. | +| **`markdown`** | Markdown used for the PR comment. | +| **`parsed-files`** | JSON array of parsed report paths. | +| **`artifact-id`** | Uploaded report artifact identifier. | + + + + + + + + + + + + +--- + +This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor). + + diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml new file mode 100644 index 0000000..71ba775 --- /dev/null +++ b/actions/ansible/test/action.yml @@ -0,0 +1,199 @@ +# Run Ansible tests with maintained upstream actions and native JUnit reports. +# +# ### Run tests +# +# Check out the consumer on a fresh Linux GitHub-hosted runner. Supply its existing +# requirements file containing an exact `ansible-core==X.Y.Z` pin and either an +# explicit `test-playbook` or one `collection`. Use a matrix for multiple targets. +# Linting and playbook syntax checks belong in separate consumer jobs. +# +# Test playbooks are executed by +# [dawidd6/action-ansible-playbook](https://github.com/dawidd6/action-ansible-playbook). +# The pinned Ansible Core is installed in a temporary virtual environment using +# the runner's Python; that Python must be supported by the selected Ansible Core. +# Ansible's built-in JUnit callback writes task results, including failed assertions. +# Provide test assertions and disposable targets, never a deployment playbook. +# `inventory`, `ansible-config`, `collections-path` and `galaxy-requirements` +# configure test execution. The action does not provision test environments. +# +# Collection sanity uses the maintained Ansible community +# [ansible-test action](https://github.com/ansible-community/ansible-test-gh-action), +# which owns Python setup, test containers and native JUnit generation. +# Its Ansible Core Git tag comes from the same requirements pin. Coverage is +# disabled for sanity checks. The selected upstream revision pins nested actions +# to full commit SHAs. Source stays in the consumer's checked-out collection. +# Sanity covers all supported Python versions unless `target-python-version` +# selects the consumer's supported runtime. These are sanity results, not code coverage. +# +# Paths must stay inside the checkout and use letters, digits, dots, underscores, +# hyphens and slashes. Each collection needs `galaxy.yml`. Use a fresh checkout per +# collection job so native `tests/output/junit` contains only that run's reports. +# +# ### Publish results +# +# Hoverkraft's `parse-ci-reports` publishes native JUnit as summaries, annotations +# and a stable PR comment. Test output and task results appear in these reports; +# use synthetic data suitable for the repository's visibility. The collection +# action also publishes its own summary. +# +# Report collection runs after upstream failures, including when the upstream +# action does not export its report output. Reports are uploaded with a unique +# artifact name and seven-day retention, including after parsing or commenting +# errors. Missing reports fail the action. Failed tests remain failures. No custom +# test runner, Docker lifecycle or JUnit generator is maintained here. +# +# Grant `pull-requests: write` for comments. Fork and Dependabot comments are skipped. +# Never run untrusted tests through `pull_request_target`. Use distinct report names +# for matrix entries. +--- +name: Ansible - Test +description: Run Ansible test playbooks and collection sanity with native reports, PR comments and artifacts. +author: Hoverkraft +branding: + icon: check-circle + color: purple +inputs: + ansible-requirements: + description: Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. + required: true + test-playbook: + description: Checkout-relative playbook containing test assertions. This playbook is executed. Supply test-playbook or collection, exclusively. + default: "" + collection: + description: One checked-out collection directory containing galaxy.yml. Use a matrix for multiple collections. + default: "" + target-python-version: + description: Optional Python major.minor for collection sanity checks. Empty tests all versions supported by Ansible Core. + default: "" + inventory: + description: Optional checkout-relative inventory file for test playbook execution. + default: "" + ansible-config: + description: Optional checkout-relative ansible.cfg file for test playbook execution. + default: "" + collections-path: + description: Optional checkout-relative first-party collection search directory containing ansible_collections. + default: "" + galaxy-requirements: + description: Optional checkout-relative Galaxy requirements file for third-party role and collection dependencies. + default: "" + report-name: + description: Stable summary and PR comment title, distinct for each test step or matrix entry. + default: Ansible test results + github-token: + description: Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped. + default: ${{ github.token }} +outputs: + report-files: + description: Newline-separated absolute paths of completed native reports, including after check failure. + value: ${{ steps.collect.outputs.report-files }} + summary: + description: Parsed validation summary. + value: ${{ steps.parse.outputs.summary }} + markdown: + description: Markdown used for the PR comment. + value: ${{ steps.parse.outputs.markdown }} + parsed-files: + description: JSON array of parsed report paths. + value: ${{ steps.parse.outputs.parsed-files }} + artifact-id: + description: Uploaded report artifact identifier. + value: ${{ steps.upload.outputs.artifact-id }} +runs: + using: composite + steps: + - name: Prepare Ansible inputs + id: inputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ANSIBLE_ACTION_PATH: ${{ github.action_path }} + ANSIBLE_PHASE: prepare + ANSIBLE_REQUIREMENTS: ${{ inputs.ansible-requirements }} + ANSIBLE_TEST_PLAYBOOK: ${{ inputs.test-playbook }} + ANSIBLE_COLLECTION: ${{ inputs.collection }} + ANSIBLE_TARGET_PYTHON_VERSION: ${{ inputs.target-python-version }} + ANSIBLE_INVENTORY_FILE: ${{ inputs.inventory }} + ANSIBLE_CONFIG_FILE: ${{ inputs.ansible-config }} + ANSIBLE_COLLECTIONS_DIRECTORY: ${{ inputs.collections-path }} + ANSIBLE_GALAXY_REQUIREMENTS: ${{ inputs.galaxy-requirements }} + with: + script: require(`${process.env.ANSIBLE_ACTION_PATH}/index.cjs`)({ core }); + + - name: Install the pinned Ansible test runtime + if: steps.inputs.outputs.mode == 'playbook' + shell: bash + env: + REPORT_DIRECTORY: ${{ steps.inputs.outputs.report-directory }} + REQUIREMENTS: ${{ steps.inputs.outputs.constraint }} + run: | + python3 -m venv "$REPORT_DIRECTORY/venv" + "$REPORT_DIRECTORY/venv/bin/pip" install --disable-pip-version-check -r "$REQUIREMENTS" + + - name: Execute Ansible test playbook + if: steps.inputs.outputs.mode == 'playbook' + uses: dawidd6/action-ansible-playbook@126642a1c6ce512da255ef2b41e8ee90f0077474 # v9 + env: + ANSIBLE_CONFIG: ${{ steps.inputs.outputs.config }} + ANSIBLE_INVENTORY: ${{ steps.inputs.outputs.inventory }} + ANSIBLE_COLLECTIONS_PATH: ${{ steps.inputs.outputs.collections-path }} + ANSIBLE_CALLBACKS_ENABLED: ansible.builtin.junit + JUNIT_OUTPUT_DIR: ${{ steps.inputs.outputs.report-directory }} + JUNIT_TASK_RELATIVE_PATH: ${{ github.workspace }} + JUNIT_HIDE_TASK_ARGUMENTS: "true" + with: + playbook: ${{ steps.inputs.outputs.test-playbook }} + requirements: ${{ steps.inputs.outputs.galaxy }} + + - name: Validate collection sanity with ansible-test + id: sanity + if: steps.inputs.outputs.mode == 'sanity' + uses: ansible-community/ansible-test-gh-action@dc5c5bfa02fa4b6633e6175e65e9f8f53088180a # unstable/v1 + with: + testing-type: sanity + ansible-core-version: ${{ steps.inputs.outputs.ansible-core-ref }} + collection-src-directory: ${{ github.workspace }} + collection-root: ${{ steps.inputs.outputs.collection-root }} + target-python-version: ${{ steps.inputs.outputs.target-python-version }} + test-deps: ${{ steps.inputs.outputs.galaxy != '' && format('-r {0}', steps.inputs.outputs.galaxy) || '' }} + coverage: never + + - name: Collect native Ansible reports + id: collect + if: ${{ always() && steps.inputs.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ANSIBLE_ACTION_PATH: ${{ github.action_path }} + ANSIBLE_PHASE: collect + ANSIBLE_REPORT_DIRECTORY: ${{ steps.inputs.outputs.report-directory }} + ANSIBLE_COLLECTION_ROOT: ${{ steps.sanity.outputs.checkout-directory || steps.inputs.outputs.collection }} + with: + script: require(`${process.env.ANSIBLE_ACTION_PATH}/index.cjs`)({ core }); + + - name: Parse Ansible reports + if: ${{ always() && steps.collect.outputs.report-files != '' }} + id: parse + uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1 + with: + report-name: ${{ inputs.report-name }} + report-paths: ${{ steps.collect.outputs.report-files }} + path-mapping: ${{ github.workspace }}:. + output-format: summary,markdown,annotations + + - name: Add Ansible PR comment + if: >- + ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && steps.parse.outputs.markdown != '' }} + uses: hoverkraft-tech/ci-github-common/actions/create-or-update-comment@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1 + with: + title: ${{ inputs.report-name }} + body: ${{ steps.parse.outputs.markdown }} + token: ${{ inputs.github-token }} + + - name: Upload Ansible reports + if: ${{ always() && steps.collect.outputs.report-files != '' }} + id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ steps.inputs.outputs.id }} + path: ${{ steps.collect.outputs.report-files }} + if-no-files-found: error + retention-days: 7 diff --git a/actions/ansible/test/index.cjs b/actions/ansible/test/index.cjs new file mode 100644 index 0000000..0d85b05 --- /dev/null +++ b/actions/ansible/test/index.cjs @@ -0,0 +1,13 @@ +const { AnsibleInputs } = require("./inputs.cjs"); +const { AnsibleReports } = require("./reports.cjs"); + +module.exports = ({ core, env = process.env }) => { + try { + if (env.ANSIBLE_PHASE === "prepare") new AnsibleInputs(core, env).prepare(); + else if (env.ANSIBLE_PHASE === "collect") + new AnsibleReports(core, env).collect(); + else throw new Error("Unknown Ansible action phase."); + } catch (error) { + core.setFailed(error.message); + } +}; diff --git a/actions/ansible/test/inputs.cjs b/actions/ansible/test/inputs.cjs new file mode 100644 index 0000000..42bae39 --- /dev/null +++ b/actions/ansible/test/inputs.cjs @@ -0,0 +1,107 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); + +class AnsibleInputs { + constructor(core, env) { + this.core = core; + this.env = env; + this.workspace = fs.realpathSync(env.GITHUB_WORKSPACE); + } + + checkoutPath(value, directory = false) { + const resolved = fs.realpathSync(path.resolve(this.workspace, value)); + const relative = path.relative(this.workspace, resolved); + if ( + relative.startsWith("..") || + path.isAbsolute(relative) || + !/^[\w./-]+$/.test(resolved) + ) { + throw new Error( + "Inputs must stay inside the checkout and use paths without spaces or shell metacharacters.", + ); + } + const stat = fs.statSync(resolved); + if (directory ? !stat.isDirectory() : !stat.isFile() || stat.size === 0) { + throw new Error( + `${value} must be a ${directory ? "directory" : "nonempty file"}.`, + ); + } + return resolved; + } + + prepare() { + const playbook = this.env.ANSIBLE_TEST_PLAYBOOK?.trim(); + const collection = this.env.ANSIBLE_COLLECTION?.trim(); + const targetPython = this.env.ANSIBLE_TARGET_PYTHON_VERSION?.trim() || ""; + if (targetPython && (!collection || !/^3\.[0-9]+$/.test(targetPython))) { + throw new Error( + "target-python-version requires a collection and a Python 3 major.minor version.", + ); + } + this.core.setOutput("target-python-version", targetPython); + if (Boolean(playbook) === Boolean(collection)) { + throw new Error( + "Provide test-playbook or one collection, exclusively. Use a matrix for multiple collections.", + ); + } + const requirements = this.checkoutPath(this.env.ANSIBLE_REQUIREMENTS); + const pins = [ + ...fs + .readFileSync(requirements, "utf8") + .matchAll(/^ansible-core==([0-9]+\.[0-9]+\.[0-9]+)\s*(?:#.*)?$/gm), + ]; + if (pins.length !== 1) + throw new Error( + "ansible-requirements must contain one exact stable ansible-core==X.Y.Z pin.", + ); + this.core.setOutput( + "test-playbook", + playbook ? this.checkoutPath(playbook) : "", + ); + const collectionRoot = collection + ? this.checkoutPath(collection, true) + : ""; + if (collectionRoot) + this.checkoutPath(path.join(collectionRoot, "galaxy.yml")); + const optionalFiles = { + inventory: this.env.ANSIBLE_INVENTORY_FILE, + config: this.env.ANSIBLE_CONFIG_FILE, + galaxy: this.env.ANSIBLE_GALAXY_REQUIREMENTS, + }; + for (const [name, value] of Object.entries(optionalFiles)) { + this.core.setOutput(name, value ? this.checkoutPath(value) : ""); + } + const collectionsPath = this.env.ANSIBLE_COLLECTIONS_DIRECTORY + ? this.checkoutPath(this.env.ANSIBLE_COLLECTIONS_DIRECTORY, true) + : ""; + this.core.setOutput( + "collections-path", + [ + collectionsPath, + path.join(os.homedir(), ".ansible/collections"), + "/usr/share/ansible/collections", + ] + .filter(Boolean) + .join(":"), + ); + const reports = fs.mkdtempSync( + path.join(this.env.RUNNER_TEMP, "ansible-reports-"), + ); + if (playbook) this.core.addPath(path.join(reports, "venv/bin")); + const constraint = path.join(reports, "constraints.txt"); + fs.writeFileSync(constraint, `ansible-core==${pins[0][1]}\n`); + this.core.setOutput("constraint", constraint); + this.core.setOutput("ansible-core-ref", `v${pins[0][1]}`); + this.core.setOutput("mode", collection ? "sanity" : "playbook"); + this.core.setOutput("collection", collectionRoot); + this.core.setOutput( + "collection-root", + collectionRoot ? path.relative(this.workspace, collectionRoot) : "", + ); + this.core.setOutput("report-directory", reports); + this.core.setOutput("id", path.basename(reports)); + } +} + +module.exports = { AnsibleInputs }; diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs new file mode 100644 index 0000000..a040177 --- /dev/null +++ b/actions/ansible/test/reports.cjs @@ -0,0 +1,61 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +class AnsibleReports { + constructor(core, env) { + this.core = core; + this.env = env; + } + + collect() { + const destination = fs.realpathSync(this.env.ANSIBLE_REPORT_DIRECTORY); + if ( + path.dirname(destination) !== fs.realpathSync(this.env.RUNNER_TEMP) || + !path.basename(destination).startsWith("ansible-reports-") + ) { + throw new Error("Invalid action report directory."); + } + const files = []; + if (this.env.ANSIBLE_COLLECTION_ROOT) { + const source = path.resolve( + this.env.GITHUB_WORKSPACE, + this.env.ANSIBLE_COLLECTION_ROOT, + "tests/output/junit", + ); + const relative = path.relative( + fs.realpathSync(this.env.GITHUB_WORKSPACE), + source, + ); + if (relative.startsWith("..") || path.isAbsolute(relative)) + throw new Error("Collection reports must remain inside the checkout."); + if (fs.existsSync(source)) { + if (fs.realpathSync(source) !== source) + throw new Error("JUnit report directories cannot be symbolic links."); + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".xml")) continue; + const target = path.join(destination, entry.name); + fs.copyFileSync( + path.join(source, entry.name), + target, + fs.constants.COPYFILE_EXCL, + ); + files.push(target); + } + } + } else { + for (const entry of fs.readdirSync(destination, { + withFileTypes: true, + })) { + if (entry.isFile() && entry.name.endsWith(".xml")) + files.push(path.join(destination, entry.name)); + } + } + if (!files.length) + throw new Error( + "The upstream Ansible action produced no reports. See its execution log.", + ); + this.core.setOutput("report-files", files.join("\n")); + } +} + +module.exports = { AnsibleReports }; diff --git a/actions/vm/create/README.md b/actions/vm/create/README.md index 053f64c..f77cf9c 100644 --- a/actions/vm/create/README.md +++ b/actions/vm/create/README.md @@ -87,7 +87,7 @@ application-specific resources such as remote browser devices. ## Usage ```yaml -- uses: hoverkraft-tech/ci-github-iac/actions/vm/create@2b6495c4fd7dad081899d21186e4f554f3ca8d9c # feat/phase-1-bootstrap +- uses: hoverkraft-tech/ci-github-iac/actions/vm/create@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0 with: # Path to a Lima YAML file in the checkout. Plain mode is enforced. # This input is required. diff --git a/actions/vm/destroy/README.md b/actions/vm/destroy/README.md index 6c30a40..5deb3b3 100644 --- a/actions/vm/destroy/README.md +++ b/actions/vm/destroy/README.md @@ -57,7 +57,7 @@ and cloud resource cleanup are outside scope. ## Usage ```yaml -- uses: hoverkraft-tech/ci-github-iac/actions/vm/destroy@2b6495c4fd7dad081899d21186e4f554f3ca8d9c # feat/phase-1-bootstrap +- uses: hoverkraft-tech/ci-github-iac/actions/vm/destroy@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0 with: # Private runtime directory returned by vm/create; repeated deletion is safe. # This input is required. diff --git a/actions/vm/test/README.md b/actions/vm/test/README.md index e174ec4..c14188e 100644 --- a/actions/vm/test/README.md +++ b/actions/vm/test/README.md @@ -101,7 +101,7 @@ No extra reporting steps are needed in the workflow. ## Usage ```yaml -- uses: hoverkraft-tech/ci-github-iac/actions/vm/test@2b6495c4fd7dad081899d21186e4f554f3ca8d9c # feat/phase-1-bootstrap +- uses: hoverkraft-tech/ci-github-iac/actions/vm/test@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0 with: # Private runtime directory returned by vm/create. # This input is required. diff --git a/docs/README.md b/docs/README.md index 6a88011..ee45859 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,4 @@ # Documentation -- [Create a VM](../actions/vm/create/README.md) -- [Run tests and publish reports](../actions/vm/test/README.md) -- [Destroy a VM](../actions/vm/destroy/README.md) - [Contributing, validation and releases](../CONTRIBUTING.md) - [ADR 0001: Focused VM actions](development/adr/0001-vm-actions.md) diff --git a/tests/ansible.test.cjs b/tests/ansible.test.cjs new file mode 100644 index 0000000..fb98a47 --- /dev/null +++ b/tests/ansible.test.cjs @@ -0,0 +1,167 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const entrypoint = require("../actions/ansible/test/index.cjs"); + +function fixture(t) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "ansible-composition-test-"), + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const workspace = path.join(root, "checkout"); + const temp = path.join(root, "temp"); + fs.mkdirSync(workspace); + fs.mkdirSync(temp); + fs.writeFileSync( + path.join(workspace, "requirements.txt"), + "ansible-core==2.99.1\n", + ); + fs.writeFileSync(path.join(workspace, "playbook.yml"), "---\n"); + const outputs = {}; + const failures = []; + const env = { + GITHUB_WORKSPACE: workspace, + RUNNER_TEMP: temp, + ANSIBLE_PHASE: "prepare", + ANSIBLE_REQUIREMENTS: "requirements.txt", + ANSIBLE_TEST_PLAYBOOK: "playbook.yml", + }; + const core = { + addPath: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + setFailed: (value) => failures.push(value), + }; + return { root, workspace, temp, outputs, failures, env, core }; +} + +test("playbook tests keep the consumer's exact Ansible pin and explicit test target", (t) => { + const f = fixture(t); + entrypoint(f); + assert.deepEqual(f.failures, []); + assert.equal(f.outputs.mode, "playbook"); + assert.equal(f.outputs["target-python-version"], ""); + assert.equal(f.outputs["ansible-core-ref"], "v2.99.1"); + assert.equal( + fs.readFileSync(f.outputs.constraint, "utf8"), + "ansible-core==2.99.1\n", + ); + assert.equal(f.outputs["test-playbook"], `${f.workspace}/playbook.yml`); + assert.equal(path.dirname(f.outputs["report-directory"]), f.temp); +}); + +test("collection inputs select a single checked-out source with Galaxy metadata", (t) => { + const f = fixture(t); + const collection = "ansible_collections/example/smoke"; + fs.mkdirSync(path.join(f.workspace, collection), { recursive: true }); + fs.writeFileSync( + path.join(f.workspace, collection, "galaxy.yml"), + "namespace: example\nname: smoke\n", + ); + entrypoint({ + ...f, + env: { + ...f.env, + ANSIBLE_TEST_PLAYBOOK: "", + ANSIBLE_COLLECTION: collection, + ANSIBLE_TARGET_PYTHON_VERSION: "3.99", + }, + }); + assert.deepEqual(f.failures, []); + assert.equal(f.outputs.mode, "sanity"); + assert.equal(f.outputs["collection-root"], collection); + assert.equal(f.outputs["target-python-version"], "3.99"); +}); + +test("ambiguous selections, missing targets, non-pinned versions and escaping paths fail", (t) => { + const f = fixture(t); + fs.symlinkSync(f.temp, path.join(f.workspace, "outside")); + fs.writeFileSync( + path.join(f.workspace, "floating.txt"), + "ansible-core>=2.99\n", + ); + for (const inputs of [ + { ANSIBLE_TEST_PLAYBOOK: "" }, + { ANSIBLE_COLLECTION: "." }, + { ANSIBLE_TEST_PLAYBOOK: "missing.yml" }, + { ANSIBLE_TEST_PLAYBOOK: "outside" }, + { ANSIBLE_TEST_PLAYBOOK: "../temp" }, + { ANSIBLE_REQUIREMENTS: "floating.txt" }, + { ANSIBLE_TARGET_PYTHON_VERSION: "3.99" }, + { + ANSIBLE_TEST_PLAYBOOK: "", + ANSIBLE_COLLECTION: ".", + ANSIBLE_TARGET_PYTHON_VERSION: "3.99; false", + }, + ]) { + f.failures.length = 0; + entrypoint({ ...f, env: { ...f.env, ...inputs } }); + assert.equal(f.failures.length, 1, JSON.stringify(inputs)); + } +}); + +test("native reports are retained verbatim even when the upstream action has failed", (t) => { + const f = fixture(t); + entrypoint(f); + const source = path.join(f.workspace, "collection/tests/output/junit"); + fs.mkdirSync(source, { recursive: true }); + const junit = + ''; + fs.writeFileSync(path.join(source, "sanity.xml"), junit); + entrypoint({ + ...f, + env: { + ...f.env, + ANSIBLE_PHASE: "collect", + ANSIBLE_COLLECTION_ROOT: "collection", + ANSIBLE_REPORT_DIRECTORY: f.outputs["report-directory"], + }, + }); + assert.deepEqual(f.failures, []); + assert.equal(fs.readFileSync(f.outputs["report-files"], "utf8"), junit); +}); + +test("missing reports and redirected report directories cannot be reported as success", (t) => { + const f = fixture(t); + entrypoint(f); + const env = { + ...f.env, + ANSIBLE_PHASE: "collect", + ANSIBLE_REPORT_DIRECTORY: f.outputs["report-directory"], + }; + entrypoint({ ...f, env }); + assert.match(f.failures.pop(), /no reports/); + fs.mkdirSync(path.join(f.workspace, "collection/tests/output"), { + recursive: true, + }); + fs.symlinkSync( + f.temp, + path.join(f.workspace, "collection/tests/output/junit"), + ); + entrypoint({ ...f, env: { ...env, ANSIBLE_COLLECTION_ROOT: "collection" } }); + assert.match(f.failures.pop(), /symbolic links/); + assert.equal(f.outputs["report-files"], undefined); +}); + +test("test playbooks publish native JUnit without exposing the installed runtime", (t) => { + const f = fixture(t); + entrypoint(f); + const reports = f.outputs["report-directory"]; + fs.mkdirSync(path.join(reports, "venv")); + const junit = + ''; + fs.writeFileSync(path.join(reports, "playbook.xml"), junit); + entrypoint({ + ...f, + env: { + ...f.env, + ANSIBLE_PHASE: "collect", + ANSIBLE_REPORT_DIRECTORY: reports, + }, + }); + assert.deepEqual(f.failures, []); + assert.equal(f.outputs["report-files"], path.join(reports, "playbook.xml")); +}); diff --git a/tests/fixture/ansible/ansible_collections/example/smoke/README.md b/tests/fixture/ansible/ansible_collections/example/smoke/README.md new file mode 100644 index 0000000..7b1a3be --- /dev/null +++ b/tests/fixture/ansible/ansible_collections/example/smoke/README.md @@ -0,0 +1,3 @@ +# Synthetic collection + +A role used by the [Ansible action tests](../../../../../../.github/workflows/__test-actions-ansible.yml). diff --git a/tests/fixture/ansible/ansible_collections/example/smoke/galaxy.yml b/tests/fixture/ansible/ansible_collections/example/smoke/galaxy.yml new file mode 100644 index 0000000..2fd0ad3 --- /dev/null +++ b/tests/fixture/ansible/ansible_collections/example/smoke/galaxy.yml @@ -0,0 +1,10 @@ +--- +namespace: example +name: smoke +version: 1.0.0 +readme: README.md +authors: [Hoverkraft] +description: Synthetic collection for testing the shared action. +license: [MIT] +tags: [tools] +repository: https://github.com/hoverkraft-tech/ci-github-iac diff --git a/tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml b/tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml new file mode 100644 index 0000000..f9c92aa --- /dev/null +++ b/tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml @@ -0,0 +1,4 @@ +--- +- name: Provide a synthetic role for collection sanity checks + ansible.builtin.debug: + msg: Synthetic collection fixture diff --git a/tests/fixture/ansible/requirements.txt b/tests/fixture/ansible/requirements.txt new file mode 100644 index 0000000..a755966 --- /dev/null +++ b/tests/fixture/ansible/requirements.txt @@ -0,0 +1 @@ +ansible-core==2.21.5 diff --git a/tests/fixture/ansible/valid.yml b/tests/fixture/ansible/valid.yml new file mode 100644 index 0000000..79716fe --- /dev/null +++ b/tests/fixture/ansible/valid.yml @@ -0,0 +1,11 @@ +--- +- name: Exercise a synthetic Ansible test assertion + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Assert the selected fixture succeeds + ansible.builtin.assert: + that: fixture_result == 'success' + vars: + fixture_result: "{{ lookup('ansible.builtin.env', 'FIXTURE_RESULT') }}"