diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 65f1de4..ae49290 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,8 +15,24 @@ updates: groups: docker-dependencies: patterns: ["*"] + - package-ecosystem: npm + directories: + - /actions/ansible/test + - /actions/ansible/test-molecule + - /actions/vm/create + - /actions/vm/test + - /actions/vm/destroy + cooldown: + default-days: 7 + schedule: + interval: weekly + groups: + github-script-test-helpers: + patterns: ["@actions/*"] - package-ecosystem: pip - directory: /tests/fixture/ansible + directories: + - /tests/fixture/ansible + - /actions/ansible/test-molecule cooldown: default-days: 7 schedule: diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json index e7d842b..e79a8ad 100644 --- a/.github/linters/.jscpd.json +++ b/.github/linters/.jscpd.json @@ -5,7 +5,9 @@ "**/__snapshots__/**", "**/node_modules/**", "actions/**/README.md", - ".github/social-preview.svg" + ".github/workflows/*.md", + ".github/social-preview.svg", + "**/package-lock.json" ], "absolute": true } diff --git a/.github/renovate.json b/.github/renovate.json index f08a6ab..92f6af0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,7 +1,13 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended"], - "enabledManagers": ["custom.regex"], + "enabledManagers": ["custom.regex", "nodenv"], + "packageRules": [ + { + "matchManagers": ["nodenv"], + "groupName": "Node.js runtimes" + } + ], "customManagers": [ { "customType": "regex", @@ -15,9 +21,23 @@ "customType": "regex", "managerFilePatterns": ["/^Makefile$/"], "matchStrings": [ - "--package=(?@ci-dokumentor/cli)@(?\\S+)" + "(?ghcr\\.io/hoverkraft-tech/ci-dokumentor/cli):(?\\S+)" + ], + "datasourceTemplate": "docker", + "versioningTemplate": "docker" + }, + { + "customType": "regex", + "description": "Update the Goss release and Linux x86-64 archive checksum together", + "managerFilePatterns": ["/^actions/vm/test/goss\\.json$/"], + "matchStrings": [ + "\"version\":\\s*\"(?[^\"]+)\",\\s*\"sha256\":\\s*\"(?[a-f0-9]{64})\"" ], - "datasourceTemplate": "npm" + "depNameTemplate": "goss-org/goss", + "datasourceTemplate": "github-release-attachments", + "versioningTemplate": "semver", + "currentValueTemplate": "v{{{version}}}", + "autoReplaceStringTemplate": "\"version\": \"{{{replace '^v' '' newValue}}}\",\n\t\"sha256\": \"{{{newDigest}}}\"" } ] } diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml index 7100faa..e79089f 100644 --- a/.github/workflows/__shared-ci.yml +++ b/.github/workflows/__shared-ci.yml @@ -7,17 +7,6 @@ on: permissions: {} jobs: - tests: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Run unit tests - run: make test linter: uses: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1 with: @@ -31,26 +20,29 @@ jobs: pull-requests: write security-events: write statuses: write - vm: + test-action-vm-create: + permissions: + contents: read + uses: $/.github/workflows/__test-actions-vm-create.yml + test-action-vm-test: + permissions: + actions: read + contents: read + pull-requests: write + uses: $/.github/workflows/__test-actions-vm-test.yml + test-action-vm-destroy: + permissions: + contents: read + uses: $/.github/workflows/__test-actions-vm-destroy.yml + test-action-ansible-test: permissions: + actions: read contents: read pull-requests: write - uses: $/.github/workflows/__test-actions-vm.yml - ansible: + uses: $/.github/workflows/__test-actions-ansible-test.yml + test-action-ansible-test-molecule: permissions: + actions: read contents: read pull-requests: write - uses: $/.github/workflows/__test-actions-ansible.yml - checks: - if: ${{ always() }} - needs: [tests, linter, vm, ansible] - runs-on: ubuntu-latest - steps: - - name: Require all checks - env: - RESULTS: ${{ toJSON(needs) }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const results = Object.values(JSON.parse(process.env.RESULTS)); - if (!results.every(job => job.result === 'success')) core.setFailed('A required check did not succeed'); + uses: $/.github/workflows/__test-actions-ansible-test-molecule.yml diff --git a/.github/workflows/__test-actions-ansible-test-molecule.yml b/.github/workflows/__test-actions-ansible-test-molecule.yml new file mode 100644 index 0000000..0d33aa1 --- /dev/null +++ b/.github/workflows/__test-actions-ansible-test-molecule.yml @@ -0,0 +1,198 @@ +--- +name: Internal - Test ansible/test-molecule +on: + workflow_call: +permissions: {} +jobs: + unit-tests: + # Standard runner and checkout setup; each action owns its test command below. + # jscpd:ignore-start + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Arrange - Checkout action sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # jscpd:ignore-end + - name: Act - Run ansible/test-molecule unit tests + uses: hoverkraft-tech/ci-github-nodejs/actions/test@97f64bc080b78daca8fb3ece49de448aaac83429 # 1.1.0 + with: + working-directory: actions/ansible/test-molecule + command: test:ci + coverage: "" + local: + runs-on: ubuntu-latest + timeout-minutes: 12 + permissions: + actions: read + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + result: [success, failure, timeout] + env: + MOLECULE_TEST_RESULT: ${{ matrix.result }} + steps: + - name: Arrange - Checkout native scenario + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Act - Test a scenario without a VM + id: scenario + continue-on-error: true + uses: $/actions/ansible/test-molecule + env: + MOLECULE_TEST_DIRECTORY: ${{ runner.temp }}/molecule-local + with: + molecule-scenario: tests/fixture/molecule/local + ansible-requirements: tests/fixture/ansible/requirements.txt + test-timeout-minutes: ${{ matrix.result == 'timeout' && '1' || '5' }} + report-name: Molecule local scenario - ${{ matrix.result }} + - name: Assert - Require native reports and scenario cleanup + if: ${{ always() }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MOLECULE_TEST_DIRECTORY: ${{ runner.temp }}/molecule-local + SCENARIO_OUTCOME: ${{ steps.scenario.outcome }} + with: + script: | + const fs = require('node:fs'); + const assert = require('node:assert/strict'); + const { PublishedReports } = require('./tests/helpers/published-reports.cjs'); + const published = new PublishedReports(github, context); + const expected = process.env.MOLECULE_TEST_RESULT === 'success' ? 'success' : 'failure'; + assert.equal(process.env.SCENARIO_OUTCOME, expected); + assert.equal(fs.existsSync(process.env.MOLECULE_TEST_DIRECTORY), false); + const [report] = await published.read('molecule-reports-', 1); + assert.ok(report.files.some(file => file.endsWith('/scenario.xml'))); + assert.equal(/<(?:failure|error)\b/.test(report.text), expected === 'failure'); + vm: + runs-on: ubuntu-latest + timeout-minutes: 25 + # Keep fixture setup visible in each action's dedicated workflow. + # jscpd:ignore-start + permissions: + actions: read + contents: read + pull-requests: write + steps: + - name: Arrange - Checkout fixture + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Arrange - Prepare QEMU inputs + id: fixture + uses: $/tests/fixture/prepare-vm + with: + backend: qemu + - name: Arrange - Create direct QEMU guest + id: vm + uses: $/actions/vm/create + with: + backend: qemu + vm-config: ${{ steps.fixture.outputs.vm-config }} + startup-timeout-minutes: 5 + # jscpd:ignore-end + - name: Arrange - Expose the fixture VM to native scenario inventories + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + FIXTURE_VM_NAME: ${{ steps.vm.outputs.name }} + FIXTURE_VM_SSH_CONFIG: ${{ steps.vm.outputs.ssh-config }} + with: + script: | + core.exportVariable('VM_NAME', process.env.FIXTURE_VM_NAME); + core.exportVariable('VM_SSH_CONFIG', process.env.FIXTURE_VM_SSH_CONFIG); + - name: Act - Test Molecule success + id: molecule-success + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + continue-on-error: true + uses: $/actions/ansible/test-molecule + env: + IAC_FORCE_FAILURE: "false" + IAC_FORCE_CHANGE: "false" + with: + molecule-scenario: tests/fixture/molecule/smoke + ansible-requirements: tests/fixture/ansible/requirements.txt + report-name: Molecule - success + + - name: Assert - Check successful Molecule execution + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SUCCESS: ${{ steps.molecule-success.outcome }} + with: + script: | + const assert = require('node:assert/strict'); + const { PublishedReports } = require('./tests/helpers/published-reports.cjs'); + const published = new PublishedReports(github, context); + assert.equal(process.env.SUCCESS, 'success'); + await published.read('molecule-reports-', 1); + + - name: Act - Test Molecule assertion-failure + id: molecule-assertion-failure + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + continue-on-error: true + uses: $/actions/ansible/test-molecule + env: + IAC_FORCE_FAILURE: "true" + IAC_FORCE_CHANGE: "false" + with: + molecule-scenario: tests/fixture/molecule/smoke + ansible-requirements: tests/fixture/ansible/requirements.txt + report-name: Molecule - assertion-failure + + - name: Assert - Check Molecule assertion failure and reports + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ASSERTION_FAILURE: ${{ steps.molecule-assertion-failure.outcome }} + with: + script: | + const assert = require('node:assert/strict'); + const { PublishedReports } = require('./tests/helpers/published-reports.cjs'); + const published = new PublishedReports(github, context); + assert.equal(process.env.ASSERTION_FAILURE, 'failure'); + await published.read('molecule-reports-', 2); + + - name: Act - Test Molecule idempotence-failure + id: molecule-idempotence-failure + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + continue-on-error: true + uses: $/actions/ansible/test-molecule + env: + IAC_FORCE_FAILURE: "false" + IAC_FORCE_CHANGE: "true" + with: + molecule-scenario: tests/fixture/molecule/smoke + ansible-requirements: tests/fixture/ansible/requirements.txt + report-name: Molecule - idempotence-failure + + - name: Assert - Check Molecule idempotence failure and reports + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + IDEMPOTENCE_FAILURE: ${{ steps.molecule-idempotence-failure.outcome }} + with: + script: | + const assert = require('node:assert/strict'); + const { PublishedReports } = require('./tests/helpers/published-reports.cjs'); + const published = new PublishedReports(github, context); + assert.equal(process.env.IDEMPOTENCE_FAILURE, 'failure'); + await published.read('molecule-reports-', 3); + + # Fixture teardown is intentionally repeated; action assertions remain above. + # jscpd:ignore-start + - name: Cleanup - Destroy direct QEMU guest + id: destroy + if: ${{ always() && steps.vm.outputs.vm != '' }} + uses: $/actions/vm/destroy + with: + vm: ${{ steps.vm.outputs.vm }} + - name: Cleanup - Remove private fixture inputs + if: ${{ always() }} + run: rm -rf .cache/qemu-fixture + # jscpd:ignore-end diff --git a/.github/workflows/__test-actions-ansible.yml b/.github/workflows/__test-actions-ansible-test.yml similarity index 52% rename from .github/workflows/__test-actions-ansible.yml rename to .github/workflows/__test-actions-ansible-test.yml index 4798836..20892e1 100644 --- a/.github/workflows/__test-actions-ansible.yml +++ b/.github/workflows/__test-actions-ansible-test.yml @@ -1,13 +1,33 @@ --- -name: Internal - Test Ansible action +name: Internal - Test ansible/test on: workflow_call: permissions: {} jobs: + unit-tests: + # Standard runner and checkout setup; each action owns its test command below. + # jscpd:ignore-start + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Arrange - Checkout action sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # jscpd:ignore-end + - name: Act - Run ansible/test unit tests + uses: hoverkraft-tech/ci-github-nodejs/actions/test@97f64bc080b78daca8fb3ece49de448aaac83429 # 1.1.0 + with: + working-directory: actions/ansible/test + command: test:ci + coverage: "" scenarios: runs-on: ubuntu-latest timeout-minutes: 30 permissions: + actions: read contents: read pull-requests: write strategy: @@ -16,11 +36,11 @@ jobs: mode: [playbook, sanity] result: [success, failure] steps: - - name: Checkout fixture + - name: Arrange - Checkout fixture uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Prepare selected scenario without a consumer Makefile + - name: Arrange - Prepare selected scenario without a consumer Makefile env: MODE: ${{ matrix.mode }} RESULT: ${{ matrix.result }} @@ -29,7 +49,7 @@ jobs: 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 + - name: Act - Test Ansible with upstream actions id: test continue-on-error: true uses: $/actions/ansible/test @@ -37,28 +57,22 @@ jobs: FIXTURE_RESULT: ${{ matrix.result }} with: ansible-requirements: tests/fixture/ansible/requirements.txt + ansible-config: ${{ matrix.mode == 'playbook' && 'tests/fixture/ansible/ansible.cfg' || '' }} 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 + - name: Assert - 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'); + const { PublishedReports } = require('./tests/helpers/published-reports.cjs'); + const published = new PublishedReports(github, context); 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'); + const [report] = await published.read('ansible-reports-', 1); + assert.ok(report.files.every(file => file.endsWith('.xml'))); + assert.equal(/<(?:failure|error)\b/.test(report.text), process.env.EXPECTED === 'failure'); diff --git a/.github/workflows/__test-actions-vm-create.yml b/.github/workflows/__test-actions-vm-create.yml new file mode 100644 index 0000000..ea5e343 --- /dev/null +++ b/.github/workflows/__test-actions-vm-create.yml @@ -0,0 +1,147 @@ +--- +name: Internal - Test vm/create +on: + workflow_call: +permissions: {} +jobs: + unit-tests: + # Standard runner and checkout setup; each action owns its test command below. + # jscpd:ignore-start + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Arrange - Checkout action sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # jscpd:ignore-end + - name: Act - Run vm/create unit tests + uses: hoverkraft-tech/ci-github-nodejs/actions/test@97f64bc080b78daca8fb3ece49de448aaac83429 # 1.1.0 + with: + working-directory: actions/vm/create + command: test:ci + coverage: "" + startup: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + backend: [lima, qemu] + steps: + - name: Arrange - Checkout creation fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Arrange - Prepare startup configurations + id: fixture + uses: $/tests/fixture/prepare-vm + with: + backend: ${{ matrix.backend }} + - name: Act - Reject empty VM configuration + id: empty-config + continue-on-error: true + uses: $/actions/vm/create + with: + backend: ${{ matrix.backend }} + vm-config: "" + - name: Act - Reject invalid startup timeout + id: invalid-timeout + continue-on-error: true + uses: $/actions/vm/create + with: + backend: ${{ matrix.backend }} + vm-config: ${{ steps.fixture.outputs.vm-config }} + startup-timeout-minutes: 41 + - name: Assert - Check invalid inputs publish no runtime + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + EMPTY_CONFIG: ${{ steps.empty-config.outcome }} + EMPTY_CONFIG_VM: ${{ steps.empty-config.outputs.vm }} + INVALID_TIMEOUT: ${{ steps.invalid-timeout.outcome }} + INVALID_TIMEOUT_VM: ${{ steps.invalid-timeout.outputs.vm }} + with: + script: | + const assert = require('node:assert/strict'); + assert.equal(process.env.EMPTY_CONFIG, 'failure'); + assert.equal(process.env.INVALID_TIMEOUT, 'failure'); + assert.equal(process.env.EMPTY_CONFIG_VM, ''); + assert.equal(process.env.INVALID_TIMEOUT_VM, ''); + - name: Act - Start the configured VM + id: create + uses: $/actions/vm/create + with: + backend: ${{ matrix.backend }} + vm-config: ${{ steps.fixture.outputs.vm-config }} + startup-timeout-minutes: 5 + - name: Assert - Check usable creation outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + VM_DIRECTORY: ${{ steps.create.outputs.vm }} + VM_NAME: ${{ steps.create.outputs.name }} + VM_SSH_CONFIG: ${{ steps.create.outputs.ssh-config }} + VM_BACKEND: ${{ matrix.backend }} + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const path = require('node:path'); + const env = process.env; + assert.ok(env.VM_NAME); + assert.equal(path.dirname(env.VM_DIRECTORY), env.VM_BACKEND === 'qemu' ? '/var/tmp' : '/tmp'); + assert.ok(Buffer.byteLength(path.join(env.VM_DIRECTORY, env.VM_NAME, 'ssh.sock.1234567890123456')) < 108); + assert.equal(fs.statSync(env.VM_DIRECTORY).mode & 0o777, 0o700); + assert.equal(fs.statSync(env.VM_SSH_CONFIG).mode & 0o777, 0o600); + assert.equal(fs.existsSync('Makefile'), false); + const system = await exec.getExecOutput('timeout', ['--kill-after=5s', '30s', 'ssh', '-F', env.VM_SSH_CONFIG, env.VM_NAME, 'uname', '-s']); + assert.equal(system.stdout.trim(), 'Linux'); + - name: Cleanup - Tear down the successful startup fixture + if: ${{ always() && steps.create.outputs.vm != '' }} + uses: $/actions/vm/destroy + with: + vm: ${{ steps.create.outputs.vm }} + - name: Act - Exercise startup rollback + id: invalid + continue-on-error: true + uses: $/actions/vm/create + with: + backend: ${{ matrix.backend }} + vm-config: ${{ steps.fixture.outputs.invalid-config }} + startup-timeout-minutes: 1 + - name: Assert - Check rollback before fallback cleanup + if: ${{ always() && steps.fixture.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + STARTUP_OUTCOME: ${{ steps.invalid.outcome }} + RECOVERY_DIRECTORY: ${{ steps.invalid.outputs.vm }} + FIRST_DIRECTORY: ${{ steps.create.outputs.vm }} + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + assert.equal(process.env.STARTUP_OUTCOME, 'failure'); + assert.ok(process.env.RECOVERY_DIRECTORY); + assert.notEqual(process.env.RECOVERY_DIRECTORY, process.env.FIRST_DIRECTORY); + assert.equal(fs.existsSync(process.env.RECOVERY_DIRECTORY), false); + - name: Cleanup - Clean up any failed-startup resources + if: ${{ always() && steps.invalid.outputs.vm != '' }} + uses: $/actions/vm/destroy + with: + vm: ${{ steps.invalid.outputs.vm }} + - name: Cleanup - Remove any runtime from empty configuration + if: ${{ always() && steps.empty-config.outputs.vm != '' }} + uses: $/actions/vm/destroy + with: + vm: ${{ steps.empty-config.outputs.vm }} + - name: Cleanup - Remove any runtime from invalid timeout + if: ${{ always() && steps.invalid-timeout.outputs.vm != '' }} + uses: $/actions/vm/destroy + with: + vm: ${{ steps.invalid-timeout.outputs.vm }} + - name: Cleanup - Remove creation fixture inputs + if: ${{ always() }} + run: rm -rf .cache/qemu-fixture diff --git a/.github/workflows/__test-actions-vm-destroy.yml b/.github/workflows/__test-actions-vm-destroy.yml new file mode 100644 index 0000000..c3fa12d --- /dev/null +++ b/.github/workflows/__test-actions-vm-destroy.yml @@ -0,0 +1,115 @@ +--- +name: Internal - Test vm/destroy +on: + workflow_call: +permissions: {} +jobs: + unit-tests: + # Standard runner and checkout setup; each action owns its test command below. + # jscpd:ignore-start + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Arrange - Checkout action sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # jscpd:ignore-end + - name: Act - Run vm/destroy unit tests + uses: hoverkraft-tech/ci-github-nodejs/actions/test@97f64bc080b78daca8fb3ece49de448aaac83429 # 1.1.0 + with: + working-directory: actions/vm/destroy + command: test:ci + coverage: "" + deletion: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + backend: [lima, qemu] + steps: + - name: Arrange - Checkout destruction fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Arrange - Prepare a disposable deletion target + id: fixture + uses: $/tests/fixture/prepare-vm + with: + backend: ${{ matrix.backend }} + - name: Arrange - Create the VM to delete + id: vm + uses: $/actions/vm/create + with: + backend: ${{ matrix.backend }} + vm-config: ${{ steps.fixture.outputs.vm-config }} + startup-timeout-minutes: 5 + - name: Arrange - Prepare unrelated synthetic data + env: + UNMANAGED: ${{ runner.temp }}/unmanaged-vm + run: | + mkdir "$UNMANAGED" + printf 'preserve this fixture\n' > "$UNMANAGED/marker" + - name: Act - Reject an unmanaged directory + id: unmanaged + continue-on-error: true + uses: $/actions/vm/destroy + with: + vm: ${{ runner.temp }}/unmanaged-vm + - name: Assert - Check unmanaged data is preserved + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REJECTED: ${{ steps.unmanaged.outcome }} + UNMANAGED: ${{ runner.temp }}/unmanaged-vm + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + assert.equal(process.env.REJECTED, 'failure'); + assert.equal(fs.readFileSync(`${process.env.UNMANAGED}/marker`, 'utf8'), 'preserve this fixture\n'); + + - name: Act - Delete the running VM + id: destroy + if: ${{ always() && steps.vm.outputs.vm != '' }} + continue-on-error: true + uses: $/actions/vm/destroy + with: + vm: ${{ steps.vm.outputs.vm }} + - name: Assert - Verify deletion before retrying + if: ${{ always() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DELETION_OUTCOME: ${{ steps.destroy.outcome }} + VM_DIRECTORY: ${{ steps.vm.outputs.vm }} + with: + script: | + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + assert.equal(process.env.DELETION_OUTCOME, 'success'); + assert.ok(process.env.VM_DIRECTORY); + assert.equal(fs.existsSync(process.env.VM_DIRECTORY), false); + - name: Act - Repeat destruction of the same reference + id: repeated + if: ${{ always() && steps.vm.outputs.vm != '' }} + uses: $/actions/vm/destroy + with: + vm: ${{ steps.vm.outputs.vm }} + - name: Assert - Check repeated destruction succeeds + if: ${{ always() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REPEATED_OUTCOME: ${{ steps.repeated.outcome }} + with: + script: | + require('node:assert/strict').equal(process.env.REPEATED_OUTCOME, 'success'); + - name: Cleanup - Remove destruction fixture inputs + if: ${{ always() }} + env: + UNMANAGED: ${{ runner.temp }}/unmanaged-vm + run: rm -rf .cache/qemu-fixture "$UNMANAGED" diff --git a/.github/workflows/__test-actions-vm-test.yml b/.github/workflows/__test-actions-vm-test.yml new file mode 100644 index 0000000..8d3b806 --- /dev/null +++ b/.github/workflows/__test-actions-vm-test.yml @@ -0,0 +1,154 @@ +--- +name: Internal - Test vm/test +on: + workflow_call: +permissions: {} +jobs: + unit-tests: + # Standard runner and checkout setup; each action owns its test command below. + # jscpd:ignore-start + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Arrange - Checkout action sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # jscpd:ignore-end + - name: Act - Run vm/test unit tests + uses: hoverkraft-tech/ci-github-nodejs/actions/test@97f64bc080b78daca8fb3ece49de448aaac83429 # 1.1.0 + with: + working-directory: actions/vm/test + command: test:ci + coverage: "" + guest: + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + actions: read + contents: read + pull-requests: write + strategy: + fail-fast: false + matrix: + backend: [lima, qemu] + steps: + - name: Arrange - Checkout fixture + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Arrange - Prepare VM inputs + id: fixture + uses: $/tests/fixture/prepare-vm + with: + backend: ${{ matrix.backend }} + # Shared creation is fixture setup; this workflow exercises testing. + # jscpd:ignore-start + - name: Arrange - Create VM + id: vm + uses: $/actions/vm/create + with: + backend: ${{ matrix.backend }} + vm-config: ${{ steps.fixture.outputs.vm-config }} + startup-timeout-minutes: 5 + # jscpd:ignore-end + - name: Arrange - Change groups after SSH connection setup + env: + VM_SSH_CONFIG: ${{ steps.vm.outputs.ssh-config }} + VM_NAME: ${{ steps.vm.outputs.name }} + run: | + ssh -F "$VM_SSH_CONFIG" "$VM_NAME" ' + set -eu + sudo -n groupadd iac-goss + sudo -n usermod -aG iac-goss "$(id -un)" + ! id -nG | grep -qw iac-goss + ' + - name: Act - Validate valid suite + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + id: valid + continue-on-error: true + uses: $/actions/vm/test + with: + vm: ${{ steps.vm.outputs.vm }} + goss-file: tests/fixture/goss/valid.yaml + goss-vars: tests/fixture/goss/vars.json + test-timeout-minutes: 1 + report-name: Goss - ${{ matrix.backend }} - valid + - name: Assert - Check valid results and retained reports + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + OUTCOME: ${{ steps.valid.outcome }} + REPORT_NAME: Goss - ${{ matrix.backend }} - valid + with: + script: | + const assert = require('node:assert/strict'); + const { PublishedReports } = require('./tests/helpers/published-reports.cjs'); + const published = new PublishedReports(github, context); + assert.equal(process.env.OUTCOME, 'success'); + const reports = await published.read('vm-test-', 1); + assert.ok(reports.some(report => report.text.includes('goss-execution'))); + await published.comment(process.env.REPORT_NAME); + - name: Act - Validate invalid suite + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + id: invalid + continue-on-error: true + uses: $/actions/vm/test + with: + vm: ${{ steps.vm.outputs.vm }} + goss-file: tests/fixture/goss/invalid.yaml + goss-vars: tests/fixture/goss/vars.json + test-timeout-minutes: 1 + report-name: Goss - ${{ matrix.backend }} - invalid + - name: Act - Validate empty suite + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + id: empty + continue-on-error: true + uses: $/actions/vm/test + with: + vm: ${{ steps.vm.outputs.vm }} + goss-file: tests/fixture/goss/empty.yaml + goss-vars: tests/fixture/goss/vars.json + test-timeout-minutes: 1 + report-name: Goss - ${{ matrix.backend }} - empty + - name: Act - Validate timeout suite + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + id: timeout + continue-on-error: true + uses: $/actions/vm/test + with: + vm: ${{ steps.vm.outputs.vm }} + goss-file: tests/fixture/goss/timeout.yaml + goss-vars: tests/fixture/goss/vars.json + test-timeout-minutes: 1 + report-name: Goss - ${{ matrix.backend }} - timeout + - name: Assert - Check failed suites, independent artifacts and comments + if: ${{ !cancelled() && steps.vm.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INVALID: ${{ steps.invalid.outcome }} + EMPTY: ${{ steps.empty.outcome }} + TIMEOUT: ${{ steps.timeout.outcome }} + BACKEND: ${{ matrix.backend }} + with: + script: | + const assert = require('node:assert/strict'); + const { PublishedReports } = require('./tests/helpers/published-reports.cjs'); + for (const outcome of [process.env.INVALID, process.env.EMPTY, process.env.TIMEOUT]) + assert.equal(outcome, 'failure'); + const published = new PublishedReports(github, context); + const reports = await published.read('vm-test-', 4); + assert.equal(reports.filter(report => report.text.includes('failures="1"')).length, 3); + assert.ok(reports.some(report => report.text.includes('iac-missing-synthetic-file'))); + for (const name of ['invalid', 'empty', 'timeout']) + await published.comment(`Goss - ${process.env.BACKEND} - ${name}`); + - name: Cleanup - Destroy VM + if: ${{ always() && steps.vm.outputs.vm != '' }} + uses: $/actions/vm/destroy + with: + vm: ${{ steps.vm.outputs.vm }} + - name: Cleanup - Remove private fixture inputs + if: ${{ always() }} + run: rm -rf .cache/qemu-fixture diff --git a/.github/workflows/__test-actions-vm.yml b/.github/workflows/__test-actions-vm.yml deleted file mode 100644 index b019f25..0000000 --- a/.github/workflows/__test-actions-vm.yml +++ /dev/null @@ -1,196 +0,0 @@ ---- -name: Internal - Test VM actions -on: - workflow_call: -permissions: {} -jobs: - scenarios: - runs-on: ubuntu-latest - timeout-minutes: 90 - permissions: - contents: read - pull-requests: write - strategy: - fail-fast: false - matrix: - include: - - scenario: success - report-file: "" - script: "" - command: | - test -d /run/systemd/system - test "$(uname -s)" = Linux - config: tests/fixture/lima.yml - create-outcome: success - test-outcome: success - - scenario: assertion-failure - report-file: "*.xml" - script: tests/fixture/fail.sh - command: "" - config: tests/fixture/lima.yml - create-outcome: success - test-outcome: failure - - scenario: startup-failure - report-file: "" - script: tests/fixture/assert.sh - command: "" - config: tests/fixture/unbootable.yml - create-outcome: failure - test-outcome: skipped - steps: - - name: Checkout fixture - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Ensure the consumer has no Makefile - run: rm Makefile - - name: Create VM - id: vm - # Expected outcomes are asserted after cleanup for every matrix case. - continue-on-error: true - uses: $/actions/vm/create - with: - vm-config: ${{ matrix.config }} - startup-timeout-minutes: 5 - - name: Verify host tools can use the exported instance - if: ${{ !cancelled() && steps.vm.outcome == 'success' }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - LIMA_HOME: ${{ steps.vm.outputs.vm }} - VM_NAME: ${{ steps.vm.outputs.name }} - with: - script: | - const assert = require('node:assert/strict'); - const { execFileSync } = require('node:child_process'); - const system = execFileSync('limactl', ['shell', '--workdir', '/tmp', process.env.VM_NAME, 'uname', '-s'], { - encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024, - }); - assert.equal(system.trim(), 'Linux'); - - name: Execute guest assertions - if: ${{ !cancelled() && steps.vm.outcome == 'success' }} - id: test - continue-on-error: true - uses: $/actions/vm/test - with: - vm: ${{ steps.vm.outputs.vm }} - test-script: ${{ matrix.script }} - command: ${{ matrix.command }} - test-timeout-minutes: 1 - report-file: ${{ matrix.report-file }} - report-name: VM tests - ${{ matrix.scenario }} - first - path-mapping: /guest/project:. - - name: Run another test on the same guest - if: ${{ !cancelled() && steps.vm.outcome == 'success' }} - id: repeated-test - continue-on-error: true - uses: $/actions/vm/test - with: - vm: ${{ steps.vm.outputs.vm }} - test-script: tests/fixture/assert.sh - test-timeout-minutes: 1 - report-file: "*.xml,coverage/*.info" - report-name: VM tests - ${{ matrix.scenario }} - repeated - - name: Verify reporting failure still uploads evidence - if: >- - ${{ !cancelled() && steps.vm.outcome == 'success' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' }} - id: reporting-failure - continue-on-error: true - uses: $/actions/vm/test - with: - vm: ${{ steps.vm.outputs.vm }} - command: "true" - report-name: VM tests - ${{ matrix.scenario }} - reporting failure - # A synthetic invalid token must fail commenting, not test execution or artifact upload. - github-token: synthetic-invalid-token - - name: Destroy VM - if: ${{ always() && steps.vm.outputs.vm != '' }} - id: destroy - uses: $/actions/vm/destroy - with: - vm: ${{ steps.vm.outputs.vm }} - - name: Verify action handoffs and cleanup - if: ${{ always() }} - env: - EXPECTED_CREATE: ${{ matrix.create-outcome }} - EXPECTED_TEST: ${{ matrix.test-outcome }} - CREATE_OUTCOME: ${{ steps.vm.outcome }} - TEST_OUTCOME: ${{ steps.test.outcome }} - REMOVED: ${{ steps.destroy.outputs.removed }} - VM_DIRECTORY: ${{ steps.vm.outputs.vm }} - REPORT: ${{ steps.test.outputs.report }} - REPEATED_REPORT: ${{ steps.repeated-test.outputs.report }} - REPEATED_OUTCOME: ${{ steps.repeated-test.outcome }} - PARSED_REPORTS: ${{ steps.test.outputs.parsed-files }} - REPEATED_PARSED_REPORTS: ${{ steps.repeated-test.outputs.parsed-files }} - ARTIFACT: ${{ steps.test.outputs.artifact-id }} - REPEATED_ARTIFACT: ${{ steps.repeated-test.outputs.artifact-id }} - REPORTING_FAILURE_OUTCOME: ${{ steps.reporting-failure.outcome }} - REPORTING_FAILURE_ARTIFACT: ${{ steps.reporting-failure.outputs.artifact-id }} - REPORTING_FAILURE_REPORT: ${{ steps.reporting-failure.outputs.report }} - REPORT_SUMMARY: ${{ steps.test.outputs.summary }} - REPEATED_SUMMARY: ${{ steps.repeated-test.outputs.summary }} - GUEST_REPORTS: ${{ steps.test.outputs.report-files }} - REPEATED_GUEST_REPORTS: ${{ steps.repeated-test.outputs.report-files }} - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const assert = require('node:assert/strict'); - const fs = require('node:fs'); - const path = require('node:path'); - const env = process.env; - assert.equal(env.CREATE_OUTCOME, env.EXPECTED_CREATE); - assert.equal(env.TEST_OUTCOME, env.EXPECTED_TEST); - assert.equal(env.REMOVED, 'true'); - assert.ok(env.VM_DIRECTORY); - assert.equal(fs.existsSync(env.VM_DIRECTORY), false); - assert.equal(fs.existsSync('Makefile'), false); - if (env.REPORTING_FAILURE_OUTCOME !== 'skipped') { - assert.equal(env.REPORTING_FAILURE_OUTCOME, 'failure'); - assert.ok(env.REPORTING_FAILURE_ARTIFACT); - assert.match(fs.readFileSync(env.REPORTING_FAILURE_REPORT, 'utf8'), /failures="0"/); - } - if (env.EXPECTED_TEST === 'skipped') { - assert.equal(env.REPORT, ''); - assert.equal(env.REPEATED_REPORT, ''); - assert.equal(env.REPEATED_OUTCOME, 'skipped'); - assert.equal(env.ARTIFACT, ''); - assert.equal(env.REPEATED_ARTIFACT, ''); - assert.equal(env.REPEATED_SUMMARY, ''); - assert.equal(env.REPORT_SUMMARY, ''); - assert.equal(env.GUEST_REPORTS, ''); - assert.equal(env.REPEATED_GUEST_REPORTS, ''); - } else { - assert.equal(env.REPEATED_OUTCOME, 'success'); - assert.notEqual(env.REPORT, env.REPEATED_REPORT); - for (const filename of [env.REPORT, env.REPEATED_REPORT]) { - assert.equal(path.dirname(path.dirname(filename)), env.RUNNER_TEMP); - } - const report = fs.readFileSync(env.REPORT, 'utf8'); - const failures = env.EXPECTED_TEST === 'failure' ? '1' : '0'; - assert.ok(report.includes(`failures="${failures}"`)); - assert.equal(report.includes('synthetic-private-output'), false); - assert.match(fs.readFileSync(env.REPEATED_REPORT, 'utf8'), /failures="0"/); - assert.ok(env.ARTIFACT); - assert.ok(env.REPEATED_ARTIFACT); - assert.notEqual(env.ARTIFACT, env.REPEATED_ARTIFACT); - const guestReports = env.GUEST_REPORTS.split('\n').filter(Boolean); - const repeatedReports = env.REPEATED_GUEST_REPORTS.split('\n').filter(Boolean); - assert.equal(guestReports.length, failures === '1' ? 1 : 0); - assert.equal(repeatedReports.length, 2); - for (const filename of [...guestReports, ...repeatedReports]) { - assert.equal(fs.readFileSync(filename, 'utf8').includes('synthetic-private-output'), false); - } - assert.deepEqual( - JSON.parse(env.PARSED_REPORTS).sort(), - (guestReports.length ? guestReports : [env.REPORT]).sort() - ); - assert.deepEqual(JSON.parse(env.REPEATED_PARSED_REPORTS).sort(), repeatedReports.sort()); - assert.ok(env.REPORT_SUMMARY.includes('| **Total** | **1** |')); - assert.ok(env.REPEATED_SUMMARY.includes('| **Total** | **1** |')); - assert.ok(env.REPORT_SUMMARY.includes(`| ❌ Failed | ${failures} |`)); - assert.equal(env.REPORT_SUMMARY.includes('synthetic-private-output'), false); - assert.ok(env.REPEATED_SUMMARY.includes('### Coverage Report')); - assert.ok(env.REPEATED_SUMMARY.includes('| ❌ Failed | 0 |')); - assert.equal(env.REPORT_SUMMARY.includes('/guest/project'), false); - } - assert.equal(fs.existsSync('.reports'), false); diff --git a/.gitignore b/.gitignore index d587502..8854b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .cache/ .reports/ node_modules/ +coverage/ tests/fixture/ansible/ansible_collections/*/*/tests/output/ .env *.tfstate* diff --git a/AGENTS.md b/AGENTS.md index 96b18ce..3d08fd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,14 +9,35 @@ Follow the [Hoverkraft engineering guidance](https://github.com/hoverkraft-tech/ test execution and report publication. OCI publication remains in ci-github-container. - Expose VM operations through GitHub Actions. Keep local Make targets for unit tests, linting and documentation. -- Use `actions/github-script` for action logic and native Actions helpers for outputs - and failures. Put each action entrypoint in `index.cjs` beside its `action.yml`; - keep only the module-loading call in YAML. Keep creation logic under create/ and - assertions and report collection under test/. Limit _internal/ to Lima commands, - private runtime ownership, verified cleanup and checkout path validation. - Validate these modules with Node.js unit tests and hosted action workflows. +- Use `actions/github-script` and its native helpers. Keep short, straightforward + scripts inline in `action.yml`. Use adjacent script files only for substantial, + complex code; do not add entrypoint wrappers or phase dispatchers just to move + small steps out of YAML. Resolve module imports from `github.action_path`. + Keep runtime allocation and startup under create/. Keep guest commands, file + transfer, assertions and report collection under test/. Limit _internal/ to + private runtime ownership and process checks, verified cleanup, bounded command + execution and checkout path validation. Shared modules must not import action-specific code. + Share only safety contracts enforced by multiple actions. Guest transport uses + `VmReference` for identity and presence; only creation and destruction use the + cleanup-capable `VirtualMachine`. The test action must not expose lifecycle teardown. + Validate public behavior through hosted workflows. Keep focused unit tests for + additional validation rules and failure paths that are difficult to reproduce + reliably in those workflows. Avoid mocked copies of hosted scenarios and checks + of internal call sequences unless the sequence protects a required contract. - Use synthetic fixtures without account credentials. Never upload VM disks, state, environment dumps, private keys or unsanitized logs. +- Dedicate one test workflow to each public action. Keep backend and scenario variants + within that workflow; use other actions only for fixture setup or cleanup. + Prefix shared-CI test jobs with `test-`. Follow Arrange, Act, Assert with explicit + step names, and keep unconditional fixture cleanup separate. + Where an action needs unit checks, run them through `ci-github-nodejs/actions/test` + inside its workflow, using that action's working directory. Keep its unit + tests, package manifest, lockfile and Node version file beside the action. + Organize JavaScript tests with native `suite()` and `test()` blocks. Label each + test's phases with `// Arrange`, `// Act` and `// Assert`; keep fixture setup, + execution and outcome checks in their respective sections. + Keep shared CI limited to composing those workflows; + keep `make test` as a local convenience. - Preserve cleanup after startup, assertion and report-generation failures. Never turn a failed or missing assertion into a successful run. - Run `make test` and `make lint` for changes. Validate VM behavior through the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72a5e23..bfe3d3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,16 +12,26 @@ the command before linting. `make help` lists commands. `make lint-fix` applies supported automatic fixes; `make lint docs/` limits linting to matching paths. Positional filters are accepted only for lint commands; unknown standalone targets still fail. `make ci` runs -`lint-fix` and unit tests locally. VM integration tests run in the hosted action-test workflow. +`lint-fix` and unit tests locally. VM integration tests run in hosted action-test workflows. Internal `__main-ci.yml` and `__pull-request-ci.yml` call `__shared-ci.yml`. Main supports pushes and manual runs; PR CI also supports merge queues. Both -cancel superseded runs for the same workflow and ref. The internal action-test -workflow composes `actions/vm/create`, `test` and `destroy` for success, assertion -failure and startup failure. Consumers compose the same actions in their VM jobs. +cancel superseded runs for the same workflow and ref. Each public action has a +dedicated test workflow, with backend and scenario variants inside it. Other actions +provide fixture setup or cleanup; assertions verify the action named by the workflow. +Consumers compose the same actions in their VM jobs. +Use `test-action-` caller IDs in `__shared-ci.yml`, following ci-github-common. +Keep additional JavaScript unit checks in the owning action's workflow through +`ci-github-nodejs/actions/test`, using the action's directory as `working-directory`, +alongside its integration scenarios. Shared CI +composes those workflows; `make test` is a local convenience. +Name test steps `Arrange - ...`, +`Act - ...` and `Assert - ...`; place assertions next to the behavior they verify. +Use explicit `Cleanup - ...` steps with `always()` for fixture teardown, even when +an action or assertion fails. Repeat Act/Assert pairs for independent cases sharing +the same fixture; repeated calls may form one Act phase when testing report isolation. Workflows start with `permissions: {}` and grant only the permissions required by -each job. Keep Actions pinned and step names descriptive. The aggregate `checks` -job requires every prerequisite to succeed and is required by branch protection. +each job. Keep Actions pinned and step names descriptive. ## Repository automation @@ -38,27 +48,72 @@ The four collaboration workflows reuse the pinned ci-github-common workflows. Renovate uses the organization's `CI_BOT_APP_CLIENT_ID` Actions variable and `CI_BOT_APP_PRIVATE_KEY` Actions secret. Its GitHub App must be installed on this repository with the permissions requested by the token-generation step. +The internal `tests/fixture/prepare-vm` action prepares synthetic VM configurations +without starting guests or asserting action behavior. Keep its Ubuntu image URL and +checksum aligned with `tests/fixture/lima.yml`. + The Lima version is maintained in `actions/vm/create/action.yml`. Keep the token scoped to this repository and the repository configuration in -`.github/renovate.json`. Dependabot owns Actions and Docker updates; Renovate -owns the configured custom dependency patterns. Schedules become active after -these workflows reach the default branch. +`.github/renovate.json`. Dependabot owns Actions, Dockerfile, Python and action +test-package updates; +Renovate owns Lima, Goss, the documentation image and the action `.node-version` +files, grouping Node.js runtime updates in one pull request. ## Validation -Use Node.js 24.8 or later locally. Run `make test` with its built-in test runner for failure, -cleanup, report and action-output contracts, `make lint` for the -Hoverkraft containerized linter, and the hosted action-test workflow for actual -VM behavior. PR CI verifies action outputs across successful assertions, failed +Use Node.js 24.8 or later locally; hosted unit jobs use each action's `.node-version`. +Run `make test` with Node.js's built-in test runner for focused validation and +failure cases, `make lint` for the Hoverkraft containerized linter, and the hosted +action-test workflows for actual +VM behavior. PR CI verifies action outcomes across successful assertions, failed assertions and failed startup, without a consumer Makefile. It repeats tests on -the same guest to verify independent reports, copies JUnit and coverage files +the same guest to verify independent reports, collects native Goss JUnit after assertion failures, and verifies action-owned parsing, summaries and artifact uploads. PR comments reuse stable titles; fork and Dependabot runs skip -comments. Consumer workflows grant comment permissions and always schedule cleanup. -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 +comments. Tests inspect native report files and GitHub artifacts directly, and +check published summaries through PR comments when comments are enabled. +Consumer workflows grant comment permissions and always schedule cleanup. +Hosted scenarios are the primary checks for public action behavior. Unit tests +call action modules directly where they add distinct coverage: +input boundaries, process ownership, failed rollback or deletion, corrupted +downloads, incomplete report transfers and report isolation after write failures. +Keep native-format preservation and private-data checks. Avoid repeating successful +or ordinary failed workflows with mocks, and assert outcomes instead of internal +calls unless ordering or deadlines are part of the contract. A unit suite is +justified by these additional cases, not by requiring one for every action. + +Group JavaScript cases in named `suite()` blocks from `node:test`, with `test()` +for each case. Mark phases with `// Arrange`, `// Act` and `// Assert`. Prepare +fixtures and mocks before execution, then check results. For expected exceptions, +define the deferred call in Act and evaluate it with `assert.throws` or +`await assert.rejects` in Assert. +Repeat labeled phases when a case verifies a sequence such as failure and recovery. + +Each action owns a private `package.json`, lockfile, `.node-version` and `tests/` +directory. Development dependencies supply the same Toolkit helpers injected by +the pinned GitHub Script action; production scripts receive them from the caller. +For example, run `npm --prefix actions/vm/create ci` followed by +`npm --prefix actions/vm/create test` for creation tests. Hosted unit jobs run +`npm run test:ci`: the `pretest:ci` hook creates the +coverage directory, then Node.js writes console results and `coverage/lcov.info`. +Generated coverage files are gitignored. `make test` discovers the action packages, +installs their locked test dependencies and runs each suite, stopping on failure. +Keep reusable test helpers in `tests/helpers/` and synthetic inputs in +`tests/fixture/`. Keep single-use setup beside its tests; avoid adding test harnesses +that parse action YAML or emulate the GitHub Script runtime. The public actions require no npm installation +in consumer workflows. + +`__shared-ci.yml` runs all five dedicated workflows: + +| Workflow in `.github/workflows/` | Action | Coverage | +| ------------------------------------------ | ----------------------- | ----------------------------------------------------------------------------------- | +| `__test-actions-ansible-test.yml` | `ansible/test` | Playbook and collection sanity success/failure, native reports | +| `__test-actions-ansible-test-molecule.yml` | `ansible/test-molecule` | Local success/failure/timeout cleanup; VM deployment, idempotence and reboot | +| `__test-actions-vm-create.yml` | `vm/create` | Lima and QEMU startup, usable SSH outputs, startup rollback before fallback cleanup | +| `__test-actions-vm-test.yml` | `vm/test` | Lima and QEMU Goss success, failure, empty suites, timeout and independent reports | +| `__test-actions-vm-destroy.yml` | `vm/destroy` | Lima and QEMU deletion, repeated deletion and unmanaged-data preservation | + +`__test-actions-ansible-test.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, @@ -72,39 +127,72 @@ pins and the fixture's requirements file. Linting remains in the separate linter Use Conventional Commits and a reviewed pull request. Publish semantic versions only after CI and consumer validation pass: major for incompatible contracts, minor for compatible features, patch for fixes. Consumers pin full commit SHAs; -do not move release tags. No release is published during bootstrap. +do not move release tags. -Dependabot updates Actions and Docker dependencies. Renovate updates Lima and the -documentation CLI. Review the Ubuntu image URL and SHA-256 together against the -official release index when refreshing the fixture; boot the new image before +Renovate updates the Goss version and archive SHA-256 together using the +`github-release-attachments` datasource. Review the Ubuntu image URL and SHA-256 +together against the official release index when refreshing the fixture; boot the new image before merging. A dependency proposal is not evidence of compatibility. -Action reference docs are generated by the existing Hoverkraft CI Dokumentor -CLI through `make docs`. Change action metadata and regenerate; never hand-edit -the generated reference. Keep usage guidance in the leading comments and input +Action and public workflow reference docs are generated by `make docs` using the stable +CI Dokumentor Docker image pinned in the Makefile and maintained by Renovate. +Install Docker and authenticate the GitHub CLI with `gh auth login` first. +The container runs with your UID/GID and receives the token from `gh auth token` +through `GITHUB_TOKEN`. Quoted patterns discover `actions/**/action.{yml,yaml}` +and `.github/workflows/!(__*).{yml,yaml}` automatically, excluding internal workflows +whose filenames start with `__`. Change metadata and regenerate; +never hand-edit the generated reference. Keep usage guidance in the leading comments and input descriptions of each `action.yml`, so the generated action readme is self-contained. ## Action runtime -Use the pinned `actions/github-script` runtime and its injected `core` helpers -for action inputs, outputs and failures. Pass inputs through step environment -variables. Keep each action entrypoint in `index.cjs` beside its `action.yml`. -The YAML script only loads that file from `github.action_path` and passes `core`. -Use relative imports inside the entrypoint, so a consumer checkout cannot replace -the implementation. Each action owns its operation: - -| Location | Responsibility | -| ------------------------------------ | --------------------------------------------------------------------------------- | -| `actions/vm/create/creation.cjs` | Validate configuration, start the guest and roll back failed startup. | -| `actions/vm/test/assertions.cjs` | Execute assertions and write the invocation's status report. | -| `actions/vm/test/reports.cjs` | Collect selected guest reports. | -| `actions/vm/_internal/runtime.cjs` | Run bounded Lima commands, protect private runtime ownership and verify deletion. | -| `actions/vm/_internal/workspace.cjs` | Resolve checkout paths without allowing escapes. | - -Actions share the internal safety primitives; action-specific code stays with its -owner. Startup rollback and explicit destruction use the same verified cleanup. +Use the pinned `actions/github-script` runtime and pass its injected helpers into +adjacent modules: `core` for outputs and status, `exec` for subprocesses, `io` for +ordinary copies, directory creation and cleanup, and `glob` for local report discovery. +Await helper calls so failures reach the action before it publishes outputs. +Keep Node filesystem APIs for private temporary directories, permissions, ownership, +exclusive writes/copies and atomic report publication. `io.cp` with `force: false` +silently skips existing files; it cannot replace a copy that must fail on collision. +VM report collection selects only the native `goss.xml` regular file. +VM commands use GNU `timeout` through `exec.exec`, with silent, bounded output +capture and literal stdin. The deadline also bounds child processes; output beyond +1 MiB is discarded and fails the command when it exits or times out. +Pass inputs through step environment variables. Keep short, straightforward scripts +inline in `action.yml`; retain adjacent modules for substantial code with complex +validation, execution or cleanup behavior. Avoid wrapper files and phase dispatchers +for simple step calls. This is a readability decision, not a fixed line-count rule. +Resolve external imports from `github.action_path`, never the consumer checkout. +Hosted workflows exercise inline action steps; unit tests call action modules +directly with Toolkit helpers. Each action owns its operation: + +- `actions/vm/create/` validates configuration, allocates private runtime directories, + starts guests and rolls back failed startup. +- `actions/vm/test/` owns guest commands and file transfer in `guest.cjs`, executes + Goss, collects native JUnit and publishes results. +- `actions/vm/_internal/runtime.cjs` validates owned runtimes, identifies running + guests and verifies deletion for Lima and QEMU. `VmReference` provides identity, + ownership checks and presence detection. Testing uses that interface; + `VirtualMachine` adds cleanup for creation rollback and explicit destruction. + It contains no allocation or guest transport. +- `actions/vm/_internal/command.cjs` bounds subprocess execution; `workspace.cjs` + validates checkout paths. + +Keep code shared only when multiple actions enforce the same safety contract: +runtime ownership and verified cleanup, bounded and redacted command execution, +or checkout-path containment. These rules stay consistent across their callers. +Action-specific behavior stays with its owner, and test transport must not inherit +cleanup. Startup rollback and explicit destruction use the same verified cleanup. The internal modules are not a public API. Keep modules executable with Node.js 24; no package installation or build is required. +Dependencies point from action code to shared helpers. Shared helpers do not import +action code; creation and destruction use the same teardown implementation. + +Expose inputs and outputs for concrete consumer needs. Test actions publish reports +and communicate success through their step outcome; they expose no report outputs. +Only `vm/create` exports `vm`, `name` and `ssh-config` for subsequent operations. +Use `github.token` for comments and native Ansible configuration for inventory and +collection search paths. Integration tests must verify these effects without adding +public options solely for testing. ## Project artwork @@ -116,3 +204,26 @@ so rendering does not depend on installed fonts. Keep the [1280 × 640 PNG export](.github/social-preview.png) aligned with the SVG source. The preview embeds the logo geometry to remain self-contained; it is excluded from code duplication checks. + +## Opinionated VM verification + +`vm/test` owns Goss installation, checksum verification, rendering, guest execution +and native reporting. Renovate maintains the release and archive checksum together +in `actions/vm/test/goss.json`; review both against the upstream release. +Its hosted fixtures cover Lima success and QEMU success, failed assertions and +empty suites. Goss state checks should be read-only. + +## Molecule scenario validation + +`ansible/test-molecule` owns the pinned runtime, bounded execution and report +publication. Consumers own native inventory, playbook locations, resource lifecycle +and test sequence. The action supplies no base configuration or VM dependency. +`__test-actions-ansible-test-molecule.yml` tests a local scenario with custom playbook names, +including native failure cleanup and bounded cleanup after timeout. The fixture +creates only synthetic temporary files on the runner. + +Its QEMU job additionally exercises deployment, idempotence, reboot and native +failure reports against a VM created by the workflow. That scenario consumes SSH +outputs and owns its reboot policy; the workflow always destroys the VM. +Dependabot updates Molecule's requirements; validate both native and VM scenarios +before accepting an update. diff --git a/Makefile b/Makefile index 5035ad4..eafec67 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,11 @@ help: ## Display help @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' test: ## Run unit tests - node --test tests/*.test.cjs + @set -eu; \ + for manifest in $(wildcard actions/*/*/package.json); do \ + npm --prefix "$${manifest%/package.json}" ci --ignore-scripts --no-audit --no-fund; \ + npm --prefix "$${manifest%/package.json}" test; \ + done lint: ## Execute linting $(call run_linter,) @@ -30,8 +34,16 @@ ci: ## Execute local CI tasks $(MAKE) lint-fix $(MAKE) test -docs: ## Generate action documentation - npm exec --yes --package=@ci-dokumentor/cli@0.5.0 -- ci-dokumentor generate --source actions/ansible/test/action.yml actions/vm/create/action.yml actions/vm/test/action.yml actions/vm/destroy/action.yml --repository git +docs: ## Generate action and public workflow documentation + @set -eu; \ + GITHUB_TOKEN="$$(gh auth token)"; \ + export GITHUB_TOKEN; \ + docker run --rm \ + -v "$(CURDIR):/workspace" \ + -u "$(HOST_UID):$(HOST_GID)" \ + -e GITHUB_TOKEN \ + ghcr.io/hoverkraft-tech/ci-dokumentor/cli:0.5.0 \ + generate --source "actions/**/action.{yml,yaml}" ".github/workflows/!(__*).{yml,yaml}" ############################# # Helpers diff --git a/README.md b/README.md index 8d6b781..542bde3 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ ## Overview 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. +Create a VM from your configuration, run your checks, publish test +reports, and verify that the VM is removed afterward. Your repository supplies the guest configuration and test scenario. The actions handle VM operations and reporting; your workflow controls their sequence and @@ -30,19 +30,24 @@ can run several test steps against the same guest. Execute test playbooks and first-party collection sanity through maintained Ansible actions. Publish test summaries, annotations, PR comments and report artifacts. +The action does not require or manage a VM. + +#### - [Test Molecule scenarios](actions/ansible/test-molecule/README.md) + +Run native Molecule scenarios with pinned tools and published task reports. +Your scenario defines its target environment, inventory, playbooks and test sequence. ### Virtual machines #### - [Create VM](actions/vm/create/README.md) -Install Lima and QEMU, start an isolated guest from a required `vm-config`, and -roll back resources if startup fails. +Start an isolated VM from a required `vm-config` and roll back resources if +startup fails. Choose how the VM runs in this step. #### - [Test VM](actions/vm/test/README.md) -Run Bash commands or a script inside the guest and collect test-generated reports, -including after test failure. Publish GitHub summaries, annotations, PR comments -and report artifacts, with optional Codecov upload. +Run Goss assertions against the VM. Collect native JUnit even after test failure +and publish GitHub summaries, annotations, PR comments and artifacts. #### - [Destroy VM](actions/vm/destroy/README.md) @@ -53,26 +58,30 @@ cleanup remains visible and preserves the runtime for a retry. 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). +[ansible/test](actions/ansible/test/README.md). For integration scenarios, use +[ansible/test-molecule](actions/ansible/test-molecule/README.md) with your native Molecule configuration. 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. +repository with `persist-credentials: false`, and provide a VM configuration and +a Goss suite for checks of the installed system. Compose `vm/create`, `vm/test` and `vm/destroy` in the same job, pinning all three -to the same reviewed commit SHA. Schedule destruction with `always()` so cleanup -runs after test or reporting failures. +to the same reviewed commit SHA. Pass the `vm` output from creation to each test +step and destruction. Molecule scenarios can also target that VM using its SSH +outputs in their inventory. Schedule destruction with `always()` so cleanup runs +after test or reporting failures. Start with the [workflow example](actions/vm/create/README.md#compose-the-vm-actions). +The [creation guide](actions/vm/create/README.md#choose-a-backend) explains the +backend choice and configuration format; testing and cleanup use the same interface. Each action's readme documents its inputs, outputs and failure behavior. -GitHub reporting needs `pull-requests: write` for PR comments; Codecov mode needs -`id-token: write` and explicit report selection. Publish only reports suitable -for your repository's visibility. +GitHub reporting needs `pull-requests: write` for PR comments. Publish only reports +suitable for your repository's visibility. ## Documentation - [Documentation index](docs/README.md) -- [Architecture decision: focused VM actions](docs/development/adr/0001-vm-actions.md) +- [Architecture: infrastructure test actions](docs/development/adr/0002-infrastructure-test-actions.md) ## Contributing diff --git a/actions/ansible/test-molecule/.node-version b/actions/ansible/test-molecule/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/actions/ansible/test-molecule/.node-version @@ -0,0 +1 @@ +24 diff --git a/actions/ansible/test-molecule/README.md b/actions/ansible/test-molecule/README.md new file mode 100644 index 0000000..58c6220 --- /dev/null +++ b/actions/ansible/test-molecule/README.md @@ -0,0 +1,171 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hlY2stY2lyY2xlIiBjb2xvcj0icHVycGxlIj48cGF0aCBkPSJNMjIgMTEuMDhWMTJhMTAgMTAgMCAxIDEtNS45My05LjE0Ij48L3BhdGg+PHBvbHlsaW5lIHBvaW50cz0iMjIgNCAxMiAxNC4wMSA5IDExLjAxIj48L3BvbHlsaW5lPjwvc3ZnPg==) GitHub Action: Ansible - Test Molecule + +
+ Ansible - Test Molecule +
+ +--- + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-ansible------test--molecule-blue?logo=github-actions)](https://github.com/marketplace/actions/ansible---test-molecule) +[![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-iac)](https://github.com/hoverkraft-tech/ci-github-iac/releases) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-iac)](http://choosealicense.com/licenses/mit/) +[![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) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) + + + + +## Overview + +Run native Molecule scenarios with native reports, PR comments and artifacts. + +Run a native Molecule scenario and publish its Ansible task reports. + +### Run a scenario + +Molecule and Ansible run on the GitHub runner. Your scenario selects its inventory, +target environment and test sequence: containers, an existing test host or a VM. +This action installs the pinned tools, runs `molecule test`, and publishes results. +Use [ansible/test](../test/README.md) for test playbooks and collection sanity. + +```yaml +- name: Test Ansible with Molecule + uses: hoverkraft-tech/ci-github-iac/actions/ansible/test-molecule@ + with: + molecule-scenario: tests/molecule/default + ansible-requirements: requirements.txt + report-name: Ansible integration tests +``` + +Supply a directory containing `molecule.yml` with +[Ansible-native configuration](https://docs.ansible.com/projects/molecule/ansible-native/). +Configure inventories, playbook locations, Galaxy dependencies and the sequence +in that file. The action supplies no base configuration, inventory or reboot step. +Use a workflow matrix to test multiple scenarios. The selected scenario must +produce native Ansible JUnit task reports; empty report sets fail. +The [local fixture](../../../tests/fixture/molecule/local/molecule.yml) runs +without a VM. Molecule's [container examples](https://docs.ansible.com/projects/molecule/examples/) +show how scenarios can manage container targets. + +### Runtime and cleanup + +Use a fresh Linux GitHub-hosted runner. Supply an exact stable `ansible-core==X.Y.Z` +pin in `ansible-requirements`; the action installs that pin and its maintained +Molecule release into a private environment. The runner's Python must support both. +Configure extra target dependencies and collection search paths in the scenario +or `ansible-config`. Input files must remain inside the checkout and use letters, +digits, dots, underscores, hyphens and slashes. + +Molecule honors the scenario's native resource lifecycle and failure cleanup. +Scenarios that create resources must provide idempotent cleanup/destroy playbooks +and include destruction in their test sequence. If the test process is interrupted +or times out, the action retries native `molecule destroy` with a two-minute limit. +Timeouts and cleanup failures remain failures. Allow time for cleanup and reporting +in the job timeout. Forced runner termination can interrupt cleanup entirely; +externally hosted resources need their own recovery policy. + +### Test an existing VM + +When a scenario needs a full OS, [vm/create](../../vm/create/README.md) can create +it in the same job. Pass its `ssh-config` and `name` outputs through step environment +variables referenced by your scenario's inventory. The +[QEMU fixture](../../../tests/fixture/molecule/smoke/molecule.yml) demonstrates +this connection and chooses to test reboot persistence. No VM input is required. +Keep [vm/destroy](../../vm/destroy/README.md) in an `always()` workflow step; +a scenario targeting an externally created VM must not destroy it itself. +[vm/test](../../vm/test/README.md) can verify the installed state afterward. + +### Publish results + +Native task reports and a scenario status report are collected after failures. +Only JUnit files are published; inventory, runtime state and installed tools remain +private. Reports contain task results: use synthetic data and `no_log` for private values. + +Hoverkraft's report actions publish summaries, annotations and stable PR comments. +Grant `pull-requests: write` for comments; fork and Dependabot comments are skipped. +Never run untrusted tests through `pull_request_target`. Choose distinct report +names for separate scenarios. Reports are uploaded with a unique artifact name +and seven-day retention, including after parsing or commenting failures. + + + + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-iac/actions/ansible/test-molecule@5c4b7307d7a2c58a01a3b62607cebde65f96b0a2 # 0.2.0 + with: + # Checkout-relative directory containing molecule.yml. The scenario defines its inventory, playbooks and test sequence. + # This input is required. + molecule-scenario: "" + + # Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. + # This input is required. + ansible-requirements: "" + + # Optional checkout-relative ansible.cfg for scenario execution and collection search paths. + ansible-config: "" + + # Native test sequence deadline, from 1 to 40 minutes. Interrupted runs allow two additional minutes for scenario cleanup. + # Default: `20` + test-timeout-minutes: "20" + + # Stable summary and PR comment title, distinct for each test step or matrix entry. + # Default: `Molecule test results` + report-name: Molecule test results +``` + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------ | ----------------------- | +| **`molecule-scenario`** | Checkout-relative directory containing molecule.yml. The scenario defines its inventory, playbooks and test sequence. | **true** | - | +| **`ansible-requirements`** | Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. | **true** | - | +| **`ansible-config`** | Optional checkout-relative ansible.cfg for scenario execution and collection search paths. | **false** | - | +| **`test-timeout-minutes`** | Native test sequence deadline, from 1 to 40 minutes. Interrupted runs allow two additional minutes for scenario cleanup. | **false** | `20` | +| **`report-name`** | Stable summary and PR comment title, distinct for each test step or matrix entry. | **false** | `Molecule test results` | + + + + + + + + + + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) for more details. + + + + + + +## License + +This project is licensed under the MIT License. + +SPDX-License-Identifier: MIT + +Copyright © 2026 Hoverkraft + +For more details, see the [license](http://choosealicense.com/licenses/mit/). + + + + +--- + +This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor). + + diff --git a/actions/ansible/test-molecule/action.yml b/actions/ansible/test-molecule/action.yml new file mode 100644 index 0000000..98a5f33 --- /dev/null +++ b/actions/ansible/test-molecule/action.yml @@ -0,0 +1,185 @@ +# Run a native Molecule scenario and publish its Ansible task reports. +# +# ### Run a scenario +# +# Molecule and Ansible run on the GitHub runner. Your scenario selects its inventory, +# target environment and test sequence: containers, an existing test host or a VM. +# This action installs the pinned tools, runs `molecule test`, and publishes results. +# Use [ansible/test](../test/README.md) for test playbooks and collection sanity. +# +# ```yaml +# - name: Test Ansible with Molecule +# uses: hoverkraft-tech/ci-github-iac/actions/ansible/test-molecule@ +# with: +# molecule-scenario: tests/molecule/default +# ansible-requirements: requirements.txt +# report-name: Ansible integration tests +# ``` +# +# Supply a directory containing `molecule.yml` with +# [Ansible-native configuration](https://docs.ansible.com/projects/molecule/ansible-native/). +# Configure inventories, playbook locations, Galaxy dependencies and the sequence +# in that file. The action supplies no base configuration, inventory or reboot step. +# Use a workflow matrix to test multiple scenarios. The selected scenario must +# produce native Ansible JUnit task reports; empty report sets fail. +# The [local fixture](../../../tests/fixture/molecule/local/molecule.yml) runs +# without a VM. Molecule's [container examples](https://docs.ansible.com/projects/molecule/examples/) +# show how scenarios can manage container targets. +# +# ### Runtime and cleanup +# +# Use a fresh Linux GitHub-hosted runner. Supply an exact stable `ansible-core==X.Y.Z` +# pin in `ansible-requirements`; the action installs that pin and its maintained +# Molecule release into a private environment. The runner's Python must support both. +# Configure extra target dependencies and collection search paths in the scenario +# or `ansible-config`. Input files must remain inside the checkout and use letters, +# digits, dots, underscores, hyphens and slashes. +# +# Molecule honors the scenario's native resource lifecycle and failure cleanup. +# Scenarios that create resources must provide idempotent cleanup/destroy playbooks +# and include destruction in their test sequence. If the test process is interrupted +# or times out, the action retries native `molecule destroy` with a two-minute limit. +# Timeouts and cleanup failures remain failures. Allow time for cleanup and reporting +# in the job timeout. Forced runner termination can interrupt cleanup entirely; +# externally hosted resources need their own recovery policy. +# +# ### Test an existing VM +# +# When a scenario needs a full OS, [vm/create](../../vm/create/README.md) can create +# it in the same job. Pass its `ssh-config` and `name` outputs through step environment +# variables referenced by your scenario's inventory. The +# [QEMU fixture](../../../tests/fixture/molecule/smoke/molecule.yml) demonstrates +# this connection and chooses to test reboot persistence. No VM input is required. +# Keep [vm/destroy](../../vm/destroy/README.md) in an `always()` workflow step; +# a scenario targeting an externally created VM must not destroy it itself. +# [vm/test](../../vm/test/README.md) can verify the installed state afterward. +# +# ### Publish results +# +# Native task reports and a scenario status report are collected after failures. +# Only JUnit files are published; inventory, runtime state and installed tools remain +# private. Reports contain task results: use synthetic data and `no_log` for private values. +# +# Hoverkraft's report actions publish summaries, annotations and stable PR comments. +# Grant `pull-requests: write` for comments; fork and Dependabot comments are skipped. +# Never run untrusted tests through `pull_request_target`. Choose distinct report +# names for separate scenarios. Reports are uploaded with a unique artifact name +# and seven-day retention, including after parsing or commenting failures. +--- +name: Ansible - Test Molecule +description: Run native Molecule scenarios with native reports, PR comments and artifacts. +author: Hoverkraft +branding: + icon: check-circle + color: purple +inputs: + molecule-scenario: + description: Checkout-relative directory containing molecule.yml. The scenario defines its inventory, playbooks and test sequence. + required: true + ansible-requirements: + description: Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. + required: true + ansible-config: + description: Optional checkout-relative ansible.cfg for scenario execution and collection search paths. + default: "" + test-timeout-minutes: + description: Native test sequence deadline, from 1 to 40 minutes. Interrupted runs allow two additional minutes for scenario cleanup. + default: "20" + report-name: + description: Stable summary and PR comment title, distinct for each test step or matrix entry. + default: Molecule test results +runs: + using: composite + steps: + - name: Prepare Molecule scenario + id: scenario-inputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTION_PATH: ${{ github.action_path }} + MOLECULE_SCENARIO: ${{ inputs.molecule-scenario }} + ANSIBLE_REQUIREMENTS: ${{ inputs.ansible-requirements }} + ANSIBLE_CONFIG_FILE: ${{ inputs.ansible-config }} + ANSIBLE_TEST_TIMEOUT_MINUTES: ${{ inputs.test-timeout-minutes }} + with: + script: | + try { + const { MoleculeInputs } = require(`${process.env.ACTION_PATH}/inputs.cjs`); + new MoleculeInputs(process.env).prepare(core); + } catch (error) { + core.setFailed(error.message); + } + + - name: Install the pinned scenario test runtime + shell: bash + env: + REPORT_DIRECTORY: ${{ steps.scenario-inputs.outputs.report-directory }} + CONSTRAINT: ${{ steps.scenario-inputs.outputs.constraint }} + ACTION_PATH: ${{ github.action_path }} + run: | + python3 -m venv "$REPORT_DIRECTORY/venv" + "$REPORT_DIRECTORY/venv/bin/pip" install --disable-pip-version-check -r "$CONSTRAINT" -r "$ACTION_PATH/requirements.txt" + + - name: Run Molecule test sequence + id: scenario-execution + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTION_PATH: ${{ github.action_path }} + ANSIBLE_REPORT_DIRECTORY: ${{ steps.scenario-inputs.outputs.report-directory }} + MOLECULE_SCENARIO: ${{ steps.scenario-inputs.outputs.scenario }} + ANSIBLE_TEST_TIMEOUT_MINUTES: ${{ inputs.test-timeout-minutes }} + ANSIBLE_CONFIG: ${{ steps.scenario-inputs.outputs.config }} + with: + script: | + try { + const { MoleculeScenario } = require(`${process.env.ACTION_PATH}/molecule.cjs`); + await new MoleculeScenario(process.env, { exec, glob }).run(); + } catch (error) { + core.setFailed(error.message); + } + + - name: Collect scenario reports + id: scenario-reports + if: ${{ always() && steps.scenario-inputs.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTION_PATH: ${{ github.action_path }} + ANSIBLE_REPORT_DIRECTORY: ${{ steps.scenario-inputs.outputs.report-directory }} + with: + script: | + try { + const path = require('node:path'); + const { MoleculeScenario } = require(path.join(process.env.ACTION_PATH, 'molecule.cjs')); + const files = await new MoleculeScenario(process.env, { glob }).collect(); + core.setOutput('report-files', files.join('\n')); + } catch (error) { + core.setFailed(error.message); + } + + - name: Parse scenario reports + if: ${{ always() && steps.scenario-reports.outputs.report-files != '' }} + id: parse-scenario-reports + uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1 + with: + report-name: ${{ inputs.report-name }} + report-paths: ${{ steps.scenario-reports.outputs.report-files }} + path-mapping: ${{ github.workspace }}:. + output-format: summary,markdown,annotations + + - name: Add scenario 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-scenario-reports.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-scenario-reports.outputs.markdown }} + token: ${{ github.token }} + + - name: Upload scenario reports + if: ${{ always() && steps.scenario-reports.outputs.report-files != '' }} + id: upload-scenario-reports + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ steps.scenario-inputs.outputs.id }} + path: ${{ steps.scenario-reports.outputs.report-files }} + if-no-files-found: error + retention-days: 7 diff --git a/actions/ansible/test-molecule/inputs.cjs b/actions/ansible/test-molecule/inputs.cjs new file mode 100644 index 0000000..c26bc50 --- /dev/null +++ b/actions/ansible/test-molecule/inputs.cjs @@ -0,0 +1,68 @@ +const path = require("node:path"); +const fs = require("node:fs"); + +class MoleculeInputs { + constructor(env) { + this.env = env; + this.checkout = fs.realpathSync(env.GITHUB_WORKSPACE); + } + + file(input) { + if (typeof input !== "string" || !input.trim()) + throw new Error("Expected a nonempty checkout path"); + const file = fs.realpathSync(path.resolve(this.checkout, input)); + const relative = path.relative(this.checkout, file); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) + throw new Error("Molecule inputs must remain inside the checkout"); + const info = fs.statSync(file); + if (!info.isFile() || info.size === 0 || !/^[\w./-]+$/.test(file)) + throw new Error("Expected a nonempty file with a shell-safe path"); + return file; + } + + prepare(core) { + const scenario = this.env.MOLECULE_SCENARIO?.trim(); + if (!scenario) throw new Error("molecule-scenario is required"); + const selected = path.dirname( + this.file(path.join(scenario, "molecule.yml")), + ); + if (!/^[a-zA-Z0-9_-]+$/.test(path.basename(selected))) + throw new Error("Invalid Molecule scenario name"); + const minutes = this.env.ANSIBLE_TEST_TIMEOUT_MINUTES || "20"; + if (!/^[1-9][0-9]?$/.test(minutes) || Number(minutes) > 40) + throw new Error("Test timeout must be 1 to 40 minutes"); + const requirements = fs.readFileSync( + this.file(this.env.ANSIBLE_REQUIREMENTS), + "utf8", + ); + const pins = [ + ...requirements.matchAll( + /^ansible-core==([0-9]+\.[0-9]+\.[0-9]+)\s*(?:#.*)?$/gm, + ), + ]; + if (pins.length !== 1) + throw new Error( + "Pin exactly one stable ansible-core==X.Y.Z in ansible-requirements", + ); + const config = this.env.ANSIBLE_CONFIG_FILE + ? this.file(this.env.ANSIBLE_CONFIG_FILE) + : ""; + const reports = fs.mkdtempSync( + path.join(this.env.RUNNER_TEMP, "molecule-reports-"), + ); + const constraint = path.join(reports, "constraints.txt"); + fs.writeFileSync(constraint, `ansible-core==${pins[0][1]}\n`, { + flag: "wx", + }); + for (const [name, value] of Object.entries({ + scenario: selected, + config, + constraint, + "report-directory": reports, + id: path.basename(reports), + })) + core.setOutput(name, value); + } +} + +module.exports = { MoleculeInputs }; diff --git a/actions/ansible/test-molecule/molecule.cjs b/actions/ansible/test-molecule/molecule.cjs new file mode 100644 index 0000000..8fdc1e9 --- /dev/null +++ b/actions/ansible/test-molecule/molecule.cjs @@ -0,0 +1,111 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +class MoleculeScenario { + constructor(env, { exec, glob }) { + this.exec = exec; + this.glob = glob; + this.env = env; + this.directory = env.ANSIBLE_REPORT_DIRECTORY; + } + + async reportFiles() { + const info = fs.lstatSync(this.directory); + if ( + !info.isDirectory() || + info.uid !== process.getuid() || + info.mode & 0o077 || + path.dirname(fs.realpathSync(this.directory)) !== + fs.realpathSync(this.env.RUNNER_TEMP) || + !path.basename(this.directory).startsWith("molecule-reports-") + ) { + throw new Error("Expected this action's private report directory"); + } + const search = await this.glob.create( + `${this.directory.replace(/[[?*]/g, "[$&]")}/*.xml`, + { + followSymbolicLinks: false, + matchDirectories: false, + implicitDescendants: false, + }, + ); + const files = []; + for await (const file of search.globGenerator()) { + if (fs.lstatSync(file).isFile()) files.push(file); + } + return files; + } + + async collect() { + const files = await this.reportFiles(); + if (!files.length || files.some((file) => fs.statSync(file).size === 0)) { + throw new Error("Molecule must produce nonempty JUnit reports"); + } + return files; + } + + async execute(command, duration) { + // GNU timeout bounds Molecule and the Ansible process group together. + return this.exec.exec( + "timeout", + [ + "--kill-after=5s", + duration, + path.join(this.directory, "venv/bin/molecule"), + command, + "--scenario-name", + path.basename(this.env.MOLECULE_SCENARIO), + ], + { + cwd: this.env.GITHUB_WORKSPACE, + ignoreReturnCode: true, + env: { + ...this.env, + PATH: `${path.join(this.directory, "venv/bin")}:${this.env.PATH || ""}`, + MOLECULE_GLOB: path.join(this.env.MOLECULE_SCENARIO, "molecule.yml"), + MOLECULE_EPHEMERAL_DIRECTORY: path.join(this.directory, "state"), + ANSIBLE_CALLBACKS_ENABLED: "ansible.builtin.junit", + JUNIT_OUTPUT_DIR: this.directory, + JUNIT_HIDE_TASK_ARGUMENTS: "true", + JUNIT_TASK_RELATIVE_PATH: this.env.GITHUB_WORKSPACE, + }, + }, + ); + } + + async run() { + let result; + try { + result = await this.execute( + "test", + `${this.env.ANSIBLE_TEST_TIMEOUT_MINUTES || "20"}m`, + ); + } catch { + result = null; + } + const interrupted = result === null || [124, 137].includes(result); + // Native test failure cleanup cannot finish after timeout kills its process. + const cleanup = interrupted + ? await this.execute("destroy", "2m").catch(() => null) + : 0; + const cleanupFailed = cleanup !== 0; + const native = (await this.reportFiles()).filter( + (file) => path.basename(file) !== "scenario.xml", + ); + const hasAssertions = native.some((file) => + /]/.test(fs.readFileSync(file, "utf8")), + ); + const failed = Boolean(result !== 0 || cleanupFailed || !hasAssertions); + const message = cleanupFailed + ? "Molecule was interrupted and scenario cleanup failed" + : "Molecule failed, timed out or produced no native assertions"; + fs.writeFileSync( + path.join(this.directory, "scenario.xml"), + `${failed ? `` : ""}\n`, + { flag: "wx" }, + ); + if (failed) throw new Error(`${message}; inspect the scenario reports`); + } +} + +module.exports = { MoleculeScenario }; diff --git a/actions/ansible/test-molecule/package-lock.json b/actions/ansible/test-molecule/package-lock.json new file mode 100644 index 0000000..b4c8504 --- /dev/null +++ b/actions/ansible/test-molecule/package-lock.json @@ -0,0 +1,141 @@ +{ + "name": "ansible-test-molecule-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ansible-test-molecule-tests", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/glob": "0.4.0" + }, + "engines": { + "node": ">=24.8.0" + } + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/glob": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz", + "integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^1.9.1", + "minimatch": "^3.0.4" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + } + } +} diff --git a/actions/ansible/test-molecule/package.json b/actions/ansible/test-molecule/package.json new file mode 100644 index 0000000..cd0dab4 --- /dev/null +++ b/actions/ansible/test-molecule/package.json @@ -0,0 +1,19 @@ +{ + "name": "ansible-test-molecule-tests", + "version": "0.0.0", + "private": true, + "description": "Unit tests for the ansible/test-molecule action", + "license": "MIT", + "engines": { + "node": ">=24.8.0" + }, + "scripts": { + "test": "node --test", + "pretest:ci": "node -e \"require('node:fs').mkdirSync('coverage', { recursive: true })\"", + "test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test" + }, + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/glob": "0.4.0" + } +} diff --git a/actions/ansible/test-molecule/requirements.txt b/actions/ansible/test-molecule/requirements.txt new file mode 100644 index 0000000..8c048d5 --- /dev/null +++ b/actions/ansible/test-molecule/requirements.txt @@ -0,0 +1 @@ +molecule==26.10.0 diff --git a/actions/ansible/test-molecule/tests/molecule.test.cjs b/actions/ansible/test-molecule/tests/molecule.test.cjs new file mode 100644 index 0000000..f729529 --- /dev/null +++ b/actions/ansible/test-molecule/tests/molecule.test.cjs @@ -0,0 +1,222 @@ +const { suite, test } = require("node:test"); +const { MoleculeInputs } = require("../inputs.cjs"); +const { MoleculeScenario } = require("../molecule.cjs"); +const fs = require("node:fs"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const os = require("node:os"); +const exec = require("@actions/exec"); +const glob = require("@actions/glob"); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "molecule-contract-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const checkout = path.join(root, "checkout"); + const temporary = path.join(root, "runner"); + fs.mkdirSync(path.join(checkout, "default"), { recursive: true }); + fs.mkdirSync(temporary); + // Scenario playbooks and inventory are selected by Molecule, not this action. + fs.writeFileSync( + path.join(checkout, "default/molecule.yml"), + "---\nansible: {}\n", + ); + fs.writeFileSync( + path.join(checkout, "requirements.txt"), + "ansible-core==2.99.1\n", + ); + const outputs = {}; + return { + checkout, + temporary, + outputs, + env: { + GITHUB_WORKSPACE: checkout, + RUNNER_TEMP: temporary, + MOLECULE_SCENARIO: "default", + ANSIBLE_REQUIREMENTS: "requirements.txt", + }, + core: { + setOutput: (name, value) => { + outputs[name] = value; + }, + }, + }; +} + +function prepared(t) { + const state = fixture(t); + new MoleculeInputs(state.env).prepare(state.core); + state.env = { + ...state.env, + ANSIBLE_REPORT_DIRECTORY: state.outputs["report-directory"], + MOLECULE_SCENARIO: state.outputs.scenario, + }; + return state; +} + +const nativeReport = + ''; + +suite("ansible/test-molecule", () => { + test("Molecule prepares an exact runtime pin without requiring a VM or fixed playbook names", async (t) => { + // Arrange + const state = fixture(t); + + // Act + new MoleculeInputs(state.env).prepare(state.core); + + // Assert + assert.equal(state.outputs.scenario, path.join(state.checkout, "default")); + assert.equal( + fs.readFileSync(state.outputs.constraint, "utf8"), + "ansible-core==2.99.1\n", + ); + assert.equal( + path.dirname(state.outputs["report-directory"]), + state.temporary, + ); + assert.deepEqual(fs.readdirSync(state.outputs["report-directory"]), [ + "constraints.txt", + ]); + }); + + test("Molecule rejects escaping scenarios and config, invalid deadlines and floating runtime pins", async (t) => { + // Arrange + const state = fixture(t); + fs.writeFileSync( + path.join(state.checkout, "floating.txt"), + "ansible-core>=2.99\n", + ); + fs.symlinkSync(state.temporary, path.join(state.checkout, "external")); + fs.writeFileSync(path.join(state.temporary, "ansible.cfg"), "[defaults]\n"); + fs.writeFileSync(path.join(state.temporary, "molecule.yml"), "---\n"); + for (const invalid of [ + { MOLECULE_SCENARIO: "" }, + { MOLECULE_SCENARIO: "../runner" }, + { MOLECULE_SCENARIO: "external" }, + { ANSIBLE_REQUIREMENTS: "floating.txt" }, + { ANSIBLE_CONFIG_FILE: "external/ansible.cfg" }, + { ANSIBLE_TEST_TIMEOUT_MINUTES: "41" }, + { ANSIBLE_TEST_TIMEOUT_MINUTES: "0" }, + ]) { + // Act + const prepare = () => + new MoleculeInputs({ ...state.env, ...invalid }).prepare(state.core); + + // Assert + assert.throws(prepare, JSON.stringify(invalid)); + assert.deepEqual(fs.readdirSync(state.temporary).sort(), [ + "ansible.cfg", + "molecule.yml", + ]); + } + + // Arrange + fs.rmSync(path.join(state.checkout, "default/molecule.yml")); + + // Act + const prepare = () => new MoleculeInputs(state.env).prepare(state.core); + + // Assert + assert.throws(prepare, /ENOENT/); + }); + + test("Molecule report selection excludes symlinks and runtime directories", async (t) => { + // Arrange + const state = prepared(t); + const directory = state.env.ANSIBLE_REPORT_DIRECTORY; + const native = path.join(directory, "assertions.xml"); + const junit = + ''; + fs.writeFileSync(native, junit); + fs.mkdirSync(path.join(directory, "venv")); + fs.symlinkSync( + state.outputs.constraint, + path.join(directory, "private.xml"), + ); + + // Act + const files = await new MoleculeScenario(state.env, { glob }).collect(); + + // Assert + assert.deepEqual(files, [native]); + assert.equal(fs.readFileSync(native, "utf8"), junit); + }); + + test("Molecule reports failed cleanup after a timeout and bounds the cleanup attempt", async (t) => { + // Arrange + const state = prepared(t); + const directory = state.env.ANSIBLE_REPORT_DIRECTORY; + state.env.ANSIBLE_TEST_TIMEOUT_MINUTES = "1"; + const subprocess = t.mock.method(exec, "exec", (_command, args) => { + fs.writeFileSync(path.join(directory, "create.xml"), nativeReport); + return args[3] === "test" ? 124 : 1; + }); + + // Act + const execute = () => new MoleculeScenario(state.env, { exec, glob }).run(); + + // Assert + await assert.rejects(execute, /cleanup failed/); + assert.equal(subprocess.mock.callCount(), 2); + assert.equal(subprocess.mock.calls[0].arguments[1][1], "1m"); + assert.deepEqual(subprocess.mock.calls[1].arguments[1], [ + "--kill-after=5s", + "2m", + `${directory}/venv/bin/molecule`, + "destroy", + "--scenario-name", + "default", + ]); + assert.match( + fs.readFileSync(path.join(directory, "scenario.xml"), "utf8"), + /failures="1"/, + ); + }); + + test("Molecule cannot succeed with empty or redirected native assertions", async (t) => { + // Arrange + const state = prepared(t); + const directory = state.env.ANSIBLE_REPORT_DIRECTORY; + + // Act + const collect = () => new MoleculeScenario(state.env, { glob }).collect(); + + // Assert + await assert.rejects(collect, /nonempty JUnit/); + + // Arrange + fs.writeFileSync(path.join(state.temporary, "outside.xml"), nativeReport); + fs.symlinkSync( + path.join(state.temporary, "outside.xml"), + path.join(directory, "redirect.xml"), + ); + fs.writeFileSync(path.join(directory, "empty.xml"), ""); + t.mock.method(exec, "exec", () => 0); + + // Act + const execute = () => new MoleculeScenario(state.env, { exec, glob }).run(); + + // Assert + await assert.rejects(execute, /no native assertions/); + + // Act + const collectEmpty = () => + new MoleculeScenario(state.env, { glob }).collect(); + + // Assert + await assert.rejects(collectEmpty, /nonempty JUnit/); + + // Arrange + const redirect = path.join(state.temporary, "molecule-reports-redirect"); + fs.symlinkSync(directory, redirect); + state.env.ANSIBLE_REPORT_DIRECTORY = redirect; + + // Act + const collectRedirected = () => + new MoleculeScenario(state.env, { glob }).collect(); + + // Assert + await assert.rejects(collectRedirected, /private report directory/); + }); +}); diff --git a/actions/ansible/test/.node-version b/actions/ansible/test/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/actions/ansible/test/.node-version @@ -0,0 +1 @@ +24 diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md index fb9646f..ccc2783 100644 --- a/actions/ansible/test/README.md +++ b/actions/ansible/test/README.md @@ -2,12 +2,20 @@ # ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hlY2stY2lyY2xlIiBjb2xvcj0icHVycGxlIj48cGF0aCBkPSJNMjIgMTEuMDhWMTJhMTAgMTAgMCAxIDEtNS45My05LjE0Ij48L3BhdGg+PHBvbHlsaW5lIHBvaW50cz0iMjIgNCAxMiAxNC4wMSA5IDExLjAxIj48L3BvbHlsaW5lPjwvc3ZnPg==) GitHub Action: Ansible - Test +
+ 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) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-iac)](http://choosealicense.com/licenses/mit/) [![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) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) @@ -18,6 +26,10 @@ Run Ansible test playbooks and collection sanity with native reports, PR comment Run Ansible tests with maintained upstream actions and native JUnit reports. +For Ansible integration scenarios with a configurable target environment, use +[ansible/test-molecule](../test-molecule/README.md). This action tests Ansible content +without managing a VM or running a deployment lifecycle. + ### Run tests Check out the consumer on a fresh Linux GitHub-hosted runner. Supply its existing @@ -31,8 +43,8 @@ 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. +Use `ansible-config` for inventory and collection search paths, and +`galaxy-requirements` for dependencies. 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), @@ -58,7 +70,7 @@ 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. +test runner or Docker lifecycle 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 @@ -75,36 +87,38 @@ for matrix entries. | **`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. | - + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) for more details. + + +## License + +This project is licensed under the MIT License. + +SPDX-License-Identifier: MIT + +Copyright © 2026 Hoverkraft + +For more details, see the [license](http://choosealicense.com/licenses/mit/). + @@ -133,25 +147,15 @@ This documentation was automatically generated by [CI Dokumentor](https://github # Optional Python major.minor for collection sanity checks. Empty tests all versions supported by Ansible Core. target-python-version: "" - # Optional checkout-relative inventory file for test playbook execution. - inventory: "" - # Optional checkout-relative ansible.cfg file for test playbook execution. ansible-config: "" - # Optional checkout-relative first-party collection search directory containing ansible_collections. - collections-path: "" - # Optional checkout-relative Galaxy requirements file for third-party role and collection dependencies. galaxy-requirements: "" # Stable summary and PR comment title, distinct for each test step or matrix entry. # Default: `Ansible test results` report-name: Ansible test results - - # Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped. - # Default: `${{ github.token }}` - github-token: ${{ github.token }} ``` diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml index 71ba775..0a90914 100644 --- a/actions/ansible/test/action.yml +++ b/actions/ansible/test/action.yml @@ -1,5 +1,9 @@ # Run Ansible tests with maintained upstream actions and native JUnit reports. # +# For Ansible integration scenarios with a configurable target environment, use +# [ansible/test-molecule](../test-molecule/README.md). This action tests Ansible content +# without managing a VM or running a deployment lifecycle. +# # ### Run tests # # Check out the consumer on a fresh Linux GitHub-hosted runner. Supply its existing @@ -13,8 +17,8 @@ # 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. +# Use `ansible-config` for inventory and collection search paths, and +# `galaxy-requirements` for dependencies. 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), @@ -40,7 +44,7 @@ # 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. +# test runner or Docker lifecycle 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 @@ -65,40 +69,15 @@ inputs: 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: @@ -106,18 +85,21 @@ runs: id: inputs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - ANSIBLE_ACTION_PATH: ${{ github.action_path }} - ANSIBLE_PHASE: prepare + ACTION_PATH: ${{ github.action_path }} 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 }); + script: | + try { + const { AnsibleInputs } = require(`${process.env.ACTION_PATH}/inputs.cjs`); + new AnsibleInputs(core, process.env).prepare(); + } catch (error) { + core.setFailed(error.message); + } - name: Install the pinned Ansible test runtime if: steps.inputs.outputs.mode == 'playbook' @@ -134,8 +116,6 @@ runs: 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 }} @@ -162,12 +142,17 @@ runs: if: ${{ always() && steps.inputs.outcome == 'success' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - ANSIBLE_ACTION_PATH: ${{ github.action_path }} - ANSIBLE_PHASE: collect + ACTION_PATH: ${{ github.action_path }} 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 }); + script: | + try { + const { AnsibleReports } = require(`${process.env.ACTION_PATH}/reports.cjs`); + await new AnsibleReports(core, process.env, glob).collect(); + } catch (error) { + core.setFailed(error.message); + } - name: Parse Ansible reports if: ${{ always() && steps.collect.outputs.report-files != '' }} @@ -186,7 +171,7 @@ runs: with: title: ${{ inputs.report-name }} body: ${{ steps.parse.outputs.markdown }} - token: ${{ inputs.github-token }} + token: ${{ github.token }} - name: Upload Ansible reports if: ${{ always() && steps.collect.outputs.report-files != '' }} diff --git a/actions/ansible/test/index.cjs b/actions/ansible/test/index.cjs deleted file mode 100644 index 0d85b05..0000000 --- a/actions/ansible/test/index.cjs +++ /dev/null @@ -1,13 +0,0 @@ -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 index 42bae39..81fa859 100644 --- a/actions/ansible/test/inputs.cjs +++ b/actions/ansible/test/inputs.cjs @@ -1,6 +1,5 @@ const fs = require("node:fs"); const path = require("node:path"); -const os = require("node:os"); class AnsibleInputs { constructor(core, env) { @@ -40,9 +39,9 @@ class AnsibleInputs { ); } this.core.setOutput("target-python-version", targetPython); - if (Boolean(playbook) === Boolean(collection)) { + if ([playbook, collection].filter(Boolean).length !== 1) { throw new Error( - "Provide test-playbook or one collection, exclusively. Use a matrix for multiple collections.", + "Provide exactly one test-playbook or collection. Use a matrix for multiple targets.", ); } const requirements = this.checkoutPath(this.env.ANSIBLE_REQUIREMENTS); @@ -65,26 +64,12 @@ class AnsibleInputs { 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-"), ); diff --git a/actions/ansible/test/package-lock.json b/actions/ansible/test/package-lock.json new file mode 100644 index 0000000..d1e4440 --- /dev/null +++ b/actions/ansible/test/package-lock.json @@ -0,0 +1,140 @@ +{ + "name": "ansible-test-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ansible-test-tests", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@actions/glob": "0.4.0" + }, + "engines": { + "node": ">=24.8.0" + } + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/glob": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz", + "integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^1.9.1", + "minimatch": "^3.0.4" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + } + } +} diff --git a/actions/ansible/test/package.json b/actions/ansible/test/package.json new file mode 100644 index 0000000..4f24bb6 --- /dev/null +++ b/actions/ansible/test/package.json @@ -0,0 +1,18 @@ +{ + "name": "ansible-test-tests", + "version": "0.0.0", + "private": true, + "description": "Unit tests for the ansible/test action", + "license": "MIT", + "engines": { + "node": ">=24.8.0" + }, + "scripts": { + "test": "node --test", + "pretest:ci": "node -e \"require('node:fs').mkdirSync('coverage', { recursive: true })\"", + "test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test" + }, + "devDependencies": { + "@actions/glob": "0.4.0" + } +} diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs index a040177..1022cf4 100644 --- a/actions/ansible/test/reports.cjs +++ b/actions/ansible/test/reports.cjs @@ -2,12 +2,25 @@ const fs = require("node:fs"); const path = require("node:path"); class AnsibleReports { - constructor(core, env) { + constructor(core, env, glob) { + this.glob = glob; this.core = core; this.env = env; } - collect() { + async find(directory) { + const search = await this.glob.create( + `${directory.replace(/[[?*]/g, "[$&]")}/*.xml`, + { + followSymbolicLinks: false, + matchDirectories: false, + implicitDescendants: false, + }, + ); + return (await search.glob()).filter((file) => fs.lstatSync(file).isFile()); + } + + async collect() { const destination = fs.realpathSync(this.env.ANSIBLE_REPORT_DIRECTORY); if ( path.dirname(destination) !== fs.realpathSync(this.env.RUNNER_TEMP) || @@ -31,24 +44,14 @@ class AnsibleReports { 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, - ); + for (const file of await this.find(source)) { + const target = path.join(destination, path.basename(file)); + fs.copyFileSync(file, 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)); - } + files.push(...(await this.find(destination))); } if (!files.length) throw new Error( diff --git a/actions/ansible/test/tests/ansible.test.cjs b/actions/ansible/test/tests/ansible.test.cjs new file mode 100644 index 0000000..f6c7aea --- /dev/null +++ b/actions/ansible/test/tests/ansible.test.cjs @@ -0,0 +1,177 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { suite, test } = require("node:test"); +const glob = require("@actions/glob"); +const { AnsibleInputs } = require("../inputs.cjs"); +const { AnsibleReports } = require("../reports.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 env = { + GITHUB_WORKSPACE: workspace, + RUNNER_TEMP: temp, + ANSIBLE_REQUIREMENTS: "requirements.txt", + ANSIBLE_TEST_PLAYBOOK: "playbook.yml", + }; + const core = { + addPath: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }; + return { root, workspace, temp, outputs, env, core }; +} + +suite("ansible/test", () => { + test("playbook tests keep the consumer's exact Ansible pin and explicit test target", async (t) => { + // Arrange + const f = fixture(t); + + // Act + new AnsibleInputs(f.core, f.env).prepare(); + + // Assert + 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", async (t) => { + // Arrange + 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", + ); + + // Act + new AnsibleInputs(f.core, { + ...f.env, + ANSIBLE_TEST_PLAYBOOK: "", + ANSIBLE_COLLECTION: collection, + ANSIBLE_TARGET_PYTHON_VERSION: "3.99", + }).prepare(); + + // Assert + 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", async (t) => { + // Arrange + 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", + }, + ]) { + // Act + const prepare = () => + new AnsibleInputs(f.core, { ...f.env, ...inputs }).prepare(); + + // Assert + assert.throws(prepare, JSON.stringify(inputs)); + } + }); + + test("native reports are retained verbatim even when the upstream action has failed", async (t) => { + // Arrange + const f = fixture(t); + new AnsibleInputs(f.core, f.env).prepare(); + 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); + + // Act + await new AnsibleReports( + f.core, + { + ...f.env, + ANSIBLE_COLLECTION_ROOT: "collection", + ANSIBLE_REPORT_DIRECTORY: f.outputs["report-directory"], + }, + glob, + ).collect(); + + // Assert + assert.equal(fs.readFileSync(f.outputs["report-files"], "utf8"), junit); + }); + + test("missing reports and redirected report directories cannot be reported as success", async (t) => { + // Arrange + const f = fixture(t); + new AnsibleInputs(f.core, f.env).prepare(); + const env = { + ...f.env, + ANSIBLE_REPORT_DIRECTORY: f.outputs["report-directory"], + }; + + // Act + const collectMissing = () => + new AnsibleReports(f.core, env, glob).collect(); + + // Assert + await assert.rejects(collectMissing, /no reports/); + + // Arrange + fs.mkdirSync(path.join(f.workspace, "collection/tests/output"), { + recursive: true, + }); + fs.symlinkSync( + f.temp, + path.join(f.workspace, "collection/tests/output/junit"), + ); + + // Act + const collectRedirected = () => + new AnsibleReports( + f.core, + { ...env, ANSIBLE_COLLECTION_ROOT: "collection" }, + glob, + ).collect(); + + // Assert + await assert.rejects(collectRedirected, /symbolic links/); + assert.equal(f.outputs["report-files"], undefined); + }); +}); diff --git a/actions/vm/_internal/command.cjs b/actions/vm/_internal/command.cjs new file mode 100644 index 0000000..dd02607 --- /dev/null +++ b/actions/vm/_internal/command.cjs @@ -0,0 +1,41 @@ +async function run( + exec, + program, + args, + { timeout = 120000, input, env = process.env } = {}, +) { + // GitHub Script owns execution; GNU timeout bounds the whole process group. + // Capture only bounded stdout, without logging commands or private output. + const chunks = []; + let stdoutBytes = 0; + let stderrBytes = 0; + const limit = 1024 * 1024; + try { + const status = await exec.exec( + "timeout", + ["--kill-after=5s", `${timeout / 1000}s`, program, ...args], + { + env, + input: Buffer.from(input ?? ""), + silent: true, + ignoreReturnCode: true, + listeners: { + stdout(data) { + stdoutBytes += data.length; + if (stdoutBytes <= limit) chunks.push(data); + }, + stderr(data) { + stderrBytes += data.length; + }, + }, + }, + ); + if (status !== 0 || stdoutBytes > limit || stderrBytes > limit) + throw new Error(); + return Buffer.concat(chunks).toString("utf8").trim(); + } catch { + throw new Error("VM command failed or timed out"); + } +} + +module.exports = { run }; diff --git a/actions/vm/_internal/runtime.cjs b/actions/vm/_internal/runtime.cjs index 6e9502c..cbdf2b0 100644 --- a/actions/vm/_internal/runtime.cjs +++ b/actions/vm/_internal/runtime.cjs @@ -1,73 +1,117 @@ const fs = require("node:fs"); const path = require("node:path"); -const childProcess = require("node:child_process"); +const { setTimeout: delay } = require("node:timers/promises"); +const { run } = require("./command.cjs"); -class VirtualMachine { +function validateDirectory(home, parent, prefix) { + if ( + typeof home !== "string" || + path.dirname(home) !== parent || + !path.basename(home).startsWith(prefix) + ) + throw new Error("Expected an invocation's private runtime"); + const info = fs.lstatSync(home, { throwIfNoEntry: false }); + if (!info) return; + if (!info.isDirectory() || info.uid !== process.getuid() || info.mode & 0o077) + throw new Error( + "VM runtime must be a private directory owned by this user", + ); +} + +class VmReference { name = "iac-test"; - constructor(home) { + constructor(home, { exec }) { + this.exec = exec; this.home = home; + this.backend = + typeof home === "string" && path.dirname(home) === "/var/tmp" + ? "qemu" + : "lima"; this.validateRuntime(); - } - - static allocate() { - return new VirtualMachine(fs.mkdtempSync(`/tmp/iac-${process.getuid()}-`)); + this.sshConfig = path.join(home, "ssh.config"); } validateRuntime() { - if ( - path.dirname(this.home) !== "/tmp" || - !path.basename(this.home).startsWith(`iac-${process.getuid()}-`) - ) { - throw new Error("Expected an invocation's private Lima home"); - } - const info = fs.lstatSync(this.home, { throwIfNoEntry: false }); - if (!info) return; // Repeated destruction is safe after verified removal. - if ( - !info.isDirectory() || - info.uid !== process.getuid() || - info.mode & 0o077 - ) { - throw new Error( - "VM runtime must be a private directory owned by this user", - ); - } + const qemu = this.backend === "qemu"; + validateDirectory( + this.home, + qemu ? "/var/tmp" : "/tmp", + `iac-${qemu ? "qemu-" : ""}${process.getuid()}-`, + ); } - command(args, { timeout = 120000, input } = {}) { + qemuPid() { this.validateRuntime(); - if (!fs.existsSync(this.home)) throw new Error("VM runtime is absent"); - const result = childProcess.spawnSync("limactl", args, { - env: { ...process.env, LIMA_HOME: this.home }, - input, - encoding: "utf8", - stdio: "pipe", - timeout, - killSignal: "SIGKILL", - maxBuffer: 1024 * 1024, - }); - // Never expose raw CLI output or exception details in public action logs. - if (result.error || result.status !== 0) - throw new Error("VM command failed or timed out"); - return result.stdout.trim(); + const file = path.join(this.home, "pid"); + if (!fs.existsSync(file)) return undefined; + const value = fs.readFileSync(file, "utf8").trim(); + if (!/^[1-9][0-9]*$/.test(value)) + throw new Error("Invalid QEMU process identity"); + const pid = Number(value); + let command; + try { + command = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0"); + } catch (error) { + if (error.code === "ENOENT") return undefined; + throw error; + } + if (command[0] === "") return undefined; // Exited child awaiting reaping. + if ( + path.basename(command[0]) !== "qemu-system-x86_64" || + command[command.indexOf("-pidfile") + 1] !== file || + fs.statSync(`/proc/${pid}`).uid !== process.getuid() + ) + throw new Error("QEMU process ownership cannot be verified"); + return pid; } - exists() { + async exists() { this.validateRuntime(); + if (this.backend === "qemu") return this.qemuPid() !== undefined; if (!fs.existsSync(path.join(this.home, this.name))) return false; - return this.command(["list", "--format", "{{.Name}}"]) + return ( + await run(this.exec, "limactl", ["list", "--format", "{{.Name}}"], { + env: { ...process.env, LIMA_HOME: this.home }, + }) + ) .split("\n") .includes(this.name); } +} + +class VirtualMachine extends VmReference { + constructor(home, { exec, io }) { + super(home, { exec }); + this.io = io; + } - destroy() { - if (this.exists()) { - this.command(["delete", "--force", this.name]); - if (this.exists()) + async destroy() { + if (this.backend === "qemu") { + for (const signal of ["SIGTERM", "SIGKILL"]) { + const pid = this.qemuPid(); + if (!pid) break; + try { + process.kill(pid, signal); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + const deadline = Date.now() + 15000; + while ((await this.exists()) && Date.now() < deadline) await delay(100); + } + if (await this.exists()) + throw new Error("QEMU still exists after deletion"); + } else if (await this.exists()) { + this.validateRuntime(); + if (!fs.existsSync(this.home)) throw new Error("VM runtime is absent"); + await run(this.exec, "limactl", ["delete", "--force", this.name], { + env: { ...process.env, LIMA_HOME: this.home }, + }); + if (await this.exists()) throw new Error("Disposable VM still exists after deletion"); } - fs.rmSync(this.home, { recursive: true, force: true }); + await this.io.rmRF(this.home); } } -module.exports = { VirtualMachine }; +module.exports = { VmReference, VirtualMachine }; diff --git a/actions/vm/create/.node-version b/actions/vm/create/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/actions/vm/create/.node-version @@ -0,0 +1 @@ +24 diff --git a/actions/vm/create/README.md b/actions/vm/create/README.md index fc34f3e..605f732 100644 --- a/actions/vm/create/README.md +++ b/actions/vm/create/README.md @@ -2,41 +2,39 @@ # ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItc2VydmVyIiBjb2xvcj0icHVycGxlIj48cmVjdCB4PSIyIiB5PSIyIiB3aWR0aD0iMjAiIGhlaWdodD0iOCIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PHJlY3QgeD0iMiIgeT0iMTQiIHdpZHRoPSIyMCIgaGVpZ2h0PSI4IiByeD0iMiIgcnk9IjIiPjwvcmVjdD48bGluZSB4MT0iNiIgeTE9IjYiIHgyPSI2LjAxIiB5Mj0iNiI+PC9saW5lPjxsaW5lIHgxPSI2IiB5MT0iMTgiIHgyPSI2LjAxIiB5Mj0iMTgiPjwvbGluZT48L3N2Zz4=) GitHub Action: VM - Create +
+ VM - Create +
+ +--- + [![Marketplace](https://img.shields.io/badge/Marketplace-vm------create-blue?logo=github-actions)](https://github.com/marketplace/actions/vm---create) [![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-iac)](https://github.com/hoverkraft-tech/ci-github-iac/releases) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-iac)](http://choosealicense.com/licenses/mit/) [![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) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) ## Overview -Create an isolated Lima VM, rolling back resources if startup fails. +Create an isolated disposable VM, rolling back resources if startup fails. -Create a disposable Lima guest and return its private runtime directory. - -### Runner and configuration - -Use an ephemeral Linux x86-64 runner with KVM, network access and `contents: read`. -Check out the consumer repository with `persist-credentials: false`. Reserve a -90-minute job timeout for installation, startup, testing and cleanup. Pin all -three VM actions to the same reviewed full commit SHA. - -`vm-config` must point to an existing Lima YAML file within the checkout. Missing -or blank input fails before allocating a runtime. The consumer owns the image -and resource settings; no default configuration is supplied. The -[test fixture](../../../tests/fixture/lima.yml) provides a digest-pinned Ubuntu -example. Plain mode is enforced: host mounts, SSH agent forwarding and dynamic -port forwarding are disabled. Provisioning still runs, so treat configuration -and scripts as trusted repository code. +Create a disposable VM and return the reference used by testing and cleanup. ### Compose the VM actions -After checkout, create the guest, [run tests](../test/README.md) and -[destroy the guest](../destroy/README.md) in the same job: +Create the VM, [run tests](../test/README.md) and +[destroy it](../destroy/README.md) in the same job. Pass the same `vm` output to +testing and cleanup. Choose the backend only when creating the VM; both later +actions use that reference to select the matching implementation automatically. + +After checkout, supply your VM configuration and a +[Goss suite](../../../tests/fixture/goss/valid.yaml): ```yaml - name: Create VM @@ -48,7 +46,7 @@ After checkout, create the guest, [run tests](../test/README.md) and uses: hoverkraft-tech/ci-github-iac/actions/vm/test@ with: vm: ${{ steps.vm.outputs.vm }} - command: test -d /run/systemd/system + goss-file: tests/goss.yaml report-name: Infrastructure VM tests - name: Destroy VM if: ${{ always() && steps.vm.outputs.vm != '' }} @@ -57,30 +55,117 @@ After checkout, create the guest, [run tests](../test/README.md) and vm: ${{ steps.vm.outputs.vm }} ``` +Testing leaves the VM running. Add further `vm/test` steps or an +[Ansible Molecule scenario](../../ansible/test-molecule/README.md) before destruction to +check deployment, idempotence and reboot against the same VM. +Reference the creation step's `name` and `ssh-config` outputs in that scenario's +inventory; Molecule's target and sequence are configured by the scenario. + Default GitHub reporting needs `pull-requests: write` for PR comments. Other reporting modes and permissions are documented by `vm/test`. The maintained -[action-test workflow](../../../.github/workflows/__test-actions-vm.yml) also +[VM test workflow](../../../.github/workflows/__test-actions-vm-test.yml) also covers failures and repeated tests. No consumer Makefile is required. +### Runner and configuration + +Use an ephemeral Linux x86-64 runner with KVM, network access and `contents: read`. +Check out the consumer repository with `persist-credentials: false`. Reserve a +90-minute job timeout for installation, startup, testing and cleanup. Pin all +three VM actions to the same reviewed full commit SHA. + +`vm-config` must point to an existing file within the checkout, using the format +for the selected backend below. Missing or blank input fails before allocating +a runtime. Your repository supplies the image and resource settings; no default +configuration is supplied. Treat configuration and scripts as trusted repository code. + +### Choose a backend + +| Need | `backend` | `vm-config` | +| ---------------------------------------------------------------- | ---------------- | -------------------- | +| Start an image and provision it with Lima | `lima` (default) | Lima YAML | +| Test an unattended installer ISO, or boot a QCOW2 image directly | `qemu` | JSON described below | + +Both choices create a VM with the same outputs and testing and cleanup actions. + +#### Lima + +Omit `backend` or set it to `lima`. The +[test fixture](../../../tests/fixture/lima.yml) provides a digest-pinned Ubuntu +configuration. The action installs Lima and QEMU and starts the guest in plain +mode: host mounts, SSH agent forwarding and dynamic port forwarding are disabled. +Provisioning defined in the configuration still runs. Lima tools can use the +returned `name` with `LIMA_HOME` set to `vm`. + +#### Direct QEMU + +Set `backend: qemu` to boot an unattended installer ISO, such as Proxmox, directly +on the runner. QEMU uses KVM, UEFI and a private raw disk +under `/var/tmp/iac-qemu--`. Lima's maintained setup action installs +the host dependencies; this backend does not start a Lima guest. +Supply a JSON `vm-config` with explicit resources, media, network and SSH identity: + +```json +{ + "cpus": 2, + "memoryMiB": 4096, + "diskGiB": 32, + "diskSerial": "TEST_DISK", + "iso": ".local/test/installer.iso", + "network": { + "subnet": "192.0.2.0/24", + "address": "192.0.2.10", + "gateway": "192.0.2.1", + "dns": "192.0.2.2", + "mac": "02:00:00:00:00:01" + }, + "ssh": { "user": "root", "privateKey": ".local/test/key" } +} +``` + +All media and keys must be regular checkout files. Use synthetic credentials. +Consumers verify downloads and prepare the ISO's unattended answer with matching +network, disk serial and SSH public key. The installer must power off on success; +the action then boots the installed disk without the ISO and waits for SSH. +Alternatively replace `iso` with a trusted `image` (QCOW2), optionally supplying +a cloud-init `seed` ISO. The image is converted to a private raw disk, then resized. +Images must fit diskGiB. No arbitrary QEMU arguments or host disks are accepted. +Limits are 1–16 CPUs, 1–65536 MiB memory and 1–128 GiB disk. The runner must have +sufficient disk-backed storage and memory. Network addresses must be distinct +hosts in the supplied IPv4 subnet (prefix 8–30); MAC addresses start with `02`. + +SSH is exposed on a dynamically allocated loopback port, with a private key and +known-hosts file. Initial trust is confined to that owned endpoint; later calls +require the recorded host key. +The startup budget is checked while waiting for installer power-off and SSH +readiness. Preparation and subprocesses have their own bounded deadlines; +allow extra time for them and cleanup in the job timeout. Startup failure +attempts verified rollback; always schedule `vm/destroy` to retry cleanup. +Reports belong to `vm/test`; no raw console logs, keys or VM state are published. +Application readiness checks remain in consumer tests. + ### Startup and cleanup -The action installs Lima and QEMU and allocates a private -`/tmp/iac--` runtime. It exports `vm` before starting the guest so -cleanup can retry after startup failure. Startup defaults to ten minutes, with -one additional minute for the subprocess deadline; `startup-timeout-minutes` -allows longer provisioning. Failed startup rolls back partial creation. -The runtime is retained if rollback -cannot verify removal; keep the `always()` destruction step and let failures -fail the job. Never upload the runtime directory, which contains SSH keys and -VM state. Forced runner termination can interrupt cleanup; ephemeral runner -disposal bounds remaining resources. Persistent runners and cloud resources -are outside this action's scope. - -Host-side test tools can use the returned `name` with `LIMA_HOME` set to `vm`. -Keep their SSH configuration private. If a scenario creates external resources, -verify its cleanup before calling `vm/destroy`; destruction cannot verify +The action allocates a private runtime directory and exports it as `vm` before +starting the guest, so cleanup can retry after startup failure. The startup +budget defaults to ten minutes and accepts 1 to 40 minutes through +`startup-timeout-minutes`. Lima adds one minute to its startup subprocess deadline. +Failed startup rolls back +partial creation. The runtime is retained if rollback cannot verify removal; +keep the `always()` destruction step and let failures fail the job. + +Never upload the runtime directory, which contains SSH keys and VM state. +Forced runner termination can interrupt cleanup; ephemeral runner disposal +bounds remaining resources. Persistent runners and cloud resources are outside +this action's scope. If a scenario creates external resources, verify their +cleanup before calling `vm/destroy`; destruction cannot verify application-specific resources such as remote browser devices. +### SSH access + +For host tools, both backends return a private `ssh-config` and a `name` to use +as the SSH host alias: `ssh -F uname -s`. Keep the SSH +configuration private. `vm/test` uses it internally to open fresh guest logins. + @@ -89,11 +174,15 @@ application-specific resources such as remote browser devices. ```yaml - uses: hoverkraft-tech/ci-github-iac/actions/vm/create@5c4b7307d7a2c58a01a3b62607cebde65f96b0a2 # 0.2.0 with: - # Path to a Lima YAML file in the checkout. Plain mode is enforced. + # Creation backend, lima (default) or qemu. Testing and cleanup use the returned vm reference without another backend choice. + # Default: `lima` + backend: lima + + # Checkout configuration file. Lima uses YAML in plain mode; QEMU uses the JSON schema described above. # This input is required. vm-config: "" - # Guest startup timeout, from 1 to 40 minutes. The subprocess deadline includes one additional minute. + # Startup budget, from 1 to 40 minutes. Lima adds one minute to its startup subprocess deadline; QEMU checks the budget during installation and SSH readiness, with separate subprocess deadlines. # Default: `10` startup-timeout-minutes: "10" ``` @@ -103,10 +192,11 @@ application-specific resources such as remote browser devices. ## Inputs -| **Input** | **Description** | **Required** | **Default** | -| ----------------------------- | ---------------------------------------------------------------------------------------------------- | ------------ | ----------- | -| **`vm-config`** | Path to a Lima YAML file in the checkout. Plain mode is enforced. | **true** | - | -| **`startup-timeout-minutes`** | Guest startup timeout, from 1 to 40 minutes. The subprocess deadline includes one additional minute. | **false** | `10` | +| **Input** | **Description** | **Required** | **Default** | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------ | ----------- | +| **`backend`** | Creation backend, lima (default) or qemu. Testing and cleanup use the returned vm reference without another backend choice. | **false** | `lima` | +| **`vm-config`** | Checkout configuration file. Lima uses YAML in plain mode; QEMU uses the JSON schema described above. | **true** | - | +| **`startup-timeout-minutes`** | Startup budget, from 1 to 40 minutes. Lima adds one minute to its startup subprocess deadline; QEMU checks the budget during installation and SSH readiness, with separate subprocess deadlines. | **false** | `10` | @@ -115,20 +205,36 @@ application-specific resources such as remote browser devices. ## Outputs -| **Output** | **Description** | -| ---------- | ------------------------------------------------------------------------------------------ | -| **`vm`** | Private Lima runtime directory for subsequent VM actions; allocated before startup. | -| **`id`** | Unique identifier for this invocation. | -| **`name`** | Lima instance name for host-side tools. Set LIMA_HOME to the vm output on the same runner. | +| **Output** | **Description** | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **`ssh-config`** | Private SSH configuration for either backend, using the returned name as host alias. Never upload it. | +| **`vm`** | VM reference for testing and cleanup on the same runner; the path to its private runtime directory, allocated before startup. | +| **`name`** | Instance name and host alias for the returned ssh-config. | + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) for more details. + + +## License + +This project is licensed under the MIT License. + +SPDX-License-Identifier: MIT + +Copyright © 2026 Hoverkraft + +For more details, see the [license](http://choosealicense.com/licenses/mit/). + diff --git a/actions/vm/create/action.yml b/actions/vm/create/action.yml index 6925f5d..d3656c2 100644 --- a/actions/vm/create/action.yml +++ b/actions/vm/create/action.yml @@ -1,24 +1,14 @@ -# Create a disposable Lima guest and return its private runtime directory. -# -# ### Runner and configuration -# -# Use an ephemeral Linux x86-64 runner with KVM, network access and `contents: read`. -# Check out the consumer repository with `persist-credentials: false`. Reserve a -# 90-minute job timeout for installation, startup, testing and cleanup. Pin all -# three VM actions to the same reviewed full commit SHA. -# -# `vm-config` must point to an existing Lima YAML file within the checkout. Missing -# or blank input fails before allocating a runtime. The consumer owns the image -# and resource settings; no default configuration is supplied. The -# [test fixture](../../../tests/fixture/lima.yml) provides a digest-pinned Ubuntu -# example. Plain mode is enforced: host mounts, SSH agent forwarding and dynamic -# port forwarding are disabled. Provisioning still runs, so treat configuration -# and scripts as trusted repository code. +# Create a disposable VM and return the reference used by testing and cleanup. # # ### Compose the VM actions # -# After checkout, create the guest, [run tests](../test/README.md) and -# [destroy the guest](../destroy/README.md) in the same job: +# Create the VM, [run tests](../test/README.md) and +# [destroy it](../destroy/README.md) in the same job. Pass the same `vm` output to +# testing and cleanup. Choose the backend only when creating the VM; both later +# actions use that reference to select the matching implementation automatically. +# +# After checkout, supply your VM configuration and a +# [Goss suite](../../../tests/fixture/goss/valid.yaml): # # ```yaml # - name: Create VM @@ -30,7 +20,7 @@ # uses: hoverkraft-tech/ci-github-iac/actions/vm/test@ # with: # vm: ${{ steps.vm.outputs.vm }} -# command: test -d /run/systemd/system +# goss-file: tests/goss.yaml # report-name: Infrastructure VM tests # - name: Destroy VM # if: ${{ always() && steps.vm.outputs.vm != '' }} @@ -39,52 +29,143 @@ # vm: ${{ steps.vm.outputs.vm }} # ``` # +# Testing leaves the VM running. Add further `vm/test` steps or an +# [Ansible Molecule scenario](../../ansible/test-molecule/README.md) before destruction to +# check deployment, idempotence and reboot against the same VM. +# Reference the creation step's `name` and `ssh-config` outputs in that scenario's +# inventory; Molecule's target and sequence are configured by the scenario. +# # Default GitHub reporting needs `pull-requests: write` for PR comments. Other # reporting modes and permissions are documented by `vm/test`. The maintained -# [action-test workflow](../../../.github/workflows/__test-actions-vm.yml) also +# [VM test workflow](../../../.github/workflows/__test-actions-vm-test.yml) also # covers failures and repeated tests. No consumer Makefile is required. # +# ### Runner and configuration +# +# Use an ephemeral Linux x86-64 runner with KVM, network access and `contents: read`. +# Check out the consumer repository with `persist-credentials: false`. Reserve a +# 90-minute job timeout for installation, startup, testing and cleanup. Pin all +# three VM actions to the same reviewed full commit SHA. +# +# `vm-config` must point to an existing file within the checkout, using the format +# for the selected backend below. Missing or blank input fails before allocating +# a runtime. Your repository supplies the image and resource settings; no default +# configuration is supplied. Treat configuration and scripts as trusted repository code. +# +# ### Choose a backend +# +# | Need | `backend` | `vm-config` | +# | ---------------------------------------------------------------- | ---------------- | -------------------- | +# | Start an image and provision it with Lima | `lima` (default) | Lima YAML | +# | Test an unattended installer ISO, or boot a QCOW2 image directly | `qemu` | JSON described below | +# +# Both choices create a VM with the same outputs and testing and cleanup actions. +# +# #### Lima +# +# Omit `backend` or set it to `lima`. The +# [test fixture](../../../tests/fixture/lima.yml) provides a digest-pinned Ubuntu +# configuration. The action installs Lima and QEMU and starts the guest in plain +# mode: host mounts, SSH agent forwarding and dynamic port forwarding are disabled. +# Provisioning defined in the configuration still runs. Lima tools can use the +# returned `name` with `LIMA_HOME` set to `vm`. +# +# #### Direct QEMU +# +# Set `backend: qemu` to boot an unattended installer ISO, such as Proxmox, directly +# on the runner. QEMU uses KVM, UEFI and a private raw disk +# under `/var/tmp/iac-qemu--`. Lima's maintained setup action installs +# the host dependencies; this backend does not start a Lima guest. +# Supply a JSON `vm-config` with explicit resources, media, network and SSH identity: +# +# ```json +# { +# "cpus": 2, +# "memoryMiB": 4096, +# "diskGiB": 32, +# "diskSerial": "TEST_DISK", +# "iso": ".local/test/installer.iso", +# "network": { +# "subnet": "192.0.2.0/24", +# "address": "192.0.2.10", +# "gateway": "192.0.2.1", +# "dns": "192.0.2.2", +# "mac": "02:00:00:00:00:01" +# }, +# "ssh": { "user": "root", "privateKey": ".local/test/key" } +# } +# ``` +# +# All media and keys must be regular checkout files. Use synthetic credentials. +# Consumers verify downloads and prepare the ISO's unattended answer with matching +# network, disk serial and SSH public key. The installer must power off on success; +# the action then boots the installed disk without the ISO and waits for SSH. +# Alternatively replace `iso` with a trusted `image` (QCOW2), optionally supplying +# a cloud-init `seed` ISO. The image is converted to a private raw disk, then resized. +# Images must fit diskGiB. No arbitrary QEMU arguments or host disks are accepted. +# Limits are 1–16 CPUs, 1–65536 MiB memory and 1–128 GiB disk. The runner must have +# sufficient disk-backed storage and memory. Network addresses must be distinct +# hosts in the supplied IPv4 subnet (prefix 8–30); MAC addresses start with `02`. +# +# SSH is exposed on a dynamically allocated loopback port, with a private key and +# known-hosts file. Initial trust is confined to that owned endpoint; later calls +# require the recorded host key. +# The startup budget is checked while waiting for installer power-off and SSH +# readiness. Preparation and subprocesses have their own bounded deadlines; +# allow extra time for them and cleanup in the job timeout. Startup failure +# attempts verified rollback; always schedule `vm/destroy` to retry cleanup. +# Reports belong to `vm/test`; no raw console logs, keys or VM state are published. +# Application readiness checks remain in consumer tests. +# # ### Startup and cleanup # -# The action installs Lima and QEMU and allocates a private -# `/tmp/iac--` runtime. It exports `vm` before starting the guest so -# cleanup can retry after startup failure. Startup defaults to ten minutes, with -# one additional minute for the subprocess deadline; `startup-timeout-minutes` -# allows longer provisioning. Failed startup rolls back partial creation. -# The runtime is retained if rollback -# cannot verify removal; keep the `always()` destruction step and let failures -# fail the job. Never upload the runtime directory, which contains SSH keys and -# VM state. Forced runner termination can interrupt cleanup; ephemeral runner -# disposal bounds remaining resources. Persistent runners and cloud resources -# are outside this action's scope. -# -# Host-side test tools can use the returned `name` with `LIMA_HOME` set to `vm`. -# Keep their SSH configuration private. If a scenario creates external resources, -# verify its cleanup before calling `vm/destroy`; destruction cannot verify +# The action allocates a private runtime directory and exports it as `vm` before +# starting the guest, so cleanup can retry after startup failure. The startup +# budget defaults to ten minutes and accepts 1 to 40 minutes through +# `startup-timeout-minutes`. Lima adds one minute to its startup subprocess deadline. +# Failed startup rolls back +# partial creation. The runtime is retained if rollback cannot verify removal; +# keep the `always()` destruction step and let failures fail the job. +# +# Never upload the runtime directory, which contains SSH keys and VM state. +# Forced runner termination can interrupt cleanup; ephemeral runner disposal +# bounds remaining resources. Persistent runners and cloud resources are outside +# this action's scope. If a scenario creates external resources, verify their +# cleanup before calling `vm/destroy`; destruction cannot verify # application-specific resources such as remote browser devices. +# +# ### SSH access +# +# For host tools, both backends return a private `ssh-config` and a `name` to use +# as the SSH host alias: `ssh -F uname -s`. Keep the SSH +# configuration private. `vm/test` uses it internally to open fresh guest logins. +# --- name: VM - Create -description: Create an isolated Lima VM, rolling back resources if startup fails. +description: Create an isolated disposable VM, rolling back resources if startup fails. author: Hoverkraft branding: icon: server color: purple inputs: + backend: + description: Creation backend, lima (default) or qemu. Testing and cleanup use the returned vm reference without another backend choice. + default: lima vm-config: - description: Path to a Lima YAML file in the checkout. Plain mode is enforced. + description: Checkout configuration file. Lima uses YAML in plain mode; QEMU uses the JSON schema described above. required: true startup-timeout-minutes: - description: Guest startup timeout, from 1 to 40 minutes. The subprocess deadline includes one additional minute. + description: Startup budget, from 1 to 40 minutes. Lima adds one minute to its startup subprocess deadline; QEMU checks the budget during installation and SSH readiness, with separate subprocess deadlines. default: "10" outputs: + ssh-config: + description: Private SSH configuration for either backend, using the returned name as host alias. Never upload it. + value: ${{ steps.create.outputs.ssh-config }} vm: - description: Private Lima runtime directory for subsequent VM actions; allocated before startup. + description: VM reference for testing and cleanup on the same runner; the path to its private runtime directory, allocated before startup. value: ${{ steps.create.outputs.vm }} - id: - description: Unique identifier for this invocation. - value: ${{ steps.create.outputs.id }} name: - description: Lima instance name for host-side tools. Set LIMA_HOME to the vm output on the same runner. + description: Instance name and host alias for the returned ssh-config. value: ${{ steps.create.outputs.name }} runs: using: composite @@ -94,12 +175,42 @@ runs: with: # renovate: datasource=github-releases depName=lima-vm/lima version: v2.2.1 + ssh: ${{ inputs.backend == 'lima' }} - name: Create VM id: create uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - VM_ACTION_PATH: ${{ github.action_path }} + ACTION_PATH: ${{ github.action_path }} VM_CONFIG: ${{ inputs.vm-config }} + VM_BACKEND: ${{ inputs.backend }} VM_STARTUP_TIMEOUT_MINUTES: ${{ inputs.startup-timeout-minutes }} with: - script: require(`${process.env.VM_ACTION_PATH}/index.cjs`)({ core }); + script: | + const fs = require('node:fs'); + const { VirtualMachine } = require(`${process.env.ACTION_PATH}/../_internal/runtime.cjs`); + const { GuestCreation } = require(`${process.env.ACTION_PATH}/creation.cjs`); + + const failed = () => + core.setFailed( + "VM creation failed. Check the required vm-config checkout file and startup timeout, then retry cleanup using the runtime directory if allocated.", + ); + try { + const backend = process.env.VM_BACKEND ?? "lima"; + if (!["lima", "qemu"].includes(backend)) + throw new Error("Invalid VM backend"); + const Creation = + backend === "qemu" ? require(`${process.env.ACTION_PATH}/qemu.cjs`).QemuCreation : GuestCreation; + const creation = new Creation( + process.env.VM_CONFIG, + process.env.VM_STARTUP_TIMEOUT_MINUTES, + ); + const prefix = backend === "qemu" ? "/var/tmp/iac-qemu-" : "/tmp/iac-"; + const vm = new VirtualMachine(fs.mkdtempSync(`${prefix}${process.getuid()}-`), { exec, io }); + core.info(`VM recovery directory: ${vm.home}`); + core.setOutput("vm", vm.home); + core.setOutput("name", vm.name); + if (vm.sshConfig) core.setOutput("ssh-config", vm.sshConfig); + await creation.start(vm, core); + } catch { + failed(); + } diff --git a/actions/vm/create/creation.cjs b/actions/vm/create/creation.cjs index 5d6150e..fb9438c 100644 --- a/actions/vm/create/creation.cjs +++ b/actions/vm/create/creation.cjs @@ -1,3 +1,5 @@ +const fs = require("node:fs"); +const { run } = require("../_internal/command.cjs"); const { Workspace } = require("../_internal/workspace.cjs"); class GuestCreation { @@ -13,10 +15,18 @@ class GuestCreation { this.startupTimeout = Number(minutes); } - start(vm) { - if (vm.exists()) throw new Error("This invocation already has a VM"); + async start(vm) { + if (await vm.exists()) throw new Error("This invocation already has a VM"); + const command = (args, options) => { + vm.validateRuntime(); + if (!fs.existsSync(vm.home)) throw new Error("VM runtime is absent"); + return run(vm.exec, "limactl", args, { + ...options, + env: { ...process.env, LIMA_HOME: vm.home }, + }); + }; try { - vm.command( + await command( [ "start", "--tty=false", @@ -27,8 +37,14 @@ class GuestCreation { ], { timeout: (this.startupTimeout + 1) * 60000 }, ); + const config = await command(["show-ssh", "--format=config", vm.name]); + fs.writeFileSync( + vm.sshConfig, + config.replace(/^Host\s+.*$/m, `Host ${vm.name}`), + { mode: 0o600, flag: "wx" }, + ); } catch (error) { - vm.destroy(); + await vm.destroy(); throw error; } } diff --git a/actions/vm/create/index.cjs b/actions/vm/create/index.cjs deleted file mode 100644 index 482355e..0000000 --- a/actions/vm/create/index.cjs +++ /dev/null @@ -1,21 +0,0 @@ -const { VirtualMachine } = require("../_internal/runtime.cjs"); -const { GuestCreation } = require("./creation.cjs"); - -module.exports = ({ core }) => { - try { - const creation = new GuestCreation( - process.env.VM_CONFIG, - process.env.VM_STARTUP_TIMEOUT_MINUTES, - ); - const vm = VirtualMachine.allocate(); - core.info(`VM recovery directory: ${vm.home}`); - core.setOutput("vm", vm.home); - core.setOutput("id", require("node:path").basename(vm.home)); - core.setOutput("name", vm.name); - creation.start(vm); - } catch { - core.setFailed( - "VM creation failed. Check the required vm-config checkout file and startup timeout, then retry cleanup using the runtime directory if allocated.", - ); - } -}; diff --git a/actions/vm/create/package-lock.json b/actions/vm/create/package-lock.json new file mode 100644 index 0000000..2913497 --- /dev/null +++ b/actions/vm/create/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "vm-create-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vm-create-tests", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/io": "1.1.3" + }, + "engines": { + "node": ">=24.8.0" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/actions/vm/create/package.json b/actions/vm/create/package.json new file mode 100644 index 0000000..b007965 --- /dev/null +++ b/actions/vm/create/package.json @@ -0,0 +1,19 @@ +{ + "name": "vm-create-tests", + "version": "0.0.0", + "private": true, + "description": "Unit tests for the vm/create action", + "license": "MIT", + "engines": { + "node": ">=24.8.0" + }, + "scripts": { + "test": "node --test", + "pretest:ci": "node -e \"require('node:fs').mkdirSync('coverage', { recursive: true })\"", + "test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test" + }, + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/io": "1.1.3" + } +} diff --git a/actions/vm/create/qemu.cjs b/actions/vm/create/qemu.cjs new file mode 100644 index 0000000..541dc7c --- /dev/null +++ b/actions/vm/create/qemu.cjs @@ -0,0 +1,290 @@ +const fs = require("node:fs"); +const net = require("node:net"); +const path = require("node:path"); +const { setTimeout: delay } = require("node:timers/promises"); +const { GuestCreation } = require("./creation.cjs"); +const { Workspace } = require("../_internal/workspace.cjs"); +const { run } = require("../_internal/command.cjs"); + +function fields(value, allowed) { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).some((key) => !allowed.includes(key)) + ) + throw new Error("Invalid QEMU configuration fields"); +} + +class QemuCreation extends GuestCreation { + constructor(config, timeout) { + super(config, timeout); + const value = JSON.parse(fs.readFileSync(this.template, "utf8")); + fields(value, [ + "cpus", + "memoryMiB", + "diskGiB", + "diskSerial", + "iso", + "image", + "seed", + "network", + "ssh", + ]); + for (const [name, maximum] of Object.entries({ + cpus: 16, + memoryMiB: 65536, + diskGiB: 128, + })) { + if ( + !Number.isInteger(value[name]) || + value[name] < 1 || + value[name] > maximum + ) + throw new Error("Invalid QEMU resource allocation"); + } + if (!/^[a-zA-Z0-9_-]{1,20}$/.test(value.diskSerial ?? "")) + throw new Error("Invalid disk serial"); + if ( + Boolean(value.iso) === Boolean(value.image) || + (value.iso && value.seed) + ) + throw new Error( + "Choose an unattended ISO or a disk image with optional seed", + ); + const workspace = new Workspace(); + for (const key of ["iso", "image", "seed"]) { + if (value[key]) value[key] = workspace.file(value[key]); + } + fields(value.network, ["subnet", "address", "gateway", "dns", "mac"]); + for (const key of ["address", "gateway", "dns"]) { + if (net.isIP(value.network[key]) !== 4) + throw new Error("Expected IPv4 networking"); + } + const [subnet, prefix, ...extra] = (value.network.subnet ?? "").split("/"); + if ( + extra.length || + net.isIP(subnet) !== 4 || + !/^(?:[89]|[12][0-9]|30)$/.test(prefix) + ) + throw new Error("Expected an IPv4 subnet with prefix 8 to 30"); + const integer = (address) => + address.split(".").reduce((sum, part) => sum * 256 + Number(part), 0); + const block = 2 ** (32 - Number(prefix)); + const base = integer(subnet); + const addresses = ["address", "gateway", "dns"].map((key) => + integer(value.network[key]), + ); + if ( + base % block || + new Set(addresses).size !== 3 || + addresses.some( + (address) => address <= base || address >= base + block - 1, + ) + ) + throw new Error( + "Network addresses must be distinct hosts in the supplied subnet", + ); + if (!/^02(?::[a-fA-F0-9]{2}){5}$/.test(value.network.mac ?? "")) + throw new Error("Expected a locally administered MAC starting with 02"); + fields(value.ssh, ["user", "privateKey"]); + if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(value.ssh.user ?? "")) + throw new Error("Invalid SSH user"); + value.ssh.privateKey = workspace.file(value.ssh.privateKey); + this.config = value; + } + + async port() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return port; + } + + async prepare(vm, port) { + const config = this.config; + const local = (filename) => path.join(vm.home, filename); + fs.accessSync("/dev/kvm", fs.constants.R_OK | fs.constants.W_OK); + if ( + ["tmpfs", "ramfs"].includes( + await run(vm.exec, "findmnt", [ + "-n", + "-o", + "FSTYPE", + "--target", + vm.home, + ]), + ) + ) + throw new Error("VM disks require disk-backed storage"); + await vm.io.cp(config.ssh.privateKey, local("key")); + fs.chmodSync(local("key"), 0o600); + await vm.io.cp("/usr/share/OVMF/OVMF_VARS_4M.fd", local("uefi.fd")); + for (const key of ["iso", "seed"]) { + if (config[key]) await vm.io.cp(config[key], local(`${key}.iso`)); + } + if (config.image) { + const info = JSON.parse( + await run(vm.exec, "qemu-img", [ + "info", + "-f", + "qcow2", + "--output=json", + config.image, + ]), + ); + if ( + info["backing-filename"] || + info["format-specific"]?.data?.["data-file"] || + !Number.isSafeInteger(info["virtual-size"]) || + info["virtual-size"] > config.diskGiB * 1024 ** 3 + ) + throw new Error( + "Image must be self-contained and fit the configured disk", + ); + await run(vm.exec, "qemu-img", [ + "convert", + "-f", + "qcow2", + "-O", + "raw", + config.image, + local("system.raw"), + ]); + await run(vm.exec, "qemu-img", [ + "resize", + "-f", + "raw", + local("system.raw"), + `${config.diskGiB}G`, + ]); + } else + await run(vm.exec, "qemu-img", [ + "create", + "-f", + "raw", + local("system.raw"), + `${config.diskGiB}G`, + ]); + fs.writeFileSync( + vm.sshConfig, + [ + `Host ${vm.name}`, + " HostName 127.0.0.1", + ` Port ${port}`, + ` User ${config.ssh.user}`, + ` IdentityFile ${local("key")}`, + ` UserKnownHostsFile ${local("known_hosts")}`, + " GlobalKnownHostsFile /dev/null", + " BatchMode yes", + " IdentitiesOnly yes", + " PasswordAuthentication no", + " KbdInteractiveAuthentication no", + " ConnectTimeout 5", + " StrictHostKeyChecking yes", + " LogLevel ERROR", + "", + ].join("\n"), + { mode: 0o600, flag: "wx" }, + ); + } + + async boot(vm, port, install) { + const config = this.config; + const network = config.network; + const local = (filename) => path.join(vm.home, filename); + await run( + vm.exec, + "qemu-system-x86_64", + [ + "-machine", + "q35,accel=kvm", + "-cpu", + "host", + "-smp", + String(config.cpus), + "-m", + String(config.memoryMiB), + "-display", + "none", + "-daemonize", + "-pidfile", + local("pid"), + "-drive", + "if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd", + "-drive", + `if=pflash,format=raw,file=${local("uefi.fd")}`, + "-drive", + `if=none,id=system,format=raw,cache=none,file=${local("system.raw")}`, + "-device", + `virtio-blk-pci,drive=system,serial=${config.diskSerial}`, + "-netdev", + `user,id=lan,net=${network.subnet},host=${network.gateway},dns=${network.dns},dhcpstart=${network.address},hostfwd=tcp:127.0.0.1:${port}-${network.address}:22`, + "-device", + `virtio-net-pci,netdev=lan,mac=${network.mac}`, + "-serial", + `file:${local("serial.log")}`, + ...(install + ? ["-cdrom", local("iso.iso"), "-boot", "order=d"] + : ["-boot", "order=c"]), + ...(!install && config.seed ? ["-cdrom", local("seed.iso")] : []), + ], + { timeout: 30000 }, + ); + } + + async start(vm, core) { + if (await vm.exists()) throw new Error("This invocation already has a VM"); + const deadline = Date.now() + this.startupTimeout * 60000; + try { + const port = await this.port(); + await this.prepare(vm, port); + if (this.config.iso) { + core.info("Booting unattended ISO; waiting for installer power-off."); + await this.boot(vm, port, true); + while (await vm.exists()) { + if (Date.now() >= deadline) + throw new Error("Installer power-off timed out"); + await delay(2000); + } + } + core.info("Booting the system disk; waiting for SSH."); + await this.boot(vm, port, false); + while (Date.now() < deadline) { + if (!(await vm.exists())) + throw new Error("QEMU exited before SSH readiness"); + try { + // TOFU is restricted to this owned loopback endpoint. Later calls use strict checking. + await run( + vm.exec, + "ssh", + [ + "-o", + "StrictHostKeyChecking=accept-new", + "-F", + vm.sshConfig, + vm.name, + "true", + ], + { timeout: 10000 }, + ); + return; + } catch { + await delay(2000); + } + } + throw new Error("Guest SSH readiness timed out"); + } catch (error) { + await vm.destroy(); + throw error; + } + } +} + +module.exports = { QemuCreation }; diff --git a/actions/vm/create/tests/create.test.cjs b/actions/vm/create/tests/create.test.cjs new file mode 100644 index 0000000..fa9a4bf --- /dev/null +++ b/actions/vm/create/tests/create.test.cjs @@ -0,0 +1,295 @@ +const exec = require("@actions/exec"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { suite, test } = require("node:test"); +const { GuestCreation } = require("../creation.cjs"); +const { run } = require("../../_internal/command.cjs"); +const { + limaFixture, + qemuFixture, +} = require("../../../../tests/helpers/vm.cjs"); + +suite("vm/create", () => { + test("configuration must be a checkout file", async (t) => { + // Arrange + const { root } = limaFixture(t); + fs.symlinkSync( + fs.realpathSync( + path.join(__dirname, "../../../../tests/fixture/lima.yml"), + ), + path.join(root, "external.yml"), + ); + for (const config of [ + undefined, + "", + " \n", + "missing.yml", + "../outside.yml", + "external.yml", + ".", + ]) { + // Act + const create = () => new GuestCreation(config); + + // Assert + assert.throws(create); + } + }); + + test("custom startup budgets retain a bounded subprocess deadline", async (t) => { + // Arrange + const { vm } = limaFixture(t); + const spawn = t.mock.method(exec, "exec", (_program, _args, options) => { + options.listeners.stdout(Buffer.from("Host fixture\n")); + return 0; + }); + + // Act + await new GuestCreation("guest.yml", "20").start(vm); + + // Assert + const [program, args, options] = spawn.mock.calls[0].arguments; + assert.equal(program, "timeout"); + assert.deepEqual(args.slice(0, 3), ["--kill-after=5s", "1260s", "limactl"]); + assert.ok(args.includes("--timeout=20m")); + assert.equal(options.env.LIMA_HOME, vm.home); + assert.equal(options.silent, true); + assert.equal(options.shell, undefined); + }); + + test("invalid startup budgets are rejected", async (t) => { + // Arrange + limaFixture(t); + for (const timeout of ["", "0", "-1", "41", "1.5", "unbounded"]) { + // Act + const create = () => new GuestCreation("guest.yml", timeout); + + // Assert + assert.throws(create, /Startup timeout/); + } + }); + + test("repeated creation refuses to reuse or delete a running VM", async (t) => { + // Arrange + const { vm } = limaFixture(t); + t.mock.method(vm, "exists", () => true); + const command = t.mock.method(exec, "exec"); + + // Act + const start = () => new GuestCreation("guest.yml").start(vm); + + // Assert + await assert.rejects(start); + assert.equal(command.mock.callCount(), 0); + }); + + test("startup does not recreate a released runtime", async (t) => { + // Arrange + const { vm } = limaFixture(t); + await vm.destroy(); + const spawn = t.mock.method(exec, "exec"); + + // Act + const start = () => new GuestCreation("guest.yml").start(vm); + + // Assert + await assert.rejects(start, /runtime is absent/); + assert.equal(spawn.mock.callCount(), 0); + assert.equal(fs.existsSync(vm.home), false); + }); + + test("failed creation rolls back its partial guest", async (t) => { + // Arrange + const { vm } = limaFixture(t); + const presence = [false, true, false]; + t.mock.method(vm, "exists", () => presence.shift()); + const command = t.mock.method(exec, "exec", (_program, args) => { + if (args[3] === "start") throw new Error("synthetic failure"); + return 0; + }); + + // Act + const start = () => new GuestCreation("guest.yml").start(vm); + + // Assert + await assert.rejects(start); + assert.deepEqual(command.mock.calls.at(-1).arguments[1].slice(3), [ + "delete", + "--force", + "iac-test", + ]); + assert.equal(fs.existsSync(vm.home), false); + }); + + test("failed rollback preserves recovery files without exposing command output", async (t) => { + // Arrange + const { vm } = limaFixture(t); + const presence = [false, true]; + fs.writeFileSync(vm.sshConfig, "synthetic recovery configuration"); + t.mock.method(vm, "exists", () => presence.shift()); + t.mock.method(exec, "exec", () => { + throw new Error("synthetic-private-output"); + }); + + // Act + const start = () => new GuestCreation("guest.yml").start(vm); + + // Assert + await assert.rejects(start, { message: "VM command failed or timed out" }); + assert.equal( + fs.readFileSync(vm.sshConfig, "utf8"), + "synthetic recovery configuration", + ); + assert.ok(fs.existsSync(vm.home)); + }); + + test("subprocess failures and timeouts cannot expose raw output", async (t) => { + // Arrange + const spawn = t.mock.method(exec, "exec"); + for (const result of [1, 124, new Error("private-command")]) { + // Arrange + spawn.mock.mockImplementation((_program, _args, options) => { + options.listeners.stdout(Buffer.from("private-output")); + options.listeners.stderr(Buffer.from("private-error")); + if (result instanceof Error) throw result; + return result; + }); + + // Act + const list = () => run(exec, "limactl", ["list"]); + + // Assert + await assert.rejects(list, { + message: "VM command failed or timed out", + }); + } + }); + + test("bounded execution preserves literal stdin and rejects oversized output", async () => { + // Arrange + const input = "guest's value; $(false) — UTF-8\n"; + + // Act + const output = await run(exec, "cat", [], { input }); + + // Assert + assert.equal(output, input.trim()); + + // Arrange + for (const stream of ["stdout", "stderr"]) { + const script = `process.${stream}.write("x".repeat(1024 * 1024 + 1))`; + + // Act + const excessive = () => run(exec, process.execPath, ["-e", script]); + + // Assert + await assert.rejects(excessive, { + message: "VM command failed or timed out", + }); + } + }); + + test("command deadlines terminate child processes before they can modify files", async (t) => { + // Arrange + const { root } = limaFixture(t); + const marker = path.join(root, "unexpected-child-output"); + const { setTimeout: delay } = require("node:timers/promises"); + + // Act + const execute = () => + run( + exec, + "bash", + [ + "-c", + '(sleep 0.3; printf unexpected > "$1") & wait', + "fixture", + marker, + ], + { timeout: 100 }, + ); + + // Assert + await assert.rejects(execute, { + message: "VM command failed or timed out", + }); + await delay(400); + assert.equal(fs.existsSync(marker), false); + }); + + test("QEMU configuration requires bounded resources, private media and matching network addresses", async (t) => { + // Arrange + const { creation, config } = qemuFixture(t); + + // Act + const valid = creation(); + + // Assert + assert.equal(valid.config.diskSerial, "TEST_DISK"); + + // Arrange + for (const patch of [ + { cpus: 0 }, + { diskGiB: 129 }, + { memoryMiB: "2048" }, + { iso: "/dev/null" }, + { image: "installer.iso" }, + { seed: "installer.iso" }, + { arguments: "-drive /dev/sda" }, + { diskSerial: "unsafe,drive" }, + { ssh: { user: "root\nProxyCommand bad", privateKey: "key" } }, + { network: { ...config.network, address: "10.0.0.10" } }, + { network: { ...config.network, dns: config.network.gateway } }, + ]) { + // Act + const create = () => creation({ ...config, ...patch }); + + // Assert + assert.throws(create); + } + }); + + test("failed QEMU setup rolls back an allocated runtime", async (t) => { + // Arrange + const { creation, vm } = qemuFixture(t); + const operation = creation(); + t.mock.method(operation, "port", async () => { + throw new Error("synthetic port allocation failure"); + }); + + // Act + const startup = operation.start(vm, { info() {} }); + + // Assert + await assert.rejects(startup); + assert.equal(fs.existsSync(vm.home), false); + }); + + test("installer power-off precedes disk boot and SSH readiness", async (t) => { + // Arrange + const { creation, vm } = qemuFixture(t); + const operation = creation(); + t.mock.method(operation, "port", async () => 2222); + t.mock.method(operation, "prepare", () => {}); + const boots = t.mock.method(operation, "boot", () => {}); + const states = [false, false, true]; + t.mock.method(vm, "exists", () => states.shift()); + const subprocess = t.mock.method(exec, "exec", () => 0); + + // Act + await operation.start(vm, { info() {} }); + + // Assert + assert.deepEqual( + boots.mock.calls.map((call) => call.arguments[2]), + [true, false], + ); + assert.equal(subprocess.mock.calls[0].arguments[1][2], "ssh"); + assert.ok( + subprocess.mock.calls[0].arguments[1].includes( + "StrictHostKeyChecking=accept-new", + ), + ); + }); +}); diff --git a/actions/vm/destroy/.node-version b/actions/vm/destroy/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/actions/vm/destroy/.node-version @@ -0,0 +1 @@ +24 diff --git a/actions/vm/destroy/README.md b/actions/vm/destroy/README.md index fbcaff4..9184516 100644 --- a/actions/vm/destroy/README.md +++ b/actions/vm/destroy/README.md @@ -2,26 +2,35 @@ # ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItdHJhc2gtMiIgY29sb3I9InB1cnBsZSI+PHBvbHlsaW5lIHBvaW50cz0iMyA2IDUgNiAyMSA2Ij48L3BvbHlsaW5lPjxwYXRoIGQ9Ik0xOSA2djE0YTIgMiAwIDAgMS0yIDJIN2EyIDIgMCAwIDEtMi0yVjZtMyAwVjRhMiAyIDAgMCAxIDItMmg0YTIgMiAwIDAgMSAyIDJ2MiI+PC9wYXRoPjxsaW5lIHgxPSIxMCIgeTE9IjExIiB4Mj0iMTAiIHkyPSIxNyI+PC9saW5lPjxsaW5lIHgxPSIxNCIgeTE9IjExIiB4Mj0iMTQiIHkyPSIxNyI+PC9saW5lPjwvc3ZnPg==) GitHub Action: VM - Destroy +
+ VM - Destroy +
+ +--- + [![Marketplace](https://img.shields.io/badge/Marketplace-vm------destroy-blue?logo=github-actions)](https://github.com/marketplace/actions/vm---destroy) [![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-iac)](https://github.com/hoverkraft-tech/ci-github-iac/releases) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-iac)](http://choosealicense.com/licenses/mit/) [![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) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) ## Overview -Delete an owned Lima VM, verify deletion and remove its runtime directory. +Delete an owned VM, verify deletion and remove its runtime directory. Destroy the guest and verify absence before removing its private runtime and keys. ### Schedule cleanup Pass the original `vm` output from [vm/create](../create/README.md) on the same -runner. Run this step after all [vm/test](../test/README.md) calls and reporting: +runner. Cleanup selects the matching backend automatically. Run this step after +all VM and Ansible tests and reporting, including if an earlier step failed: ```yaml - name: Destroy VM @@ -38,9 +47,9 @@ additional GitHub permission is needed. Do not suppress cleanup failures. ### Verification and recovery Destruction accepts only an owned private runtime created by `vm/create`. It -verifies guest absence before deleting the runtime and SSH keys. `removed` is -true only after verified cleanup. Lima deletion and verification commands have -two-minute deadlines and a 1 MiB output limit per stream. +verifies guest absence before deleting the runtime and SSH keys. +Backend operations have bounded deadlines; +destruction fails if it cannot verify removal of the owned VM. Failed cleanup fails the step and retains the runtime for recovery. Retry with another `vm/destroy` step using `if: always()` and the original `vm` output on @@ -59,7 +68,7 @@ and cloud resource cleanup are outside scope. ```yaml - uses: hoverkraft-tech/ci-github-iac/actions/vm/destroy@5c4b7307d7a2c58a01a3b62607cebde65f96b0a2 # 0.2.0 with: - # Private runtime directory returned by vm/create; repeated deletion is safe. + # VM reference returned by vm/create in the same job; works with either creation backend. Repeated deletion is safe. # This input is required. vm: "" ``` @@ -69,29 +78,38 @@ and cloud resource cleanup are outside scope. ## Inputs -| **Input** | **Description** | **Required** | **Default** | -| --------- | --------------------------------------------------------------------------- | ------------ | ----------- | -| **`vm`** | Private runtime directory returned by vm/create; repeated deletion is safe. | **true** | - | +| **Input** | **Description** | **Required** | **Default** | +| --------- | ------------------------------------------------------------------------------------------------------------------ | ------------ | ----------- | +| **`vm`** | VM reference returned by vm/create in the same job; works with either creation backend. Repeated deletion is safe. | **true** | - | - -## Outputs - -| **Output** | **Description** | -| ------------- | --------------------------------------------------------------- | -| **`removed`** | True only after verified deletion of the guest and its runtime. | - + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) for more details. + + +## License + +This project is licensed under the MIT License. + +SPDX-License-Identifier: MIT + +Copyright © 2026 Hoverkraft + +For more details, see the [license](http://choosealicense.com/licenses/mit/). + diff --git a/actions/vm/destroy/action.yml b/actions/vm/destroy/action.yml index c2def6e..d82f72e 100644 --- a/actions/vm/destroy/action.yml +++ b/actions/vm/destroy/action.yml @@ -3,7 +3,8 @@ # ### Schedule cleanup # # Pass the original `vm` output from [vm/create](../create/README.md) on the same -# runner. Run this step after all [vm/test](../test/README.md) calls and reporting: +# runner. Cleanup selects the matching backend automatically. Run this step after +# all VM and Ansible tests and reporting, including if an earlier step failed: # # ```yaml # - name: Destroy VM @@ -20,9 +21,9 @@ # ### Verification and recovery # # Destruction accepts only an owned private runtime created by `vm/create`. It -# verifies guest absence before deleting the runtime and SSH keys. `removed` is -# true only after verified cleanup. Lima deletion and verification commands have -# two-minute deadlines and a 1 MiB output limit per stream. +# verifies guest absence before deleting the runtime and SSH keys. +# Backend operations have bounded deadlines; +# destruction fails if it cannot verify removal of the owned VM. # # Failed cleanup fails the step and retains the runtime for recovery. Retry with # another `vm/destroy` step using `if: always()` and the original `vm` output on @@ -34,19 +35,15 @@ # and cloud resource cleanup are outside scope. --- name: VM - Destroy -description: Delete an owned Lima VM, verify deletion and remove its runtime directory. +description: Delete an owned VM, verify deletion and remove its runtime directory. author: Hoverkraft branding: icon: trash-2 color: purple inputs: vm: - description: Private runtime directory returned by vm/create; repeated deletion is safe. + description: VM reference returned by vm/create in the same job; works with either creation backend. Repeated deletion is safe. required: true -outputs: - removed: - description: True only after verified deletion of the guest and its runtime. - value: ${{ steps.destroy.outputs.removed }} runs: using: composite steps: @@ -54,7 +51,16 @@ runs: id: destroy uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - VM_ACTION_PATH: ${{ github.action_path }} + ACTION_PATH: ${{ github.action_path }} VM_DIRECTORY: ${{ inputs.vm }} with: - script: require(`${process.env.VM_ACTION_PATH}/index.cjs`)({ core }); + script: | + const { VirtualMachine } = require(`${process.env.ACTION_PATH}/../_internal/runtime.cjs`); + + try { + await new VirtualMachine(process.env.VM_DIRECTORY, { exec, io }).destroy(); + } catch { + core.setFailed( + "VM destruction failed. The runtime directory is retained for recovery.", + ); + } diff --git a/actions/vm/destroy/index.cjs b/actions/vm/destroy/index.cjs deleted file mode 100644 index 0139838..0000000 --- a/actions/vm/destroy/index.cjs +++ /dev/null @@ -1,12 +0,0 @@ -const { VirtualMachine } = require("../_internal/runtime.cjs"); - -module.exports = ({ core }) => { - try { - new VirtualMachine(process.env.VM_DIRECTORY).destroy(); - core.setOutput("removed", "true"); - } catch { - core.setFailed( - "VM destruction failed. The runtime directory is retained for recovery.", - ); - } -}; diff --git a/actions/vm/destroy/package-lock.json b/actions/vm/destroy/package-lock.json new file mode 100644 index 0000000..d1ab97c --- /dev/null +++ b/actions/vm/destroy/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "vm-destroy-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vm-destroy-tests", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/io": "1.1.3" + }, + "engines": { + "node": ">=24.8.0" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/actions/vm/destroy/package.json b/actions/vm/destroy/package.json new file mode 100644 index 0000000..c948c84 --- /dev/null +++ b/actions/vm/destroy/package.json @@ -0,0 +1,19 @@ +{ + "name": "vm-destroy-tests", + "version": "0.0.0", + "private": true, + "description": "Unit tests for the vm/destroy action", + "license": "MIT", + "engines": { + "node": ">=24.8.0" + }, + "scripts": { + "test": "node --test", + "pretest:ci": "node -e \"require('node:fs').mkdirSync('coverage', { recursive: true })\"", + "test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test" + }, + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/io": "1.1.3" + } +} diff --git a/actions/vm/destroy/tests/destroy.test.cjs b/actions/vm/destroy/tests/destroy.test.cjs new file mode 100644 index 0000000..b98f6f5 --- /dev/null +++ b/actions/vm/destroy/tests/destroy.test.cjs @@ -0,0 +1,127 @@ +const exec = require("@actions/exec"); +const io = require("@actions/io"); +const helpers = { exec, io }; +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { suite, test } = require("node:test"); +const { VirtualMachine } = require("../../_internal/runtime.cjs"); +const { + limaFixture, + qemuFixture, +} = require("../../../../tests/helpers/vm.cjs"); + +suite("vm/destroy", () => { + test("shared, unmanaged and symlinked runtimes are rejected", async (t) => { + // Arrange + const { root, vm } = limaFixture(t); + for (const directory of ["", root, "/tmp/not-owned"]) { + // Act + const destroy = () => new VirtualMachine(directory, helpers).destroy(); + + // Assert + assert.throws(destroy); + } + + // Arrange + fs.chmodSync(vm.home, 0o755); + + // Act + const destroyShared = () => vm.destroy(); + + // Assert + await assert.rejects(destroyShared); + + // Arrange + fs.rmSync(vm.home, { recursive: true }); + fs.symlinkSync(path.join(root, "missing"), vm.home); + + // Act + const destroySymlink = () => vm.destroy(); + + // Assert + await assert.rejects(destroySymlink); + assert.ok(fs.existsSync(path.join(root, "guest.yml"))); + }); + + test("an empty runtime can be destroyed without installed Lima", async (t) => { + // Arrange + const { vm } = limaFixture(t); + const command = t.mock.method(exec, "exec"); + + // Act + await vm.destroy(); + + // Assert + assert.equal(command.mock.callCount(), 0); + assert.equal(fs.existsSync(vm.home), false); + }); + + test("failed destruction preserves recovery access", async (t) => { + // Arrange + const { vm } = limaFixture(t); + fs.writeFileSync(vm.sshConfig, "synthetic recovery configuration"); + t.mock.method(VirtualMachine.prototype, "exists", () => true); + t.mock.method(exec, "exec", () => 0); + + // Act + const destroy = () => vm.destroy(); + + // Assert + await assert.rejects(destroy, /still exists/); + assert.equal( + fs.readFileSync(vm.sshConfig, "utf8"), + "synthetic recovery configuration", + ); + }); + test("QEMU runtime selection rejects unmanaged paths and unsafe ownership", async (t) => { + // Arrange + const { vm, root } = qemuFixture(t); + + // Act + const selected = new VirtualMachine(vm.home, helpers); + const openUnmanaged = () => new VirtualMachine(root, helpers); + + // Assert + assert.equal(selected.backend, "qemu"); + assert.throws(openUnmanaged); + + // Arrange + fs.chmodSync(vm.home, 0o755); + + // Act + const destroyShared = () => vm.destroy(); + + // Assert + await assert.rejects(destroyShared); + + // Arrange + fs.chmodSync(vm.home, 0o700); + fs.rmSync(vm.home, { recursive: true }); + fs.symlinkSync(root, vm.home); + + // Act + const destroySymlink = () => vm.destroy(); + + // Assert + await assert.rejects(destroySymlink); + assert.ok(fs.existsSync(path.join(root, "key"))); + }); + + test("cleanup never signals another process or deletes its recovery files", async (t) => { + // Arrange + const { vm } = qemuFixture(t); + fs.writeFileSync(path.join(vm.home, "pid"), String(process.pid)); + const kill = t.mock.method(process, "kill", () => + assert.fail("Must not signal an unrelated process"), + ); + + // Act + const destroy = () => vm.destroy(); + + // Assert + await assert.rejects(destroy, /ownership/); + assert.equal(kill.mock.callCount(), 0); + assert.ok(fs.existsSync(vm.home)); + }); +}); diff --git a/actions/vm/test/.node-version b/actions/vm/test/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/actions/vm/test/.node-version @@ -0,0 +1 @@ +24 diff --git a/actions/vm/test/README.md b/actions/vm/test/README.md index 394b42e..99b875a 100644 --- a/actions/vm/test/README.md +++ b/actions/vm/test/README.md @@ -2,98 +2,72 @@ # ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hlY2stY2lyY2xlIiBjb2xvcj0icHVycGxlIj48cGF0aCBkPSJNMjIgMTEuMDhWMTJhMTAgMTAgMCAxIDEtNS45My05LjE0Ij48L3BhdGg+PHBvbHlsaW5lIHBvaW50cz0iMjIgNCAxMiAxNC4wMSA5IDExLjAxIj48L3BvbHlsaW5lPjwvc3ZnPg==) GitHub Action: VM - Test +
+ VM - Test +
+ +--- + [![Marketplace](https://img.shields.io/badge/Marketplace-vm------test-blue?logo=github-actions)](https://github.com/marketplace/actions/vm---test) [![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-iac)](https://github.com/hoverkraft-tech/ci-github-iac/releases) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-iac)](http://choosealicense.com/licenses/mit/) [![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) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) ## Overview -Run guest test commands, parse reports, comment on pull requests and upload results. +Validate VM state with Goss, publish summaries and PR comments, and upload JUnit results. -Execute guest tests, collect reports and publish coverage results. +Validate a disposable Linux VM with Goss and publish JUnit results. -### Execute guest commands +Pass the `vm` output from [vm/create](../create/README.md) in the same job and +a required checkout-relative `goss-file`. The action works with both creation +backends. Always schedule [vm/destroy](../destroy/README.md), including after +assertion or report publication failure. Testing leaves the VM running. -Pass the `vm` output of [vm/create](../create/README.md) from the same job and -exactly one of `command` or `test-script`. Commands accept a YAML multiline -block; a script must be a nonempty file within the checkout. Both execute in -guest Bash with `-euo pipefail`, through standard input, starting in `/tmp`. -Checkout files and host environment variables are not copied into the guest. -Use self-contained scripts or code prepared by Lima provisioning. Nonzero exit -or timeout fails the action; the timeout accepts 1 to 40 minutes, default 10. +### Write a suite -Testing leaves the guest running for additional test steps. Always schedule -[vm/destroy](../destroy/README.md) afterward, including after test or reporting -failure. The [action-test workflow](../../../.github/workflows/__test-actions-vm.yml) -demonstrates composition and failure assertions. +Use Goss resources for files, packages, services, users and commands. Keep +deployment, idempotence and reboot in Ansible Molecule scenarios. Complex checks +can invoke consumer-owned probes already installed in the guest; checkout files +other than the rendered suite are not copied automatically. -### Collect test-generated reports +The action downloads and verifies the pinned Goss release, renders the suite, +includes and optional `goss-vars` on the runner, then copies the binary and +rendered suite to the guest. Templates therefore use runner environment values; +pass guest-specific expectations through `goss-vars`. Goss runs as the SSH user +through a fresh login so group changes made during provisioning take effect. +Both runner and guest must be Linux x86-64. The guest needs Bash, uname, chmod, +GNU find and an SSH file-copy service. Consumers need no Goss installation. -Configure test tools to write into the fresh guest directory in `REPORTS_DIR`. -Set `report-file` to relative paths or glob patterns, for example: +Setup and execution share `test-timeout-minutes` (1 to 40, default 10). +Collection has a separate two-minute limit and accepts only the native Goss +report, a nonempty regular file up to 20 MiB. Missing, empty and fully skipped +suites fail. Reports are collected after failed assertions; private guest test +files are then removed. Incomplete transfers are never published. -```yaml -report-file: | - junit.xml - coverage/*.info -``` +### Publish results -Lists accept newlines, commas or semicolons. Basic `*`, `**`, `?` and character -class globs are supported. Use filenames containing letters, digits, spaces, -dots, underscores or hyphens. Absolute paths and parent traversal are rejected; -symbolic links are excluded. Every pattern must match a nonempty regular file. -Collection is limited to 100 files, 20 MiB total and two minutes. Guests need -Bash, GNU find and an SSH file-copy service, as provided by the Ubuntu fixture. - -Collection is attempted after command failure or timeout, then the guest report -directory is removed. Missing files, incomplete transfers and cleanup errors -fail the action. An incomplete collection exports no guest files. Completed -files retain their relative names in an isolated host directory; `report-files` -lists their absolute paths. See the [success](../../../tests/fixture/assert.sh) -and [failure](../../../tests/fixture/fail.sh) fixtures for report examples. - -Each invocation keeps separate reports under `runner.temp`; later calls preserve -earlier results. `report` is a sanitized command-status JUnit file without raw -command output or exceptions. Only completed files are exported; failed writes -discard partial output and preserve completed guest reports. Collected tool -reports retain their original contents. Use synthetic data and exclude secrets, -environment dumps and private logs from reports published by public workflows. +Each invocation publishes native JUnit plus a sanitized execution-status report +through Hoverkraft's report parser: job summary, annotations and, on eligible +pull requests, an updated comment. Use a distinct stable `report-name` for each +step and matrix scenario. Comments use `github.token` and require +`pull-requests: write`; forks and Dependabot still receive summaries and artifacts. +Do not use `pull_request_target` to execute untrusted suites. -### Publish results +Completed reports are retained as a unique GitHub artifact for seven days, even +after parser or comment failure. Raw command output and exceptions are excluded +from the execution-status report. Native Goss reports retain assertion details: +use synthetic expectations and never match or print credentials. Infrastructure +assertions produce test results, not application code coverage. -| `coverage` | Behavior | Job permission | -| ------------------ | ------------------------------------------ | ----------------------------------- | -| `github` (default) | Summaries, annotations and PR comments | `pull-requests: write` for comments | -| `codecov` | Upload selected guest reports through OIDC | `id-token: write` | -| `""` | Skip parsing, comments and Codecov | No additional permission | - -GitHub mode uses Hoverkraft's `parse-ci-reports` and `create-or-update-comment` -actions. It parses collected files, falling back to the command-status report -when none are available. `path-mapping` translates guest paths to checkout paths; -`summary`, `markdown` and `parsed-files` expose parser results. The parser manages -its own dependencies. - -Choose a distinct, stable `report-name` for each test step and matrix scenario. -Reruns update the matching comment. `github-token` defaults to `github.token`. -Comments are limited to same-repository pull requests, excluding Dependabot; -forks still receive summaries and artifacts. Do not use `pull_request_target` -to execute untrusted tests. - -Codecov mode requires a repository enabled in Codecov and explicit `report-file` -selection. Discovery is disabled: only collected guest files are uploaded, never -the command-status wrapper. Configure Codecov path fixes when guest source paths -differ from repository paths. Upload errors fail the action. - -Every mode uploads completed reports as a GitHub artifact with a unique name and -seven-day retention; `artifact-id` identifies the upload. Upload still runs after -parser, comment or Codecov errors. Test and reporting failures remain failures. -No extra reporting steps are needed in the workflow. +See the [lifecycle example](../create/README.md#compose-the-vm-actions). @@ -103,44 +77,24 @@ No extra reporting steps are needed in the workflow. ```yaml - uses: hoverkraft-tech/ci-github-iac/actions/vm/test@5c4b7307d7a2c58a01a3b62607cebde65f96b0a2 # 0.2.0 with: - # Private runtime directory returned by vm/create. + # VM reference returned by vm/create in the same job; works with either backend. # This input is required. vm: "" - # Bash command or multiline commands to execute inside the guest from /tmp. - # Provide exactly one of command or test-script. Checkout files are not mounted in the guest. - command: "" + # Required checkout-relative Goss suite, including any referenced Goss files. + # This input is required. + goss-file: "" - # Nonempty Bash script in the checkout to send to the guest instead of command. - test-script: "" + # Optional checkout-relative JSON or YAML variables file. Use values suitable for public reports. + goss-vars: "" - # Guest command timeout, from 1 to 40 minutes. + # Combined Goss setup and execution timeout, from 1 to 40 minutes. # Default: `10` test-timeout-minutes: "10" - # Optional report paths or glob patterns relative to the guest REPORTS_DIR. - # Separate patterns with newlines, commas or semicolons. Every pattern must match. - # Commands receive a fresh REPORTS_DIR; matched files are collected even after test failure. - # Report contents are preserved. Use only reports suitable for public CI. - report-file: "" - - # Coverage reporter; `github` for summaries and PR comments, `codecov` for upload, - # or empty to disable reporting. Report artifacts are always retained. - # Codecov requires report-file selection and id-token write permission for OIDC. - # - # Default: `github` - coverage: github - # Summary and PR comment title. Use a distinct stable name for each test step and matrix scenario. # Default: `VM test results` report-name: VM test results - - # Optional path mappings forwarded to Hoverkraft's report parser. - path-mapping: "" - - # Token for coverage PR comments. Requires pull-requests write; fork and Dependabot PR comments are skipped. - # Default: `${{ github.token }}` - github-token: ${{ github.token }} ``` @@ -148,48 +102,42 @@ No extra reporting steps are needed in the workflow. ## Inputs -| **Input** | **Description** | **Required** | **Default** | -| -------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------ | --------------------- | -| **`vm`** | Private runtime directory returned by vm/create. | **true** | - | -| **`command`** | Bash command or multiline commands to execute inside the guest from /tmp. | **false** | - | -| | Provide exactly one of command or test-script. Checkout files are not mounted in the guest. | | | -| **`test-script`** | Nonempty Bash script in the checkout to send to the guest instead of command. | **false** | - | -| **`test-timeout-minutes`** | Guest command timeout, from 1 to 40 minutes. | **false** | `10` | -| **`report-file`** | Optional report paths or glob patterns relative to the guest REPORTS_DIR. | **false** | - | -| | Separate patterns with newlines, commas or semicolons. Every pattern must match. | | | -| | Commands receive a fresh REPORTS_DIR; matched files are collected even after test failure. | | | -| | Report contents are preserved. Use only reports suitable for public CI. | | | -| **`coverage`** | Coverage reporter; `github` for summaries and PR comments, `codecov` for upload, | **false** | `github` | -| | or empty to disable reporting. Report artifacts are always retained. | | | -| | Codecov requires report-file selection and id-token write permission for OIDC. | | | -| **`report-name`** | Summary and PR comment title. Use a distinct stable name for each test step and matrix scenario. | **false** | `VM test results` | -| **`path-mapping`** | Optional path mappings forwarded to Hoverkraft's report parser. | **false** | - | -| **`github-token`** | Token for coverage PR comments. Requires pull-requests write; fork and Dependabot PR comments are skipped. | **false** | `${{ github.token }}` | +| **Input** | **Description** | **Required** | **Default** | +| -------------------------- | ------------------------------------------------------------------------------------------------ | ------------ | ----------------- | +| **`vm`** | VM reference returned by vm/create in the same job; works with either backend. | **true** | - | +| **`goss-file`** | Required checkout-relative Goss suite, including any referenced Goss files. | **true** | - | +| **`goss-vars`** | Optional checkout-relative JSON or YAML variables file. Use values suitable for public reports. | **false** | - | +| **`test-timeout-minutes`** | Combined Goss setup and execution timeout, from 1 to 40 minutes. | **false** | `10` | +| **`report-name`** | Summary and PR comment title. Use a distinct stable name for each test step and matrix scenario. | **false** | `VM test results` | - -## Outputs - -| **Output** | **Description** | -| ------------------ | ------------------------------------------------------------------------------------------------------------- | -| **`report`** | Absolute path of the sanitized command-status JUnit report, including on assertion or collection failure. | -| **`report-files`** | Newline-separated absolute paths of completed guest report files. Empty when collection is disabled or fails. | -| **`summary`** | Parsed test and coverage summary in GitHub mode. | -| **`markdown`** | Markdown used for the coverage PR comment in GitHub mode. | -| **`parsed-files`** | JSON array of report files parsed in GitHub mode. | -| **`artifact-id`** | Uploaded report artifact identifier. | - + +## Contributing + +Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-iac/blob/main/CONTRIBUTING.md) for more details. + + +## License + +This project is licensed under the MIT License. + +SPDX-License-Identifier: MIT + +Copyright © 2026 Hoverkraft + +For more details, see the [license](http://choosealicense.com/licenses/mit/). + diff --git a/actions/vm/test/action.yml b/actions/vm/test/action.yml index f91a101..c270be9 100644 --- a/actions/vm/test/action.yml +++ b/actions/vm/test/action.yml @@ -1,191 +1,126 @@ -# Execute guest tests, collect reports and publish coverage results. +# Validate a disposable Linux VM with Goss and publish JUnit results. # -# ### Execute guest commands +# Pass the `vm` output from [vm/create](../create/README.md) in the same job and +# a required checkout-relative `goss-file`. The action works with both creation +# backends. Always schedule [vm/destroy](../destroy/README.md), including after +# assertion or report publication failure. Testing leaves the VM running. # -# Pass the `vm` output of [vm/create](../create/README.md) from the same job and -# exactly one of `command` or `test-script`. Commands accept a YAML multiline -# block; a script must be a nonempty file within the checkout. Both execute in -# guest Bash with `-euo pipefail`, through standard input, starting in `/tmp`. -# Checkout files and host environment variables are not copied into the guest. -# Use self-contained scripts or code prepared by Lima provisioning. Nonzero exit -# or timeout fails the action; the timeout accepts 1 to 40 minutes, default 10. +# ### Write a suite # -# Testing leaves the guest running for additional test steps. Always schedule -# [vm/destroy](../destroy/README.md) afterward, including after test or reporting -# failure. The [action-test workflow](../../../.github/workflows/__test-actions-vm.yml) -# demonstrates composition and failure assertions. +# Use Goss resources for files, packages, services, users and commands. Keep +# deployment, idempotence and reboot in Ansible Molecule scenarios. Complex checks +# can invoke consumer-owned probes already installed in the guest; checkout files +# other than the rendered suite are not copied automatically. # -# ### Collect test-generated reports +# The action downloads and verifies the pinned Goss release, renders the suite, +# includes and optional `goss-vars` on the runner, then copies the binary and +# rendered suite to the guest. Templates therefore use runner environment values; +# pass guest-specific expectations through `goss-vars`. Goss runs as the SSH user +# through a fresh login so group changes made during provisioning take effect. +# Both runner and guest must be Linux x86-64. The guest needs Bash, uname, chmod, +# GNU find and an SSH file-copy service. Consumers need no Goss installation. # -# Configure test tools to write into the fresh guest directory in `REPORTS_DIR`. -# Set `report-file` to relative paths or glob patterns, for example: -# -# ```yaml -# report-file: | -# junit.xml -# coverage/*.info -# ``` -# -# Lists accept newlines, commas or semicolons. Basic `*`, `**`, `?` and character -# class globs are supported. Use filenames containing letters, digits, spaces, -# dots, underscores or hyphens. Absolute paths and parent traversal are rejected; -# symbolic links are excluded. Every pattern must match a nonempty regular file. -# Collection is limited to 100 files, 20 MiB total and two minutes. Guests need -# Bash, GNU find and an SSH file-copy service, as provided by the Ubuntu fixture. -# -# Collection is attempted after command failure or timeout, then the guest report -# directory is removed. Missing files, incomplete transfers and cleanup errors -# fail the action. An incomplete collection exports no guest files. Completed -# files retain their relative names in an isolated host directory; `report-files` -# lists their absolute paths. See the [success](../../../tests/fixture/assert.sh) -# and [failure](../../../tests/fixture/fail.sh) fixtures for report examples. -# -# Each invocation keeps separate reports under `runner.temp`; later calls preserve -# earlier results. `report` is a sanitized command-status JUnit file without raw -# command output or exceptions. Only completed files are exported; failed writes -# discard partial output and preserve completed guest reports. Collected tool -# reports retain their original contents. Use synthetic data and exclude secrets, -# environment dumps and private logs from reports published by public workflows. +# Setup and execution share `test-timeout-minutes` (1 to 40, default 10). +# Collection has a separate two-minute limit and accepts only the native Goss +# report, a nonempty regular file up to 20 MiB. Missing, empty and fully skipped +# suites fail. Reports are collected after failed assertions; private guest test +# files are then removed. Incomplete transfers are never published. # # ### Publish results # -# | `coverage` | Behavior | Job permission | -# | ------------------ | ------------------------------------------ | ----------------------------------- | -# | `github` (default) | Summaries, annotations and PR comments | `pull-requests: write` for comments | -# | `codecov` | Upload selected guest reports through OIDC | `id-token: write` | -# | `""` | Skip parsing, comments and Codecov | No additional permission | +# Each invocation publishes native JUnit plus a sanitized execution-status report +# through Hoverkraft's report parser: job summary, annotations and, on eligible +# pull requests, an updated comment. Use a distinct stable `report-name` for each +# step and matrix scenario. Comments use `github.token` and require +# `pull-requests: write`; forks and Dependabot still receive summaries and artifacts. +# Do not use `pull_request_target` to execute untrusted suites. # -# GitHub mode uses Hoverkraft's `parse-ci-reports` and `create-or-update-comment` -# actions. It parses collected files, falling back to the command-status report -# when none are available. `path-mapping` translates guest paths to checkout paths; -# `summary`, `markdown` and `parsed-files` expose parser results. The parser manages -# its own dependencies. +# Completed reports are retained as a unique GitHub artifact for seven days, even +# after parser or comment failure. Raw command output and exceptions are excluded +# from the execution-status report. Native Goss reports retain assertion details: +# use synthetic expectations and never match or print credentials. Infrastructure +# assertions produce test results, not application code coverage. # -# Choose a distinct, stable `report-name` for each test step and matrix scenario. -# Reruns update the matching comment. `github-token` defaults to `github.token`. -# Comments are limited to same-repository pull requests, excluding Dependabot; -# forks still receive summaries and artifacts. Do not use `pull_request_target` -# to execute untrusted tests. -# -# Codecov mode requires a repository enabled in Codecov and explicit `report-file` -# selection. Discovery is disabled: only collected guest files are uploaded, never -# the command-status wrapper. Configure Codecov path fixes when guest source paths -# differ from repository paths. Upload errors fail the action. -# -# Every mode uploads completed reports as a GitHub artifact with a unique name and -# seven-day retention; `artifact-id` identifies the upload. Upload still runs after -# parser, comment or Codecov errors. Test and reporting failures remain failures. -# No extra reporting steps are needed in the workflow. +# See the [lifecycle example](../create/README.md#compose-the-vm-actions). --- name: VM - Test -description: Run guest test commands, parse reports, comment on pull requests and upload results. +description: Validate VM state with Goss, publish summaries and PR comments, and upload JUnit results. author: Hoverkraft branding: icon: check-circle color: purple inputs: vm: - description: Private runtime directory returned by vm/create. + description: VM reference returned by vm/create in the same job; works with either backend. required: true - command: - description: | - Bash command or multiline commands to execute inside the guest from /tmp. - Provide exactly one of command or test-script. Checkout files are not mounted in the guest. - default: "" - test-script: - description: Nonempty Bash script in the checkout to send to the guest instead of command. + goss-file: + description: Required checkout-relative Goss suite, including any referenced Goss files. + required: true + goss-vars: + description: Optional checkout-relative JSON or YAML variables file. Use values suitable for public reports. default: "" test-timeout-minutes: - description: Guest command timeout, from 1 to 40 minutes. + description: Combined Goss setup and execution timeout, from 1 to 40 minutes. default: "10" - report-file: - description: | - Optional report paths or glob patterns relative to the guest REPORTS_DIR. - Separate patterns with newlines, commas or semicolons. Every pattern must match. - Commands receive a fresh REPORTS_DIR; matched files are collected even after test failure. - Report contents are preserved. Use only reports suitable for public CI. - default: "" - coverage: - description: | - Coverage reporter; `github` for summaries and PR comments, `codecov` for upload, - or empty to disable reporting. Report artifacts are always retained. - Codecov requires report-file selection and id-token write permission for OIDC. - default: github report-name: description: Summary and PR comment title. Use a distinct stable name for each test step and matrix scenario. default: VM test results - path-mapping: - description: Optional path mappings forwarded to Hoverkraft's report parser. - default: "" - github-token: - description: Token for coverage PR comments. Requires pull-requests write; fork and Dependabot PR comments are skipped. - default: ${{ github.token }} -outputs: - report: - description: Absolute path of the sanitized command-status JUnit report, including on assertion or collection failure. - value: ${{ steps.test.outputs.report }} - report-files: - description: Newline-separated absolute paths of completed guest report files. Empty when collection is disabled or fails. - value: ${{ steps.test.outputs.report-files }} - summary: - description: Parsed test and coverage summary in GitHub mode. - value: ${{ steps.parse-coverage-reports.outputs.summary }} - markdown: - description: Markdown used for the coverage PR comment in GitHub mode. - value: ${{ steps.parse-coverage-reports.outputs.markdown }} - parsed-files: - description: JSON array of report files parsed in GitHub mode. - value: ${{ steps.parse-coverage-reports.outputs.parsed-files }} - artifact-id: - description: Uploaded report artifact identifier. - value: ${{ steps.upload.outputs.artifact-id }} runs: using: composite steps: - - name: Run tests + - name: Run Goss assertions id: test uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - VM_ACTION_PATH: ${{ github.action_path }} + ACTION_PATH: ${{ github.action_path }} VM_DIRECTORY: ${{ inputs.vm }} - TEST_COMMAND: ${{ inputs.command }} - TEST_SCRIPT: ${{ inputs.test-script }} + GOSS_FILE: ${{ inputs.goss-file }} + GOSS_VARS: ${{ inputs.goss-vars }} TEST_TIMEOUT_MINUTES: ${{ inputs.test-timeout-minutes }} - REPORT_FILE: ${{ inputs.report-file }} - COVERAGE: ${{ inputs.coverage }} with: - script: require(`${process.env.VM_ACTION_PATH}/index.cjs`)({ core }); + script: | + const { Guest } = require(`${process.env.ACTION_PATH}/guest.cjs`); + const { GuestAssertions } = require(`${process.env.ACTION_PATH}/assertions.cjs`); + try { + const assertions = new GuestAssertions( + { goss: process.env.GOSS_FILE, variables: process.env.GOSS_VARS }, + process.env.RUNNER_TEMP, + process.env.TEST_TIMEOUT_MINUTES, + ); + try { + await assertions.run(new Guest(process.env.VM_DIRECTORY, { exec }), { exec, io }); + } finally { + if (assertions.report || assertions.reportFiles?.length) + core.setOutput("id", assertions.id); + if (assertions.report) core.setOutput("report", assertions.report); + if (assertions.reportFiles?.length) + core.setOutput("report-files", assertions.reportFiles.join("\n")); + } + } catch { + core.setFailed("Goss assertions or report collection failed. Inspect the available reports."); + } - - name: Parse coverage reports - if: ${{ always() && inputs.coverage == 'github' && (steps.test.outputs.report-files != '' || steps.test.outputs.report != '') }} - id: parse-coverage-reports + - name: Parse test reports + if: ${{ always() && (steps.test.outputs.report-files != '' || steps.test.outputs.report != '') }} + id: parse-test-reports uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1 with: report-name: ${{ inputs.report-name }} - report-paths: ${{ steps.test.outputs.report-files || steps.test.outputs.report }} - path-mapping: ${{ inputs.path-mapping }} + report-paths: | + ${{ steps.test.outputs.report }} + ${{ steps.test.outputs.report-files }} output-format: summary,markdown,annotations - - name: Add coverage PR comment + - name: Add test PR comment if: >- - ${{ always() && inputs.coverage == 'github' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && steps.parse-coverage-reports.outputs.markdown != '' }} + ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && steps.parse-test-reports.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-coverage-reports.outputs.markdown }} - token: ${{ inputs.github-token }} - - - name: Upload coverage to Codecov - if: ${{ always() && inputs.coverage == 'codecov' && steps.test.outputs.codecov-files != '' }} - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 - with: - files: ${{ steps.test.outputs.codecov-files }} - disable_search: true - use_oidc: true - disable_telem: true - fail_ci_if_error: true + body: ${{ steps.parse-test-reports.outputs.markdown }} + token: ${{ github.token }} - - name: Upload test and coverage reports + - name: Upload test reports if: ${{ always() && (steps.test.outputs.report-files != '' || steps.test.outputs.report != '') }} id: upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/actions/vm/test/assertions.cjs b/actions/vm/test/assertions.cjs index 3ff85db..d624f25 100644 --- a/actions/vm/test/assertions.cjs +++ b/actions/vm/test/assertions.cjs @@ -1,10 +1,10 @@ const fs = require("node:fs"); const path = require("node:path"); -const { Workspace } = require("../_internal/workspace.cjs"); const { GuestReports } = require("./reports.cjs"); +const { GossSuite } = require("./goss.cjs"); class GuestAssertions { - constructor(source, directory, minutes = "10", reportFiles = "") { + constructor(source, directory, minutes = "10") { if (!directory || !path.isAbsolute(directory)) { throw new Error("Expected an absolute report directory"); } @@ -18,35 +18,31 @@ class GuestAssertions { ); } this.timeout = Number(minutes) * 60000; - this.source = source; this.directory = directory; - this.guestReports = new GuestReports(reportFiles); + if (!source.goss?.trim()) throw new Error("goss-file is required"); + this.goss = new GossSuite(source.goss, source.variables); + this.guestReports = new GuestReports(); } - readCommand() { - const { command = "", script = "" } = this.source; - if (Boolean(command.trim()) === Boolean(script.trim())) - throw new Error("Provide exactly one of command or test-script"); - const content = command.trim() - ? command - : fs.readFileSync(new Workspace().file(script), "utf8"); - if (!content.trim()) throw new Error("The test command must not be empty"); - return content; - } - - run(vm) { + async run(vm, { exec, io }) { this.report = undefined; this.reportFiles = []; - fs.mkdirSync(this.directory, { recursive: true }); + await io.mkdirP(this.directory); const directory = fs.mkdtempSync(path.join(this.directory, "vm-test-")); this.id = path.basename(directory); const report = path.join(directory, "e2e.junit.xml"); const started = performance.now(); let failed = false; try { - const command = this.readCommand(); - const environment = this.guestReports.prepare(vm); - vm.command( + const environment = await this.guestReports.prepare(vm, this.timeout); + const command = await this.goss.prepare( + vm, + directory, + this.guestReports.home, + Math.max(1, Math.ceil(this.timeout - (performance.now() - started))), + { exec, io }, + ); + await vm.command( [ "shell", "--workdir=/tmp", @@ -58,7 +54,10 @@ class GuestAssertions { "-s", ], { - timeout: this.timeout, + timeout: Math.max( + 1, + Math.ceil(this.timeout - (performance.now() - started)), + ), input: command, }, ); @@ -66,12 +65,21 @@ class GuestAssertions { failed = true; } try { - this.reportFiles = this.guestReports.collect(vm, directory); + this.reportFiles = await this.guestReports.collect(vm, directory, { + io, + }); + try { + this.goss.validateReports(this.reportFiles); + } catch (error) { + // Empty native suites cannot replace the failed execution summary. + this.reportFiles = []; + throw error; + } } catch { failed = true; } finally { try { - this.guestReports.cleanup(vm); + await this.guestReports.cleanup(vm, io); } catch { failed = true; } @@ -86,17 +94,16 @@ class GuestAssertions { report, '\n' + `` + - `${failure}\n`, + `${failure}\n`, { flag: "wx" }, ); } catch (error) { - fs.rmSync(report, { force: true }); - if (!this.reportFiles.length) - fs.rmSync(directory, { recursive: true, force: true }); + await io.rmRF(report); + if (!this.reportFiles.length) await io.rmRF(directory); throw error; } this.report = report; - if (failed) throw new Error("Consumer assertions failed"); + if (failed) throw new Error("Goss assertions failed"); } } diff --git a/actions/vm/test/goss.cjs b/actions/vm/test/goss.cjs new file mode 100644 index 0000000..8baf601 --- /dev/null +++ b/actions/vm/test/goss.cjs @@ -0,0 +1,99 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { Workspace } = require("../_internal/workspace.cjs"); +const { run } = require("../_internal/command.cjs"); +const release = require("./goss.json"); + +class GossSuite { + constructor(file, variables = "") { + this.file = new Workspace().file(file); + this.variables = variables ? new Workspace().file(variables) : ""; + } + + async prepare(vm, directory, destination, timeout, { exec, io }) { + const deadline = Date.now() + timeout; + const options = () => { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("Goss setup timed out"); + return { timeout: remaining }; + }; + const platform = await vm.command( + ["shell", "--workdir=/tmp", vm.name, "uname", "-sm"], + options(), + ); + if ( + process.platform !== "linux" || + process.arch !== "x64" || + platform !== "Linux x86_64" + ) + throw new Error( + "Goss currently requires Linux x86-64 on runner and guest", + ); + const tools = fs.mkdtempSync(path.join(directory, "goss-")); + try { + const archive = path.join(tools, "goss.tar.gz"); + await run( + exec, + "curl", + [ + "--fail", + "--silent", + "--show-error", + "--location", + "--proto", + "=https", + "--proto-redir", + "=https", + "--max-time", + "120", + "--output", + archive, + `https://github.com/goss-org/goss/releases/download/v${release.version}/goss_${release.version}_linux_x86_64.tar.gz`, + ], + options(), + ); + const digest = crypto + .createHash("sha256") + .update(fs.readFileSync(archive)) + .digest("hex"); + if (digest !== release.sha256) throw new Error("Goss checksum mismatch"); + await run(exec, "tar", ["-xzf", archive, "-C", tools, "goss"], options()); + const binary = path.join(tools, "goss"); + fs.chmodSync(binary, 0o700); + const rendered = await run( + exec, + binary, + [ + "-g", + this.file, + ...(this.variables ? ["--vars", this.variables] : []), + "render", + ], + options(), + ); + const suite = path.join(tools, "goss.yaml"); + fs.writeFileSync(suite, rendered, { mode: 0o600 }); + await vm.copyTo(binary, `${destination}/goss`, options()); + await vm.copyTo(suite, `${destination}/goss.yaml`, options()); + } finally { + await io.rmRF(tools); + } + return 'chmod 700 "$REPORTS_DIR/goss"\n"$REPORTS_DIR/goss" -g "$REPORTS_DIR/goss.yaml" validate --format junit > "$REPORTS_DIR/goss.xml"\n'; + } + + validateReports(files) { + const report = files.find((file) => path.basename(file) === "goss.xml"); + const xml = report ? fs.readFileSync(report, "utf8") : ""; + const total = Number( + xml.match(/]*\btests="([0-9]+)"/)?.[1] || 0, + ); + const skipped = Number( + xml.match(/]*\bskipped="([0-9]+)"/)?.[1] || 0, + ); + if (total <= skipped || !/]/.test(xml)) + throw new Error("Goss must execute at least one assertion"); + } +} + +module.exports = { GossSuite }; diff --git a/actions/vm/test/goss.json b/actions/vm/test/goss.json new file mode 100644 index 0000000..07ad191 --- /dev/null +++ b/actions/vm/test/goss.json @@ -0,0 +1,4 @@ +{ + "version": "0.4.10", + "sha256": "26e365428946294bcec0c61d867bb3c8349f39feb3d0e6f59084e98632785cc7" +} diff --git a/actions/vm/test/guest.cjs b/actions/vm/test/guest.cjs new file mode 100644 index 0000000..6564d47 --- /dev/null +++ b/actions/vm/test/guest.cjs @@ -0,0 +1,77 @@ +const fs = require("node:fs"); +const { VmReference } = require("../_internal/runtime.cjs"); +const { run } = require("../_internal/command.cjs"); + +const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; + +class Guest extends VmReference { + async copyTo(source, destination, options) { + this.validateRuntime(); + if ( + (this.backend === "qemu" && !(await this.exists())) || + !/^\/tmp\/iac-reports\.[a-zA-Z0-9]+\/[a-zA-Z0-9.-]+$/.test(destination) + ) + throw new Error("Expected a running VM and a private guest test file"); + if (this.backend === "lima") + return this.command( + ["copy", "--backend=scp", source, `${this.name}:${destination}`], + options, + ); + return run( + this.exec, + "scp", + ["-F", this.sshConfig, "--", source, `${this.name}:${destination}`], + options, + ); + } + + async command(args, options = {}) { + this.validateRuntime(); + if (!fs.existsSync(this.home)) throw new Error("VM runtime is absent"); + if (this.backend === "qemu" && !(await this.exists())) + throw new Error("QEMU is not running"); + if ( + args[0] === "shell" && + args[1] === "--workdir=/tmp" && + args[2] === this.name + ) { + return run( + this.exec, + "ssh", + [ + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "-o", + "ControlPersist=no", + "-F", + this.sshConfig, + this.name, + `cd /tmp && ${args.slice(3).map(quote).join(" ")}`, + ], + options, + ); + } + if (this.backend === "lima") + return run(this.exec, "limactl", args, { + ...options, + env: { ...process.env, LIMA_HOME: this.home }, + }); + if ( + args[0] === "copy" && + args[1] === "--backend=scp" && + args[2].startsWith(`${this.name}:/tmp/iac-reports.`) + ) { + return run( + this.exec, + "scp", + ["-F", this.sshConfig, "--", args[2], args[3]], + options, + ); + } + throw new Error("Unsupported QEMU guest operation"); + } +} + +module.exports = { Guest }; diff --git a/actions/vm/test/index.cjs b/actions/vm/test/index.cjs deleted file mode 100644 index 0cd01d9..0000000 --- a/actions/vm/test/index.cjs +++ /dev/null @@ -1,32 +0,0 @@ -const { VirtualMachine } = require("../_internal/runtime.cjs"); -const { GuestAssertions } = require("./assertions.cjs"); - -module.exports = ({ core }) => { - try { - if (!["", "github", "codecov"].includes(process.env.COVERAGE ?? "github")) - throw new Error("Invalid coverage mode"); - if (process.env.COVERAGE === "codecov" && !process.env.REPORT_FILE?.trim()) - throw new Error("Codecov requires explicitly selected reports"); - const assertions = new GuestAssertions( - { command: process.env.TEST_COMMAND, script: process.env.TEST_SCRIPT }, - process.env.RUNNER_TEMP, - process.env.TEST_TIMEOUT_MINUTES, - process.env.REPORT_FILE, - ); - try { - assertions.run(new VirtualMachine(process.env.VM_DIRECTORY)); - } finally { - if (assertions.report || assertions.reportFiles?.length) - core.setOutput("id", assertions.id); - if (assertions.report) core.setOutput("report", assertions.report); - if (assertions.reportFiles?.length) { - core.setOutput("report-files", assertions.reportFiles.join("\n")); - core.setOutput("codecov-files", assertions.reportFiles.join(",")); - } - } - } catch { - core.setFailed( - "VM assertions or report collection failed. Inspect the available reports.", - ); - } -}; diff --git a/actions/vm/test/package-lock.json b/actions/vm/test/package-lock.json new file mode 100644 index 0000000..d597cb1 --- /dev/null +++ b/actions/vm/test/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "vm-test-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vm-test-tests", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/io": "1.1.3" + }, + "engines": { + "node": ">=24.8.0" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/actions/vm/test/package.json b/actions/vm/test/package.json new file mode 100644 index 0000000..8371cd3 --- /dev/null +++ b/actions/vm/test/package.json @@ -0,0 +1,19 @@ +{ + "name": "vm-test-tests", + "version": "0.0.0", + "private": true, + "description": "Unit tests for the vm/test action", + "license": "MIT", + "engines": { + "node": ">=24.8.0" + }, + "scripts": { + "test": "node --test", + "pretest:ci": "node -e \"require('node:fs').mkdirSync('coverage', { recursive: true })\"", + "test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test" + }, + "devDependencies": { + "@actions/exec": "1.1.1", + "@actions/io": "1.1.3" + } +} diff --git a/actions/vm/test/reports.cjs b/actions/vm/test/reports.cjs index 26f52d9..f1f6ee9 100644 --- a/actions/vm/test/reports.cjs +++ b/actions/vm/test/reports.cjs @@ -2,33 +2,19 @@ const fs = require("node:fs"); const path = require("node:path"); class GuestReports { - constructor(selection = "") { - this.patterns = selection - .split(/[\n,;]/) - .map((value) => value.trim()) - .filter(Boolean); - for (const pattern of this.patterns) { - if ( - path.isAbsolute(pattern) || - pattern.split("/").includes("..") || - !/^[a-zA-Z0-9_./*?[\] -]+$/.test(pattern) - ) { - throw new Error("Report patterns must be relative to REPORTS_DIR"); - } - } - } - - prepare(vm) { + async prepare(vm, timeout) { this.home = undefined; - if (!this.patterns.length) return []; - const directory = vm.command([ - "shell", - "--workdir=/tmp", - vm.name, - "mktemp", - "-d", - "/tmp/iac-reports.XXXXXXXX", - ]); + const directory = await vm.command( + [ + "shell", + "--workdir=/tmp", + vm.name, + "mktemp", + "-d", + "/tmp/iac-reports.XXXXXXXX", + ], + { timeout }, + ); if (!/^\/tmp\/iac-reports\.[a-zA-Z0-9]+$/.test(directory)) { throw new Error("Invalid guest report directory"); } @@ -54,32 +40,19 @@ class GuestReports { } return { path: filename, size: Number(size) }; }); - const selected = new Map(); - for (const pattern of this.patterns) { - const matches = files.filter((file) => - path.matchesGlob(file.path, pattern), - ); - if (!matches.length) - throw new Error("A requested report pattern matched no files"); - for (const file of matches) selected.set(file.path, file); - } - const reports = [...selected.values()].sort((left, right) => - left.path.localeCompare(right.path), - ); - const total = reports.reduce((sum, file) => sum + file.size, 0); + const reports = files.filter((file) => file.path === "goss.xml"); if ( - reports.length > 100 || - total > 20 * 1024 * 1024 || - reports.some((file) => file.size === 0) - ) { + reports.length !== 1 || + reports[0].size === 0 || + reports[0].size > 20 * 1024 * 1024 + ) throw new Error( - "Reports must be nonempty and limited to 100 files and 20 MiB", + "Goss must produce one nonempty regular report up to 20 MiB", ); - } return reports; } - collect(vm, directory) { + async collect(vm, directory, { io }) { if (!this.home) return []; const deadline = Date.now() + 120000; const command = (args) => { @@ -87,7 +60,7 @@ class GuestReports { if (timeout <= 0) throw new Error("Report collection timed out"); return vm.command(args, { timeout }); }; - const manifest = command([ + const manifest = await command([ "shell", "--workdir=/tmp", vm.name, @@ -105,8 +78,8 @@ class GuestReports { try { for (const file of files) { const target = path.join(staging, file.path); - fs.mkdirSync(path.dirname(target), { recursive: true }); - command([ + await io.mkdirP(path.dirname(target)); + await command([ "copy", "--backend=scp", `${vm.name}:${this.home}/${file.path}`, @@ -119,16 +92,16 @@ class GuestReports { fs.renameSync(staging, destination); return files.map((file) => path.join(destination, file.path)); } catch (error) { - fs.rmSync(staging, { recursive: true, force: true }); + await io.rmRF(staging); throw error; } } - cleanup(vm) { + async cleanup(vm) { if (!this.home) return; const directory = this.home; this.home = undefined; - vm.command([ + await vm.command([ "shell", "--workdir=/tmp", vm.name, diff --git a/actions/vm/test/tests/goss.test.cjs b/actions/vm/test/tests/goss.test.cjs new file mode 100644 index 0000000..7b58010 --- /dev/null +++ b/actions/vm/test/tests/goss.test.cjs @@ -0,0 +1,90 @@ +const exec = require("@actions/exec"); +const io = require("@actions/io"); +const helpers = { exec, io }; +const { suite, test } = require("node:test"); +const { GossSuite } = require("../goss.cjs"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "goss-test-")); + const previousDirectory = process.cwd(); + process.chdir(path.join(__dirname, "../../../../tests/fixture/goss")); + t.after(() => { + process.chdir(previousDirectory); + fs.rmSync(directory, { recursive: true, force: true }); + }); + return directory; +} + +suite("vm/test - Goss verification", () => { + test("Goss rejects a mismatched release before extracting or copying it", async (t) => { + // Arrange + const directory = fixture(t); + let copies = 0; + const calls = []; + t.mock.method(exec, "exec", (_program, args) => { + const program = args[2]; + calls.push(program); + if (program === "curl") + fs.writeFileSync(args[args.indexOf("--output") + 1], "corrupt release"); + return 0; + }); + const goss = new GossSuite("valid.yaml"); + const vm = { + name: "iac-test", + command: () => "Linux x86_64", + copyTo: () => copies++, + }; + + // Act + const prepare = () => + goss.prepare(vm, directory, "/tmp/iac-reports.fixture", 60000, helpers); + + // Assert + await assert.rejects(prepare, /checksum/); + assert.deepEqual(calls, ["curl"]); + assert.equal(copies, 0); + assert.deepEqual(fs.readdirSync(directory), []); + }); + + test("Goss rejects empty native reports while retaining failed assertion reports", async (t) => { + // Arrange + const directory = fixture(t); + const file = path.join(directory, "goss.xml"); + const goss = new GossSuite("valid.yaml"); + fs.writeFileSync(file, ''); + + // Act + const validateEmpty = () => goss.validateReports([file]); + + // Assert + assert.throws(validateEmpty, /at least one/); + + // Arrange + fs.writeFileSync( + file, + '', + ); + + // Act + const validateSkipped = () => goss.validateReports([file]); + + // Assert + assert.throws(validateSkipped, /at least one/); + + // Arrange + fs.writeFileSync( + file, + '', + ); + + // Act + const validateFailed = () => goss.validateReports([file]); + + // Assert + assert.doesNotThrow(validateFailed); + }); +}); diff --git a/actions/vm/test/tests/reports.test.cjs b/actions/vm/test/tests/reports.test.cjs new file mode 100644 index 0000000..1385a9e --- /dev/null +++ b/actions/vm/test/tests/reports.test.cjs @@ -0,0 +1,146 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { suite, test } = require("node:test"); +const { GuestReports } = require("../reports.cjs"); +const { GuestAssertions } = require("../assertions.cjs"); +const exec = require("@actions/exec"); +const io = require("@actions/io"); + +async function fixture(t) { + const directory = fs.mkdtempSync("/tmp/report-test-"); + const guest = fs.mkdtempSync("/tmp/iac-reports."); + const previous = process.cwd(); + process.chdir(directory); + t.after(() => { + process.chdir(previous); + fs.rmSync(directory, { recursive: true, force: true }); + fs.rmSync(guest, { recursive: true, force: true }); + }); + fs.writeFileSync("suite.yaml", "{}"); + const xml = ''; + fs.writeFileSync(path.join(guest, "goss.xml"), xml); + fs.writeFileSync(path.join(guest, "private.txt"), "synthetic-private-output"); + const execute = async (args) => { + if (args.includes("mktemp")) return guest; + if (args.includes("find")) + return ( + await exec.getExecOutput("find", args.slice(args.indexOf("find") + 1), { + silent: true, + }) + ).stdout; + if (args[0] === "copy") fs.copyFileSync(args[2].split(":")[1], args[3]); + if (args.includes("rm")) fs.rmSync(guest, { recursive: true }); + return ""; + }; + const vm = { name: "fixture", command: execute }; + const assertions = new GuestAssertions({ goss: "suite.yaml" }, directory); + t.mock.method(assertions.goss, "prepare", () => "goss validate"); + return { directory, guest, vm, execute, assertions, xml }; +} + +suite("vm/test - report safety", () => { + test("only one bounded nonempty native report can be collected", () => { + // Arrange + const reports = new GuestReports(); + for (const manifest of [ + "", + "0\tgoss.xml\0", + "20971521\tgoss.xml\0", + "2\t../goss.xml\0", + "2\tgoss.xml\0".repeat(2), + ]) { + // Act + const select = () => reports.select(manifest); + // Assert + assert.throws(select); + } + // Act + const selected = reports.select("10\tgoss.xml\0" + "20\tprivate.txt\0"); + // Assert + assert.deepEqual(selected, [{ path: "goss.xml", size: 10 }]); + }); + + test("failed assertions retain native results and redact raw errors", async (t) => { + // Arrange + const { directory, guest, vm, execute, assertions, xml } = await fixture(t); + t.mock.method(vm, "command", (args) => { + if (args.includes("bash")) throw new Error("synthetic-private-output"); + return execute(args); + }); + // Act + const run = () => assertions.run(vm, { exec, io }); + // Assert + await assert.rejects(run); + assert.equal(fs.existsSync(guest), false); + assert.equal(assertions.reportFiles.length, 1); + assert.equal(fs.readFileSync(assertions.reportFiles[0], "utf8"), xml); + const status = fs.readFileSync(assertions.report, "utf8"); + assert.match(status, /failures="1"/); + assert.equal(status.includes("synthetic-private-output"), false); + assert.equal( + fs.existsSync(path.join(directory, "guest/private.txt")), + false, + ); + }); + + for (const failure of [ + "symlink", + "partial-transfer", + "cleanup", + "empty-suite", + "report-write", + ]) { + test(`${failure} fails without publishing incomplete results`, async (t) => { + // Arrange + const { guest, vm, execute, assertions } = await fixture(t); + if (failure === "symlink") { + fs.unlinkSync(path.join(guest, "goss.xml")); + fs.symlinkSync( + path.join(guest, "private.txt"), + path.join(guest, "goss.xml"), + ); + } + if (failure === "empty-suite") + fs.writeFileSync( + path.join(guest, "goss.xml"), + '', + ); + t.mock.method(vm, "command", (args) => { + if (failure === "partial-transfer" && args[0] === "copy") { + fs.writeFileSync(args[3], "partial"); + return ""; + } + if (failure === "cleanup" && args.includes("rm")) + throw new Error("cleanup failed"); + return execute(args); + }); + if (failure === "report-write") { + const write = fs.writeFileSync; + t.mock.method(fs, "writeFileSync", (file, ...args) => { + if (file.endsWith("e2e.junit.xml")) { + write(file, "partial"); + throw new Error("write failed"); + } + return write(file, ...args); + }); + } + // Act + const run = () => assertions.run(vm, { exec, io }); + // Assert + await assert.rejects(run); + assert.equal( + assertions.reportFiles.length, + ["cleanup", "report-write"].includes(failure) ? 1 : 0, + ); + if (failure === "report-write") + assert.equal(assertions.report, undefined); + else + assert.match( + fs.readFileSync(assertions.report, "utf8"), + /failures="1"/, + ); + assert.equal(fs.existsSync(guest), failure === "cleanup"); + }); + } +}); diff --git a/actions/vm/test/tests/test.test.cjs b/actions/vm/test/tests/test.test.cjs new file mode 100644 index 0000000..649fd92 --- /dev/null +++ b/actions/vm/test/tests/test.test.cjs @@ -0,0 +1,128 @@ +const exec = require("@actions/exec"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { suite, test } = require("node:test"); +const { GuestAssertions } = require("../assertions.cjs"); +const { Guest } = require("../guest.cjs"); +const { + limaFixture, + qemuFixture, +} = require("../../../../tests/helpers/vm.cjs"); + +suite("vm/test - execution", () => { + for (const [backend, fixture] of [ + ["lima", limaFixture], + ["qemu", qemuFixture], + ]) { + test(`${backend} guest transport opens fresh logins and preserves quoted arguments, stdin and deadlines`, async (t) => { + // Arrange + const { vm } = fixture(t, Guest); + t.mock.method(vm, "exists", () => true); + const spawn = t.mock.method(exec, "exec", (_program, _args, options) => { + options.listeners.stdout(Buffer.from("result\n")); + return 0; + }); + const input = 'printf "%s" "$GUEST_VALUE"\n'; + + // Act + const result = await vm.command( + [ + "shell", + "--workdir=/tmp", + vm.name, + "printf", + "%s", + "guest's value; $(false)", + ], + { input, timeout: 12345 }, + ); + + // Assert + assert.equal(result, "result"); + const [program, args, options] = spawn.mock.calls[0].arguments; + assert.equal(program, "timeout"); + assert.deepEqual(args, [ + "--kill-after=5s", + "12.345s", + "ssh", + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "-o", + "ControlPersist=no", + "-F", + vm.sshConfig, + vm.name, + "cd /tmp && 'printf' '%s' 'guest'\\''s value; $(false)'", + ]); + assert.equal(options.input.toString(), input); + assert.equal(options.silent, true); + assert.equal(options.shell, undefined); + }); + } + + for (const [backend, fixture] of [ + ["lima", limaFixture], + ["qemu", qemuFixture], + ]) { + test(`${backend} guest uploads reject paths outside private test files`, async (t) => { + // Arrange + const { vm } = fixture(t, Guest); + t.mock.method(vm, "exists", () => true); + const spawn = t.mock.method(exec, "exec"); + for (const destination of [ + "/etc/passwd", + "/tmp/iac-reports.fixture/../outside", + "/tmp/iac-reports.fixture/file;false", + ]) { + // Act + const copy = () => vm.copyTo("synthetic", destination); + + // Assert + await assert.rejects(copy, /private guest test file/); + } + assert.equal(spawn.mock.callCount(), 0); + }); + } + + test("reports require a resolved directory and a bounded deadline", async (t) => { + // Arrange + const { reports } = limaFixture(t, Guest); + for (const directory of [undefined, "", ".reports"]) { + // Act + const configure = () => + new GuestAssertions({ goss: "guest.yml" }, directory); + + // Assert + assert.throws(configure); + } + for (const minutes of ["0", "41", "-1", "1.5", "nan", ""]) { + // Act + const configure = () => + new GuestAssertions({ goss: "guest.yml" }, reports, minutes); + + // Assert + assert.throws(configure); + } + }); + + test("invalid suites fail before any guest work", (t) => { + // Arrange + const { root, reports } = limaFixture(t, Guest); + fs.symlinkSync(__filename, path.join(root, "external.yaml")); + for (const goss of [ + undefined, + "", + "missing.yaml", + "external.yaml", + "../outside.yaml", + ]) { + // Act + const configure = () => new GuestAssertions({ goss }, reports); + // Assert + assert.throws(configure); + } + }); +}); diff --git a/docs/README.md b/docs/README.md index ee45859..8489a3f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,3 +2,4 @@ - [Contributing, validation and releases](../CONTRIBUTING.md) - [ADR 0001: Focused VM actions](development/adr/0001-vm-actions.md) +- [ADR 0002: Infrastructure test actions and native scenarios](development/adr/0002-infrastructure-test-actions.md) diff --git a/docs/development/adr/0001-vm-actions.md b/docs/development/adr/0001-vm-actions.md index 69a285f..7c69865 100644 --- a/docs/development/adr/0001-vm-actions.md +++ b/docs/development/adr/0001-vm-actions.md @@ -2,7 +2,9 @@ Date: 2026-10-29 -Status: Accepted +Status: Accepted. Backend and test execution contracts are extended, and mandatory +external entrypoints are superseded, by +[ADR 0002](0002-infrastructure-test-actions.md). ## Context diff --git a/docs/development/adr/0002-infrastructure-test-actions.md b/docs/development/adr/0002-infrastructure-test-actions.md new file mode 100644 index 0000000..4e09cc0 --- /dev/null +++ b/docs/development/adr/0002-infrastructure-test-actions.md @@ -0,0 +1,215 @@ +# ADR 0002: Infrastructure test actions and native scenarios + +Date: 2026-11-03. Amended: 2026-11-04. + +Status: Accepted. Implementation is under review in +[PR #8](https://github.com/hoverkraft-tech/ci-github-iac/pull/8). + +Extends the backend and test execution contracts in +[ADR 0001](0001-vm-actions.md) and supersedes its mandatory external entrypoints. + +## Context + +The Proxmox installation consumer needs direct QEMU because installation inside +Lima timed out on GitHub-hosted runners. Its working direct-QEMU implementation +duplicates VM lifecycle and reporting already owned by this repository. + +Arbitrary test commands also leave consumers to repeat tool installation, SSH +setup and reporting. Shared actions should provide those operations while consumers +retain their assertions and deployment policy. Ansible scenarios can target several +environments; requiring a VM or supplying a reboot sequence couples the test +framework to one kind of deployment. + +## Decision + +### Action responsibilities + +Keep actions focused on lifecycle operations or the test framework they execute: + +- `vm/create` creates a guest using the selected Lima or QEMU backend, validates + required configuration and rolls back failed startup. Lima remains the default. +- `vm/test` requires a Goss suite, verifies guest state and publishes native JUnit. +- `vm/destroy` verifies guest termination before removing its private runtime. +- `ansible/test` runs test playbooks and collection sanity checks through maintained + upstream actions. It does not require a VM or perform linting. +- `ansible/test-molecule` installs the pinned runtime, executes one native scenario + with a bounded timeout and publishes native JUnit and scenario status reports. + +Workflows own sequencing and the lifetime of VMs they create. They consume the +private SSH outputs consistently across backends and schedule destruction with +`always()`. Consumers own platform assertions, readiness expectations, provisioning +and synthetic installation inputs. The shared actions contain no Proxmox policy. + +### Direct QEMU + +Require explicit JSON configuration for QEMU and YAML configuration for Lima. +QEMU supports unattended ISO installation followed by system-disk boot and SSH +readiness, or a QCOW2 image with a cloud-init seed. Consumers own image authenticity +and provisioning; the hosted fixture verifies its pinned image checksum. + +Reuse Lima's maintained setup action for host packages, including QEMU and UEFI +firmware. Direct QEMU starts no Lima guest. Expose no arbitrary QEMU flags, host +disks or host mounts; forward SSH only over loopback. Validate process ownership +and termination before removing private runtime files. Failed cleanup retains +recovery access and fails the job; repeated destruction remains safe. + +### Native tests and reports + +Use Goss for declarative guest-state verification in `vm/test`. Pin its release +and archive checksum, render the consumer suite and variables, execute in the +guest and publish native JUnit. Empty or fully skipped suites fail. Goss command +resources can invoke focused consumer probes for complex assertions. The action +exposes no arbitrary command, script, runner execution or report selection mode. + +Keep Molecule separate from playbook and collection tests. Its native scenario +defines inventory, playbook paths, dependencies, resource lifecycle and sequence. +The action supplies no base configuration, VM requirement or reboot policy, and +imports no VM modules. Native Molecule handles ordinary failure cleanup. After an +interrupted process, the action retries native destruction with a two-minute bound +and retains failure. Scenarios using workflow-created VMs consume their SSH outputs; +the workflow remains responsible for destroying those VMs. + +Each test invocation owns its reports. Preserve native tool reports, publish +completed files after test failures and keep execution, collection and publication +failures visible. Report parsing, summaries, PR comments and artifacts are part of +VM testing. Goss produces test results rather than application code coverage; +there is no Codecov mode. Linting stays separate. + +Keep public interfaces tied to consumer needs. Test actions expose no outputs; +their outcome, summaries, comments and artifacts communicate results. Only +`vm/create` exports `vm`, `name` and `ssh-config` for subsequent operations. Retain +the `name` output even with a fixed SSH alias so consumers do not duplicate it. +Use `github.token` for comments and native `ansible-config` for inventory and +collection search paths. Hosted tests inspect report files, uploaded artifacts and +published comments without requiring test-only outputs. + +### Implementation and validation + +Write short, straightforward logic inline in `actions/github-script`. Keep adjacent +modules for substantial validation, execution, report transfer and VM lifecycle +code, resolving imports from the action directory. Allocate runtimes in `vm/create` +and keep guest command execution and file transfer in `vm/test/guest.cjs`. +Shared `runtime.cjs` owns only runtime validation, process identity, presence checks +and verified teardown for both backends. Narrow its interfaces: `VmReference` owns +identity, ownership and presence checks; `VirtualMachine` adds cleanup for creation +rollback and explicit destruction. Guest transport inherits only `VmReference`, +so testing does not acquire lifecycle teardown. Keep bounded subprocess execution in `command.cjs` +and checkout validation in `workspace.cjs`. Shared modules do not import action code; +there is no circular backend dependency. This narrows the earlier shared backend +scope while preserving the VM reference format and public action interfaces. +Mandatory wrappers and phase dispatchers add no useful boundary. +Retain sharing only for safety contracts enforced by multiple actions: runtime +ownership and cleanup, command deadlines/output protection and checkout containment. +These callers need consistent rules. Keep action-specific policy local; add no +shared packaging or generic action framework. + +Inject GitHub Script's `core`, `exec`, `io` and `glob` helpers into adjacent code. +Use asynchronous Toolkit execution with GNU `timeout` for process-group deadlines +and bounded, silent output capture. Retain low-level filesystem operations where +Toolkit helpers do not provide private allocation, ownership checks, exclusive +writes/copies or atomic report publication. Runner report discovery uses `glob` +without following symlinks; guest manifests remain independent of the runner's +filesystem. Unit packages use Toolkit development dependencies matching the pinned +GitHub Script runtime, maintained by grouped Dependabot updates. Consumer workflows +need no npm installation. This replaces custom subprocess execution while keeping +cleanup and report contracts. + +Dedicate one hosted test workflow to each public action, keeping backend variants +within it. Use Arrange, Act, Assert steps and unconditional cleanup. Hosted scenarios +are the primary checks for success, failure and cleanup behavior. Add unit tests +for distinct input boundaries, data protection and injected failures that are +difficult to reproduce reliably in integration tests. Avoid mocked duplicates of +hosted scenarios; a unit suite is not mandatory for every action. Where needed, +run it through the shared Node.js test action in the owning workflow. Shared CI +composes the linter and those workflows; local Make targets remain development +conveniences. Unit tests call retained modules directly; hosted workflows verify +inline steps and action outputs. Keep shared test helpers in `tests/helpers/` and +synthetic inputs in `tests/fixture/`, without a YAML-extracting execution harness. + +## Alternatives considered + +- Separate public create actions for Lima and QEMU would expose backend selection + through different action paths. One selector preserves a consistent lifecycle + interface while allowing backend-specific configuration. +- Arbitrary commands alone preserve flexibility but require consumers to implement + their own test harness. Native Goss and Molecule support provides shared execution + and reporting without introducing another scenario language. +- Combining Molecule with `ansible/test`, or requiring an owned VM and generated + inventory, mixes test frameworks with environment policy. Native scenarios keep + target selection and deployment sequences explicit in each consumer. +- Testinfra was considered during the initial tool review. Its upstream + [maintenance notice](https://github.com/pytest-dev/pytest-testinfra), recorded on + 2026-10-31, informed the choice of Goss as the shared state verifier. The initial + proposal retained procedural suites; the 2026-11-04 amendment below removes + that alternative. This is historical selection evidence. +- A separate script for every step adds navigation and dispatch code. Inlining + simple orchestration preserves readability while complex modules remain testable. + +## Goss-only amendment, 2026-11-04 + +The user selected Goss as the single VM assertion framework across all three +consumers. This supersedes the procedural execution choice in the earlier draft +of this record and the test-command contract in ADR 0001. Remove `command`, +`test-script`, `execution`, `report-file`, `coverage` and `path-mapping` from +`vm/test`, and require `goss-file`. Infrastructure coverage remains consumer-owned +Goss resources and focused probes; pytest continues to serve Python unit and +integration tests, while Molecule owns deployment lifecycle checks. + +The workstation consumer migrates backup, setup and cleanup assertions from +Testinfra. Preserve recovery completeness, permissions, archive exclusions, +local changes, browser state and verified external-device teardown. Move the +Docker restart to the Act phase so parallel Goss checks see a stable daemon. + +Guest assertions use fresh SSH logins for both backends. Reusing a connection +opened before provisioning retains its old supplementary groups, even after +the account database changes. The shared action owns this transport behavior; +consumers should not need elevated assertions or connection-reset workarounds. + +## Consequences + +The 2026-11-04 development-branch interface audit removed five inputs and eighteen outputs: optional +`inventory` and `collections-path` from `ansible/test`; `github-token` from all +three test actions; all test-action outputs; `vm/create.id`; and `vm/destroy.removed`. +That earlier inventory included the unreleased Molecule action. Comparing that audit +with base revision `8cb45605599e06183c110a8cdff7beebebe15809` identifies four removed +published inputs and thirteen removed outputs: `inventory`, `collections-path` +and `github-token` from `ansible/test`; `github-token` from `vm/test`; both actions' +outputs; `vm/create.id`; and `vm/destroy.removed`. The Goss-only amendment adds +the VM input removals listed above and its required suite input. +Known consumers do not use these overrides or outputs. The published removals +require a breaking release under repository +policy, replacing the earlier assumption that this work was entirely additive. +Consumers pin related actions to one reviewed revision. Unreleased Molecule +interfaces are consolidated into `ansible/test-molecule` without compatibility aliases. + +Direct QEMU requires an ephemeral Linux x86-64 runner with KVM, UEFI firmware and +disk-backed storage. Reusing host setup installs an unused Lima binary for direct +QEMU consumers, avoiding another package installation implementation. Forced runner +termination still relies on ephemeral runner disposal for cleanup. + +Goss requires guest file transfer. Molecule runs in an isolated Python environment +on the runner; consumers must define their native scenario policy and clean up +resources they create. Neither tool turns synthetic CI into qualification of a +production host or every possible Molecule target or plugin. + +## Validation evidence + +On 2026-11-03, [shared CI](https://github.com/hoverkraft-tech/ci-github-iac/actions/runs/41656205593) +passed at `e4a6f4cba532409ee8119fdd5f7db472567efb66`, covering the five action +workflows, unit contracts, Lima/QEMU behavior, Goss, native Molecule scenarios +without a VM, failure reporting, timeout cleanup and reboot scenarios. + +The [Proxmox consumer run](https://github.com/neilime/digital-household/actions/runs/41615037775) +passed at `c5482db6d1d790bea5d9b2578c89a766240a5912`, using shared action revision +`a752dd6e1d27d80e510ae533ca71c2cb045cabb9`. It validated installation, idempotence, +reboot, reports and cleanup. Later shared commits changed test organization and +documentation before the 2026-11-04 interface cleanup. These runs are evidence for +the cited revisions, not validation of that cleanup; review, release and the final +consumer pin remain pending for those revisions. + +On 2026-11-04, [shared CI](https://github.com/hoverkraft-tech/ci-github-iac/actions/runs/41837608802) +passed all 24 jobs at `3f6dd9f0bf375e3deec90083ab07ded0c7777f3a` after the interface +cleanup and runtime separation. The [consumer run](https://github.com/neilime/digital-household/actions/runs/41837758273) +passed all six jobs at `495941a2c42dfc33769cea215246c4d228badfc2`, using that shared +revision. These results cover installation, deployment, idempotence, reboot, +reports and cleanup before documentation cleanup and commit consolidation. diff --git a/tests/ansible.test.cjs b/tests/ansible.test.cjs deleted file mode 100644 index fb98a47..0000000 --- a/tests/ansible.test.cjs +++ /dev/null @@ -1,167 +0,0 @@ -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.cfg b/tests/fixture/ansible/ansible.cfg new file mode 100644 index 0000000..34ef252 --- /dev/null +++ b/tests/fixture/ansible/ansible.cfg @@ -0,0 +1,3 @@ +[defaults] +inventory = ./inventory.yml +collections_path = . diff --git a/tests/fixture/ansible/ansible_collections/example/smoke/README.md b/tests/fixture/ansible/ansible_collections/example/smoke/README.md index 7b1a3be..43cc631 100644 --- a/tests/fixture/ansible/ansible_collections/example/smoke/README.md +++ b/tests/fixture/ansible/ansible_collections/example/smoke/README.md @@ -1,3 +1,3 @@ # Synthetic collection -A role used by the [Ansible action tests](../../../../../../.github/workflows/__test-actions-ansible.yml). +A role used by the [Ansible action tests](../../../../../../.github/workflows/__test-actions-ansible-test.yml). diff --git a/tests/fixture/ansible/inventory.yml b/tests/fixture/ansible/inventory.yml new file mode 100644 index 0000000..924dc65 --- /dev/null +++ b/tests/fixture/ansible/inventory.yml @@ -0,0 +1,5 @@ +--- +all: + hosts: + fixture: + ansible_connection: local diff --git a/tests/fixture/ansible/valid.yml b/tests/fixture/ansible/valid.yml index 79716fe..640cf97 100644 --- a/tests/fixture/ansible/valid.yml +++ b/tests/fixture/ansible/valid.yml @@ -1,8 +1,9 @@ --- - name: Exercise a synthetic Ansible test assertion - hosts: localhost - connection: local + hosts: fixture gather_facts: false + roles: + - example.smoke.smoke tasks: - name: Assert the selected fixture succeeds ansible.builtin.assert: diff --git a/tests/fixture/assert.sh b/tests/fixture/assert.sh deleted file mode 100755 index a438af0..0000000 --- a/tests/fixture/assert.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# The guest supplies its distribution metadata. -# shellcheck source=/dev/null -. /etc/os-release -test "$ID" = ubuntu -test -d /run/systemd/system -test ! -e /tmp/iac-fixture -printf '%s' synthetic >/tmp/iac-fixture -test "$(cat /tmp/iac-fixture)" = synthetic -rm /tmp/iac-fixture - -if [[ -n ${REPORTS_DIR:-} ]]; then - cat >"$REPORTS_DIR/junit.xml" <<'XML' - -XML - mkdir "$REPORTS_DIR/coverage" - printf 'TN:fixture\nSF:tests/fixture/assert.sh\nDA:1,1\nend_of_record\n' >"$REPORTS_DIR/coverage/lcov.info" - printf 'synthetic-private-output\n' >"$REPORTS_DIR/private.txt" - ln -s "$REPORTS_DIR/private.txt" "$REPORTS_DIR/linked.xml" -fi diff --git a/tests/fixture/fail.sh b/tests/fixture/fail.sh deleted file mode 100755 index c698e0d..0000000 --- a/tests/fixture/fail.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Synthetic output must never reach the public report. -printf '%s\n' 'synthetic-private-output' -if [[ -n ${REPORTS_DIR:-} ]]; then - cat >"$REPORTS_DIR/junit.xml" <<'XML' - - - -XML -fi -exit 1 diff --git a/tests/fixture/goss/empty.yaml b/tests/fixture/goss/empty.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixture/goss/empty.yaml @@ -0,0 +1 @@ +{} diff --git a/tests/fixture/goss/included.yaml b/tests/fixture/goss/included.yaml new file mode 100644 index 0000000..19a4e76 --- /dev/null +++ b/tests/fixture/goss/included.yaml @@ -0,0 +1,4 @@ +file: + /run/systemd/system: + exists: true + filetype: directory diff --git a/tests/fixture/goss/invalid.yaml b/tests/fixture/goss/invalid.yaml new file mode 100644 index 0000000..840b56f --- /dev/null +++ b/tests/fixture/goss/invalid.yaml @@ -0,0 +1,3 @@ +file: + /iac-missing-synthetic-file: + exists: true diff --git a/tests/fixture/goss/timeout.yaml b/tests/fixture/goss/timeout.yaml new file mode 100644 index 0000000..7853108 --- /dev/null +++ b/tests/fixture/goss/timeout.yaml @@ -0,0 +1,5 @@ +command: + bounded-probe: + exec: sleep 120 + timeout: 180000 + exit-status: 0 diff --git a/tests/fixture/goss/valid.yaml b/tests/fixture/goss/valid.yaml new file mode 100644 index 0000000..888a608 --- /dev/null +++ b/tests/fixture/goss/valid.yaml @@ -0,0 +1,13 @@ +gossfile: + included.yaml: {} +command: + provisioned-group: + exec: id -nG + exit-status: 0 + stdout: + - /\biac-goss\b/ + kernel: + exec: uname -s + exit-status: 0 + stdout: + - "{{ .Vars.kernel }}" diff --git a/tests/fixture/goss/vars.json b/tests/fixture/goss/vars.json new file mode 100644 index 0000000..33ea052 --- /dev/null +++ b/tests/fixture/goss/vars.json @@ -0,0 +1 @@ +{ "kernel": "Linux" } diff --git a/tests/fixture/molecule/local/assertions.yml b/tests/fixture/molecule/local/assertions.yml new file mode 100644 index 0000000..8511962 --- /dev/null +++ b/tests/fixture/molecule/local/assertions.yml @@ -0,0 +1,18 @@ +--- +- name: Verify the local test resource + hosts: fixture + gather_facts: false + tasks: + - name: Simulate a stalled verification + ansible.builtin.command: sleep 120 + changed_when: false + when: lookup('ansible.builtin.env', 'MOLECULE_TEST_RESULT') == 'timeout' + - name: Read the generated configuration + ansible.builtin.slurp: + src: "{{ fixture_directory }}/configuration" + register: configuration + - name: Assert configuration and selected outcome + ansible.builtin.assert: + that: + - (configuration.content | b64decode) == "scenario-owned configuration\n" + - lookup('ansible.builtin.env', 'MOLECULE_TEST_RESULT') != 'failure' diff --git a/tests/fixture/molecule/local/configure.yml b/tests/fixture/molecule/local/configure.yml new file mode 100644 index 0000000..151654e --- /dev/null +++ b/tests/fixture/molecule/local/configure.yml @@ -0,0 +1,10 @@ +--- +- name: Configure the local test resource + hosts: fixture + gather_facts: false + tasks: + - name: Write the expected configuration + ansible.builtin.copy: + dest: "{{ fixture_directory }}/configuration" + content: "scenario-owned configuration\n" + mode: "0600" diff --git a/tests/fixture/molecule/local/create.yml b/tests/fixture/molecule/local/create.yml new file mode 100644 index 0000000..f95c1c7 --- /dev/null +++ b/tests/fixture/molecule/local/create.yml @@ -0,0 +1,10 @@ +--- +- name: Create the local test resource + hosts: fixture + gather_facts: false + tasks: + - name: Create the isolated directory + ansible.builtin.file: + path: "{{ fixture_directory }}" + state: directory + mode: "0700" diff --git a/tests/fixture/molecule/local/destroy.yml b/tests/fixture/molecule/local/destroy.yml new file mode 100644 index 0000000..3bf6d5d --- /dev/null +++ b/tests/fixture/molecule/local/destroy.yml @@ -0,0 +1,9 @@ +--- +- name: Destroy the local test resource + hosts: fixture + gather_facts: false + tasks: + - name: Remove the isolated directory + ansible.builtin.file: + path: "{{ fixture_directory }}" + state: absent diff --git a/tests/fixture/molecule/local/inventory.yml b/tests/fixture/molecule/local/inventory.yml new file mode 100644 index 0000000..abbc8df --- /dev/null +++ b/tests/fixture/molecule/local/inventory.yml @@ -0,0 +1,7 @@ +--- +fixture: + hosts: + local-fixture: + ansible_connection: local + ansible_python_interpreter: "{{ ansible_playbook_python }}" + fixture_directory: "{{ lookup('ansible.builtin.env', 'MOLECULE_TEST_DIRECTORY', default=undef()) }}" diff --git a/tests/fixture/molecule/local/molecule.yml b/tests/fixture/molecule/local/molecule.yml new file mode 100644 index 0000000..da0c7ea --- /dev/null +++ b/tests/fixture/molecule/local/molecule.yml @@ -0,0 +1,21 @@ +--- +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + playbooks: + create: create.yml + converge: configure.yml + verify: assertions.yml + destroy: destroy.yml +scenario: + test_sequence: + - create + - converge + - verify + - idempotence + - destroy +shared_state: false +verifier: + name: ansible diff --git a/tests/fixture/molecule/smoke/converge.yml b/tests/fixture/molecule/smoke/converge.yml new file mode 100644 index 0000000..e3cc432 --- /dev/null +++ b/tests/fixture/molecule/smoke/converge.yml @@ -0,0 +1,12 @@ +--- +- name: Apply a disposable test configuration + hosts: vm + gather_facts: false + tasks: + - name: Write the fixture marker + ansible.builtin.copy: + dest: /var/tmp/iac-molecule-marker + content: "synthetic fixture\n" + mode: "0600" + register: marker + changed_when: marker.changed or lookup('env', 'IAC_FORCE_CHANGE') == 'true' diff --git a/tests/fixture/molecule/smoke/inventory.yml b/tests/fixture/molecule/smoke/inventory.yml new file mode 100644 index 0000000..5056930 --- /dev/null +++ b/tests/fixture/molecule/smoke/inventory.yml @@ -0,0 +1,8 @@ +--- +vm: + hosts: + smoke: + ansible_connection: ssh + ansible_host: "{{ lookup('ansible.builtin.env', 'VM_NAME', default=undef()) }}" + ansible_user: tester + ansible_ssh_common_args: "-F {{ lookup('ansible.builtin.env', 'VM_SSH_CONFIG', default=undef()) }}" diff --git a/tests/fixture/molecule/smoke/molecule.yml b/tests/fixture/molecule/smoke/molecule.yml new file mode 100644 index 0000000..7b17102 --- /dev/null +++ b/tests/fixture/molecule/smoke/molecule.yml @@ -0,0 +1,23 @@ +--- +ansible: + executor: + args: + ansible_playbook: + - --inventory=${MOLECULE_SCENARIO_DIRECTORY}/inventory.yml + playbooks: + prepare: ready.yml + converge: converge.yml + verify: verify.yml + side_effect: reboot.yml +scenario: + test_sequence: + - prepare + - converge + - verify + - idempotence + - side_effect + - verify + - idempotence +shared_state: false +verifier: + name: ansible diff --git a/tests/fixture/molecule/smoke/ready.yml b/tests/fixture/molecule/smoke/ready.yml new file mode 100644 index 0000000..a262b59 --- /dev/null +++ b/tests/fixture/molecule/smoke/ready.yml @@ -0,0 +1,8 @@ +--- +- name: Wait for the disposable VM + hosts: vm + gather_facts: false + tasks: + - name: Wait for the SSH connection + ansible.builtin.wait_for_connection: + timeout: 300 diff --git a/tests/fixture/molecule/smoke/reboot.yml b/tests/fixture/molecule/smoke/reboot.yml new file mode 100644 index 0000000..6247cc4 --- /dev/null +++ b/tests/fixture/molecule/smoke/reboot.yml @@ -0,0 +1,9 @@ +--- +- name: Reboot the disposable VM + hosts: vm + become: true + gather_facts: false + tasks: + - name: Reboot and require a new boot identity + ansible.builtin.reboot: + reboot_timeout: 300 diff --git a/tests/fixture/molecule/smoke/verify.yml b/tests/fixture/molecule/smoke/verify.yml new file mode 100644 index 0000000..4ac3fb9 --- /dev/null +++ b/tests/fixture/molecule/smoke/verify.yml @@ -0,0 +1,14 @@ +--- +- name: Verify the disposable test configuration + hosts: vm + gather_facts: false + tasks: + - name: Read the fixture marker + ansible.builtin.slurp: + src: /var/tmp/iac-molecule-marker + register: marker + - name: Assert the expected content + ansible.builtin.assert: + that: + - (marker.content | b64decode) == "synthetic fixture\n" + - lookup('env', 'IAC_FORCE_FAILURE') != 'true' diff --git a/tests/fixture/prepare-vm/action.yml b/tests/fixture/prepare-vm/action.yml new file mode 100644 index 0000000..869d780 --- /dev/null +++ b/tests/fixture/prepare-vm/action.yml @@ -0,0 +1,42 @@ +--- +name: Prepare VM test fixture +description: Prepare synthetic backend configuration for internal action tests without starting a VM. +inputs: + backend: + description: Backend exercised by the calling test. + required: true +outputs: + vm-config: + description: Checkout-relative configuration for successful startup. + value: ${{ steps.config.outputs.valid }} + invalid-config: + description: Checkout-relative configuration that fails during startup. + value: ${{ steps.config.outputs.invalid }} +runs: + using: composite + steps: + - name: Prepare verified QEMU image + if: inputs.backend == 'qemu' + shell: bash + run: | + mkdir -p .cache/qemu-fixture + curl --fail --location --retry 3 https://cloud-images.ubuntu.com/releases/resolute/release-20261016/ubuntu-26.04-server-cloudimg-amd64.img -o .cache/qemu-fixture/image.qcow2 + echo '5645d0318ad21f643ebb1ac4567514620e9893380b07b0201fda163baa5b7eb5 .cache/qemu-fixture/image.qcow2' | sha256sum --check + sudo apt-get update -qq + sudo apt-get install --no-install-recommends -y cloud-image-utils + - name: Select synthetic fixture configurations + id: config + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BACKEND: ${{ inputs.backend }} + FIXTURE_PATH: ${{ github.action_path }} + with: + script: | + const qemu = process.env.BACKEND === 'qemu'; + if (!qemu && process.env.BACKEND !== 'lima') throw new Error('Unsupported fixture backend'); + if (qemu) await require(`${process.env.FIXTURE_PATH}/qemu.cjs`)({ exec, io }); + core.setOutput('valid', qemu ? '.cache/qemu-fixture/vm.json' : 'tests/fixture/lima.yml'); + core.setOutput('invalid', qemu ? '.cache/qemu-fixture/invalid.json' : 'tests/fixture/unbootable.yml'); + - name: Remove the consumer Makefile + shell: bash + run: rm Makefile diff --git a/tests/fixture/prepare-vm/qemu.cjs b/tests/fixture/prepare-vm/qemu.cjs new file mode 100644 index 0000000..6b1a3da --- /dev/null +++ b/tests/fixture/prepare-vm/qemu.cjs @@ -0,0 +1,53 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +module.exports = async ({ exec, io }) => { + const directory = path.resolve(".cache/qemu-fixture"); + await io.mkdirP(directory); + fs.chmodSync(directory, 0o700); + await exec.exec("ssh-keygen", [ + "-q", + "-t", + "ed25519", + "-N", + "", + "-f", + `${directory}/key`, + ]); + const key = fs.readFileSync(`${directory}/key.pub`, "utf8").trim(); + fs.writeFileSync( + `${directory}/user-data`, + `#cloud-config\nusers:\n - name: tester\n shell: /bin/bash\n sudo: ALL=(ALL) NOPASSWD:ALL\n ssh_authorized_keys:\n - ${key}\n`, + ); + fs.writeFileSync( + `${directory}/meta-data`, + "instance-id: iac-qemu-fixture\nlocal-hostname: iac-test\n", + ); + await exec.exec("cloud-localds", [ + `${directory}/seed.iso`, + `${directory}/user-data`, + `${directory}/meta-data`, + ]); + const config = { + cpus: 2, + memoryMiB: 2048, + diskGiB: 10, + diskSerial: "IAC_TEST_DISK", + image: ".cache/qemu-fixture/image.qcow2", + seed: ".cache/qemu-fixture/seed.iso", + network: { + subnet: "192.0.2.0/24", + address: "192.0.2.10", + gateway: "192.0.2.1", + dns: "192.0.2.2", + mac: "02:00:00:00:00:01", + }, + ssh: { user: "tester", privateKey: ".cache/qemu-fixture/key" }, + }; + fs.writeFileSync(`${directory}/vm.json`, JSON.stringify(config)); + fs.writeFileSync(`${directory}/invalid.qcow2`, "synthetic invalid image"); + fs.writeFileSync( + `${directory}/invalid.json`, + JSON.stringify({ ...config, image: ".cache/qemu-fixture/invalid.qcow2" }), + ); +}; diff --git a/tests/helpers/published-reports.cjs b/tests/helpers/published-reports.cjs new file mode 100644 index 0000000..abce75d --- /dev/null +++ b/tests/helpers/published-reports.cjs @@ -0,0 +1,82 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +// Hosted assertions inspect report side effects without expanding the public API. +class PublishedReports { + constructor(github, context) { + this.github = github; + this.context = context; + } + + async read(prefix, expectedCount) { + const directories = fs + .readdirSync(process.env.RUNNER_TEMP, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) + .map((entry) => path.join(process.env.RUNNER_TEMP, entry.name)); + assert.equal(directories.length, expectedCount); + const artifacts = await this.github.paginate( + this.github.rest.actions.listWorkflowRunArtifacts, + { ...this.context.repo, run_id: this.context.runId, per_page: 100 }, + ); + return directories.map((directory) => { + assert.equal(fs.statSync(directory).mode & 0o777, 0o700); + const artifact = artifacts.find( + (item) => item.name === path.basename(directory), + ); + assert.ok( + artifact, + `Missing uploaded reports for ${path.basename(directory)}`, + ); + assert.ok(artifact.size_in_bytes > 0); + assert.equal(artifact.expired, false); + const files = fs + .readdirSync(directory) + .filter((name) => /\.(xml|info|json)$/.test(name)) + .map((name) => path.join(directory, name)); + for (const name of ["guest"]) { + const source = path.join(directory, name); + if (!fs.existsSync(source)) continue; + for (const relative of fs.readdirSync(source, { recursive: true })) { + const file = path.join(source, relative); + if (fs.lstatSync(file).isFile()) files.push(file); + } + } + assert.ok(files.length > 0); + const text = files + .map((file) => fs.readFileSync(file, "utf8")) + .join("\n"); + assert.equal(text.includes("synthetic-private-output"), false); + return { directory, artifact, files, text }; + }); + } + + async comment(title) { + const pullRequest = this.context.payload.pull_request; + if ( + this.context.eventName !== "pull_request" || + pullRequest.head.repo.full_name !== + `${this.context.repo.owner}/${this.context.repo.repo}` || + this.context.actor === "dependabot[bot]" + ) + return null; + const comments = await this.github.paginate( + this.github.rest.issues.listComments, + { + ...this.context.repo, + issue_number: pullRequest.number, + per_page: 100, + }, + ); + const comment = comments.find( + (item) => + item.user.login === "github-actions[bot]" && + item.body.startsWith(`${title}\n`), + ); + assert.ok(comment, `Missing report comment: ${title}`); + assert.equal(comment.body.includes("synthetic-private-output"), false); + return comment.body; + } +} + +module.exports = { PublishedReports }; diff --git a/tests/helpers/vm.cjs b/tests/helpers/vm.cjs new file mode 100644 index 0000000..5761208 --- /dev/null +++ b/tests/helpers/vm.cjs @@ -0,0 +1,68 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { VirtualMachine } = require("../../actions/vm/_internal/runtime.cjs"); +const { QemuCreation } = require("../../actions/vm/create/qemu.cjs"); +const { createRequire } = require("node:module"); +const actionRequire = createRequire(path.resolve("package.json")); +const helpers = { + exec: actionRequire("@actions/exec"), + io: actionRequire("@actions/io"), +}; + +function limaFixture(t, Machine = VirtualMachine) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vm-test-")); + const previousDirectory = process.cwd(); + process.chdir(root); + const vm = new Machine( + fs.mkdtempSync(`/tmp/iac-${process.getuid()}-`), + helpers, + ); + fs.writeFileSync(path.join(root, "guest.yml"), "cpus: 1\n"); + + t.after(() => { + process.chdir(previousDirectory); + fs.rmSync(vm.home, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + }); + return { root, vm, reports: path.join(root, ".reports") }; +} + +function qemuFixture(t, Machine = VirtualMachine) { + const root = fs.mkdtempSync("/tmp/qemu-test-"); + const previous = process.cwd(); + process.chdir(root); + const vm = new Machine( + fs.mkdtempSync(`/var/tmp/iac-qemu-${process.getuid()}-`), + helpers, + ); + for (const name of ["installer.iso", "key"]) + fs.writeFileSync(name, "synthetic"); + const config = { + cpus: 2, + memoryMiB: 2048, + diskGiB: 10, + diskSerial: "TEST_DISK", + iso: "installer.iso", + network: { + subnet: "192.0.2.0/24", + address: "192.0.2.10", + gateway: "192.0.2.1", + dns: "192.0.2.2", + mac: "02:00:00:00:00:01", + }, + ssh: { user: "root", privateKey: "key" }, + }; + const creation = (value = config) => { + fs.writeFileSync("vm.json", JSON.stringify(value)); + return new QemuCreation("vm.json", "1"); + }; + t.after(() => { + process.chdir(previous); + fs.rmSync(vm.home, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + }); + return { root, vm, config, creation }; +} + +module.exports = { limaFixture, qemuFixture }; diff --git a/tests/reports.test.cjs b/tests/reports.test.cjs deleted file mode 100644 index 4b2ba33..0000000 --- a/tests/reports.test.cjs +++ /dev/null @@ -1,213 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const { execFileSync } = require("node:child_process"); -const { test } = require("node:test"); -const { GuestReports } = require("../actions/vm/test/reports.cjs"); -const { GuestAssertions } = require("../actions/vm/test/assertions.cjs"); - -function reportFixture(t, selection = "**/*.xml,coverage/*.info") { - const directory = fs.mkdtempSync("/tmp/report-test-"); - const guest = fs.mkdtempSync("/tmp/iac-reports."); - t.after(() => { - fs.rmSync(directory, { recursive: true, force: true }); - fs.rmSync(guest, { recursive: true, force: true }); - }); - fs.writeFileSync(path.join(guest, "junit.xml"), ''); - fs.mkdirSync(path.join(guest, "coverage")); - fs.writeFileSync(path.join(guest, "coverage/lcov.info"), "TN:synthetic\n"); - fs.writeFileSync(path.join(guest, "private.txt"), "synthetic-private-output"); - fs.symlinkSync( - path.join(guest, "private.txt"), - path.join(guest, "linked.xml"), - ); - fs.symlinkSync(directory, path.join(guest, "external")); - const vm = { name: "fixture", command: () => "" }; - const execute = (args) => { - if (args.includes("mktemp")) return guest; - if (args.includes("find")) - return execFileSync("find", args.slice(args.indexOf("find") + 1), { - encoding: "utf8", - }); - if (args[0] === "copy") - fs.copyFileSync(args[2].slice(args[2].indexOf(":") + 1), args[3]); - if (args.includes("rm")) fs.rmSync(guest, { recursive: true }); - return ""; - }; - const command = t.mock.method(vm, "command", execute); - const reports = new GuestReports(selection); - reports.prepare(vm); - return { directory, guest, vm, command, reports, execute }; -} - -function assertionFixture(t) { - const fixture = reportFixture(t); - const previous = process.cwd(); - process.chdir(fixture.directory); - t.after(() => process.chdir(previous)); - fs.writeFileSync("assert.sh", "true\n"); - return fixture; -} - -test("report selection accepts lists and globs within REPORTS_DIR", () => { - const reports = new GuestReports("*.xml;coverage/*.info\n*.xml"); - assert.deepEqual( - reports - .select("20\tjunit.xml\0" + "8\tcoverage/lcov.info\0") - .map((file) => file.path), - ["coverage/lcov.info", "junit.xml"], - ); - for (const pattern of [ - "/etc/passwd", - "../*.xml", - "nested/../../x", - "$(id)", - ]) { - assert.throws(() => new GuestReports(pattern)); - } -}); - -test("requested reports must exist, be nonempty and fit collection limits", () => { - const reports = new GuestReports("*.xml"); - for (const manifest of [ - "", - "0\tempty.xml\0", - "20971521\tlarge.xml\0", - "1\t../outside.xml\0", - "1\tbad\nname.xml\0", - Array.from({ length: 101 }, (_, index) => `1\t${index}.xml\0`).join(""), - ]) { - assert.throws(() => reports.select(manifest)); - } - assert.throws(() => - new GuestReports("*.xml,missing.info").select("1\tgood.xml\0"), - ); -}); - -test("collection copies only selected regular files and preserves their names", (t) => { - const { reports, vm, directory, guest, command } = reportFixture(t); - const files = reports.collect(vm, directory); - assert.deepEqual( - files.map((file) => path.relative(directory, file)), - ["guest/coverage/lcov.info", "guest/junit.xml"], - ); - assert.equal( - fs.readFileSync(files[1], "utf8"), - fs.readFileSync(path.join(guest, "junit.xml"), "utf8"), - ); - assert.equal(fs.existsSync(path.join(directory, "guest/linked.xml")), false); - assert.equal(fs.existsSync(path.join(directory, "guest/private.txt")), false); - for (const call of command.mock.calls.filter( - (call) => call.arguments[0][0] === "copy", - )) { - assert.equal(call.arguments[0][1], "--backend=scp"); - assert.ok( - call.arguments[1].timeout > 0 && call.arguments[1].timeout <= 120000, - ); - } - reports.cleanup(vm); - assert.equal(fs.existsSync(guest), false); - assert.ok(files.every((file) => fs.existsSync(file))); -}); - -test("a failed transfer removes all staged files without disturbing prior evidence", (t) => { - const { reports, vm, directory, command, execute } = reportFixture(t); - fs.writeFileSync(path.join(directory, "prior.xml"), "earlier result"); - let copies = 0; - command.mock.mockImplementation((args) => { - if (args[0] !== "copy" || copies++ === 0) return execute(args); - fs.writeFileSync(args[3], "partial"); - throw new Error("synthetic transfer failure"); - }); - assert.throws(() => reports.collect(vm, directory)); - assert.deepEqual(fs.readdirSync(directory), ["prior.xml"]); - assert.equal( - fs.readFileSync(path.join(directory, "prior.xml"), "utf8"), - "earlier result", - ); -}); - -test("incomplete copies fail before reports become visible", (t) => { - const { reports, vm, directory, command } = reportFixture(t, "*.xml"); - command.mock.mockImplementation((args) => { - if (args.includes("find")) return "100\tjunit.xml\0"; - if (args[0] === "copy") fs.writeFileSync(args[3], "partial"); - return ""; - }); - assert.throws(() => reports.collect(vm, directory)); - assert.deepEqual(fs.readdirSync(directory), []); -}); - -test("test failures and timeouts still collect reports before guest report cleanup", (t) => { - const { directory, guest, vm, command, execute } = assertionFixture(t); - command.mock.mockImplementation((args) => { - if (args.includes("bash")) { - assert.ok(args.includes(`REPORTS_DIR=${guest}`)); - throw Object.assign(new Error("synthetic timeout"), { - code: "ETIMEDOUT", - }); - } - return execute(args); - }); - const assertions = new GuestAssertions( - { script: "assert.sh" }, - directory, - "1", - "*.xml", - ); - assert.throws(() => assertions.run(vm)); - assert.equal(assertions.reportFiles.length, 1); - assert.ok(fs.existsSync(assertions.reportFiles[0])); - assert.match(fs.readFileSync(assertions.report, "utf8"), /failures="1"/); - assert.equal(fs.existsSync(guest), false); -}); - -test("collection failure fails successful assertions and cleans the guest report directory", (t) => { - const { directory, guest, vm } = assertionFixture(t); - const assertions = new GuestAssertions( - { script: "assert.sh" }, - directory, - "1", - "missing.xml", - ); - assert.throws(() => assertions.run(vm)); - assert.deepEqual(assertions.reportFiles, []); - assert.match(fs.readFileSync(assertions.report, "utf8"), /failures="1"/); - assert.equal(fs.existsSync(guest), false); -}); - -test("report collection has a single deadline across all transfers", (t) => { - const { reports, vm, directory, command } = reportFixture(t); - const times = [1000, 121001]; - t.mock.method(Date, "now", () => times.shift()); - assert.throws(() => reports.collect(vm, directory), /timed out/); - assert.equal(command.mock.callCount(), 1); -}); - -test("completed guest reports survive a command-status report write failure", (t) => { - const { directory, vm } = assertionFixture(t); - const write = fs.writeFileSync; - t.mock.method(fs, "writeFileSync", (filename) => { - write(filename, "partial"); - throw new Error("synthetic report write failure"); - }); - const assertions = new GuestAssertions( - { script: "assert.sh" }, - directory, - "1", - "*.xml", - ); - assert.throws(() => assertions.run(vm)); - assert.equal(assertions.report, undefined); - assert.equal(assertions.reportFiles.length, 1); - assert.ok(fs.existsSync(assertions.reportFiles[0])); - assert.equal( - fs.existsSync( - path.join( - path.dirname(path.dirname(assertions.reportFiles[0])), - "e2e.junit.xml", - ), - ), - false, - ); -}); diff --git a/tests/vm.test.cjs b/tests/vm.test.cjs deleted file mode 100644 index ef85bc8..0000000 --- a/tests/vm.test.cjs +++ /dev/null @@ -1,500 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const childProcess = require("node:child_process"); -const { test } = require("node:test"); -const { VirtualMachine } = require("../actions/vm/_internal/runtime.cjs"); -const { GuestAssertions } = require("../actions/vm/test/assertions.cjs"); -const { GuestCreation } = require("../actions/vm/create/creation.cjs"); - -const backend = path.resolve(__dirname, "../actions/vm"); - -function fixture(t) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "vm-test-")); - const previousDirectory = process.cwd(); - process.chdir(root); - const vm = VirtualMachine.allocate(); - fs.writeFileSync(path.join(root, "guest.yml"), "cpus: 1\n"); - fs.writeFileSync( - path.join(root, "assert.sh"), - "test -d /run/systemd/system\n", - ); - t.after(() => { - process.chdir(previousDirectory); - fs.rmSync(vm.home, { recursive: true, force: true }); - fs.rmSync(root, { recursive: true, force: true }); - }); - return { root, vm, reports: path.join(root, ".reports") }; -} - -function completedTest(t) { - const environment = fixture(t); - const command = t.mock.method(environment.vm, "command", () => ""); - const assertions = new GuestAssertions( - { script: "assert.sh" }, - environment.reports, - ); - assertions.run(environment.vm); - return { - ...environment, - command, - assertions, - report: assertions.report, - content: fs.readFileSync(assertions.report, "utf8"), - }; -} - -function runAction(t, operation, inputs) { - const outputs = {}; - const failures = []; - const core = { - setOutput: (key, value) => { - outputs[key] = value; - }, - setFailed: (message) => failures.push(message), - info: () => {}, - }; - const actionInputs = inputs; - const previous = Object.fromEntries( - Object.keys(actionInputs).map((key) => [key, process.env[key]]), - ); - t.after(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - Object.assign(process.env, actionInputs); - const execute = require(path.join(backend, operation, "index.cjs")); - execute({ core }); - return { outputs, failures }; -} - -test("each invocation has a short private runtime", (t) => { - const { vm } = fixture(t); - const other = VirtualMachine.allocate(); - t.after(() => other.destroy()); - assert.notEqual(vm.home, other.home); - assert.equal(fs.statSync(vm.home).mode & 0o777, 0o700); - assert.ok( - Buffer.byteLength( - path.join(vm.home, vm.name, "ssh.sock.1234567890123456"), - ) < 108, - ); -}); - -test("shared, unmanaged and symlinked runtimes are rejected", (t) => { - const { root, vm } = fixture(t); - for (const directory of ["", root, "/tmp/not-owned"]) { - assert.throws(() => new VirtualMachine(directory).destroy()); - } - fs.chmodSync(vm.home, 0o755); - assert.throws(() => vm.destroy()); - fs.rmSync(vm.home, { recursive: true }); - fs.symlinkSync(path.join(root, "missing"), vm.home); - assert.throws(() => vm.destroy()); - assert.ok(fs.existsSync(path.join(root, "assert.sh"))); -}); - -test("configuration must be a checkout file", (t) => { - const { root } = fixture(t); - fs.symlinkSync( - path.join(__dirname, "fixture/lima.yml"), - path.join(root, "external.yml"), - ); - for (const config of [ - undefined, - "", - " \n", - "missing.yml", - "../outside.yml", - "external.yml", - ".", - ]) { - assert.throws(() => new GuestCreation(config)); - } -}); - -test("create accepts configuration without assertion or report inputs", (t) => { - const { root, vm } = fixture(t); - const config = path.join(root, "guest.yml"); - const command = t.mock.method(vm, "command", () => ""); - new GuestCreation("guest.yml").start(vm); - assert.ok(command.mock.calls[0].arguments[0].includes(config)); - assert.ok(command.mock.calls[0].arguments[0].includes("--plain")); - assert.equal(command.mock.calls[0].arguments[1].timeout, 660000); - assert.equal(fs.existsSync(path.join(root, ".reports")), false); -}); - -test("creation action rejects empty configuration before allocating a runtime", (t) => { - const allocate = t.mock.method(VirtualMachine, "allocate", () => { - throw new Error("Unexpected allocation"); - }); - const { outputs, failures } = runAction(t, "create", { VM_CONFIG: "" }); - assert.deepEqual(outputs, {}); - assert.equal(failures.length, 1); - assert.match(failures[0], /required vm-config/); - assert.equal(allocate.mock.callCount(), 0); -}); - -test("custom startup budgets retain a bounded subprocess deadline", (t) => { - const { vm } = fixture(t); - const command = t.mock.method(vm, "command", () => ""); - new GuestCreation("guest.yml", "20").start(vm); - assert.ok(command.mock.calls[0].arguments[0].includes("--timeout=20m")); - assert.equal(command.mock.calls[0].arguments[1].timeout, 21 * 60000); -}); - -test("invalid startup budgets fail before allocating a runtime", (t) => { - fixture(t); - const allocate = t.mock.method(VirtualMachine, "allocate"); - for (const timeout of ["", "0", "-1", "41", "1.5", "unbounded"]) { - assert.throws( - () => new GuestCreation("guest.yml", timeout), - /Startup timeout/, - ); - } - const { outputs, failures } = runAction(t, "create", { - VM_CONFIG: "guest.yml", - VM_STARTUP_TIMEOUT_MINUTES: "41", - }); - assert.deepEqual(outputs, {}); - assert.equal(failures.length, 1); - assert.equal(allocate.mock.callCount(), 0); -}); - -test("repeated creation refuses to reuse or delete a running VM", (t) => { - const { vm } = fixture(t); - t.mock.method(vm, "exists", () => true); - const command = t.mock.method(vm, "command"); - assert.throws(() => new GuestCreation("guest.yml").start(vm)); - assert.equal(command.mock.callCount(), 0); -}); - -test("failed creation rolls back its partial guest", (t) => { - const { vm } = fixture(t); - const presence = [false, true, false]; - t.mock.method(vm, "exists", () => presence.shift()); - const command = t.mock.method(vm, "command", (args) => { - if (args[0] === "start") throw new Error("synthetic failure"); - return ""; - }); - assert.throws(() => new GuestCreation("guest.yml").start(vm)); - assert.deepEqual(command.mock.calls.at(-1).arguments[0], [ - "delete", - "--force", - "iac-test", - ]); - assert.equal(fs.existsSync(vm.home), false); -}); - -test("failed rollback preserves the recovery directory", (t) => { - const { vm } = fixture(t); - const presence = [false, true]; - t.mock.method(vm, "exists", () => presence.shift()); - t.mock.method(vm, "command", () => { - throw new Error("synthetic failure"); - }); - assert.throws(() => new GuestCreation("guest.yml").start(vm)); - assert.ok(fs.existsSync(vm.home)); -}); - -test("reports require a resolved directory and a bounded deadline", (t) => { - const { reports } = fixture(t); - for (const directory of [undefined, "", ".reports"]) { - assert.throws( - () => new GuestAssertions({ script: "assert.sh" }, directory), - ); - } - for (const minutes of ["0", "41", "-1", "1.5", "nan", ""]) { - assert.throws( - () => new GuestAssertions({ script: "assert.sh" }, reports, minutes), - ); - } -}); - -test("invalid settings preserve earlier reports without creating files", (t) => { - const { reports, report, content } = completedTest(t); - const directories = fs.readdirSync(reports); - assert.throws( - () => new GuestAssertions({ script: "assert.sh" }, reports, "invalid"), - ); - assert.deepEqual(fs.readdirSync(reports), directories); - assert.equal(fs.readFileSync(report, "utf8"), content); -}); - -test("assertions reach guest stdin with a deadline and leave the VM intact", (t) => { - const { vm, reports } = fixture(t); - const assertions = new GuestAssertions({ script: "assert.sh" }, reports, "3"); - const command = t.mock.method(vm, "command", () => ""); - assertions.run(vm); - assert.deepEqual(command.mock.calls[0].arguments, [ - ["shell", "--workdir=/tmp", "iac-test", "bash", "-euo", "pipefail", "-s"], - { timeout: 180000, input: "test -d /run/systemd/system\n" }, - ]); - assert.equal(command.mock.callCount(), 1); - assert.ok(fs.existsSync(vm.home)); - assert.match( - fs.readFileSync(assertions.report, "utf8"), - /tests="1" failures="0"/, - ); -}); - -test("multiline commands reach guest stdin without host expansion", (t) => { - const { vm, reports } = fixture(t); - const command = 'test -d /run/systemd/system\nprintf "%s" "$GUEST_VALUE"\n'; - const execute = t.mock.method(vm, "command", () => ""); - const assertions = new GuestAssertions({ command }, reports); - assertions.run(vm); - assert.equal(execute.mock.calls[0].arguments[1].input, command); - assert.match(fs.readFileSync(assertions.report, "utf8"), /failures="0"/); -}); - -test("missing or conflicting command inputs fail before guest execution", (t) => { - const { vm, reports } = fixture(t); - const execute = t.mock.method(vm, "command", () => ""); - for (const source of [ - {}, - { command: " \n" }, - { command: "true", script: "assert.sh" }, - ]) { - const assertions = new GuestAssertions(source, reports); - assert.throws(() => assertions.run(vm)); - assert.match(fs.readFileSync(assertions.report, "utf8"), /failures="1"/); - } - assert.equal(execute.mock.callCount(), 0); -}); - -test("failed and timed out assertions produce redacted failure evidence", (t) => { - const { vm, reports } = fixture(t); - const assertions = new GuestAssertions({ script: "assert.sh" }, reports); - for (const error of [ - new Error("secret-output"), - Object.assign(new Error("secret"), { code: "ETIMEDOUT" }), - ]) { - t.mock.method(vm, "command", () => { - throw error; - }); - assert.throws(() => assertions.run(vm)); - const report = fs.readFileSync(assertions.report, "utf8"); - assert.match(report, /failures="1"/); - assert.equal(report.includes("secret"), false); - assert.ok(fs.existsSync(vm.home)); - } -}); - -test("invalid scripts fail without guest execution", (t) => { - const { root, vm, reports } = fixture(t); - fs.writeFileSync(path.join(root, "empty.sh"), " \n"); - fs.symlinkSync( - path.join(__dirname, "fixture/assert.sh"), - path.join(root, "external.sh"), - ); - const command = t.mock.method(vm, "command"); - for (const script of [ - "", - "missing.sh", - "empty.sh", - "../outside.sh", - "external.sh", - ]) { - const assertions = new GuestAssertions({ script }, reports); - assert.throws(() => assertions.run(vm)); - assert.match(fs.readFileSync(assertions.report, "utf8"), /failures="1"/); - } - assert.equal(command.mock.callCount(), 0); -}); - -test("repeated tests on one guest preserve each report", (t) => { - const { vm, command, assertions, report, content } = completedTest(t); - command.mock.mockImplementation(() => { - throw new Error("synthetic failure"); - }); - assert.throws(() => assertions.run(vm)); - assert.notEqual(assertions.report, report); - assert.equal(fs.readFileSync(report, "utf8"), content); - assert.match(fs.readFileSync(assertions.report, "utf8"), /failures="1"/); - assert.equal( - fs.statSync(path.dirname(assertions.report)).mode & 0o777, - 0o700, - ); -}); - -test("failed report writes expose no partial or previous report", (t) => { - const { vm, reports, assertions, report, content } = completedTest(t); - const directories = fs.readdirSync(reports); - const write = fs.writeFileSync; - t.mock.method(fs, "writeFileSync", (filename) => { - write(filename, "partial report"); - throw new Error("synthetic write failure"); - }); - assert.throws(() => assertions.run(vm)); - assert.equal(assertions.report, undefined); - assert.deepEqual(fs.readdirSync(reports), directories); - assert.equal(fs.readFileSync(report, "utf8"), content); -}); - -test("Lima commands use private home, stdin and a forced timeout", (t) => { - const { vm } = fixture(t); - const spawn = t.mock.method(childProcess, "spawnSync", () => ({ - status: 0, - stdout: "Running\n", - })); - assert.equal(vm.command(["list"]), "Running"); - const [executable, args, options] = spawn.mock.calls[0].arguments; - assert.equal(executable, "limactl"); - assert.deepEqual(args, ["list"]); - assert.equal(options.env.LIMA_HOME, vm.home); - assert.equal(options.timeout, 120000); - assert.equal(options.killSignal, "SIGKILL"); - assert.equal(options.stdio, "pipe"); - assert.equal(options.shell, undefined); -}); - -test("subprocess failures and timeouts cannot expose raw output", (t) => { - const { vm } = fixture(t); - const spawn = t.mock.method(childProcess, "spawnSync"); - for (const result of [ - { status: 1, stdout: "private-output", stderr: "private-error" }, - { status: null, error: new Error("private-command") }, - ]) { - spawn.mock.mockImplementation(() => result); - assert.throws(() => vm.command(["list"]), { - message: "VM command failed or timed out", - }); - } -}); - -test("destruction verifies absence before removing runtime keys", (t) => { - const { vm } = fixture(t); - const presence = [true, false]; - t.mock.method(vm, "exists", () => presence.shift()); - const command = t.mock.method(vm, "command", () => ""); - vm.destroy(); - assert.deepEqual(command.mock.calls[0].arguments[0], [ - "delete", - "--force", - "iac-test", - ]); - assert.equal(fs.existsSync(vm.home), false); - const rerun = runAction(t, "destroy", { VM_DIRECTORY: vm.home }); - assert.equal(rerun.outputs.removed, "true"); - assert.deepEqual(rerun.failures, []); -}); - -test("a guest surviving deletion preserves recovery access and fails", (t) => { - const { vm } = fixture(t); - t.mock.method(vm, "exists", () => true); - t.mock.method(vm, "command", () => ""); - assert.throws(() => vm.destroy()); - assert.ok(fs.existsSync(vm.home)); -}); - -test("an empty runtime can be destroyed without installed Lima", (t) => { - const { vm } = fixture(t); - const command = t.mock.method(vm, "command"); - vm.destroy(); - assert.equal(command.mock.callCount(), 0); - assert.equal(fs.existsSync(vm.home), false); -}); - -test("creation action exports recovery outputs before failed startup", (t) => { - const { vm } = fixture(t); - t.mock.method(VirtualMachine, "allocate", () => vm); - t.mock.method(vm, "command", () => { - throw new Error("secret-output"); - }); - const { outputs, failures } = runAction(t, "create", { - VM_CONFIG: "guest.yml", - }); - assert.equal(outputs.vm, vm.home); - assert.equal(outputs.id, path.basename(vm.home)); - assert.equal(outputs.name, vm.name); - assert.equal(failures.length, 1); - assert.equal(failures[0].includes("secret"), false); - assert.equal(fs.existsSync(vm.home), false); -}); - -test("test action exports a failed report while preserving the guest", (t) => { - const { root, vm } = fixture(t); - t.mock.method(VirtualMachine.prototype, "command", () => { - throw new Error("secret-command"); - }); - const { outputs, failures } = runAction(t, "test", { - VM_DIRECTORY: vm.home, - TEST_COMMAND: "false\n", - RUNNER_TEMP: path.join(root, "runner temp"), - TEST_TIMEOUT_MINUTES: "1", - }); - assert.equal( - path.dirname(path.dirname(outputs.report)), - path.join(root, "runner temp"), - ); - assert.equal(fs.existsSync(path.join(root, ".reports")), false); - assert.equal(outputs.id, path.basename(path.dirname(outputs.report))); - assert.equal(failures.length, 1); - assert.equal(failures[0].includes("secret"), false); - assert.match(fs.readFileSync(outputs.report, "utf8"), /failures="1"/); - assert.ok(fs.existsSync(vm.home)); -}); - -for (const coverage of ["unknown", "codecov"]) { - test(`${coverage} coverage without valid report settings fails before guest execution`, (t) => { - const { root, vm } = fixture(t); - const command = t.mock.method( - VirtualMachine.prototype, - "command", - () => "", - ); - const { outputs, failures } = runAction(t, "test", { - VM_DIRECTORY: vm.home, - TEST_COMMAND: "true", - RUNNER_TEMP: root, - COVERAGE: coverage, - REPORT_FILE: "", - }); - assert.deepEqual(outputs, {}); - assert.equal(failures.length, 1); - assert.equal(command.mock.callCount(), 0); - }); -} - -test("report write failures fail the test action and allow destruction", (t) => { - const { root, vm } = fixture(t); - t.mock.method(VirtualMachine.prototype, "command", () => ""); - t.mock.method(fs, "writeFileSync", () => { - throw new Error("synthetic disk failure"); - }); - const { outputs, failures } = runAction(t, "test", { - VM_DIRECTORY: vm.home, - TEST_SCRIPT: "assert.sh", - RUNNER_TEMP: path.join(root, "runner temp"), - TEST_TIMEOUT_MINUTES: "1", - }); - assert.deepEqual(outputs, {}); - assert.equal(failures.length, 1); - const cleanup = runAction(t, "destroy", { VM_DIRECTORY: vm.home }); - assert.equal(cleanup.outputs.removed, "true"); - assert.deepEqual(cleanup.failures, []); - assert.equal(fs.existsSync(vm.home), false); -}); - -test("destruction action exports removal only after verified cleanup", (t) => { - const { vm } = fixture(t); - const presence = t.mock.method( - VirtualMachine.prototype, - "exists", - () => true, - ); - t.mock.method(VirtualMachine.prototype, "command", () => ""); - const failed = runAction(t, "destroy", { VM_DIRECTORY: vm.home }); - assert.equal(failed.outputs.removed, undefined); - assert.equal(failed.failures.length, 1); - assert.ok(fs.existsSync(vm.home)); - presence.mock.mockImplementation(() => false); - const success = runAction(t, "destroy", { VM_DIRECTORY: vm.home }); - assert.equal(success.outputs.removed, "true"); - assert.deepEqual(success.failures, []); - assert.equal(fs.existsSync(vm.home), false); -});