diff --git a/.codecov.yml b/.codecov.yml index e9e0ca7be20e..337b0b15e740 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -2,7 +2,7 @@ codecov: notify: - after_n_builds: 6 # Number of test matrix+lint jobs uploading coverage + after_n_builds: 9 # Number of test matrix+lint jobs uploading coverage wait_for_ci: false require_ci_to_pass: false diff --git a/.coveragerc b/.coveragerc index 086c99d2c80e..14aa98d8f966 100644 --- a/.coveragerc +++ b/.coveragerc @@ -17,8 +17,27 @@ exclude_also = [run] branch = True +# NOTE: `disable_warnings` is needed when `pytest-cov` runs in tandem +# NOTE: with `pytest-xdist`. These warnings are false negative in this +# NOTE: context. +# +# NOTE: It's `coveragepy` that emits the warnings and previously they +# NOTE: wouldn't get on the radar of `pytest`'s `filterwarnings` +# NOTE: mechanism. This changed, however, with `pytest >= 8.4`. And +# NOTE: since we set `filterwarnings = error`, those warnings are being +# NOTE: raised as exceptions, cascading into `pytest`'s internals and +# NOTE: causing tracebacks and crashes of the test sessions. +# +# Ref: +# * https://github.com/pytest-dev/pytest-cov/issues/693 +# * https://github.com/pytest-dev/pytest-cov/pull/695 +# * https://github.com/pytest-dev/pytest-cov/pull/696 +disable_warnings = + module-not-measured omit = awx/main/migrations/* + awx/settings/defaults.py + awx/settings/*_defaults.py source = . source_pkgs = diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 0164155b8102..31368dcb706b 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,3 +1,3 @@ # Community Code of Conduct -Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html). +Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bfad8bec3ae0..144f4599eb84 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: Please confirm the following options: - - label: I agree to follow this project's [code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html). + - label: I agree to follow this project's [code of conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). required: true - label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates. required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b3e5d26591c8..88a8445f2789 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -5,7 +5,7 @@ contact_links: url: https://github.com/ansible/awx#get-involved about: For general debugging or technical support please see the Get Involved section of our readme. - name: 📝 Ansible Code of Conduct - url: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html?utm_medium=github&utm_source=issue_template_chooser + url: https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html?utm_medium=github&utm_source=issue_template_chooser about: AWX uses the Ansible Code of Conduct; ❤ Be nice to other members of the community. ☮ Behave. - name: 💼 For Enterprise url: https://www.ansible.com/products/engine?utm_medium=github&utm_source=issue_template_chooser diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 53ba31b9f6d1..d1c81fef755f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -13,7 +13,7 @@ body: attributes: label: Please confirm the following options: - - label: I agree to follow this project's [code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html). + - label: I agree to follow this project's [code of conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). required: true - label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates. required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 07d23adf000b..99a30cb41125 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,8 @@ ##### ISSUE TYPE @@ -16,20 +17,14 @@ the change does. ##### COMPONENT NAME - API - - UI - Collection - CLI - Docs - Other -##### AWX VERSION - -``` - -``` -##### ADDITIONAL INFORMATION +##### STEPS TO REPRODUCE AND EXTRA INFO ' reports/coverage.xml + echo "Injected PR number ${{ github.event.pull_request.number }} into reports/coverage.xml" + fi + if [ -f "awxkit/coverage.xml" ]; then + sed -i '2i' awxkit/coverage.xml + echo "Injected PR number ${{ github.event.pull_request.number }} into awxkit/coverage.xml" + fi + - name: Upload test coverage to Codecov if: >- !cancelled() @@ -102,25 +140,37 @@ jobs: }} token: ${{ secrets.CODECOV_TOKEN }} - - name: Upload awx jUnit test reports + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.tests.name }}-artifacts + path: | + reports/coverage.xml + awxkit/coverage.xml + retention-days: 5 + + - name: >- + Upload ${{ + matrix.tests.coverage-upload-name || 'awx' + }} jUnit test reports to the unified dashboard if: >- !cancelled() && steps.make-run.outputs.test-result-files != '' && github.event_name == 'push' && env.UPSTREAM_REPOSITORY_ID == github.repository_id && github.ref_name == github.event.repository.default_branch - run: | - for junit_file in $(echo '${{ steps.make-run.outputs.test-result-files }}' | sed 's/,/ /') - do - curl \ - -v \ - --user "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }}:${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }}" \ - --form "xunit_xml=@${junit_file}" \ - --form "component_name=${{ matrix.tests.coverage-upload-name || 'awx' }}" \ - --form "git_commit_sha=${{ github.sha }}" \ - --form "git_repository_url=https://github.com/${{ github.repository }}" \ - "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}/api/results/upload/" - done + uses: ansible/gh-action-record-test-results@3784db66a1b7fb3809999a7251c8a7203a7ffbe8 + with: + aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }} + http-auth-password: >- + ${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }} + http-auth-username: >- + ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }} + project-component-name: >- + ${{ matrix.tests.coverage-upload-name || 'awx' }} + test-result-files: >- + ${{ steps.make-run.outputs.test-result-files }} dev-env: runs-on: ubuntu-latest @@ -130,9 +180,9 @@ jobs: with: show-progress: false - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-python with: - python-version: '3.x' + python-version: '3.13' - uses: ./.github/actions/run_awx_devel id: awx @@ -161,6 +211,10 @@ jobs: show-progress: false path: awx + - uses: ./awx/.github/actions/setup-ssh-agent + with: + ssh-private-key: ${{ secrets.PRIVATE_GITHUB_KEY }} + - name: Checkout awx-operator uses: actions/checkout@v4 with: @@ -168,39 +222,20 @@ jobs: repository: ansible/awx-operator path: awx-operator - - name: Get python version from Makefile - working-directory: awx - run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV - - - name: Install python ${{ env.py_version }} - uses: actions/setup-python@v4 + - name: Setup python, referencing action at awx relative path + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: - python-version: ${{ env.py_version }} + python-version: '3.12' - name: Install playbook dependencies run: | - python3 -m pip install docker + python -m pip install docker - - name: Generate placeholder SSH private key if SSH auth for private repos is not needed - id: generate_key - shell: bash + - name: Check Python version + working-directory: awx run: | - if [[ -z "${{ secrets.PRIVATE_GITHUB_KEY }}" ]]; then - ssh-keygen -t ed25519 -C "github-actions" -N "" -f ~/.ssh/id_ed25519 - echo "SSH_PRIVATE_KEY<> $GITHUB_OUTPUT - cat ~/.ssh/id_ed25519 >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - else - echo "SSH_PRIVATE_KEY<> $GITHUB_OUTPUT - echo "${{ secrets.PRIVATE_GITHUB_KEY }}" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - fi - - - name: Add private GitHub key to SSH agent - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ steps.generate_key.outputs.SSH_PRIVATE_KEY }} - + make print-PYTHON + - name: Build AWX image working-directory: awx run: | @@ -212,27 +247,59 @@ jobs: - name: Run test deployment with awx-operator working-directory: awx-operator + id: awx_operator_test + timeout-minutes: 60 + continue-on-error: true run: | - python3 -m pip install -r molecule/requirements.txt - python3 -m pip install PyYAML # for awx/tools/scripts/rewrite-awx-operator-requirements.py - $(realpath ../awx/tools/scripts/rewrite-awx-operator-requirements.py) molecule/requirements.yml $(realpath ../awx) - ansible-galaxy collection install -r molecule/requirements.yml - sudo rm -f $(which kustomize) - make kustomize - KUSTOMIZE_PATH=$(readlink -f bin/kustomize) molecule -v test -s kind -- --skip-tags=replicas + set +e + timeout 15m bash -elc ' + python -m pip install -r molecule/requirements.txt + python -m pip install PyYAML # for awx/tools/scripts/rewrite-awx-operator-requirements.py + $(realpath ../awx/tools/scripts/rewrite-awx-operator-requirements.py) molecule/requirements.yml $(realpath ../awx) + ansible-galaxy collection install -r molecule/requirements.yml + sudo rm -f $(which kustomize) + make kustomize + KUSTOMIZE_PATH=$(readlink -f bin/kustomize) molecule -v test -s kind -- --skip-tags=replicas + ' + rc=$? + if [ $rc -eq 124 ]; then + echo "timed_out=true" >> "$GITHUB_OUTPUT" + fi + exit $rc env: AWX_TEST_IMAGE: local/awx AWX_TEST_VERSION: ci AWX_EE_TEST_IMAGE: quay.io/ansible/awx-ee:latest STORE_DEBUG_OUTPUT: true + - name: Collect awx-operator logs on timeout + # Only run on timeout; normal failures should use molecule's built-in log collection. + if: steps.awx_operator_test.outputs.timed_out == 'true' + run: | + mkdir -p "$DEBUG_OUTPUT_DIR" + if command -v kind >/dev/null 2>&1; then + for cluster in $(kind get clusters 2>/dev/null); do + kind export logs "$DEBUG_OUTPUT_DIR/$cluster" --name "$cluster" || true + done + fi + if command -v kubectl >/dev/null 2>&1; then + kubectl get all -A -o wide > "$DEBUG_OUTPUT_DIR/kubectl-get-all.txt" || true + kubectl get pods -A -o wide > "$DEBUG_OUTPUT_DIR/kubectl-get-pods.txt" || true + kubectl describe pods -A > "$DEBUG_OUTPUT_DIR/kubectl-describe-pods.txt" || true + fi + docker ps -a > "$DEBUG_OUTPUT_DIR/docker-ps.txt" || true + - name: Upload debug output - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: awx-operator-debug-output path: ${{ env.DEBUG_OUTPUT_DIR }} + - name: Fail awx-operator check if test deployment failed + if: steps.awx_operator_test.outcome != 'success' + run: exit 1 + collection-sanity: name: awx_collection sanity runs-on: ubuntu-latest @@ -267,18 +334,16 @@ jobs: && github.event_name == 'push' && env.UPSTREAM_REPOSITORY_ID == github.repository_id && github.ref_name == github.event.repository.default_branch - run: | - for junit_file in $(echo '${{ steps.make-run.outputs.test-result-files }}' | sed 's/,/ /') - do - curl \ - -v \ - --user "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }}:${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }}" \ - --form "xunit_xml=@${junit_file}" \ - --form "component_name=awx" \ - --form "git_commit_sha=${{ github.sha }}" \ - --form "git_repository_url=https://github.com/${{ github.repository }}" \ - "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}/api/results/upload/" - done + uses: ansible/gh-action-record-test-results@3784db66a1b7fb3809999a7251c8a7203a7ffbe8 + with: + aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }} + http-auth-password: >- + ${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }} + http-auth-username: >- + ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }} + project-component-name: awx + test-result-files: >- + ${{ steps.make-run.outputs.test-result-files }} collection-integration: name: awx_collection integration @@ -299,9 +364,13 @@ jobs: with: show-progress: false - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-python with: - python-version: '3.x' + python-version: '3.13' + + - name: Remove system ansible to avoid conflicts + run: | + python -m pip uninstall -y ansible ansible-core || true - uses: ./.github/actions/run_awx_devel id: awx @@ -312,8 +381,9 @@ jobs: - name: Install dependencies for running tests run: | - python3 -m pip install -e ./awxkit/ - python3 -m pip install -r awx_collection/requirements.txt + python -m pip install -e ./awxkit/ + python -m pip install -r awx_collection/requirements.txt + hash -r # Rehash to pick up newly installed scripts - name: Run integration tests id: make-run @@ -325,6 +395,7 @@ jobs: echo 'password = password' >> ~/.tower_cli.cfg echo 'verify_ssl = false' >> ~/.tower_cli.cfg TARGETS="$(ls awx_collection/tests/integration/targets | grep '${{ matrix.target-regex.regex }}' | tr '\n' ' ')" + export PYTHONPATH="$(python -c 'import site; print(":".join(site.getsitepackages()))')${PYTHONPATH:+:$PYTHONPATH}" make COLLECTION_VERSION=100.100.100-git COLLECTION_TEST_TARGET="--requirements $TARGETS" test_collection_integration env: ANSIBLE_TEST_PREFER_PODMAN: 1 @@ -356,6 +427,7 @@ jobs: with: name: coverage-${{ matrix.target-regex.name }} path: ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage/ + retention-days: 1 - uses: ./.github/actions/upload_awx_devel_logs if: always() @@ -373,32 +445,26 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false show-progress: false - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-python with: - python-version: '3.x' - - - name: Upgrade ansible-core - run: python3 -m pip install --upgrade ansible-core + python-version: '3.13' - - name: Download coverage artifacts A to H - uses: actions/download-artifact@v4 - with: - name: coverage-a-h - path: coverage + - name: Remove system ansible to avoid conflicts + run: | + python -m pip uninstall -y ansible ansible-core || true - - name: Download coverage artifacts I to P - uses: actions/download-artifact@v4 - with: - name: coverage-i-p - path: coverage + - name: Upgrade ansible-core + run: python -m pip install --upgrade ansible-core - - name: Download coverage artifacts Z to Z + - name: Download coverage artifacts uses: actions/download-artifact@v4 with: - name: coverage-r-z0-9 + merge-multiple: true path: coverage + pattern: coverage-* - name: Combine coverage run: | @@ -406,56 +472,17 @@ jobs: mkdir -p ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage cp -rv coverage/* ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage/ cd ~/.ansible/collections/ansible_collections/awx/awx - ansible-test coverage combine --requirements - ansible-test coverage html + hash -r # Rehash to pick up newly installed scripts + PATH="$(python -c 'import sys; import os; print(os.path.dirname(sys.executable))'):$PATH" ansible-test coverage combine --requirements + PATH="$(python -c 'import sys; import os; print(os.path.dirname(sys.executable))'):$PATH" ansible-test coverage html echo '## AWX Collection Integration Coverage' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - ansible-test coverage report >> $GITHUB_STEP_SUMMARY + PATH="$(python -c 'import sys; import os; print(os.path.dirname(sys.executable))'):$PATH" ansible-test coverage report >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo >> $GITHUB_STEP_SUMMARY echo '## AWX Collection Integration Coverage HTML' >> $GITHUB_STEP_SUMMARY echo 'Download the HTML artifacts to view the coverage report.' >> $GITHUB_STEP_SUMMARY - # This is a huge hack, there's no official action for removing artifacts currently. - # Also ACTIONS_RUNTIME_URL and ACTIONS_RUNTIME_TOKEN aren't available in normal run - # steps, so we have to use github-script to get them. - # - # The advantage of doing this, though, is that we save on artifact storage space. - - - name: Get secret artifact runtime URL - uses: actions/github-script@v6 - id: get-runtime-url - with: - result-encoding: string - script: | - const { ACTIONS_RUNTIME_URL } = process.env; - return ACTIONS_RUNTIME_URL; - - - name: Get secret artifact runtime token - uses: actions/github-script@v6 - id: get-runtime-token - with: - result-encoding: string - script: | - const { ACTIONS_RUNTIME_TOKEN } = process.env; - return ACTIONS_RUNTIME_TOKEN; - - - name: Remove intermediary artifacts - env: - ACTIONS_RUNTIME_URL: ${{ steps.get-runtime-url.outputs.result }} - ACTIONS_RUNTIME_TOKEN: ${{ steps.get-runtime-token.outputs.result }} - run: | - echo "::add-mask::${ACTIONS_RUNTIME_TOKEN}" - artifacts=$( - curl -H "Authorization: Bearer $ACTIONS_RUNTIME_TOKEN" \ - ${ACTIONS_RUNTIME_URL}_apis/pipelines/workflows/${{ github.run_id }}/artifacts?api-version=6.0-preview \ - | jq -r '.value | .[] | select(.name | startswith("coverage-")) | .url' - ) - - for artifact in $artifacts; do - curl -i -X DELETE -H "Accept: application/json;api-version=6.0-preview" -H "Authorization: Bearer $ACTIONS_RUNTIME_TOKEN" "$artifact" - done - - name: Upload coverage report as artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/devel_images.yml b/.github/workflows/devel_images.yml index b6b9d4f16ad4..544ef8912bff 100644 --- a/.github/workflows/devel_images.yml +++ b/.github/workflows/devel_images.yml @@ -10,8 +10,13 @@ on: - devel - release_* - feature_* + - stable-* jobs: push-development-images: + if: | + github.event_name == 'workflow_dispatch' || + (github.repository == 'ansible/awx' && (github.ref_name == 'devel' || startsWith(github.ref_name, 'feature_'))) || + (github.repository == 'ansible/tower' && (startsWith(github.ref_name, 'stable-') || startsWith(github.ref_name, 'release_'))) runs-on: ubuntu-latest timeout-minutes: 120 permissions: @@ -29,12 +34,6 @@ jobs: make-target: awx-kube-buildx steps: - - name: Skipping build of awx image for non-awx repository - run: | - echo "Skipping build of awx image for non-awx repository" - exit 0 - if: matrix.build-targets.image-name == 'awx' && !endsWith(github.repository, '/awx') - - uses: actions/checkout@v4 with: show-progress: false @@ -49,14 +48,10 @@ jobs: run: | echo "DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER,,}" >> $GITHUB_ENV echo "COMPOSE_TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV - echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV env: OWNER: '${{ github.repository_owner }}' - - name: Install python ${{ env.py_version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ env.py_version }} + - uses: ./.github/actions/setup-python - name: Log in to registry run: | @@ -73,25 +68,9 @@ jobs: make ui if: matrix.build-targets.image-name == 'awx' - - name: Generate placeholder SSH private key if SSH auth for private repos is not needed - id: generate_key - shell: bash - run: | - if [[ -z "${{ secrets.PRIVATE_GITHUB_KEY }}" ]]; then - ssh-keygen -t ed25519 -C "github-actions" -N "" -f ~/.ssh/id_ed25519 - echo "SSH_PRIVATE_KEY<> $GITHUB_OUTPUT - cat ~/.ssh/id_ed25519 >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - else - echo "SSH_PRIVATE_KEY<> $GITHUB_OUTPUT - echo "${{ secrets.PRIVATE_GITHUB_KEY }}" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - fi - - - name: Add private GitHub key to SSH agent - uses: webfactory/ssh-agent@v0.9.0 + - uses: ./.github/actions/setup-ssh-agent with: - ssh-private-key: ${{ steps.generate_key.outputs.SSH_PRIVATE_KEY }} + ssh-private-key: ${{ secrets.PRIVATE_GITHUB_KEY }} - name: Build and push AWX devel images run: | diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e520ffdae2ac..ec6c9f4a4f2f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,7 +12,7 @@ jobs: with: show-progress: false - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-python with: python-version: '3.x' diff --git a/.github/workflows/feature_branch_deletion.yml b/.github/workflows/feature_branch_deletion.yml index 4893f8267d50..0de807aaf4f8 100644 --- a/.github/workflows/feature_branch_deletion.yml +++ b/.github/workflows/feature_branch_deletion.yml @@ -20,4 +20,4 @@ jobs: run: | ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}" ansible localhost -c local -m aws_s3 \ - -a "bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=delobj permission=public-read" + -a "bucket=awx-public-ci-files object=${{ github.event.repository.name }}/${GITHUB_REF##*/}/schema.json mode=delobj permission=public-read" diff --git a/.github/workflows/label_issue.yml b/.github/workflows/label_issue.yml index 9f666588c939..952685cd5476 100644 --- a/.github/workflows/label_issue.yml +++ b/.github/workflows/label_issue.yml @@ -34,9 +34,11 @@ jobs: with: show-progress: false - - uses: actions/setup-python@v4 + - uses: ./.github/actions/setup-python + - name: Install python requests run: pip install requests + - name: Check if user is a member of Ansible org uses: jannekem/run-python-script-action@v1 id: check_user diff --git a/.github/workflows/label_pr.yml b/.github/workflows/label_pr.yml index a5d5aa861f4b..43f1e3a2915c 100644 --- a/.github/workflows/label_pr.yml +++ b/.github/workflows/label_pr.yml @@ -33,7 +33,7 @@ jobs: with: show-progress: false - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-python with: python-version: '3.x' diff --git a/.github/workflows/pr_body_check.yml b/.github/workflows/pr_body_check.yml index 9532aa87ede1..b317162e0c80 100644 --- a/.github/workflows/pr_body_check.yml +++ b/.github/workflows/pr_body_check.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 permissions: - packages: write + packages: read contents: read steps: - name: Check for each of the lines diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index d84e4ad81e8b..ba723c07f975 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -36,13 +36,7 @@ jobs: with: show-progress: false - - name: Get python version from Makefile - run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV - - - name: Install python ${{ env.py_version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ env.py_version }} + - uses: ./.github/actions/setup-python - name: Install dependencies run: | diff --git a/.github/workflows/sonarcloud_pr.yml b/.github/workflows/sonarcloud_pr.yml new file mode 100644 index 000000000000..380118d7b5c4 --- /dev/null +++ b/.github/workflows/sonarcloud_pr.yml @@ -0,0 +1,248 @@ +# SonarCloud Analysis Workflow for awx +# +# This workflow runs SonarCloud analysis triggered by CI workflow completion. +# It is split into two separate jobs for clarity and maintainability: +# +# FLOW: CI completes → workflow_run triggers this workflow → appropriate job runs +# +# JOB 1: sonar-pr-analysis (for PRs) +# - Triggered by: workflow_run (CI on pull_request) +# - Steps: Download coverage → Get PR info → Get changed files → Run SonarCloud PR analysis +# - Scans: All changed files in the PR (Python, YAML, JSON, etc.) +# - Quality gate: Focuses on new/changed code in PR only +# +# JOB 2: sonar-branch-analysis (for long-lived branches) +# - Triggered by: workflow_run (CI on push to devel) +# - Steps: Download coverage → Run SonarCloud branch analysis +# - Scans: Full codebase +# - Quality gate: Focuses on overall project health +# +# This ensures coverage data is always available from CI before analysis runs. +# +# What files are scanned: +# - All files in the repository that SonarCloud can analyze +# - Excludes: tests, scripts, dev environments, external collections (see sonar-project.properties) + + +# With much help from: +# https://community.sonarsource.com/t/how-to-use-sonarcloud-with-a-forked-repository-on-github/7363/30 +# https://community.sonarsource.com/t/how-to-use-sonarcloud-with-a-forked-repository-on-github/7363/32 +name: SonarCloud +on: + workflow_run: # This is triggered by CI being completed. + workflows: + - CI + types: + - completed +permissions: read-all +jobs: + sonar-pr-analysis: + name: SonarCloud PR Analysis + runs-on: ubuntu-latest + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' && + github.repository == 'ansible/awx' + steps: + - uses: actions/checkout@v4 + + # Download all individual coverage artifacts from CI workflow + - name: Download coverage artifacts + uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + workflow: CI + run_id: ${{ github.event.workflow_run.id }} + pattern: api-test-artifacts + + # Extract PR metadata from workflow_run event + - name: Set PR metadata and prepare files for analysis + env: + COMMIT_SHA: ${{ github.event.workflow_run.head_sha }} + REPO_NAME: ${{ github.event.repository.full_name }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Find all downloaded coverage XML files + coverage_files=$(find . -name "coverage.xml" -type f | tr '\n' ',' | sed 's/,$//') + echo "Found coverage files: $coverage_files" + echo "COVERAGE_PATHS=$coverage_files" >> $GITHUB_ENV + + # Extract PR number from first coverage.xml file found + first_coverage=$(find . -name "coverage.xml" -type f | head -1) + if [ -f "$first_coverage" ]; then + PR_NUMBER=$(grep -m 1 ' [inner_wf] + Inner WF: [job_second] --success--> [job_reader] + + job_first sets via set_stats: + var1: "outer-only" (only source, should propagate through) + var2: "should-be-overridden" (will be overridden by job_second) + + job_second sets via set_stats: + var2: "from-inner" (should override outer's value) + var3: "inner-only" (only source, should be available) + + job_reader runs debug.yml (no set_stats), we inspect its extra_vars: + var1 should be "outer-only" - outer artifacts propagate when uncontested + var2 should be "from-inner" - inner artifacts override outer (THE BUG) + var3 should be "inner-only" - inner-only artifacts propagate normally + """ + # Clean up resources from prior runs (delete individually for signals) + for name in WFT_NAMES: + for wft in WorkflowJobTemplate.objects.filter(name=name): + wft.delete() + for name in JT_NAMES: + for jt in JobTemplate.objects.filter(name=name): + jt.delete() + + proj = project_factory(scm_url=f'file://{live_tmp_folder}/debug') + if proj.current_job: + wait_for_job(proj.current_job) + + # job_first: sets var1 (outer-only) and var2 (to be overridden by inner) + jt_first = JobTemplate.objects.create( + name='artifact-test-first', + project=proj, + playbook='set_stats.yml', + inventory=demo_inv, + extra_vars=json.dumps({'stats_data': {'var1': 'outer-only', 'var2': 'should-be-overridden'}}), + ) + # job_second: overrides var2, introduces var3 + jt_second = JobTemplate.objects.create( + name='artifact-test-second', + project=proj, + playbook='set_stats.yml', + inventory=demo_inv, + extra_vars=json.dumps({'stats_data': {'var2': 'from-inner', 'var3': 'inner-only'}}), + ) + # job_reader: just runs, we check what extra_vars it receives + jt_reader = JobTemplate.objects.create( + name='artifact-test-reader', + project=proj, + playbook='debug.yml', + inventory=demo_inv, + ) + + # Inner WFT: job_second -> job_reader + inner_wft = WorkflowJobTemplate.objects.create(name='artifact-test-inner-wf', organization=default_org) + inner_node_1 = WorkflowJobTemplateNode.objects.create( + workflow_job_template=inner_wft, + unified_job_template=jt_second, + identifier='second', + ) + inner_node_2 = WorkflowJobTemplateNode.objects.create( + workflow_job_template=inner_wft, + unified_job_template=jt_reader, + identifier='reader', + ) + inner_node_1.success_nodes.add(inner_node_2) + + # Outer WFT: job_first -> inner_wf + outer_wft = WorkflowJobTemplate.objects.create(name='artifact-test-outer-wf', organization=default_org) + outer_node_1 = WorkflowJobTemplateNode.objects.create( + workflow_job_template=outer_wft, + unified_job_template=jt_first, + identifier='first', + ) + outer_node_2 = WorkflowJobTemplateNode.objects.create( + workflow_job_template=outer_wft, + unified_job_template=inner_wft, + identifier='inner', + ) + outer_node_1.success_nodes.add(outer_node_2) + + # Launch and wait + outer_wfj = outer_wft.create_unified_job() + outer_wfj.signal_start() + wait_for_job(outer_wfj, running_timeout=120) + + # Find the reader job inside the inner workflow + inner_wf_node = outer_wfj.workflow_job_nodes.get(identifier='inner') + inner_wfj = inner_wf_node.job + assert inner_wfj is not None, 'Inner workflow job was never created' + + # Check that root node of inner WF (job_second) received outer artifacts + second_node = inner_wfj.workflow_job_nodes.get(identifier='second') + assert second_node.job is not None, 'Second job was never created' + second_extra_vars = json.loads(second_node.job.extra_vars) + assert second_extra_vars.get('var1') == 'outer-only', ( + f'Root node var1: expected "outer-only" (outer artifact should be available to root node), ' + f'got "{second_extra_vars.get("var1")}". ' + f'Outer artifacts are not reaching root nodes of child workflows.' + ) + + reader_node = inner_wfj.workflow_job_nodes.get(identifier='reader') + assert reader_node.job is not None, 'Reader job was never created' + + reader_extra_vars = json.loads(reader_node.job.extra_vars) + + # var1: only set by outer job_first, no conflict — should propagate through + assert reader_extra_vars.get('var1') == 'outer-only', f'var1: expected "outer-only" (uncontested outer artifact), ' f'got "{reader_extra_vars.get("var1")}"' + + # var2: set by outer as "should-be-overridden", then by inner as "from-inner" + # Inner workflow's own ancestor artifacts should take precedence + assert reader_extra_vars.get('var2') == 'from-inner', ( + f'var2: expected "from-inner" (inner workflow artifact should override outer), ' + f'got "{reader_extra_vars.get("var2")}". ' + f'Outer workflow artifacts are leaking via wj_special_vars. ' + f'reader node ancestor_artifacts={reader_node.ancestor_artifacts}' + ) + + # var3: only set by inner job_second — should propagate normally + assert reader_extra_vars.get('var3') == 'inner-only', f'var3: expected "inner-only" (inner-only artifact), ' f'got "{reader_extra_vars.get("var3")}"' + + +@pytest.mark.django_db(transaction=True) +def test_workflow_extra_vars_override_artifacts(live_tmp_folder, demo_inv, project_factory, default_org): + """Workflow extra_vars should take precedence over set_stats artifacts + within a single (non-nested) workflow. + + WF (extra_vars: my_var="from-wf-extra-vars"): + [job_setter] --success--> [job_reader] + + job_setter sets my_var="from-set-stats" via set_stats + job_reader should see my_var="from-wf-extra-vars" because workflow + extra_vars are higher precedence than ancestor artifacts. + """ + wft_name = 'artifact-test-wf-extra-vars-precedence' + jt_names = ('artifact-test-setter', 'artifact-test-checker') + + for wft in WorkflowJobTemplate.objects.filter(name=wft_name): + wft.delete() + for name in jt_names: + for jt in JobTemplate.objects.filter(name=name): + jt.delete() + + proj = project_factory(scm_url=f'file://{live_tmp_folder}/debug') + if proj.current_job: + wait_for_job(proj.current_job) + + jt_setter = JobTemplate.objects.create( + name='artifact-test-setter', + project=proj, + playbook='set_stats.yml', + inventory=demo_inv, + extra_vars=json.dumps({'stats_data': {'my_var': 'from-set-stats'}}), + ) + jt_checker = JobTemplate.objects.create( + name='artifact-test-checker', + project=proj, + playbook='debug.yml', + inventory=demo_inv, + ) + + wft = WorkflowJobTemplate.objects.create( + name=wft_name, + organization=default_org, + extra_vars=json.dumps({'my_var': 'from-wf-extra-vars'}), + ) + node_1 = WorkflowJobTemplateNode.objects.create( + workflow_job_template=wft, + unified_job_template=jt_setter, + identifier='setter', + ) + node_2 = WorkflowJobTemplateNode.objects.create( + workflow_job_template=wft, + unified_job_template=jt_checker, + identifier='checker', + ) + node_1.success_nodes.add(node_2) + + wfj = wft.create_unified_job() + wfj.signal_start() + wait_for_job(wfj, running_timeout=120) + + checker_node = wfj.workflow_job_nodes.get(identifier='checker') + assert checker_node.job is not None, 'Checker job was never created' + + checker_extra_vars = json.loads(checker_node.job.extra_vars) + assert checker_extra_vars.get('my_var') == 'from-wf-extra-vars', ( + f'Expected my_var="from-wf-extra-vars" (workflow extra_vars should override artifacts), ' + f'got my_var="{checker_extra_vars.get("my_var")}". ' + f'checker node ancestor_artifacts={checker_node.ancestor_artifacts}' + ) diff --git a/awx/main/tests/live/tests/test_smart_inventory.py b/awx/main/tests/live/tests/test_smart_inventory.py new file mode 100644 index 000000000000..d5fc364bbc1a --- /dev/null +++ b/awx/main/tests/live/tests/test_smart_inventory.py @@ -0,0 +1,320 @@ +"""Smart inventory tests that require PostgreSQL. + +These tests exercise SmartFilter and smart inventory host resolution against +a real PostgreSQL database. Most are unit-style tests that set ansible_facts +directly on Host objects rather than running playbooks. + +The smart inventory HostManager uses DISTINCT ON which requires PostgreSQL, +so any test that reads smart inventory hosts must run here (not in functional/). +""" + +import pytest + +from awx.main.models import Organization, Inventory, Host, Group +from awx.main.utils.filters import SmartFilter + + +@pytest.fixture +def fact_org(): + org, _ = Organization.objects.get_or_create(name='smart-inv-fact-test-org') + return org + + +@pytest.fixture +def fact_inventory(fact_org): + inv, created = Inventory.objects.get_or_create(name='smart-inv-fact-test-inv', organization=fact_org) + if not created: + inv.hosts.all().delete() + inv.groups.all().delete() + + groupA = Group.objects.create(name='factGroupA', inventory=inv) + groupB = Group.objects.create(name='factGroupB', inventory=inv) + + hostA = Host.objects.create( + name='factHostA', + inventory=inv, + ansible_facts={ + 'ansible_system': 'Linux', + 'ansible_distribution': 'CentOS', + 'ansible_python': { + 'version': {'major': 3, 'minor': 9, 'micro': 7}, + 'version_info': [3, 9, 7, 'final', 0], + }, + 'ansible_env': {'HOME': '/root'}, + }, + ) + hostB = Host.objects.create( + name='factHostB', + inventory=inv, + ansible_facts={ + 'ansible_system': 'Linux', + 'ansible_distribution': 'Ubuntu', + 'ansible_python': { + 'version': {'major': 3, 'minor': 11, 'micro': 2}, + 'version_info': [3, 11, 2, 'final', 0], + }, + 'ansible_env': {'HOME': '/home/user'}, + }, + ) + hostC = Host.objects.create( + name='factHostC', + inventory=inv, + ansible_facts={ + 'ansible_system': 'Darwin', + 'ansible_distribution': 'MacOSX', + 'ansible_python': { + 'version': {'major': 3, 'minor': 10, 'micro': 0}, + 'version_info': [3, 10, 0, 'final', 0], + }, + 'ansible_env': {'HOME': '/Users/test'}, + }, + ) + + groupA.hosts.add(hostA, hostC) + groupB.hosts.add(hostB, hostC) + + yield { + 'org': fact_org, + 'inv': inv, + 'hosts': {'hostA': hostA, 'hostB': hostB, 'hostC': hostC}, + 'groups': {'groupA': groupA, 'groupB': groupB}, + } + + hostA.delete() + hostB.delete() + hostC.delete() + groupA.delete() + groupB.delete() + + +@pytest.fixture +def smart_inventory_factory(): + created = [] + + def _factory(name, host_filter, organization): + inv = Inventory.objects.create(name=name, kind='smart', host_filter=host_filter, organization=organization) + created.append(inv) + return inv + + yield _factory + for inv in reversed(created): + inv.delete() + + +@pytest.fixture +def host_factory(): + created = [] + + def _factory(**kwargs): + host = Host.objects.create(**kwargs) + created.append(host) + return host + + yield _factory + for host in reversed(created): + if host.pk is not None: + host.delete() + + +@pytest.fixture +def group_factory(): + created = [] + + def _factory(**kwargs): + group = Group.objects.create(**kwargs) + created.append(group) + return group + + yield _factory + for group in reversed(created): + group.delete() + + +def query_names(filter_string): + return sorted(SmartFilter.query_from_string(filter_string).distinct().values_list('name', flat=True)) + + +# --- Fact-based filter tests (require PostgreSQL for JSONField __contains) --- + + +def test_fact_based_host_filter(fact_inventory): + assert query_names('ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB'] + assert query_names('ansible_facts__ansible_distribution=CentOS') == ['factHostA'] + assert query_names('ansible_facts__ansible_distribution=Ubuntu') == ['factHostB'] + assert query_names('ansible_facts__ansible_system=Darwin') == ['factHostC'] + assert query_names('ansible_facts__ansible_system=Windows') == [] + + +def test_nested_fact_search(fact_inventory): + assert query_names('ansible_facts__ansible_python__version__major=3') == ['factHostA', 'factHostB', 'factHostC'] + assert query_names('ansible_facts__ansible_python__version__minor=9') == ['factHostA'] + assert query_names('ansible_facts__ansible_python__version__minor=11') == ['factHostB'] + assert query_names('ansible_facts__ansible_env__HOME=/root') == ['factHostA'] + + +def test_list_fact_search(fact_inventory): + assert query_names('ansible_facts__ansible_python__version_info[]=9') == ['factHostA'] + assert query_names('ansible_facts__ansible_python__version_info[]=11') == ['factHostB'] + assert query_names('ansible_facts__ansible_python__version_info[]=3') == ['factHostA', 'factHostB', 'factHostC'] + + +def test_fact_search_with_or(fact_inventory): + assert query_names('ansible_facts__ansible_system=Linux or ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB'] + assert query_names('ansible_facts__ansible_system=Linux or ansible_facts__ansible_system=not_found') == ['factHostA', 'factHostB'] + assert query_names('ansible_facts__ansible_system=not_found or ansible_facts__ansible_system=not_found') == [] + assert query_names('ansible_facts__ansible_system=Linux or ansible_facts__ansible_system=Darwin') == ['factHostA', 'factHostB', 'factHostC'] + + +def test_fact_search_with_and(fact_inventory): + assert query_names('ansible_facts__ansible_system=Linux and ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB'] + assert query_names('ansible_facts__ansible_system=Linux and ansible_facts__ansible_system=not_found') == [] + assert query_names('ansible_facts__ansible_system=Linux and ansible_facts__ansible_distribution=CentOS') == ['factHostA'] + + +def test_hybrid_fact_name_group_search(fact_inventory): + assert query_names('name=factHostA or groups__name=factGroupB or ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB', 'factHostC'] + + assert query_names('name=factHostA or groups__name=factGroupA or ansible_facts__ansible_system=not_found') == ['factHostA', 'factHostC'] + + assert query_names('name=factHostA and groups__name=factGroupA and ansible_facts__ansible_system=not_found') == [] + + assert query_names('name=factHostA and groups__name=factGroupA and ansible_facts__ansible_system=Linux') == ['factHostA'] + + +def test_advanced_hybrid_with_parentheses(fact_inventory): + assert query_names('name=factHostA or (groups__name=factGroupB and ansible_facts__ansible_system=not_found)') == ['factHostA'] + + assert query_names('name=not_found or (groups__name=factGroupB and ansible_facts__ansible_system=Linux)') == ['factHostB'] + + assert query_names('(name=factHostA or groups__name=factGroupB) and ansible_facts__ansible_system=not_found') == [] + + assert query_names('(name=factHostA or groups__name=factGroupB) and ansible_facts__ansible_system=Linux') == ['factHostA', 'factHostB'] + + assert query_names('(name=factHostC or groups__name=factGroupA) and ansible_facts__ansible_system=Darwin') == ['factHostC'] + + +# --- Smart inventory host resolution tests (require PostgreSQL for DISTINCT ON) --- + + +def test_smart_inventory_hosts_by_name(fact_inventory, smart_inventory_factory): + org = fact_inventory['org'] + smart_inv = smart_inventory_factory('smart-by-name', 'name=factHostA', org) + hosts = sorted(smart_inv.hosts.values_list('name', flat=True)) + assert hosts == ['factHostA'] + + +def test_smart_inventory_hosts_by_group(fact_inventory, smart_inventory_factory): + org = fact_inventory['org'] + smart_inv = smart_inventory_factory('smart-by-group', 'groups__name=factGroupA', org) + hosts = sorted(smart_inv.hosts.values_list('name', flat=True)) + assert hosts == ['factHostA', 'factHostC'] + + +def test_smart_inventory_with_facts(fact_inventory, smart_inventory_factory): + org = fact_inventory['org'] + smart_inv = smart_inventory_factory('fact-smart-inv', 'ansible_facts__ansible_system=Linux', org) + hosts = sorted(smart_inv.hosts.values_list('name', flat=True)) + assert hosts == ['factHostA', 'factHostB'] + assert smart_inv.total_hosts == 2 + + +def test_smart_inventory_with_nested_facts(fact_inventory, smart_inventory_factory): + org = fact_inventory['org'] + smart_inv = smart_inventory_factory( + 'nested-fact-smart-inv', + 'ansible_facts__ansible_distribution=CentOS and ansible_facts__ansible_python__version__minor=9', + org, + ) + hosts = list(smart_inv.hosts.values_list('name', flat=True)) + assert hosts == ['factHostA'] + + +def test_host_filter_is_organization_scoped(fact_inventory, smart_inventory_factory, host_factory): + """Smart inventory only includes hosts from its own organization.""" + org1 = fact_inventory['org'] + org2, _ = Organization.objects.get_or_create(name='smart-inv-other-org') + inv2, _ = Inventory.objects.get_or_create(name='other-org-inv', organization=org2) + Host.objects.filter(name='factHostA', inventory=inv2).delete() + host_factory(name='factHostA', inventory=inv2) + + smart_inv = smart_inventory_factory('scoped-smart', 'name=factHostA', org1) + hosts = list(smart_inv.hosts.all()) + assert len(hosts) == 1 + assert hosts[0].inventory_id == fact_inventory['inv'].id + + +def test_duplicate_hosts_deduplicated(smart_inventory_factory, host_factory): + """Same-name hosts across inventories in the same org yield only one smart inventory entry.""" + org, _ = Organization.objects.get_or_create(name='smart-inv-dedup-org') + inv1, _ = Inventory.objects.get_or_create(name='dedup-inv1', organization=org) + inv2, _ = Inventory.objects.get_or_create(name='dedup-inv2', organization=org) + Host.objects.filter(name='dedup_host', inventory__in=[inv1, inv2]).delete() + host1 = host_factory(name='dedup_host', inventory=inv1) + host2 = host_factory(name='dedup_host', inventory=inv2) + + smart_inv = smart_inventory_factory('dedup-smart', 'name=dedup_host', org) + hosts = list(smart_inv.hosts.all()) + assert len(hosts) == 1 + assert hosts[0].id == min(host1.id, host2.id) + + +def test_host_sources_original_inventory(fact_inventory, smart_inventory_factory): + """Hosts in a smart inventory still reference their source inventory.""" + org = fact_inventory['org'] + source_inv = fact_inventory['inv'] + + smart_inv = smart_inventory_factory('sources-original', 'name=factHostA', org) + host = smart_inv.hosts.first() + assert host.inventory_id == source_inv.id + + +def test_host_updates_reflected_in_smart_inventory(fact_inventory, smart_inventory_factory, host_factory): + """Editing or deleting a host is immediately reflected in a smart inventory.""" + org = fact_inventory['org'] + inv = fact_inventory['inv'] + host = host_factory(name='mutable_host', inventory=inv) + + smart_inv = smart_inventory_factory('updates-reflected', 'name=mutable_host', org) + assert smart_inv.hosts.count() == 1 + + host.description = 'updated' + host.save() + assert smart_inv.hosts.first().description == 'updated' + + host.delete() + assert smart_inv.hosts.count() == 0 + + +def test_smart_inventory_duplicate_hosts_matching_group_names(fact_inventory, smart_inventory_factory, host_factory, group_factory): + """A host in multiple groups whose names match an icontains filter appears only once.""" + org = fact_inventory['org'] + inv = fact_inventory['inv'] + g1 = group_factory(name='dedup_another_group', inventory=inv) + g2 = group_factory(name='dedup_yet_another_group', inventory=inv) + host = host_factory(name='dedup_grouped_host', inventory=inv) + g1.hosts.add(host) + g2.hosts.add(host) + + smart_inv = smart_inventory_factory('group-dedup-smart', 'groups__name__icontains=dedup_another', org) + assert smart_inv.hosts.count() == 1 + + +def test_smart_inventory_computed_fields(fact_inventory, smart_inventory_factory): + """Smart inventory total_hosts and related computed fields are accurate.""" + org = fact_inventory['org'] + smart_inv = smart_inventory_factory('computed-fields', 'name=factHostA or name=factHostB', org) + assert smart_inv.total_hosts == 2 + assert smart_inv.total_groups == 0 + assert smart_inv.total_inventory_sources == 0 + assert smart_inv.has_inventory_sources is False + + +def test_smart_inventory_matches_host_filter(fact_inventory, smart_inventory_factory): + """Smart inventory hosts should match the equivalent SmartFilter query.""" + org = fact_inventory['org'] + host_filter = 'groups__name=factGroupA or groups__name=factGroupB' + + smart_inv = smart_inventory_factory('match-filter', host_filter, org) + smart_names = sorted(smart_inv.hosts.values_list('name', flat=True)) + filter_names = sorted(SmartFilter.query_from_string(host_filter).distinct().values_list('name', flat=True)) + assert smart_names == filter_names diff --git a/awx/main/tests/settings_for_test.py b/awx/main/tests/settings_for_test.py index b7d5cdf0235f..5634494c3373 100644 --- a/awx/main/tests/settings_for_test.py +++ b/awx/main/tests/settings_for_test.py @@ -7,9 +7,6 @@ # Some things make decisions based on settings.SETTINGS_MODULE, so this is done for that SETTINGS_MODULE = 'awx.settings.development' -# Turn off task submission, because sqlite3 does not have pg_notify -DISPATCHER_MOCK_PUBLISH = True - # Use SQLite for unit tests instead of PostgreSQL. If the lines below are # commented out, Django will create the test_awx-dev database in PostgreSQL to # run unit tests. diff --git a/awx/main/tests/unit/analytics/test_broadcast_websocket.py b/awx/main/tests/unit/analytics/test_broadcast_websocket.py index cd7f4323b5cc..9aa24e772b2a 100644 --- a/awx/main/tests/unit/analytics/test_broadcast_websocket.py +++ b/awx/main/tests/unit/analytics/test_broadcast_websocket.py @@ -1,6 +1,7 @@ import datetime +from unittest.mock import Mock, patch -from awx.main.analytics.broadcast_websocket import FixedSlidingWindow +from awx.main.analytics.broadcast_websocket import FixedSlidingWindow, RelayWebsocketStatsManager from awx.main.analytics.broadcast_websocket import dt_to_seconds @@ -59,3 +60,70 @@ def test_record_same_minute_render_diff_minute(self): assert 20 - i == fsw.render(self.ts(minute=1, second=i, microsecond=0)), "E. Sliding window where 1 record() should drop from the results each time" assert 0 == fsw.render(self.ts(minute=1, second=20, microsecond=0)), "F. First second one minute after all record() calls" + + +class TestRelayWebsocketStatsManager: + """Test Redis client caching in RelayWebsocketStatsManager.""" + + def test_get_stats_sync_caches_redis_client(self): + """Verify get_stats_sync caches Redis client to avoid creating new connection pools.""" + # Reset class variable + RelayWebsocketStatsManager._redis_client = None + + mock_redis = Mock() + mock_redis.get.return_value = b'' + + with patch('awx.main.analytics.broadcast_websocket.get_redis_client', return_value=mock_redis) as mock_get_client: + # First call should create client + RelayWebsocketStatsManager.get_stats_sync() + assert mock_get_client.call_count == 1 + + # Second call should reuse cached client + RelayWebsocketStatsManager.get_stats_sync() + assert mock_get_client.call_count == 1 # Still 1, not called again + + # Third call should still reuse cached client + RelayWebsocketStatsManager.get_stats_sync() + assert mock_get_client.call_count == 1 + + # Cleanup + RelayWebsocketStatsManager._redis_client = None + + def test_get_stats_sync_returns_parsed_metrics(self): + """Verify get_stats_sync returns parsed metric families from Redis.""" + # Reset class variable + RelayWebsocketStatsManager._redis_client = None + + # Sample Prometheus metrics format + sample_metrics = b'# HELP test_metric A test metric\n# TYPE test_metric gauge\ntest_metric 42\n' + + mock_redis = Mock() + mock_redis.get.return_value = sample_metrics + + with patch('awx.main.analytics.broadcast_websocket.get_redis_client', return_value=mock_redis): + result = list(RelayWebsocketStatsManager.get_stats_sync()) + + # Should return parsed metric families + assert len(result) > 0 + assert mock_redis.get.called + + # Cleanup + RelayWebsocketStatsManager._redis_client = None + + def test_get_stats_sync_handles_empty_redis_data(self): + """Verify get_stats_sync handles empty data from Redis gracefully.""" + # Reset class variable + RelayWebsocketStatsManager._redis_client = None + + mock_redis = Mock() + mock_redis.get.return_value = None # Redis returns None when key doesn't exist + + with patch('awx.main.analytics.broadcast_websocket.get_redis_client', return_value=mock_redis): + result = list(RelayWebsocketStatsManager.get_stats_sync()) + + # Should handle empty data gracefully + assert result == [] + assert mock_redis.get.called + + # Cleanup + RelayWebsocketStatsManager._redis_client = None diff --git a/awx/main/tests/unit/analytics/test_core_ship.py b/awx/main/tests/unit/analytics/test_core_ship.py new file mode 100644 index 000000000000..a544860b3508 --- /dev/null +++ b/awx/main/tests/unit/analytics/test_core_ship.py @@ -0,0 +1,271 @@ +# Copyright (c) 2026 Ansible, Inc. +# All Rights Reserved. + +"""Tests for analytics ship() function with mTLS authentication.""" + +import os +import tempfile +from unittest import mock + +from django.test.utils import override_settings + +from awx.main.analytics.core import ship, _get_cert_upload_url + + +class TestGetCertUploadUrl: + """Test _get_cert_upload_url() helper function.""" + + def test_adds_cert_subdomain(self): + """Test that 'cert.' is added to hostname.""" + url = 'https://analytics.example.com/api/ingress/v1/upload' + result = _get_cert_upload_url(url) + assert result == 'https://cert.analytics.example.com/api/ingress/v1/upload' + + def test_preserves_existing_cert_subdomain(self): + """Test that existing 'cert.' subdomain is preserved.""" + url = 'https://cert.analytics.example.com/api/ingress/v1/upload' + result = _get_cert_upload_url(url) + assert result == 'https://cert.analytics.example.com/api/ingress/v1/upload' + + +class TestShipMTLS: + """Test ship() function's mTLS authentication path.""" + + def setup_method(self): + """Create a temporary tarball for testing.""" + self.temp_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.tar.gz', delete=False) + self.temp_file.write(b'test tarball content') + self.temp_file.close() + self.tarball_path = self.temp_file.name + + def teardown_method(self): + """Clean up temporary tarball.""" + if os.path.exists(self.tarball_path): + os.unlink(self.tarball_path) + + @override_settings( + AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload', + INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz', + INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt', + REDHAT_USERNAME='test_user', + REDHAT_PASSWORD='test_pass', # NOSONAR + AWX_TASK_ENV={}, + ) + @mock.patch('awx.main.analytics.core.get_awx_http_client_headers') + @mock.patch('awx.main.analytics.core._temp_cert_files') + @mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate') + @mock.patch('awx.main.analytics.core.requests.Session') + def test_ship_with_mtls_success(self, mock_session_class, mock_get_cert, mock_temp_files, mock_headers): + """Test successful upload with mTLS certificate authentication.""" + # Mock headers to avoid database access + mock_headers.return_value = {'Content-Type': 'application/json'} + + # Mock certificate retrieval + mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data') + + # Mock temp files context manager + mock_temp_files.return_value.__enter__.return_value = ('/tmp/cert.pem', '/tmp/key.pem') + mock_temp_files.return_value.__exit__.return_value = None + + # Mock successful mTLS response + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_session = mock.Mock() + mock_session.headers = {} + mock_session.post.return_value = mock_response + mock_session_class.return_value = mock_session + + result = ship(self.tarball_path) + + assert result is True + mock_get_cert.assert_called_once() + mock_temp_files.assert_called_once_with('cert-pem-data', 'key-pem-data') + mock_session.post.assert_called_once() + + # Verify cert URL is used (cert. subdomain added) + call_args = mock_session.post.call_args + assert call_args[0][0] == 'https://cert.analytics.example.com/api/ingress/v1/upload' + + # Verify mTLS cert was used + call_kwargs = call_args[1] + assert call_kwargs['cert'] == ('/tmp/cert.pem', '/tmp/key.pem') + + @override_settings( + AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload', + INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz', + INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt', + REDHAT_USERNAME='test_user', + REDHAT_PASSWORD='test_pass', # NOSONAR + AWX_TASK_ENV={}, + ) + @mock.patch('awx.main.analytics.core.get_awx_http_client_headers') + @mock.patch('awx.main.analytics.core.OIDCClient') + @mock.patch('awx.main.analytics.core._temp_cert_files') + @mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate') + @mock.patch('awx.main.analytics.core.requests.Session') + def test_ship_mtls_fallback_to_oidc_on_cert_failure(self, mock_session_class, mock_get_cert, mock_temp_files, mock_oidc_client, mock_headers): + """Test fallback to OIDC auth when mTLS cert authentication fails.""" + # Mock headers to avoid database access + mock_headers.return_value = {'Content-Type': 'application/json'} + + # Mock certificate retrieval + mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data') + + # Mock temp files context manager + mock_temp_files.return_value.__enter__.return_value = ('/tmp/cert.pem', '/tmp/key.pem') + mock_temp_files.return_value.__exit__.return_value = None + + # Mock failed mTLS response (401 Unauthorized) + mock_mtls_response = mock.Mock() + mock_mtls_response.status_code = 401 + mock_session = mock.Mock() + mock_session.headers = {} + mock_session.post.return_value = mock_mtls_response + mock_session_class.return_value = mock_session + + # Mock successful OIDC response + mock_oidc_response = mock.Mock() + mock_oidc_response.status_code = 200 + mock_oidc_instance = mock.Mock() + mock_oidc_instance.make_request.return_value = mock_oidc_response + mock_oidc_client.return_value = mock_oidc_instance + + result = ship(self.tarball_path) + + assert result is True + # Both mTLS and OIDC should be attempted + assert mock_session.post.call_count == 1 + mock_oidc_instance.make_request.assert_called_once() + + # Verify mTLS used cert URL + mtls_call_args = mock_session.post.call_args + assert mtls_call_args[0][0] == 'https://cert.analytics.example.com/api/ingress/v1/upload' + + # Verify OIDC used original URL + oidc_call_args = mock_oidc_instance.make_request.call_args + assert oidc_call_args[0][1] == 'https://analytics.example.com/api/ingress/v1/upload' + + @override_settings( + AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload', + INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz', + INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt', + REDHAT_USERNAME='test_user', + REDHAT_PASSWORD='test_pass', # NOSONAR + AWX_TASK_ENV={}, + ) + @mock.patch('awx.main.analytics.core.get_awx_http_client_headers') + @mock.patch('awx.main.analytics.core._temp_cert_files') + @mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate') + @mock.patch('awx.main.analytics.core.OIDCClient') + @mock.patch('awx.main.analytics.core.requests.Session') + def test_ship_mtls_exception_fallback_to_oidc(self, mock_session_class, mock_oidc_client, mock_get_cert, mock_temp_files, mock_headers): + """Test fallback to OIDC auth when mTLS raises an exception.""" + # Mock headers to avoid database access + mock_headers.return_value = {'Content-Type': 'application/json'} + + # Mock certificate retrieval + mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data') + + # Mock temp files context manager raising an exception + mock_temp_files.return_value.__enter__.side_effect = OSError('Temp file creation failed') + + # Mock successful OIDC response + mock_oidc_response = mock.Mock() + mock_oidc_response.status_code = 200 + mock_oidc_instance = mock.Mock() + mock_oidc_instance.make_request.return_value = mock_oidc_response + mock_oidc_client.return_value = mock_oidc_instance + + mock_session = mock.Mock() + mock_session.headers = {} + mock_session_class.return_value = mock_session + + result = ship(self.tarball_path) + + assert result is True + # mTLS should fail, OIDC should succeed + mock_oidc_instance.make_request.assert_called_once() + + @override_settings( + AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload', + INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz', + INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt', + REDHAT_USERNAME='test_user', + REDHAT_PASSWORD='test_pass', # NOSONAR + AWX_TASK_ENV={}, + ) + @mock.patch('awx.main.analytics.core.get_awx_http_client_headers') + @mock.patch('awx.main.analytics.core.OIDCClient') + @mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate') + @mock.patch('awx.main.analytics.core.requests.Session') + def test_ship_no_certificate_available(self, mock_session_class, mock_get_cert, mock_oidc_client, mock_headers): + """Test ship() when no Candlepin certificate is available.""" + # Mock headers to avoid database access + mock_headers.return_value = {'Content-Type': 'application/json'} + + # Mock no certificate available + mock_get_cert.return_value = (None, None) + + # Mock successful OIDC response + mock_oidc_response = mock.Mock() + mock_oidc_response.status_code = 200 + mock_oidc_instance = mock.Mock() + mock_oidc_instance.make_request.return_value = mock_oidc_response + mock_oidc_client.return_value = mock_oidc_instance + + mock_session = mock.Mock() + mock_session.headers = {} + mock_session_class.return_value = mock_session + + result = ship(self.tarball_path) + + assert result is True + # Should skip mTLS and go straight to OIDC + mock_oidc_instance.make_request.assert_called_once() + + @override_settings( + AUTOMATION_ANALYTICS_URL='https://analytics.example.com/api/ingress/v1/upload', + INSIGHTS_AGENT_MIME='application/vnd.redhat.tower.analytics+tgz', + INSIGHTS_CERT_PATH='/etc/pki/tls/certs/ca-bundle.crt', + REDHAT_USERNAME='test_user', + REDHAT_PASSWORD='test_pass', # NOSONAR + AWX_TASK_ENV={}, + ) + @mock.patch('awx.main.analytics.core.get_awx_http_client_headers') + @mock.patch('awx.main.analytics.core.OIDCClient') + @mock.patch('awx.main.analytics.core._temp_cert_files') + @mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate') + @mock.patch('awx.main.analytics.core.requests.Session') + def test_ship_both_auth_methods_fail(self, mock_session_class, mock_get_cert, mock_temp_files, mock_oidc_client, mock_headers): + """Test ship() when both mTLS and OIDC authentication fail.""" + # Mock headers to avoid database access + mock_headers.return_value = {'Content-Type': 'application/json'} + + # Mock certificate retrieval + mock_get_cert.return_value = ('cert-pem-data', 'key-pem-data') + + # Mock temp files context manager + mock_temp_files.return_value.__enter__.return_value = ('/tmp/cert.pem', '/tmp/key.pem') + mock_temp_files.return_value.__exit__.return_value = None + + # Mock failed mTLS response + mock_mtls_response = mock.Mock() + mock_mtls_response.status_code = 401 + mock_session = mock.Mock() + mock_session.headers = {} + mock_session.post.return_value = mock_mtls_response + mock_session_class.return_value = mock_session + + # Mock failed OIDC response + mock_oidc_response = mock.Mock() + mock_oidc_response.status_code = 403 + mock_oidc_response.text = 'Forbidden' + mock_oidc_instance = mock.Mock() + mock_oidc_instance.make_request.return_value = mock_oidc_response + mock_oidc_client.return_value = mock_oidc_instance + + result = ship(self.tarball_path) + + assert result is False + mock_session.post.assert_called_once() + mock_oidc_instance.make_request.assert_called_once() diff --git a/awx/main/tests/unit/api/serializers/test_unified_serializers.py b/awx/main/tests/unit/api/serializers/test_unified_serializers.py index 36558f92cb4f..47451d849a03 100644 --- a/awx/main/tests/unit/api/serializers/test_unified_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_unified_serializers.py @@ -39,7 +39,7 @@ def test_unified_job_detail_exclusive_fields(): For each type, assert that the only fields allowed to be exclusive to detail view are the allowed types """ - allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished')) + allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts')) for cls in UnifiedJob.__subclasses__(): list_serializer = getattr(serializers, '{}ListSerializer'.format(cls.__name__)) detail_serializer = getattr(serializers, '{}Serializer'.format(cls.__name__)) diff --git a/awx/main/tests/unit/api/test_fields.py b/awx/main/tests/unit/api/test_fields.py new file mode 100644 index 000000000000..d6b6ae49d266 --- /dev/null +++ b/awx/main/tests/unit/api/test_fields.py @@ -0,0 +1,49 @@ +import pytest +from collections import OrderedDict +from unittest import mock + +from rest_framework.exceptions import ValidationError + +from awx.api.fields import DeprecatedCredentialField + + +class TestDeprecatedCredentialField: + """Test that DeprecatedCredentialField handles unexpected input types gracefully.""" + + def test_dict_value_raises_validation_error(self): + """Passing a dict instead of an integer should return a 400 validation error, not a 500 TypeError.""" + field = DeprecatedCredentialField() + with pytest.raises(ValidationError): + field.to_internal_value({"username": "admin", "password": "secret"}) + + def test_ordered_dict_value_raises_validation_error(self): + """Passing an OrderedDict should return a 400 validation error, not a 500 TypeError.""" + field = DeprecatedCredentialField() + with pytest.raises(ValidationError): + field.to_internal_value(OrderedDict([("username", "admin")])) + + def test_list_value_raises_validation_error(self): + """Passing a list should return a 400 validation error, not a 500 TypeError.""" + field = DeprecatedCredentialField() + with pytest.raises(ValidationError): + field.to_internal_value([1, 2, 3]) + + def test_string_value_raises_validation_error(self): + """Passing a non-numeric string should return a 400 validation error.""" + field = DeprecatedCredentialField() + with pytest.raises(ValidationError): + field.to_internal_value("not_a_number") + + @mock.patch('awx.api.fields.Credential.objects') + def test_valid_integer_value_works(self, mock_cred_objects): + """Passing a valid integer PK should work when the credential exists.""" + mock_cred_objects.get.return_value = mock.MagicMock() + field = DeprecatedCredentialField() + assert field.to_internal_value(42) == 42 + + @mock.patch('awx.api.fields.Credential.objects') + def test_valid_string_integer_value_works(self, mock_cred_objects): + """Passing a numeric string PK should work when the credential exists.""" + mock_cred_objects.get.return_value = mock.MagicMock() + field = DeprecatedCredentialField() + assert field.to_internal_value("42") == 42 diff --git a/awx/main/tests/unit/api/test_generics.py b/awx/main/tests/unit/api/test_generics.py index ea8cb388786c..05cc72cc1935 100644 --- a/awx/main/tests/unit/api/test_generics.py +++ b/awx/main/tests/unit/api/test_generics.py @@ -50,7 +50,7 @@ def test_attach_validate_ok(self, mocker): mock_request = mocker.MagicMock(data=dict(id=1)) serializer = SubListCreateAttachDetachAPIView() - (sub_id, res) = serializer.attach_validate(mock_request) + sub_id, res = serializer.attach_validate(mock_request) assert sub_id == 1 assert res is None @@ -59,12 +59,12 @@ def test_attach_validate_invalid_type(self, mocker): mock_request = mocker.MagicMock(data=dict(id='foobar')) serializer = SubListCreateAttachDetachAPIView() - (sub_id, res) = serializer.attach_validate(mock_request) + sub_id, res = serializer.attach_validate(mock_request) assert type(res) is Response def test_attach_create_and_associate(self, mocker, get_object_or_400, parent_relationship_factory): - (serializer, mock_parent_relationship) = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') + serializer, mock_parent_relationship = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') create_return_value = mocker.MagicMock(status_code=status.HTTP_201_CREATED) serializer.create = mocker.Mock(return_value=create_return_value) @@ -75,7 +75,7 @@ def test_attach_create_and_associate(self, mocker, get_object_or_400, parent_rel mock_parent_relationship.wife.add.assert_called_with(get_object_or_400.return_value) def test_attach_associate_only(self, mocker, get_object_or_400, parent_relationship_factory): - (serializer, mock_parent_relationship) = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') + serializer, mock_parent_relationship = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') serializer.create = mocker.Mock(return_value=mocker.MagicMock()) mock_request = mocker.MagicMock(data=dict(id=1)) @@ -88,7 +88,7 @@ def test_unattach_validate_ok(self, mocker): mock_request = mocker.MagicMock(data=dict(id=1)) serializer = SubListCreateAttachDetachAPIView() - (sub_id, res) = serializer.unattach_validate(mock_request) + sub_id, res = serializer.unattach_validate(mock_request) assert sub_id == 1 assert res is None @@ -97,7 +97,7 @@ def test_unattach_validate_invalid_type(self, mocker): mock_request = mocker.MagicMock(data=dict(id='foobar')) serializer = SubListCreateAttachDetachAPIView() - (sub_id, res) = serializer.unattach_validate(mock_request) + sub_id, res = serializer.unattach_validate(mock_request) assert type(res) is Response @@ -105,13 +105,13 @@ def test_unattach_validate_missing_id(self, mocker): mock_request = mocker.MagicMock(data=dict()) serializer = SubListCreateAttachDetachAPIView() - (sub_id, res) = serializer.unattach_validate(mock_request) + sub_id, res = serializer.unattach_validate(mock_request) assert sub_id is None assert type(res) is Response def test_unattach_by_id_ok(self, mocker, parent_relationship_factory, get_object_or_400): - (serializer, mock_parent_relationship) = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') + serializer, mock_parent_relationship = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife') mock_request = mocker.MagicMock() mock_sub = mocker.MagicMock(name="object to unattach") get_object_or_400.return_value = mock_sub diff --git a/awx/main/tests/unit/api/test_schema.py b/awx/main/tests/unit/api/test_schema.py new file mode 100644 index 000000000000..74c2ad2e9806 --- /dev/null +++ b/awx/main/tests/unit/api/test_schema.py @@ -0,0 +1,551 @@ +import copy +import json +import warnings +from unittest.mock import Mock, mock_open, patch + +from rest_framework.permissions import IsAuthenticated + +from awx.api.schema import ( + CustomAutoSchema, + AuthenticatedSpectacularAPIView, + AuthenticatedSpectacularSwaggerView, + AuthenticatedSpectacularRedocView, + filter_credential_type_schema, + inject_ai_descriptions, +) + + +class TestCustomAutoSchema: + """Unit tests for CustomAutoSchema class.""" + + def test_get_tags_with_swagger_topic(self): + """Test get_tags returns swagger_topic when available.""" + view = Mock() + view.swagger_topic = 'custom_topic' + view.get_serializer = Mock(return_value=Mock()) + + schema = CustomAutoSchema() + schema.view = view + + tags = schema.get_tags() + assert tags == ['Custom_Topic'] + + def test_get_tags_with_serializer_meta_model(self): + """Test get_tags returns model verbose_name_plural from serializer.""" + # Create a mock model with verbose_name_plural + mock_model = Mock() + mock_model._meta.verbose_name_plural = 'test models' + + # Create a mock serializer with Meta.model + mock_serializer = Mock() + mock_serializer.Meta.model = mock_model + + view = Mock(spec=[]) # View without swagger_topic + view.get_serializer = Mock(return_value=mock_serializer) + + schema = CustomAutoSchema() + schema.view = view + + tags = schema.get_tags() + assert tags == ['Test Models'] + + def test_get_tags_with_view_model(self): + """Test get_tags returns model verbose_name_plural from view.""" + # Create a mock model with verbose_name_plural + mock_model = Mock() + mock_model._meta.verbose_name_plural = 'view models' + + view = Mock(spec=['model']) # View without swagger_topic or get_serializer + view.model = mock_model + + schema = CustomAutoSchema() + schema.view = view + + tags = schema.get_tags() + assert tags == ['View Models'] + + def test_get_tags_without_get_serializer(self): + """Test get_tags when view doesn't have get_serializer method.""" + mock_model = Mock() + mock_model._meta.verbose_name_plural = 'test objects' + + view = Mock(spec=['model']) + view.model = mock_model + + schema = CustomAutoSchema() + schema.view = view + + tags = schema.get_tags() + assert tags == ['Test Objects'] + + def test_get_tags_serializer_exception_with_warning(self): + """Test get_tags handles exception in get_serializer with warning.""" + mock_model = Mock() + mock_model._meta.verbose_name_plural = 'fallback models' + + view = Mock(spec=['get_serializer', 'model', '__class__']) + view.__class__.__name__ = 'TestView' + view.get_serializer = Mock(side_effect=Exception('Serializer error')) + view.model = mock_model + + schema = CustomAutoSchema() + schema.view = view + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + tags = schema.get_tags() + + # Check that a warning was raised + assert len(w) == 1 + assert 'TestView.get_serializer() raised an exception' in str(w[0].message) + + # Should still get tags from view.model + assert tags == ['Fallback Models'] + + def test_get_tags_serializer_without_meta_model(self): + """Test get_tags when serializer doesn't have Meta.model.""" + mock_serializer = Mock(spec=[]) # No Meta attribute + + view = Mock(spec=['get_serializer']) + view.__class__.__name__ = 'NoMetaView' + view.get_serializer = Mock(return_value=mock_serializer) + + schema = CustomAutoSchema() + schema.view = view + + with patch.object(CustomAutoSchema.__bases__[0], 'get_tags', return_value=['Default Tag']) as mock_super: + tags = schema.get_tags() + mock_super.assert_called_once() + assert tags == ['Default Tag'] + + def test_get_tags_fallback_to_super(self): + """Test get_tags falls back to parent class method.""" + view = Mock(spec=['get_serializer']) + view.get_serializer = Mock(return_value=Mock(spec=[])) + + schema = CustomAutoSchema() + schema.view = view + + with patch.object(CustomAutoSchema.__bases__[0], 'get_tags', return_value=['Super Tag']) as mock_super: + tags = schema.get_tags() + mock_super.assert_called_once() + assert tags == ['Super Tag'] + + def test_get_tags_empty_with_warning(self): + """Test get_tags returns 'api' fallback when no tags can be determined.""" + view = Mock(spec=['get_serializer']) + view.__class__.__name__ = 'EmptyView' + view.get_serializer = Mock(return_value=Mock(spec=[])) + + schema = CustomAutoSchema() + schema.view = view + + with patch.object(CustomAutoSchema.__bases__[0], 'get_tags', return_value=[]): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + tags = schema.get_tags() + + # Check that a warning was raised + assert len(w) == 1 + assert 'Could not determine tags for EmptyView' in str(w[0].message) + + # Should fallback to 'api' + assert tags == ['api'] + + def test_get_tags_swagger_topic_title_case(self): + """Test that swagger_topic is properly title-cased.""" + view = Mock() + view.swagger_topic = 'multi_word_topic' + view.get_serializer = Mock(return_value=Mock()) + + schema = CustomAutoSchema() + schema.view = view + + tags = schema.get_tags() + assert tags == ['Multi_Word_Topic'] + + def test_is_deprecated_true(self): + """Test is_deprecated returns True when view has deprecated=True.""" + view = Mock() + view.deprecated = True + + schema = CustomAutoSchema() + schema.view = view + + assert schema.is_deprecated() is True + + def test_is_deprecated_false(self): + """Test is_deprecated returns False when view has deprecated=False.""" + view = Mock() + view.deprecated = False + + schema = CustomAutoSchema() + schema.view = view + + assert schema.is_deprecated() is False + + def test_is_deprecated_missing_attribute(self): + """Test is_deprecated returns False when view doesn't have deprecated attribute.""" + view = Mock(spec=[]) + + schema = CustomAutoSchema() + schema.view = view + + assert schema.is_deprecated() is False + + def test_get_tags_serializer_meta_without_model(self): + """Test get_tags when serializer has Meta but no model attribute.""" + mock_serializer = Mock() + mock_serializer.Meta = Mock(spec=[]) # Meta exists but no model + + mock_model = Mock() + mock_model._meta.verbose_name_plural = 'backup models' + + view = Mock(spec=['get_serializer', 'model']) + view.get_serializer = Mock(return_value=mock_serializer) + view.model = mock_model + + schema = CustomAutoSchema() + schema.view = view + + tags = schema.get_tags() + # Should fall back to view.model + assert tags == ['Backup Models'] + + def test_get_tags_complex_scenario_exception_recovery(self): + """Test complex scenario where serializer fails but view.model exists.""" + mock_model = Mock() + mock_model._meta.verbose_name_plural = 'recovery models' + + view = Mock(spec=['get_serializer', 'model', '__class__']) + view.__class__.__name__ = 'ComplexView' + view.get_serializer = Mock(side_effect=ValueError('Invalid serializer')) + view.model = mock_model + + schema = CustomAutoSchema() + schema.view = view + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + tags = schema.get_tags() + + # Should have warned about the exception + assert len(w) == 1 + assert 'ComplexView.get_serializer() raised an exception' in str(w[0].message) + + # But still recovered and got tags from view.model + assert tags == ['Recovery Models'] + + def test_get_tags_priority_order(self): + """Test that get_tags respects priority: swagger_topic > serializer.Meta.model > view.model.""" + # Set up a view with all three options + mock_model_view = Mock() + mock_model_view._meta.verbose_name_plural = 'view models' + + mock_model_serializer = Mock() + mock_model_serializer._meta.verbose_name_plural = 'serializer models' + + mock_serializer = Mock() + mock_serializer.Meta.model = mock_model_serializer + + view = Mock() + view.swagger_topic = 'priority_topic' + view.get_serializer = Mock(return_value=mock_serializer) + view.model = mock_model_view + + schema = CustomAutoSchema() + schema.view = view + + tags = schema.get_tags() + # swagger_topic should take priority + assert tags == ['Priority_Topic'] + + +class TestAuthenticatedSchemaViews: + """Unit tests for authenticated schema view classes.""" + + def test_authenticated_spectacular_api_view_requires_authentication(self): + """Test that AuthenticatedSpectacularAPIView requires authentication.""" + assert IsAuthenticated in AuthenticatedSpectacularAPIView.permission_classes + + def test_authenticated_spectacular_swagger_view_requires_authentication(self): + """Test that AuthenticatedSpectacularSwaggerView requires authentication.""" + assert IsAuthenticated in AuthenticatedSpectacularSwaggerView.permission_classes + + def test_authenticated_spectacular_redoc_view_requires_authentication(self): + """Test that AuthenticatedSpectacularRedocView requires authentication.""" + assert IsAuthenticated in AuthenticatedSpectacularRedocView.permission_classes + + +class TestFilterCredentialTypeSchema: + """Unit tests for filter_credential_type_schema postprocessing hook.""" + + def test_filters_both_schemas_correctly(self): + """Test that both CredentialTypeRequest and PatchedCredentialTypeRequest schemas are filtered.""" + result = { + 'components': { + 'schemas': { + 'CredentialTypeRequest': { + 'properties': { + 'kind': { + 'enum': [ + 'ssh', + 'vault', + 'net', + 'scm', + 'cloud', + 'registry', + 'token', + 'insights', + 'external', + 'kubernetes', + 'galaxy', + 'cryptography', + None, + ], + 'type': 'string', + } + } + }, + 'PatchedCredentialTypeRequest': { + 'properties': { + 'kind': { + 'enum': [ + 'ssh', + 'vault', + 'net', + 'scm', + 'cloud', + 'registry', + 'token', + 'insights', + 'external', + 'kubernetes', + 'galaxy', + 'cryptography', + None, + ], + 'type': 'string', + } + } + }, + } + } + } + + returned = filter_credential_type_schema(result, None, None, None) + + # POST/PUT schema: no None (required field) + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['description'] == "* `cloud` - Cloud\\n* `net` - Network" + + # PATCH schema: includes None (optional field) + assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None] + assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['description'] == "* `cloud` - Cloud\\n* `net` - Network" + + # Other properties should be preserved + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['type'] == 'string' + + # Function should return the result + assert returned is result + + def test_handles_empty_result(self): + """Test graceful handling when result dict is empty.""" + result = {} + original = copy.deepcopy(result) + + returned = filter_credential_type_schema(result, None, None, None) + + assert result == original + assert returned is result + + def test_handles_missing_enum(self): + """Test that schemas without enum key are not modified.""" + result = {'components': {'schemas': {'CredentialTypeRequest': {'properties': {'kind': {'type': 'string', 'description': 'Some description'}}}}}} + original = copy.deepcopy(result) + + filter_credential_type_schema(result, None, None, None) + + assert result == original + + def test_filters_only_target_schemas(self): + """Test that only CredentialTypeRequest schemas are modified, not others.""" + result = { + 'components': { + 'schemas': { + 'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'cloud', 'net', None]}}}, + 'OtherSchema': {'properties': {'kind': {'enum': ['option1', 'option2']}}}, + } + } + } + + other_schema_before = copy.deepcopy(result['components']['schemas']['OtherSchema']) + + filter_credential_type_schema(result, None, None, None) + + # CredentialTypeRequest should be filtered (no None for required field) + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + + # OtherSchema should be unchanged + assert result['components']['schemas']['OtherSchema'] == other_schema_before + + def test_handles_only_one_schema_present(self): + """Test that function works when only one target schema is present.""" + result = {'components': {'schemas': {'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'cloud', 'net', None]}}}}}} + + filter_credential_type_schema(result, None, None, None) + + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + + def test_handles_missing_properties(self): + """Test graceful handling when schema has no properties key.""" + result = {'components': {'schemas': {'CredentialTypeRequest': {}}}} + original = copy.deepcopy(result) + + filter_credential_type_schema(result, None, None, None) + + assert result == original + + def test_differentiates_required_vs_optional_fields(self): + """Test that CredentialTypeRequest excludes None but PatchedCredentialTypeRequest includes it.""" + result = { + 'components': { + 'schemas': { + 'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'vault', 'net', 'scm', 'cloud', 'registry', None]}}}, + 'PatchedCredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'vault', 'net', 'scm', 'cloud', 'registry', None]}}}, + } + } + } + + filter_credential_type_schema(result, None, None, None) + + # POST/PUT schema: no None (required field) + assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net'] + + # PATCH schema: includes None (optional field) + assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None] + + +class TestInjectAiDescriptions: + """Unit tests for inject_ai_descriptions postprocessing hook.""" + + def _make_result(self, operations): + """Build a minimal OpenAPI result dict from a list of (path, method, operationId, existing_desc) tuples.""" + paths = {} + for path, method, op_id, desc in operations: + paths.setdefault(path, {})[method] = {'operationId': op_id} + if desc: + paths[path][method]['x-ai-description'] = desc + return {'paths': paths} + + def test_injects_missing_descriptions(self): + """Test that descriptions are injected for operations without x-ai-description.""" + overlay = {'op_list': 'List items', 'op_create': 'Create an item'} + result = self._make_result( + [ + ('/api/v2/items/', 'get', 'op_list', None), + ('/api/v2/items/', 'post', 'op_create', None), + ] + ) + + with patch('builtins.open', mock_open(read_data=json.dumps(overlay))): + returned = inject_ai_descriptions(result, None, None, None) + + assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'List items' + assert result['paths']['/api/v2/items/']['post']['x-ai-description'] == 'Create an item' + assert returned is result + + def test_does_not_overwrite_existing_descriptions(self): + """Test that existing x-ai-description from decorators is preserved.""" + overlay = {'op_list': 'Overlay description'} + result = self._make_result( + [ + ('/api/v2/items/', 'get', 'op_list', 'Decorator description'), + ] + ) + + with patch('builtins.open', mock_open(read_data=json.dumps(overlay))): + inject_ai_descriptions(result, None, None, None) + + assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'Decorator description' + + def test_skips_operations_not_in_overlay(self): + """Test that operations without a matching operationId in the overlay are unchanged.""" + overlay = {'op_other': 'Other description'} + result = self._make_result( + [ + ('/api/v2/items/', 'get', 'op_list', None), + ] + ) + + with patch('builtins.open', mock_open(read_data=json.dumps(overlay))): + inject_ai_descriptions(result, None, None, None) + + assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get'] + + def test_handles_missing_overlay_file(self): + """Test graceful handling when the overlay file doesn't exist.""" + result = self._make_result( + [ + ('/api/v2/items/', 'get', 'op_list', None), + ] + ) + original = copy.deepcopy(result) + + with patch('builtins.open', side_effect=FileNotFoundError): + returned = inject_ai_descriptions(result, None, None, None) + + assert result == original + assert returned is result + + def test_handles_invalid_json(self): + """Test graceful handling when the overlay file contains invalid JSON.""" + result = self._make_result( + [ + ('/api/v2/items/', 'get', 'op_list', None), + ] + ) + original = copy.deepcopy(result) + + with patch('builtins.open', mock_open(read_data='not valid json')): + returned = inject_ai_descriptions(result, None, None, None) + + assert result == original + assert returned is result + + def test_handles_empty_result(self): + """Test graceful handling when result has no paths.""" + result = {} + overlay = {'op_list': 'List items'} + + with patch('builtins.open', mock_open(read_data=json.dumps(overlay))): + returned = inject_ai_descriptions(result, None, None, None) + + assert returned is result + + def test_skips_non_dict_path_items(self): + """Test that non-dict values in path items (e.g. parameters list) are skipped.""" + overlay = {'op_list': 'List items'} + result = { + 'paths': { + '/api/v2/items/': { + 'parameters': [{'name': 'id', 'in': 'path'}], + 'get': {'operationId': 'op_list'}, + } + } + } + + with patch('builtins.open', mock_open(read_data=json.dumps(overlay))): + inject_ai_descriptions(result, None, None, None) + + assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'List items' + + def test_handles_operation_without_operation_id(self): + """Test that operations without operationId are skipped.""" + overlay = {'op_list': 'List items'} + result = {'paths': {'/api/v2/items/': {'get': {'summary': 'List'}}}} + + with patch('builtins.open', mock_open(read_data=json.dumps(overlay))): + inject_ai_descriptions(result, None, None, None) + + assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get'] diff --git a/awx/main/tests/unit/commands/test_dispatcherctl.py b/awx/main/tests/unit/commands/test_dispatcherctl.py new file mode 100644 index 000000000000..50804577c36e --- /dev/null +++ b/awx/main/tests/unit/commands/test_dispatcherctl.py @@ -0,0 +1,92 @@ +import io + +import pytest + +from django.core.management.base import CommandError + +from awx.main.management.commands import dispatcherctl + + +@pytest.fixture(autouse=True) +def clear_dispatcher_env(monkeypatch, mocker): + monkeypatch.delenv('DISPATCHERD_CONFIG_FILE', raising=False) + mocker.patch.object(dispatcherctl.logging, 'basicConfig') + mocker.patch.object(dispatcherctl, 'connection', mocker.Mock(vendor='postgresql')) + + +def test_dispatcherctl_runs_control_with_generated_config(mocker): + command = dispatcherctl.Command() + command.stdout = io.StringIO() + + data = {'foo': 'bar'} + mocker.patch.object(dispatcherctl, '_build_command_data_from_args', return_value=data) + dispatcher_setup = mocker.patch.object(dispatcherctl, 'dispatcher_setup') + config_data = {'setting': 'value'} + mocker.patch.object(dispatcherctl, 'get_dispatcherd_config', return_value=config_data) + + control = mocker.Mock() + control.control_with_reply.return_value = [{'status': 'ok'}] + mocker.patch.object(dispatcherctl, 'get_control_from_settings', return_value=control) + mocker.patch.object(dispatcherctl.yaml, 'dump', return_value='payload\n') + + command.handle( + command='running', + config=dispatcherctl.DEFAULT_CONFIG_FILE, + expected_replies=1, + log_level='INFO', + ) + + dispatcher_setup.assert_called_once_with(config_data) + control.control_with_reply.assert_called_once_with('running', data=data, expected_replies=1) + assert command.stdout.getvalue() == 'payload\n' + + +def test_dispatcherctl_rejects_custom_config_path(): + command = dispatcherctl.Command() + command.stdout = io.StringIO() + + with pytest.raises(CommandError): + command.handle( + command='running', + config='/tmp/dispatcher.yml', + expected_replies=1, + log_level='INFO', + ) + + +def test_dispatcherctl_rejects_sqlite_db(mocker): + command = dispatcherctl.Command() + command.stdout = io.StringIO() + + mocker.patch.object(dispatcherctl, 'connection', mocker.Mock(vendor='sqlite')) + + with pytest.raises(CommandError, match='sqlite3'): + command.handle( + command='running', + config=dispatcherctl.DEFAULT_CONFIG_FILE, + expected_replies=1, + log_level='INFO', + ) + + +def test_dispatcherctl_raises_when_replies_missing(mocker): + command = dispatcherctl.Command() + command.stdout = io.StringIO() + + mocker.patch.object(dispatcherctl, '_build_command_data_from_args', return_value={}) + mocker.patch.object(dispatcherctl, 'dispatcher_setup') + mocker.patch.object(dispatcherctl, 'get_dispatcherd_config', return_value={}) + control = mocker.Mock() + control.control_with_reply.return_value = [{'status': 'ok'}] + mocker.patch.object(dispatcherctl, 'get_control_from_settings', return_value=control) + mocker.patch.object(dispatcherctl.yaml, 'dump', return_value='- status: ok\n') + + with pytest.raises(CommandError): + command.handle( + command='running', + config=dispatcherctl.DEFAULT_CONFIG_FILE, + expected_replies=2, + log_level='INFO', + ) + + control.control_with_reply.assert_called_once_with('running', data={}, expected_replies=2) diff --git a/awx/main/tests/unit/management/commands/test_candlepin_cert.py b/awx/main/tests/unit/management/commands/test_candlepin_cert.py new file mode 100644 index 000000000000..e9f93a89b4ed --- /dev/null +++ b/awx/main/tests/unit/management/commands/test_candlepin_cert.py @@ -0,0 +1,310 @@ +# Copyright (c) 2026 Ansible, Inc. +# All Rights Reserved. + +"""Tests for candlepin_cert management command.""" + +from io import StringIO +from unittest import mock + +import pytest +from django.core.management import call_command +from django.test.utils import override_settings + + +class TestCandlepinCertCommand: + """Tests for candlepin_cert management command.""" + + @mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_registration_to_db') + @mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient') + @mock.patch('awx.main.management.commands.candlepin_cert.resolve_registration_credentials') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + @override_settings( + AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com', + AWX_ANALYTICS_CANDLEPIN_CA=None, + AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None, + ) + def test_register_success(self, mock_fetch_cert, mock_resolve_creds, mock_client_class, mock_save_reg): + """Test successful registration.""" + # No existing cert + mock_fetch_cert.return_value = (None, None, None) + + # Valid credentials + mock_resolve_creds.return_value = ('test_user', 'test_pass', 'test_org', 'install-uuid', None) + + # Mock successful registration + mock_client = mock.Mock() + mock_client.register_consumer.return_value = ('cert-pem', 'key-pem', 'consumer-uuid') + mock_client_class.return_value = mock_client + + # Mock successful save + mock_save_reg.return_value = True + + out = StringIO() + call_command('candlepin_cert', 'register', stdout=out, stderr=StringIO()) + + output = out.getvalue() + assert 'Registered successfully' in output + assert 'consumer-uuid' in output + + mock_client.register_consumer.assert_called_once_with('test_user', 'test_pass', 'test_org', install_uuid='install-uuid') + mock_save_reg.assert_called_once_with('cert-pem', 'key-pem', 'consumer-uuid') + + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + def test_register_already_registered_without_force(self, mock_fetch_cert): + """Test registration fails when cert already exists and --force not provided.""" + # Existing cert + mock_fetch_cert.return_value = ('existing-cert', 'existing-key', 'existing-uuid') + + out = StringIO() + call_command('candlepin_cert', 'register', stdout=out, stderr=StringIO()) + + output = out.getvalue() + assert 'already stored' in output + assert '--force' in output + + @mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_registration_to_db') + @mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient') + @mock.patch('awx.main.management.commands.candlepin_cert.resolve_registration_credentials') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + @override_settings( + AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com', + AWX_ANALYTICS_CANDLEPIN_CA=None, + AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None, + ) + def test_register_with_force_flag(self, mock_fetch_cert, mock_resolve_creds, mock_client_class, mock_save_reg): + """Test registration succeeds with --force even when cert exists.""" + # Existing cert + mock_fetch_cert.return_value = ('existing-cert', 'existing-key', 'existing-uuid') + + # Valid credentials + mock_resolve_creds.return_value = ('test_user', 'test_pass', 'test_org', 'install-uuid', None) + + # Mock successful registration + mock_client = mock.Mock() + mock_client.register_consumer.return_value = ('new-cert-pem', 'new-key-pem', 'new-consumer-uuid') + mock_client_class.return_value = mock_client + + # Mock successful save + mock_save_reg.return_value = True + + out = StringIO() + call_command('candlepin_cert', 'register', '--force', stdout=out, stderr=StringIO()) + + output = out.getvalue() + assert 'Registered successfully' in output + + mock_client.register_consumer.assert_called_once() + mock_save_reg.assert_called_once_with('new-cert-pem', 'new-key-pem', 'new-consumer-uuid') + + @mock.patch('awx.main.management.commands.candlepin_cert.resolve_registration_credentials') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + def test_register_missing_credentials(self, mock_fetch_cert, mock_resolve_creds): + """Test registration fails when credentials are missing.""" + mock_fetch_cert.return_value = (None, None, None) + + # Missing credentials + mock_resolve_creds.return_value = (None, None, None, None, ['username', 'password']) + + err = StringIO() + with pytest.raises(SystemExit) as exc_info: + call_command('candlepin_cert', 'register', stderr=err) + + assert exc_info.value.code == 1 + error_output = err.getvalue() + assert 'Missing required value' in error_output + + @mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_cert_to_db') + @mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient') + @mock.patch('awx.main.management.commands.candlepin_cert.parse_cert') + @mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + @override_settings( + AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com', + AWX_ANALYTICS_CANDLEPIN_CA=None, + AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None, + AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90, + ) + def test_renew_success(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class, mock_save_cert): + """Test successful certificate renewal.""" + # Existing cert + mock_fetch_cert.return_value = ('old-cert', 'old-key', 'consumer-uuid') + + # Parse cert returns metadata + mock_parse_cert.side_effect = [ + {'serial': '123', 'cn': 'test', 'not_after': '2026-06-01', 'days_remaining': 10}, # Current cert + {'serial': '456', 'cn': 'test', 'not_after': '2027-06-01', 'days_remaining': 365}, # Renewed cert + ] + + # Renewal needed + mock_needs_renewal.return_value = True + + # Mock successful check-in and renewal + mock_client = mock.Mock() + mock_client.checkin.return_value = True + mock_client.regenerate_cert.return_value = ('new-cert', 'new-key') + mock_client_class.return_value = mock_client + + mock_save_cert.return_value = True + + out = StringIO() + call_command('candlepin_cert', 'renew', stdout=out, stderr=StringIO()) + + output = out.getvalue() + assert 'Check-in successful' in output + assert 'Certificate renewed successfully' in output + assert 'saved to database' in output + + mock_client.checkin.assert_called_once_with('consumer-uuid', 'old-cert', 'old-key') + mock_client.regenerate_cert.assert_called_once() + mock_save_cert.assert_called_once_with('new-cert', 'new-key') + + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + def test_renew_no_cert_in_db(self, mock_fetch_cert): + """Test renew fails when no certificate exists in database.""" + mock_fetch_cert.return_value = (None, None, None) + + err = StringIO() + with pytest.raises(SystemExit) as exc_info: + call_command('candlepin_cert', 'renew', stderr=err) + + assert exc_info.value.code == 1 + error_output = err.getvalue() + assert 'No Candlepin identity certificate found' in error_output + assert 'Run the register subcommand first' in error_output + + @mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient') + @mock.patch('awx.main.management.commands.candlepin_cert.parse_cert') + @mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + @override_settings( + AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com', + AWX_ANALYTICS_CANDLEPIN_CA=None, + AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None, + AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90, + ) + def test_renew_not_needed(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class): + """Test renew when certificate is still valid and renewal not needed.""" + mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid') + + # Parse cert returns healthy cert + mock_parse_cert.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 200} + + # Renewal not needed + mock_needs_renewal.return_value = False + + # Mock successful check-in + mock_client = mock.Mock() + mock_client.checkin.return_value = True + mock_client_class.return_value = mock_client + + out = StringIO() + call_command('candlepin_cert', 'renew', stdout=out, stderr=StringIO()) + + output = out.getvalue() + assert 'Check-in successful' in output + assert 'No renewal needed' in output + + mock_client.checkin.assert_called_once() + mock_client.regenerate_cert.assert_not_called() + + @mock.patch('awx.main.management.commands.candlepin_cert._save_candlepin_cert_to_db') + @mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient') + @mock.patch('awx.main.management.commands.candlepin_cert.parse_cert') + @mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + @override_settings( + AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com', + AWX_ANALYTICS_CANDLEPIN_CA=None, + AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None, + AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90, + ) + def test_renew_with_force_flag(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class, mock_save_cert): + """Test renew --force renews even when not needed.""" + mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid') + + # Parse cert + mock_parse_cert.side_effect = [ + {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 200}, # Current cert (healthy) + {'serial': '456', 'cn': 'test', 'not_after': '2027-06-01', 'days_remaining': 365}, # New cert + ] + + # Would not need renewal without --force + mock_needs_renewal.return_value = False + + # Mock successful operations + mock_client = mock.Mock() + mock_client.checkin.return_value = True + mock_client.regenerate_cert.return_value = ('new-cert', 'new-key') + mock_client_class.return_value = mock_client + + mock_save_cert.return_value = True + + out = StringIO() + call_command('candlepin_cert', 'renew', '--force', stdout=out, stderr=StringIO()) + + output = out.getvalue() + assert 'forced via --force' in output + assert 'Certificate renewed successfully' in output + + mock_client.regenerate_cert.assert_called_once() + + @mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient') + @mock.patch('awx.main.management.commands.candlepin_cert.parse_cert') + @mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + @override_settings( + AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com', + AWX_ANALYTICS_CANDLEPIN_CA=None, + AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None, + AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90, + ) + def test_renew_checkin_failure(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class): + """Test renew handles check-in failure gracefully.""" + mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid') + + mock_parse_cert.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 100} + mock_needs_renewal.return_value = False # Not needed for renewal, just testing check-in failure + + # Mock failed check-in + mock_client = mock.Mock() + mock_client.checkin.return_value = False + mock_client_class.return_value = mock_client + + err = StringIO() + with pytest.raises(SystemExit) as exc_info: + call_command('candlepin_cert', 'renew', stderr=err) + + assert exc_info.value.code == 1 + error_output = err.getvalue() + assert 'Check-in with Candlepin failed' in error_output + + @mock.patch('awx.main.management.commands.candlepin_cert.CandlepinClient') + @mock.patch('awx.main.management.commands.candlepin_cert.parse_cert') + @mock.patch('awx.main.management.commands.candlepin_cert.needs_renewal') + @mock.patch('awx.main.management.commands.candlepin_cert._fetch_candlepin_cert_from_db') + @override_settings( + AWX_ANALYTICS_CANDLEPIN_URL='https://test.example.com', + AWX_ANALYTICS_CANDLEPIN_CA=None, + AWX_ANALYTICS_CANDLEPIN_PROXY_URL=None, + AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS=90, + ) + def test_renew_regenerate_cert_failure(self, mock_fetch_cert, mock_needs_renewal, mock_parse_cert, mock_client_class): + """Test renew handles certificate regeneration failure.""" + mock_fetch_cert.return_value = ('cert', 'key', 'consumer-uuid') + + mock_parse_cert.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2026-06-01', 'days_remaining': 10} + mock_needs_renewal.return_value = True + + # Mock successful check-in but failed regeneration + mock_client = mock.Mock() + mock_client.checkin.return_value = True + mock_client.regenerate_cert.side_effect = Exception('Certificate regeneration failed') + mock_client_class.return_value = mock_client + + err = StringIO() + with pytest.raises(SystemExit) as exc_info: + call_command('candlepin_cert', 'renew', stderr=err) + + assert exc_info.value.code == 1 + error_output = err.getvalue() + assert 'Certificate renewal failed' in error_output diff --git a/awx/main/tests/unit/management/commands/test_check_db.py b/awx/main/tests/unit/management/commands/test_check_db.py new file mode 100644 index 000000000000..e1bb9efbd922 --- /dev/null +++ b/awx/main/tests/unit/management/commands/test_check_db.py @@ -0,0 +1,35 @@ +import pytest +from django.core.management.base import CommandError + +from awx.main.management.commands.check_db import Command + + +def test_check_db_command_success(mocker): + mock_cursor = mocker.MagicMock() + mock_cursor.fetchone.return_value = ['PostgreSQL 12.8 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 9.3.0, 64-bit'] + mock_connection = mocker.MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + mocker.patch('awx.main.management.commands.check_db.connection', mock_connection) + mocker.patch('awx.main.management.commands.check_db.db_requirement_violations', return_value=None) + + command = Command() + result = command.handle() + + assert 'Database Version:' in result + mock_cursor.execute.assert_called_once_with('SELECT version()') + + +def test_check_db_command_version_violations(mocker): + mock_cursor = mocker.MagicMock() + mock_cursor.fetchone.return_value = ['PostgreSQL 11.0 on x86_64-pc-linux-gnu'] + mock_connection = mocker.MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + mocker.patch('awx.main.management.commands.check_db.connection', mock_connection) + violation_msg = "At a minimum, postgres version 12 is required, found 11\n" + mocker.patch('awx.main.management.commands.check_db.db_requirement_violations', return_value=violation_msg) + + command = Command() + with pytest.raises(CommandError) as exc_info: + command.handle() + + assert str(exc_info.value) == violation_msg diff --git a/awx/main/tests/unit/models/test_credential.py b/awx/main/tests/unit/models/test_credential.py index 81f243ddefd5..437945efc27d 100644 --- a/awx/main/tests/unit/models/test_credential.py +++ b/awx/main/tests/unit/models/test_credential.py @@ -2,7 +2,11 @@ import pytest +from types import SimpleNamespace +from unittest import mock + from awx.main.models import Credential, CredentialType +from awx.main.models.credential import CredentialTypeHelper, ManagedCredentialType from django.apps import apps @@ -47,3 +51,84 @@ def test__get_credential_type_class_invalid_params(): assert type(e.value) is ValueError assert str(e.value) == 'Expected only apps or app_config to be defined, not both' + + +def test_credential_context_property(): + """Test that credential context property initializes empty dict and persists across accesses.""" + ct = CredentialType(name='Test Cred', kind='vault') + cred = Credential(id=1, name='Test Credential', credential_type=ct, inputs={}) + + # First access should return empty dict + context = cred.context + assert context == {} + + # Modify the context + context['test_key'] = 'test_value' + + # Second access should return the same dict with modifications + assert cred.context == {'test_key': 'test_value'} + assert cred.context is context # Same object reference + + +def test_credential_context_property_independent_instances(): + """Test that context property is independent between credential instances.""" + ct = CredentialType(name='Test Cred', kind='vault') + cred1 = Credential(id=1, name='Cred 1', credential_type=ct, inputs={}) + cred2 = Credential(id=2, name='Cred 2', credential_type=ct, inputs={}) + + cred1.context['key1'] = 'value1' + cred2.context['key2'] = 'value2' + + assert cred1.context == {'key1': 'value1'} + assert cred2.context == {'key2': 'value2'} + assert cred1.context is not cred2.context + + +def test_load_plugin_passes_description(): + plugin = SimpleNamespace(name='test_plugin', inputs={'fields': []}, backend=None, plugin_description='A test plugin') + CredentialType.load_plugin('test_ns', plugin) + entry = ManagedCredentialType.registry['test_ns'] + assert entry.description == 'A test plugin' + del ManagedCredentialType.registry['test_ns'] + + +def test_load_plugin_missing_description(): + plugin = SimpleNamespace(name='test_plugin', inputs={'fields': []}, backend=None) + CredentialType.load_plugin('test_ns', plugin) + entry = ManagedCredentialType.registry['test_ns'] + assert entry.description == '' + del ManagedCredentialType.registry['test_ns'] + + +def test_get_creation_params_external_includes_description(): + cred_type = SimpleNamespace(namespace='test_ns', kind='external', name='Test', description='My description') + params = CredentialTypeHelper.get_creation_params(cred_type) + assert params['description'] == 'My description' + + +def test_get_creation_params_external_missing_description(): + cred_type = SimpleNamespace(namespace='test_ns', kind='external', name='Test') + params = CredentialTypeHelper.get_creation_params(cred_type) + assert params['description'] == '' + + +@pytest.mark.django_db +def test_setup_tower_managed_defaults_updates_description(): + registry_entry = SimpleNamespace( + namespace='test_ns', + kind='external', + name='Test Plugin', + inputs={'fields': []}, + backend=None, + description='Updated description', + ) + # Create an existing credential type with no description + ct = CredentialType.objects.create(name='Test Plugin', kind='external', namespace='old_ns') + assert ct.description == '' + + with mock.patch.dict(ManagedCredentialType.registry, {'test_ns': registry_entry}, clear=True): + CredentialType._setup_tower_managed_defaults() + + ct.refresh_from_db() + assert ct.description == 'Updated description' + assert ct.namespace == 'test_ns' diff --git a/awx/main/tests/unit/models/test_jobs.py b/awx/main/tests/unit/models/test_jobs.py index 4f05a82535d3..763dc0b4e0ef 100644 --- a/awx/main/tests/unit/models/test_jobs.py +++ b/awx/main/tests/unit/models/test_jobs.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- import json import os -import time - import pytest +from unittest import mock from awx.main.models import ( Inventory, @@ -15,6 +14,8 @@ from datetime import timedelta +import time + @pytest.fixture def ref_time(): @@ -33,15 +34,23 @@ def hosts(ref_time): def test_start_job_fact_cache(hosts, tmpdir): - fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(hosts, fact_cache, timeout=0) + # Create artifacts dir inside tmpdir + artifacts_dir = tmpdir.mkdir("artifacts") + + # Assign a mock inventory ID + inventory_id = 42 + + # Call the function WITHOUT log_data — the decorator handles it + start_fact_cache(hosts, artifacts_dir=str(artifacts_dir), timeout=0, inventory_id=inventory_id) + + # Fact files are written into artifacts_dir/fact_cache/ + fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache') for host in hosts: - filepath = os.path.join(fact_cache, host.name) + filepath = os.path.join(fact_cache_dir, host.name) assert os.path.exists(filepath) - with open(filepath, 'r') as f: - assert f.read() == json.dumps(host.ansible_facts) - assert os.path.getmtime(filepath) <= last_modified + with open(filepath, 'r', encoding='utf-8') as f: + assert json.load(f) == host.ansible_facts def test_fact_cache_with_invalid_path_traversal(tmpdir): @@ -51,92 +60,95 @@ def test_fact_cache_with_invalid_path_traversal(tmpdir): ansible_facts={"a": 1, "b": 2}, ), ] + artifacts_dir = tmpdir.mkdir("artifacts") + inventory_id = 42 - fact_cache = os.path.join(tmpdir, 'facts') - start_fact_cache(hosts, fact_cache, timeout=0) - # a file called "foo" should _not_ be written outside the facts dir - assert os.listdir(os.path.join(fact_cache, '..')) == ['facts'] + start_fact_cache(hosts, artifacts_dir=str(artifacts_dir), timeout=0, inventory_id=inventory_id) + + # Fact cache directory (safe location) + fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache') + + # The bad host name should not produce a file + assert not os.path.exists(os.path.join(fact_cache_dir, '../foo')) + + # Make sure the fact_cache dir exists and is still empty + assert os.listdir(fact_cache_dir) == [] def test_start_job_fact_cache_past_timeout(hosts, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - # the hosts fixture was modified 5s ago, which is more than 2s - last_modified = start_fact_cache(hosts, fact_cache, timeout=2) - assert last_modified is None + start_fact_cache(hosts, fact_cache, timeout=2) for host in hosts: assert not os.path.exists(os.path.join(fact_cache, host.name)) + ret = start_fact_cache(hosts, fact_cache, timeout=2) + assert ret is None def test_start_job_fact_cache_within_timeout(hosts, tmpdir): - fact_cache = os.path.join(tmpdir, 'facts') - # the hosts fixture was modified 5s ago, which is less than 7s - last_modified = start_fact_cache(hosts, fact_cache, timeout=7) - assert last_modified + artifacts_dir = tmpdir.mkdir("artifacts") + + # The hosts fixture was modified 5s ago, which is less than 7s + start_fact_cache(hosts, str(artifacts_dir), timeout=7) + fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache') for host in hosts: - assert os.path.exists(os.path.join(fact_cache, host.name)) + filepath = os.path.join(fact_cache_dir, host.name) + assert os.path.exists(filepath) + with open(filepath, 'r') as f: + assert json.load(f) == host.ansible_facts -def test_finish_job_fact_cache_with_existing_data(hosts, mocker, tmpdir, ref_time): - fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(hosts, fact_cache, timeout=0) +def test_finish_job_fact_cache_clear(hosts, mocker, ref_time, tmpdir): + artifacts_dir = str(tmpdir.mkdir("artifacts")) + inventory_id = 5 + + start_fact_cache(hosts, artifacts_dir=artifacts_dir, timeout=0, inventory_id=inventory_id) - bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') + mocker.patch('awx.main.tasks.facts.bulk_update_sorted_by_id') - ansible_facts_new = {"foo": "bar"} - filepath = os.path.join(fact_cache, hosts[1].name) - with open(filepath, 'w') as f: - f.write(json.dumps(ansible_facts_new)) - f.flush() - # I feel kind of gross about calling `os.utime` by hand, but I noticed - # that in our container-based dev environment, the resolution for - # `os.stat()` after a file write was over a second, and I don't want to put - # a sleep() in this test - new_modification_time = time.time() + 3600 - os.utime(filepath, (new_modification_time, new_modification_time)) + # Remove the fact file for hosts[1] to simulate ansible's clear_facts + fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache') + os.remove(os.path.join(fact_cache_dir, hosts[1].name)) - finish_fact_cache(hosts, fact_cache, last_modified) + hosts_qs = mock.MagicMock() + # The new code calls host_qs.filter(name__in=...).select_related('inventory') + # Only hosts[1] needs clearing (its file was removed), so return just that host + hosts_qs.filter.return_value.select_related.return_value = [hosts[1]] + finish_fact_cache(hosts_qs, artifacts_dir=artifacts_dir, inventory_id=inventory_id) + + # hosts[1] should have had its facts cleared (file was missing, job_created=None) + assert hosts[1].ansible_facts == {} + assert hosts[1].ansible_facts_modified > ref_time + + # Other hosts should be unmodified (fact files exist but weren't changed by ansible) for host in (hosts[0], hosts[2], hosts[3]): assert host.ansible_facts == {"a": 1, "b": 2} assert host.ansible_facts_modified == ref_time - assert hosts[1].ansible_facts == ansible_facts_new - assert hosts[1].ansible_facts_modified > ref_time - bulk_update.assert_called_once_with([hosts[1]], ['ansible_facts', 'ansible_facts_modified']) def test_finish_job_fact_cache_with_bad_data(hosts, mocker, tmpdir): - fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(hosts, fact_cache, timeout=0) + artifacts_dir = str(tmpdir.mkdir("artifacts")) + inventory_id = 5 + + start_fact_cache(hosts, artifacts_dir=artifacts_dir, timeout=0, inventory_id=inventory_id) - bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') + bulk_update = mocker.patch('awx.main.tasks.facts.bulk_update_sorted_by_id') + # Overwrite fact files with invalid JSON and set future mtime + fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache') for h in hosts: - filepath = os.path.join(fact_cache, h.name) + filepath = os.path.join(fact_cache_dir, h.name) with open(filepath, 'w') as f: f.write('not valid json!') f.flush() new_modification_time = time.time() + 3600 os.utime(filepath, (new_modification_time, new_modification_time)) - finish_fact_cache(hosts, fact_cache, last_modified) - - bulk_update.assert_not_called() - + hosts_qs = mock.MagicMock() -def test_finish_job_fact_cache_clear(hosts, mocker, ref_time, tmpdir): - fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(hosts, fact_cache, timeout=0) - - bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') + finish_fact_cache(hosts_qs, artifacts_dir=artifacts_dir, inventory_id=inventory_id) - os.remove(os.path.join(fact_cache, hosts[1].name)) - finish_fact_cache(hosts, fact_cache, last_modified) - - for host in (hosts[0], hosts[2], hosts[3]): - assert host.ansible_facts == {"a": 1, "b": 2} - assert host.ansible_facts_modified == ref_time - assert hosts[1].ansible_facts == {} - assert hosts[1].ansible_facts_modified > ref_time - bulk_update.assert_called_once_with([hosts[1]], ['ansible_facts', 'ansible_facts_modified']) + # Invalid JSON should be skipped — no hosts updated, bulk_update never called + bulk_update.assert_not_called() diff --git a/awx/main/tests/unit/models/test_label.py b/awx/main/tests/unit/models/test_label.py index e049a8857867..8017782ffa5c 100644 --- a/awx/main/tests/unit/models/test_label.py +++ b/awx/main/tests/unit/models/test_label.py @@ -11,7 +11,6 @@ WorkflowJobNode, ) - mock_query_set = mock.MagicMock() mock_objects = mock.MagicMock(filter=mock.MagicMock(return_value=mock_query_set)) diff --git a/awx/main/tests/unit/models/test_survey_models.py b/awx/main/tests/unit/models/test_survey_models.py index 8ac8bcd227ff..6d9eb5dec999 100644 --- a/awx/main/tests/unit/models/test_survey_models.py +++ b/awx/main/tests/unit/models/test_survey_models.py @@ -176,22 +176,22 @@ def test_display_survey_spec_encrypts_default(survey_spec_factory): @pytest.mark.survey @pytest.mark.parametrize( - "question_type,default,min,max,expect_use,expect_value", + "question_type,default,min,max,expect_valid,expect_use,expect_value", [ - ("text", "", 0, 0, True, ''), # default used - ("text", "", 1, 0, False, 'N/A'), # value less than min length - ("password", "", 1, 0, False, 'N/A'), # passwords behave the same as text - ("multiplechoice", "", 0, 0, False, 'N/A'), # historical bug - ("multiplechoice", "zeb", 0, 0, False, 'N/A'), # zeb not in choices - ("multiplechoice", "coffee", 0, 0, True, 'coffee'), - ("multiselect", None, 0, 0, False, 'N/A'), # NOTE: Behavior is arguable, value of [] may be prefered - ("multiselect", "", 0, 0, False, 'N/A'), - ("multiselect", ["zeb"], 0, 0, False, 'N/A'), - ("multiselect", ["milk"], 0, 0, True, ["milk"]), - ("multiselect", ["orange\nmilk"], 0, 0, False, 'N/A'), # historical bug + ("text", "", 0, 0, True, False, 'N/A'), # valid but empty default not sent for optional question + ("text", "", 1, 0, False, False, 'N/A'), # value less than min length + ("password", "", 1, 0, False, False, 'N/A'), # passwords behave the same as text + ("multiplechoice", "", 0, 0, False, False, 'N/A'), # historical bug + ("multiplechoice", "zeb", 0, 0, False, False, 'N/A'), # zeb not in choices + ("multiplechoice", "coffee", 0, 0, True, True, 'coffee'), + ("multiselect", None, 0, 0, False, False, 'N/A'), # NOTE: Behavior is arguable, value of [] may be prefered + ("multiselect", "", 0, 0, False, False, 'N/A'), + ("multiselect", ["zeb"], 0, 0, False, False, 'N/A'), + ("multiselect", ["milk"], 0, 0, True, True, ["milk"]), + ("multiselect", ["orange\nmilk"], 0, 0, False, False, 'N/A'), # historical bug ], ) -def test_optional_survey_question_defaults(survey_spec_factory, question_type, default, min, max, expect_use, expect_value): +def test_optional_survey_question_defaults(survey_spec_factory, question_type, default, min, max, expect_valid, expect_use, expect_value): spec = survey_spec_factory( [ { @@ -208,7 +208,7 @@ def test_optional_survey_question_defaults(survey_spec_factory, question_type, d jt = JobTemplate(name="test-jt", survey_spec=spec, survey_enabled=True) defaulted_extra_vars = jt._update_unified_job_kwargs({}, {}) element = spec['spec'][0] - if expect_use: + if expect_valid: assert jt._survey_element_validation(element, {element['variable']: element['default']}) == [] else: assert jt._survey_element_validation(element, {element['variable']: element['default']}) @@ -218,6 +218,28 @@ def test_optional_survey_question_defaults(survey_spec_factory, question_type, d assert 'c' not in defaulted_extra_vars['extra_vars'] +@pytest.mark.survey +def test_optional_survey_empty_default_with_runtime_extra_var(survey_spec_factory): + """When a user explicitly provides an empty string at runtime for an optional + survey question, the variable should still be included in extra_vars.""" + spec = survey_spec_factory( + [ + { + "required": False, + "default": "", + "choices": "", + "variable": "c", + "min": 0, + "max": 0, + "type": "text", + }, + ] + ) + jt = JobTemplate(name="test-jt", survey_spec=spec, survey_enabled=True) + defaulted_extra_vars = jt._update_unified_job_kwargs({}, {'extra_vars': json.dumps({'c': ''})}) + assert json.loads(defaulted_extra_vars['extra_vars'])['c'] == '' + + @pytest.mark.survey @pytest.mark.parametrize( "question_type,default,maxlen,kwargs,expected", diff --git a/awx/main/tests/unit/models/test_unified_job_unit.py b/awx/main/tests/unit/models/test_unified_job_unit.py index b6080f55f7b6..54113184baf8 100644 --- a/awx/main/tests/unit/models/test_unified_job_unit.py +++ b/awx/main/tests/unit/models/test_unified_job_unit.py @@ -1,8 +1,7 @@ -import pytest from unittest import mock from awx.main.models import UnifiedJob, UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, WorkflowApprovalTemplate, Job, User, Project, JobTemplate, Inventory -from awx.main.constants import JOB_VARIABLE_PREFIXES +from awx.main.utils.common import get_job_variable_prefixes def test_incorrectly_formatted_variables(): @@ -22,52 +21,6 @@ def test_unified_job_workflow_attributes(): assert job.workflow_job_id == 1 -def mock_on_commit(f): - f() - - -@pytest.fixture -def unified_job(mocker): - mocker.patch.object(UnifiedJob, 'can_cancel', return_value=True) - j = UnifiedJob() - j.status = 'pending' - j.cancel_flag = None - j.save = mocker.MagicMock() - j.websocket_emit_status = mocker.MagicMock() - j.fallback_cancel = mocker.MagicMock() - return j - - -def test_cancel(unified_job): - with mock.patch('awx.main.models.unified_jobs.connection.on_commit', wraps=mock_on_commit): - unified_job.cancel() - - assert unified_job.cancel_flag is True - assert unified_job.status == 'canceled' - assert unified_job.job_explanation == '' - # Note: the websocket emit status check is just reflecting the state of the current code. - # Some more thought may want to go into only emitting canceled if/when the job record - # status is changed to canceled. Unlike, currently, where it's emitted unconditionally. - unified_job.websocket_emit_status.assert_called_with("canceled") - assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [ - ((), {'update_fields': ['cancel_flag', 'start_args']}), - ((), {'update_fields': ['status']}), - ] - - -def test_cancel_job_explanation(unified_job): - job_explanation = 'giggity giggity' - - with mock.patch('awx.main.models.unified_jobs.connection.on_commit'): - unified_job.cancel(job_explanation=job_explanation) - - assert unified_job.job_explanation == job_explanation - assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [ - ((), {'update_fields': ['cancel_flag', 'start_args', 'job_explanation']}), - ((), {'update_fields': ['status']}), - ] - - def test_organization_copy_to_jobs(): """ All unified job types should infer their organization from their template organization @@ -97,7 +50,7 @@ def test_job_metavars(self): maker = User(username='joe', pk=47, id=47) inv = Inventory(name='example-inv', id=45) result_hash = {} - for name in JOB_VARIABLE_PREFIXES: + for name in get_job_variable_prefixes(): result_hash['{}_job_id'.format(name)] = 42 result_hash['{}_job_launch_type'.format(name)] = 'manual' result_hash['{}_user_name'.format(name)] = 'joe' @@ -122,8 +75,48 @@ def test_project_update_metavars(self): project=Project(name='jobs-sync', scm_revision='12345444'), job_template=JobTemplate(name='jobs-jt', id=92, pk=92), ).awx_meta_vars() - for name in JOB_VARIABLE_PREFIXES: + for name in get_job_variable_prefixes(): assert data['{}_project_revision'.format(name)] == '12345444' assert '{}_job_template_id'.format(name) in data assert data['{}_job_template_id'.format(name)] == 92 assert data['{}_job_template_name'.format(name)] == 'jobs-jt' + + +class TestGetJobVariablePrefixes: + """Tests for the get_job_variable_prefixes() helper function.""" + + def test_default_returns_both(self): + from django.conf import settings + + with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', True, create=True): + assert get_job_variable_prefixes() == ['awx', 'tower'] + + def test_disabled_returns_tower_only(self): + from django.conf import settings + + with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', False, create=True): + assert get_job_variable_prefixes() == ['tower'] + + def test_fallback_when_setting_not_available(self): + """When setting is not available, falls back to both prefixes for backward compatibility.""" + fake_settings = mock.MagicMock(spec=[]) + with mock.patch('django.conf.settings', fake_settings): + assert get_job_variable_prefixes() == ['awx', 'tower'] + + def test_job_metavars_both_prefixes(self): + """With INCLUDE_DEPRECATED_AWX_VAR_PREFIX=True, both awx_ and tower_ variables.""" + from django.conf import settings + + with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', True, create=True): + data = Job(name='fake-job', pk=1, id=1, launch_type='manual').awx_meta_vars() + assert 'awx_job_id' in data + assert 'tower_job_id' in data + + def test_job_metavars_tower_only(self): + """With INCLUDE_DEPRECATED_AWX_VAR_PREFIX=False, only tower_ prefixed variables.""" + from django.conf import settings + + with mock.patch.object(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', False, create=True): + data = Job(name='fake-job', pk=1, id=1, launch_type='manual').awx_meta_vars() + assert 'tower_job_id' in data + assert 'awx_job_id' not in data diff --git a/awx/main/tests/unit/notifications/test_grafana.py b/awx/main/tests/unit/notifications/test_grafana.py index 70750e33150b..4e21e683ea50 100644 --- a/awx/main/tests/unit/notifications/test_grafana.py +++ b/awx/main/tests/unit/notifications/test_grafana.py @@ -10,10 +10,10 @@ def test_send_messages(): with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock: requests_mock.post.return_value.status_code = 200 m = {} - m['started'] = dt.datetime.utcfromtimestamp(60).isoformat() - m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat() + m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat() + m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat() m['subject'] = "test subject" - backend = grafana_backend.GrafanaBackend("testapikey") + backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='') message = EmailMessage( m['subject'], {"started": m['started'], "finished": m['finished']}, @@ -40,10 +40,10 @@ def test_send_messages_with_no_verify_ssl(): with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock: requests_mock.post.return_value.status_code = 200 m = {} - m['started'] = dt.datetime.utcfromtimestamp(60).isoformat() - m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat() + m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat() + m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat() m['subject'] = "test subject" - backend = grafana_backend.GrafanaBackend("testapikey", grafana_no_verify_ssl=True) + backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='', grafana_no_verify_ssl=True) message = EmailMessage( m['subject'], {"started": m['started'], "finished": m['finished']}, @@ -71,10 +71,10 @@ def test_send_messages_with_dashboardid(dashboardId): with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock: requests_mock.post.return_value.status_code = 200 m = {} - m['started'] = dt.datetime.utcfromtimestamp(60).isoformat() - m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat() + m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat() + m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat() m['subject'] = "test subject" - backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=dashboardId) + backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=dashboardId, panelId='') message = EmailMessage( m['subject'], {"started": m['started'], "finished": m['finished']}, @@ -97,15 +97,15 @@ def test_send_messages_with_dashboardid(dashboardId): assert sent_messages == 1 -@pytest.mark.parametrize("panelId", [42, 0]) +@pytest.mark.parametrize("panelId", ['42', '0']) def test_send_messages_with_panelid(panelId): with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock: requests_mock.post.return_value.status_code = 200 m = {} - m['started'] = dt.datetime.utcfromtimestamp(60).isoformat() - m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat() + m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat() + m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat() m['subject'] = "test subject" - backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=None, panelId=panelId) + backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId=panelId) message = EmailMessage( m['subject'], {"started": m['started'], "finished": m['finished']}, @@ -122,7 +122,7 @@ def test_send_messages_with_panelid(panelId): requests_mock.post.assert_called_once_with( 'https://example.com/api/annotations', headers={'Content-Type': 'application/json', 'Authorization': 'Bearer testapikey'}, - json={'text': 'test subject', 'isRegion': True, 'timeEnd': 120000, 'panelId': panelId, 'time': 60000}, + json={'text': 'test subject', 'isRegion': True, 'timeEnd': 120000, 'panelId': int(panelId), 'time': 60000}, verify=True, ) assert sent_messages == 1 @@ -132,10 +132,10 @@ def test_send_messages_with_bothids(): with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock: requests_mock.post.return_value.status_code = 200 m = {} - m['started'] = dt.datetime.utcfromtimestamp(60).isoformat() - m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat() + m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat() + m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat() m['subject'] = "test subject" - backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=42, panelId=42) + backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='42', panelId='42') message = EmailMessage( m['subject'], {"started": m['started'], "finished": m['finished']}, @@ -158,14 +158,44 @@ def test_send_messages_with_bothids(): assert sent_messages == 1 +def test_send_messages_with_emptyids(): + with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock: + requests_mock.post.return_value.status_code = 200 + m = {} + m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat() + m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat() + m['subject'] = "test subject" + backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='') + message = EmailMessage( + m['subject'], + {"started": m['started'], "finished": m['finished']}, + [], + [ + 'https://example.com', + ], + ) + sent_messages = backend.send_messages( + [ + message, + ] + ) + requests_mock.post.assert_called_once_with( + 'https://example.com/api/annotations', + headers={'Content-Type': 'application/json', 'Authorization': 'Bearer testapikey'}, + json={'text': 'test subject', 'isRegion': True, 'timeEnd': 120000, 'time': 60000}, + verify=True, + ) + assert sent_messages == 1 + + def test_send_messages_with_tags(): with mock.patch('awx.main.notifications.grafana_backend.requests') as requests_mock: requests_mock.post.return_value.status_code = 200 m = {} - m['started'] = dt.datetime.utcfromtimestamp(60).isoformat() - m['finished'] = dt.datetime.utcfromtimestamp(120).isoformat() + m['started'] = dt.datetime.fromtimestamp(60, tz=dt.timezone.utc).isoformat() + m['finished'] = dt.datetime.fromtimestamp(120, tz=dt.timezone.utc).isoformat() m['subject'] = "test subject" - backend = grafana_backend.GrafanaBackend("testapikey", dashboardId=None, panelId=None, annotation_tags=["ansible"]) + backend = grafana_backend.GrafanaBackend("testapikey", dashboardId='', panelId='', annotation_tags=["ansible"]) message = EmailMessage( m['subject'], {"started": m['started'], "finished": m['finished']}, diff --git a/awx/main/tests/unit/notifications/test_webhook.py b/awx/main/tests/unit/notifications/test_webhook.py index b2c92c59ab39..4abbf45b70ee 100644 --- a/awx/main/tests/unit/notifications/test_webhook.py +++ b/awx/main/tests/unit/notifications/test_webhook.py @@ -226,3 +226,140 @@ def test_send_messages_with_additional_headers(): allow_redirects=False, ) assert sent_messages == 1 + + +def test_send_messages_with_redirects_ok(): + with mock.patch('awx.main.notifications.webhook_backend.requests') as requests_mock, mock.patch( + 'awx.main.notifications.webhook_backend.get_awx_http_client_headers' + ) as version_mock: + # First two calls return redirects, third call returns 200 + requests_mock.post.side_effect = [ + mock.Mock(status_code=301, headers={"Location": "http://redirect1.com"}), + mock.Mock(status_code=307, headers={"Location": "http://redirect2.com"}), + mock.Mock(status_code=200), + ] + version_mock.return_value = {'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'} + backend = webhook_backend.WebhookBackend('POST', None) + message = EmailMessage( + 'test subject', + {'text': 'test body'}, + [], + [ + 'http://example.com', + ], + ) + sent_messages = backend.send_messages( + [ + message, + ] + ) + assert requests_mock.post.call_count == 3 + requests_mock.post.assert_called_with( + url='http://redirect2.com', + auth=None, + data=json.dumps({'text': 'test body'}, ensure_ascii=False).encode('utf-8'), + headers={'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'}, + verify=True, + allow_redirects=False, + ) + assert sent_messages == 1 + + +def test_send_messages_with_redirects_blank(): + with mock.patch('awx.main.notifications.webhook_backend.requests') as requests_mock, mock.patch( + 'awx.main.notifications.webhook_backend.get_awx_http_client_headers' + ) as version_mock, mock.patch('awx.main.notifications.webhook_backend.logger') as logger_mock: + # First call returns a redirect with Location header, second call returns 301 but NO Location header + requests_mock.post.side_effect = [ + mock.Mock(status_code=301, headers={"Location": "http://redirect1.com"}), + mock.Mock(status_code=301, headers={}), # 301 with no Location header + ] + version_mock.return_value = {'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'} + backend = webhook_backend.WebhookBackend('POST', None, fail_silently=True) + message = EmailMessage( + 'test subject', + {'text': 'test body'}, + [], + [ + 'http://example.com', + ], + ) + sent_messages = backend.send_messages( + [ + message, + ] + ) + # Should make 2 requests (initial + 1 redirect attempt) + assert requests_mock.post.call_count == 2 + # The error message should be logged + logger_mock.error.assert_called_once() + error_call_args = logger_mock.error.call_args[0][0] + assert "redirect to a blank URL" in error_call_args + assert sent_messages == 0 + + +def test_send_messages_with_redirects_max_retries_exceeded(): + with mock.patch('awx.main.notifications.webhook_backend.requests') as requests_mock, mock.patch( + 'awx.main.notifications.webhook_backend.get_awx_http_client_headers' + ) as version_mock, mock.patch('awx.main.notifications.webhook_backend.logger') as logger_mock: + # Return MAX_RETRIES (5) redirect responses to exceed the retry limit + requests_mock.post.side_effect = [ + mock.Mock(status_code=301, headers={"Location": "http://redirect1.com"}), + mock.Mock(status_code=301, headers={"Location": "http://redirect2.com"}), + mock.Mock(status_code=307, headers={"Location": "http://redirect3.com"}), + mock.Mock(status_code=301, headers={"Location": "http://redirect4.com"}), + mock.Mock(status_code=307, headers={"Location": "http://redirect5.com"}), + ] + version_mock.return_value = {'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'} + backend = webhook_backend.WebhookBackend('POST', None, fail_silently=True) + message = EmailMessage( + 'test subject', + {'text': 'test body'}, + [], + [ + 'http://example.com', + ], + ) + sent_messages = backend.send_messages( + [ + message, + ] + ) + # Should make exactly 5 requests (MAX_RETRIES) + assert requests_mock.post.call_count == 5 + # The error message should be logged for exceeding max retries + logger_mock.error.assert_called_once() + error_call_args = logger_mock.error.call_args[0][0] + assert "max number of retries" in error_call_args + assert "[5]" in error_call_args + assert sent_messages == 0 + + +def test_send_messages_with_error_status_code(): + with mock.patch('awx.main.notifications.webhook_backend.requests') as requests_mock, mock.patch( + 'awx.main.notifications.webhook_backend.get_awx_http_client_headers' + ) as version_mock, mock.patch('awx.main.notifications.webhook_backend.logger') as logger_mock: + # Return a 404 error status code + requests_mock.post.return_value = mock.Mock(status_code=404) + version_mock.return_value = {'Content-Type': 'application/json', 'User-Agent': 'AWX 0.0.1.dev (open)'} + backend = webhook_backend.WebhookBackend('POST', None, fail_silently=True) + message = EmailMessage( + 'test subject', + {'text': 'test body'}, + [], + [ + 'http://example.com', + ], + ) + sent_messages = backend.send_messages( + [ + message, + ] + ) + # Should make exactly 1 request + assert requests_mock.post.call_count == 1 + # The error message should be logged + logger_mock.error.assert_called_once() + error_call_args = logger_mock.error.call_args[0][0] + assert "Error sending webhook notification: 404" in error_call_args + assert sent_messages == 0 diff --git a/awx/main/tests/unit/scheduler/test_dag_simple.py b/awx/main/tests/unit/scheduler/test_dag_simple.py index 4bb141815754..1cea21e92d27 100644 --- a/awx/main/tests/unit/scheduler/test_dag_simple.py +++ b/awx/main/tests/unit/scheduler/test_dag_simple.py @@ -39,6 +39,6 @@ def simple_cycle_1(node_generator): def test_has_cycle(simple_cycle_1): - (g, nodes) = simple_cycle_1 + g, nodes = simple_cycle_1 assert g.has_cycle() is True diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index a3225b76a3e7..b4681e6b90a8 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -86,13 +86,13 @@ def workflow_dag_root_children(self, wf_node_generator): return (g, wf_root_nodes, wf_leaf_nodes) def test_get_root_nodes(self, workflow_dag_root_children): - (g, wf_root_nodes, ignore) = workflow_dag_root_children + g, wf_root_nodes, ignore = workflow_dag_root_children assert set([n.id for n in wf_root_nodes]) == set([n['node_object'].id for n in g.get_root_nodes()]) class TestDNR: def test_mark_dnr_nodes(self, workflow_dag_1): - (g, nodes) = workflow_dag_1 + g, nodes = workflow_dag_1 r''' 0 @@ -166,7 +166,7 @@ def simple_all_convergence(self, wf_node_generator): return (g, nodes) def test_simple_all_convergence(self, simple_all_convergence): - (g, nodes) = simple_all_convergence + g, nodes = simple_all_convergence dnr_nodes = g.mark_dnr_nodes() assert 0 == len(dnr_nodes), "no nodes should be marked DNR" @@ -197,7 +197,7 @@ def workflow_all_converge_1(self, wf_node_generator): return (g, nodes) def test_all_converge_edge_case_1(self, workflow_all_converge_1): - (g, nodes) = workflow_all_converge_1 + g, nodes = workflow_all_converge_1 dnr_nodes = g.mark_dnr_nodes() assert 2 == len(dnr_nodes), "node[1] and node[2] should be marked DNR" assert nodes[1] == dnr_nodes[0], "Node 1 should be marked DNR" @@ -233,7 +233,7 @@ def workflow_all_converge_2(self, wf_node_generator): return (g, nodes) def test_all_converge_edge_case_2(self, workflow_all_converge_2): - (g, nodes) = workflow_all_converge_2 + g, nodes = workflow_all_converge_2 dnr_nodes = g.mark_dnr_nodes() assert 1 == len(dnr_nodes), "1 and only 1 node should be marked DNR" assert nodes[2] == dnr_nodes[0], "Node 3 should be marked DNR" @@ -268,7 +268,7 @@ def workflow_all_converge_will_run(self, wf_node_generator): return (g, nodes) def test_workflow_all_converge_will_run(self, workflow_all_converge_will_run): - (g, nodes) = workflow_all_converge_will_run + g, nodes = workflow_all_converge_will_run dnr_nodes = g.mark_dnr_nodes() assert 0 == len(dnr_nodes), "No nodes should get marked DNR" @@ -306,7 +306,7 @@ def workflow_all_converge_dnr(self, wf_node_generator): return (g, nodes) def test_workflow_all_converge_while_parent_runs(self, workflow_all_converge_dnr): - (g, nodes) = workflow_all_converge_dnr + g, nodes = workflow_all_converge_dnr dnr_nodes = g.mark_dnr_nodes() assert 0 == len(dnr_nodes), "No nodes should get marked DNR" @@ -315,7 +315,7 @@ def test_workflow_all_converge_while_parent_runs(self, workflow_all_converge_dnr def test_workflow_all_converge_with_incorrect_parent(self, workflow_all_converge_dnr): # Another tick of the scheduler - (g, nodes) = workflow_all_converge_dnr + g, nodes = workflow_all_converge_dnr nodes[1].job.status = 'successful' dnr_nodes = g.mark_dnr_nodes() assert 1 == len(dnr_nodes), "1 and only 1 node should be marked DNR" @@ -326,7 +326,7 @@ def test_workflow_all_converge_with_incorrect_parent(self, workflow_all_converge def test_workflow_all_converge_runs(self, workflow_all_converge_dnr): # Trick the scheduler again to make sure the convergence node acutally runs - (g, nodes) = workflow_all_converge_dnr + g, nodes = workflow_all_converge_dnr nodes[1].job.status = 'failed' dnr_nodes = g.mark_dnr_nodes() assert 0 == len(dnr_nodes), "No nodes should be marked DNR" @@ -375,7 +375,7 @@ def workflow_all_converge_deep_dnr_tree(self, wf_node_generator): return (g, nodes) def test_workflow_all_converge_deep_dnr_tree(self, workflow_all_converge_deep_dnr_tree): - (g, nodes) = workflow_all_converge_deep_dnr_tree + g, nodes = workflow_all_converge_deep_dnr_tree dnr_nodes = g.mark_dnr_nodes() assert 4 == len(dnr_nodes), "All nodes w/ no jobs should be marked DNR" @@ -391,7 +391,7 @@ def test_workflow_all_converge_deep_dnr_tree(self, workflow_all_converge_deep_dn class TestIsWorkflowDone: @pytest.fixture def workflow_dag_2(self, workflow_dag_1): - (g, nodes) = workflow_dag_1 + g, nodes = workflow_dag_1 r''' S0 /\ @@ -416,7 +416,7 @@ def workflow_dag_2(self, workflow_dag_1): @pytest.fixture def workflow_dag_failed(self, workflow_dag_1): - (g, nodes) = workflow_dag_1 + g, nodes = workflow_dag_1 r''' S0 /\ @@ -453,7 +453,7 @@ def workflow_dag_canceled(self, wf_node_generator): @pytest.fixture def workflow_dag_failure(self, workflow_dag_canceled): - (g, nodes) = workflow_dag_canceled + g, nodes = workflow_dag_canceled nodes[0].job.status = 'failed' return (g, nodes) @@ -463,7 +463,7 @@ def test_done(self, workflow_dag_2): assert g.is_workflow_done() is False def test_workflow_done_and_failed(self, workflow_dag_failed): - (g, nodes) = workflow_dag_failed + g, nodes = workflow_dag_failed assert g.is_workflow_done() is True assert g.has_workflow_failed() == ( @@ -477,7 +477,7 @@ def test_workflow_done_and_failed(self, workflow_dag_failed): ) def test_is_workflow_done_no_unified_job_tempalte_end(self, workflow_dag_failed): - (g, nodes) = workflow_dag_failed + g, nodes = workflow_dag_failed nodes[2].unified_job_template = None @@ -492,7 +492,7 @@ def test_is_workflow_done_no_unified_job_tempalte_end(self, workflow_dag_failed) ) def test_is_workflow_done_no_unified_job_tempalte_begin(self, workflow_dag_1): - (g, nodes) = workflow_dag_1 + g, nodes = workflow_dag_1 nodes[0].unified_job_template = None g.mark_dnr_nodes() @@ -508,7 +508,7 @@ def test_is_workflow_done_no_unified_job_tempalte_begin(self, workflow_dag_1): ) def test_canceled_should_fail(self, workflow_dag_canceled): - (g, nodes) = workflow_dag_canceled + g, nodes = workflow_dag_canceled assert g.has_workflow_failed() == ( True, @@ -521,7 +521,7 @@ def test_canceled_should_fail(self, workflow_dag_canceled): ) def test_failure_should_fail(self, workflow_dag_failure): - (g, nodes) = workflow_dag_failure + g, nodes = workflow_dag_failure assert g.has_workflow_failed() == ( True, @@ -555,13 +555,13 @@ def workflow_dag_canceled(self, wf_node_generator): return (g, nodes) def test_cancel_still_runs_children(self, workflow_dag_canceled): - (g, nodes) = workflow_dag_canceled + g, nodes = workflow_dag_canceled g.mark_dnr_nodes() assert set([nodes[1], nodes[2]]) == set(g.bfs_nodes_to_run()) -@pytest.mark.skip(reason="Run manually to re-generate doc images") +@pytest.mark.xfail(reason="Run manually to re-generate doc images") class TestDocsExample: @pytest.fixture def complex_dag(self, wf_node_generator): @@ -587,7 +587,7 @@ def complex_dag(self, wf_node_generator): return (g, nodes) def test_dnr_step(self, complex_dag): - (g, nodes) = complex_dag + g, nodes = complex_dag base_dir = '/awx_devel' g.generate_graphviz_plot(file_name=os.path.join(base_dir, "workflow_step0.gv")) diff --git a/awx/main/tests/unit/settings/test_defaults.py b/awx/main/tests/unit/settings/test_defaults.py index a7f5eeeca8db..10cb5561a7f4 100644 --- a/awx/main/tests/unit/settings/test_defaults.py +++ b/awx/main/tests/unit/settings/test_defaults.py @@ -1,20 +1,19 @@ import pytest from django.conf import settings -from datetime import timedelta @pytest.mark.parametrize( - "job_name,function_path", + "task_name", [ - ('tower_scheduler', 'awx.main.tasks.system.awx_periodic_scheduler'), + 'awx.main.tasks.system.awx_periodic_scheduler', ], ) -def test_CELERYBEAT_SCHEDULE(mocker, job_name, function_path): - assert job_name in settings.CELERYBEAT_SCHEDULE - assert 'schedule' in settings.CELERYBEAT_SCHEDULE[job_name] - assert type(settings.CELERYBEAT_SCHEDULE[job_name]['schedule']) is timedelta - assert settings.CELERYBEAT_SCHEDULE[job_name]['task'] == function_path +def test_DISPATCHER_SCHEDULE(mocker, task_name): + assert task_name in settings.DISPATCHER_SCHEDULE + assert 'schedule' in settings.DISPATCHER_SCHEDULE[task_name] + assert type(settings.DISPATCHER_SCHEDULE[task_name]['schedule']) in (int, float) + assert settings.DISPATCHER_SCHEDULE[task_name]['task'] == task_name # Ensures that the function exists - mocker.patch(function_path) + mocker.patch(task_name) diff --git a/awx/main/tests/unit/tasks/test_host_indirect_unit.py b/awx/main/tests/unit/tasks/test_host_indirect_unit.py new file mode 100644 index 000000000000..2b128ca6fafe --- /dev/null +++ b/awx/main/tests/unit/tasks/test_host_indirect_unit.py @@ -0,0 +1,56 @@ +import copy + +import pytest + +from awx.main.tasks.host_indirect import get_hashable_form + + +class TestHashableForm: + @pytest.mark.parametrize( + 'data', + [ + {'a': 'b'}, + ['a', 'b'], + ('a', 'b'), + {'a': {'b': 'c'}}, + {'a': ['b', 'c']}, + {'a': ('b', 'c')}, + ['a', ['b', 'c']], + ['a', ('b', 'c')], + ['a', {'b': 'c'}], + ], + ) + def test_compare_equal_data(self, data): + other_data = copy.deepcopy(data) + # A tuple of scalars may be cached so ids could legitimately be the same + if data != ('a', 'b'): + assert id(data) != id(other_data) # sanity + assert id(get_hashable_form(data)) != id(get_hashable_form(data)) + + assert get_hashable_form(data) == get_hashable_form(data) + assert hash(get_hashable_form(data)) == hash(get_hashable_form(data)) + + assert get_hashable_form(data) in {get_hashable_form(data): 1} # test lookup hit + + @pytest.mark.parametrize( + 'data, other_data', + [ + [{'a': 'b'}, {'a': 'c'}], + [{'a': 'b'}, {'a': 'b', 'c': 'd'}], + [['a', 'b'], ['a', 'c']], + [('a', 'b'), ('a', 'c')], + [{'a': {'b': 'c'}}, {'a': {'b': 'd'}}], + [{'a': ['b', 'c']}, {'a': ['b', 'd']}], + [{'a': ('b', 'c')}, {'a': ('b', 'd')}], + [['a', ['b', 'c']], ['a', ['b', 'd']]], + [['a', ('b', 'c')], ['a', ('b', 'd')]], + [['a', {'b': 'c'}], ['a', {'b': 'd'}]], + ], + ) + def test_compare_different_data(self, data, other_data): + assert data != other_data # sanity, otherwise why test this? + assert get_hashable_form(data) != get_hashable_form(other_data) + assert hash(get_hashable_form(data)) != hash(get_hashable_form(other_data)) + + assert get_hashable_form(other_data) not in {get_hashable_form(data): 1} # test lookup miss + assert get_hashable_form(data) not in {get_hashable_form(other_data): 1} diff --git a/awx/main/tests/unit/tasks/test_jobs.py b/awx/main/tests/unit/tasks/test_jobs.py new file mode 100644 index 000000000000..0fc0fa98b6d9 --- /dev/null +++ b/awx/main/tests/unit/tasks/test_jobs.py @@ -0,0 +1,663 @@ +# -*- coding: utf-8 -*- +import pytest +from unittest import mock + +from awx.main.models import ( + Inventory, + Host, +) + +from django.utils.timezone import now +from django.db.models.query import QuerySet + +from awx.main.models import ( + Job, + Organization, + Project, + JobTemplate, + UnifiedJobTemplate, + InstanceGroup, + ExecutionEnvironment, + ProjectUpdate, + InventoryUpdate, + InventorySource, + AdHocCommand, +) +from awx.main.tasks import jobs +from ansible_base.lib.workload_identity.controller import AutomationControllerJobScope + + +@pytest.fixture +def private_data_dir(tmp_path): + private_data = tmp_path / 'awx_pdd' + private_data.mkdir() + for subfolder in ('inventory', 'env'): + (private_data / subfolder).mkdir() + return str(private_data) + + +@pytest.fixture +def job_template_with_credentials(): + """ + Factory fixture that creates a job template with specified credentials. + + Usage: + job = job_template_with_credentials(ssh_cred, vault_cred) + """ + + def _create_job_template( + *credentials, org_name='test-org', project_name='test-project', inventory_name='test-inventory', jt_name='test-jt', playbook='test.yml' + ): + """ + Create a job template with the given credentials. + + Args: + *credentials: Variable number of Credential objects to attach to the job template + org_name: Name for the organization + project_name: Name for the project + inventory_name: Name for the inventory + jt_name: Name for the job template + playbook: Playbook filename + + Returns: + Job instance created from the job template + """ + org = Organization.objects.create(name=org_name) + proj = Project.objects.create(name=project_name, organization=org) + inv = Inventory.objects.create(name=inventory_name, organization=org) + jt = JobTemplate.objects.create(name=jt_name, project=proj, inventory=inv, playbook=playbook) + + if credentials: + jt.credentials.add(*credentials) + + return jt.create_unified_job() + + return _create_job_template + + +@mock.patch('awx.main.tasks.facts.settings') +@mock.patch('awx.main.tasks.jobs.create_partition', return_value=True) +def test_pre_post_run_hook_facts(mock_create_partition, mock_facts_settings, private_data_dir, execution_environment): + # Create mocked inventory and host queryset + inventory = mock.MagicMock(spec=Inventory, pk=1, kind='') + host1 = mock.MagicMock(spec=Host, id=1, name='host1', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=now(), inventory=inventory) + host2 = mock.MagicMock(spec=Host, id=2, name='host2', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=now(), inventory=inventory) + + # Mock hosts queryset — must support .only().filter().order_by().iterator() chain + hosts = [host1, host2] + qs_hosts = mock.MagicMock(spec=QuerySet) + qs_hosts._result_cache = hosts + qs_hosts.__iter__ = lambda self: iter(self._result_cache) + qs_hosts.only.return_value = qs_hosts + qs_hosts.filter.return_value = qs_hosts + qs_hosts.order_by.return_value = qs_hosts + qs_hosts.iterator.side_effect = lambda: iter(qs_hosts._result_cache) + qs_hosts.count.side_effect = lambda: len(qs_hosts._result_cache) + inventory.hosts = qs_hosts + + # Create mocked job object + org = mock.MagicMock(spec=Organization, pk=1) + proj = mock.MagicMock(spec=Project, pk=1, organization=org) + job = mock.MagicMock( + spec=Job, + pk=1, + id=1, + use_fact_cache=True, + project=proj, + organization=org, + job_slice_number=1, + job_slice_count=1, + inventory=inventory, + inventory_id=inventory.pk, + created=now(), + execution_environment=execution_environment, + ) + job.get_hosts_for_fact_cache = Job.get_hosts_for_fact_cache.__get__(job) + job.job_env.get = mock.MagicMock(return_value=private_data_dir) + + # Mock RunJob task + mock_facts_settings.ANSIBLE_FACT_CACHE_TIMEOUT = False + task = jobs.RunJob() + task.instance = job + task.update_model = mock.Mock(return_value=job) + task.model.objects.get = mock.Mock(return_value=job) + + # Run pre_run_hook + task.facts_write_time = task.pre_run_hook(job, private_data_dir) + + # Add a third mocked host + host3 = mock.MagicMock(spec=Host, id=3, name='host3', ansible_facts={"added": True}, ansible_facts_modified=now(), inventory=inventory) + qs_hosts._result_cache.append(host3) + assert inventory.hosts.count() == 3 + + # Run post_run_hook + task.runner_callback.artifacts_processed = mock.MagicMock(return_value=True) + task.post_run_hook(job, "success") + + # Verify final host facts + assert qs_hosts._result_cache[2].ansible_facts == {"added": True} + + +@mock.patch('awx.main.tasks.facts.bulk_update_sorted_by_id') +@mock.patch('awx.main.tasks.facts.settings') +@mock.patch('awx.main.tasks.jobs.create_partition', return_value=True) +def test_pre_post_run_hook_facts_deleted_sliced( + mock_create_partition, mock_facts_settings, mock_bulk_update_sorted_by_id, private_data_dir, execution_environment +): + # Fully mocked inventory + mock_inventory = mock.MagicMock(spec=Inventory, pk=1, kind='') + + # Create 999 mocked Host instances + hosts = [] + for i in range(999): + host = mock.MagicMock(spec=Host) + host.id = i + host.name = f'host{i}' + host.ansible_facts = {"a": 1, "b": 2} + host.ansible_facts_modified = now() + host.inventory = mock_inventory + hosts.append(host) + + # Mock inventory.hosts behavior — must support .only().filter().order_by().iterator() chain + mock_qs_hosts = mock.MagicMock() + mock_qs_hosts.only.return_value = mock_qs_hosts + mock_qs_hosts.filter.return_value = mock_qs_hosts + mock_qs_hosts.order_by.return_value = mock_qs_hosts + mock_qs_hosts.iterator.side_effect = lambda: iter(hosts) + mock_qs_hosts.count.return_value = 999 + mock_inventory.hosts = mock_qs_hosts + + # Mock Organization and Project + org = mock.MagicMock(spec=Organization) + proj = mock.MagicMock(spec=Project) + proj.organization = org + + # Mock job object + job = mock.MagicMock(spec=Job) + job.pk = 2 + job.id = 2 + job.use_fact_cache = True + job.project = proj + job.organization = org + job.job_slice_number = 1 + job.job_slice_count = 3 + job.execution_environment = execution_environment + job.inventory = mock_inventory + job.inventory_id = mock_inventory.pk + job.created = now() + job.job_env.get.return_value = private_data_dir + + # Bind actual method for host filtering + job.get_hosts_for_fact_cache = Job.get_hosts_for_fact_cache.__get__(job) + + # Mock task instance + mock_facts_settings.ANSIBLE_FACT_CACHE_TIMEOUT = False + task = jobs.RunJob() + task.instance = job + task.update_model = mock.Mock(return_value=job) + task.model.objects.get = mock.Mock(return_value=job) + + # Call pre_run_hook + task.facts_write_time = task.pre_run_hook(job, private_data_dir) + + # Simulate one host deletion + hosts.pop(1) + mock_qs_hosts.count.return_value = 998 + + # Call post_run_hook + task.runner_callback.artifacts_processed = mock.MagicMock(return_value=True) + task.post_run_hook(job, "success") + + # Assert that ansible_facts were preserved + for host in hosts: + assert host.ansible_facts == {"a": 1, "b": 2} + + # Add expected failure cases + failures = [] + for host in hosts: + try: + assert host.ansible_facts == {"a": 1, "b": 2, "unexpected_key": "bad"} + except AssertionError: + failures.append(f"Host named {host.name} has facts {host.ansible_facts}") + + assert len(failures) > 0, f"Failures occurred for the following hosts: {failures}" + + +@mock.patch('awx.main.tasks.facts.bulk_update_sorted_by_id') +@mock.patch('awx.main.tasks.facts.settings') +def test_invalid_host_facts(mock_facts_settings, bulk_update_sorted_by_id, private_data_dir, execution_environment): + inventory = Inventory(pk=1) + mock_inventory = mock.MagicMock(spec=Inventory, wraps=inventory) + mock_inventory._state = mock.MagicMock() + + hosts = [ + Host(id=0, name='host0', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=now(), inventory=mock_inventory), + Host(id=1, name='host1', ansible_facts={"a": 1, "b": 2, "unexpected_key": "bad"}, ansible_facts_modified=now(), inventory=mock_inventory), + ] + mock_inventory.hosts = hosts + + failures = [] + for host in mock_inventory.hosts: + assert "a" in host.ansible_facts + if "unexpected_key" in host.ansible_facts: + failures.append(host.name) + + mock_facts_settings.SOME_SETTING = True + bulk_update_sorted_by_id(Host, mock_inventory.hosts, fields=['ansible_facts']) + + with pytest.raises(pytest.fail.Exception): + if failures: + pytest.fail(f" {len(failures)} facts cleared failures : {','.join(failures)}") + + +@pytest.mark.parametrize( + "job_attrs,expected_claims", + [ + ( + { + 'id': 100, + 'name': 'Test Job', + 'job_type': 'run', + 'launch_type': 'manual', + 'playbook': 'site.yml', + 'organization': Organization(id=1, name='Test Org'), + 'inventory': Inventory(id=2, name='Test Inventory'), + 'project': Project(id=3, name='Test Project'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'job_template': JobTemplate(id=5, name='Test Job Template'), + 'unified_job_template': UnifiedJobTemplate(pk=6, id=6, name='Test Unified Job Template'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 100, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Test Job', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME: 'site.yml', + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_INVENTORY_NAME: 'Test Inventory', + AutomationControllerJobScope.CLAIM_INVENTORY_ID: 2, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_PROJECT_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_PROJECT_ID: 3, + AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_NAME: 'Test Job Template', + AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_ID: 5, + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_NAME: 'Test Unified Job Template', + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_ID: 6, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + {'id': 100, 'name': 'Test', 'job_type': 'run', 'launch_type': 'manual', 'organization': Organization(id=1, name='')}, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 100, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Test', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: '', + AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME: '', + }, + ), + ], +) +def test_populate_claims_for_workload(job_attrs, expected_claims): + job = Job() + + for attr, value in job_attrs.items(): + setattr(job, attr, value) + + claims = jobs.populate_claims_for_workload(job) + assert claims == expected_claims + + +@pytest.mark.parametrize( + "workload_attrs,expected_claims", + [ + ( + { + 'id': 200, + 'name': 'Git Sync', + 'job_type': 'check', + 'launch_type': 'sync', + 'organization': Organization(id=1, name='Test Org'), + 'project': Project(pk=3, id=3, name='Test Project'), + 'unified_job_template': Project(pk=3, id=3, name='Test Project'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 200, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Git Sync', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'check', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'sync', + AutomationControllerJobScope.CLAIM_LAUNCHED_BY_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_LAUNCHED_BY_ID: 3, + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_PROJECT_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_PROJECT_ID: 3, + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_NAME: 'Test Project', + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_ID: 3, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + { + 'id': 201, + 'name': 'Minimal Project Update', + 'job_type': 'run', + 'launch_type': 'manual', + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 201, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Minimal Project Update', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + }, + ), + ], +) +def test_populate_claims_for_project_update(workload_attrs, expected_claims): + project_update = ProjectUpdate() + for attr, value in workload_attrs.items(): + setattr(project_update, attr, value) + + claims = jobs.populate_claims_for_workload(project_update) + assert claims == expected_claims + + +@pytest.mark.parametrize( + "workload_attrs,expected_claims", + [ + ( + { + 'id': 300, + 'name': 'AWS Sync', + 'launch_type': 'scheduled', + 'organization': Organization(id=1, name='Test Org'), + 'inventory': Inventory(id=2, name='AWS Inventory'), + 'unified_job_template': InventorySource(pk=8, id=8, name='AWS Source'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 300, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'AWS Sync', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'scheduled', + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_INVENTORY_NAME: 'AWS Inventory', + AutomationControllerJobScope.CLAIM_INVENTORY_ID: 2, + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_NAME: 'AWS Source', + AutomationControllerJobScope.CLAIM_UNIFIED_JOB_TEMPLATE_ID: 8, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + { + 'id': 301, + 'name': 'Minimal Inventory Update', + 'launch_type': 'manual', + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 301, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Minimal Inventory Update', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + }, + ), + ], +) +def test_populate_claims_for_inventory_update(workload_attrs, expected_claims): + inventory_update = InventoryUpdate() + for attr, value in workload_attrs.items(): + setattr(inventory_update, attr, value) + + claims = jobs.populate_claims_for_workload(inventory_update) + assert claims == expected_claims + + +@pytest.mark.parametrize( + "workload_attrs,expected_claims", + [ + ( + { + 'id': 400, + 'name': 'Ping All Hosts', + 'job_type': 'run', + 'launch_type': 'manual', + 'organization': Organization(id=1, name='Test Org'), + 'inventory': Inventory(id=2, name='Test Inventory'), + 'execution_environment': ExecutionEnvironment(id=4, name='Test EE'), + 'instance_group': InstanceGroup(id=7, name='Test Instance Group'), + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 400, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Ping All Hosts', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: 'Test Org', + AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: 1, + AutomationControllerJobScope.CLAIM_INVENTORY_NAME: 'Test Inventory', + AutomationControllerJobScope.CLAIM_INVENTORY_ID: 2, + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_NAME: 'Test EE', + AutomationControllerJobScope.CLAIM_EXECUTION_ENVIRONMENT_ID: 4, + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_NAME: 'Test Instance Group', + AutomationControllerJobScope.CLAIM_INSTANCE_GROUP_ID: 7, + }, + ), + ( + { + 'id': 401, + 'name': 'Minimal Ad Hoc', + 'job_type': 'run', + 'launch_type': 'manual', + }, + { + AutomationControllerJobScope.CLAIM_JOB_ID: 401, + AutomationControllerJobScope.CLAIM_JOB_NAME: 'Minimal Ad Hoc', + AutomationControllerJobScope.CLAIM_JOB_TYPE: 'run', + AutomationControllerJobScope.CLAIM_LAUNCH_TYPE: 'manual', + }, + ), + ], +) +def test_populate_claims_for_adhoc_command(workload_attrs, expected_claims): + adhoc_command = AdHocCommand() + for attr, value in workload_attrs.items(): + setattr(adhoc_command, attr, value) + + claims = jobs.populate_claims_for_workload(adhoc_command) + assert claims == expected_claims + + +@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client') +def test_retrieve_workload_identity_jwt_returns_jwt_from_client(mock_get_client): + """retrieve_workload_identity_jwt returns the JWT string from the client.""" + mock_client = mock.MagicMock() + mock_response = mock.MagicMock() + mock_response.jwt = 'eyJ.test.jwt' + mock_client.request_workload_jwt.return_value = mock_response + mock_get_client.return_value = mock_client + + unified_job = Job() + unified_job.id = 42 + unified_job.name = 'Test Job' + unified_job.launch_type = 'manual' + unified_job.organization = Organization(id=1, name='Test Org') + unified_job.unified_job_template = None + unified_job.instance_group = None + + result = jobs.retrieve_workload_identity_jwt(unified_job, audience='https://api.example.com', scope='aap_controller_automation_job') + + assert result == 'eyJ.test.jwt' + mock_client.request_workload_jwt.assert_called_once() + call_kwargs = mock_client.request_workload_jwt.call_args[1] + assert call_kwargs['audience'] == 'https://api.example.com' + assert call_kwargs['scope'] == 'aap_controller_automation_job' + assert 'claims' in call_kwargs + assert call_kwargs['claims'][AutomationControllerJobScope.CLAIM_JOB_ID] == 42 + assert call_kwargs['claims'][AutomationControllerJobScope.CLAIM_JOB_NAME] == 'Test Job' + + +@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client') +def test_retrieve_workload_identity_jwt_passes_audience_and_scope(mock_get_client): + """retrieve_workload_identity_jwt passes audience and scope to the client.""" + mock_client = mock.MagicMock() + mock_client.request_workload_jwt.return_value = mock.MagicMock(jwt='token') + mock_get_client.return_value = mock_client + + unified_job = mock.MagicMock() + audience = 'custom_audience' + scope = 'custom_scope' + with mock.patch('awx.main.tasks.jobs.populate_claims_for_workload', return_value={'job_id': 1}): + jobs.retrieve_workload_identity_jwt(unified_job, audience=audience, scope=scope) + + mock_client.request_workload_jwt.assert_called_once_with(claims={'job_id': 1}, scope=scope, audience=audience) + + +@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client') +def test_retrieve_workload_identity_jwt_passes_workload_ttl(mock_get_client): + """retrieve_workload_identity_jwt passes workload_ttl_seconds when provided.""" + mock_client = mock.Mock() + mock_client.request_workload_jwt.return_value = mock.Mock(jwt='token') + mock_get_client.return_value = mock_client + + unified_job = mock.MagicMock() + with mock.patch('awx.main.tasks.jobs.populate_claims_for_workload', return_value={'job_id': 1}): + jobs.retrieve_workload_identity_jwt( + unified_job, + audience='https://vault.example.com', + scope='aap_controller_automation_job', + workload_ttl_seconds=3600, + ) + + mock_client.request_workload_jwt.assert_called_once_with( + claims={'job_id': 1}, + scope='aap_controller_automation_job', + audience='https://vault.example.com', + workload_ttl_seconds=3600, + ) + + +@mock.patch('awx.main.utils.workload_identity.get_workload_identity_client') +def test_retrieve_workload_identity_jwt_raises_when_client_not_configured(mock_get_client): + """retrieve_workload_identity_jwt raises RuntimeError when client is None.""" + mock_get_client.return_value = None + + unified_job = mock.MagicMock() + + with pytest.raises(RuntimeError, match="Workload identity client is not configured"): + jobs.retrieve_workload_identity_jwt(unified_job, audience='test_audience', scope='test_scope') + + +@pytest.mark.parametrize('effective_timeout,expected_ttl', [(3600, 3600), (0, None)]) +@mock.patch('awx.main.tasks.jobs.retrieve_workload_identity_jwt') +@mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=True) +def test_populate_workload_identity_tokens_passes_get_instance_timeout_to_client(mock_flag_enabled, mock_retrieve_jwt, effective_timeout, expected_ttl): + """populate_workload_identity_tokens passes get_instance_timeout() value as workload_ttl_seconds to retrieve_workload_identity_jwt.""" + mock_retrieve_jwt.return_value = 'eyJ.test.jwt' + + task = jobs.RunJob() + task.instance = mock.MagicMock() + + # Minimal credential with workload identity input source + credential_ctx = {} + input_src = mock.MagicMock() + input_src.pk = 1 + input_src.source_credential = mock.MagicMock() + input_src.source_credential.get_input.return_value = 'https://vault.example.com' + input_src.source_credential.name = 'vault-cred' + input_src.source_credential.credential_type = mock.MagicMock() + input_src.source_credential.credential_type.inputs = {'fields': [{'id': 'workload_identity_token', 'internal': True}]} + + credential = mock.MagicMock() + credential.context = credential_ctx + credential.input_sources = mock.MagicMock() + credential.input_sources.all.return_value = [input_src] + + task._credentials = [credential] + + with mock.patch.object(task, 'get_instance_timeout', return_value=effective_timeout): + task.populate_workload_identity_tokens() + + mock_flag_enabled.assert_called_once_with("FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED") + mock_retrieve_jwt.assert_called_once_with( + task.instance, + audience='https://vault.example.com', + scope=AutomationControllerJobScope.name, + workload_ttl_seconds=expected_ttl, + ) + + +class TestRunInventoryUpdatePopulateWorkloadIdentityTokens: + """Tests for RunInventoryUpdate.populate_workload_identity_tokens.""" + + def test_cloud_credential_passed_as_additional_credential(self): + """The cloud credential is forwarded to super().populate_workload_identity_tokens via additional_credentials.""" + cloud_cred = mock.MagicMock(name='cloud_cred') + cloud_cred.context = {} + + task = jobs.RunInventoryUpdate() + task.instance = mock.MagicMock() + task.instance.get_cloud_credential.return_value = cloud_cred + task._credentials = [] + + with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens') as mock_super: + task.populate_workload_identity_tokens() + + mock_super.assert_called_once_with(additional_credentials=[cloud_cred]) + + def test_no_cloud_credential_calls_super_with_none(self): + """When there is no cloud credential, super() is called with additional_credentials=None.""" + task = jobs.RunInventoryUpdate() + task.instance = mock.MagicMock() + task.instance.get_cloud_credential.return_value = None + task._credentials = [] + + with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens') as mock_super: + task.populate_workload_identity_tokens() + + mock_super.assert_called_once_with(additional_credentials=None) + + def test_additional_credentials_combined_with_cloud_credential(self): + """Caller-supplied additional_credentials are combined with the cloud credential.""" + cloud_cred = mock.MagicMock(name='cloud_cred') + cloud_cred.context = {} + extra_cred = mock.MagicMock(name='extra_cred') + + task = jobs.RunInventoryUpdate() + task.instance = mock.MagicMock() + task.instance.get_cloud_credential.return_value = cloud_cred + task._credentials = [] + + with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens') as mock_super: + task.populate_workload_identity_tokens(additional_credentials=[extra_cred]) + + mock_super.assert_called_once_with(additional_credentials=[extra_cred, cloud_cred]) + + def test_cloud_credential_override_after_context_set(self): + """After OIDC processing, get_cloud_credential is overridden on the instance when context is populated.""" + cloud_cred = mock.MagicMock(name='cloud_cred') + # Simulate that super().populate_workload_identity_tokens populates context + cloud_cred.context = {'workload_identity_token': 'eyJ.test.jwt'} + + task = jobs.RunInventoryUpdate() + task.instance = mock.MagicMock() + task.instance.get_cloud_credential.return_value = cloud_cred + task._credentials = [] + + with mock.patch.object(jobs.BaseTask, 'populate_workload_identity_tokens'): + task.populate_workload_identity_tokens() + + # The instance's get_cloud_credential should now return the same object with context + assert task.instance.get_cloud_credential() is cloud_cred diff --git a/awx/main/tests/unit/tasks/test_runner_callback.py b/awx/main/tests/unit/tasks/test_runner_callback.py index fb04842e10a0..54c964cc1c7c 100644 --- a/awx/main/tests/unit/tasks/test_runner_callback.py +++ b/awx/main/tests/unit/tasks/test_runner_callback.py @@ -1,4 +1,9 @@ -from awx.main.tasks.callback import RunnerCallback +import json +import os +import tempfile +from unittest import mock + +from awx.main.tasks.callback import RunnerCallback, try_load_query_file from awx.main.constants import ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE from django.utils.translation import gettext_lazy as _ @@ -50,3 +55,102 @@ def test_special_ansible_runner_message(mock_me): 'Traceback:\ngot an unexpected keyword argument\nFile: bar.py\n' f'{ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE}' ) + + +SAMPLE_ANSIBLE_DATA = { + 'installed_collections': { + 'ansible.builtin': {'version': '2.16.0'}, + 'community.general': {'version': '8.0.0', 'host_query': 'SELECT * FROM hosts'}, + }, + 'ansible_version': '2.16.0', +} + + +class TestTryLoadQueryFile: + def test_loads_file_without_feature_flag(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, 'ansible_data.json') + with open(path, 'w') as f: + json.dump(SAMPLE_ANSIBLE_DATA, f) + + with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): + success, data = try_load_query_file(tmpdir) + + assert success is True + assert data['ansible_version'] == '2.16.0' + assert 'ansible.builtin' in data['installed_collections'] + + def test_loads_file_with_feature_flag(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, 'ansible_data.json') + with open(path, 'w') as f: + json.dump(SAMPLE_ANSIBLE_DATA, f) + + with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=True): + success, data = try_load_query_file(tmpdir) + + assert success is True + assert data == SAMPLE_ANSIBLE_DATA + + def test_returns_false_when_file_missing(self): + with tempfile.TemporaryDirectory() as tmpdir: + success, data = try_load_query_file(tmpdir) + + assert success is False + assert data is None + + +class TestArtifactsHandler: + def test_always_persists_metadata_when_flag_off(self, mock_me): + rc = RunnerCallback() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, 'ansible_data.json') + with open(path, 'w') as f: + json.dump(SAMPLE_ANSIBLE_DATA, f) + + with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): + rc.artifacts_handler(tmpdir) + + assert rc.extra_update_fields['installed_collections'] == SAMPLE_ANSIBLE_DATA['installed_collections'] + assert rc.extra_update_fields['ansible_version'] == '2.16.0' + assert 'event_queries_processed' not in rc.extra_update_fields + assert rc.artifacts_processed is True + + @mock.patch('awx.main.tasks.callback.EventQuery') + def test_creates_event_queries_when_flag_on(self, mock_event_query, mock_me): + rc = RunnerCallback() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, 'ansible_data.json') + with open(path, 'w') as f: + json.dump(SAMPLE_ANSIBLE_DATA, f) + + with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=True): + rc.artifacts_handler(tmpdir) + + assert rc.extra_update_fields['installed_collections'] == SAMPLE_ANSIBLE_DATA['installed_collections'] + assert rc.extra_update_fields['ansible_version'] == '2.16.0' + assert rc.extra_update_fields['event_queries_processed'] is False + mock_event_query.assert_called_once() + + @mock.patch('awx.main.tasks.callback.EventQuery') + def test_no_event_queries_when_flag_off(self, mock_event_query, mock_me): + rc = RunnerCallback() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, 'ansible_data.json') + with open(path, 'w') as f: + json.dump(SAMPLE_ANSIBLE_DATA, f) + + with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): + rc.artifacts_handler(tmpdir) + + mock_event_query.assert_not_called() + + def test_handles_missing_artifact_file(self, mock_me): + rc = RunnerCallback() + with tempfile.TemporaryDirectory() as tmpdir: + with mock.patch('awx.main.tasks.callback.flag_enabled', return_value=False): + rc.artifacts_handler(tmpdir) + + assert 'installed_collections' not in rc.extra_update_fields + assert 'ansible_version' not in rc.extra_update_fields + assert rc.artifacts_processed is True diff --git a/awx/main/tests/unit/tasks/test_signals.py b/awx/main/tests/unit/tasks/test_signals.py index 75915504c538..f089ea749da9 100644 --- a/awx/main/tests/unit/tasks/test_signals.py +++ b/awx/main/tests/unit/tasks/test_signals.py @@ -12,6 +12,10 @@ def pytest_sigterm(): pytest_sigterm.called_count += 1 +def pytest_sigusr1(): + pytest_sigusr1.called_count += 1 + + def tmp_signals_for_test(func): """ When we run our internal signal handlers, it will call the original signal @@ -26,13 +30,17 @@ def tmp_signals_for_test(func): def wrapper(): original_sigterm = signal.getsignal(signal.SIGTERM) original_sigint = signal.getsignal(signal.SIGINT) + original_sigusr1 = signal.getsignal(signal.SIGUSR1) signal.signal(signal.SIGTERM, pytest_sigterm) signal.signal(signal.SIGINT, pytest_sigint) + signal.signal(signal.SIGUSR1, pytest_sigusr1) pytest_sigterm.called_count = 0 pytest_sigint.called_count = 0 + pytest_sigusr1.called_count = 0 func() signal.signal(signal.SIGTERM, original_sigterm) signal.signal(signal.SIGINT, original_sigint) + signal.signal(signal.SIGUSR1, original_sigusr1) return wrapper @@ -50,7 +58,7 @@ def f2(): @with_signal_handling def f1(): assert signal_callback() is False - signal_state.set_sigterm_flag() + signal_state.set_signal_flag(for_signal=signal.SIGTERM) assert signal_callback() f2() @@ -58,11 +66,13 @@ def f1(): assert signal_callback() is False assert pytest_sigterm.called_count == 0 assert pytest_sigint.called_count == 0 + assert pytest_sigusr1.called_count == 0 f1() assert signal_callback() is False assert signal.getsignal(signal.SIGTERM) is original_sigterm assert pytest_sigterm.called_count == 1 assert pytest_sigint.called_count == 0 + assert pytest_sigusr1.called_count == 0 @tmp_signals_for_test @@ -74,7 +84,7 @@ def test_inner_outer_signal_handling(): @with_signal_handling def f2(): assert signal_callback() is False - signal_state.set_sigint_flag() + signal_state.set_signal_flag(for_signal=signal.SIGINT) assert signal_callback() @with_signal_handling @@ -87,8 +97,31 @@ def f1(): assert signal_callback() is False assert pytest_sigterm.called_count == 0 assert pytest_sigint.called_count == 0 + assert pytest_sigusr1.called_count == 0 f1() assert signal_callback() is False assert signal.getsignal(signal.SIGTERM) is original_sigterm assert pytest_sigterm.called_count == 0 assert pytest_sigint.called_count == 1 + assert pytest_sigusr1.called_count == 0 + + +@tmp_signals_for_test +def test_sigusr1_signal_handling(): + @with_signal_handling + def f1(): + assert signal_callback() is False + signal_state.set_signal_flag(for_signal=signal.SIGUSR1) + assert signal_callback() + + original_sigusr1 = signal.getsignal(signal.SIGUSR1) + assert signal_callback() is False + assert pytest_sigterm.called_count == 0 + assert pytest_sigint.called_count == 0 + assert pytest_sigusr1.called_count == 0 + f1() + assert signal_callback() is False + assert signal.getsignal(signal.SIGUSR1) is original_sigusr1 + assert pytest_sigterm.called_count == 0 + assert pytest_sigint.called_count == 0 + assert pytest_sigusr1.called_count == 1 diff --git a/awx/main/tests/unit/test_capacity.py b/awx/main/tests/unit/test_capacity.py index 8132415c405d..79659edd3901 100644 --- a/awx/main/tests/unit/test_capacity.py +++ b/awx/main/tests/unit/test_capacity.py @@ -172,6 +172,69 @@ def Is(param): return instances +def percent_capacity_remaining(capacity, consumed_capacity): + """Standalone implementation of the percent_capacity_remaining formula. + + This mirrors the logic in InstanceGroupSerializer.get_percent_capacity_remaining + and InstanceSerializer.get_percent_capacity_remaining. + """ + if not capacity or consumed_capacity >= capacity: + return 0.0 + return float("{0:.2f}".format(((float(capacity) - float(consumed_capacity)) / (float(capacity))) * 100)) + + +class TestInstanceGroupPercentCapacityRemaining: + """Tests for the percent_capacity_remaining overflow guard. + + Validates that get_percent_capacity_remaining returns 0.0 when consumed + capacity equals or exceeds total capacity, matching the guard in + InstanceSerializer.get_percent_capacity_remaining. + """ + + @pytest.mark.parametrize( + 'capacity,consumed,expected,description', + [ + (100, 0, 100.0, "No consumption returns 100%"), + (100, 50, 50.0, "Half consumed returns 50%"), + (100, 100, 0.0, "Fully consumed returns 0%"), + (100, 200, 0.0, "Over-consumed returns 0% (not negative)"), + (0, 0, 0.0, "Zero capacity returns 0%"), + (0, 50, 0.0, "Zero capacity with consumption returns 0%"), + (200, 43, 78.5, "Normal partial consumption"), + ], + ) + def test_percent_capacity_remaining_formula(self, capacity, consumed, expected, description): + """Verify percent_capacity_remaining handles overflow correctly.""" + result = percent_capacity_remaining(capacity, consumed) + assert result == expected, description + assert result >= 0.0, "percent_capacity_remaining must never be negative" + + def test_overconsumed_instance_group_remaining_capacity(self, sample_cluster, create_ig_manager): + """Verify that when consumed capacity exceeds total capacity in an instance group, + the remaining capacity is clamped to zero (not negative). + + This is the integration-level test for the overflow guard added to + InstanceGroupSerializer.get_percent_capacity_remaining. + """ + ig = InstanceGroup(name='overloaded_ig') + # Instance with capacity 10, but we'll run a job with task_impact=20 + inst = Instance(hostname='overloaded_host', capacity=10, node_type='hybrid') + ig.instances.add(inst) + + tasks = [Job(task_impact=20, execution_node='overloaded_host', instance_group=ig)] + ig_mgr = create_ig_manager([ig], tasks) + + capacity = ig_mgr.get_capacity('overloaded_ig') + consumed = ig_mgr.get_consumed_capacity('overloaded_ig') + + # Consumed exceeds capacity + assert consumed > capacity, "Test setup: consumed should exceed capacity" + # The percent_capacity_remaining formula should return 0.0, not negative + result = percent_capacity_remaining(capacity, consumed) + assert result == 0.0, "Over-consumed instance group should return 0.0% remaining" + assert result >= 0.0, "Result must never be negative" + + class TestSelectBestInstanceForTask(object): @pytest.mark.parametrize( 'task,instances,instance_fit_index,reason', diff --git a/awx/main/tests/unit/test_db.py b/awx/main/tests/unit/test_db.py index ce0b8bbeccb8..dc40ea77f355 100644 --- a/awx/main/tests/unit/test_db.py +++ b/awx/main/tests/unit/test_db.py @@ -8,7 +8,7 @@ import awx from awx.main.db.profiled_pg.base import RecordedQueryLog - +from awx.main.utils.db import db_requirement_violations QUERY = {'sql': 'SELECT * FROM main_job', 'time': '.01'} EXPLAIN = 'Seq Scan on public.main_job (cost=0.00..1.18 rows=18 width=86)' @@ -146,3 +146,71 @@ def dict_factory(cursor, row): assert q['sql'] == QUERY['sql'] assert EXPLAIN in q['explain'] assert 'test_sql_above_threshold' in q['bt'] + + +def test_db_requirement_violations_skip_env_var(mocker): + mocker.patch.dict(os.environ, {'SKIP_PG_VERSION_CHECK': 'true'}) + result = db_requirement_violations() + assert result is None + + +def test_db_requirement_violations_postgresql_sufficient_version(mocker): + mock_connection = mocker.MagicMock() + mock_connection.vendor = 'postgresql' + mock_connection.pg_version = 120000 # Version 12.0 + mocker.patch('awx.main.utils.db.connection', mock_connection) + mocker.patch.dict(os.environ, {}, clear=True) + + result = db_requirement_violations() + + assert result is None + + +def test_db_requirement_violations_postgresql_insufficient_version(mocker): + mock_connection = mocker.MagicMock() + mock_connection.vendor = 'postgresql' + mock_connection.pg_version = 110000 # Version 11.0 + mocker.patch('awx.main.utils.db.connection', mock_connection) + mocker.patch.dict(os.environ, {}, clear=True) + + result = db_requirement_violations() + + assert result is not None + assert "At a minimum, postgres version 12 is required, found 11" in result + + +def test_db_requirement_violations_non_postgresql_production(mocker): + mock_connection = mocker.MagicMock() + mock_connection.vendor = 'sqlite' + mocker.patch('awx.main.utils.db.connection', mock_connection) + mocker.patch('awx.main.utils.db.MODE', 'production') + mocker.patch.dict(os.environ, {}, clear=True) + + result = db_requirement_violations() + + assert result is not None + assert "Running server with 'sqlite' type database is not supported" in result + + +def test_db_requirement_violations_non_postgresql_development(mocker): + mock_connection = mocker.MagicMock() + mock_connection.vendor = 'sqlite' + mocker.patch('awx.main.utils.db.connection', mock_connection) + mocker.patch('awx.main.utils.db.MODE', 'development') + mocker.patch.dict(os.environ, {}, clear=True) + + result = db_requirement_violations() + + assert result is None + + +def test_db_requirement_violations_postgresql_edge_case_version(mocker): + mock_connection = mocker.MagicMock() + mock_connection.vendor = 'postgresql' + mock_connection.pg_version = 129999 # Version 12.9999 + mocker.patch('awx.main.utils.db.connection', mock_connection) + mocker.patch.dict(os.environ, {}, clear=True) + + result = db_requirement_violations() + + assert result is None diff --git a/awx/main/tests/unit/test_fields.py b/awx/main/tests/unit/test_fields.py index da669ae47d2c..2250dbde70fb 100644 --- a/awx/main/tests/unit/test_fields.py +++ b/awx/main/tests/unit/test_fields.py @@ -76,6 +76,9 @@ def schema(self, model_instance): ({'fields': [{'id': 'token', 'label': 'Token', 'secret': 'bad'}]}, False), ({'fields': [{'id': 'token', 'label': 'Token', 'ask_at_runtime': True}]}, True), ({'fields': [{'id': 'token', 'label': 'Token', 'ask_at_runtime': 'bad'}]}, False), # noqa + ({'fields': [{'id': 'token', 'label': 'Token', 'internal': True}]}, True), + ({'fields': [{'id': 'token', 'label': 'Token', 'internal': False}]}, True), + ({'fields': [{'id': 'token', 'label': 'Token', 'internal': 'bad'}]}, False), ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': 'not-a-list'}]}, False), # noqa ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': []}]}, False), ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': ['su', 'sudo']}]}, True), # noqa @@ -204,6 +207,68 @@ def test_credential_creation_validation_failure(inputs): assert e.type in (ValidationError, DRFValidationError) +def test_credential_input_field_excludes_internal_fields(): + """Internal fields should be excluded from the schema generated by CredentialInputField, + preventing users from providing values for internally resolved fields.""" + type_ = CredentialType( + kind='cloud', + name='SomeCloud', + managed=True, + inputs={ + 'fields': [ + {'id': 'username', 'label': 'Username', 'type': 'string'}, + {'id': 'resolved_token', 'label': 'Token', 'type': 'string', 'internal': True}, + ] + }, + ) + cred = Credential(credential_type=type_, name="Test Credential", inputs={'username': 'joe'}) + field = cred._meta.get_field('inputs') + schema = field.schema(cred) + + assert 'username' in schema['properties'] + assert 'resolved_token' not in schema['properties'] + + +def test_credential_input_field_rejects_values_for_internal_fields(): + """Users should not be able to provide values for fields marked as internal.""" + type_ = CredentialType( + kind='cloud', + name='SomeCloud', + managed=True, + inputs={ + 'fields': [ + {'id': 'username', 'label': 'Username', 'type': 'string'}, + {'id': 'resolved_token', 'label': 'Token', 'type': 'string', 'internal': True}, + ] + }, + ) + cred = Credential(credential_type=type_, name="Test Credential", inputs={'username': 'joe', 'resolved_token': 'secret'}) + field = cred._meta.get_field('inputs') + + with pytest.raises(Exception) as e: + field.validate(cred.inputs, cred) + assert e.type in (ValidationError, DRFValidationError) + + +def test_credential_input_field_accepts_non_internal_fields_only(): + """Credentials with only non-internal field values should validate successfully.""" + type_ = CredentialType( + kind='cloud', + name='SomeCloud', + managed=True, + inputs={ + 'fields': [ + {'id': 'username', 'label': 'Username', 'type': 'string'}, + {'id': 'resolved_token', 'label': 'Token', 'type': 'string', 'internal': True}, + ] + }, + ) + cred = Credential(credential_type=type_, name="Test Credential", inputs={'username': 'joe'}) + field = cred._meta.get_field('inputs') + # Should not raise + field.validate(cred.inputs, cred) + + def test_implicit_role_field_parents(): """This assures that every ImplicitRoleField only references parents which are relationships that actually exist diff --git a/awx/main/tests/unit/test_indirect_query_discovery.py b/awx/main/tests/unit/test_indirect_query_discovery.py new file mode 100644 index 000000000000..3694cf8466d1 --- /dev/null +++ b/awx/main/tests/unit/test_indirect_query_discovery.py @@ -0,0 +1,464 @@ +""" +Unit tests for external query discovery and version fallback logic. +Tests for AAP-58456: Unit Test Suite for External Query Handling +""" + +import sys +from io import StringIO +from unittest import mock + +import pytest +from packaging.version import Version + + +# Helper for mocking importlib.resources.files() path traversal +def create_chainable_path_mock(final_mock, depth=3): + """Mock that supports chained / operations: mock / 'a' / 'b' / 'c' -> final_mock""" + + class ChainableMock: + def __init__(self, d=0): + self.d = d + + def __truediv__(self, other): + return final_mock if self.d >= depth - 1 else ChainableMock(self.d + 1) + + return ChainableMock() + + +def create_queries_dir_mock(file_lookup_func): + """Mock for queries_dir: mock / 'filename' -> file_lookup_func('filename')""" + + class QueriesDirMock: + def __truediv__(self, filename): + return file_lookup_func(filename) + + return QueriesDirMock() + + +# Ansible mocking required for importing the module (it imports from ansible.plugins.callback.CallbackBase) +class MockCallbackBase: + def __init__(self): + self._display = mock.MagicMock() + self._plugin_options = {} + + def get_option(self, key): + return self._plugin_options.get(key) + + def set_option(self, key, value): + self._plugin_options[key] = value + + def v2_playbook_on_stats(self, stats): + pass + + +_mock_callback_module = mock.MagicMock() +_mock_callback_module.CallbackBase = MockCallbackBase + + +@pytest.fixture(autouse=True) +def _mock_ansible_modules(): + """Temporarily inject fake ansible modules so the callback plugin can be imported.""" + with mock.patch.dict( + sys.modules, + { + 'ansible': mock.MagicMock(), + 'ansible.plugins': mock.MagicMock(), + 'ansible.plugins.callback': _mock_callback_module, + 'ansible.cli': mock.MagicMock(), + 'ansible.cli.galaxy': mock.MagicMock(), + 'ansible.release': mock.MagicMock(__version__='2.16.0'), + 'ansible.galaxy': mock.MagicMock(), + 'ansible.galaxy.collection': mock.MagicMock(), + 'ansible.utils': mock.MagicMock(), + 'ansible.utils.collection_loader': mock.MagicMock(), + 'ansible.constants': mock.MagicMock(), + }, + ): + yield + + +class TestListExternalQueries: + """Tests for list_external_queries function.""" + + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + def test_returns_empty_when_collection_not_installed(self, mock_files): + from awx.playbooks.library.indirect_instance_count import list_external_queries + + mock_files.side_effect = ModuleNotFoundError("No module named 'ansible_collections.redhat'") + + result = list_external_queries('demo', 'external') + + assert result == [] + + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + def test_parses_version_from_filenames(self, mock_files): + from awx.playbooks.library.indirect_instance_count import list_external_queries + + mock_file_1 = mock.Mock() + mock_file_1.name = 'demo.external.1.0.0.yml' + mock_file_2 = mock.Mock() + mock_file_2.name = 'demo.external.2.1.0.yml' + mock_file_other = mock.Mock() + mock_file_other.name = 'other.collection.1.0.0.yml' + + mock_queries_dir = mock.Mock() + mock_queries_dir.iterdir.return_value = [mock_file_1, mock_file_2, mock_file_other] + mock_files.return_value = create_chainable_path_mock(mock_queries_dir) + + result = list_external_queries('demo', 'external') + + assert len(result) == 2 + assert Version('1.0.0') in result + assert Version('2.1.0') in result + + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + def test_skips_invalid_versions(self, mock_files): + from awx.playbooks.library.indirect_instance_count import list_external_queries + + mock_file_valid = mock.Mock() + mock_file_valid.name = 'demo.external.1.0.0.yml' + mock_file_invalid = mock.Mock() + mock_file_invalid.name = 'demo.external.invalid.yml' + + mock_queries_dir = mock.Mock() + mock_queries_dir.iterdir.return_value = [mock_file_valid, mock_file_invalid] + mock_files.return_value = create_chainable_path_mock(mock_queries_dir) + + result = list_external_queries('demo', 'external') + + assert len(result) == 1 + assert Version('1.0.0') in result + + +class TestVersionFallback: + """Tests for version fallback logic (AC7.4-AC7.9).""" + + @mock.patch('awx.playbooks.library.indirect_instance_count._get_query_file_dir') + def test_exact_match_preferred(self, mock_get_dir): + """AC7.4: Exact version match is preferred over fallback version.""" + from awx.playbooks.library.indirect_instance_count import find_external_query_with_fallback + + mock_exact_file = mock.Mock() + mock_exact_file.exists.return_value = True + mock_exact_file.open.return_value.__enter__ = mock.Mock(return_value=StringIO('exact_version_query')) + mock_exact_file.open.return_value.__exit__ = mock.Mock(return_value=False) + + mock_get_dir.return_value = create_queries_dir_mock(lambda f: mock_exact_file) + + content, fallback_used, version = find_external_query_with_fallback('demo', 'external', '2.5.0') + + assert content == 'exact_version_query' + assert fallback_used is False + assert version == '2.5.0' + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_external_queries') + @mock.patch('awx.playbooks.library.indirect_instance_count._get_query_file_dir') + def test_fallback_nearest_lower_same_major(self, mock_get_dir, mock_list): + """AC7.5: Fallback selects nearest lower version within same major version. + + When installed is 4.5.0 and 4.0.0/4.1.0 are available, selects 4.1.0. + """ + from awx.playbooks.library.indirect_instance_count import find_external_query_with_fallback + + mock_list.return_value = [Version('4.0.0'), Version('4.1.0')] + + mock_exact_file = mock.Mock(exists=mock.Mock(return_value=False)) + mock_fallback_file = mock.Mock() + mock_fallback_file.exists.return_value = True + mock_fallback_file.open.return_value.__enter__ = mock.Mock(return_value=StringIO('fallback_query')) + mock_fallback_file.open.return_value.__exit__ = mock.Mock(return_value=False) + + def file_lookup(filename): + return mock_fallback_file if '4.1.0' in filename else mock_exact_file + + mock_get_dir.return_value = create_queries_dir_mock(file_lookup) + + content, fallback_used, version = find_external_query_with_fallback('community', 'vmware', '4.5.0') + + assert content == 'fallback_query' + assert fallback_used is True + assert version == '4.1.0' + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_external_queries') + @mock.patch('awx.playbooks.library.indirect_instance_count._get_query_file_dir') + def test_fallback_respects_major_version_boundary(self, mock_get_dir, mock_list): + """Test that fallback does NOT cross major version boundaries. + + When installed version is 6.0.0 and only 5.0.0 query exists, + no fallback should occur because major versions differ. + """ + from awx.playbooks.library.indirect_instance_count import find_external_query_with_fallback + + mock_list.return_value = [Version('5.0.0')] + + # Mock exact file (6.0.0) to not exist + mock_exact_file = mock.Mock(exists=mock.Mock(return_value=False)) + # Mock fallback file (5.0.0) to exist - if major version check is broken, + # this file would be incorrectly selected + mock_fallback_file = mock.Mock() + mock_fallback_file.exists.return_value = True + mock_fallback_file.open.return_value.__enter__ = mock.Mock(return_value=StringIO('wrong_major_version_query')) + mock_fallback_file.open.return_value.__exit__ = mock.Mock(return_value=False) + + def file_lookup(filename): + return mock_fallback_file if '5.0.0' in filename else mock_exact_file + + mock_get_dir.return_value = create_queries_dir_mock(file_lookup) + + content, fallback_used, version = find_external_query_with_fallback('community', 'vmware', '6.0.0') + + # Should NOT fall back to 5.0.0 because major version differs (5 vs 6) + assert content is None + assert fallback_used is False + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_external_queries') + @mock.patch('awx.playbooks.library.indirect_instance_count._get_query_file_dir') + def test_no_fallback_when_incompatible(self, mock_get_dir, mock_list): + """AC7.7: No fallback when all available versions are higher than installed. + + When installed version is 3.8.0 and only 4.0.0 and 5.0.0 exist, + no fallback should occur because both are higher than installed. + """ + from awx.playbooks.library.indirect_instance_count import find_external_query_with_fallback + + mock_list.return_value = [Version('4.0.0'), Version('5.0.0')] + + # Mock exact file (3.8.0) to not exist + mock_exact_file = mock.Mock(exists=mock.Mock(return_value=False)) + # Mock available files to exist - if version filtering is broken, + # one of these would be incorrectly selected + mock_available_file = mock.Mock() + mock_available_file.exists.return_value = True + mock_available_file.open.return_value.__enter__ = mock.Mock(return_value=StringIO('higher_version_query')) + mock_available_file.open.return_value.__exit__ = mock.Mock(return_value=False) + + def file_lookup(filename): + if '4.0.0' in filename or '5.0.0' in filename: + return mock_available_file + return mock_exact_file + + mock_get_dir.return_value = create_queries_dir_mock(file_lookup) + + content, fallback_used, version = find_external_query_with_fallback('community', 'vmware', '3.8.0') + + # Should NOT fall back to 4.0.0 or 5.0.0 because both are higher than 3.8.0 + assert content is None + assert fallback_used is False + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_external_queries') + @mock.patch('awx.playbooks.library.indirect_instance_count._get_query_file_dir') + def test_fallback_selection_logic(self, mock_get_dir, mock_list): + """AC7.9: Complex fallback scenario with multiple candidates. + + When installed is 4.5.0 and 4.0.0, 4.1.0, 5.0.0 are available, + selects 4.1.0 (highest compatible within same major, <= installed). + """ + from awx.playbooks.library.indirect_instance_count import find_external_query_with_fallback + + mock_list.return_value = [Version('4.0.0'), Version('4.1.0'), Version('5.0.0')] + + mock_exact_file = mock.Mock(exists=mock.Mock(return_value=False)) + mock_fallback_file = mock.Mock() + mock_fallback_file.exists.return_value = True + mock_fallback_file.open.return_value.__enter__ = mock.Mock(return_value=StringIO('query_4.1.0')) + mock_fallback_file.open.return_value.__exit__ = mock.Mock(return_value=False) + + def file_lookup(filename): + return mock_fallback_file if '4.1.0' in filename else mock_exact_file + + mock_get_dir.return_value = create_queries_dir_mock(file_lookup) + + content, fallback_used, version = find_external_query_with_fallback('community', 'vmware', '4.5.0') + + assert version == '4.1.0' + assert fallback_used is True + assert content == 'query_4.1.0' + + +class TestExternalQueryDiscovery: + """Tests for callback plugin query discovery (AC7.1-AC7.3).""" + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_collections') + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + @mock.patch('awx.playbooks.library.indirect_instance_count.find_external_query_with_fallback') + @mock.patch.dict('os.environ', {'AWX_ISOLATED_DATA_DIR': '/tmp/artifacts'}) + def test_precedence_embedded_over_external(self, mock_fallback, mock_files, mock_list_collections): + """AC7.1: Embedded query takes precedence when both embedded and external exist.""" + from awx.playbooks.library.indirect_instance_count import CallbackModule + + mock_list_collections.return_value = [mock.Mock(namespace='demo', name='query', ver='1.0.0', fqcn='demo.query')] + + mock_embedded_file = mock.Mock() + mock_embedded_file.exists.return_value = True + mock_embedded_file.open.return_value.__enter__ = mock.Mock(return_value=StringIO('embedded_query')) + mock_embedded_file.open.return_value.__exit__ = mock.Mock(return_value=False) + mock_files.return_value = create_chainable_path_mock(mock_embedded_file) + + callback = CallbackModule() + callback._display = mock.Mock() + callback.set_option('collect_host_queries', True) + + with mock.patch('builtins.open', mock.mock_open()): + with mock.patch('json.dumps', return_value='{}'): + callback.v2_playbook_on_stats(mock.Mock()) + + mock_fallback.assert_not_called() + callback._display.vv.assert_called() + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_collections') + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + @mock.patch('awx.playbooks.library.indirect_instance_count.find_external_query_with_fallback') + @mock.patch.dict('os.environ', {'AWX_ISOLATED_DATA_DIR': '/tmp/artifacts'}) + def test_external_query_when_embedded_missing(self, mock_fallback, mock_files, mock_list_collections): + """AC7.2: External query is discovered when embedded query is missing.""" + from awx.playbooks.library.indirect_instance_count import CallbackModule + + mock_candidate = mock.Mock() + mock_candidate.namespace = 'demo' + mock_candidate.name = 'external' + mock_candidate.ver = '2.5.0' + mock_candidate.fqcn = 'demo.external' + mock_list_collections.return_value = [mock_candidate] + + mock_embedded_file = mock.Mock(exists=mock.Mock(return_value=False)) + mock_files.return_value = create_chainable_path_mock(mock_embedded_file) + mock_fallback.return_value = ('external_query_content', False, '2.5.0') + + callback = CallbackModule() + callback._display = mock.Mock() + callback.set_option('collect_host_queries', True) + + with mock.patch('builtins.open', mock.mock_open()): + with mock.patch('json.dumps', return_value='{}'): + callback.v2_playbook_on_stats(mock.Mock()) + + mock_fallback.assert_called_once_with('demo', 'external', '2.5.0') + callback._display.v.assert_called() + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_collections') + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + @mock.patch('awx.playbooks.library.indirect_instance_count.find_external_query_with_fallback') + @mock.patch.dict('os.environ', {'AWX_ISOLATED_DATA_DIR': '/tmp/artifacts'}) + def test_no_query_when_both_missing(self, mock_fallback, mock_files, mock_list_collections): + """AC7.3: No query is used when both embedded and external queries are missing.""" + from awx.playbooks.library.indirect_instance_count import CallbackModule + + mock_list_collections.return_value = [mock.Mock(namespace='unknown', name='collection', ver='1.0.0', fqcn='unknown.collection')] + + mock_embedded_file = mock.Mock(exists=mock.Mock(return_value=False)) + mock_files.return_value = create_chainable_path_mock(mock_embedded_file) + mock_fallback.return_value = (None, False, None) + + callback = CallbackModule() + callback._display = mock.Mock() + callback.set_option('collect_host_queries', True) + + with mock.patch('builtins.open', mock.mock_open()): + with mock.patch('json.dumps', return_value='{}'): + callback.v2_playbook_on_stats(mock.Mock()) + + mock_fallback.assert_called_once() + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_collections') + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + @mock.patch('awx.playbooks.library.indirect_instance_count.find_external_query_with_fallback') + @mock.patch.dict('os.environ', {'AWX_ISOLATED_DATA_DIR': '/tmp/artifacts'}) + def test_info_log_on_fallback(self, mock_fallback, mock_files, mock_list_collections): + """AC7.8: Log message is emitted when fallback version is used. + + Verifies that when a fallback version is used, a log message is emitted + containing both the fallback version and the collection FQCN. + + Note: AC7.8 specifies 'warning logs' but implementation uses verbose/info + level (_display.v) as this is informational rather than a warning condition. + """ + from awx.playbooks.library.indirect_instance_count import CallbackModule + + mock_list_collections.return_value = [mock.Mock(namespace='community', name='vmware', ver='4.5.0', fqcn='community.vmware')] + + mock_embedded_file = mock.Mock(exists=mock.Mock(return_value=False)) + mock_files.return_value = create_chainable_path_mock(mock_embedded_file) + mock_fallback.return_value = ('fallback_query_content', True, '4.1.0') + + callback = CallbackModule() + callback._display = mock.Mock() + callback.set_option('collect_host_queries', True) + + with mock.patch('builtins.open', mock.mock_open()): + with mock.patch('json.dumps', return_value='{}'): + callback.v2_playbook_on_stats(mock.Mock()) + + callback._display.v.assert_called() + call_args = callback._display.v.call_args[0][0] + assert '4.1.0' in call_args + assert 'community.vmware' in call_args + + @mock.patch('awx.playbooks.library.indirect_instance_count.list_collections') + @mock.patch('awx.playbooks.library.indirect_instance_count.files') + @mock.patch('awx.playbooks.library.indirect_instance_count.find_external_query_with_fallback') + @mock.patch.dict('os.environ', {'AWX_ISOLATED_DATA_DIR': '/tmp/artifacts'}) + def test_queries_not_collected_when_option_disabled(self, mock_fallback, mock_files, mock_list_collections): + """Host query scanning is skipped when collect_host_queries is disabled.""" + from awx.playbooks.library.indirect_instance_count import CallbackModule + + mock_list_collections.return_value = [mock.Mock(namespace='demo', name='query', ver='1.0.0', fqcn='demo.query')] + + callback = CallbackModule() + callback._display = mock.Mock() + callback.set_option('collect_host_queries', False) + + with mock.patch('builtins.open', mock.mock_open()): + with mock.patch('json.dumps', return_value='{}'): + callback.v2_playbook_on_stats(mock.Mock()) + + mock_list_collections.assert_called_once() + mock_files.assert_not_called() + mock_fallback.assert_not_called() + + +class TestPrivateDataDirIntegration: + """Tests for vendor collection copying (AC7.10-AC7.11).""" + + @mock.patch('awx.main.tasks.jobs.flag_enabled') + @mock.patch('awx.main.tasks.jobs.shutil.copytree') + @mock.patch('awx.main.tasks.jobs.os.path.exists') + def test_vendor_collections_copied(self, mock_exists, mock_copytree, mock_flag): + """AC7.10: build_private_data_files() copies vendor collections to private_data_dir.""" + from awx.main.tasks.jobs import BaseTask + + mock_flag.return_value = True + mock_exists.return_value = True + + task = BaseTask() + task.instance = mock.Mock() + task.cleanup_paths = [] + task.build_private_data = mock.Mock(return_value=None) + + private_data_dir = '/tmp/awx_123_abc' + task.build_private_data_files(task.instance, private_data_dir) + + mock_copytree.assert_called_once_with('/var/lib/awx/vendor_collections', f'{private_data_dir}/vendor_collections') + + @mock.patch('awx.main.tasks.jobs.flag_enabled') + @mock.patch('awx.main.tasks.jobs.logger') + @mock.patch('awx.main.tasks.jobs.shutil.copytree') + @mock.patch('awx.main.tasks.jobs.os.path.exists') + def test_missing_source_handled_gracefully(self, mock_exists, mock_copytree, mock_logger, mock_flag): + """AC7.11: Collection copy handles missing source directory gracefully.""" + from awx.main.tasks.jobs import BaseTask + + mock_flag.return_value = True + mock_exists.return_value = False + + task = BaseTask() + task.instance = mock.Mock() + task.cleanup_paths = [] + task.build_private_data = mock.Mock(return_value=None) + + private_data_dir = '/tmp/awx_123_abc' + result = task.build_private_data_files(task.instance, private_data_dir) + + # copytree should not be called when source doesn't exist + mock_copytree.assert_not_called() + # Function should complete without raising an exception + assert result is not None diff --git a/awx/main/tests/unit/test_redact.py b/awx/main/tests/unit/test_redact.py index c5585ff75cda..f175cbbf7a55 100644 --- a/awx/main/tests/unit/test_redact.py +++ b/awx/main/tests/unit/test_redact.py @@ -36,8 +36,7 @@ TEST_CLEARTEXT.append( { 'uri': uri, - 'text': textwrap.dedent( - """\ + 'text': textwrap.dedent("""\ PLAY [all] ******************************************************************** TASK: [delete project directory before update] ******************************** @@ -59,9 +58,7 @@ localhost : ok=0 changed=0 unreachable=0 failed=1 - """ - % (uri.username, uri.password, str(uri), str(uri)) - ), + """ % (uri.username, uri.password, str(uri), str(uri))), 'host_occurrences': 2, } ) @@ -70,8 +67,7 @@ TEST_CLEARTEXT.append( { 'uri': uri, - 'text': textwrap.dedent( - """\ + 'text': textwrap.dedent("""\ TASK: [update project using git] ** failed: [localhost] => {"cmd": "/usr/bin/git ls-remote https://REDACTED:********", "failed": true, "rc": 128} stderr: error: Couldn't resolve host '@%s' while accessing %s @@ -81,9 +77,7 @@ msg: error: Couldn't resolve host '@%s' while accessing %s fatal: HTTP request failed - """ - % (uri.host, str(uri), uri.host, str(uri)) - ), + """ % (uri.host, str(uri), uri.host, str(uri))), 'host_occurrences': 4, } ) diff --git a/awx/main/tests/unit/test_settings.py b/awx/main/tests/unit/test_settings.py index 7ff2e3f4abf6..ee517d6a870e 100644 --- a/awx/main/tests/unit/test_settings.py +++ b/awx/main/tests/unit/test_settings.py @@ -1,6 +1,3 @@ -from split_settings.tools import include - - LOCAL_SETTINGS = ( 'ALLOWED_HOSTS', 'BROADCAST_WEBSOCKET_PORT', @@ -11,18 +8,20 @@ 'CACHES', 'DEBUG', 'NAMED_URL_GRAPH', - 'DISPATCHER_MOCK_PUBLISH', + # Platform flags are managed by the platform flags system and have environment-specific defaults + 'FEATURE_INDIRECT_NODE_COUNTING_ENABLED', ) def test_postprocess_auth_basic_enabled(): - locals().update({'__file__': __file__}) + """The final loaded settings should have basic auth enabled.""" + from awx.settings import REST_FRAMEWORK - include('../../../settings/defaults.py', scope=locals()) - assert 'awx.api.authentication.LoggedBasicAuthentication' in locals()['REST_FRAMEWORK']['DEFAULT_AUTHENTICATION_CLASSES'] + assert 'awx.api.authentication.LoggedBasicAuthentication' in REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] def test_default_settings(): + """Ensure that all default settings are present in the snapshot.""" from django.conf import settings for k in dir(settings): @@ -30,4 +29,65 @@ def test_default_settings(): continue default_val = getattr(settings.default_settings, k, None) snapshot_val = settings.DEFAULTS_SNAPSHOT[k] - assert default_val == snapshot_val, f'Setting for {k} does not match shapshot:\nsnapshot: {snapshot_val}\ndefault: {default_val}' + assert default_val == snapshot_val, f'Setting for {k} does not match snapshot:\nsnapshot: {snapshot_val}\ndefault: {default_val}' + + +def test_django_conf_settings_is_awx_settings(): + """Ensure that the settings loaded from dynaconf are the same as the settings delivered to django.""" + from django.conf import settings + from awx.settings import REST_FRAMEWORK + + assert settings.REST_FRAMEWORK == REST_FRAMEWORK + + +def test_dynaconf_is_awx_settings(): + """Ensure that the settings loaded from dynaconf are the same as the settings delivered to django.""" + from django.conf import settings + from awx.settings import REST_FRAMEWORK + + assert settings.DYNACONF.REST_FRAMEWORK == REST_FRAMEWORK + + +def test_development_settings_can_be_directly_imported(monkeypatch): + """Ensure that the development settings can be directly imported.""" + monkeypatch.setenv('AWX_MODE', 'development') + from django.conf import settings + from awx.settings.development import REST_FRAMEWORK + from awx.settings.development import DEBUG # actually set on defaults.py and not overridden in development.py + + assert settings.REST_FRAMEWORK == REST_FRAMEWORK + assert DEBUG is True + + +def test_merge_application_name(): + """Ensure that the merge_application_name function works as expected.""" + from awx.settings.functions import merge_application_name + + settings = { + "DATABASES__default__ENGINE": "django.db.backends.postgresql", + "CLUSTER_HOST_ID": "test-cluster-host-id", + } + result = merge_application_name(settings)["DATABASES__default__OPTIONS__application_name"] + assert result.startswith("awx-") + assert "test-cluster" in result + + +def test_development_defaults_feature_flags(monkeypatch): + """Ensure that development_defaults.py sets the correct feature flags.""" + monkeypatch.setenv('AWX_MODE', 'development') + + # Import the development_defaults module directly to trigger coverage of the new lines + import importlib.util + import os + + spec = importlib.util.spec_from_file_location("development_defaults", os.path.join(os.path.dirname(__file__), "../../../settings/development_defaults.py")) + development_defaults = importlib.util.module_from_spec(spec) + spec.loader.exec_module(development_defaults) + + # Also import through the development settings to ensure both paths are tested + from awx.settings.development import FEATURE_INDIRECT_NODE_COUNTING_ENABLED + + # Verify the feature flags are set correctly in both the module and settings + assert hasattr(development_defaults, 'FEATURE_INDIRECT_NODE_COUNTING_ENABLED') + assert development_defaults.FEATURE_INDIRECT_NODE_COUNTING_ENABLED is True + assert FEATURE_INDIRECT_NODE_COUNTING_ENABLED is True diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 9ac278b5689b..68b4d8e6d06a 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- import json import os -import shutil -import tempfile from pathlib import Path import fcntl @@ -39,7 +37,7 @@ from awx.main.utils.safe_yaml import SafeLoader from awx.main.utils.licensing import Licenser -from awx.main.constants import JOB_VARIABLE_PREFIXES +from awx.main.utils.common import get_job_variable_prefixes from receptorctl.socket_interface import ReceptorControl @@ -60,14 +58,12 @@ class TestJobExecution(object): @pytest.fixture -def private_data_dir(): - private_data = tempfile.mkdtemp(prefix='awx_') +def private_data_dir(tmp_path): + private_data = tmp_path / 'awx_pdd' + private_data.mkdir() for subfolder in ('inventory', 'env'): - runner_subfolder = os.path.join(private_data, subfolder) - if not os.path.exists(runner_subfolder): - os.mkdir(runner_subfolder) - yield private_data - shutil.rmtree(private_data, True) + (private_data / subfolder).mkdir() + return str(private_data) @pytest.fixture @@ -107,7 +103,7 @@ def job(): @pytest.fixture def adhoc_job(): - return AdHocCommand(pk=1, id=1, inventory=Inventory()) + return AdHocCommand(pk=1, id=1, inventory=Inventory(), status='waiting') @pytest.fixture @@ -139,7 +135,7 @@ def test_send_notifications_job_id(mocker): mocker.patch('awx.main.models.UnifiedJob.objects.get') system.send_notifications([], job_id=1) assert UnifiedJob.objects.get.called - assert UnifiedJob.objects.get.called_with(id=1) + UnifiedJob.objects.get.assert_called_with(id=1) @mock.patch('awx.main.models.UnifiedJob.objects.get') @@ -156,7 +152,7 @@ def test_send_notifications_list(mock_notifications_filter, mock_job_get, mocker assert mock_notifications[0].save.called assert mock_job.notifications.add.called - assert mock_job.notifications.add.called_with(*mock_notifications) + mock_job.notifications.add.assert_called_with(*mock_notifications) @pytest.mark.parametrize( @@ -376,12 +372,12 @@ def test_vars_unsafe_by_default(self, job, private_data_dir, mock_me): extra_vars = yaml.load(fd, Loader=SafeLoader) # ensure that strings are marked as unsafe - for name in JOB_VARIABLE_PREFIXES: + for name in get_job_variable_prefixes(): for variable_name in ['_job_template_name', '_user_name', '_job_launch_type', '_project_revision', '_inventory_name']: assert hasattr(extra_vars['{}{}'.format(name, variable_name)], '__UNSAFE__') # ensure that non-strings are marked as safe - for name in JOB_VARIABLE_PREFIXES: + for name in get_job_variable_prefixes(): for variable_name in ['_job_template_id', '_job_id', '_user_id', '_inventory_id']: assert not hasattr(extra_vars['{}{}'.format(name, variable_name)], '__UNSAFE__') @@ -461,6 +457,7 @@ def test_overwritten_jt_extra_vars(self, job, private_data_dir, mock_me): class TestGenericRun: + @pytest.mark.django_db(reset_sequences=True) def test_generic_failure(self, patch_Job, execution_environment, mock_me, mock_create_partition): job = Job(status='running', inventory=Inventory(), project=Project(local_path='/projects/_23_foo')) job.websocket_emit_status = mock.Mock() @@ -472,7 +469,7 @@ def test_generic_failure(self, patch_Job, execution_environment, mock_me, mock_c task.model.objects.get = mock.Mock(return_value=job) task.build_private_data_files = mock.Mock(side_effect=OSError()) - with mock.patch('awx.main.tasks.jobs.shutil.copytree'): + with mock.patch('awx.main.tasks.jobs.shutil.copytree'), mock.patch('awx.main.tasks.jobs.evaluate_policy'): with pytest.raises(Exception): task.run(1) @@ -481,26 +478,6 @@ def test_generic_failure(self, patch_Job, execution_environment, mock_me, mock_c assert update_model_call['status'] == 'error' assert update_model_call['emitted_events'] == 0 - def test_cancel_flag(self, job, update_model_wrapper, execution_environment, mock_me, mock_create_partition): - job.status = 'running' - job.cancel_flag = True - job.websocket_emit_status = mock.Mock() - job.send_notification_templates = mock.Mock() - job.execution_environment = execution_environment - - task = jobs.RunJob() - task.instance = job - task.update_model = mock.Mock(wraps=update_model_wrapper) - task.model.objects.get = mock.Mock(return_value=job) - task.build_private_data_files = mock.Mock() - - with mock.patch('awx.main.tasks.jobs.shutil.copytree'): - with pytest.raises(Exception): - task.run(1) - - for c in [mock.call(1, start_args='', status='canceled')]: - assert c in task.update_model.call_args_list - def test_event_count(self, mock_me): task = jobs.RunJob() task.runner_callback.dispatcher = mock.MagicMock() @@ -547,7 +524,7 @@ def test_created_by_extra_vars(self, mock_me): call_args, _ = task._write_extra_vars_file.call_args_list[0] private_data_dir, extra_vars, safe_dict = call_args - for name in JOB_VARIABLE_PREFIXES: + for name in get_job_variable_prefixes(): assert extra_vars['{}_user_id'.format(name)] == 123 assert extra_vars['{}_user_name'.format(name)] == "angry-spud" @@ -565,6 +542,7 @@ def test_survey_extra_vars(self, mock_me): private_data_dir, extra_vars, safe_dict = call_args assert extra_vars['super_secret'] == "CLASSIFIED" + @pytest.mark.django_db def test_awx_task_env(self, patch_Job, private_data_dir, execution_environment, mock_me): job = Job(project=Project(), inventory=Inventory()) job.execution_environment = execution_environment @@ -574,7 +552,8 @@ def test_awx_task_env(self, patch_Job, private_data_dir, execution_environment, task._write_extra_vars_file = mock.Mock() with mock.patch('awx.main.tasks.jobs.settings.AWX_TASK_ENV', {'FOO': 'BAR'}): - env = task.build_env(job, private_data_dir) + with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): + env = task.build_env(job, private_data_dir) assert env['FOO'] == 'BAR' @@ -589,6 +568,8 @@ def test_options_jinja_usage(self, adhoc_job, adhoc_update_model_wrapper, mock_m adhoc_job.send_notification_templates = mock.Mock() task = jobs.RunAdHocCommand() + adhoc_job.status = 'running' # to bypass status flip + task.instance = adhoc_job # to bypass fetch task.update_model = mock.Mock(wraps=adhoc_update_model_wrapper) task.model.objects.get = mock.Mock(return_value=adhoc_job) task.build_inventory = mock.Mock() @@ -634,12 +615,17 @@ def test_created_by_extra_vars(self, mock_me): call_args, _ = task._write_extra_vars_file.call_args_list[0] private_data_dir, extra_vars = call_args - for name in JOB_VARIABLE_PREFIXES: + for name in get_job_variable_prefixes(): assert extra_vars['{}_user_id'.format(name)] == 123 assert extra_vars['{}_user_name'.format(name)] == "angry-spud" class TestJobCredentials(TestJobExecution): + @pytest.fixture(autouse=True) + def mock_flag_enabled(self): + with mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=False): + yield + @pytest.fixture def job(self, execution_environment): job = Job(pk=1, inventory=Inventory(pk=1), project=Project(pk=1)) @@ -665,7 +651,9 @@ def _credentials_filter(credential_type__kind=None): ) with mock.patch.object(UnifiedJob, 'credentials', credentials_mock): - yield job + # Mock build_credentials_list to work with the cached credentials mechanism + with mock.patch.object(jobs.RunJob, 'build_credentials_list', return_value=job._credentials, autospec=True): + yield job @pytest.fixture def update_model_wrapper(self, job): @@ -863,6 +851,7 @@ def test_multi_vault_password_ask(self, private_data_dir, job, mock_me): [None, '0'], ], ) + @pytest.mark.django_db def test_net_credentials(self, authorize, expected_authorize, job, private_data_dir, mock_me): task = jobs.RunJob() task.instance = job @@ -919,6 +908,7 @@ def test_multi_cloud(self, private_data_dir, mock_me): assert safe_env['AZURE_PASSWORD'] == HIDDEN_PASSWORD + @pytest.mark.django_db def test_awx_task_env(self, settings, private_data_dir, job, mock_me): settings.AWX_TASK_ENV = {'FOO': 'BAR'} task = jobs.RunJob() @@ -928,6 +918,81 @@ def test_awx_task_env(self, settings, private_data_dir, job, mock_me): assert env['FOO'] == 'BAR' +class TestCallbacksEnabled(TestJobExecution): + @pytest.fixture(autouse=True) + def mock_flag_enabled(self): + with mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=False): + yield + + def test_callbacks_enabled_default(self, patch_Job, private_data_dir, execution_environment, mock_me): + job = Job(project=Project(), inventory=Inventory()) + job.execution_environment = execution_environment + + task = jobs.RunJob() + task.instance = job + task._write_extra_vars_file = mock.Mock() + + with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): + env = task.build_env(job, private_data_dir) + + assert env['ANSIBLE_CALLBACKS_ENABLED'] == 'indirect_instance_count' + + def test_callbacks_enabled_preserves_user_config(self, patch_Job, private_data_dir, execution_environment, mock_me): + job = Job(project=Project(), inventory=Inventory()) + job.execution_environment = execution_environment + + task = jobs.RunJob() + task.instance = job + task._write_extra_vars_file = mock.Mock() + + with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): + with mock.patch('awx.main.tasks.jobs.read_ansible_config', return_value={'callbacks_enabled': 'custom_callback,another_callback'}): + env = task.build_env(job, private_data_dir) + + assert env['ANSIBLE_CALLBACKS_ENABLED'] == 'indirect_instance_count,custom_callback,another_callback' + + def test_callbacks_enabled_uses_comma_delimiter(self, patch_Job, private_data_dir, execution_environment, mock_me): + job = Job(project=Project(), inventory=Inventory()) + job.execution_environment = execution_environment + + task = jobs.RunJob() + task.instance = job + task._write_extra_vars_file = mock.Mock() + + with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): + with mock.patch('awx.main.tasks.jobs.read_ansible_config', return_value={'callbacks_enabled': 'my_callback'}): + env = task.build_env(job, private_data_dir) + + assert env['ANSIBLE_CALLBACKS_ENABLED'] == 'indirect_instance_count,my_callback' + + def test_collect_host_queries_set_when_flag_on(self, patch_Job, private_data_dir, execution_environment, mock_me): + job = Job(project=Project(), inventory=Inventory()) + job.execution_environment = execution_environment + + task = jobs.RunJob() + task.instance = job + task._write_extra_vars_file = mock.Mock() + + with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): + with mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=True): + env = task.build_env(job, private_data_dir) + + assert env['AWX_COLLECT_HOST_QUERIES'] == '1' + + def test_collect_host_queries_not_set_when_flag_off(self, patch_Job, private_data_dir, execution_environment, mock_me): + job = Job(project=Project(), inventory=Inventory()) + job.execution_environment = execution_environment + + task = jobs.RunJob() + task.instance = job + task._write_extra_vars_file = mock.Mock() + + with mock.patch.object(task, 'build_credentials_list', return_value=[], autospec=True): + env = task.build_env(job, private_data_dir) + + assert 'AWX_COLLECT_HOST_QUERIES' not in env + + @pytest.mark.usefixtures("patch_Organization") class TestProjectUpdateGalaxyCredentials(TestJobExecution): @pytest.fixture @@ -1104,7 +1169,76 @@ def test_awx_task_env(self, project_update, settings, private_data_dir, scm_type assert env['FOO'] == 'BAR' +@pytest.mark.django_db +class TestProjectUpdateRefspec(TestJobExecution): + @pytest.fixture + def project_update(self, execution_environment): + org = Organization(pk=1) + proj = Project(pk=1, organization=org, allow_override=True) + project_update = ProjectUpdate(pk=1, project=proj, scm_type='git') + project_update.websocket_emit_status = mock.Mock() + project_update.execution_environment = execution_environment + return project_update + + def test_refspec_with_allow_override_includes_plus_prefix(self, project_update, private_data_dir, mock_me): + """Test that refspec includes + prefix to allow non-fast-forward updates when allow_override is True""" + task = jobs.RunProjectUpdate() + task.instance = project_update + + # Call build_extra_vars_file which sets the refspec + with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}): + task.build_extra_vars_file(project_update, private_data_dir) + + # Read the extra vars file to check the refspec + with open(os.path.join(private_data_dir, 'env', 'extravars')) as fd: + extra_vars = yaml.load(fd, Loader=SafeLoader) + + # Verify the refspec includes the + prefix for force updates + assert 'scm_refspec' in extra_vars + assert extra_vars['scm_refspec'] == '+refs/heads/*:refs/remotes/origin/*' + + def test_custom_refspec_not_overridden(self, project_update, private_data_dir, mock_me): + """Test that custom user-provided refspec is not overridden""" + task = jobs.RunProjectUpdate() + task.instance = project_update + project_update.scm_refspec = 'refs/pull/*/head:refs/remotes/origin/pr/*' + + with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}): + task.build_extra_vars_file(project_update, private_data_dir) + + with open(os.path.join(private_data_dir, 'env', 'extravars')) as fd: + extra_vars = yaml.load(fd, Loader=SafeLoader) + + # Custom refspec should be preserved + assert extra_vars['scm_refspec'] == 'refs/pull/*/head:refs/remotes/origin/pr/*' + + def test_no_refspec_without_allow_override(self, execution_environment, private_data_dir, mock_me): + """Test that no refspec is set when allow_override is False""" + org = Organization(pk=1) + proj = Project(pk=1, organization=org, allow_override=False) + project_update = ProjectUpdate(pk=1, project=proj, scm_type='git') + project_update.websocket_emit_status = mock.Mock() + project_update.execution_environment = execution_environment + + task = jobs.RunProjectUpdate() + task.instance = project_update + + with mock.patch.object(Licenser, 'validate', lambda *args, **kw: {}): + task.build_extra_vars_file(project_update, private_data_dir) + + with open(os.path.join(private_data_dir, 'env', 'extravars')) as fd: + extra_vars = yaml.load(fd, Loader=SafeLoader) + + # No refspec should be set + assert 'scm_refspec' not in extra_vars + + class TestInventoryUpdateCredentials(TestJobExecution): + @pytest.fixture(autouse=True) + def mock_flag_enabled(self): + with mock.patch('awx.main.tasks.jobs.flag_enabled', return_value=False): + yield + @pytest.fixture def inventory_update(self, execution_environment): return InventoryUpdate(pk=1, execution_environment=execution_environment, inventory_source=InventorySource(pk=1, inventory=Inventory(pk=1))) @@ -1458,8 +1592,8 @@ def test_fcntl_ioerror(): @mock.patch('os.open') -@mock.patch('logging.getLogger') -def test_acquire_lock_open_fail_logged(logging_getLogger, os_open, mock_me): +@mock.patch('awx.main.tasks.jobs.logger') +def test_acquire_lock_open_fail_logged(logger_mock, os_open, mock_me): err = OSError() err.errno = 3 err.strerror = 'dummy message' @@ -1469,21 +1603,18 @@ def test_acquire_lock_open_fail_logged(logging_getLogger, os_open, mock_me): os_open.side_effect = err - logger = mock.Mock() - logging_getLogger.return_value = logger - ProjectUpdate = jobs.RunProjectUpdate() with pytest.raises(OSError): ProjectUpdate.acquire_lock(instance) - assert logger.err.called_with("I/O error({0}) while trying to open lock file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message')) + logger_mock.error.assert_called_with("I/O error({0}) while trying to open lock file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message')) @mock.patch('os.open') @mock.patch('os.close') -@mock.patch('logging.getLogger') +@mock.patch('awx.main.tasks.jobs.logger') @mock.patch('fcntl.lockf') -def test_acquire_lock_acquisition_fail_logged(fcntl_lockf, logging_getLogger, os_close, os_open, mock_me): +def test_acquire_lock_acquisition_fail_logged(fcntl_lockf, logger_mock, os_close, os_open, mock_me): err = IOError() err.errno = 3 err.strerror = 'dummy message' @@ -1494,16 +1625,15 @@ def test_acquire_lock_acquisition_fail_logged(fcntl_lockf, logging_getLogger, os os_open.return_value = 3 - logger = mock.Mock() - logging_getLogger.return_value = logger - fcntl_lockf.side_effect = err ProjectUpdate = jobs.RunProjectUpdate() with pytest.raises(IOError): ProjectUpdate.acquire_lock(instance) os_close.assert_called_with(3) - assert logger.err.called_with("I/O error({0}) while trying to acquire lock on file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message')) + logger_mock.error.assert_called_with( + "I/O error({0}) while trying to acquire lock on file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message') + ) @pytest.mark.parametrize('injector_cls', [cls for cls in ManagedCredentialType.registry.values() if cls.injectors]) @@ -1528,7 +1658,7 @@ def test_managed_injector_redaction(injector_cls): assert 'very_secret_value' not in str(build_safe_env(env)) -def test_job_run_no_ee(mock_me, mock_create_partition): +def test_job_run_no_ee(mock_me, mock_create_partition, private_data_dir): org = Organization(pk=1) proj = Project(pk=1, organization=org) job = Job(project=proj, organization=org, inventory=Inventory(pk=1)) diff --git a/awx/main/tests/unit/test_validators.py b/awx/main/tests/unit/test_validators.py index 925ea64335eb..2512ab28af73 100644 --- a/awx/main/tests/unit/test_validators.py +++ b/awx/main/tests/unit/test_validators.py @@ -132,6 +132,25 @@ def test_cert_with_key(): assert not pem_objects[1]['key_enc'] +def test_ssh_key_with_whitespace(): + # Test that SSH keys with leading/trailing whitespace/newlines are properly sanitized + # This addresses issue #14219 where copy-paste can introduce hidden newlines + valid_key_with_whitespace = "\n\n" + TEST_SSH_KEY_DATA + "\n\n" + pem_objects = validate_ssh_private_key(valid_key_with_whitespace) + assert pem_objects[0]['key_type'] == 'rsa' + assert not pem_objects[0]['key_enc'] + + # Test with just leading whitespace + valid_key_leading = "\n\n\n" + TEST_SSH_KEY_DATA + pem_objects = validate_ssh_private_key(valid_key_leading) + assert pem_objects[0]['key_type'] == 'rsa' + + # Test with just trailing whitespace + valid_key_trailing = TEST_SSH_KEY_DATA + "\n\n\n" + pem_objects = validate_ssh_private_key(valid_key_trailing) + assert pem_objects[0]['key_type'] == 'rsa' + + @pytest.mark.parametrize( "var_str", [ diff --git a/awx/main/tests/unit/test_views.py b/awx/main/tests/unit/test_views.py index cc0df24089e7..371e44157ab5 100644 --- a/awx/main/tests/unit/test_views.py +++ b/awx/main/tests/unit/test_views.py @@ -10,7 +10,6 @@ from awx.api.views import JobList from awx.api.generics import ListCreateAPIView, SubListAttachDetachAPIView - HTTP_METHOD_NAMES = [ 'get', 'post', diff --git a/awx/main/tests/unit/utils/test_analytics_proxy.py b/awx/main/tests/unit/utils/test_analytics_proxy.py index 0096306e5709..0a49c33cb97a 100644 --- a/awx/main/tests/unit/utils/test_analytics_proxy.py +++ b/awx/main/tests/unit/utils/test_analytics_proxy.py @@ -4,7 +4,6 @@ from awx.main.utils.analytics_proxy import OIDCClient, TokenType, TokenError - MOCK_TOKEN_RESPONSE = { 'access_token': 'bob-access-token', 'expires_in': 500, diff --git a/awx/main/tests/unit/utils/test_candlepin_certificate_registration.py b/awx/main/tests/unit/utils/test_candlepin_certificate_registration.py new file mode 100644 index 000000000000..e8ada9bf6d04 --- /dev/null +++ b/awx/main/tests/unit/utils/test_candlepin_certificate_registration.py @@ -0,0 +1,383 @@ +# Copyright (c) 2026 Ansible, Inc. +# All Rights Reserved. + +from unittest import mock + +from awx.main.utils.candlepin import ( + _discover_org, + _fetch_candlepin_cert_from_db, + _fetch_registration_credentials_from_db, + _save_candlepin_cert_to_db, + _save_candlepin_registration_to_db, + _register_candlepin_consumer, + _run_candlepin_lifecycle, + get_or_generate_candlepin_certificate, + resolve_registration_credentials, +) + + +class TestCandlepinCertificateRegistration: + """Tests for Candlepin integration in certificate registration module.""" + + @mock.patch('awx.main.utils.candlepin.requests.get') + @mock.patch('awx.main.utils.candlepin.get_candlepin_ca') + def test_discover_org_success(self, mock_get_ca, mock_requests_get): + """Test successful organization discovery.""" + mock_get_ca.return_value = '/path/to/ca.pem' + mock_response = mock.Mock() + mock_response.json.return_value = [ + {'key': 'test_org', 'displayName': 'Test Organization'}, + {'key': 'other_org', 'displayName': 'Other Organization'}, + ] + mock_requests_get.return_value = mock_response + + org = _discover_org('https://candlepin.example.com', 'test_user', 'test_pass') + + assert org == 'test_org' + mock_requests_get.assert_called_once_with( + 'https://candlepin.example.com/users/test_user/owners', + auth=('test_user', 'test_pass'), + verify='/path/to/ca.pem', + timeout=30, + ) + + @mock.patch('awx.main.utils.candlepin.requests.get') + @mock.patch('awx.main.utils.candlepin.get_candlepin_ca') + def test_discover_org_no_ca(self, mock_get_ca, mock_requests_get): + """Test organization discovery without custom CA (uses system certs).""" + mock_get_ca.return_value = None + mock_response = mock.Mock() + mock_response.json.return_value = [{'key': 'test_org', 'displayName': 'Test Organization'}] + mock_requests_get.return_value = mock_response + + org = _discover_org('https://candlepin.example.com', 'test_user', 'test_pass') + + assert org == 'test_org' + # Should use True for verify when no CA is configured + mock_requests_get.assert_called_once_with( + 'https://candlepin.example.com/users/test_user/owners', + auth=('test_user', 'test_pass'), + verify=True, + timeout=30, + ) + + @mock.patch('awx.main.utils.candlepin.requests.get') + def test_discover_org_no_verify_tls(self, mock_requests_get): + """Test organization discovery with TLS verification disabled.""" + mock_response = mock.Mock() + mock_response.json.return_value = [{'key': 'test_org', 'displayName': 'Test Organization'}] + mock_requests_get.return_value = mock_response + + org = _discover_org('https://candlepin.example.com', 'test_user', 'test_pass', verify_tls=False) + + assert org == 'test_org' + # Should use False for verify when verify_tls=False + mock_requests_get.assert_called_once_with( + 'https://candlepin.example.com/users/test_user/owners', + auth=('test_user', 'test_pass'), + verify=False, + timeout=30, + ) + + @mock.patch('awx.main.utils.candlepin.settings') + def test_fetch_candlepin_cert_from_db(self, mock_settings): + """Test fetching Candlepin cert from conf_settings.""" + mock_settings.CANDLEPIN_CONSUMER_UUID = 'test-uuid' + mock_settings.CANDLEPIN_CERT_PEM = 'cert-pem-data' + mock_settings.CANDLEPIN_KEY_PEM = 'key-pem-data' + + cert, key, uuid = _fetch_candlepin_cert_from_db() + + assert cert == 'cert-pem-data' + assert key == 'key-pem-data' + assert uuid == 'test-uuid' + + @mock.patch('awx.main.utils.candlepin._discover_org') + @mock.patch('awx.main.utils.candlepin.settings') + def test_fetch_registration_credentials_from_db(self, mock_settings, mock_discover_org): + """Test fetching registration credentials from settings. + + When both REDHAT and SUBSCRIPTIONS credentials exist, REDHAT takes priority + for both authentication and org discovery. + """ + mock_settings.REDHAT_USERNAME = 'test_user' + mock_settings.REDHAT_PASSWORD = 'test_pass' + mock_settings.INSTALL_UUID = 'test-install-uuid' + mock_settings.SUBSCRIPTIONS_USERNAME = 'subs_user' + mock_settings.SUBSCRIPTIONS_PASSWORD = 'subs_pass' + mock_discover_org.return_value = 'test_org' + + username, password, org, install_uuid = _fetch_registration_credentials_from_db() + + assert username == 'test_user' + assert password == 'test_pass' + assert org == 'test_org' + assert install_uuid == 'test-install-uuid' + # Verify _discover_org was called with REDHAT credentials (takes priority) + assert mock_discover_org.call_count == 1 + args = mock_discover_org.call_args[0] + assert args[1] == 'test_user' # REDHAT_USERNAME (selected) + assert args[2] == 'test_pass' # REDHAT_PASSWORD (selected) + + @mock.patch('awx.main.utils.candlepin._discover_org') + @mock.patch('awx.main.utils.candlepin.settings') + def test_fetch_registration_credentials_no_verify_tls(self, mock_settings, mock_discover_org): + """Test fetching credentials passes verify_tls=False to _discover_org. + + Also verifies that selected credentials (REDHAT in this case) are used for org discovery. + """ + mock_settings.REDHAT_USERNAME = 'test_user' + mock_settings.REDHAT_PASSWORD = 'test_pass' + mock_settings.INSTALL_UUID = 'test-install-uuid' + mock_settings.SUBSCRIPTIONS_USERNAME = 'subs_user' + mock_settings.SUBSCRIPTIONS_PASSWORD = 'subs_pass' + mock_discover_org.return_value = 'test_org' + + username, password, org, install_uuid = _fetch_registration_credentials_from_db(verify_tls=False) + + assert username == 'test_user' + assert password == 'test_pass' + assert org == 'test_org' + assert install_uuid == 'test-install-uuid' + # Verify _discover_org was called with verify_tls=False and REDHAT credentials + mock_discover_org.assert_called_once() + call_args = mock_discover_org.call_args + assert call_args[0][1] == 'test_user' # REDHAT_USERNAME (selected) + assert call_args[0][2] == 'test_pass' # REDHAT_PASSWORD (selected) + call_kwargs = call_args[1] + assert call_kwargs['verify_tls'] is False + + @mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db') + def test_resolve_registration_credentials_no_overrides(self, mock_fetch): + """Test resolve_registration_credentials with no overrides.""" + mock_fetch.return_value = ('db_user', 'db_pass', 'db_org', 'install-uuid') + + username, password, org, install_uuid, errors = resolve_registration_credentials() + + assert username == 'db_user' + assert password == 'db_pass' + assert org == 'db_org' + assert install_uuid == 'install-uuid' + assert errors is None + + @mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db') + def test_resolve_registration_credentials_with_overrides(self, mock_fetch): + """Test resolve_registration_credentials with CLI overrides.""" + mock_fetch.return_value = ('db_user', 'db_pass', 'db_org', 'install-uuid') + + username, password, org, install_uuid, errors = resolve_registration_credentials( + username_override='cli_user', password_override='cli_pass', org_override='cli_org' + ) + + assert username == 'cli_user' + assert password == 'cli_pass' + assert org == 'cli_org' + assert install_uuid == 'install-uuid' + assert errors is None + + @mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db') + def test_resolve_registration_credentials_verify_tls_false(self, mock_fetch): + """Test resolve_registration_credentials passes verify_tls=False to fetch function.""" + mock_fetch.return_value = ('db_user', 'db_pass', 'db_org', 'install-uuid') + + username, password, org, install_uuid, errors = resolve_registration_credentials(verify_tls=False) + + # Verify _fetch_registration_credentials_from_db was called with verify_tls=False + mock_fetch.assert_called_once_with(verify_tls=False) + assert username == 'db_user' + assert password == 'db_pass' + assert org == 'db_org' + assert install_uuid == 'install-uuid' + assert errors is None + + @mock.patch('awx.main.utils.candlepin.parse_cert') + @mock.patch('awx.main.utils.candlepin.settings') + def test_save_candlepin_cert_to_db(self, mock_settings, mock_parse_cert): + """Test saving Candlepin cert to conf_settings.""" + mock_parse_cert.return_value = { + 'serial': '123456', + 'cn': 'test-consumer', + 'not_before': '2026-01-01T00:00:00+00:00', + 'not_after': '2027-01-01T00:00:00+00:00', + 'days_remaining': 365, + } + + result = _save_candlepin_cert_to_db('new-cert', 'new-key') + + assert result is True + # Verify settings were assigned + assert mock_settings.CANDLEPIN_CERT_PEM == 'new-cert' + assert mock_settings.CANDLEPIN_KEY_PEM == 'new-key' + assert mock_settings.CANDLEPIN_SERIAL_NUMBER == '123456' + + @mock.patch('awx.main.utils.candlepin.parse_cert') + @mock.patch('awx.main.utils.candlepin.settings') + def test_save_candlepin_registration_to_db(self, mock_settings, mock_parse_cert): + """Test saving Candlepin registration to conf_settings.""" + mock_parse_cert.return_value = { + 'serial': '789012', + 'cn': 'test-consumer', + 'not_before': '2026-01-01T00:00:00+00:00', + 'not_after': '2027-01-01T00:00:00+00:00', + 'days_remaining': 365, + } + + result = _save_candlepin_registration_to_db('cert', 'key', 'uuid') + + assert result is True + # Verify all registration data was saved + assert mock_settings.CANDLEPIN_CONSUMER_UUID == 'uuid' + assert mock_settings.CANDLEPIN_CERT_PEM == 'cert' + assert mock_settings.CANDLEPIN_KEY_PEM == 'key' + assert mock_settings.CANDLEPIN_SERIAL_NUMBER == '789012' + + @mock.patch('awx.main.utils.candlepin._save_candlepin_registration_to_db') + @mock.patch('awx.main.utils.candlepin.CandlepinClient') + @mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db') + @mock.patch('awx.main.utils.candlepin.get_proxy_url') + @mock.patch('awx.main.utils.candlepin.get_candlepin_ca') + @mock.patch('awx.main.utils.candlepin.get_candlepin_url') + def test_register_candlepin_consumer_success(self, mock_get_url, mock_get_ca, mock_get_proxy, mock_fetch_creds, mock_client_class, mock_save): + """Test successful Candlepin consumer registration.""" + mock_get_url.return_value = 'https://candlepin.example.com' + mock_get_ca.return_value = '/path/to/ca.pem' + mock_get_proxy.return_value = None + mock_fetch_creds.return_value = ('user', 'pass', 'org', 'install-uuid') + mock_save.return_value = True + + mock_client = mock.Mock() + mock_client.register_consumer.return_value = ('cert', 'key', 'uuid') + mock_client_class.return_value = mock_client + + cert, key, uuid = _register_candlepin_consumer() + + assert cert == 'cert' + assert key == 'key' + assert uuid == 'uuid' + mock_save.assert_called_once_with('cert', 'key', 'uuid') + + @mock.patch('awx.main.utils.candlepin._fetch_registration_credentials_from_db') + def test_register_candlepin_consumer_missing_credentials(self, mock_fetch_creds): + """Test registration fails when credentials are missing.""" + mock_fetch_creds.return_value = (None, None, None, None) + + cert, key, uuid = _register_candlepin_consumer() + + assert cert is None + assert key is None + assert uuid is None + + @mock.patch('awx.main.utils.candlepin._save_candlepin_cert_to_db') + @mock.patch('awx.main.utils.candlepin.run_candlepin_lifecycle') + @mock.patch('awx.main.utils.candlepin.get_proxy_url') + @mock.patch('awx.main.utils.candlepin.get_candlepin_ca') + @mock.patch('awx.main.utils.candlepin.get_renewal_days') + @mock.patch('awx.main.utils.candlepin.get_candlepin_url') + def test_run_candlepin_lifecycle_with_renewal(self, mock_get_url, mock_get_days, mock_get_ca, mock_get_proxy, mock_lifecycle, mock_save): + """Test lifecycle with certificate renewal.""" + mock_get_url.return_value = 'https://candlepin.example.com' + mock_get_days.return_value = 90 + mock_get_ca.return_value = '/path/to/ca.pem' + mock_get_proxy.return_value = None + mock_lifecycle.return_value = ('new-cert', 'new-key') + mock_save.return_value = True + + cert, key = _run_candlepin_lifecycle('old-cert', 'old-key', 'real-uuid') + + assert cert == 'new-cert' + assert key == 'new-key' + mock_lifecycle.assert_called_once() + mock_save.assert_called_once_with('new-cert', 'new-key') + + @mock.patch('awx.main.utils.candlepin.is_cert_valid') + @mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle') + @mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db') + def test_get_or_generate_candlepin_certificate_existing_valid(self, mock_fetch, mock_lifecycle, mock_is_valid): + """Test get_or_generate with existing valid certificate.""" + mock_fetch.return_value = ('cert-pem', 'key-pem', 'consumer-uuid') + mock_lifecycle.return_value = ('cert-pem', 'key-pem') + mock_is_valid.return_value = True + + cert, key = get_or_generate_candlepin_certificate() + + assert cert == 'cert-pem' + assert key == 'key-pem' + mock_lifecycle.assert_called_once_with('cert-pem', 'key-pem', 'consumer-uuid') + + @mock.patch('awx.main.utils.candlepin.is_cert_valid') + @mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle') + @mock.patch('awx.main.utils.candlepin._register_candlepin_consumer') + @mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db') + def test_get_or_generate_candlepin_certificate_register_new(self, mock_fetch, mock_register, mock_lifecycle, mock_is_valid): + """Test get_or_generate when no certificate exists - registers new.""" + mock_fetch.return_value = (None, None, None) + mock_register.return_value = ('new-cert', 'new-key', 'new-uuid') + mock_lifecycle.return_value = ('new-cert', 'new-key') + mock_is_valid.return_value = True + + cert, key = get_or_generate_candlepin_certificate() + + assert cert == 'new-cert' + assert key == 'new-key' + mock_register.assert_called_once() + mock_lifecycle.assert_called_once_with('new-cert', 'new-key', 'new-uuid') + + @mock.patch('awx.main.utils.candlepin._register_candlepin_consumer') + @mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db') + def test_get_or_generate_candlepin_certificate_registration_fails(self, mock_fetch, mock_register): + """Test get_or_generate when registration fails.""" + mock_fetch.return_value = (None, None, None) + mock_register.return_value = (None, None, None) + + cert, key = get_or_generate_candlepin_certificate() + + assert cert is None + assert key is None + + @mock.patch('awx.main.utils.candlepin.is_cert_valid') + @mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle') + @mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db') + def test_get_or_generate_candlepin_certificate_invalid_cert(self, mock_fetch, mock_lifecycle, mock_is_valid): + """Test get_or_generate when certificate is invalid.""" + mock_fetch.return_value = ('cert-pem', 'key-pem', 'consumer-uuid') + mock_lifecycle.return_value = ('cert-pem', 'key-pem') + mock_is_valid.return_value = False + + cert, key = get_or_generate_candlepin_certificate() + + assert cert is None + assert key is None + + @mock.patch('awx.main.utils.candlepin.is_cert_valid') + @mock.patch('awx.main.utils.candlepin._run_candlepin_lifecycle') + @mock.patch('awx.main.utils.candlepin._fetch_candlepin_cert_from_db') + def test_get_or_generate_candlepin_certificate_expired_cert_renewed_successfully(self, mock_fetch, mock_lifecycle, mock_is_valid): + """Test get_or_generate with expired certificate that is successfully renewed.""" + mock_fetch.return_value = ('expired-cert', 'old-key', 'consumer-uuid') + # Lifecycle successfully renews + mock_lifecycle.return_value = ('new-cert', 'new-key') + # New certificate is valid + mock_is_valid.return_value = True + + cert, key = get_or_generate_candlepin_certificate() + + assert cert == 'new-cert' + assert key == 'new-key' + mock_lifecycle.assert_called_once_with('expired-cert', 'old-key', 'consumer-uuid') + + @mock.patch('awx.main.utils.candlepin.parse_cert') + @mock.patch('awx.main.utils.candlepin.settings') + def test_save_candlepin_registration_to_db_cert_parse_failure(self, mock_settings, mock_parse_cert): + """Test _save_candlepin_registration_to_db handles cert parsing failure gracefully.""" + # Cert parsing fails + mock_parse_cert.side_effect = ValueError('Invalid certificate format') + + result = _save_candlepin_registration_to_db('invalid-cert', 'key-pem', 'consumer-uuid') + + # Should still save registration even if parsing fails + assert result is True + # Verify UUID, cert, key, and serial (empty string) were saved + assert mock_settings.CANDLEPIN_CONSUMER_UUID == 'consumer-uuid' + assert mock_settings.CANDLEPIN_CERT_PEM == 'invalid-cert' + assert mock_settings.CANDLEPIN_KEY_PEM == 'key-pem' + assert mock_settings.CANDLEPIN_SERIAL_NUMBER == '' diff --git a/awx/main/tests/unit/utils/test_candlepin_client.py b/awx/main/tests/unit/utils/test_candlepin_client.py new file mode 100644 index 000000000000..13c3197b5e52 --- /dev/null +++ b/awx/main/tests/unit/utils/test_candlepin_client.py @@ -0,0 +1,124 @@ +# Copyright (c) 2026 Ansible, Inc. +# All Rights Reserved. + +import os +from unittest import mock + +from awx.main.utils.candlepin.client import CandlepinClient, _temp_cert_files + + +class TestCandlepinClient: + """Tests for CandlepinClient.""" + + def test_base_url_required(self): + """Test base_url parameter is required.""" + client = CandlepinClient(base_url='https://subscription.example.com/candlepin') + assert client.base_url == 'https://subscription.example.com/candlepin' + + def test_verify_tls_enabled_by_default(self): + """Test TLS verification is enabled by default.""" + client = CandlepinClient(base_url='https://test.example.com') + assert client.verify is True + + def test_verify_tls_with_ca(self): + """Test TLS verification with custom CA.""" + client = CandlepinClient(base_url='https://test.example.com', candlepin_ca='/path/to/ca.pem') + assert client.verify == '/path/to/ca.pem' + + def test_proxy_configuration(self): + """Test proxy configuration.""" + client = CandlepinClient(base_url='https://test.example.com', proxy='http://proxy.example.com:8080') + assert client.proxies == {'https': 'http://proxy.example.com:8080', 'http': 'http://proxy.example.com:8080'} + + def test_temp_cert_files_cleanup(self): + """Test temporary certificate files are created and cleaned up.""" + cert_pem = '-----BEGIN CERTIFICATE-----\ntest_cert\n-----END CERTIFICATE-----' + key_pem = '-----BEGIN PRIVATE KEY-----\ntest_key\n-----END PRIVATE KEY-----' + + with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path): + assert os.path.exists(cert_path) + assert os.path.exists(key_path) + # Verify file permissions + cert_stat = os.stat(cert_path) + assert oct(cert_stat.st_mode)[-3:] == '600' + + # Verify cleanup + assert not os.path.exists(cert_path) + assert not os.path.exists(key_path) + + @mock.patch('awx.main.utils.candlepin.client.requests.post') + def test_register_consumer_success(self, mock_post): + """Test successful consumer registration.""" + mock_response = mock.Mock() + mock_response.ok = True + mock_response.json.return_value = { + 'uuid': 'test-consumer-uuid', + 'idCert': { + 'cert': '-----BEGIN CERTIFICATE-----\ncert_data\n-----END CERTIFICATE-----', + 'key': '-----BEGIN PRIVATE KEY-----\nkey_data\n-----END PRIVATE KEY-----', + }, + } + mock_post.return_value = mock_response + + client = CandlepinClient(base_url='https://test.example.com') + cert_pem, key_pem, consumer_uuid = client.register_consumer('test_user', 'test_pass', 'test_org', install_uuid='test-install-uuid') + + assert consumer_uuid == 'test-consumer-uuid' + assert '-----BEGIN CERTIFICATE-----' in cert_pem + assert '-----BEGIN PRIVATE KEY-----' in key_pem + + @mock.patch('awx.main.utils.candlepin.client.requests.put') + def test_checkin_success(self, mock_put): + """Test successful check-in.""" + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_put.return_value = mock_response + + client = CandlepinClient(base_url='https://test.example.com') + cert_pem = '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----' + key_pem = '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----' + + result = client.checkin('test-uuid', cert_pem, key_pem) + assert result is True + + @mock.patch('awx.main.utils.candlepin.client.requests.get') + def test_get_consumer_success(self, mock_get): + """Test successful consumer retrieval.""" + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'uuid': 'test-consumer-uuid', + 'name': 'aap-12345678', + 'idCert': {'cert': '-----BEGIN CERTIFICATE-----\nserver_cert\n-----END CERTIFICATE-----', 'serial': {'serial': 123456789}}, + } + mock_get.return_value = mock_response + + client = CandlepinClient(base_url='https://test.example.com') + cert_pem = '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----' + key_pem = '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----' + + result = client.get_consumer('test-uuid', cert_pem, key_pem) + assert result is not None + assert result['uuid'] == 'test-consumer-uuid' + assert 'idCert' in result + + @mock.patch('awx.main.utils.candlepin.client.requests.post') + def test_regenerate_cert_success(self, mock_post): + """Test successful certificate regeneration.""" + mock_response = mock.Mock() + mock_response.ok = True + mock_response.json.return_value = { + 'idCert': { + 'cert': '-----BEGIN CERTIFICATE-----\nnew_cert\n-----END CERTIFICATE-----', + 'key': '-----BEGIN PRIVATE KEY-----\nnew_key\n-----END PRIVATE KEY-----', + } + } + mock_post.return_value = mock_response + + client = CandlepinClient(base_url='https://test.example.com') + old_cert = '-----BEGIN CERTIFICATE-----\nold\n-----END CERTIFICATE-----' + old_key = '-----BEGIN PRIVATE KEY-----\nold\n-----END PRIVATE KEY-----' + + new_cert, new_key = client.regenerate_cert('test-uuid', old_cert, old_key) + assert 'new_cert' in new_cert + assert 'new_key' in new_key diff --git a/awx/main/tests/unit/utils/test_candlepin_lifecycle.py b/awx/main/tests/unit/utils/test_candlepin_lifecycle.py new file mode 100644 index 000000000000..f9762ed2a80d --- /dev/null +++ b/awx/main/tests/unit/utils/test_candlepin_lifecycle.py @@ -0,0 +1,222 @@ +# Copyright (c) 2026 Ansible, Inc. +# All Rights Reserved. + +from datetime import datetime, timezone +from unittest import mock + +from awx.main.utils.candlepin.lifecycle import ( + parse_cert, + needs_renewal, + run_candlepin_lifecycle, + get_candlepin_url, + get_renewal_days, + get_candlepin_ca, + get_proxy_url, +) + +# Sample test certificate (expires far in the future for testing) +SAMPLE_CERT_PEM = """-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKJ5VZ2cPQE5MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMjYwMTAxMDAwMDAwWhcNMjcwMTAxMDAwMDAwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA0a7Y3l3X4L7pKq3xDl8vCRrRK6qU5dF7r3xQH5YRz4hZJN9wE3xW0qDT +-----END CERTIFICATE-----""" + + +class TestCandlepinLifecycle: + """Tests for Candlepin lifecycle functions.""" + + @mock.patch('awx.main.utils.candlepin.lifecycle.settings') + def test_get_candlepin_url_default(self, mock_settings): + """Test default Candlepin URL from defaults.py.""" + mock_settings.AWX_ANALYTICS_CANDLEPIN_URL = 'https://subscription.example.com/candlepin/' + url = get_candlepin_url() + assert url == 'https://subscription.example.com/candlepin/' + + @mock.patch('awx.main.utils.candlepin.lifecycle.settings') + def test_get_renewal_days_from_settings(self, mock_settings): + """Test renewal days from Django settings.""" + mock_settings.AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS = 45 + days = get_renewal_days() + assert days == 45 + + @mock.patch('awx.main.utils.candlepin.lifecycle.os.path.isfile') + @mock.patch('awx.main.utils.candlepin.lifecycle.settings') + def test_get_candlepin_ca_from_settings(self, mock_settings, mock_isfile): + """Test Candlepin CA from Django settings when file exists.""" + mock_settings.AWX_ANALYTICS_CANDLEPIN_CA = '/path/to/ca.pem' + mock_isfile.return_value = True + ca = get_candlepin_ca() + assert ca == '/path/to/ca.pem' + + @mock.patch('awx.main.utils.candlepin.lifecycle.os.path.isfile') + @mock.patch('awx.main.utils.candlepin.lifecycle.settings') + def test_get_candlepin_ca_file_not_found(self, mock_settings, mock_isfile): + """Test Candlepin CA returns None when configured path doesn't exist.""" + mock_settings.AWX_ANALYTICS_CANDLEPIN_CA = '/path/to/missing.pem' + mock_isfile.return_value = False + ca = get_candlepin_ca() + assert ca is None + + @mock.patch('awx.main.utils.candlepin.lifecycle.settings') + def test_get_proxy_url_from_settings(self, mock_settings): + """Test proxy URL from Django settings.""" + mock_settings.AWX_ANALYTICS_CANDLEPIN_PROXY_URL = 'http://proxy.example.com:8080' + proxy = get_proxy_url() + assert proxy == 'http://proxy.example.com:8080' + + @mock.patch('awx.main.utils.candlepin.lifecycle.x509.load_pem_x509_certificate') + def test_parse_cert(self, mock_load_cert): + """Test certificate parsing.""" + # Mock a certificate object + mock_cert = mock.Mock() + mock_cert.serial_number = 123456 + mock_cert.not_valid_before_utc = datetime(2026, 1, 1, tzinfo=timezone.utc) + mock_cert.not_valid_after_utc = datetime(2027, 1, 1, tzinfo=timezone.utc) + + # Mock subject and issuer + mock_attr = mock.Mock() + mock_attr.oid._name = 'commonName' + mock_attr.value = 'test-cn' + mock_cert.subject = [mock_attr] + mock_cert.issuer = [mock_attr] + + mock_load_cert.return_value = mock_cert + + result = parse_cert('fake-pem') + + assert result['serial'] == '123456' + assert result['cn'] == 'test-cn' + assert 'not_before' in result + assert 'not_after' in result + assert 'days_remaining' in result + + @mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert') + def test_needs_renewal_true(self, mock_parse): + """Test needs_renewal returns True when cert is expiring soon.""" + mock_parse.return_value = {'days_remaining': 10} + + result = needs_renewal('fake-cert', days_before_expiry=30) + assert result is True + + @mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert') + def test_needs_renewal_false(self, mock_parse): + """Test needs_renewal returns False when cert has time remaining.""" + mock_parse.return_value = {'days_remaining': 100} + + result = needs_renewal('fake-cert', days_before_expiry=30) + assert result is False + + @mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient') + @mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert') + def test_run_candlepin_lifecycle_no_renewal_needed(self, mock_parse, mock_client_class): + """Test lifecycle when no renewal is needed.""" + mock_parse.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01T00:00:00+00:00', 'days_remaining': 100} + + mock_client = mock.Mock() + mock_client.checkin.return_value = True + mock_client.get_consumer.return_value = None # Skip serial comparison + mock_client_class.return_value = mock_client + + cert_pem, key_pem = run_candlepin_lifecycle('cert-pem', 'key-pem', 'consumer-uuid', candlepin_url='https://test.example.com', renewal_days=30) + + assert cert_pem == 'cert-pem' + assert key_pem == 'key-pem' + mock_client.checkin.assert_called_once() + mock_client.regenerate_cert.assert_not_called() + + @mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient') + @mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert') + def test_run_candlepin_lifecycle_with_renewal(self, mock_parse, mock_client_class): + """Test lifecycle when renewal is needed.""" + # parse_cert is called multiple times: + # 1. Parse original cert + # 2. In needs_renewal() to check expiry + # 3. Parse new cert after renewal for logging + mock_parse.side_effect = [ + {'serial': '123', 'cn': 'test', 'not_after': '2026-02-01', 'days_remaining': 10}, # Original cert + {'serial': '123', 'cn': 'test', 'not_after': '2026-02-01', 'days_remaining': 10}, # needs_renewal check + {'serial': '456', 'cn': 'test', 'not_after': '2027-02-01', 'days_remaining': 365}, # New cert + ] + + mock_client = mock.Mock() + mock_client.checkin.return_value = True + mock_client.get_consumer.return_value = None # Skip serial comparison + mock_client.regenerate_cert.return_value = ('new-cert', 'new-key') + mock_client_class.return_value = mock_client + + cert_pem, key_pem = run_candlepin_lifecycle('old-cert', 'old-key', 'consumer-uuid', renewal_days=90) + + assert cert_pem == 'new-cert' + assert key_pem == 'new-key' + mock_client.regenerate_cert.assert_called_once() + + @mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient') + @mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert') + def test_run_candlepin_lifecycle_expired_cert_renewal(self, mock_parse, mock_client_class): + """Test lifecycle renews an expired certificate.""" + # parse_cert called for: + # 1. Parse original expired cert + # 2. needs_renewal check (expired, so returns True) + # 3. Parse new cert after renewal + mock_parse.side_effect = [ + {'serial': '123', 'cn': 'test', 'not_after': '2025-12-31', 'days_remaining': -120}, # Expired cert + {'serial': '123', 'cn': 'test', 'not_after': '2025-12-31', 'days_remaining': -120}, # needs_renewal + {'serial': '456', 'cn': 'test', 'not_after': '2027-06-01', 'days_remaining': 365}, # New cert + ] + + mock_client = mock.Mock() + mock_client.checkin.return_value = True + mock_client.get_consumer.return_value = None + mock_client.regenerate_cert.return_value = ('new-cert', 'new-key') + mock_client_class.return_value = mock_client + + cert_pem, key_pem = run_candlepin_lifecycle('expired-cert', 'old-key', 'consumer-uuid', renewal_days=90) + + assert cert_pem == 'new-cert' + assert key_pem == 'new-key' + mock_client.regenerate_cert.assert_called_once() + + @mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient') + @mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert') + def test_run_candlepin_lifecycle_checkin_failure_revoked_cert(self, mock_parse, mock_client_class): + """Test lifecycle handles check-in failure (e.g., revoked certificate).""" + mock_parse.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 100} + + # Check-in fails (could indicate revoked cert or deleted consumer) + mock_client = mock.Mock() + mock_client.checkin.return_value = False + mock_client.get_consumer.return_value = None # get_consumer also fails + mock_client_class.return_value = mock_client + + # Lifecycle should continue and return original cert + cert_pem, key_pem = run_candlepin_lifecycle('cert-pem', 'key-pem', 'consumer-uuid', renewal_days=30) + + assert cert_pem == 'cert-pem' + assert key_pem == 'key-pem' + mock_client.checkin.assert_called_once() + # Regeneration should not be attempted since get_consumer indicates consumer doesn't exist + mock_client.regenerate_cert.assert_not_called() + + @mock.patch('awx.main.utils.candlepin.lifecycle.CandlepinClient') + @mock.patch('awx.main.utils.candlepin.lifecycle.parse_cert') + def test_run_candlepin_lifecycle_consumer_deleted_server_side(self, mock_parse, mock_client_class): + """Test lifecycle detects when consumer was deleted from Candlepin server.""" + mock_parse.return_value = {'serial': '123', 'cn': 'test', 'not_after': '2027-01-01', 'days_remaining': 100} + + # Both check-in and get_consumer fail (consumer deleted) + mock_client = mock.Mock() + mock_client.checkin.return_value = False + mock_client.get_consumer.return_value = None + mock_client_class.return_value = mock_client + + cert_pem, key_pem = run_candlepin_lifecycle('cert-pem', 'key-pem', 'consumer-uuid', renewal_days=30) + + # Should return original cert (caller can attempt mTLS, which will fail and fall back to service account) + assert cert_pem == 'cert-pem' + assert key_pem == 'key-pem' + mock_client.checkin.assert_called_once() + mock_client.get_consumer.assert_called_once() + mock_client.regenerate_cert.assert_not_called() diff --git a/awx/main/tests/unit/utils/test_common.py b/awx/main/tests/unit/utils/test_common.py index 20585b8fa33a..b5983497b20a 100644 --- a/awx/main/tests/unit/utils/test_common.py +++ b/awx/main/tests/unit/utils/test_common.py @@ -240,7 +240,15 @@ def test_extract_ansible_vars(): ('git', 'https://example.com/bar.git', 'user', 'pw', True, False, 'https://user:pw@example.com/bar.git'), ('git', 'https://example@example.com/bar.git', False, 'something', True, False, 'https://example.com/bar.git'), # Special github/bitbucket cases - ('git', 'notgit@github.com:ansible/awx.git', True, True, True, False, ValueError('Username must be "git" for SSH access to github.com.')), + ( + 'git', + 'notgit@github.com:ansible/awx.git', + True, + True, + True, + False, + ValueError('Username must be "git" for SSH access to github.com.'), + ), ( 'git', 'notgit@bitbucket.org:does-not-exist/example.git', @@ -322,17 +330,13 @@ def test_good_call(self, regex_expr, re_flags): def test_bad_call(self, regex_expr, re_flags): h = HostnameRegexValidator(regex=regex_expr, flags=re_flags) - try: + with pytest.raises(ValidationError, match=r"^\['illegal characters detected in hostname=@#\$%\)\$#\(TUFAS_DG. Please verify.'\]$"): h("@#$%)$#(TUFAS_DG") - except ValidationError as e: - assert e.message is not None def test_good_call_with_inverse(self, regex_expr, re_flags, inverse_match=True): h = HostnameRegexValidator(regex=regex_expr, flags=re_flags, inverse_match=inverse_match) - try: + with pytest.raises(ValidationError, match=r"^\['Enter a valid value.'\]$"): h("1.2.3.4") - except ValidationError as e: - assert e.message is not None def test_bad_call_with_inverse(self, regex_expr, re_flags, inverse_match=True): h = HostnameRegexValidator(regex=regex_expr, flags=re_flags, inverse_match=inverse_match) diff --git a/awx/main/tests/unit/utils/test_execution_environments.py b/awx/main/tests/unit/utils/test_execution_environments.py index 941623d7e127..31ed3eaf7d3b 100644 --- a/awx/main/tests/unit/utils/test_execution_environments.py +++ b/awx/main/tests/unit/utils/test_execution_environments.py @@ -1,12 +1,9 @@ -import shutil import os -from uuid import uuid4 import pytest from awx_plugins.interfaces._temporary_private_container_api import get_incontainer_path - private_data_dir = '/tmp/pdd_iso/awx_xxx' @@ -25,17 +22,11 @@ def test_switch_paths(container_path, host_path): assert get_incontainer_path(host_path, private_data_dir) == container_path -def test_symlink_isolation_dir(request): - rand_str = str(uuid4())[:8] - dst_path = f'/tmp/ee_{rand_str}_symlink_dst' - src_path = f'/tmp/ee_{rand_str}_symlink_src' - - def remove_folders(): - os.unlink(dst_path) - shutil.rmtree(src_path) +def test_symlink_isolation_dir(tmp_path): + src_path = tmp_path / 'symlink_src' + dst_path = tmp_path / 'symlink_dst' - request.addfinalizer(remove_folders) - os.mkdir(src_path) + src_path.mkdir() os.symlink(src_path, dst_path) pdd = f'{dst_path}/awx_xxx' diff --git a/awx/main/tests/unit/utils/test_inventory_vars.py b/awx/main/tests/unit/utils/test_inventory_vars.py new file mode 100644 index 000000000000..8ba55c900e17 --- /dev/null +++ b/awx/main/tests/unit/utils/test_inventory_vars.py @@ -0,0 +1,110 @@ +""" +Test utility functions and classes for inventory variable handling. +""" + +import pytest + +from awx.main.utils.inventory_vars import InventoryVariable +from awx.main.utils.inventory_vars import InventoryGroupVariables + + +def test_inventory_variable_update_basic(): + """Test basic functionality of an inventory variable.""" + x = InventoryVariable("x") + assert x.has_no_source + x.update(1, 101) + assert str(x) == "1" + x.update(2, 102) + assert str(x) == "2" + x.update(3, 103) + assert str(x) == "3" + x.delete(102) + assert str(x) == "3" + x.delete(103) + assert str(x) == "1" + x.delete(101) + assert x.value is None + assert x.has_no_source + + +@pytest.mark.parametrize( + "updates", # (, , ) + [ + ((101, 1, 1),), + ((101, 1, 1), (101, None, None)), + ((101, 1, 1), (102, 2, 2), (102, None, 1)), + ((101, 1, 1), (102, 2, 2), (101, None, 2), (102, None, None)), + ( + (101, 0, 0), + (101, 1, 1), + (102, 2, 2), + (103, 3, 3), + (102, None, 3), + (103, None, 1), + (101, None, None), + ), + ], +) +def test_inventory_variable_update(updates: tuple[int, int | None, int | None]): + """ + Test if the variable value is set correctly on a sequence of updates. + + For this test, the value `None` implies the deletion of the source. + """ + x = InventoryVariable("x") + for src_id, value, expected_value in updates: + if value is None: + x.delete(src_id) + else: + x.update(value, src_id) + assert x.value == expected_value + + +def test_inventory_group_variables_update_basic(): + """Test basic functionality of an inventory variables update.""" + vars = InventoryGroupVariables(1) + vars.update_from_src({"x": 1, "y": 2}, 101) + assert vars == {"x": 1, "y": 2} + + +@pytest.mark.parametrize( + "updates", # (, : dict, : dict) + [ + ((101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),), + ( + (101, {"x": 1, "y": 1}, {"x": 1, "y": 1}), + (102, {}, {"x": 1, "y": 1}), + ), + ( + (101, {"x": 1, "y": 1}, {"x": 1, "y": 1}), + (102, {"x": 2}, {"x": 2, "y": 1}), + ), + ( + (101, {"x": 1, "y": 1}, {"x": 1, "y": 1}), + (102, {"x": 2, "y": 2}, {"x": 2, "y": 2}), + ), + ( + (101, {"x": 1, "y": 1}, {"x": 1, "y": 1}), + (102, {"x": 2, "z": 2}, {"x": 2, "y": 1, "z": 2}), + ), + ( + (101, {"x": 1, "y": 1}, {"x": 1, "y": 1}), + (102, {"x": 2, "z": 2}, {"x": 2, "y": 1, "z": 2}), + (102, {}, {"x": 1, "y": 1}), + ), + ( + (101, {"x": 1, "y": 1}, {"x": 1, "y": 1}), + (102, {"x": 2, "z": 2}, {"x": 2, "y": 1, "z": 2}), + (103, {"x": 3}, {"x": 3, "y": 1, "z": 2}), + (101, {}, {"x": 3, "z": 2}), + ), + ], +) +def test_inventory_group_variables_update(updates: tuple[int, int | None, int | None]): + """ + Test if the group vars are set correctly on various update sequences. + """ + groupvars = InventoryGroupVariables(2) + for src_id, vars, expected_vars in updates: + groupvars.update_from_src(vars, src_id) + assert groupvars == expected_vars diff --git a/awx/main/tests/unit/utils/test_redis.py b/awx/main/tests/unit/utils/test_redis.py new file mode 100644 index 000000000000..23e0940fc032 --- /dev/null +++ b/awx/main/tests/unit/utils/test_redis.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Ansible, Inc. +# All Rights Reserved + +from django.test.utils import override_settings + +from awx.main.utils.redis import get_redis_client, get_redis_client_async +from redis.exceptions import BusyLoadingError, ConnectionError, TimeoutError +from redis.backoff import ExponentialBackoff + + +class TestRedisRetryConfiguration: + """Verify Redis retry configuration is applied to connection objects.""" + + def test_retry_configuration_applied_to_client(self, settings): + """Verify all retry settings are applied to the connection pool.""" + # Test sync client + client = get_redis_client() + retry = client.connection_pool.connection_kwargs['retry'] + backoff = retry._backoff + retry_errors = client.connection_pool.connection_kwargs['retry_on_error'] + + # Assert provided values match values on the object + assert retry._retries == settings.REDIS_RETRY_COUNT == 3 + assert isinstance(backoff, ExponentialBackoff) + assert backoff._base == settings.REDIS_BACKOFF_BASE == 0.5 + assert backoff._cap == settings.REDIS_BACKOFF_CAP == 1.0 + assert BusyLoadingError in retry_errors + assert ConnectionError in retry_errors + assert TimeoutError in retry_errors + + # Test async client has same config + client_async = get_redis_client_async() + retry_async = client_async.connection_pool.connection_kwargs['retry'] + backoff_async = retry_async._backoff + retry_errors_async = client_async.connection_pool.connection_kwargs['retry_on_error'] + + assert retry_async._retries == settings.REDIS_RETRY_COUNT + assert backoff_async._base == settings.REDIS_BACKOFF_BASE + assert backoff_async._cap == settings.REDIS_BACKOFF_CAP + assert ConnectionError in retry_errors_async + + @override_settings(REDIS_RETRY_COUNT=5) + def test_override_settings_applied_to_client(self): + """Verify override_settings changes are applied to client object.""" + client = get_redis_client() + retry = client.connection_pool.connection_kwargs['retry'] + + assert retry._retries == 5 + + @override_settings(REDIS_BACKOFF_CAP=2.0, REDIS_BACKOFF_BASE=1.0) + def test_override_backoff_settings_applied_to_client(self): + """Verify override_settings for backoff parameters are applied to client object.""" + client = get_redis_client() + retry = client.connection_pool.connection_kwargs['retry'] + backoff = retry._backoff + + # Assert provided values match values on object + assert backoff._cap == 2.0 + assert backoff._base == 1.0 diff --git a/awx/main/tests/unit/utils/test_schedule_fast_forward.py b/awx/main/tests/unit/utils/test_schedule_fast_forward.py index be1bdae53eff..b5050a935b34 100644 --- a/awx/main/tests/unit/utils/test_schedule_fast_forward.py +++ b/awx/main/tests/unit/utils/test_schedule_fast_forward.py @@ -7,7 +7,7 @@ from awx.main.models.schedules import _fast_forward_rrule, Schedule from dateutil.rrule import HOURLY, MINUTELY, MONTHLY -REF_DT = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc) +REF_DT = datetime.datetime(2026, 4, 16, tzinfo=datetime.timezone.utc) @pytest.mark.parametrize( @@ -20,6 +20,10 @@ 'DTSTART;TZID=America/New_York:20201118T200000 RRULE:FREQ=MINUTELY;INTERVAL=5;WKST=SU;BYMONTH=2,3;BYMONTHDAY=18;BYHOUR=5;BYMINUTE=35;BYSECOND=0', id='every-5-minutes-at-5:35:00-am-on-the-18th-day-of-feb-or-march-with-week-starting-on-sundays', ), + pytest.param( + 'DTSTART;TZID=America/New_York:20251211T130000 RRULE:FREQ=HOURLY;INTERVAL=4;WKST=MO;BYDAY=MO,TU,WE,TH,FR;BYHOUR=1,5,9,13,17,21;BYMINUTE=0', + id='every-4-hours-at-1-5-9-13-17-21-am-on-monday-through-friday-with-week-starting-on-monday', + ), pytest.param( 'DTSTART;TZID=America/New_York:20201118T200000 RRULE:FREQ=HOURLY;INTERVAL=5;WKST=SU;BYMONTH=2,3;BYHOUR=5', id='every-5-hours-at-5-am-in-feb-or-march-with-week-starting-on-sundays', @@ -48,6 +52,7 @@ def test_fast_forwarded_rrule_matches_original_occurrence(rrulestr): [ pytest.param(datetime.datetime(2024, 12, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-out-of-dst'), pytest.param(datetime.datetime(2024, 6, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-in-dst'), + pytest.param(datetime.datetime(2024, 11, 3, 6, 30, tzinfo=datetime.timezone.utc), id='ref-dt-fall-back-day'), ], ) @pytest.mark.parametrize( @@ -58,6 +63,8 @@ def test_fast_forwarded_rrule_matches_original_occurrence(rrulestr): pytest.param( 'DTSTART;TZID=Europe/Lisbon:20230703T005800 RRULE:INTERVAL=10;FREQ=MINUTELY;BYHOUR=9,10,11,12,13,14,15,16,17,18,19,20,21', id='rrule-in-dst-by-hour' ), + pytest.param('DTSTART;TZID=America/New_York:20230313T005800 RRULE:FREQ=MINUTELY;INTERVAL=7', id='rrule-post-dst-7min'), + pytest.param('DTSTART;TZID=America/New_York:20230313T005800 RRULE:FREQ=MINUTELY;INTERVAL=13', id='rrule-post-dst-13min'), ], ) def test_fast_forward_across_dst(rrulestr, ref_dt): diff --git a/awx/main/tests/unit/utils/test_validate_rh.py b/awx/main/tests/unit/utils/test_validate_rh.py new file mode 100644 index 000000000000..65052bbdefff --- /dev/null +++ b/awx/main/tests/unit/utils/test_validate_rh.py @@ -0,0 +1,154 @@ +from unittest.mock import patch +from awx.main.utils.licensing import Licenser + + +def test_validate_rh_basic_auth_rhsm(): + """ + Assert get_rhsm_subs is called when + - basic_auth=True + - host is subscription.rhsm.redhat.com + """ + licenser = Licenser() + + with patch.object(licenser, 'get_host_from_rhsm_config', return_value='https://subscription.rhsm.redhat.com') as mock_get_host, patch.object( + licenser, 'get_rhsm_subs', return_value=[] + ) as mock_get_rhsm, patch.object(licenser, 'get_satellite_subs') as mock_get_satellite, patch.object( + licenser, 'get_crc_subs' + ) as mock_get_crc, patch.object( + licenser, 'generate_license_options_from_entitlements' + ) as mock_generate: + + licenser.validate_rh('testuser', 'testpass', basic_auth=True) + + # Assert the correct methods were called + mock_get_host.assert_called_once() + mock_get_rhsm.assert_called_once_with('https://subscription.rhsm.redhat.com', 'testuser', 'testpass') + mock_get_satellite.assert_not_called() + mock_get_crc.assert_not_called() + mock_generate.assert_called_once_with([], is_candlepin=True) + + +def test_validate_rh_basic_auth_satellite(): + """ + Assert get_satellite_subs is called when + - basic_auth=True + - custom satellite host + """ + licenser = Licenser() + + with patch.object(licenser, 'get_host_from_rhsm_config', return_value='https://satellite.example.com') as mock_get_host, patch.object( + licenser, 'get_rhsm_subs' + ) as mock_get_rhsm, patch.object(licenser, 'get_satellite_subs', return_value=[]) as mock_get_satellite, patch.object( + licenser, 'get_crc_subs' + ) as mock_get_crc, patch.object( + licenser, 'generate_license_options_from_entitlements' + ) as mock_generate: + + licenser.validate_rh('testuser', 'testpass', basic_auth=True) + + # Assert the correct methods were called + mock_get_host.assert_called_once() + mock_get_rhsm.assert_not_called() + mock_get_satellite.assert_called_once_with('https://satellite.example.com', 'testuser', 'testpass') + mock_get_crc.assert_not_called() + mock_generate.assert_called_once_with([], is_candlepin=True) + + +def test_validate_rh_service_account_crc(): + """ + Assert get_crc_subs is called when + - basic_auth=False + """ + licenser = Licenser() + + with patch('awx.main.utils.licensing.settings') as mock_settings, patch.object(licenser, 'get_host_from_rhsm_config') as mock_get_host, patch.object( + licenser, 'get_rhsm_subs' + ) as mock_get_rhsm, patch.object(licenser, 'get_satellite_subs') as mock_get_satellite, patch.object( + licenser, 'get_crc_subs', return_value=[] + ) as mock_get_crc, patch.object( + licenser, 'generate_license_options_from_entitlements' + ) as mock_generate: + + mock_settings.SUBSCRIPTIONS_RHSM_URL = 'https://console.redhat.com/api/rhsm/v1/subscriptions' + + licenser.validate_rh('client_id', 'client_secret', basic_auth=False) + + # Assert the correct methods were called + mock_get_host.assert_not_called() + mock_get_rhsm.assert_not_called() + mock_get_satellite.assert_not_called() + mock_get_crc.assert_called_once_with('https://console.redhat.com/api/rhsm/v1/subscriptions', 'client_id', 'client_secret') + mock_generate.assert_called_once_with([], is_candlepin=False) + + +def test_validate_rh_missing_user_raises_error(): + """Test validate_rh raises ValueError when user is missing""" + licenser = Licenser() + + with patch.object(licenser, 'get_host_from_rhsm_config', return_value='https://subscription.rhsm.redhat.com'): + try: + licenser.validate_rh(None, 'testpass', basic_auth=True) + assert False, "Expected ValueError to be raised" + except ValueError as e: + assert 'subscriptions_client_id or subscriptions_username is required' in str(e) + + +def test_validate_rh_missing_password_raises_error(): + """Test validate_rh raises ValueError when password is missing""" + licenser = Licenser() + + with patch.object(licenser, 'get_host_from_rhsm_config', return_value='https://subscription.rhsm.redhat.com'): + try: + licenser.validate_rh('testuser', None, basic_auth=True) + assert False, "Expected ValueError to be raised" + except ValueError as e: + assert 'subscriptions_client_secret or subscriptions_password is required' in str(e) + + +def test_validate_rh_no_host_fallback_to_candlepin(): + """Test validate_rh falls back to REDHAT_CANDLEPIN_HOST when no host from config + - basic_auth=True + - no host from config + - REDHAT_CANDLEPIN_HOST is set + """ + licenser = Licenser() + + with patch('awx.main.utils.licensing.settings') as mock_settings, patch.object( + licenser, 'get_host_from_rhsm_config', return_value=None + ) as mock_get_host, patch.object(licenser, 'get_rhsm_subs', return_value=[]) as mock_get_rhsm, patch.object( + licenser, 'get_satellite_subs', return_value=[] + ) as mock_get_satellite, patch.object( + licenser, 'get_crc_subs' + ) as mock_get_crc, patch.object( + licenser, 'generate_license_options_from_entitlements' + ) as mock_generate: + + mock_settings.REDHAT_CANDLEPIN_HOST = 'https://candlepin.example.com' + licenser.validate_rh('testuser', 'testpass', basic_auth=True) + + # Assert the correct methods were called + mock_get_host.assert_called_once() + mock_get_rhsm.assert_not_called() + mock_get_satellite.assert_called_once_with('https://candlepin.example.com', 'testuser', 'testpass') + mock_get_crc.assert_not_called() + mock_generate.assert_called_once_with([], is_candlepin=True) + + +def test_validate_rh_empty_credentials_basic_auth(): + """Test validate_rh with empty string credentials raises ValueError""" + licenser = Licenser() + + with patch.object(licenser, 'get_host_from_rhsm_config', return_value='https://subscription.rhsm.redhat.com'): + # Test empty user + try: + licenser.validate_rh(None, 'testpass', basic_auth=True) + assert False, "Expected ValueError to be raised" + except ValueError as e: + assert 'subscriptions_client_id or subscriptions_username is required' in str(e) + + # Test empty password + try: + licenser.validate_rh('testuser', None, basic_auth=True) + assert False, "Expected ValueError to be raised" + except ValueError as e: + assert 'subscriptions_client_secret or subscriptions_password is required' in str(e) diff --git a/awx/main/utils/__init__.py b/awx/main/utils/__init__.py index 2ffec9d8b617..af7473b0bd9e 100644 --- a/awx/main/utils/__init__.py +++ b/awx/main/utils/__init__.py @@ -3,6 +3,7 @@ # AWX from awx.main.utils.common import * # noqa +from awx.main.utils.redis import get_redis_client, get_redis_client_async # noqa from awx.main.utils.encryption import ( # noqa get_encryption_key, encrypt_field, diff --git a/awx/main/utils/analytics_proxy.py b/awx/main/utils/analytics_proxy.py index f46ed7e0caba..a6a599942e21 100644 --- a/awx/main/utils/analytics_proxy.py +++ b/awx/main/utils/analytics_proxy.py @@ -10,6 +10,8 @@ import requests +DEFAULT_OIDC_TOKEN_ENDPOINT = 'https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token' + class TokenError(requests.RequestException): ''' @@ -21,7 +23,7 @@ class TokenError(requests.RequestException): try: client = OIDCClient(...) client.make_request(...) - except TokenGenerationError as e: + except TokenError as e: print(f"Token generation failed due to {e.__cause__}") except requests.RequestException: print("API request failed) @@ -100,13 +102,15 @@ def __init__( self, client_id: str, client_secret: str, - token_url: str, - scopes: list[str], + token_url: str = DEFAULT_OIDC_TOKEN_ENDPOINT, + scopes: list[str] = None, base_url: str = '', ) -> None: self.client_id: str = client_id self.client_secret: str = client_secret self.token_url: str = token_url + if scopes is None: + scopes = ['api.console'] self.scopes = scopes self.base_url: str = base_url self.token: Optional[Token] = None diff --git a/awx/main/utils/ansible.py b/awx/main/utils/ansible.py index 64530c53007c..cd99b347de8d 100644 --- a/awx/main/utils/ansible.py +++ b/awx/main/utils/ansible.py @@ -48,15 +48,16 @@ def could_be_playbook(project_path, dir_path, filename): # show up. matched = False try: - for n, line in enumerate(codecs.open(playbook_path, 'r', encoding='utf-8', errors='ignore')): - if valid_playbook_re.match(line): - matched = True - break - # Any YAML file can also be encrypted with vault; - # allow these to be used as the main playbook. - elif n == 0 and line.startswith('$ANSIBLE_VAULT;'): - matched = True - break + with codecs.open(playbook_path, 'r', encoding='utf-8', errors='ignore') as f: + for n, line in enumerate(f): + if valid_playbook_re.match(line): + matched = True + break + # Any YAML file can also be encrypted with vault; + # allow these to be used as the main playbook. + elif n == 0 and line.startswith('$ANSIBLE_VAULT;'): + matched = True + break except IOError: return None if not matched: diff --git a/awx/main/utils/candlepin/__init__.py b/awx/main/utils/candlepin/__init__.py new file mode 100644 index 000000000000..191c3d964019 --- /dev/null +++ b/awx/main/utils/candlepin/__init__.py @@ -0,0 +1,349 @@ +# Copyright (c) 2026 Ansible, Inc. +# All Rights Reserved. + +""" +Candlepin integration for mTLS-based authentication. + +This package provides Candlepin consumer identity certificate support, +enabling AAP controller instances to authenticate analytics uploads using +mTLS instead of service account credentials. +""" + +import logging +import requests + +from django.conf import settings + +from .client import CandlepinClient +from .lifecycle import ( + get_candlepin_ca, + get_candlepin_url, + get_proxy_url, + get_renewal_days, + is_cert_valid, + parse_cert, + run_candlepin_lifecycle, +) + +logger = logging.getLogger('awx.main.utils.candlepin') + + +def _fetch_candlepin_cert_from_db(): + """Read cert PEM, key PEM, and consumer UUID from AWX conf_settings. + + Returns (cert_pem, key_pem, consumer_uuid) if valid certificate data exists, + or (None, None, None) if placeholder/unregistered data. + Best-effort: failures are logged as warnings and never propagate. + """ + try: + consumer_uuid = getattr(settings, 'CANDLEPIN_CONSUMER_UUID', '') + cert_pem = getattr(settings, 'CANDLEPIN_CERT_PEM', '') + key_pem = getattr(settings, 'CANDLEPIN_KEY_PEM', '') + + # Check if we have valid data + if not consumer_uuid or not cert_pem or not key_pem: + return None, None, None + + return cert_pem, key_pem, consumer_uuid + except Exception as e: + logger.warning(f'Could not fetch Candlepin lifecycle data from settings: {e}') + return None, None, None + + +def _save_candlepin_cert_to_db(cert_pem, key_pem): + """Persist a renewed Candlepin identity cert and key to AWX conf_settings. + + Returns: + bool: True if save succeeded, False on any error. + """ + try: + # Parse certificate to extract metadata + try: + cert_info = parse_cert(cert_pem) + serial_number = cert_info.get('serial', '') + except Exception as e: + logger.warning(f'Could not parse certificate metadata: {e}') + serial_number = '' + + # Update conf_settings via settings wrapper + settings.CANDLEPIN_CERT_PEM = cert_pem + settings.CANDLEPIN_KEY_PEM = key_pem + settings.CANDLEPIN_SERIAL_NUMBER = serial_number + + logger.info('Renewed Candlepin cert and key saved to conf_settings.') + return True + except Exception as e: + logger.error(f'Could not save renewed Candlepin cert to conf_settings: {e}') + return False + + +def _discover_org(candlepin_url, username, password, verify_tls=True): + """Discover org key via GET /users/{username}/owners. + + Args: + candlepin_url: Candlepin base URL + username: Username for authentication + password: Password for authentication + verify_tls: Whether to verify TLS certificates (default: True) + + Returns: + str: Organization key if found, None on any failure. + """ + try: + url = f"{candlepin_url}/users/{username}/owners" + if verify_tls: + candlepin_ca = get_candlepin_ca() + verify = candlepin_ca if candlepin_ca else True + else: + verify = False + + resp = requests.get(url, auth=(username, password), verify=verify, timeout=30) + resp.raise_for_status() + + owners = resp.json() + if not owners: + logger.warning(f'No organizations found for user {username}') + return None + + # Pick the first org, but warn if multiple exist + if len(owners) > 1: + logger.warning(f'User {username} has access to {len(owners)} organizations. Using first: {owners[0]}') + first_org = owners[0] + org = first_org.get('key') + if not org: + logger.warning(f'Organization key missing in first org entry for user {username}') + return None + + return org + except requests.exceptions.RequestException as e: + logger.warning(f'Failed to discover organization for user {username}: {e}') + return None + except Exception as e: + logger.warning(f'Unexpected error discovering organization for user {username}: {e}') + return None + + +def _fetch_registration_credentials_from_db(verify_tls=True): + """Read Candlepin registration credentials from AWX settings. + + Tries several options to retrieve the Candlepin credentials (set by AWX when the + customer configures their Red Hat subscription), and to discover the org (org + key for the Candlepin /consumers endpoint), and INSTALL_UUID (used as the + consumer's aap.instance_uuid fact). + + Priority for authentication credentials: + - If both REDHAT_USERNAME and SUBSCRIPTIONS_USERNAME exist: use REDHAT_USERNAME + - If only SUBSCRIPTIONS_USERNAME exists: use SUBSCRIPTIONS_USERNAME + + Args: + verify_tls: Whether to verify TLS certificates during org discovery (default: True) + + Returns (username, password, org, install_uuid), any of which may be None + if the corresponding setting is not configured. + """ + candlepin_url = get_candlepin_url() + try: + username = getattr(settings, 'REDHAT_USERNAME', None) + password = getattr(settings, 'REDHAT_PASSWORD', None) + + if not (username and password): + username = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None) + password = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None) + + install_uuid = getattr(settings, 'INSTALL_UUID', None) + + org = _discover_org(candlepin_url, username, password, verify_tls=verify_tls) if username and password else None + + return username, password, org, install_uuid + except Exception as e: + logger.warning(f'Could not fetch Candlepin registration credentials from settings: {e}') + return None, None, None, None + + +def resolve_registration_credentials(username_override=None, password_override=None, org_override=None, verify_tls=True): + """Resolve Candlepin registration credentials with optional overrides. + + Fetches credentials from database settings and merges with any provided overrides. + Validates that all required fields are present. + + Args: + username_override: Optional username to use instead of database value + password_override: Optional password to use instead of database value + org_override: Optional org to use instead of auto-discovered value + verify_tls: Whether to verify TLS certificates during org discovery (default: True) + + Returns: + Tuple (username, password, org, install_uuid) if all required fields present, + or (None, None, None, None, error_messages) if validation fails. + error_messages is a list of strings describing missing values. + """ + db_username, db_password, db_org, db_install_uuid = _fetch_registration_credentials_from_db(verify_tls=verify_tls) + + username = username_override or db_username + password = password_override or db_password + org = org_override or db_org + + # Validate all required fields are present + missing = [] + if not username: + missing.append('username (provide --username or set REDHAT_USERNAME in database)') + if not password: + missing.append('password (provide password or set REDHAT_PASSWORD in database)') + if not org: + missing.append('org (provide --org or ensure SUBSCRIPTIONS_USERNAME/PASSWORD are configured for auto-discovery)') + + if missing: + return None, None, None, None, missing + + return username, password, org, db_install_uuid, None + + +def _save_candlepin_registration_to_db(cert_pem, key_pem, consumer_uuid): + """Persist a new Candlepin consumer registration (cert, key, UUID) to AWX conf_settings. + + Returns: + bool: True if save succeeded, False on any error. + """ + try: + # Parse certificate to extract metadata + try: + cert_info = parse_cert(cert_pem) + serial_number = cert_info.get('serial', '') + except Exception as e: + logger.warning(f'Could not parse certificate metadata: {e}') + serial_number = '' + + # Update conf_settings with all registration data via settings wrapper + settings.CANDLEPIN_CONSUMER_UUID = consumer_uuid + settings.CANDLEPIN_CERT_PEM = cert_pem + settings.CANDLEPIN_KEY_PEM = key_pem + settings.CANDLEPIN_SERIAL_NUMBER = serial_number + + logger.info(f'Candlepin consumer registration saved to conf_settings (uuid={consumer_uuid}).') + return True + except Exception as e: + logger.error(f'Could not save Candlepin registration to conf_settings: {e}') + return False + + +def _register_candlepin_consumer(): + """Register a new Candlepin consumer using credentials from AWX settings. + + Called when no identity cert exists in the DB. + + Reads the Candlepin credentials and the org key and then calls + POST /consumers on Candlepin to obtain an identity certificate. + On success the cert, key, and consumer UUID are persisted to conf_settings. + + Returns (cert_pem, key_pem, consumer_uuid) on success, (None, None, None) on + any failure. Best-effort: logs errors but never propagates. + """ + username, password, org, install_uuid = _fetch_registration_credentials_from_db() + + if not username or not password: + logger.warning('Candlepin registration is enabled but credentials are not set; skipping registration.') + return None, None, None + + if not org: + logger.warning('Candlepin registration is enabled but subscription org is not available; skipping registration.') + return None, None, None + + candlepin_url = get_candlepin_url() + candlepin_ca = get_candlepin_ca() + proxy = get_proxy_url() + client = CandlepinClient(base_url=candlepin_url, candlepin_ca=candlepin_ca, proxy=proxy) + + try: + cert_pem, key_pem, consumer_uuid = client.register_consumer(username, password, org, install_uuid) + except Exception as e: + logger.error(f'Candlepin consumer registration failed: {e}') + return None, None, None + + if not _save_candlepin_registration_to_db(cert_pem, key_pem, consumer_uuid): + logger.error('Candlepin consumer registration succeeded but failed to save to database.') + return None, None, None + return cert_pem, key_pem, consumer_uuid + + +def _run_candlepin_lifecycle(cert_pem, key_pem, consumer_uuid): + """Orchestrate Candlepin check-in and proactive cert renewal. + + Returns the (possibly renewed) (cert_pem, key_pem) tuple. If renewal fails, the + original cert is returned and the caller will validate it with is_cert_valid(). + If invalid, the caller skips mTLS and falls back directly to OIDC authentication. + """ + if not consumer_uuid: + logger.warning('Candlepin lifecycle is enabled but consumer UUID is not set; skipping check-in and renewal.') + return cert_pem, key_pem + + candlepin_url = get_candlepin_url() + renewal_days = get_renewal_days() + candlepin_ca = get_candlepin_ca() + proxy = get_proxy_url() + + try: + new_cert_pem, new_key_pem = run_candlepin_lifecycle( + cert_pem, + key_pem, + consumer_uuid, + candlepin_url=candlepin_url, + renewal_days=renewal_days, + candlepin_ca=candlepin_ca, + proxy=proxy, + ) + if (new_cert_pem, new_key_pem) != (cert_pem, key_pem): + if not _save_candlepin_cert_to_db(new_cert_pem, new_key_pem): + logger.warning('Renewed certificate will be used for this request, but failed to persist to database for future use.') + return new_cert_pem, new_key_pem + except Exception as e: + logger.error(f'Candlepin lifecycle (check-in / renewal) failed: {e}; will attempt mTLS with existing cert') + return cert_pem, key_pem + + +def get_or_generate_candlepin_certificate(): + """ + Get or generate Candlepin certificate for analytics authentication. + + This function provides certificate-based authentication for analytics uploads. + It will: + 1. Check for existing certificate in conf_settings + 2. If missing, attempt to register with Candlepin (credentials from settings) + 3. If exists, check for renewal needs and refresh if needed + 4. Return the certificate and key as PEM strings + + Returns: + Tuple (cert_pem, key_pem) as strings if certificate is available, (None, None) otherwise. + + Note: + Credentials for registration are retrieved from Django settings internally + (REDHAT_USERNAME/PASSWORD, SUBSCRIPTIONS_USERNAME/PASSWORD, or + SUBSCRIPTIONS_CLIENT_ID/CLIENT_SECRET in priority order). + """ + cert_pem, key_pem, consumer_uuid = _fetch_candlepin_cert_from_db() + + # If no certificate exists, attempt registration + if not cert_pem or not key_pem: + logger.info('No Candlepin certificate found, attempting registration') + cert_pem, key_pem, consumer_uuid = _register_candlepin_consumer() + + if not cert_pem or not key_pem: + logger.debug('Candlepin certificate registration failed or not configured') + return None, None + + # Run lifecycle (check-in and renewal if needed) + if consumer_uuid: + cert_pem, key_pem = _run_candlepin_lifecycle(cert_pem, key_pem, consumer_uuid) + + # Validate certificate is still usable + if not is_cert_valid(cert_pem): + logger.warning('Candlepin certificate is not valid (expired or not yet valid)') + return None, None + + # Return raw PEM strings - caller will create temp files if needed + return cert_pem, key_pem + + +__all__ = [ + 'get_or_generate_candlepin_certificate', + 'resolve_registration_credentials', +] diff --git a/awx/main/utils/candlepin/client.py b/awx/main/utils/candlepin/client.py new file mode 100644 index 000000000000..5a036977fd25 --- /dev/null +++ b/awx/main/utils/candlepin/client.py @@ -0,0 +1,258 @@ +import os +import tempfile +import uuid as _uuid_mod +from datetime import datetime, timezone +import requests +import logging + +logger = logging.getLogger('awx.main.utils.candlepin') + + +class _temp_cert_files: + """ + Context manager: writes cert + key to secure temp files, auto-deletes on exit. + + Uses NamedTemporaryFile with delete=True for better cleanup on process termination. + Files are unlinked immediately on Unix systems, providing better security against + orphaned private keys in /tmp. + """ + + def __init__(self, cert_pem, key_pem): + self._cert_pem = cert_pem + self._key_pem = key_pem + self._cert_file = None + self._key_file = None + + def __enter__(self): + try: + # Create temp file for certificate + self._cert_file = tempfile.NamedTemporaryFile(mode='w', prefix='candlepin_cert_', suffix='.pem', delete=True) + self._cert_file.write(self._cert_pem) + self._cert_file.flush() + os.chmod(self._cert_file.name, 0o600) + + # Create temp file for private key + self._key_file = tempfile.NamedTemporaryFile(mode='w', prefix='candlepin_key_', suffix='.pem', delete=True) + self._key_file.write(self._key_pem) + self._key_file.flush() + os.chmod(self._key_file.name, 0o600) + + return self._cert_file.name, self._key_file.name + except Exception: + # Clean up on error + if self._cert_file: + self._cert_file.close() + if self._key_file: + self._key_file.close() + raise + + def __exit__(self, *_): + # Closing NamedTemporaryFile automatically deletes it + if self._cert_file: + try: + self._cert_file.close() + except Exception as e: + logger.warning(f'Error closing cert temp file: {e}') + if self._key_file: + try: + self._key_file.close() + except Exception as e: + logger.warning(f'Error closing key temp file: {e}') + + +class CandlepinClient: + """ + Minimal Candlepin REST client for certificate lifecycle operations. + + All API calls authenticate with the consumer identity certificate (mTLS), + matching the pattern used by subscription-manager after initial registration. + + TLS server verification is **enabled** by default (``verify_tls=True``). + Pass ``candlepin_ca`` to verify against a specific CA bundle rather than the + system trust store. Verification can only be disabled by explicitly passing + ``verify_tls=False``; this should be used only in controlled test environments + and never in production. + """ + + def __init__(self, base_url, candlepin_ca=None, proxy=None, verify_tls=True): + self.base_url = base_url.rstrip('/') + if candlepin_ca: + self.verify = candlepin_ca + elif verify_tls: + self.verify = True + else: + # Explicit opt-in required to reach this branch — never set by default. + logger.warning('CandlepinClient: TLS verification is DISABLED (verify_tls=False). Do not use in production.') + self.verify = False + if proxy: + # Use the caller-supplied URL as-is for HTTPS targets (preserves the + # intended scheme — usually http:// so requests uses plain HTTP to reach + # the proxy and issues CONNECT for TLS tunneling, but https:// is also + # accepted for the rare case of an HTTPS-fronted proxy). + # The http:// key always uses plain HTTP since non-TLS traffic never + # needs TLS to the proxy itself. + host = proxy.split('://', 1)[-1] + self.proxies = {'https': proxy, 'http': f'http://{host}'} + else: + self.proxies = {} + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def register_consumer(self, username, password, org, install_uuid=None): + """POST /consumers?owner={org} — register a new AAP consumer with basic auth. + + Uses the customer's Red Hat subscription credentials (REDHAT_USERNAME / + REDHAT_PASSWORD from AWX conf_setting) to register this controller + instance as a Candlepin consumer and obtain an identity certificate for mTLS. + + Args: + username: Red Hat subscription username (from REDHAT_USERNAME). + password: Red Hat subscription password (from REDHAT_PASSWORD). + org: Candlepin owner/org key (retrieved with subscription credentials). + install_uuid: AWX INSTALL_UUID used as the consumer's aap.instance_uuid + fact; falls back to a random UUID if not provided. + + Returns: + Tuple ``(cert_pem, key_pem, consumer_uuid)``. + + Raises: + RuntimeError on any network or API failure. + """ + url = f'{self.base_url}/consumers' + instance_uuid = install_uuid or str(_uuid_mod.uuid4()) + payload = { + 'name': f'aap-{instance_uuid[:8]}', + 'type': {'label': 'aap'}, + 'facts': { + 'system.certificate_version': '3.3', + 'system.name': 'aap-controller', + 'aap.instance_uuid': instance_uuid, + }, + } + try: + resp = requests.post( + url, + params={'owner': org}, + auth=(username, password), + json=payload, + headers={'Content-Type': 'application/json'}, + verify=self.verify, + proxies=self.proxies, + timeout=120, + ) + except Exception as e: + raise RuntimeError(f'Candlepin register_consumer network error: {e}') from e + + if not resp.ok: + raise RuntimeError(f'Candlepin register_consumer failed with status {resp.status_code}: {resp.text}') + + try: + body = resp.json() + consumer_uuid = body.get('uuid') + id_cert = body.get('idCert', {}) + cert_pem = id_cert.get('cert') + key_pem = id_cert.get('key') + except Exception as e: + raise RuntimeError(f'Candlepin register_consumer: could not parse response JSON: {e}') from e + + if not consumer_uuid or not cert_pem or not key_pem: + raise RuntimeError('Candlepin register_consumer: response missing uuid, idCert.cert or idCert.key') + + logger.info(f'Candlepin consumer registered successfully (uuid={consumer_uuid})') + return cert_pem, key_pem, consumer_uuid + + def get_consumer(self, consumer_uuid, cert_pem, key_pem): + """GET /consumers/{uuid} — retrieve consumer information from server. + + Best-effort: logs a warning on failure but never raises. + + Returns: + Dict with consumer data (including 'idCert' with serial) on success, + None on any failure. + """ + url = f'{self.base_url}/consumers/{consumer_uuid}' + try: + with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path): + resp = requests.get( + url, + cert=(cert_path, key_path), + verify=self.verify, + proxies=self.proxies, + timeout=30, + ) + if resp.status_code == 200: + logger.debug(f'Candlepin get_consumer successful for consumer {consumer_uuid}') + return resp.json() + logger.warning(f'Candlepin get_consumer returned unexpected status {resp.status_code} for consumer {consumer_uuid}') + return None + except Exception as e: + logger.warning(f'Candlepin get_consumer failed for consumer {consumer_uuid}: {e}') + return None + + def checkin(self, consumer_uuid, cert_pem, key_pem): + """PUT /consumers/{uuid} — reset inactivity timer. + + Best-effort: logs a warning on failure but never raises so that a + transient Candlepin outage cannot abort a gather run. + + Returns True on success, False on any failure. + """ + url = f'{self.base_url}/consumers/{consumer_uuid}' + try: + with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path): + resp = requests.put( + url, + cert=(cert_path, key_path), + json={'facts': {'aap.last_checkin': datetime.now(timezone.utc).isoformat()}}, + headers={'Content-Type': 'application/json'}, + verify=self.verify, + proxies=self.proxies, + timeout=30, + ) + if resp.status_code in (200, 204): + logger.info(f'Candlepin check-in successful for consumer {consumer_uuid}') + return True + logger.warning(f'Candlepin check-in returned unexpected status {resp.status_code} for consumer {consumer_uuid}') + return False + except Exception as e: + logger.warning(f'Candlepin check-in failed for consumer {consumer_uuid}: {e}') + return False + + def regenerate_cert(self, consumer_uuid, cert_pem, key_pem): + """POST /consumers/{uuid} — regenerate the identity certificate. + + Returns ``(new_cert_pem, new_key_pem)`` on success. + Raises ``RuntimeError`` on API or parsing failure so the caller can + decide whether to fall back to service-account auth. + """ + url = f'{self.base_url}/consumers/{consumer_uuid}' + with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path): + try: + resp = requests.post( + url, + cert=(cert_path, key_path), + verify=self.verify, + proxies=self.proxies, + timeout=120, + ) + except Exception as e: + raise RuntimeError(f'Candlepin regenerate_cert network error for consumer {consumer_uuid}: {e}') from e + + if not resp.ok: + raise RuntimeError(f'Candlepin regenerate_cert failed with status {resp.status_code} for consumer {consumer_uuid}: {resp.text}') + + try: + body = resp.json() + id_cert = body.get('idCert', {}) + new_cert_pem = id_cert.get('cert') + new_key_pem = id_cert.get('key') + except Exception as e: + raise RuntimeError(f'Candlepin regenerate_cert: could not parse response JSON: {e}') from e + + if not new_cert_pem or not new_key_pem: + raise RuntimeError(f'Candlepin regenerate_cert: response did not contain idCert.cert / idCert.key for consumer {consumer_uuid}') + + logger.info(f'Candlepin cert regenerated successfully for consumer {consumer_uuid}') + return new_cert_pem, new_key_pem diff --git a/awx/main/utils/candlepin/lifecycle.py b/awx/main/utils/candlepin/lifecycle.py new file mode 100644 index 000000000000..dba476f80d4b --- /dev/null +++ b/awx/main/utils/candlepin/lifecycle.py @@ -0,0 +1,221 @@ +""" +Candlepin certificate lifecycle helpers. + +is_cert_valid — quick parseable/non-expired guard used at ship time +parse_cert — extract metadata from a PEM cert string +needs_renewal — check whether a cert is within the renewal window +run_candlepin_lifecycle — orchestrate check-in + proactive renewal per gather run +""" + +import os +from datetime import datetime, timezone + +from cryptography import x509 +from django.conf import settings + +import logging + +logger = logging.getLogger('awx.main.utils.candlepin') + +from .client import CandlepinClient + +# --------------------------------------------------------------------------- +# Certificate helpers +# --------------------------------------------------------------------------- + + +def parse_cert(pem_text): + """Parse a PEM certificate and return a metadata dict. + + Returns a dict with keys: serial, cn, issuer_cn, issuer_org, + not_before, not_after, days_remaining, validity_days. + + Raises ``ValueError`` if the PEM cannot be parsed. + """ + data = pem_text.encode('utf-8') if isinstance(pem_text, str) else pem_text + try: + cert = x509.load_pem_x509_certificate(data) + except Exception as e: + raise ValueError(f'Could not parse PEM certificate: {e}') from e + + expiry = cert.not_valid_after_utc + remaining = expiry - datetime.now(timezone.utc) + + subject = {attr.oid._name: attr.value for attr in cert.subject} + issuer = {attr.oid._name: attr.value for attr in cert.issuer} + + return { + 'serial': str(cert.serial_number), + 'cn': subject.get('commonName', 'unknown'), + 'issuer_cn': issuer.get('commonName', 'unknown'), + 'issuer_org': issuer.get('organizationName', 'unknown'), + 'not_before': cert.not_valid_before_utc.isoformat(), + 'not_after': expiry.isoformat(), + 'days_remaining': remaining.days, + 'validity_days': (expiry - cert.not_valid_before_utc).days, + } + + +def is_cert_valid(cert_pem: str) -> bool: + """Return True if cert_pem is parseable, already valid, and not yet expired. + + Logs a warning (suitable for operator visibility) when the cert is not yet + valid, expired, or unparseable, then returns False so the caller can fall + back to service-account authentication. + """ + try: + info = parse_cert(cert_pem) + now = datetime.now(timezone.utc) + not_before = datetime.fromisoformat(info['not_before']) + if now < not_before: + logger.warning(f'Candlepin cert is not yet valid (not_before={info["not_before"]}); falling back to service account auth') + return False + if info['days_remaining'] < 0: + logger.warning(f'Candlepin cert expired at {info["not_after"]}; falling back to service account auth') + return False + return True + except ValueError as e: + logger.warning(f'Could not parse Candlepin cert: {e}') + return False + + +def needs_renewal(pem_text, days_before_expiry): + """Return True if the cert expires within ``days_before_expiry`` days. + + Also returns True if the cert is already expired (days_remaining < 0). + Raises ``ValueError`` if the PEM cannot be parsed. + """ + info = parse_cert(pem_text) + return info['days_remaining'] <= days_before_expiry + + +# --------------------------------------------------------------------------- +# Lifecycle orchestration +# --------------------------------------------------------------------------- + + +def run_candlepin_lifecycle(cert_pem, key_pem, consumer_uuid, *, candlepin_url=None, renewal_days=90, candlepin_ca=None, proxy=None): + """Perform check-in and, if needed, proactive cert renewal. + + Called once per gather run. Returns ``(cert_pem, key_pem)`` — either + the originals (if no renewal was needed) or the freshly regenerated pair. + + Args: + cert_pem: Consumer identity certificate PEM string. + key_pem: Consumer identity key PEM string. + consumer_uuid: Candlepin consumer UUID string. + candlepin_url: Candlepin base URL (defaults to prod). + renewal_days: Renew if expiry is within this many days (default 90). + candlepin_ca: Path to Candlepin CA cert for server verification + (default None → uses system trust store). + proxy: Optional HTTP/HTTPS proxy URL string. + + Returns: + Tuple ``(cert_pem, key_pem)`` — possibly updated after renewal. + + Raises: + RuntimeError if cert regeneration is attempted and fails. + """ + client = CandlepinClient(base_url=candlepin_url, candlepin_ca=candlepin_ca, proxy=proxy) + + # Step 1: Inspect cert metadata for diagnostics and renewal decision. + try: + info = parse_cert(cert_pem) + except ValueError as e: + logger.warning(f'Candlepin lifecycle: could not parse cert, skipping lifecycle: {e}') + return cert_pem, key_pem + + logger.info(f'Candlepin cert: serial={info["serial"]}, CN={info["cn"]}, expires={info["not_after"]}, days_remaining={info["days_remaining"]}') + + # Step 2: Check-in (best-effort, never raises). + checkin_success = client.checkin(consumer_uuid, cert_pem, key_pem) + if not checkin_success: + logger.warning( + f'Candlepin check-in failed for consumer {consumer_uuid}. ' + f'Consumer may have been deleted server-side or certificate is invalid. ' + f'Lifecycle will continue but may fail.' + ) + + # Step 3: Compare local cert serial with server's serial. + # If they differ, the server has issued a new cert (e.g., admin regenerated it). + consumer_data = client.get_consumer(consumer_uuid, cert_pem, key_pem) + if not consumer_data: + if not checkin_success: + logger.error( + f'Both check-in and get_consumer failed for consumer {consumer_uuid}. ' + f'Consumer was likely deleted from Candlepin server. ' + f'Re-registration may be required. Will attempt cert renewal anyway.' + ) + else: + logger.warning(f'Could not retrieve consumer data for {consumer_uuid} but check-in succeeded. Continuing lifecycle.') + else: + server_cert_pem = consumer_data.get('idCert', {}).get('cert') + if server_cert_pem: + try: + server_info = parse_cert(server_cert_pem) + server_serial = server_info['serial'] + local_serial = info['serial'] + + if server_serial != local_serial: + logger.warning( + f'Candlepin cert serial mismatch: local={local_serial}, server={server_serial}. ' + f'Server has issued a new certificate; requesting updated cert.' + ) + # Fetch the new cert from the server + new_cert_pem, new_key_pem = client.regenerate_cert(consumer_uuid, cert_pem, key_pem) + + try: + new_info = parse_cert(new_cert_pem) + logger.info(f'Candlepin cert updated: old serial={local_serial}, new serial={new_info["serial"]}, new expiry={new_info["not_after"]}') + except ValueError: + logger.warning('Candlepin lifecycle: could not parse updated cert for logging') + + return new_cert_pem, new_key_pem + else: + logger.debug(f'Candlepin cert serial matches server: {local_serial}') + except ValueError as e: + logger.warning(f'Candlepin lifecycle: could not parse server cert from get_consumer: {e}') + + # Step 4: Proactive renewal if within the renewal window (or already expired). + if needs_renewal(cert_pem, renewal_days): + logger.info(f'Candlepin cert expires in {info["days_remaining"]} days (threshold: {renewal_days}); requesting renewal for consumer {consumer_uuid}') + new_cert_pem, new_key_pem = client.regenerate_cert(consumer_uuid, cert_pem, key_pem) + + try: + new_info = parse_cert(new_cert_pem) + logger.info(f'Candlepin cert renewed: old serial={info["serial"]}, new serial={new_info["serial"]}, new expiry={new_info["not_after"]}') + except ValueError: + logger.warning('Candlepin lifecycle: could not parse renewed cert for logging') + + return new_cert_pem, new_key_pem + + logger.info(f'Candlepin cert is healthy ({info["days_remaining"]} days remaining); no renewal needed') + return cert_pem, key_pem + + +def get_candlepin_url(): + """Get Candlepin base URL from Django settings.""" + return settings.AWX_ANALYTICS_CANDLEPIN_URL + + +def get_renewal_days(): + """Get certificate renewal threshold in days from Django settings.""" + return settings.AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS + + +def get_candlepin_ca(): + """Get Candlepin CA certificate path from Django settings. + + Returns: + str: Path to CA certificate file if configured and exists, None otherwise. + """ + ca_path = settings.AWX_ANALYTICS_CANDLEPIN_CA + if ca_path and not os.path.isfile(ca_path): + logger.warning(f'Configured Candlepin CA certificate not found at {ca_path}, using system default CA bundle') + return None + return ca_path + + +def get_proxy_url(): + """Get proxy URL from Django settings.""" + return settings.AWX_ANALYTICS_CANDLEPIN_PROXY_URL diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 2f45bb7c8fed..21a27980b284 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -43,6 +43,9 @@ # AWX from awx.conf.license import get_license +# ansible-runner +from ansible_runner.utils.capacity import get_mem_in_bytes, get_cpu_count + logger = logging.getLogger('awx.main.utils') __all__ = [ @@ -90,6 +93,7 @@ 'get_event_partition_epoch', 'cleanup_new_process', 'unified_job_class_to_event_table_name', + 'get_job_variable_prefixes', ] @@ -147,14 +151,6 @@ def is_testing(argv=None): return False -def bypass_in_test(func): - def fn(*args, **kwargs): - if not is_testing(): - return func(*args, **kwargs) - - return fn - - class RequireDebugTrueOrTest(logging.Filter): """ Logging filter to output when in DEBUG mode or running tests. @@ -770,6 +766,21 @@ def get_cpu_effective_capacity(cpu_count, is_control_node=False): return max(1, int(cpu_count * forkcpu)) +def get_job_variable_prefixes(): + """Return the list of active job variable prefixes based on INCLUDE_DEPRECATED_AWX_VAR_PREFIX setting. + + When True (default), returns both 'awx' and 'tower' prefixes for backward compatibility. + When False, returns only 'tower'. The 'awx' prefix is deprecated and this setting + will default to False in a future release. + """ + from django.conf import settings + + include_awx = getattr(settings, 'INCLUDE_DEPRECATED_AWX_VAR_PREFIX', True) + if include_awx: + return ['awx', 'tower'] + return ['tower'] + + def convert_mem_str_to_bytes(mem_str): """Convert string with suffix indicating units to memory in bytes (base 2) @@ -997,9 +1008,15 @@ def getattrd(obj, name, default=NoDefaultProvided): raise -def getattr_dne(obj, name, notfound=ObjectDoesNotExist): +empty = object() + + +def getattr_dne(obj, name, default=empty, notfound=ObjectDoesNotExist): try: - return getattr(obj, name) + if default is empty: + return getattr(obj, name) + else: + return getattr(obj, name, default) except notfound: return None @@ -1220,3 +1237,38 @@ def unified_job_class_to_event_table_name(job_class): def load_all_entry_points_for(entry_point_subsections: list[str], /) -> dict[str, EntryPoint]: return {ep.name: ep for entry_point_category in entry_point_subsections for ep in entry_points(group=f'awx_plugins.{entry_point_category}')} + + +def get_auto_max_workers(): + """Method we normally rely on to get max_workers + + Uses almost same logic as Instance.local_health_check + The important thing is to be MORE than Instance.capacity + so that the task-manager does not over-schedule this node + + Ideally we would just use the capacity from the database plus reserve workers, + but this poses some bootstrap problems where OCP task containers + register themselves after startup + """ + # Get memory from ansible-runner + total_memory_gb = get_mem_in_bytes() + + # This may replace memory calculation with a user override + corrected_memory = get_corrected_memory(total_memory_gb) + + # Get same number as max forks based on memory, this function takes memory as bytes + mem_capacity = get_mem_effective_capacity(corrected_memory, is_control_node=True) + + # Follow same process for CPU capacity constraint + cpu_count = get_cpu_count() + corrected_cpu = get_corrected_cpu(cpu_count) + cpu_capacity = get_cpu_effective_capacity(corrected_cpu, is_control_node=True) + + # Here is what is different from health checks, + auto_max = max(mem_capacity, cpu_capacity) + + # add magic number of extra workers to ensure + # we have a few extra workers to run the heartbeat + auto_max += 7 + + return auto_max diff --git a/awx/main/utils/db.py b/awx/main/utils/db.py index 8cc6aacce9f2..9b1b887ebed4 100644 --- a/awx/main/utils/db.py +++ b/awx/main/utils/db.py @@ -1,10 +1,61 @@ # Copyright (c) 2017 Ansible by Red Hat # All Rights Reserved. +from typing import Optional +import os from awx.settings.application_name import set_application_name +from awx import MODE + from django.conf import settings +from django.db import connection def set_connection_name(function): set_application_name(settings.DATABASES, settings.CLUSTER_HOST_ID, function=function) + + +def bulk_update_sorted_by_id(model, objects, fields, batch_size=1000): + """ + Perform a sorted bulk update on model instances to avoid database deadlocks. + + This function was introduced to prevent deadlocks observed in the AWX Controller + when concurrent jobs attempt to update different fields on the same `main_hosts` table. + Specifically, deadlocks occurred when one process updated `last_job_id` while another + simultaneously updated `ansible_facts`. + + By sorting updates ID, we ensure a consistent update order, + which helps avoid the row-level locking contention that can lead to deadlocks + in PostgreSQL when multiple processes are involved. + + Returns: + int: The number of rows affected by the update. + """ + objects = [obj for obj in objects if obj.id is not None] + if not objects: + return 0 # Return 0 when nothing is updated + + sorted_objects = sorted(objects, key=lambda obj: obj.id) + return model.objects.bulk_update(sorted_objects, fields, batch_size=batch_size) + + +MIN_PG_VERSION = 12 + + +def db_requirement_violations() -> Optional[str]: + if os.getenv('SKIP_PG_VERSION_CHECK', False): + return None + if connection.vendor == 'postgresql': + + # enforce the postgres version is a minimum of 12 (we need this for partitioning); if not, then terminate program with exit code of 1 + # In the future if we require a feature of a version of postgres > 12 this should be updated to reflect that. + # The return of connection.pg_version is something like 12013 + major_version = connection.pg_version // 10000 + if major_version < MIN_PG_VERSION: + return f"At a minimum, postgres version {MIN_PG_VERSION} is required, found {major_version}\n" + + return None + else: + if MODE == 'production': + return f"Running server with '{connection.vendor}' type database is not supported\n" + return None diff --git a/awx/main/utils/encryption.py b/awx/main/utils/encryption.py index 4272e3e07fc1..d23685d33456 100644 --- a/awx/main/utils/encryption.py +++ b/awx/main/utils/encryption.py @@ -9,7 +9,6 @@ from cryptography.hazmat.backends import default_backend from django.utils.encoding import smart_str, smart_bytes - __all__ = ['get_encryption_key', 'encrypt_field', 'decrypt_field', 'encrypt_value', 'decrypt_value', 'encrypt_dict'] logger = logging.getLogger('awx.main.utils.encryption') diff --git a/awx/main/utils/execution_environments.py b/awx/main/utils/execution_environments.py index 7b498d50a172..111b76acc637 100644 --- a/awx/main/utils/execution_environments.py +++ b/awx/main/utils/execution_environments.py @@ -4,7 +4,6 @@ from awx.main.models.execution_environments import ExecutionEnvironment - logger = logging.getLogger(__name__) diff --git a/awx/main/utils/external_logging.py b/awx/main/utils/external_logging.py index 061ab40a9581..ff98123febbc 100644 --- a/awx/main/utils/external_logging.py +++ b/awx/main/utils/external_logging.py @@ -4,9 +4,9 @@ import urllib.parse as urlparse from django.conf import settings +from dispatcherd.publish import task from awx.main.utils.reload import supervisor_service_command -from awx.main.dispatch.publish import task def construct_rsyslog_conf_template(settings=settings): @@ -55,6 +55,8 @@ def construct_rsyslog_conf_template(settings=settings): ) def escape_quotes(x): + if x is None: + return '' return x.replace('"', '\\"') if not enabled: @@ -139,7 +141,7 @@ def escape_quotes(x): return tmpl -@task(queue='rsyslog_configurer') +@task(queue='rsyslog_configurer', timeout=600, on_duplicate='queue_one') def reconfigure_rsyslog(): tmpl = construct_rsyslog_conf_template() # Write config to a temp file then move it to preserve atomicity diff --git a/awx/main/utils/filters.py b/awx/main/utils/filters.py index 389b1f93c401..3b22f7e4ab45 100644 --- a/awx/main/utils/filters.py +++ b/awx/main/utils/filters.py @@ -2,14 +2,7 @@ from functools import reduce from django.core.exceptions import FieldDoesNotExist -from pyparsing import ( - infixNotation, - opAssoc, - Optional, - Literal, - CharsNotIn, - ParseException, -) +import pyparsing as pp import logging from logging import Filter @@ -247,32 +240,19 @@ def _json_path_to_contains(self, k, v): return (assembled_k, assembled_v) def _extract_key_value(self, t): - t_len = len(t) - - k = None - v = None - - # key - # "something"= - v_offset = 2 - if t_len >= 2 and t[0] == "\"" and t[2] == "\"": - k = t[1] - v_offset = 4 - # something= - else: - k = t[0] - - # value - # ="something" - if t_len > (v_offset + 2) and t[v_offset] == "\"" and t[v_offset + 2] == "\"": - v = u'"' + str(t[v_offset + 1]) + u'"' - # v = t[v_offset + 1] - # empty "" - elif t_len > (v_offset + 1): - v = u"" - # no "" + k = t[0] + v = t[1] if len(t) > 1 else u"" + + # Strip quotes from key + if isinstance(k, str) and k.startswith('"') and k.endswith('"'): + k = k[1:-1] + + # For quoted values, keep the quotes (strip_quotes_* will handle them later). + # For unquoted values, convert to the appropriate Python type. + if isinstance(v, str) and v.startswith('"') and v.endswith('"'): + pass # keep as-is, e.g. '"true"', '""', '"null"' else: - v = string_to_type(t[v_offset]) + v = string_to_type(v) return (k, v) @@ -288,7 +268,7 @@ def _expand_search(self, k, v): try: model = get_model(relation) except LookupError: - raise ParseException('No related field named %s' % relation) + raise pp.ParseException('No related field named %s' % relation) search_kwargs = {} if model is not None: @@ -328,34 +308,31 @@ def execute_logic(self, left, right): def query_from_string(cls, filter_string): """ TODO: - * handle values with " via: a.b.c.d="hello\"world" * handle keys with " via: a.\"b.c="yeah" * handle key with __ in it """ filter_string_raw = filter_string filter_string = str(filter_string) - unicode_spaces = list(set(str(c) for c in filter_string if c.isspace())) - unicode_spaces_other = unicode_spaces + [u'(', u')', u'=', u'"'] - atom = CharsNotIn(unicode_spaces_other) - atom_inside_quotes = CharsNotIn(u'"') - atom_quoted = Literal('"') + Optional(atom_inside_quotes) + Literal('"') - EQUAL = Literal('=') + unquoted = pp.CharsNotIn('()= \t\r\n"') + unquoted.skipWhitespace = True + quoted = pp.QuotedString('"', esc_char='\\', unquote_results=False) + token = quoted | unquoted - grammar = (atom_quoted | atom) + EQUAL + Optional((atom_quoted | atom)) - grammar.setParseAction(cls.BoolOperand) + operand = token + pp.Suppress("=") + pp.Optional(token, default="") + operand.set_parse_action(cls.BoolOperand) - boolExpr = infixNotation( - grammar, + bool_expr = pp.infix_notation( + operand, [ - ("and", 2, opAssoc.LEFT, cls.BoolAnd), - ("or", 2, opAssoc.LEFT, cls.BoolOr), + (pp.Keyword("and"), 2, pp.OpAssoc.LEFT, cls.BoolAnd), + (pp.Keyword("or"), 2, pp.OpAssoc.LEFT, cls.BoolOr), ], ) try: - res = boolExpr.parseString('(' + filter_string + ')') - except (ParseException, FieldDoesNotExist): + res = bool_expr.parse_string(filter_string, parse_all=True) + except (pp.ParseException, FieldDoesNotExist): raise RuntimeError(u"Invalid query %s" % filter_string_raw) if len(res) > 0: diff --git a/awx/main/utils/formatters.py b/awx/main/utils/formatters.py index 5cf5e17a29a6..45ff3f0d955c 100644 --- a/awx/main/utils/formatters.py +++ b/awx/main/utils/formatters.py @@ -257,8 +257,7 @@ def get_extra_fields(self, record): return fields def format(self, record): - stamp = datetime.utcfromtimestamp(record.created) - stamp = stamp.replace(tzinfo=tzutc()) + stamp = datetime.fromtimestamp(record.created, tz=tzutc()) message = { # Field not included, but exist in related logs # 'path': record.pathname diff --git a/awx/main/utils/handlers.py b/awx/main/utils/handlers.py index 4def0b6ba094..f6209c755ec1 100644 --- a/awx/main/utils/handlers.py +++ b/awx/main/utils/handlers.py @@ -4,10 +4,11 @@ # Python import base64 import logging +import logging.handlers import sys import traceback import os -from datetime import datetime +from datetime import datetime, timezone # Django from django.conf import settings @@ -26,6 +27,8 @@ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.resources import Resource +__all__ = ['RSysLogHandler', 'SpecialInventoryHandler', 'ColorHandler'] + class RSysLogHandler(logging.handlers.SysLogHandler): append_nul = False @@ -46,7 +49,7 @@ def handleError(self, record): # because the alternative is blocking the # socket.send() in the Python process, which we definitely don't # want to do) - dt = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + dt = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') msg = f'{dt} ERROR rsyslogd was unresponsive: ' exc = traceback.format_exc() try: @@ -109,39 +112,35 @@ def emit(self, record): if settings.COLOR_LOGS is True: - try: - from logutils.colorize import ColorizingStreamHandler - import colorama - - colorama.deinit() - colorama.init(wrap=False, convert=False, strip=False) - - class ColorHandler(ColorizingStreamHandler): - def colorize(self, line, record): - # comment out this method if you don't like the job_lifecycle - # logs rendered with cyan text - previous_level_map = self.level_map.copy() - if record.name == "awx.analytics.job_lifecycle": - self.level_map[logging.INFO] = (None, 'cyan', True) - msg = super(ColorHandler, self).colorize(line, record) - self.level_map = previous_level_map - return msg - - def format(self, record): - message = logging.StreamHandler.format(self, record) - return '\n'.join([self.colorize(line, record) for line in message.splitlines()]) - - level_map = { - logging.DEBUG: (None, 'green', True), - logging.INFO: (None, None, True), - logging.WARNING: (None, 'yellow', True), - logging.ERROR: (None, 'red', True), - logging.CRITICAL: (None, 'red', True), - } - - except ImportError: - # logutils is only used for colored logs in the dev environment - pass + from logutils.colorize import ColorizingStreamHandler + import colorama + + colorama.deinit() + colorama.init(wrap=False, convert=False, strip=False) + + class ColorHandler(ColorizingStreamHandler): + def colorize(self, line, record): + # comment out this method if you don't like the job_lifecycle + # logs rendered with cyan text + previous_level_map = self.level_map.copy() + if record.name == "awx.analytics.job_lifecycle": + self.level_map[logging.INFO] = (None, 'cyan', True) + msg = super(ColorHandler, self).colorize(line, record) + self.level_map = previous_level_map + return msg + + def format(self, record): + message = logging.StreamHandler.format(self, record) + return '\n'.join([self.colorize(line, record) for line in message.splitlines()]) + + level_map = { + logging.DEBUG: (None, 'green', True), + logging.INFO: (None, None, True), + logging.WARNING: (None, 'yellow', True), + logging.ERROR: (None, 'red', True), + logging.CRITICAL: (None, 'red', True), + } + else: ColorHandler = logging.StreamHandler diff --git a/awx/main/utils/inventory_vars.py b/awx/main/utils/inventory_vars.py new file mode 100644 index 000000000000..7780a93c73ff --- /dev/null +++ b/awx/main/utils/inventory_vars.py @@ -0,0 +1,276 @@ +import logging +from typing import TypeAlias, Any + +from awx.main.models import InventoryGroupVariablesWithHistory + +var_value: TypeAlias = Any +update_queue: TypeAlias = list[tuple[int, var_value]] + + +logger = logging.getLogger('awx.api.inventory_import') + + +class InventoryVariable: + """ + Represents an inventory variable. + + This class keeps track of the variable updates from different inventory + sources. + """ + + def __init__(self, name: str) -> None: + """ + :param str name: The variable's name. + :return: None + """ + self.name = name + self._update_queue: update_queue = [] + """ + A queue representing updates from inventory sources in the sequence of + occurrence. + + The queue is realized as a list of two-tuples containing variable values + and their originating inventory source. The last item of the list is + considered the top of the queue, and holds the current value of the + variable. + """ + + def reset(self) -> None: + """Reset the variable by deleting its history.""" + self._update_queue = [] + + def load(self, updates: update_queue) -> "InventoryVariable": + """Load internal state from a list.""" + self._update_queue = updates + return self + + def dump(self) -> update_queue: + """Save internal state to a list.""" + return self._update_queue + + def update(self, value: var_value, invsrc_id: int) -> None: + """ + Update the variable with a new value from an inventory source. + + Updating means that this source is moved to the top of the queue + and `value` becomes the new current value. + + :param value: The new value of the variable. + :param int invsrc_id: The inventory source of the new variable value. + :return: None + """ + logger.debug(f"InventoryVariable().update({value}, {invsrc_id}):") + # Move this source to the front of the queue by first deleting a + # possibly existing entry, and then add the new entry to the front. + self.delete(invsrc_id) + self._update_queue.append((invsrc_id, value)) + + def delete(self, invsrc_id: int) -> None: + """ + Delete an inventory source from the variable. + + :param int invsrc_id: The inventory source id. + :return: None + """ + data_index = self._get_invsrc_index(invsrc_id) + # Remove last update from this source, if there was any. + if data_index is not None: + value = self._update_queue.pop(data_index)[1] + logger.debug(f"InventoryVariable().delete({invsrc_id}): {data_index=} {value=}") + + def _get_invsrc_index(self, invsrc_id: int) -> int | None: + """Return the inventory source's position in the queue, or `None`.""" + for i, entry in enumerate(self._update_queue): + if entry[0] == invsrc_id: + return i + return None + + def _get_current_value(self) -> var_value: + """ + Return the current value of the variable, or None if the variable has no + history. + """ + return self._update_queue[-1][1] if self._update_queue else None + + @property + def value(self) -> var_value: + """Read the current value of the variable.""" + return self._get_current_value() + + @property + def has_no_source(self) -> bool: + """True, if the variable is orphan, i.e. no source contains this var anymore.""" + return not self._update_queue + + def __str__(self): + """Return the string representation of the current value.""" + return str(self.value or "") + + +class InventoryGroupVariables(dict): + """ + Represent all inventory variables from one group. + + This dict contains all variables of a inventory group and their current + value under consideration of the inventory source update history. + + Note that variables values cannot be `None`, use the empty string to + indicate that a variable holds no value. See also `InventoryVariable`. + """ + + def __init__(self, id: int) -> None: + """ + :param int id: The id of the group object. + :return: None + """ + super().__init__() + self.id = id + # In _vars we keep all sources for a given variable. This enables us to + # find the current value for a variable, which is the value from the + # latest update which defined this variable. + self._vars: dict[str, InventoryVariable] = {} + + def _sync_vars(self) -> None: + """ + Copy the current values of all variables into the internal dict. + + Call this everytime the `_vars` structure has been modified. + """ + for name, inv_var in self._vars.items(): + self[name] = inv_var.value + + def load_state(self, state: dict[str, update_queue]) -> "InventoryGroupVariables": + """Load internal state from a dict.""" + for name, updates in state.items(): + self._vars[name] = InventoryVariable(name).load(updates) + self._sync_vars() + return self + + def save_state(self) -> dict[str, update_queue]: + """Return internal state as a dict.""" + state = {} + for name, inv_var in self._vars.items(): + state[name] = inv_var.dump() + return state + + def update_from_src( + self, + new_vars: dict[str, var_value], + source_id: int, + overwrite_vars: bool = True, + reset: bool = False, + ) -> None: + """ + Update with variables from an inventory source. + + Delete all variables for this source which are not in the update vars. + + :param dict new_vars: The variables from the inventory source. + :param int invsrc_id: The id of the inventory source for this update. + :param bool overwrite_vars: If `True`, delete this source's history + entry for variables which are not in this update. If `False`, keep + the old updates in the history for such variables. Default is + `True`. + :param bool reset: If `True`, delete the update history for all existing + variables before updating the new vars. Therewith making this update + overwrite all history. Default is `False`. + :return: None + """ + logger.debug(f"InventoryGroupVariables({self.id}).update_from_src({new_vars=}, {source_id=}, {overwrite_vars=}, {reset=}): {self=}") + # Create variables which are newly introduced by this source. + for name in new_vars: + if name not in self._vars: + self._vars[name] = InventoryVariable(name) + # Combine the names of the existing vars and the new vars from this update. + all_var_names = list(set(list(self.keys()) + list(new_vars.keys()))) + # In reset-mode, delete all existing vars and their history before + # updating. + if reset: + for name in all_var_names: + self._vars[name].reset() + # Go through all variables (the existing ones, and the ones added by + # this update), delete this source from variables which are not in this + # update, and update the value of variables which are part of this + # update. + for name in all_var_names: + # Update or delete source from var (if name not in vars). + if name in new_vars: + self._vars[name].update(new_vars[name], source_id) + elif overwrite_vars: + self._vars[name].delete(source_id) + # Delete vars which have no source anymore. + if self._vars[name].has_no_source: + del self._vars[name] + del self[name] + # After the update, refresh the internal dict with the possibly changed + # current values. + self._sync_vars() + logger.debug(f"InventoryGroupVariables({self.id}).update_from_src(): {self=}") + + +def update_group_variables( + group_id: int | None, + newvars: dict, + dbvars: dict | None, + invsrc_id: int, + inventory_id: int, + overwrite_vars: bool = True, + reset: bool = False, +) -> dict[str, var_value]: + """ + Update the inventory variables of one group. + + Merge the new variables into the existing group variables. + + The update can be triggered either by an inventory update via API, or via a + manual edit of the variables field in the awx inventory form. + + TODO: Can we get rid of the dbvars? This is only needed because the new + update-var mechanism needs to be properly initialized if the db already + contains some variables. + + :param int group_id: The inventory group id (pk). For the 'all'-group use + `None`, because this group is not an actual `Group` object in the + database. + :param dict newvars: The variables contained in this update. + :param dict dbvars: The variables which are already stored in the database + for this inventory and this group. Can be `None`. + :param int invsrc_id: The id of the inventory source. Usually this is the + database primary key of the inventory source object, but there is one + special id -1 which is used for the initial update from the database and + for manual updates via the GUI. + :param int inventory_id: The id of the inventory on which this update is + applied. + :param bool overwrite_vars: If `True`, delete variables which were merged + from the same source in a previous update, but are no longer contained + in that source. If `False`, such variables would not be removed from the + group. Default is `True`. + :param bool reset: If `True`, delete all variables from previous updates, + therewith making this update overwrite all history. Default is `False`. + :return: The variables and their current values as a dict. + :rtype: dict + """ + inv_group_vars = InventoryGroupVariables(group_id) + # Restore the existing variables state. + try: + # Get the object for this group from the database. + model = InventoryGroupVariablesWithHistory.objects.get(inventory_id=inventory_id, group_id=group_id) + except InventoryGroupVariablesWithHistory.DoesNotExist: + # If no previous state exists, create a new database object, and + # initialize it with the current group variables. + model = InventoryGroupVariablesWithHistory(inventory_id=inventory_id, group_id=group_id) + if dbvars: + inv_group_vars.update_from_src(dbvars, -1) # Assume -1 as inv_source_id for existing vars. + else: + # Load the group variables state from the database object. + inv_group_vars.load_state(model.variables) + # + logger.debug(f"update_group_variables: before update_from_src {model.variables=}") + # Apply the new inventory update onto the group variables. + inv_group_vars.update_from_src(newvars, invsrc_id, overwrite_vars, reset) + # Save the new variables state. + model.variables = inv_group_vars.save_state() + model.save() + logger.debug(f"update_group_variables: after update_from_src {model.variables=}") + logger.debug(f"update_group_variables({group_id=}, {newvars}): {inv_group_vars}") + return inv_group_vars diff --git a/awx/main/utils/lazy_registry.py b/awx/main/utils/lazy_registry.py new file mode 100644 index 000000000000..bb90c84df600 --- /dev/null +++ b/awx/main/utils/lazy_registry.py @@ -0,0 +1,64 @@ +class LazyLoadDict(dict): + """A dict subclass that calls a loader function on first read access. + + Writes (e.g. during the loading process itself) go straight through + without triggering the loader. + """ + + def __init__(self, loader): + super().__init__() + self._loader = loader + self._loaded = False + + def _ensure_loaded(self): + if not self._loaded: + self._loaded = True + self._loader() + + def __getitem__(self, key): + self._ensure_loaded() + return super().__getitem__(key) + + def get(self, key, default=None): + self._ensure_loaded() + return super().get(key, default) + + def __contains__(self, key): + self._ensure_loaded() + return super().__contains__(key) + + def __iter__(self): + self._ensure_loaded() + return super().__iter__() + + def __len__(self): + self._ensure_loaded() + return super().__len__() + + def keys(self): + self._ensure_loaded() + return super().keys() + + def values(self): + self._ensure_loaded() + return super().values() + + def items(self): + self._ensure_loaded() + return super().items() + + def __bool__(self): + self._ensure_loaded() + return super().__bool__() + + def __repr__(self): + self._ensure_loaded() + return super().__repr__() + + def copy(self): + self._ensure_loaded() + return super().copy() + + def clear(self): + super().clear() + self._loaded = True diff --git a/awx/main/utils/licensing.py b/awx/main/utils/licensing.py index 24109cd39ce0..20417940b8bc 100644 --- a/awx/main/utils/licensing.py +++ b/awx/main/utils/licensing.py @@ -38,6 +38,7 @@ from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name from awx.main.constants import SUBSCRIPTION_USAGE_MODEL_UNIQUE_HOSTS +from awx.main.utils.analytics_proxy import OIDCClient MAX_INSTANCES = 9999999 @@ -218,27 +219,43 @@ def update(self, **kwargs): kwargs['license_date'] = int(kwargs['license_date']) self._attrs.update(kwargs) - def validate_rh(self, user, pw): + def get_host_from_rhsm_config(self): try: host = 'https://' + str(self.config.get("server", "hostname")) except Exception: logger.exception('Cannot access rhsm.conf, make sure subscription manager is installed and configured.') host = None + return host + + def validate_rh(self, user, pw, basic_auth): + # if basic auth is True, host is read from rhsm.conf (subscription.rhsm.redhat.com) + # if basic auth is False, host is settings.SUBSCRIPTIONS_RHSM_URL (console.redhat.com) + # if rhsm.conf is not found, host is settings.REDHAT_CANDLEPIN_HOST (satellite server) + if basic_auth: + host = self.get_host_from_rhsm_config() + if not host: + host = getattr(settings, 'REDHAT_CANDLEPIN_HOST', None) + else: + host = settings.SUBSCRIPTIONS_RHSM_URL + if not host: - host = getattr(settings, 'REDHAT_CANDLEPIN_HOST', None) + raise ValueError('Could not get host url for subscriptions') if not user: - raise ValueError('subscriptions_username is required') + raise ValueError('subscriptions_client_id or subscriptions_username is required') if not pw: - raise ValueError('subscriptions_password is required') + raise ValueError('subscriptions_client_secret or subscriptions_password is required') if host and user and pw: - if 'subscription.rhsm.redhat.com' in host: - json = self.get_rhsm_subs(host, user, pw) + if basic_auth: + if 'subscription.rhsm.redhat.com' in host: + json = self.get_rhsm_subs(host, user, pw) + else: + json = self.get_satellite_subs(host, user, pw) else: - json = self.get_satellite_subs(host, user, pw) - return self.generate_license_options_from_entitlements(json) + json = self.get_crc_subs(host, user, pw) + return self.generate_license_options_from_entitlements(json, is_candlepin=basic_auth) return [] def get_rhsm_subs(self, host, user, pw): @@ -260,6 +277,35 @@ def get_rhsm_subs(self, host, user, pw): json.extend(resp.json()) return json + def get_crc_subs(self, host, client_id, client_secret): + try: + client = OIDCClient(client_id, client_secret) + subs = client.make_request( + 'GET', + host, + verify=True, + timeout=(31, 31), + ) + except requests.RequestException: + logger.warning("Failed to connect to console.redhat.com using Service Account credentials. Falling back to basic auth.") + subs = requests.request( + 'GET', + host, + auth=(client_id, client_secret), + verify=True, + timeout=(31, 31), + ) + subs.raise_for_status() + subs_formatted = [] + for sku in subs.json()['body']: + sku_data = {k: v for k, v in sku.items() if k != 'subscriptions'} + for sub in sku['subscriptions']: + sub_data = sku_data.copy() + sub_data['subscriptions'] = sub + subs_formatted.append(sub_data) + + return subs_formatted + def get_satellite_subs(self, host, user, pw): port = None try: @@ -267,7 +313,7 @@ def get_satellite_subs(self, host, user, pw): port = str(self.config.get("server", "port")) except Exception as e: logger.exception('Unable to read rhsm config to get ca_cert location. {}'.format(str(e))) - verify = getattr(settings, 'REDHAT_CANDLEPIN_VERIFY', True) + verify = True if port: host = ':'.join([host, port]) json = [] @@ -309,11 +355,6 @@ def get_satellite_subs(self, host, user, pw): json.append(license) return json - def is_appropriate_sat_sub(self, sub): - if 'Red Hat Ansible Automation' not in sub['subscription_name']: - return False - return True - def is_appropriate_sub(self, sub): if sub['activeSubscription'] is False: return False @@ -323,67 +364,88 @@ def is_appropriate_sub(self, sub): return True return False - def generate_license_options_from_entitlements(self, json): + def is_appropriate_sat_sub(self, sub): + if 'Red Hat Ansible Automation' not in sub['subscription_name']: + return False + return True + + def generate_license_options_from_entitlements(self, json, is_candlepin=False): from dateutil.parser import parse ValidSub = collections.namedtuple( - 'ValidSub', 'sku name support_level end_date trial developer_license quantity pool_id satellite subscription_id account_number usage' + 'ValidSub', 'sku name support_level end_date trial developer_license quantity satellite subscription_id account_number usage' ) valid_subs = [] for sub in json: satellite = sub.get('satellite') if satellite: is_valid = self.is_appropriate_sat_sub(sub) - else: + elif is_candlepin: is_valid = self.is_appropriate_sub(sub) + else: + # the list of subs from console.redhat.com and subscriptions.rhsm.redhat.com are already valid based on the query params we provided + is_valid = True if is_valid: try: - end_date = parse(sub.get('endDate')) + if is_candlepin: + end_date = parse(sub.get('endDate')) + else: + end_date = parse(sub['subscriptions']['endDate']) except Exception: continue - now = datetime.utcnow() + now = datetime.now(timezone.utc) now = now.replace(tzinfo=end_date.tzinfo) if end_date < now: # If the sub has a past end date, skip it continue - try: - quantity = int(sub['quantity']) - if quantity == -1: - # effectively, unlimited - quantity = MAX_INSTANCES - except Exception: - continue - sku = sub['productId'] - trial = sku.startswith('S') # i.e.,, SER/SVC developer_license = False - support_level = '' - usage = '' - pool_id = sub['id'] - subscription_id = sub['subscriptionId'] - account_number = sub['accountNumber'] - if satellite: - support_level = sub['support_level'] - usage = sub['usage'] + support_level = sub.get('support_level', '') + account_number = '' + usage = sub.get('usage', '') + if is_candlepin: + try: + quantity = int(sub['quantity']) + except Exception: + continue + sku = sub['productId'] + subscription_id = sub['subscriptionId'] + sub_name = sub['productName'] + account_number = sub['accountNumber'] else: - for attr in sub.get('productAttributes', []): - if attr.get('name') == 'support_level': - support_level = attr.get('value') - elif attr.get('name') == 'usage': - usage = attr.get('value') - elif attr.get('name') == 'ph_product_name' and attr.get('value') == 'RHEL Developer': - developer_license = True + try: + # Determine total quantity based on capacity name + # if capacity name is Nodes, capacity quantity x subscription quantity + # if capacity name is Sockets, capacity quantity / 2 (minimum of 1) x subscription quantity + if sub['capacity']['name'] == "Nodes": + quantity = int(sub['capacity']['quantity']) * int(sub['subscriptions']['quantity']) + elif sub['capacity']['name'] == "Sockets": + quantity = max(int(sub['capacity']['quantity']) / 2, 1) * int(sub['subscriptions']['quantity']) + else: + continue + except Exception: + continue + sku = sub['sku'] + sub_name = sub['name'] + support_level = sub['serviceLevel'] + subscription_id = sub['subscriptions']['number'] + if sub.get('name') == 'RHEL Developer': + developer_license = True + + if quantity == -1: + # effectively, unlimited + quantity = MAX_INSTANCES + trial = sku.startswith('S') # i.e.,, SER/SVC valid_subs.append( ValidSub( sku, - sub['productName'], + sub_name, support_level, end_date, trial, developer_license, quantity, - pool_id, satellite, subscription_id, account_number, @@ -414,10 +476,11 @@ def generate_license_options_from_entitlements(self, json): license._attrs['satellite'] = satellite license._attrs['valid_key'] = True license.update(license_date=int(sub.end_date.strftime('%s'))) - license.update(pool_id=sub.pool_id) license.update(subscription_id=sub.subscription_id) license.update(account_number=sub.account_number) licenses.append(license._attrs.copy()) + # sort by sku + licenses.sort(key=lambda x: x['sku']) return licenses raise ValueError('No valid Red Hat Ansible Automation subscription could be found for this account.') # noqa diff --git a/awx/main/utils/mem_inventory.py b/awx/main/utils/mem_inventory.py index 58962096fb96..3167710d8c5d 100644 --- a/awx/main/utils/mem_inventory.py +++ b/awx/main/utils/mem_inventory.py @@ -6,7 +6,6 @@ import logging from collections import OrderedDict - # Logger is used for any data-related messages so that the log level # can be adjusted on command invocation logger = logging.getLogger('awx.main.commands.inventory_import') diff --git a/awx/main/utils/named_url_graph.py b/awx/main/utils/named_url_graph.py index 632064f0c11b..51a85fc68441 100644 --- a/awx/main/utils/named_url_graph.py +++ b/awx/main/utils/named_url_graph.py @@ -6,7 +6,6 @@ from django.db import models from django.conf import settings - NAMED_URL_RES_DILIMITER = "++" NAMED_URL_RES_INNER_DILIMITER = "+" NAMED_URL_RES_DILIMITER_ENCODE = "%2B" diff --git a/awx/main/utils/proxy.py b/awx/main/utils/proxy.py index 744c73fed52e..0676e8f44ae6 100644 --- a/awx/main/utils/proxy.py +++ b/awx/main/utils/proxy.py @@ -5,7 +5,6 @@ # DRF from rest_framework.request import Request - """ Note that these methods operate on request.environ. This data is from uwsgi. It is the source data from which request.headers (read-only) is constructed. diff --git a/awx/main/utils/redis.py b/awx/main/utils/redis.py new file mode 100644 index 000000000000..98aa89ba29ce --- /dev/null +++ b/awx/main/utils/redis.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Ansible, Inc. +# All Rights Reserved + +"""Redis client utilities with automatic retry on connection errors.""" + +import redis +import redis.asyncio +from django.conf import settings +from redis.backoff import ExponentialBackoff +from redis.retry import Retry +from redis.exceptions import BusyLoadingError, ConnectionError, TimeoutError + + +def _get_redis_pool_kwargs(): + """ + Get common Redis connection pool kwargs with retry configuration. + + Returns: + dict: Keyword arguments for redis.ConnectionPool.from_url() + """ + retry = Retry(ExponentialBackoff(cap=settings.REDIS_BACKOFF_CAP, base=settings.REDIS_BACKOFF_BASE), retries=settings.REDIS_RETRY_COUNT) + return { + 'retry': retry, + 'retry_on_error': [BusyLoadingError, ConnectionError, TimeoutError], + } + + +def get_redis_client(): + """ + Create a Redis client with automatic retry on connection errors. + + This function creates a Redis connection with built-in retry logic to handle + transient connection failures (like broken pipes, timeouts, etc.) that can occur + during long-running operations. + + Based on PR feedback: https://github.com/ansible/awx/pull/16158#issuecomment-3486839154 + Uses redis-py's built-in retry mechanism instead of custom retry logic. + + Returns: + redis.Redis: A Redis client instance configured with retry logic + + Notes: + - Uses exponential backoff with configurable retries (REDIS_RETRY_COUNT setting) + - Retries on BusyLoadingError, ConnectionError, and TimeoutError + - Requires redis-py 7.0+ + """ + pool = redis.ConnectionPool.from_url( + settings.BROKER_URL, + **_get_redis_pool_kwargs(), + ) + return redis.Redis(connection_pool=pool) + + +def get_redis_client_async(): + """ + Create an async Redis client with automatic retry on connection errors. + + This is the async version of get_redis_client() for use with asyncio code. + + Returns: + redis.asyncio.Redis: An async Redis client instance configured with retry logic + + Notes: + - Uses exponential backoff with configurable retries (REDIS_RETRY_COUNT setting) + - Retries on BusyLoadingError, ConnectionError, and TimeoutError + - Requires redis-py 7.0+ + """ + pool = redis.asyncio.ConnectionPool.from_url( + settings.BROKER_URL, + **_get_redis_pool_kwargs(), + ) + return redis.asyncio.Redis(connection_pool=pool) diff --git a/awx/main/utils/reload.py b/awx/main/utils/reload.py index 29d0784f1292..4306a1ae2fd3 100644 --- a/awx/main/utils/reload.py +++ b/awx/main/utils/reload.py @@ -6,7 +6,6 @@ import logging import os - logger = logging.getLogger('awx.main.utils.reload') diff --git a/awx/main/utils/safe_yaml.py b/awx/main/utils/safe_yaml.py index abf21e3428db..3dbfffc6a177 100644 --- a/awx/main/utils/safe_yaml.py +++ b/awx/main/utils/safe_yaml.py @@ -1,7 +1,6 @@ import re import yaml - __all__ = ['safe_dump', 'SafeLoader'] diff --git a/awx/main/utils/update_model.py b/awx/main/utils/update_model.py index 0b2998561cdf..37f35f7091a0 100644 --- a/awx/main/utils/update_model.py +++ b/awx/main/utils/update_model.py @@ -6,7 +6,6 @@ from awx.main.tasks.signals import signal_callback - logger = logging.getLogger('awx.main.tasks.utils') diff --git a/awx/main/utils/workload_identity.py b/awx/main/utils/workload_identity.py new file mode 100644 index 000000000000..50582e224597 --- /dev/null +++ b/awx/main/utils/workload_identity.py @@ -0,0 +1,22 @@ +from ansible_base.resource_registry.workload_identity_client import get_workload_identity_client + +__all__ = ['retrieve_workload_identity_jwt_with_claims'] + + +def retrieve_workload_identity_jwt_with_claims( + claims: dict, + audience: str, + scope: str, + workload_ttl_seconds: int | None = None, +) -> str: + """Retrieve JWT token from workload claims. + Raises: + RuntimeError: if the workload identity client is not configured. + """ + client = get_workload_identity_client() + if client is None: + raise RuntimeError("Workload identity client is not configured") + kwargs = {"claims": claims, "scope": scope, "audience": audience} + if workload_ttl_seconds: + kwargs["workload_ttl_seconds"] = workload_ttl_seconds + return client.request_workload_jwt(**kwargs).jwt diff --git a/awx/main/validators.py b/awx/main/validators.py index 751d38060bbf..ad1552996b88 100644 --- a/awx/main/validators.py +++ b/awx/main/validators.py @@ -181,6 +181,8 @@ def validate_ssh_private_key(data): certificates; should handle any valid options for ssh_private_key on a credential. """ + # Strip leading and trailing whitespace/newlines to handle common copy-paste issues + data = data.strip() return validate_pem(data, min_keys=1) diff --git a/awx/main/wsrelay.py b/awx/main/wsrelay.py index 38f73c71a37b..d13ff136713a 100644 --- a/awx/main/wsrelay.py +++ b/awx/main/wsrelay.py @@ -94,7 +94,7 @@ async def connect(self): except asyncio.CancelledError: # TODO: Check if connected and disconnect # Possibly use run_until_complete() if disconnect is async - logger.warning(f"Connection from {self.name} to {self.remote_host} cancelled.") + logger.warning(f"Connection from {self.name} to {self.remote_host} canceled.") except client_exceptions.ClientConnectorError as e: logger.warning(f"Connection from {self.name} to {self.remote_host} failed: '{e}'.", exc_info=True) except asyncio.TimeoutError: @@ -139,7 +139,7 @@ async def run_connection(self, websocket: aiohttp.ClientWebSocketResponse): except json.JSONDecodeError: logmsg = "Failed to decode message from web node" if logger.isEnabledFor(logging.DEBUG): - logmsg = "{} {}".format(logmsg, payload) + logmsg = "{} {}".format(logmsg, msg.data) logger.warning(logmsg) continue @@ -242,7 +242,7 @@ async def on_ws_heartbeat(self, conn): except json.JSONDecodeError: logmsg = "Failed to decode message from pg_notify channel `web_ws_heartbeat`" if logger.isEnabledFor(logging.DEBUG): - logmsg = "{} {}".format(logmsg, payload) + logmsg = "{} {}".format(logmsg, notif.payload) logger.warning(logmsg) continue @@ -291,7 +291,7 @@ async def cleanup_offline_host(self, hostname): except asyncio.TimeoutError: logger.warning(f"Tried to cancel relay connection for {hostname} but it timed out during cleanup.") except asyncio.CancelledError: - # Handle the case where the task was already cancelled by the time we got here. + # Handle the case where the task was already canceled by the time we got here. pass del self.relay_connections[hostname] diff --git a/awx/playbooks/action_plugins/insights.py b/awx/playbooks/action_plugins/insights.py index e3f9b9b6e892..2d6b563c292f 100644 --- a/awx/playbooks/action_plugins/insights.py +++ b/awx/playbooks/action_plugins/insights.py @@ -38,7 +38,7 @@ def write_version(self, proj_path, etag): def _obtain_auth_token(self, oidc_endpoint, client_id, client_secret): if oidc_endpoint.endswith('/'): - oidc_endpoint = oidc_endpoint.rstrip('/') + oidc_endpoint = oidc_endpoint[:-1] main_url = oidc_endpoint + '/.well-known/openid-configuration' response = requests.get(url=main_url, headers={'Accept': 'application/json'}) data = {} @@ -83,7 +83,6 @@ def run(self, tmp=None, task_vars=None): password = self._task.args.get('password', None) client_id = self._task.args.get('client_id', None) client_secret = self._task.args.get('client_secret', None) - oidc_endpoint = self._task.args.get('oidc_endpoint', DEFAULT_OIDC_ENDPOINT) session.headers.update( { @@ -93,7 +92,7 @@ def run(self, tmp=None, task_vars=None): ) if authentication == 'service_account' or (client_id and client_secret): - data = self._obtain_auth_token(oidc_endpoint, client_id, client_secret) + data = self._obtain_auth_token(DEFAULT_OIDC_ENDPOINT, client_id, client_secret) if 'token' not in data: result['failed'] = data['failed'] result['msg'] = data['msg'] diff --git a/awx/playbooks/library/indirect_instance_count.py b/awx/playbooks/library/indirect_instance_count.py new file mode 100644 index 000000000000..4cbce89a436c --- /dev/null +++ b/awx/playbooks/library/indirect_instance_count.py @@ -0,0 +1,209 @@ +# (C) 2012, Michael DeHaan, +# (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = ''' + callback: host_query + type: notification + short_description: for demo of indirect host data and counting, this produces collection data + version_added: historical + description: + - Saves collection data to artifacts folder + requirements: + - Whitelist in configuration + - Set AWX_ISOLATED_DATA_DIR, AWX will do this + options: + collect_host_queries: + description: When enabled, scan collections for host query files used in indirect node counting. + type: bool + default: false + env: + - name: AWX_COLLECT_HOST_QUERIES +''' + +import os +import json +import re +from importlib.resources import files + +from packaging.version import Version, InvalidVersion + +from ansible.plugins.callback import CallbackBase + +# NOTE: in Ansible 1.2 or later general logging is available without +# this plugin, just set ANSIBLE_LOG_PATH as an environment variable +# or log_path in the DEFAULTS section of your ansible configuration +# file. This callback is an example of per hosts logging for those +# that want it. + + +# Taken from https://github.com/ansible/ansible/blob/devel/lib/ansible/cli/galaxy.py#L1624 + +from ansible.cli.galaxy import with_collection_artifacts_manager +from ansible.release import __version__ + +from ansible.galaxy.collection import find_existing_collections +from ansible.utils.collection_loader import AnsibleCollectionConfig +import ansible.constants as C + +# External query path constants +EXTERNAL_QUERY_COLLECTION = 'ansible_collections.redhat.indirect_accounting' + + +def _get_query_file_dir(): + """Return the query file directory or None.""" + try: + queries_dir = files(EXTERNAL_QUERY_COLLECTION) / 'extensions' / 'audit' / 'external_queries' + except ModuleNotFoundError: + return None + if not queries_dir.is_dir(): + return None + return queries_dir + + +def list_external_queries(namespace, name): + """List all available external query versions for a collection. + + Args: + namespace: Collection namespace (e.g., 'community') + name: Collection name (e.g., 'vmware') + + Returns: + List of Version objects for all available query files + matching the namespace.name pattern. + """ + versions = [] + + if not (queries_dir := _get_query_file_dir()): + return versions + + # Pattern: namespace.name.X.Y.Z.yml where X.Y.Z is the version + pattern = re.compile(rf'^{re.escape(namespace)}\.{re.escape(name)}\.(.+)\.yml$') + + for query_file in queries_dir.iterdir(): + match = pattern.match(query_file.name) + if match: + version_str = match.group(1) + try: + versions.append(Version(version_str)) + except InvalidVersion: + # Skip files with invalid version strings + pass + + return versions + + +def find_external_query_with_fallback(namespace, name, installed_version): + """Find external query file with semantic version fallback. + + Args: + namespace: Collection namespace (e.g., 'community') + name: Collection name (e.g., 'vmware') + installed_version: Version string of installed collection (e.g., '4.5.0') + + Returns: + Tuple of (query_content, fallback_used, fallback_version) or (None, False, None) + - query_content: The query file content if found + - fallback_used: True if a fallback version was used instead of exact match + - fallback_version: The version string used (for logging) + """ + if not (queries_dir := _get_query_file_dir()): + return None, False, None + + # 1. Try exact version match first + exact_file = queries_dir / f'{namespace}.{name}.{installed_version}.yml' + if exact_file.exists(): + with exact_file.open('r') as f: + return f.read(), False, installed_version + + # 2. Find compatible fallback (same major version, nearest lower version) + try: + installed_version_object = Version(installed_version) + except InvalidVersion: + # Can't do version comparison for fallback + return None, False, None + available_versions = list_external_queries(namespace, name) + if not available_versions: + return None, False, None + + # Filter to same major version and versions <= installed version + compatible_versions = [v for v in available_versions if v.major == installed_version_object.major and v <= installed_version_object] + if not compatible_versions: + return None, False, None + + # Select nearest lower version - highest compatible version + fallback_version_object = max(compatible_versions) + fallback_version_str = str(fallback_version_object) + fallback_file = queries_dir / f'{namespace}.{name}.{fallback_version_str}.yml' + if fallback_file.exists(): + with fallback_file.open('r') as f: + return f.read(), True, fallback_version_str + + return None, False, None + + +@with_collection_artifacts_manager +def list_collections(artifacts_manager=None): + artifacts_manager.require_build_metadata = False + + default_collections_path = set(C.COLLECTIONS_PATHS) + collections_search_paths = default_collections_path | set(AnsibleCollectionConfig.collection_paths) + collections = list(find_existing_collections(list(collections_search_paths), artifacts_manager, dedupe=False)) + return collections + + +class CallbackModule(CallbackBase): + """ + logs playbook results, per host, in /var/log/ansible/hosts + """ + + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'notification' + CALLBACK_NAME = 'indirect_instance_count' + CALLBACK_NEEDS_WHITELIST = True + + TIME_FORMAT = "%b %d %Y %H:%M:%S" + MSG_FORMAT = "%(now)s - %(category)s - %(data)s\n\n" + + def v2_playbook_on_stats(self, stats): + artifact_dir = os.getenv('AWX_ISOLATED_DATA_DIR') + if not artifact_dir: + raise RuntimeError('Only suitable in AWX, did not find private_data_dir') + + collect_host_queries = self.get_option('collect_host_queries') + + collections_print = {} + for candidate in list_collections(): + collection_print = { + 'version': candidate.ver, + } + + if collect_host_queries: + embedded_query_file = files(f'ansible_collections.{candidate.namespace}.{candidate.name}') / 'extensions' / 'audit' / 'event_query.yml' + if embedded_query_file.exists(): + with embedded_query_file.open('r') as f: + collection_print['host_query'] = f.read() + self._display.vv(f"Using embedded query for {candidate.fqcn} v{candidate.ver}") + else: + query_content, fallback_used, version_used = find_external_query_with_fallback(candidate.namespace, candidate.name, candidate.ver) + if query_content: + collection_print['host_query'] = query_content + if fallback_used: + self._display.v(f"Using external query {version_used} for {candidate.fqcn} v{candidate.ver}.") + else: + self._display.v(f"Using external query for {candidate.fqcn} v{candidate.ver}") + + collections_print[candidate.fqcn] = collection_print + + ansible_data = {'installed_collections': collections_print, 'ansible_version': __version__} + + write_path = os.path.join(artifact_dir, 'ansible_data.json') + with open(write_path, "w") as fd: + fd.write(json.dumps(ansible_data, indent=2)) + + super().v2_playbook_on_stats(stats) diff --git a/awx/playbooks/project_update.yml b/awx/playbooks/project_update.yml index 2f4ab183c747..22db2a7152c6 100644 --- a/awx/playbooks/project_update.yml +++ b/awx/playbooks/project_update.yml @@ -201,7 +201,7 @@ # additional_galaxy_env contains environment variables are used for installing roles and collections and will take precedence over items in galaxy_task_env additional_galaxy_env: # These paths control where ansible-galaxy installs collections and roles on top the filesystem - ANSIBLE_COLLECTIONS_PATHS: "{{ projects_root }}/.__awx_cache/{{ local_path }}/stage/requirements_collections" + ANSIBLE_COLLECTIONS_PATH: "{{ projects_root }}/.__awx_cache/{{ local_path }}/stage/requirements_collections" ANSIBLE_ROLES_PATH: "{{ projects_root }}/.__awx_cache/{{ local_path }}/stage/requirements_roles" # Put the local tmp directory in same volume as collection destination # otherwise, files cannot be moved accross volumes and will cause error @@ -236,7 +236,7 @@ changed_when: "'was installed successfully' in galaxy_result.stdout" when: - roles_enabled | bool - - req_file + - req_file | length > 0 tags: - install_roles @@ -255,7 +255,7 @@ when: - "ansible_version.full is version_compare('2.9', '>=')" - collections_enabled | bool - - req_file + - req_file | length > 0 tags: - install_collections @@ -276,7 +276,7 @@ - "ansible_version.full is version_compare('2.10', '>=')" - collections_enabled | bool - roles_enabled | bool - - req_file + - req_file | length > 0 tags: - install_collections - install_roles diff --git a/awx/resource_api.py b/awx/resource_api.py index 2009dfab8b78..10c2eac4ed51 100644 --- a/awx/resource_api.py +++ b/awx/resource_api.py @@ -1,6 +1,14 @@ from ansible_base.resource_registry.registry import ParentResource, ResourceConfig, ServiceAPIConfig, SharedResource -from ansible_base.resource_registry.shared_types import OrganizationType, TeamType, UserType +from ansible_base.rbac.models import RoleDefinition +from ansible_base.resource_registry.shared_types import ( + FeatureFlagType, + RoleDefinitionType, + OrganizationType, + TeamType, + UserType, +) +from ansible_base.feature_flags.models import AAPFlag from awx.main import models @@ -13,10 +21,22 @@ class APIConfig(ServiceAPIConfig): models.Organization, shared_resource=SharedResource(serializer=OrganizationType, is_provider=False), ), - ResourceConfig(models.User, shared_resource=SharedResource(serializer=UserType, is_provider=False), name_field="username"), + ResourceConfig( + models.User, + shared_resource=SharedResource(serializer=UserType, is_provider=False), + name_field="username", + ), ResourceConfig( models.Team, shared_resource=SharedResource(serializer=TeamType, is_provider=False), parent_resources=[ParentResource(model=models.Organization, field_name="organization")], ), + ResourceConfig( + RoleDefinition, + shared_resource=SharedResource(serializer=RoleDefinitionType, is_provider=False), + ), + ResourceConfig( + AAPFlag, + shared_resource=SharedResource(serializer=FeatureFlagType, is_provider=False), + ), ) diff --git a/awx/settings/__init__.py b/awx/settings/__init__.py index e484e62be15d..78afed70bdfc 100644 --- a/awx/settings/__init__.py +++ b/awx/settings/__init__.py @@ -1,2 +1,83 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +import os +import copy +from ansible_base.lib.dynamic_config import ( + factory, + export, + load_envvars, + load_python_file_with_injected_context, + load_standard_settings_files, +) +from .functions import ( + assert_production_settings, + merge_application_name, + add_backwards_compatibility, + load_extra_development_files, +) + +add_backwards_compatibility() + +# Create a the standard DYNACONF instance which will come with DAB defaults +# This loads defaults.py and environment specific file e.g: development_defaults.py +DYNACONF = factory( + __name__, + "AWX", + environments=("development", "production", "quiet", "kube"), + settings_files=["defaults.py"], +) + +# Store snapshot before loading any custom config file +DYNACONF.set( + "DEFAULTS_SNAPSHOT", + copy.deepcopy(DYNACONF.as_dict(internal=False)), + loader_identifier="awx.settings:DEFAULTS_SNAPSHOT", +) + +############################################################################################# +# Settings loaded before this point will be allowed to be overridden by the database settings +# Any settings loaded after this point will be marked as as a read_only database setting +############################################################################################# + +# Load extra settings files from the following directories +# /etc/tower/conf.d/ and /etc/tower/ +# this is the legacy location, kept for backwards compatibility +settings_dir = os.environ.get('AWX_SETTINGS_DIR', '/etc/tower/conf.d/') +settings_files_path = os.path.join(settings_dir, '*.py') +settings_file_path = os.environ.get('AWX_SETTINGS_FILE', '/etc/tower/settings.py') +load_python_file_with_injected_context(settings_files_path, settings=DYNACONF) +load_python_file_with_injected_context(settings_file_path, settings=DYNACONF) + +# Load extra settings files from the following directories +# /etc/ansible-automation-platform/{settings,flags,.secrets}.yaml +# and /etc/ansible-automation-platform/awx/{settings,flags,.secrets}.yaml +# this is the new standard location for all services +load_standard_settings_files(DYNACONF) + +# Load optional development only settings files +load_extra_development_files(DYNACONF) + +# Check at least one setting file has been loaded in production mode +assert_production_settings(DYNACONF, settings_dir, settings_file_path) + +# Load envvars at the end to allow them to override everything loaded so far +load_envvars(DYNACONF) + +# When deployed as part of AAP (RESOURCE_SERVER__URL is set), enforce JWT-only +# authentication. This ensures all requests go through the gateway and prevents +# direct API access to Controller bypassing the platform's authentication. +if DYNACONF.get('RESOURCE_SERVER__URL', None): + DYNACONF.set( + "REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES", + ['ansible_base.jwt_consumer.awx.auth.AwxJWTAuthentication'], + ) + +# This must run after all custom settings are loaded +DYNACONF.update( + merge_application_name(DYNACONF), + loader_identifier="awx.settings:merge_application_name", + merge=True, +) + +# Update django.conf.settings with DYNACONF values +export(__name__, DYNACONF) diff --git a/awx/settings/application_name.py b/awx/settings/application_name.py index ed76886c395e..ac7e40553e00 100644 --- a/awx/settings/application_name.py +++ b/awx/settings/application_name.py @@ -25,6 +25,7 @@ def get_application_name(CLUSTER_HOST_ID, function=''): def set_application_name(DATABASES, CLUSTER_HOST_ID, function=''): + """In place modification of DATABASES to set the application name for the connection.""" # If settings files were not properly passed DATABASES could be {} at which point we don't need to set the app name. if not DATABASES or 'default' not in DATABASES: return diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 2f83414a482e..b3e3fe10dc08 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -7,10 +7,6 @@ import re # noqa import tempfile import socket -from datetime import timedelta - -from split_settings.tools import include - DEBUG = True SQL_DEBUG = DEBUG @@ -83,10 +79,6 @@ # to load the internationalization machinery. USE_I18N = True -# If you set this to False, Django will not format dates, numbers and -# calendars according to the current locale -USE_L10N = True - USE_TZ = True STATICFILES_DIRS = [ @@ -223,6 +215,9 @@ # events into the database JOB_EVENT_WORKERS = 4 +# Minimum number of workers for the dispatcher (dispatcherd) process pool +DISPATCHER_MIN_WORKERS = 4 + # The number of seconds to buffer callback receiver bulk # writes in memory before flushing via JobEvent.objects.bulk_create() JOB_EVENT_BUFFER_SECONDS = 1 @@ -359,6 +354,7 @@ 'ansible_base.resource_registry', 'ansible_base.rbac', 'ansible_base.feature_flags', + 'ansible_base.api_documentation', 'flags', ] @@ -382,15 +378,13 @@ 'VIEW_DESCRIPTION_FUNCTION': 'awx.api.generics.get_view_description', 'NON_FIELD_ERRORS_KEY': '__all__', 'DEFAULT_VERSION': 'v2', - # For swagger schema generation + # For OpenAPI schema generation with drf-spectacular # see https://github.com/encode/django-rest-framework/pull/6532 - 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema', + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', # 'URL_FORMAT_OVERRIDE': None, } -SWAGGER_SETTINGS = { - 'DEFAULT_AUTO_SCHEMA_CLASS': 'awx.api.swagger.CustomSwaggerAutoSchema', -} +# SWAGGER_SETTINGS removed - migrated to drf-spectacular (see SPECTACULAR_SETTINGS below) AUTHENTICATION_BACKENDS = ('awx.main.backends.AWXModelBackend',) @@ -424,34 +418,37 @@ # Amount of time dispatcher will try to reconnect to database for jobs and consuming new work DISPATCHER_DB_DOWNTIME_TOLERANCE = 40 -# If you set this, nothing will ever be sent to pg_notify -# this is not practical to use, although periodic schedules may still run slugish but functional tasks -# sqlite3 based tests will use this -DISPATCHER_MOCK_PUBLISH = False - BROKER_URL = 'unix:///var/run/redis/redis.sock' -CELERYBEAT_SCHEDULE = { - 'tower_scheduler': {'task': 'awx.main.tasks.system.awx_periodic_scheduler', 'schedule': timedelta(seconds=30), 'options': {'expires': 20}}, - 'cluster_heartbeat': { +REDIS_RETRY_COUNT = 3 # Number of retries for Redis connection errors +REDIS_BACKOFF_CAP = 1.0 # Maximum backoff delay in seconds for Redis retries +REDIS_BACKOFF_BASE = 0.5 # Base for exponential backoff calculation for Redis retries + +DISPATCHER_SCHEDULE = { + 'awx.main.tasks.system.awx_periodic_scheduler': {'task': 'awx.main.tasks.system.awx_periodic_scheduler', 'schedule': 30, 'options': {'expires': 20}}, + 'awx.main.tasks.system.cluster_node_heartbeat': { 'task': 'awx.main.tasks.system.cluster_node_heartbeat', - 'schedule': timedelta(seconds=CLUSTER_NODE_HEARTBEAT_PERIOD), + 'schedule': CLUSTER_NODE_HEARTBEAT_PERIOD, 'options': {'expires': 50}, }, - 'gather_analytics': {'task': 'awx.main.tasks.system.gather_analytics', 'schedule': timedelta(minutes=5)}, - 'task_manager': {'task': 'awx.main.scheduler.tasks.task_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}}, - 'dependency_manager': {'task': 'awx.main.scheduler.tasks.dependency_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}}, - 'k8s_reaper': {'task': 'awx.main.tasks.system.awx_k8s_reaper', 'schedule': timedelta(seconds=60), 'options': {'expires': 50}}, - 'receptor_reaper': {'task': 'awx.main.tasks.system.awx_receptor_workunit_reaper', 'schedule': timedelta(seconds=60)}, - 'send_subsystem_metrics': {'task': 'awx.main.analytics.analytics_tasks.send_subsystem_metrics', 'schedule': timedelta(seconds=20)}, - 'cleanup_images': {'task': 'awx.main.tasks.system.cleanup_images_and_files', 'schedule': timedelta(hours=3)}, - 'cleanup_host_metrics': {'task': 'awx.main.tasks.host_metrics.cleanup_host_metrics', 'schedule': timedelta(hours=3, minutes=30)}, - 'host_metric_summary_monthly': {'task': 'awx.main.tasks.host_metrics.host_metric_summary_monthly', 'schedule': timedelta(hours=4)}, - 'periodic_resource_sync': {'task': 'awx.main.tasks.system.periodic_resource_sync', 'schedule': timedelta(minutes=15)}, + 'awx.main.tasks.system.gather_analytics': {'task': 'awx.main.tasks.system.gather_analytics', 'schedule': 300}, + 'awx.main.scheduler.tasks.task_manager': {'task': 'awx.main.scheduler.tasks.task_manager', 'schedule': 20, 'options': {'expires': 20}}, + 'awx.main.scheduler.tasks.dependency_manager': {'task': 'awx.main.scheduler.tasks.dependency_manager', 'schedule': 20, 'options': {'expires': 20}}, + 'awx.main.tasks.system.awx_k8s_reaper': {'task': 'awx.main.tasks.system.awx_k8s_reaper', 'schedule': 60, 'options': {'expires': 50}}, + 'awx.main.tasks.system.awx_receptor_workunit_reaper': {'task': 'awx.main.tasks.system.awx_receptor_workunit_reaper', 'schedule': 60}, + 'awx.main.analytics.analytics_tasks.send_subsystem_metrics': {'task': 'awx.main.analytics.analytics_tasks.send_subsystem_metrics', 'schedule': 20}, + 'awx.main.tasks.system.cleanup_images_and_files': {'task': 'awx.main.tasks.system.cleanup_images_and_files', 'schedule': 10800}, + 'awx.main.tasks.host_metrics.cleanup_host_metrics': {'task': 'awx.main.tasks.host_metrics.cleanup_host_metrics', 'schedule': 12600}, + 'awx.main.tasks.host_metrics.host_metric_summary_monthly': {'task': 'awx.main.tasks.host_metrics.host_metric_summary_monthly', 'schedule': 14400}, + 'awx.main.tasks.system.periodic_resource_sync': {'task': 'awx.main.tasks.system.periodic_resource_sync', 'schedule': 900}, + 'awx.main.tasks.host_indirect.cleanup_and_save_indirect_host_entries_fallback': { + 'task': 'awx.main.tasks.host_indirect.cleanup_and_save_indirect_host_entries_fallback', + 'schedule': 3600, + }, } # Django Caching Configuration DJANGO_REDIS_IGNORE_EXCEPTIONS = True -CACHES = {'default': {'BACKEND': 'awx.main.cache.AWXRedisCache', 'LOCATION': 'unix:///var/run/redis/redis.sock?db=1'}} +CACHES = {'default': {'BACKEND': 'ansible_base.lib.cache.redis_cache.DABRedisCache', 'LOCATION': 'unix:///var/run/redis/redis.sock?db=1'}} ROLE_SINGLETON_USER_RELATIONSHIP = '' ROLE_SINGLETON_TEAM_RELATIONSHIP = '' @@ -527,12 +524,9 @@ # Automatically remove nodes that have missed their heartbeats after some time AWX_AUTO_DEPROVISION_INSTANCES = False -# If False, do not allow creation of resources that are shared with the platform ingress -# e.g. organizations, teams, and users -ALLOW_LOCAL_RESOURCE_MANAGEMENT = True # If True, allow users to be assigned to roles that were created via JWT -ALLOW_LOCAL_ASSIGNING_JWT_ROLES = False +ALLOW_LOCAL_ASSIGNING_JWT_ROLES = True # Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed' # Note: This setting may be overridden by database settings. @@ -547,6 +541,9 @@ # Last gathered entries for expensive Analytics AUTOMATION_ANALYTICS_LAST_ENTRIES = '' +# Candlepin integration settings for analytics authentication +AWX_ANALYTICS_CANDLEPIN_URL = 'https://subscription.rhsm.redhat.com/subscription/' + # Default list of modules allowed for ad hoc commands. # Note: This setting may be overridden by database settings. AD_HOC_COMMANDS = [ @@ -591,6 +588,12 @@ VMWARE_VALIDATE_CERTS = False +# ----------------- +# -- VMware ESXi -- +# ----------------- +# TODO: Verify matches with AAP-53978 solution in awx-plugins +VMWARE_ESXI_EXCLUDE_EMPTY_GROUPS = True + # --------------------------- # -- Google Compute Engine -- # --------------------------- @@ -703,7 +706,6 @@ TOWER_URL_BASE = "https://platformhost" INSIGHTS_URL_BASE = "https://example.org" -INSIGHTS_OIDC_ENDPOINT = "https://sso.example.org" INSIGHTS_AGENT_MIME = 'application/example' # See https://github.com/ansible/awx-facts-playbooks INSIGHTS_SYSTEM_ID_FILE = '/etc/redhat-access-insights/machine-id' @@ -778,7 +780,7 @@ 'awx.conf.settings': {'handlers': ['null'], 'level': 'WARNING'}, 'awx.main': {'handlers': ['null']}, 'awx.main.commands.run_callback_receiver': {'handlers': ['callback_receiver'], 'level': 'INFO'}, # very noisey debug-level logs - 'awx.main.dispatch': {'handlers': ['dispatcher']}, + 'awx.main.dispatch': {'handlers': ['task_system']}, 'awx.main.consumers': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'INFO'}, 'awx.main.rsyslog_configurer': {'handlers': ['rsyslog_configurer']}, 'awx.main.cache_clear': {'handlers': ['cache_clear']}, @@ -798,6 +800,7 @@ 'social': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'}, 'system_tracking_migrations': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'}, 'rbac_migrations': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'DEBUG'}, + 'dispatcherd': {'handlers': ['dispatcher', 'console'], 'level': 'INFO'}, }, } @@ -967,6 +970,9 @@ # - 'unique_managed_hosts': Compliant = automated - deleted hosts (using /api/v2/host_metrics/) SUBSCRIPTION_USAGE_MODEL = '' +# Default URL and query params for obtaining valid AAP subscriptions +SUBSCRIPTIONS_RHSM_URL = 'https://console.redhat.com/api/rhsm/v2/products?include=providedProducts&oids=480&status=Active' + # Host metrics cleanup - last time of the task/command run CLEANUP_HOST_METRICS_LAST_TS = None # Host metrics cleanup - minimal interval between two cleanups in days @@ -994,7 +1000,7 @@ # projects can take advantage. METRICS_SERVICE_CALLBACK_RECEIVER = 'callback_receiver' -METRICS_SERVICE_DISPATCHER = 'dispatcher' +METRICS_SERVICE_DISPATCHER = 'dispatcherd' METRICS_SERVICE_WEBSOCKETS = 'websockets' METRICS_SUBSYSTEM_CONFIG = { @@ -1011,16 +1017,56 @@ } } - # django-ansible-base ANSIBLE_BASE_TEAM_MODEL = 'main.Team' ANSIBLE_BASE_ORGANIZATION_MODEL = 'main.Organization' ANSIBLE_BASE_RESOURCE_CONFIG_MODULE = 'awx.resource_api' -ANSIBLE_BASE_PERMISSION_MODEL = 'main.Permission' -from ansible_base.lib import dynamic_config # noqa: E402 - -include(os.path.join(os.path.dirname(dynamic_config.__file__), 'dynamic_settings.py')) +# Defaults to be overridden by DAB +SPECTACULAR_SETTINGS = { + 'TITLE': 'AWX API', + 'DESCRIPTION': 'AWX API Documentation', + 'VERSION': 'v2', + 'OAS_VERSION': '3.0.3', # Set OpenAPI Specification version to 3.0.3 + 'SERVE_INCLUDE_SCHEMA': False, + 'SCHEMA_PATH_PREFIX': r'/api/v[0-9]', + 'DEFAULT_GENERATOR_CLASS': 'drf_spectacular.generators.SchemaGenerator', + 'SCHEMA_COERCE_PATH_PK_SUFFIX': True, + 'CONTACT': {'email': 'ansible-community@redhat.com'}, + 'LICENSE': {'name': 'Apache License'}, + 'TERMS_OF_SERVICE': 'https://www.google.com/policies/terms/', + # Use our custom schema class that handles swagger_topic and deprecated views + 'DEFAULT_SCHEMA_CLASS': 'awx.api.schema.CustomAutoSchema', + 'COMPONENT_SPLIT_REQUEST': True, + # Postprocessing hooks for OpenAPI schema generation + 'POSTPROCESSING_HOOKS': [ + 'awx.api.schema.filter_credential_type_schema', + 'awx.api.schema.inject_ai_descriptions', + ], + 'SWAGGER_UI_SETTINGS': { + 'deepLinking': True, + 'persistAuthorization': True, + 'displayOperationId': True, + }, + # Resolve enum naming collisions with meaningful names + 'ENUM_NAME_OVERRIDES': { + # Status field collisions + 'Status4e1Enum': 'UnifiedJobStatusEnum', + 'Status876Enum': 'JobStatusEnum', + # Job type field collisions + 'JobType8b8Enum': 'JobTemplateJobTypeEnum', + 'JobType95bEnum': 'AdHocCommandJobTypeEnum', + 'JobType963Enum': 'ProjectUpdateJobTypeEnum', + # Verbosity field collisions + 'Verbosity481Enum': 'JobVerbosityEnum', + 'Verbosity8cfEnum': 'InventoryUpdateVerbosityEnum', + # Event field collision + 'Event4d3Enum': 'JobEventEnum', + # Kind field collision + 'Kind362Enum': 'InventoryKindEnum', + }, +} +OAUTH2_PROVIDER = {} # Add a postfix to the API URL patterns # example if set to '' API pattern will be /api @@ -1058,6 +1104,7 @@ # Currently features are enabled to keep compatibility with old system, except custom roles ANSIBLE_BASE_ALLOW_TEAM_ORG_ADMIN = False # ANSIBLE_BASE_ALLOW_CUSTOM_ROLES = True +ANSIBLE_BASE_ALLOW_TEAM_PARENTS = False ANSIBLE_BASE_ALLOW_CUSTOM_TEAM_ROLES = False ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES = True ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES = False # System auditor has always been restricted to users @@ -1066,5 +1113,39 @@ # system username for django-ansible-base SYSTEM_USERNAME = None +# For indirect host query processing +# if a job is not immediently confirmed to have all events processed +# it will be eligable for processing after this number of minutes +INDIRECT_HOST_QUERY_FALLBACK_MINUTES = 60 + +# If an error happens in event collection, give up after this time +INDIRECT_HOST_QUERY_FALLBACK_GIVEUP_DAYS = 3 + +# Maximum age for indirect host audit records +# Older records will be cleaned up +INDIRECT_HOST_AUDIT_RECORD_MAX_AGE_DAYS = 7 + +# setting for Policy as Code feature +FEATURE_POLICY_AS_CODE_ENABLED = False + +OPA_HOST = '' # The hostname used to connect to the OPA server. If empty, policy enforcement will be disabled. +OPA_PORT = 8181 # The port used to connect to the OPA server. Defaults to 8181. +OPA_SSL = False # Enable or disable the use of SSL to connect to the OPA server. Defaults to false. + +OPA_AUTH_TYPE = 'None' # The authentication type that will be used to connect to the OPA server: "None", "Token", or "Certificate". +OPA_AUTH_TOKEN = '' # The token for authentication to the OPA server. Required when OPA_AUTH_TYPE is "Token". If an authorization header is defined in OPA_AUTH_CUSTOM_HEADERS, it will be overridden by OPA_AUTH_TOKEN. +OPA_AUTH_CLIENT_CERT = '' # The content of the client certificate file for mTLS authentication to the OPA server. Required when OPA_AUTH_TYPE is "Certificate". +OPA_AUTH_CLIENT_KEY = '' # The content of the client key for mTLS authentication to the OPA server. Required when OPA_AUTH_TYPE is "Certificate". +OPA_AUTH_CA_CERT = '' # The content of the CA certificate for mTLS authentication to the OPA server. Required when OPA_AUTH_TYPE is "Certificate". +OPA_AUTH_CUSTOM_HEADERS = {} # Optional custom headers included in requests to the OPA server. Defaults to empty dictionary ({}). +OPA_REQUEST_TIMEOUT = 1.5 # The number of seconds after which the connection to the OPA server will time out. Defaults to 1.5 seconds. +OPA_REQUEST_RETRIES = 2 # The number of retry attempts for connecting to the OPA server. Default is 2. + # feature flags -FLAGS = {'FEATURE_INDIRECT_NODE_COUNTING_ENABLED': [{'condition': 'boolean', 'value': False}]} +FEATURE_INDIRECT_NODE_COUNTING_ENABLED = False +FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED = False + +# Dispatcher worker lifetime. If set to None, workers will never be retired +# based on age. Note workers will finish their last task before retiring if +# they are busy when they reach retirement age. +WORKER_MAX_LIFETIME_SECONDS = 14400 # seconds diff --git a/awx/settings/development.py b/awx/settings/development.py index d38c2759e2c0..5b630c49841a 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -1,126 +1,13 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Development settings for AWX project. - -# Python +# This file exists for backwards compatibility only +# the current way of running AWX is to point settings to +# awx/settings/__init__.py as the entry point for the settings +# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings import os -import socket -import copy -import sys -import traceback - -# Centos-7 doesn't include the svg mime type -# /usr/lib64/python/mimetypes.py -import mimetypes - -# Django Split Settings -from split_settings.tools import optional, include - -# Load default settings. -from .defaults import * # NOQA - -# awx-manage shell_plus --notebook -NOTEBOOK_ARGUMENTS = ['--NotebookApp.token=', '--ip', '0.0.0.0', '--port', '9888', '--allow-root', '--no-browser'] - -# print SQL queries in shell_plus -SHELL_PLUS_PRINT_SQL = False - -# show colored logs in the dev environment -# to disable this, set `COLOR_LOGS = False` in awx/settings/local_settings.py -COLOR_LOGS = True -LOGGING['handlers']['console']['()'] = 'awx.main.utils.handlers.ColorHandler' # noqa - -ALLOWED_HOSTS = ['*'] - -mimetypes.add_type("image/svg+xml", ".svg", True) -mimetypes.add_type("image/svg+xml", ".svgz", True) - -# Disallow sending session cookies over insecure connections -SESSION_COOKIE_SECURE = False - -# Disallow sending csrf cookies over insecure connections -CSRF_COOKIE_SECURE = False - -# Disable Pendo on the UI for development/test. -# Note: This setting may be overridden by database settings. -PENDO_TRACKING_STATE = "off" -INSIGHTS_TRACKING_STATE = False - -# debug toolbar and swagger assume that requirements/requirements_dev.txt are installed - -INSTALLED_APPS += ['drf_yasg', 'debug_toolbar'] # NOQA - -MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE # NOQA - -DEBUG_TOOLBAR_CONFIG = {'ENABLE_STACKTRACES': True} - -# Configure a default UUID for development only. -SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' -INSTALL_UUID = '00000000-0000-0000-0000-000000000000' - -# Ansible base virtualenv paths and enablement -# only used for deprecated fields and management commands for them -BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv") - -CLUSTER_HOST_ID = socket.gethostname() - -AWX_CALLBACK_PROFILE = True - -# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!================================= -# Disable normal scheduled/triggered task managers (DependencyManager, TaskManager, WorkflowManager). -# Allows user to trigger task managers directly for debugging and profiling purposes. -# Only works in combination with settings.SETTINGS_MODULE == 'awx.settings.development' -AWX_DISABLE_TASK_MANAGERS = False - -# Needed for launching runserver in debug mode -# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!================================= - -# Store a snapshot of default settings at this point before loading any -# customizable config files. -this_module = sys.modules[__name__] -local_vars = dir(this_module) -DEFAULTS_SNAPSHOT = {} # define after we save local_vars so we do not snapshot the snapshot -for setting in local_vars: - if setting.isupper(): - DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting)) - -del local_vars # avoid temporary variables from showing up in dir(settings) -del this_module -# -############################################################################################### -# -# Any settings defined after this point will be marked as as a read_only database setting -# -################################################################################################ - -# If there is an `/etc/tower/settings.py`, include it. -# If there is a `/etc/tower/conf.d/*.py`, include them. -include(optional('/etc/tower/settings.py'), scope=locals()) -include(optional('/etc/tower/conf.d/*.py'), scope=locals()) - -# If any local_*.py files are present in awx/settings/, use them to override -# default settings for development. If not present, we can still run using -# only the defaults. -# this needs to stay at the bottom of this file -try: - if os.getenv('AWX_KUBE_DEVEL', False): - include(optional('development_kube.py'), scope=locals()) - else: - include(optional('local_*.py'), scope=locals()) -except ImportError: - traceback.print_exc() - sys.exit(1) - -# The below runs AFTER all of the custom settings are imported -# because conf.d files will define DATABASES and this should modify that -from .application_name import set_application_name -set_application_name(DATABASES, CLUSTER_HOST_ID) # NOQA +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings") +os.environ.setdefault("AWX_MODE", "development") -del set_application_name +from ansible_base.lib.dynamic_config import export +from . import DYNACONF # noqa -# Set the value of any feature flags that are defined in the local settings -for feature in list(FLAGS.keys()): # noqa: F405 - if feature in locals(): - FLAGS[feature][0]['value'] = locals()[feature] # noqa: F405 +export(__name__, DYNACONF) diff --git a/awx/settings/development_defaults.py b/awx/settings/development_defaults.py new file mode 100644 index 000000000000..49cb1a68b6bc --- /dev/null +++ b/awx/settings/development_defaults.py @@ -0,0 +1,71 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Development settings for AWX project. + +# Python +import os +import socket + +# Centos-7 doesn't include the svg mime type +# /usr/lib64/python/mimetypes.py +import mimetypes + +# awx-manage shell_plus --notebook +NOTEBOOK_ARGUMENTS = ['--NotebookApp.token=', '--ip', '0.0.0.0', '--port', '9888', '--allow-root', '--no-browser'] + +# print SQL queries in shell_plus +SHELL_PLUS_PRINT_SQL = False + +# show colored logs in the dev environment +# to disable this, set `COLOR_LOGS = False` in awx/settings/local_settings.py +COLOR_LOGS = True +LOGGING__handlers__console = '@merge {"()": "awx.main.utils.handlers.ColorHandler"}' + +ALLOWED_HOSTS = ['*'] + +mimetypes.add_type("image/svg+xml", ".svg", True) +mimetypes.add_type("image/svg+xml", ".svgz", True) + +# Disallow sending session cookies over insecure connections +SESSION_COOKIE_SECURE = False + +# Disallow sending csrf cookies over insecure connections +CSRF_COOKIE_SECURE = False + +# Disable Pendo on the UI for development/test. +# Note: This setting may be overridden by database settings. +PENDO_TRACKING_STATE = "off" +INSIGHTS_TRACKING_STATE = False + +# debug toolbar and swagger assume that requirements/requirements_dev.txt are installed +INSTALLED_APPS = "@merge drf_spectacular,debug_toolbar" +MIDDLEWARE = "@insert 0 debug_toolbar.middleware.DebugToolbarMiddleware" + +DEBUG_TOOLBAR_CONFIG = {'ENABLE_STACKTRACES': True} + +# drf-spectacular settings for API schema generation +# SPECTACULAR_SETTINGS moved to defaults.py so it's available in all environments + +# Configure a default UUID for development only. +SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' +INSTALL_UUID = '00000000-0000-0000-0000-000000000000' + +# Ansible base virtualenv paths and enablement +# only used for deprecated fields and management commands for them +BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv") + +CLUSTER_HOST_ID = socket.gethostname() + +AWX_CALLBACK_PROFILE = True + +# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!================================= +# Disable normal scheduled/triggered task managers (DependencyManager, TaskManager, WorkflowManager). +# Allows user to trigger task managers directly for debugging and profiling purposes. +# Only works in combination with settings.SETTINGS_MODULE == 'awx.settings.development' +AWX_DISABLE_TASK_MANAGERS = False + +# Needed for launching runserver in debug mode +# ======================!!!!!!! FOR DEVELOPMENT ONLY !!!!!!!================================= + +FEATURE_INDIRECT_NODE_COUNTING_ENABLED = True diff --git a/awx/settings/development_kube.py b/awx/settings/development_kube.py index c30a7fe025fe..e6ba6170c6d7 100644 --- a/awx/settings/development_kube.py +++ b/awx/settings/development_kube.py @@ -1,4 +1,13 @@ -BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖' -BROADCAST_WEBSOCKET_PORT = 8052 -BROADCAST_WEBSOCKET_VERIFY_CERT = False -BROADCAST_WEBSOCKET_PROTOCOL = 'http' +# This file exists for backwards compatibility only +# the current way of running AWX is to point settings to +# awx/settings/__init__.py as the entry point for the settings +# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings") +os.environ.setdefault("AWX_MODE", "development,kube") + +from ansible_base.lib.dynamic_config import export +from . import DYNACONF # noqa + +export(__name__, DYNACONF) diff --git a/awx/settings/development_quiet.py b/awx/settings/development_quiet.py index c47e78b69d86..5fea2756e908 100644 --- a/awx/settings/development_quiet.py +++ b/awx/settings/development_quiet.py @@ -1,15 +1,13 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. +# This file exists for backwards compatibility only +# the current way of running AWX is to point settings to +# awx/settings/__init__.py as the entry point for the settings +# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings +import os -# Development settings for AWX project, but with DEBUG disabled +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings") +os.environ.setdefault("AWX_MODE", "development,quiet") -# Load development settings. -from defaults import * # NOQA +from ansible_base.lib.dynamic_config import export +from . import DYNACONF # noqa -# Load development settings. -from development import * # NOQA - -# Disable capturing DEBUG -DEBUG = False -TEMPLATE_DEBUG = DEBUG -SQL_DEBUG = DEBUG +export(__name__, DYNACONF) diff --git a/awx/settings/functions.py b/awx/settings/functions.py new file mode 100644 index 000000000000..70be9befdbae --- /dev/null +++ b/awx/settings/functions.py @@ -0,0 +1,86 @@ +import os +from ansible_base.lib.dynamic_config import load_python_file_with_injected_context +from dynaconf import Dynaconf +from .application_name import get_application_name + + +def merge_application_name(settings): + """Return a dynaconf merge dict to set the application name for the connection.""" + data = {} + if "sqlite3" not in settings.get("DATABASES__default__ENGINE", ""): + data["DATABASES__default__OPTIONS__application_name"] = get_application_name(settings.get("CLUSTER_HOST_ID")) + return data + + +def add_backwards_compatibility(): + """Add backwards compatibility for AWX_MODE. + + Before dynaconf integration the usage of AWX settings was supported to be just + DJANGO_SETTINGS_MODULE=awx.settings.production or DJANGO_SETTINGS_MODULE=awx.settings.development + (development_quiet and development_kube were also supported). + + With dynaconf the DJANGO_SETTINGS_MODULE should be set always to "awx.settings" as the only entry point + for settings and then "AWX_MODE" can be set to any of production,development,quiet,kube + or a combination of them separated by comma. + + E.g: + + export DJANGO_SETTINGS_MODULE=awx.settings + export AWX_MODE=production + awx-manage [command] + dynaconf [command] + + If pointing `DJANGO_SETTINGS_MODULE` to `awx.settings.production` or `awx.settings.development` then + this function will set `AWX_MODE` to the correct value. + """ + django_settings_module = os.getenv("DJANGO_SETTINGS_MODULE", "awx.settings") + if django_settings_module == "awx.settings": + return + + current_mode = os.getenv("AWX_MODE", "") + for _module_name in ["development", "production", "development_quiet", "development_kube"]: + if django_settings_module == f"awx.settings.{_module_name}": + _mode = current_mode.split(",") + if "development_" in _module_name and "development" not in current_mode: + _mode.append("development") + _mode_fragment = _module_name.replace("development_", "") + if _mode_fragment not in _mode: + _mode.append(_mode_fragment) + os.environ["AWX_MODE"] = ",".join(_mode) + + +def load_extra_development_files(settings: Dynaconf): + """Load optional development only settings files.""" + if not settings.is_development_mode: + return + + if settings.get_environ("AWX_KUBE_DEVEL"): + load_python_file_with_injected_context("kube_defaults.py", settings=settings) + else: + load_python_file_with_injected_context("local_*.py", settings=settings) + + +def assert_production_settings(settings: Dynaconf, settings_dir: str, settings_file_path: str): # pragma: no cover + """Ensure at least one setting file has been loaded in production mode. + Current systems will require /etc/tower/settings.py and + new systems will require /etc/ansible-automation-platform/*.yaml + """ + if "production" not in settings.current_env.lower(): + return + + required_settings_paths = [ + os.path.dirname(settings_file_path), + "/etc/ansible-automation-platform/", + settings_dir, + ] + + for path in required_settings_paths: + if any([path in os.path.dirname(f) for f in settings._loaded_files]): + break + else: + from django.core.exceptions import ImproperlyConfigured # noqa + + msg = 'No AWX configuration found at %s.' % required_settings_paths + msg += '\nDefine the AWX_SETTINGS_FILE environment variable to ' + msg += 'specify an alternate path.' + raise ImproperlyConfigured(msg) diff --git a/awx/settings/kube_defaults.py b/awx/settings/kube_defaults.py new file mode 100644 index 000000000000..c30a7fe025fe --- /dev/null +++ b/awx/settings/kube_defaults.py @@ -0,0 +1,4 @@ +BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖' +BROADCAST_WEBSOCKET_PORT = 8052 +BROADCAST_WEBSOCKET_VERIFY_CERT = False +BROADCAST_WEBSOCKET_PROTOCOL = 'http' diff --git a/awx/settings/production.py b/awx/settings/production.py index e340de4fbbc1..bcf483b118cf 100644 --- a/awx/settings/production.py +++ b/awx/settings/production.py @@ -1,111 +1,13 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Production settings for AWX project. - -# Python +# This file exists for backwards compatibility only +# the current way of running AWX is to point settings to +# awx/settings/__init__.py as the entry point for the settings +# that is done by exporting: export DJANGO_SETTINGS_MODULE=awx.settings import os -import copy -import errno -import sys -import traceback - -# Django Split Settings -from split_settings.tools import optional, include - -# Load default settings. -from .defaults import * # NOQA - -DEBUG = False -TEMPLATE_DEBUG = DEBUG -SQL_DEBUG = DEBUG - -# Clear database settings to force production environment to define them. -DATABASES = {} - -# Clear the secret key to force production environment to define it. -SECRET_KEY = None - -# Hosts/domain names that are valid for this site; required if DEBUG is False -# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [] - -# Ansible base virtualenv paths and enablement -# only used for deprecated fields and management commands for them -BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv") - -# Very important that this is editable (not read_only) in the API -AWX_ISOLATION_SHOW_PATHS = [ - '/etc/pki/ca-trust:/etc/pki/ca-trust:O', - '/usr/share/pki:/usr/share/pki:O', -] - -# Store a snapshot of default settings at this point before loading any -# customizable config files. -this_module = sys.modules[__name__] -local_vars = dir(this_module) -DEFAULTS_SNAPSHOT = {} # define after we save local_vars so we do not snapshot the snapshot -for setting in local_vars: - if setting.isupper(): - DEFAULTS_SNAPSHOT[setting] = copy.deepcopy(getattr(this_module, setting)) - -del local_vars # avoid temporary variables from showing up in dir(settings) -del this_module -# -############################################################################################### -# -# Any settings defined after this point will be marked as as a read_only database setting -# -################################################################################################ - -# Load settings from any .py files in the global conf.d directory specified in -# the environment, defaulting to /etc/tower/conf.d/. -settings_dir = os.environ.get('AWX_SETTINGS_DIR', '/etc/tower/conf.d/') -settings_files = os.path.join(settings_dir, '*.py') - -# Load remaining settings from the global settings file specified in the -# environment, defaulting to /etc/tower/settings.py. -settings_file = os.environ.get('AWX_SETTINGS_FILE', '/etc/tower/settings.py') - -# Attempt to load settings from /etc/tower/settings.py first, followed by -# /etc/tower/conf.d/*.py. -try: - include(settings_file, optional(settings_files), scope=locals()) -except ImportError: - traceback.print_exc() - sys.exit(1) -except IOError: - from django.core.exceptions import ImproperlyConfigured - - included_file = locals().get('__included_file__', '') - if not included_file or included_file == settings_file: - # The import doesn't always give permission denied, so try to open the - # settings file directly. - try: - e = None - open(settings_file) - except IOError: - pass - if e and e.errno == errno.EACCES: - SECRET_KEY = 'permission-denied' - LOGGING = {} - else: - msg = 'No AWX configuration found at %s.' % settings_file - msg += '\nDefine the AWX_SETTINGS_FILE environment variable to ' - msg += 'specify an alternate path.' - raise ImproperlyConfigured(msg) - else: - raise - -# The below runs AFTER all of the custom settings are imported -# because conf.d files will define DATABASES and this should modify that -from .application_name import set_application_name -set_application_name(DATABASES, CLUSTER_HOST_ID) # NOQA +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings") +os.environ.setdefault("AWX_MODE", "production") -del set_application_name +from ansible_base.lib.dynamic_config import export +from . import DYNACONF # noqa -# Set the value of any feature flags that are defined in the local settings -for feature in list(FLAGS.keys()): # noqa: F405 - if feature in locals(): - FLAGS[feature][0]['value'] = locals()[feature] # noqa: F405 +export(__name__, DYNACONF) diff --git a/awx/settings/production_defaults.py b/awx/settings/production_defaults.py new file mode 100644 index 000000000000..02184abbda8b --- /dev/null +++ b/awx/settings/production_defaults.py @@ -0,0 +1,35 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Production settings for AWX project. + +import os + +DEBUG = False +TEMPLATE_DEBUG = DEBUG +SQL_DEBUG = DEBUG + +# Clear database settings to force production environment to define them. +DATABASES = {} + +# Clear the secret key to force production environment to define it. +SECRET_KEY = None + +# Hosts/domain names that are valid for this site; required if DEBUG is False +# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = [] + +# In production, trust the X-Forwarded-For header set by the reverse proxy +REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR'] + +# Ansible base virtualenv paths and enablement +# only used for deprecated fields and management commands for them +BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv") + +# Very important that this is editable (not read_only) in the API +AWX_ISOLATION_SHOW_PATHS = [ + '/etc/pki/ca-trust:/etc/pki/ca-trust:O', + '/usr/share/pki:/usr/share/pki:O', +] + +del os diff --git a/awx/settings/quiet_defaults.py b/awx/settings/quiet_defaults.py new file mode 100644 index 000000000000..1cb21720f7dd --- /dev/null +++ b/awx/settings/quiet_defaults.py @@ -0,0 +1,8 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. +# Development settings for AWX project, but with DEBUG disabled + +# Disable capturing DEBUG +DEBUG = False +TEMPLATE_DEBUG = DEBUG +SQL_DEBUG = DEBUG diff --git a/awx/static/api/api.js b/awx/static/api/api.js index 67053ae2f626..98d803ad9f0c 100644 --- a/awx/static/api/api.js +++ b/awx/static/api/api.js @@ -14,7 +14,7 @@ $(function() { $('span.str').each(function() { var s = $(this).html(); if (s.match(/^\"\/.+\/\"$/) || s.match(/^\"\/.+\/\?.*\"$/)) { - $(this).html('"' + s.replace(/\"/g, '') + '"'); + $(this).html('"' + s.replaceAll('"', '') + '"'); } }); @@ -27,7 +27,7 @@ $(function() { }).each(function() { $(this).nextUntil('span.pun:contains("]")').filter('span.str').each(function() { if ($(this).text().match(/^\".+\"$/)) { - var s = $(this).text().replace(/\"/g, ''); + var s = $(this).text().replaceAll('"', ''); $(this).html('"' + s + '"'); } else if ($(this).text() !== '"') { diff --git a/awx/static/custom_404.html b/awx/static/custom_404.html index 71350db7a12a..9e22cc0767d3 100644 --- a/awx/static/custom_404.html +++ b/awx/static/custom_404.html @@ -1,4 +1,5 @@ - + + Redirecting diff --git a/awx/static/custom_502.html b/awx/static/custom_502.html index cc5ab94b4e43..5fc541245a3c 100644 --- a/awx/static/custom_502.html +++ b/awx/static/custom_502.html @@ -1,5 +1,5 @@ - + On Break... @@ -8,7 +8,7 @@
- + AWX mascot reading a book 502
diff --git a/awx/static/custom_504.html b/awx/static/custom_504.html index ba40e495bbb1..5f39b7f28d6b 100644 --- a/awx/static/custom_504.html +++ b/awx/static/custom_504.html @@ -1,5 +1,5 @@ - + On Break... @@ -8,7 +8,7 @@
- + AWX mascot reading a book 504
diff --git a/awx/static/custom_error.css b/awx/static/custom_error.css index e52b535e800a..94401bb3937b 100644 --- a/awx/static/custom_error.css +++ b/awx/static/custom_error.css @@ -28,7 +28,6 @@ body { .upper_div { background-color: #F8EBA7; justify-content: center; - align-items: center; text-align: center; height: 50%; align-items: flex-end; @@ -48,7 +47,7 @@ body { right: 90px; font-size:200px; color: #FDBA48; - font-family: Impact, Haettenschweiler, "Franklin Gothic Bold", Charcoal, "Helvetica Inserat", "Bitstream Vera Sans Bold", "Arial Black", "sans serif"; + font-family: Impact, Haettenschweiler, "Franklin Gothic Bold", Charcoal, "Helvetica Inserat", "Bitstream Vera Sans Bold", "Arial Black", sans-serif; } .message_div { @@ -62,7 +61,7 @@ body { .m1,.m2,.m3 { color: #151515; width: 100%; - font-family: redhat-display-medium; + font-family: redhat-display-medium, sans-serif; } .m1 { @@ -78,5 +77,5 @@ body { .m3 { font-size: 16px; padding-top: 20px; - font-family: redhat-display-regular; + font-family: redhat-display-regular, sans-serif; } diff --git a/awx/templates/error.html b/awx/templates/error.html index 815235ebfc85..8f6dcc754aeb 100644 --- a/awx/templates/error.html +++ b/awx/templates/error.html @@ -18,7 +18,7 @@