Compare commits

..

1 Commits

Author SHA1 Message Date
Rodrigo Horie
d99efc0de8 Fix gather analytics 2025-06-23 09:30:17 -03:00
526 changed files with 7084 additions and 23035 deletions

View File

@@ -1,3 +1,3 @@
# Community Code of Conduct # Community Code of Conduct
Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html).

View File

@@ -13,7 +13,7 @@ body:
attributes: attributes:
label: Please confirm the following label: Please confirm the following
options: options:
- label: I agree to follow this project's [code of conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). - label: I agree to follow this project's [code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html).
required: true required: true
- label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates. - label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates.
required: true required: true

View File

@@ -5,7 +5,7 @@ contact_links:
url: https://github.com/ansible/awx#get-involved url: https://github.com/ansible/awx#get-involved
about: For general debugging or technical support please see the Get Involved section of our readme. about: For general debugging or technical support please see the Get Involved section of our readme.
- name: 📝 Ansible Code of Conduct - name: 📝 Ansible Code of Conduct
url: https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html?utm_medium=github&utm_source=issue_template_chooser url: https://docs.ansible.com/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. about: AWX uses the Ansible Code of Conduct; ❤ Be nice to other members of the community. ☮ Behave.
- name: 💼 For Enterprise - name: 💼 For Enterprise
url: https://www.ansible.com/products/engine?utm_medium=github&utm_source=issue_template_chooser url: https://www.ansible.com/products/engine?utm_medium=github&utm_source=issue_template_chooser

View File

@@ -13,7 +13,7 @@ body:
attributes: attributes:
label: Please confirm the following label: Please confirm the following
options: options:
- label: I agree to follow this project's [code of conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). - label: I agree to follow this project's [code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html).
required: true required: true
- label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates. - label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates.
required: true required: true

View File

@@ -17,6 +17,7 @@ in as the first entry for your PR title.
##### COMPONENT NAME ##### COMPONENT NAME
<!--- Name of the module/plugin/module/task --> <!--- Name of the module/plugin/module/task -->
- API - API
- UI
- Collection - Collection
- CLI - CLI
- Docs - Docs
@@ -24,7 +25,7 @@ in as the first entry for your PR title.
##### STEPS TO REPRODUCE AND EXTRA INFO ##### ADDITIONAL INFORMATION
<!--- <!---
Include additional information to help people understand the change here. Include additional information to help people understand the change here.
For bugs that don't have a linked bug report, a step-by-step reproduction For bugs that don't have a linked bug report, a step-by-step reproduction

View File

@@ -11,6 +11,8 @@ inputs:
runs: runs:
using: composite using: composite
steps: steps:
- uses: ./.github/actions/setup-python
- name: Set lower case owner name - name: Set lower case owner name
shell: bash shell: bash
run: echo "OWNER_LC=${OWNER,,}" >> $GITHUB_ENV run: echo "OWNER_LC=${OWNER,,}" >> $GITHUB_ENV

View File

@@ -36,7 +36,7 @@ runs:
- name: Upgrade ansible-core - name: Upgrade ansible-core
shell: bash shell: bash
run: python -m pip install --upgrade ansible-core run: python3 -m pip install --upgrade ansible-core
- name: Install system deps - name: Install system deps
shell: bash shell: bash

View File

@@ -8,10 +8,3 @@ updates:
labels: labels:
- "docs" - "docs"
- "dependencies" - "dependencies"
- package-ecosystem: "pip"
directory: "requirements/"
schedule:
interval: "daily" #run daily until we trust it, then back this off to weekly
open-pull-requests-limit: 2
labels:
- "dependencies"

View File

@@ -70,10 +70,10 @@ Thank you for your submission and for supporting AWX!
- Hello, we'd love to help, but we need a little more information about the problem you're having. Screenshots, log outputs, or any reproducers would be very helpful. - Hello, we'd love to help, but we need a little more information about the problem you're having. Screenshots, log outputs, or any reproducers would be very helpful.
### Code of Conduct ### Code of Conduct
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html - Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
### EE Contents / Community General ### EE Contents / Community General
- Hello. The awx-ee contains the collections and dependencies needed for supported AWX features to function. Anything beyond that (like the community.general package) will require you to build your own EE. For information on how to do that, see https://docs.ansible.com/projects/builder/en/stable/ \ - Hello. The awx-ee contains the collections and dependencies needed for supported AWX features to function. Anything beyond that (like the community.general package) will require you to build your own EE. For information on how to do that, see https://ansible-builder.readthedocs.io/en/stable/ \
\ \
The Ansible Community is looking at building an EE that corresponds to all of the collections inside the ansible package. That may help you if and when it happens; see https://github.com/ansible-community/community-topics/issues/31 for details. The Ansible Community is looking at building an EE that corresponds to all of the collections inside the ansible package. That may help you if and when it happens; see https://github.com/ansible-community/community-topics/issues/31 for details.
@@ -88,7 +88,7 @@ The Ansible Community is looking at building an EE that corresponds to all of th
- Hello, we think your idea is good! Please consider contributing a PR for this following our contributing guidelines: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md - Hello, we think your idea is good! Please consider contributing a PR for this following our contributing guidelines: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
### Receptor ### Receptor
- You can find the receptor docs here: https://docs.ansible.com/projects/receptor/en/latest/ - You can find the receptor docs here: https://receptor.readthedocs.io/en/latest/
- Hello, your issue seems related to receptor. Could you please open an issue in the receptor repository? https://github.com/ansible/receptor. Thanks! - Hello, your issue seems related to receptor. Could you please open an issue in the receptor repository? https://github.com/ansible/receptor. Thanks!
### Ansible Engine not AWX ### Ansible Engine not AWX

View File

@@ -1,102 +0,0 @@
---
name: API Schema Change Detection
env:
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
CI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEV_DOCKER_OWNER: ${{ github.repository_owner }}
COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
UPSTREAM_REPOSITORY_ID: 91594105
on:
pull_request:
branches:
- devel
- release_**
- feature_**
- stable-**
jobs:
api-schema-detection:
name: Detect API Schema Changes
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
with:
show-progress: false
fetch-depth: 0
- name: Build awx_devel image for schema check
uses: ./.github/actions/awx_devel_image
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
private-github-key: ${{ secrets.PRIVATE_GITHUB_KEY }}
- name: Detect API schema changes
id: schema-check
continue-on-error: true
run: |
AWX_DOCKER_ARGS='-e GITHUB_ACTIONS' \
AWX_DOCKER_CMD='make detect-schema-change SCHEMA_DIFF_BASE_BRANCH=${{ github.event.pull_request.base.ref }}' \
make docker-runner 2>&1 | tee schema-diff.txt
exit ${PIPESTATUS[0]}
- name: Validate OpenAPI schema
id: schema-validation
continue-on-error: true
run: |
AWX_DOCKER_ARGS='-e GITHUB_ACTIONS' \
AWX_DOCKER_CMD='make validate-openapi-schema' \
make docker-runner 2>&1 | tee schema-validation.txt
exit ${PIPESTATUS[0]}
- name: Add schema validation and diff to job summary
if: always()
# show text and if for some reason, it can't be generated, state that it can't be.
run: |
echo "## API Schema Check Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Show validation status
echo "### OpenAPI Validation" >> $GITHUB_STEP_SUMMARY
if [ -f schema-validation.txt ] && grep -q "✓ Schema is valid" schema-validation.txt; then
echo "✅ **Status:** PASSED - Schema is valid OpenAPI 3.0.3" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Status:** FAILED - Schema validation failed" >> $GITHUB_STEP_SUMMARY
if [ -f schema-validation.txt ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Validation errors</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat schema-validation.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Show schema changes
echo "### Schema Changes" >> $GITHUB_STEP_SUMMARY
if [ -f schema-diff.txt ]; then
if grep -q "^+" schema-diff.txt || grep -q "^-" schema-diff.txt; then
echo "**Changes detected** between this PR and the base branch" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Truncate to first 1000 lines to stay under GitHub's 1MB summary limit
TOTAL_LINES=$(wc -l < schema-diff.txt)
if [ $TOTAL_LINES -gt 1000 ]; then
echo "_Showing first 1000 of ${TOTAL_LINES} lines. See job logs or download artifact for full diff._" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo '```diff' >> $GITHUB_STEP_SUMMARY
head -n 1000 schema-diff.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
else
echo "No schema changes detected" >> $GITHUB_STEP_SUMMARY
fi
else
echo "Unable to generate schema diff" >> $GITHUB_STEP_SUMMARY
fi

View File

@@ -4,46 +4,14 @@ env:
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
CI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEV_DOCKER_OWNER: ${{ github.repository_owner }} DEV_DOCKER_OWNER: ${{ github.repository_owner }}
COMPOSE_TAG: ${{ github.base_ref || github.ref_name || 'devel' }} COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
UPSTREAM_REPOSITORY_ID: 91594105 UPSTREAM_REPOSITORY_ID: 91594105
on: on:
pull_request: pull_request:
push: push:
branches: branches:
- devel # needed to publish code coverage post-merge - devel # needed to publish code coverage post-merge
schedule:
- cron: '0 12,18 * * 1-5'
workflow_dispatch: {}
jobs: jobs:
trigger-release-branches:
name: "Dispatch CI to release branches"
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Trigger CI on release_4.6
id: dispatch_release_46
continue-on-error: true
run: gh workflow run ci.yml --ref release_4.6
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- name: Trigger CI on stable-2.6
id: dispatch_stable_26
continue-on-error: true
run: gh workflow run ci.yml --ref stable-2.6
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- name: Check dispatch results
if: steps.dispatch_release_46.outcome == 'failure' || steps.dispatch_stable_26.outcome == 'failure'
run: |
echo "One or more dispatches failed:"
echo " release_4.6: ${{ steps.dispatch_release_46.outcome }}"
echo " stable-2.6: ${{ steps.dispatch_stable_26.outcome }}"
exit 1
common-tests: common-tests:
name: ${{ matrix.tests.name }} name: ${{ matrix.tests.name }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -64,9 +32,18 @@ jobs:
- name: api-lint - name: api-lint
command: /var/lib/awx/venv/awx/bin/tox -e linters command: /var/lib/awx/venv/awx/bin/tox -e linters
coverage-upload-name: "" coverage-upload-name: ""
- name: api-swagger
command: /start_tests.sh swagger
coverage-upload-name: ""
- name: awx-collection - name: awx-collection
command: /start_tests.sh test_collection_all command: /start_tests.sh test_collection_all
coverage-upload-name: "awx-collection" coverage-upload-name: "awx-collection"
- name: api-schema
command: >-
/start_tests.sh detect-schema-change SCHEMA_DIFF_BASE_BRANCH=${{
github.event.pull_request.base.ref || github.ref_name
}}
coverage-upload-name: ""
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -86,21 +63,6 @@ jobs:
AWX_DOCKER_CMD='${{ matrix.tests.command }}' AWX_DOCKER_CMD='${{ matrix.tests.command }}'
make docker-runner make docker-runner
- name: Inject PR number into coverage.xml
if: >-
!cancelled()
&& github.event_name == 'pull_request'
&& steps.make-run.outputs.cov-report-files != ''
run: |
if [ -f "reports/coverage.xml" ]; then
sed -i '2i<!-- PR ${{ github.event.pull_request.number }} -->' 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<!-- PR ${{ github.event.pull_request.number }} -->' awxkit/coverage.xml
echo "Injected PR number ${{ github.event.pull_request.number }} into awxkit/coverage.xml"
fi
- name: Upload test coverage to Codecov - name: Upload test coverage to Codecov
if: >- if: >-
!cancelled() !cancelled()
@@ -140,37 +102,25 @@ jobs:
}} }}
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test artifacts - name: Upload awx jUnit test reports
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: >- if: >-
!cancelled() !cancelled()
&& steps.make-run.outputs.test-result-files != '' && steps.make-run.outputs.test-result-files != ''
&& github.event_name == 'push' && github.event_name == 'push'
&& env.UPSTREAM_REPOSITORY_ID == github.repository_id && env.UPSTREAM_REPOSITORY_ID == github.repository_id
&& github.ref_name == github.event.repository.default_branch && github.ref_name == github.event.repository.default_branch
uses: ansible/gh-action-record-test-results@3784db66a1b7fb3809999a7251c8a7203a7ffbe8 run: |
with: for junit_file in $(echo '${{ steps.make-run.outputs.test-result-files }}' | sed 's/,/ /')
aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }} do
http-auth-password: >- curl \
${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }} -v \
http-auth-username: >- --user "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }}:${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }}" \
${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }} --form "xunit_xml=@${junit_file}" \
project-component-name: >- --form "component_name=${{ matrix.tests.coverage-upload-name || 'awx' }}" \
${{ matrix.tests.coverage-upload-name || 'awx' }} --form "git_commit_sha=${{ github.sha }}" \
test-result-files: >- --form "git_repository_url=https://github.com/${{ github.repository }}" \
${{ steps.make-run.outputs.test-result-files }} "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}/api/results/upload/"
done
dev-env: dev-env:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -182,7 +132,7 @@ jobs:
- uses: ./.github/actions/setup-python - uses: ./.github/actions/setup-python
with: with:
python-version: '3.13' python-version: '3.x'
- uses: ./.github/actions/run_awx_devel - uses: ./.github/actions/run_awx_devel
id: awx id: awx
@@ -222,20 +172,14 @@ jobs:
repository: ansible/awx-operator repository: ansible/awx-operator
path: awx-operator path: awx-operator
- name: Setup python, referencing action at awx relative path - uses: ./awx/.github/actions/setup-python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with: with:
python-version: '3.12' working-directory: awx
- name: Install playbook dependencies - name: Install playbook dependencies
run: | run: |
python -m pip install docker python3 -m pip install docker
- name: Check Python version
working-directory: awx
run: |
make print-PYTHON
- name: Build AWX image - name: Build AWX image
working-directory: awx working-directory: awx
run: | run: |
@@ -247,59 +191,27 @@ jobs:
- name: Run test deployment with awx-operator - name: Run test deployment with awx-operator
working-directory: awx-operator working-directory: awx-operator
id: awx_operator_test
timeout-minutes: 60
continue-on-error: true
run: | run: |
set +e python3 -m pip install -r molecule/requirements.txt
timeout 15m bash -elc ' python3 -m pip install PyYAML # for awx/tools/scripts/rewrite-awx-operator-requirements.py
python -m pip install -r molecule/requirements.txt $(realpath ../awx/tools/scripts/rewrite-awx-operator-requirements.py) molecule/requirements.yml $(realpath ../awx)
python -m pip install PyYAML # for awx/tools/scripts/rewrite-awx-operator-requirements.py ansible-galaxy collection install -r molecule/requirements.yml
$(realpath ../awx/tools/scripts/rewrite-awx-operator-requirements.py) molecule/requirements.yml $(realpath ../awx) sudo rm -f $(which kustomize)
ansible-galaxy collection install -r molecule/requirements.yml make kustomize
sudo rm -f $(which kustomize) KUSTOMIZE_PATH=$(readlink -f bin/kustomize) molecule -v test -s kind -- --skip-tags=replicas
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: env:
AWX_TEST_IMAGE: local/awx AWX_TEST_IMAGE: local/awx
AWX_TEST_VERSION: ci AWX_TEST_VERSION: ci
AWX_EE_TEST_IMAGE: quay.io/ansible/awx-ee:latest AWX_EE_TEST_IMAGE: quay.io/ansible/awx-ee:latest
STORE_DEBUG_OUTPUT: true 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 - name: Upload debug output
if: always() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: awx-operator-debug-output name: awx-operator-debug-output
path: ${{ env.DEBUG_OUTPUT_DIR }} 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: collection-sanity:
name: awx_collection sanity name: awx_collection sanity
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -334,16 +246,18 @@ jobs:
&& github.event_name == 'push' && github.event_name == 'push'
&& env.UPSTREAM_REPOSITORY_ID == github.repository_id && env.UPSTREAM_REPOSITORY_ID == github.repository_id
&& github.ref_name == github.event.repository.default_branch && github.ref_name == github.event.repository.default_branch
uses: ansible/gh-action-record-test-results@3784db66a1b7fb3809999a7251c8a7203a7ffbe8 run: |
with: for junit_file in $(echo '${{ steps.make-run.outputs.test-result-files }}' | sed 's/,/ /')
aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }} do
http-auth-password: >- curl \
${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }} -v \
http-auth-username: >- --user "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }}:${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }}" \
${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }} --form "xunit_xml=@${junit_file}" \
project-component-name: awx --form "component_name=awx" \
test-result-files: >- --form "git_commit_sha=${{ github.sha }}" \
${{ steps.make-run.outputs.test-result-files }} --form "git_repository_url=https://github.com/${{ github.repository }}" \
"${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}/api/results/upload/"
done
collection-integration: collection-integration:
name: awx_collection integration name: awx_collection integration
@@ -366,11 +280,7 @@ jobs:
- uses: ./.github/actions/setup-python - uses: ./.github/actions/setup-python
with: with:
python-version: '3.13' python-version: '3.x'
- name: Remove system ansible to avoid conflicts
run: |
python -m pip uninstall -y ansible ansible-core || true
- uses: ./.github/actions/run_awx_devel - uses: ./.github/actions/run_awx_devel
id: awx id: awx
@@ -381,9 +291,8 @@ jobs:
- name: Install dependencies for running tests - name: Install dependencies for running tests
run: | run: |
python -m pip install -e ./awxkit/ python3 -m pip install -e ./awxkit/
python -m pip install -r awx_collection/requirements.txt python3 -m pip install -r awx_collection/requirements.txt
hash -r # Rehash to pick up newly installed scripts
- name: Run integration tests - name: Run integration tests
id: make-run id: make-run
@@ -395,7 +304,6 @@ jobs:
echo 'password = password' >> ~/.tower_cli.cfg echo 'password = password' >> ~/.tower_cli.cfg
echo 'verify_ssl = false' >> ~/.tower_cli.cfg echo 'verify_ssl = false' >> ~/.tower_cli.cfg
TARGETS="$(ls awx_collection/tests/integration/targets | grep '${{ matrix.target-regex.regex }}' | tr '\n' ' ')" 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 make COLLECTION_VERSION=100.100.100-git COLLECTION_TEST_TARGET="--requirements $TARGETS" test_collection_integration
env: env:
ANSIBLE_TEST_PREFER_PODMAN: 1 ANSIBLE_TEST_PREFER_PODMAN: 1
@@ -450,14 +358,10 @@ jobs:
- uses: ./.github/actions/setup-python - uses: ./.github/actions/setup-python
with: with:
python-version: '3.13' python-version: '3.x'
- name: Remove system ansible to avoid conflicts
run: |
python -m pip uninstall -y ansible ansible-core || true
- name: Upgrade ansible-core - name: Upgrade ansible-core
run: python -m pip install --upgrade ansible-core run: python3 -m pip install --upgrade ansible-core
- name: Download coverage artifacts - name: Download coverage artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@@ -472,12 +376,11 @@ jobs:
mkdir -p ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage mkdir -p ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage
cp -rv coverage/* ~/.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 cd ~/.ansible/collections/ansible_collections/awx/awx
hash -r # Rehash to pick up newly installed scripts ansible-test coverage combine --requirements
PATH="$(python -c 'import sys; import os; print(os.path.dirname(sys.executable))'):$PATH" ansible-test coverage combine --requirements ansible-test coverage html
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 '## AWX Collection Integration Coverage' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
PATH="$(python -c 'import sys; import os; print(os.path.dirname(sys.executable))'):$PATH" ansible-test coverage report >> $GITHUB_STEP_SUMMARY ansible-test coverage report >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
echo >> $GITHUB_STEP_SUMMARY echo >> $GITHUB_STEP_SUMMARY
echo '## AWX Collection Integration Coverage HTML' >> $GITHUB_STEP_SUMMARY echo '## AWX Collection Integration Coverage HTML' >> $GITHUB_STEP_SUMMARY

View File

@@ -10,13 +10,8 @@ on:
- devel - devel
- release_* - release_*
- feature_* - feature_*
- stable-*
jobs: jobs:
push-development-images: 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 runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
permissions: permissions:
@@ -34,6 +29,12 @@ jobs:
make-target: awx-kube-buildx make-target: awx-kube-buildx
steps: 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 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false

View File

@@ -20,4 +20,4 @@ jobs:
run: | run: |
ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}" ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}"
ansible localhost -c local -m aws_s3 \ ansible localhost -c local -m aws_s3 \
-a "bucket=awx-public-ci-files object=${{ github.event.repository.name }}/${GITHUB_REF##*/}/schema.json mode=delobj permission=public-read" -a "bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=delobj permission=public-read"

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
permissions: permissions:
packages: read packages: write
contents: read contents: read
steps: steps:
- name: Check for each of the lines - name: Check for each of the lines

View File

@@ -1,248 +0,0 @@
# 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 '<!-- PR' "$first_coverage" | awk '{print $3}' || echo "")
else
PR_NUMBER=""
fi
echo "🔍 SonarCloud Analysis Decision Summary"
echo "========================================"
echo "├── CI Event: ✅ Pull Request"
echo "├── PR Number from coverage.xml: #${PR_NUMBER:-<not found>}"
if [ -z "$PR_NUMBER" ]; then
echo "##[error]❌ FATAL: PR number not found in coverage.xml"
echo "##[error]This job requires a PR number to run PR analysis."
echo "##[error]The ci workflow should have injected the PR number into coverage.xml."
exit 1
fi
# Get PR metadata from GitHub API
PR_DATA=$(gh api "repos/$REPO_NAME/pulls/$PR_NUMBER")
PR_BASE=$(echo "$PR_DATA" | jq -r '.base.ref')
PR_HEAD=$(echo "$PR_DATA" | jq -r '.head.ref')
# Print summary
echo "🔍 SonarCloud Analysis Decision Summary"
echo "========================================"
echo "├── CI Event: ✅ Pull Request"
echo "├── PR Number: #$PR_NUMBER"
echo "├── Base Branch: $PR_BASE"
echo "├── Head Branch: $PR_HEAD"
echo "├── Repo: $REPO_NAME"
# Export to GitHub env for later steps
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR_BASE=$PR_BASE" >> $GITHUB_ENV
echo "PR_HEAD=$PR_HEAD" >> $GITHUB_ENV
echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV
echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV
# Get all changed files from PR (with error handling)
files=""
if [ -n "$PR_NUMBER" ]; then
if gh api repos/$REPO_NAME/pulls/$PR_NUMBER/files --jq '.[].filename' > /tmp/pr_files.txt 2>/tmp/pr_error.txt; then
files=$(cat /tmp/pr_files.txt)
else
echo "├── Changed Files: ⚠️ Could not fetch (likely test repo or PR not found)"
if [ -f coverage.xml ] && [ -s coverage.xml ]; then
echo "├── Coverage Data: ✅ Available"
else
echo "├── Coverage Data: ⚠️ Not available"
fi
echo "└── Result: ✅ Running SonarCloud analysis (full scan)"
# No files = no inclusions filter = full scan
exit 0
fi
else
echo "├── PR Number: ⚠️ Not available"
if [ -f coverage.xml ] && [ -s coverage.xml ]; then
echo "├── Coverage Data: ✅ Available"
else
echo "├── Coverage Data: ⚠️ Not available"
fi
echo "└── Result: ✅ Running SonarCloud analysis (full scan)"
exit 0
fi
# Get file extensions and count for summary
extensions=$(echo "$files" | sed 's/.*\.//' | sort | uniq | tr '\n' ',' | sed 's/,$//')
file_count=$(echo "$files" | wc -l)
echo "├── Changed Files: $file_count file(s) (.${extensions})"
# Check if coverage.xml exists and has content
if [ -f coverage.xml ] && [ -s coverage.xml ]; then
echo "├── Coverage Data: ✅ Available"
else
echo "├── Coverage Data: ⚠️ Not available (analysis will proceed without coverage)"
fi
# Prepare file list for Sonar
echo "All changed files in PR:"
echo "$files"
# Filter out files that are excluded by .coveragerc to avoid coverage conflicts
# This prevents SonarCloud from analyzing files that have no coverage data
if [ -n "$files" ]; then
# Filter out files matching .coveragerc omit patterns
filtered_files=$(echo "$files" | grep -v "settings/.*_defaults\.py$" | grep -v "settings/defaults\.py$" | grep -v "main/migrations/")
# Show which files were filtered out for transparency
excluded_files=$(echo "$files" | grep -E "(settings/.*_defaults\.py$|settings/defaults\.py$|main/migrations/)" || true)
if [ -n "$excluded_files" ]; then
echo "├── Filtered out (coverage-excluded): $(echo "$excluded_files" | wc -l) file(s)"
echo "$excluded_files" | sed 's/^/│ - /'
fi
if [ -n "$filtered_files" ]; then
inclusions=$(echo "$filtered_files" | tr '\n' ',' | sed 's/,$//')
echo "SONAR_INCLUSIONS=$inclusions" >> $GITHUB_ENV
echo "└── Result: ✅ Will scan these files (excluding coverage-omitted files): $inclusions"
else
echo "└── Result: ✅ All changed files are excluded by coverage config, running full SonarCloud analysis"
# Don't set SONAR_INCLUSIONS, let it scan everything per sonar-project.properties
fi
else
echo "└── Result: ✅ Running SonarCloud analysis"
fi
- name: Add base branch
if: env.PR_NUMBER != ''
run: |
gh pr checkout ${{ env.PR_NUMBER }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.CICD_ORG_SONAR_TOKEN_CICD_BOT }}
with:
args: >
-Dsonar.scm.revision=${{ env.COMMIT_SHA }}
-Dsonar.pullrequest.key=${{ env.PR_NUMBER }}
-Dsonar.pullrequest.branch=${{ env.PR_HEAD }}
-Dsonar.pullrequest.base=${{ env.PR_BASE }}
-Dsonar.python.coverage.reportPaths=${{ env.COVERAGE_PATHS }}
${{ env.SONAR_INCLUSIONS && format('-Dsonar.inclusions={0}', env.SONAR_INCLUSIONS) || '' }}
sonar-branch-analysis:
name: SonarCloud Branch Analysis
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.repository == 'ansible/awx'
steps:
- uses: actions/checkout@v4
# Download all individual coverage artifacts from CI workflow (optional for branch pushes)
- name: Download coverage artifacts
continue-on-error: true
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
- name: Print SonarCloud Analysis Summary
env:
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
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
echo "🔍 SonarCloud Analysis Summary"
echo "=============================="
echo "├── CI Event: ✅ Push (via workflow_run)"
echo "├── Branch: $BRANCH_NAME"
echo "├── Coverage Files: ${coverage_files:-none}"
echo "├── Python Changes: N/A (Full codebase scan)"
echo "└── Result: ✅ Proceed - \"Running SonarCloud analysis\""
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.CICD_ORG_SONAR_TOKEN_CICD_BOT }}
with:
args: >
-Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }}
-Dsonar.branch.name=${{ github.event.workflow_run.head_branch }}
${{ env.COVERAGE_PATHS && format('-Dsonar.python.coverage.reportPaths={0}', env.COVERAGE_PATHS) || '' }}

View File

@@ -1,211 +0,0 @@
# Sync OpenAPI Spec on Merge
#
# This workflow runs when code is merged to the devel branch.
# It runs the dev environment to generate the OpenAPI spec, then syncs it to
# the central spec repository.
#
# FLOW: PR merged → push to branch → dev environment runs → spec synced to central repo
#
# NOTE: This is an inlined version for testing with private forks.
# Production version will use a reusable workflow from the org repos.
name: Sync OpenAPI Spec on Merge
env:
LC_ALL: "C.UTF-8"
DEV_DOCKER_OWNER: ${{ github.repository_owner }}
on:
push:
branches:
- devel
- 'stable-2.[6-9]'
- 'stable-2.[1-9][0-9]'
workflow_dispatch: # Allow manual triggering for testing
jobs:
sync-openapi-spec:
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_')))
name: Sync OpenAPI spec to central repo
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout Controller repository
uses: actions/checkout@v4
with:
show-progress: false
- name: Build awx_devel image to use for schema gen
uses: ./.github/actions/awx_devel_image
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
private-github-key: ${{ secrets.PRIVATE_GITHUB_KEY }}
- name: Generate API Schema
env:
REF_NAME: ${{ github.ref_name }}
BASE_REF: ${{ github.base_ref }}
run: |
DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER_LC} \
COMPOSE_TAG=${BASE_REF:-${REF_NAME}} \
docker run -u $(id -u) --rm -v ${{ github.workspace }}:/awx_devel/:Z \
--workdir=/awx_devel `make print-DEVEL_IMAGE_NAME` /start_tests.sh genschema
- name: Verify spec file exists
run: |
SPEC_FILE="./schema.json"
if [ ! -f "$SPEC_FILE" ]; then
echo "❌ Spec file not found at $SPEC_FILE"
echo "Contents of workspace:"
ls -la .
exit 1
fi
echo "✅ Found spec file at $SPEC_FILE"
- name: Checkout spec repo
id: checkout_spec_repo
continue-on-error: true
uses: actions/checkout@v4
with:
repository: ansible-automation-platform/aap-openapi-specs
ref: ${{ github.ref_name }}
path: spec-repo
token: ${{ secrets.OPENAPI_SPEC_SYNC_TOKEN }}
- name: Fail if branch doesn't exist
if: steps.checkout_spec_repo.outcome == 'failure'
env:
REF_NAME: ${{ github.ref_name }}
run: |
echo "##[error]❌ Branch '${REF_NAME}' does not exist in the central spec repository."
echo "##[error]Expected branch: ${REF_NAME}"
echo "##[error]This branch must be created in the spec repo before specs can be synced."
exit 1
- name: Compare specs
id: compare
run: |
COMPONENT_SPEC="./schema.json"
SPEC_REPO_FILE="spec-repo/controller.json"
# Check if spec file exists in spec repo
if [ ! -f "$SPEC_REPO_FILE" ]; then
echo "Spec file doesn't exist in spec repo - will create new file"
echo "has_diff=true" >> $GITHUB_OUTPUT
echo "is_new_file=true" >> $GITHUB_OUTPUT
else
# Compare files
if diff -q "$COMPONENT_SPEC" "$SPEC_REPO_FILE" > /dev/null; then
echo "✅ No differences found - specs are identical"
echo "has_diff=false" >> $GITHUB_OUTPUT
else
echo "📝 Differences found - spec has changed"
echo "has_diff=true" >> $GITHUB_OUTPUT
echo "is_new_file=false" >> $GITHUB_OUTPUT
fi
fi
- name: Update spec file
if: steps.compare.outputs.has_diff == 'true'
run: |
cp "./schema.json" "spec-repo/controller.json"
echo "✅ Updated spec-repo/controller.json"
- name: Create PR in spec repo
if: steps.compare.outputs.has_diff == 'true'
working-directory: spec-repo
env:
GH_TOKEN: ${{ secrets.OPENAPI_SPEC_SYNC_TOKEN }}
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
SPEC_REPO: ansible-automation-platform/aap-openapi-specs
REF_NAME: ${{ github.ref_name }}
GITHUB_SHA_FULL: ${{ github.sha }}
GITHUB_REPO: ${{ github.repository }}
IS_NEW_FILE: ${{ steps.compare.outputs.is_new_file }}
run: |
SHORT_SHA="${GITHUB_SHA_FULL:0:7}"
BRANCH_NAME="update-Controller-${REF_NAME}-${SHORT_SHA}"
if [ "${IS_NEW_FILE}" == "true" ]; then
COMMIT_MSG="Add Controller OpenAPI spec for ${REF_NAME}"
else
COMMIT_MSG="Update Controller OpenAPI spec for ${REF_NAME}"
fi
COMMIT_MSG="${COMMIT_MSG}
Synced from ${GITHUB_REPO}@${GITHUB_SHA_FULL}
Source branch: ${REF_NAME}"
# Create branch via API
BASE_SHA=$(gh api "repos/${SPEC_REPO}/git/ref/heads/${REF_NAME}" --jq '.object.sha')
gh api "repos/${SPEC_REPO}/git/refs" \
-f "ref=refs/heads/${BRANCH_NAME}" \
-f "sha=${BASE_SHA}"
# Get the tree SHA from the base commit (base_tree requires a tree SHA, not a commit SHA)
BASE_TREE_SHA=$(gh api "repos/${SPEC_REPO}/git/commits/${BASE_SHA}" --jq '.tree.sha')
# Create blob and commit via API (commits created through the API are automatically signed by GitHub)
BLOB_SHA=$(gh api "repos/${SPEC_REPO}/git/blobs" \
-f "content=$(base64 -w 0 controller.json)" \
-f "encoding=base64" \
--jq '.sha')
TREE_SHA=$(gh api "repos/${SPEC_REPO}/git/trees" \
-f "base_tree=${BASE_TREE_SHA}" \
--input <(jq -n --arg blob "$BLOB_SHA" '{tree: [{path: "controller.json", mode: "100644", type: "blob", sha: $blob}]}') \
--jq '.sha')
NEW_COMMIT_SHA=$(gh api "repos/${SPEC_REPO}/git/commits" \
-f "message=${COMMIT_MSG}" \
-f "tree=${TREE_SHA}" \
-f "parents[]=${BASE_SHA}" \
--jq '.sha')
# Update branch ref to point to the new signed commit
gh api "repos/${SPEC_REPO}/git/refs/heads/${BRANCH_NAME}" \
-X PATCH \
-f "sha=${NEW_COMMIT_SHA}"
# Create PR
PR_TITLE="[${REF_NAME}] Update Controller spec from merged commit"
PR_BODY="## Summary
Automated OpenAPI spec sync from component repository merge.
**Source:** ${GITHUB_REPO}@${GITHUB_SHA_FULL}
**Branch:** \`${REF_NAME}\`
**Component:** \`Controller\`
**Spec File:** \`controller.json\`
## Changes
$(if [ "${IS_NEW_FILE}" == "true" ]; then echo "- 🆕 New spec file created"; else echo "- 📝 Spec file updated with latest changes"; fi)
## Source Commit
\`\`\`
${COMMIT_MESSAGE}
\`\`\`
---
🤖 This PR was automatically generated by the OpenAPI spec sync workflow."
gh pr create \
--repo "${SPEC_REPO}" \
--title "$PR_TITLE" \
--body "$PR_BODY" \
--base "${REF_NAME}" \
--head "$BRANCH_NAME"
echo "✅ Created PR in spec repo"
- name: Report results
if: always()
env:
HAS_DIFF: ${{ steps.compare.outputs.has_diff }}
run: |
if [ "${HAS_DIFF}" == "true" ]; then
echo "📝 Spec sync completed - PR created in spec repo"
else
echo "✅ Spec sync completed - no changes needed"
fi

View File

@@ -85,11 +85,9 @@ jobs:
cp ../awx-logos/awx/ui/client/assets/* awx/ui/public/static/media/ cp ../awx-logos/awx/ui/client/assets/* awx/ui/public/static/media/
- name: Setup node and npm for new UI build - name: Setup node and npm for new UI build
uses: actions/setup-node@v4 uses: actions/setup-node@v2
with: with:
node-version: '18' node-version: '18'
cache: 'npm'
cache-dependency-path: awx/awx/ui/**/package-lock.json
- name: Prebuild new UI for awx image (to speed up build process) - name: Prebuild new UI for awx image (to speed up build process)
working-directory: awx working-directory: awx

View File

@@ -11,13 +11,8 @@ on:
- devel - devel
- release_** - release_**
- feature_** - feature_**
- stable-**
jobs: jobs:
push: push:
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 runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
permissions: permissions:
@@ -28,26 +23,35 @@ jobs:
with: with:
show-progress: false show-progress: false
- name: Build awx_devel image to use for schema gen - uses: ./.github/actions/setup-python
uses: ./.github/actions/awx_devel_image
- name: Log in to registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- uses: ./.github/actions/setup-ssh-agent
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} ssh-private-key: ${{ secrets.PRIVATE_GITHUB_KEY }}
private-github-key: ${{ secrets.PRIVATE_GITHUB_KEY }}
- name: Pre-pull image to warm build cache
run: |
docker pull -q ghcr.io/${{ github.repository_owner }}/awx_devel:${GITHUB_REF##*/} || :
- name: Build image
run: |
DEV_DOCKER_TAG_BASE=ghcr.io/${{ github.repository_owner }} COMPOSE_TAG=${GITHUB_REF##*/} make docker-compose-build
- name: Generate API Schema - name: Generate API Schema
run: | run: |
DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER_LC} \
COMPOSE_TAG=${{ github.base_ref || github.ref_name }} \
docker run -u $(id -u) --rm -v ${{ github.workspace }}:/awx_devel/:Z \ docker run -u $(id -u) --rm -v ${{ github.workspace }}:/awx_devel/:Z \
--workdir=/awx_devel `make print-DEVEL_IMAGE_NAME` /start_tests.sh genschema --workdir=/awx_devel ghcr.io/${{ github.repository_owner }}/awx_devel:${GITHUB_REF##*/} /start_tests.sh genschema
- name: Upload API Schema - name: Upload API Schema
uses: keithweaver/aws-s3-github-action@4dd5a7b81d54abaa23bbac92b27e85d7f405ae53 env:
with: AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
command: cp AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }}
source: ${{ github.workspace }}/schema.json AWS_REGION: 'us-east-1'
destination: s3://awx-public-ci-files/${{ github.event.repository.name }}/${{ github.ref_name }}/schema.json run: |
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }} ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}"
aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }} ansible localhost -c local -m aws_s3 \
aws_region: us-east-1 -a "src=${{ github.workspace }}/schema.json bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=put permission=public-read"
flags: --acl public-read --only-show-errors

1
.gitignore vendored
View File

@@ -1,7 +1,6 @@
# Ignore generated schema # Ignore generated schema
swagger.json swagger.json
schema.json schema.json
schema.yaml
reference-schema.json reference-schema.json
# Tags # Tags

View File

@@ -7,7 +7,7 @@ build:
os: ubuntu-22.04 os: ubuntu-22.04
tools: tools:
python: >- python: >-
3.12 3.11
commands: commands:
- pip install --user tox - pip install --user tox
- python3 -m tox -e docs --notest -v - python3 -m tox -e docs --notest -v

View File

@@ -1,65 +0,0 @@
---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
name: awx-atf-tests-pull-request
annotations:
build.appstudio.openshift.io/repo: https://github.com/{{repo_owner}}/{{repo_name}}?rev={{revision}}
build.appstudio.redhat.com/commit_sha: '{{revision}}'
build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}'
build.appstudio.redhat.com/target_branch: '{{target_branch}}'
pipelinesascode.tekton.dev/cancel-in-progress: 'true'
pipelinesascode.tekton.dev/max-keep-runs: "3"
pipelinesascode.tekton.dev/on-comment: "^/run-atf-tests$"
pipelinesascode.tekton.dev/target-namespace: ansible-ci-tenant
labels:
appstudio.openshift.io/application: '{{repo_owner}}'
appstudio.openshift.io/component: '{{repo_owner}}-{{repo_name}}'
pipelines.appstudio.openshift.io/type: build
spec:
timeouts:
pipeline: "8h"
tasks: "7h"
finally: "1h"
pipelineRef:
resolver: bundles
params:
- name: name
value: aap-api-tests
- name: bundle
value: quay.io/aap-ci/tekton-catalog/pipeline/test/aap-api-tests:0.1@sha256:50aadd6725a239ab53247deb7cf601d1163ceb1792792fd239a3f37d21a490d7
- name: kind
value: pipeline
- name: secret
value: quay-aap-ci-viewer
taskRunTemplate:
serviceAccountName: konflux-integration-runner
params:
- name: git-url
value: "{{source_url}}"
- name: pipeline-github-org
value: "{{repo_owner}}"
- name: pipeline-github-repo
value: "{{repo_name}}"
- name: pipeline-github-target-branch
value: '{{target_branch}}'
- name: pipeline-github-pr-revision
value: "{{revision}}"
- name: pipeline-github-pr-number
value: "{{pull_request_number}}"
- name: aap-dev-component-source-name
value: "controller"
- name: pytest-number-of-parallel-processes
value: "6"
workspaces:
- name: workspace
volumeClaimTemplate:
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

View File

@@ -31,7 +31,7 @@ Have questions about this document or anything not covered here? Create a topic
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason. - Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason.
- If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see [git push docs](https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt). - If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see [git push docs](https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt).
- If submitting a large code change, it's a good idea to create a [forum topic tagged with 'awx'](https://forum.ansible.com/tag/awx), and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed. - If submitting a large code change, it's a good idea to create a [forum topic tagged with 'awx'](https://forum.ansible.com/tag/awx), and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed.
- We ask all of our community members and contributors to adhere to the [Ansible code of conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com) - We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
## Setting up your development environment ## Setting up your development environment
@@ -103,12 +103,6 @@ When necessary, remove any AWX containers and images by running the following:
### Pre commit hooks ### Pre commit hooks
Install the pre-commit hook before contributing:
```
make pre-commit
```
When you attempt to perform a `git commit` there will be a pre-commit hook that gets run before the commit is allowed to your local repository. For example, python's [black](https://pypi.org/project/black/) will be run to test the formatting of any python files. When you attempt to perform a `git commit` there will be a pre-commit hook that gets run before the commit is allowed to your local repository. For example, python's [black](https://pypi.org/project/black/) will be run to test the formatting of any python files.
While you can use environment variables to skip the pre-commit hooks GitHub will run similar tests and prevent merging of PRs if the tests do not pass. While you can use environment variables to skip the pre-commit hooks GitHub will run similar tests and prevent merging of PRs if the tests do not pass.

122
Makefile
View File

@@ -1,6 +1,6 @@
-include awx/ui/Makefile -include awx/ui/Makefile
PYTHON := $(notdir $(shell for i in python3.12 python3.11 python3; do command -v $$i; done|sed 1q)) PYTHON := $(notdir $(shell for i in python3.11 python3; do command -v $$i; done|sed 1q))
SHELL := bash SHELL := bash
DOCKER_COMPOSE ?= docker compose DOCKER_COMPOSE ?= docker compose
OFFICIAL ?= no OFFICIAL ?= no
@@ -10,7 +10,6 @@ KIND_BIN ?= $(shell which kind)
CHROMIUM_BIN=/tmp/chrome-linux/chrome CHROMIUM_BIN=/tmp/chrome-linux/chrome
GIT_REPO_NAME ?= $(shell basename `git rev-parse --show-toplevel`) GIT_REPO_NAME ?= $(shell basename `git rev-parse --show-toplevel`)
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
GIT_IS_WORKTREE := $(shell test -f .git && echo yes)
MANAGEMENT_COMMAND ?= awx-manage MANAGEMENT_COMMAND ?= awx-manage
VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py 2> /dev/null) VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py 2> /dev/null)
@@ -28,8 +27,6 @@ TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests
PARALLEL_TESTS ?= -n auto PARALLEL_TESTS ?= -n auto
# collection integration test directories (defaults to all) # collection integration test directories (defaults to all)
COLLECTION_TEST_TARGET ?= COLLECTION_TEST_TARGET ?=
# Python version for ansible-test (must be 3.11, 3.12, or 3.13)
ANSIBLE_TEST_PYTHON_VERSION ?= 3.13
# args for collection install # args for collection install
COLLECTION_PACKAGE ?= awx COLLECTION_PACKAGE ?= awx
COLLECTION_NAMESPACE ?= awx COLLECTION_NAMESPACE ?= awx
@@ -80,7 +77,7 @@ RECEPTOR_IMAGE ?= quay.io/ansible/receptor:devel
SRC_ONLY_PKGS ?= cffi,pycparser,psycopg,twilio SRC_ONLY_PKGS ?= cffi,pycparser,psycopg,twilio
# These should be upgraded in the AWX and Ansible venv before attempting # These should be upgraded in the AWX and Ansible venv before attempting
# to install the actual requirements # to install the actual requirements
VENV_BOOTSTRAP ?= pip==25.3 setuptools==80.9.0 setuptools_scm[toml]==9.2.2 wheel==0.46.3 cython==3.1.3 VENV_BOOTSTRAP ?= pip==21.2.4 setuptools==70.3.0 setuptools_scm[toml]==8.1.0 wheel==0.45.1 cython==3.0.11
NAME ?= awx NAME ?= awx
@@ -107,23 +104,12 @@ else
DOCKER_KUBE_CACHE_FLAG=$(DOCKER_CACHE) DOCKER_KUBE_CACHE_FLAG=$(DOCKER_CACHE)
endif endif
# AWX TUI variables
AWX_HOST ?= https://localhost:8043
AWX_USER ?= admin
AWX_PASSWORD ?= $$(awk -F"'" '/^admin_password:/{print $$2}' tools/docker-compose/_sources/secrets/admin_password.yml 2>/dev/null || echo "admin")
AWX_VERIFY_SSL ?= false
# For git worktree to find the referenced git dir
GIT_COMMON_DIR := $(shell git rev-parse --git-common-dir 2>/dev/null || echo .git)
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \ .PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
update_requirements upgrade_requirements update_requirements_dev \
docker_update_requirements docker_upgrade_requirements docker_update_requirements_dev \
develop refresh adduser migrate dbchange \ develop refresh adduser migrate dbchange \
receiver test test_unit test_coverage coverage_html \ receiver test test_unit test_coverage coverage_html \
sdist \ sdist \
VERSION PYTHON_VERSION docker-compose-sources \ VERSION PYTHON_VERSION docker-compose-sources \
pre-commit .git/hooks/pre-commit
clean-tmp: clean-tmp:
rm -rf tmp/ rm -rf tmp/
@@ -158,7 +144,7 @@ clean-api:
rm -rf build $(NAME)-$(VERSION) *.egg-info rm -rf build $(NAME)-$(VERSION) *.egg-info
rm -rf .tox rm -rf .tox
find . -type f -regex ".*\.py[co]$$" -delete find . -type f -regex ".*\.py[co]$$" -delete
find . -type d -name "__pycache__" -exec rm -rf {} + find . -type d -name "__pycache__" -delete
rm -f awx/awx_test.sqlite3* rm -f awx/awx_test.sqlite3*
rm -rf requirements/vendor rm -rf requirements/vendor
rm -rf awx/projects rm -rf awx/projects
@@ -208,36 +194,6 @@ requirements_dev: requirements_awx requirements_awx_dev
requirements_test: requirements requirements_test: requirements
## Update requirements files using pip-compile (run inside container)
update_requirements:
cd requirements && ./updater.sh run
## Upgrade all requirements to latest versions (run inside container)
upgrade_requirements:
cd requirements && ./updater.sh upgrade
## Update development requirements (run inside container)
update_requirements_dev:
cd requirements && ./updater.sh dev
## Update requirements using docker-runner
docker_update_requirements:
@echo "Running requirements updater..."
AWX_DOCKER_CMD='make update_requirements' $(MAKE) docker-runner
@echo "Requirements update complete!"
## Upgrade requirements using docker-runner
docker_upgrade_requirements:
@echo "Running requirements upgrader..."
AWX_DOCKER_CMD='make upgrade_requirements' $(MAKE) docker-runner
@echo "Requirements upgrade complete!"
## Update dev requirements using docker-runner
docker_update_requirements_dev:
@echo "Running dev requirements updater..."
AWX_DOCKER_CMD='make update_requirements_dev' $(MAKE) docker-runner
@echo "Dev requirements update complete!"
## "Install" awx package in development mode. ## "Install" awx package in development mode.
develop: develop:
@if [ "$(VIRTUAL_ENV)" ]; then \ @if [ "$(VIRTUAL_ENV)" ]; then \
@@ -299,7 +255,7 @@ dispatcher:
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \ . $(VENV_BASE)/awx/bin/activate; \
fi; \ fi; \
$(PYTHON) manage.py dispatcherd $(PYTHON) manage.py run_dispatcher
## Run to start the zeromq callback receiver ## Run to start the zeromq callback receiver
receiver: receiver:
@@ -352,22 +308,26 @@ black: reports
@command -v black >/dev/null 2>&1 || { echo "could not find black on your PATH, you may need to \`pip install black\`, or set AWX_IGNORE_BLACK=1" && exit 1; } @command -v black >/dev/null 2>&1 || { echo "could not find black on your PATH, you may need to \`pip install black\`, or set AWX_IGNORE_BLACK=1" && exit 1; }
@(set -o pipefail && $@ $(BLACK_ARGS) awx awxkit awx_collection | tee reports/$@.report) @(set -o pipefail && $@ $(BLACK_ARGS) awx awxkit awx_collection | tee reports/$@.report)
$(GIT_COMMON_DIR)/hooks/pre-commit: .git/hooks/pre-commit:
ln -sf ../../pre-commit.sh $(GIT_COMMON_DIR)/hooks/pre-commit @echo "if [ -x pre-commit.sh ]; then" > .git/hooks/pre-commit
@echo " ./pre-commit.sh;" >> .git/hooks/pre-commit
@echo "fi" >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
pre-commit: $(GIT_COMMON_DIR)/hooks/pre-commit genschema: reports
$(MAKE) swagger PYTEST_ADDOPTS="--genschema --create-db "
mv swagger.json schema.json
genschema: awx-link reports swagger: reports
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \ . $(VENV_BASE)/awx/bin/activate; \
fi; \ fi; \
$(MANAGEMENT_COMMAND) spectacular --format openapi-json --file schema.json (set -o pipefail && py.test $(COVERAGE_ARGS) $(PARALLEL_TESTS) awx/conf/tests/functional awx/main/tests/functional/api awx/main/tests/docs | tee reports/$@.report)
@if [ "${GITHUB_ACTIONS}" = "true" ]; \
genschema-yaml: awx-link reports then \
@if [ "$(VENV_BASE)" ]; then \ echo 'cov-report-files=reports/coverage.xml' >> "${GITHUB_OUTPUT}"; \
. $(VENV_BASE)/awx/bin/activate; \ echo 'test-result-files=reports/junit.xml' >> "${GITHUB_OUTPUT}"; \
fi; \ fi
$(MANAGEMENT_COMMAND) spectacular --format openapi --file schema.yaml
check: black check: black
@@ -471,8 +431,8 @@ test_collection_sanity:
test_collection_integration: install_collection test_collection_integration: install_collection
cd $(COLLECTION_INSTALL) && \ cd $(COLLECTION_INSTALL) && \
PATH="$$($(PYTHON) -c 'import sys; import os; print(os.path.dirname(sys.executable))'):$$PATH" ansible-test integration --python $(ANSIBLE_TEST_PYTHON_VERSION) --coverage -vvv $(COLLECTION_TEST_TARGET) && \ ansible-test integration --coverage -vvv $(COLLECTION_TEST_TARGET) && \
PATH="$$($(PYTHON) -c 'import sys; import os; print(os.path.dirname(sys.executable))'):$$PATH" ansible-test coverage xml --requirements --group-by command --group-by version ansible-test coverage xml --requirements --group-by command --group-by version
@if [ "${GITHUB_ACTIONS}" = "true" ]; \ @if [ "${GITHUB_ACTIONS}" = "true" ]; \
then \ then \
echo cov-report-files="$$(find "$(COLLECTION_INSTALL)/tests/output/reports/" -type f -name 'coverage=integration*.xml' -print0 | tr '\0' ',' | sed 's#,$$##')" >> "${GITHUB_OUTPUT}"; \ echo cov-report-files="$$(find "$(COLLECTION_INSTALL)/tests/output/reports/" -type f -name 'coverage=integration*.xml' -print0 | tr '\0' ',' | sed 's#,$$##')" >> "${GITHUB_OUTPUT}"; \
@@ -530,7 +490,7 @@ ifneq ($(ADMIN_PASSWORD),)
EXTRA_SOURCES_ANSIBLE_OPTS := -e admin_password=$(ADMIN_PASSWORD) $(EXTRA_SOURCES_ANSIBLE_OPTS) EXTRA_SOURCES_ANSIBLE_OPTS := -e admin_password=$(ADMIN_PASSWORD) $(EXTRA_SOURCES_ANSIBLE_OPTS)
endif endif
docker-compose-sources: docker-compose-sources: .git/hooks/pre-commit
@if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\ @if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\
$(ANSIBLE_PLAYBOOK) -i tools/docker-compose/inventory -e minikube_setup=$(MINIKUBE_SETUP) tools/docker-compose-minikube/deploy.yml; \ $(ANSIBLE_PLAYBOOK) -i tools/docker-compose/inventory -e minikube_setup=$(MINIKUBE_SETUP) tools/docker-compose-minikube/deploy.yml; \
fi; fi;
@@ -562,7 +522,7 @@ docker-compose: awx/projects docker-compose-sources
$(MAKE) docker-compose-up $(MAKE) docker-compose-up
docker-compose-up: docker-compose-up:
$(if $(GIT_IS_WORKTREE),SETUPTOOLS_SCM_PRETEND_VERSION="$(VERSION)") $(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) up $(COMPOSE_UP_OPTS) --remove-orphans $(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) up $(COMPOSE_UP_OPTS) --remove-orphans
docker-compose-down: docker-compose-down:
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) down --remove-orphans $(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) down --remove-orphans
@@ -577,34 +537,14 @@ docker-compose-test: awx/projects docker-compose-sources
docker-compose-runtest: awx/projects docker-compose-sources docker-compose-runtest: awx/projects docker-compose-sources
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports awx_1 /start_tests.sh $(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports awx_1 /start_tests.sh
docker-compose-build-schema: awx/projects docker-compose-sources docker-compose-build-swagger: awx/projects docker-compose-sources
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports --no-deps awx_1 make genschema $(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports --no-deps awx_1 /start_tests.sh swagger
awx-tui:
@if ! command -v awx-tui > /dev/null 2>&1; then \
$(PYTHON) -m pip install awx-tui; \
fi
@if [ -f "$(HOME)/.config/awx-tui/config.yaml" ]; then \
$(PYTHON) -m awx_tui.main; \
else \
AWX_HOST=$(AWX_HOST) \
AWX_USER=$(AWX_USER) \
AWX_PASSWORD=$(AWX_PASSWORD) \
AWX_VERIFY_SSL=$(AWX_VERIFY_SSL) \
$(PYTHON) -m awx_tui.main --host $(AWX_HOST); \
fi
SCHEMA_DIFF_BASE_FOLDER ?= awx
SCHEMA_DIFF_BASE_BRANCH ?= devel SCHEMA_DIFF_BASE_BRANCH ?= devel
detect-schema-change: genschema detect-schema-change: genschema
curl https://s3.amazonaws.com/awx-public-ci-files/$(SCHEMA_DIFF_BASE_FOLDER)/$(SCHEMA_DIFF_BASE_BRANCH)/schema.json -o reference-schema.json curl https://s3.amazonaws.com/awx-public-ci-files/$(SCHEMA_DIFF_BASE_BRANCH)/schema.json -o reference-schema.json
# Ignore differences in whitespace with -b # Ignore differences in whitespace with -b
# diff exits with 1 when files differ - capture but don't fail diff -u -b reference-schema.json schema.json
-diff -u -b reference-schema.json schema.json
validate-openapi-schema: genschema
@echo "Validating OpenAPI schema from schema.json..."
@python3 -c "from openapi_spec_validator import validate; import json; spec = json.load(open('schema.json')); validate(spec); print('✓ Schema is valid')"
docker-compose-clean: awx/projects docker-compose-clean: awx/projects
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml rm -sf $(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml rm -sf
@@ -637,7 +577,7 @@ docker-compose-build: Dockerfile.dev
docker-compose-buildx: Dockerfile.dev docker-compose-buildx: Dockerfile.dev
- docker buildx create --name docker-compose-buildx - docker buildx create --name docker-compose-buildx
docker buildx use docker-compose-buildx docker buildx use docker-compose-buildx
docker buildx build \ - docker buildx build \
--ssh default=$(SSH_AUTH_SOCK) \ --ssh default=$(SSH_AUTH_SOCK) \
--push \ --push \
--build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg BUILDKIT_INLINE_CACHE=1 \
@@ -697,7 +637,7 @@ awx-kube-build: Dockerfile
awx-kube-buildx: Dockerfile awx-kube-buildx: Dockerfile
- docker buildx create --name awx-kube-buildx - docker buildx create --name awx-kube-buildx
docker buildx use awx-kube-buildx docker buildx use awx-kube-buildx
docker buildx build \ - docker buildx build \
--ssh default=$(SSH_AUTH_SOCK) \ --ssh default=$(SSH_AUTH_SOCK) \
--push \ --push \
--build-arg VERSION=$(VERSION) \ --build-arg VERSION=$(VERSION) \
@@ -731,7 +671,7 @@ awx-kube-dev-build: Dockerfile.kube-dev
awx-kube-dev-buildx: Dockerfile.kube-dev awx-kube-dev-buildx: Dockerfile.kube-dev
- docker buildx create --name awx-kube-dev-buildx - docker buildx create --name awx-kube-dev-buildx
docker buildx use awx-kube-dev-buildx docker buildx use awx-kube-dev-buildx
docker buildx build \ - docker buildx build \
--ssh default=$(SSH_AUTH_SOCK) \ --ssh default=$(SSH_AUTH_SOCK) \
--push \ --push \
--build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg BUILDKIT_INLINE_CACHE=1 \

View File

@@ -1,4 +1,4 @@
[![CI](https://github.com/ansible/awx/actions/workflows/ci.yml/badge.svg?branch=devel)](https://github.com/ansible/awx/actions/workflows/ci.yml) [![codecov](https://codecov.io/github/ansible/awx/graph/badge.svg?token=4L4GSP9IAR)](https://codecov.io/github/ansible/awx) [![Code of Conduct](https://img.shields.io/badge/code%20of%20conduct-Ansible-yellow.svg)](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html) [![Apache v2 License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/ansible/awx/blob/devel/LICENSE.md) [![AWX on the Ansible Forum](https://img.shields.io/badge/mailing%20list-AWX-orange.svg)](https://forum.ansible.com/tag/awx) [![CI](https://github.com/ansible/awx/actions/workflows/ci.yml/badge.svg?branch=devel)](https://github.com/ansible/awx/actions/workflows/ci.yml) [![codecov](https://codecov.io/github/ansible/awx/graph/badge.svg?token=4L4GSP9IAR)](https://codecov.io/github/ansible/awx) [![Code of Conduct](https://img.shields.io/badge/code%20of%20conduct-Ansible-yellow.svg)](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) [![Apache v2 License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/ansible/awx/blob/devel/LICENSE.md) [![AWX on the Ansible Forum](https://img.shields.io/badge/mailing%20list-AWX-orange.svg)](https://forum.ansible.com/tag/awx)
[![Ansible Matrix](https://img.shields.io/badge/matrix-Ansible%20Community-blueviolet.svg?logo=matrix)](https://chat.ansible.im/#/welcome) [![Ansible Discourse](https://img.shields.io/badge/discourse-Ansible%20Community-yellowgreen.svg?logo=discourse)](https://forum.ansible.com) [![Ansible Matrix](https://img.shields.io/badge/matrix-Ansible%20Community-blueviolet.svg?logo=matrix)](https://chat.ansible.im/#/welcome) [![Ansible Discourse](https://img.shields.io/badge/discourse-Ansible%20Community-yellowgreen.svg?logo=discourse)](https://forum.ansible.com)
<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" /> <img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" />
@@ -18,7 +18,7 @@ AWX provides a web-based user interface, REST API, and task engine built on top
To install AWX, please view the [Install guide](./INSTALL.md). To install AWX, please view the [Install guide](./INSTALL.md).
To learn more about using AWX, view the [AWX docs site](https://docs.ansible.com/projects/awx/en/latest/). To learn more about using AWX, view the [AWX docs site](https://ansible.readthedocs.io/projects/awx/en/latest/).
The AWX Project Frequently Asked Questions can be found [here](https://www.ansible.com/awx-project-faq). The AWX Project Frequently Asked Questions can be found [here](https://www.ansible.com/awx-project-faq).
@@ -41,11 +41,11 @@ If you're experiencing a problem that you feel is a bug in AWX or have ideas for
Code of Conduct Code of Conduct
--------------- ---------------
We require all of our community members and contributors to adhere to the [Ansible code of conduct](https://docs.ansible.com/projects/ansible/latest/community/code_of_conduct.html). If you have questions or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com) We require all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
Get Involved Get Involved
------------ ------------
We welcome your feedback and ideas via the [Ansible Forum](https://forum.ansible.com/tag/awx). We welcome your feedback and ideas via the [Ansible Forum](https://forum.ansible.com/tag/awx).
For a full list of all the ways to talk with the Ansible Community, see the [AWX Communication guide](https://docs.ansible.com/projects/awx/en/latest/contributor/communication.html). For a full list of all the ways to talk with the Ansible Community, see the [AWX Communication guide](https://ansible.readthedocs.io/projects/awx/en/latest/contributor/communication.html).

View File

@@ -52,6 +52,14 @@ except ImportError: # pragma: no cover
MODE = 'production' MODE = 'production'
try:
import django # noqa: F401
except ImportError:
pass
else:
from django.db import connection
def prepare_env(): def prepare_env():
# Update the default settings environment variable based on current mode. # Update the default settings environment variable based on current mode.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings')
@@ -71,6 +79,14 @@ def manage():
from django.conf import settings from django.conf import settings
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
# 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
if not os.getenv('SKIP_PG_VERSION_CHECK', False) and not MODE == 'development':
if (connection.pg_version // 10000) < 12:
sys.stderr.write("At a minimum, postgres version 12 is required\n")
sys.exit(1)
if len(sys.argv) >= 2 and sys.argv[1] in ('version', '--version'): # pragma: no cover if len(sys.argv) >= 2 and sys.argv[1] in ('version', '--version'): # pragma: no cover
sys.stdout.write('%s\n' % __version__) sys.stdout.write('%s\n' % __version__)
# If running as a user without permission to read settings, display an # If running as a user without permission to read settings, display an

View File

@@ -7,6 +7,7 @@ from rest_framework import serializers
# AWX # AWX
from awx.conf import fields, register, register_validate from awx.conf import fields, register, register_validate
register( register(
'SESSION_COOKIE_AGE', 'SESSION_COOKIE_AGE',
field_class=fields.IntegerField, field_class=fields.IntegerField,

View File

@@ -21,7 +21,7 @@ class NullFieldMixin(object):
""" """
def validate_empty_values(self, data): def validate_empty_values(self, data):
is_empty_value, data = super(NullFieldMixin, self).validate_empty_values(data) (is_empty_value, data) = super(NullFieldMixin, self).validate_empty_values(data)
if is_empty_value and data is None: if is_empty_value and data is None:
return (False, data) return (False, data)
return (is_empty_value, data) return (is_empty_value, data)
@@ -89,7 +89,7 @@ class DeprecatedCredentialField(serializers.IntegerField):
def to_internal_value(self, pk): def to_internal_value(self, pk):
try: try:
pk = int(pk) pk = int(pk)
except (ValueError, TypeError): except ValueError:
self.fail('invalid') self.fail('invalid')
try: try:
Credential.objects.get(pk=pk) Credential.objects.get(pk=pk)

View File

@@ -131,14 +131,8 @@ class LoggedLoginView(auth_views.LoginView):
class LoggedLogoutView(auth_views.LogoutView): class LoggedLogoutView(auth_views.LogoutView):
# Override http_method_names to allow GET requests (Django 5.2+ defaults to POST only)
http_method_names = ["get", "post", "options"]
success_url_allowed_hosts = set(settings.LOGOUT_ALLOWED_HOSTS.split(",")) if settings.LOGOUT_ALLOWED_HOSTS else set() success_url_allowed_hosts = set(settings.LOGOUT_ALLOWED_HOSTS.split(",")) if settings.LOGOUT_ALLOWED_HOSTS else set()
def get(self, request, *args, **kwargs):
"""Handle GET requests for logout (for backward compatibility)."""
return self.post(request, *args, **kwargs)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if is_proxied_request(): if is_proxied_request():
# 1) We intentionally don't obey ?next= here, just always redirect to platform login # 1) We intentionally don't obey ?next= here, just always redirect to platform login
@@ -167,14 +161,16 @@ def get_view_description(view, html=False):
def get_default_schema(): def get_default_schema():
# drf-spectacular is configured via REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS'] if settings.DYNACONF.is_development_mode:
# Just use the DRF default, which will pick up our CustomAutoSchema from awx.api.swagger import schema_view
return views.APIView.schema
return schema_view
else:
return views.APIView.schema
class APIView(views.APIView): class APIView(views.APIView):
# Schema is inherited from DRF's APIView, which uses DEFAULT_SCHEMA_CLASS schema = get_default_schema()
# No need to override it here - drf-spectacular will handle it
versioning_class = URLPathVersioning versioning_class = URLPathVersioning
def initialize_request(self, request, *args, **kwargs): def initialize_request(self, request, *args, **kwargs):
@@ -272,10 +268,7 @@ class APIView(views.APIView):
response = self.handle_exception(self.__init_request_error__) response = self.handle_exception(self.__init_request_error__)
if response.status_code == 401: if response.status_code == 401:
if response.data and 'detail' in response.data: if response.data and 'detail' in response.data:
if getattr(settings, 'RESOURCE_SERVER__URL', None): response.data['detail'] += _(' To establish a login session, visit') + ' /api/login/.'
response.data['detail'] += _(' Direct access is not allowed, authenticate via the platform gateway.')
else:
response.data['detail'] += _(' To establish a login session, visit') + ' /api/login/.'
logger.info(status_msg) logger.info(status_msg)
else: else:
logger.warning(status_msg) logger.warning(status_msg)
@@ -773,7 +766,7 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
def unattach(self, request, *args, **kwargs): def unattach(self, request, *args, **kwargs):
sub_id, res = self.unattach_validate(request) (sub_id, res) = self.unattach_validate(request)
if res: if res:
return res return res
return self.unattach_by_id(request, sub_id) return self.unattach_by_id(request, sub_id)
@@ -851,7 +844,7 @@ class ResourceAccessList(ParentMixin, ListAPIView):
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
ancestors = set(RoleEvaluation.objects.filter(content_type_id=content_type.id, object_id=obj.id).values_list('role_id', flat=True)) ancestors = set(RoleEvaluation.objects.filter(content_type_id=content_type.id, object_id=obj.id).values_list('role_id', flat=True))
qs = User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True) qs = User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True)
auditor_role = RoleDefinition.objects.filter(name="Platform Auditor").first() auditor_role = RoleDefinition.objects.filter(name="Controller System Auditor").first()
if auditor_role: if auditor_role:
qs |= User.objects.filter(role_assignments__role_definition=auditor_role) qs |= User.objects.filter(role_assignments__role_definition=auditor_role)
return qs.distinct() return qs.distinct()
@@ -1032,9 +1025,6 @@ class GenericCancelView(RetrieveAPIView):
# In subclass set model, serializer_class # In subclass set model, serializer_class
obj_permission_type = 'cancel' obj_permission_type = 'cancel'
def get(self, request, *args, **kwargs):
return super(GenericCancelView, self).get(request, *args, **kwargs)
@transaction.non_atomic_requests @transaction.non_atomic_requests
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(GenericCancelView, self).dispatch(*args, **kwargs) return super(GenericCancelView, self).dispatch(*args, **kwargs)

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import MetricsView from awx.api.views import MetricsView
urls = [re_path(r'^$', MetricsView.as_view(), name='metrics_view')] urls = [re_path(r'^$', MetricsView.as_view(), name='metrics_view')]
__all__ = ['urls'] __all__ = ['urls']

View File

@@ -1,471 +0,0 @@
{
"activity_stream_retrieve": "Retrieve an audit trail entry for tracking all changes within the system",
"ad_hoc_commands_activity_stream_list": "List activity stream of an ad hoc command",
"ad_hoc_commands_create": "Create an ad hoc command",
"ad_hoc_commands_destroy": "Delete an ad hoc command",
"ad_hoc_commands_events_list": "List events of an ad hoc command",
"ad_hoc_commands_list": "List ad hoc commands",
"ad_hoc_commands_notifications_list": "List notifications of an ad hoc command",
"ad_hoc_commands_retrieve": "Retrieve an ad hoc command",
"ad_hoc_commands_stdout_retrieve": "Retrieve a stdout output of an ad hoc command",
"analytics_adoption_rate_options_retrieve": "Retrieve single analytics adoption rate option",
"analytics_adoption_rate_retrieve": "Retrieve single analytics adoption rate",
"analytics_event_explorer_options_retrieve": "Retrieve single analytics event explorer option",
"analytics_event_explorer_retrieve": "Retrieve single analytics event explorer",
"analytics_host_explorer_options_retrieve": "Retrieve single analytics host explorer option",
"analytics_host_explorer_retrieve": "Retrieve single analytics host explorer",
"analytics_job_explorer_options_retrieve": "Retrieve single analytics job explorer option",
"analytics_job_explorer_retrieve": "Retrieve single analytics job explorer",
"analytics_probe_template_for_hosts_options_retrieve": "Retrieve single analytics probe template for hosts option",
"analytics_probe_template_for_hosts_retrieve": "Retrieve single analytics probe template for host",
"analytics_probe_templates_options_retrieve": "Retrieve single analytics probe templates option",
"analytics_probe_templates_retrieve": "Retrieve single analytics probe template",
"analytics_reports_retrieve": "Retrieve single analytics report",
"analytics_roi_templates_options_retrieve": "Retrieve single analytics roi templates option",
"analytics_roi_templates_retrieve": "Retrieve single analytics roi template",
"constructed_inventories_create": "Create a constructed inventory",
"constructed_inventories_destroy": "Delete a constructed inventory",
"constructed_inventories_partial_update": "Update a constructed inventory",
"constructed_inventories_retrieve": "Retrieve a constructed inventory",
"constructed_inventories_update": "Update a constructed inventory",
"credential_input_sources_create": "Create a credential input source",
"credential_input_sources_destroy": "Delete a credential input source",
"credential_input_sources_list": "List credential input sources",
"credential_input_sources_partial_update": "Update a credential input source",
"credential_input_sources_retrieve": "Retrieve a credential input source",
"credential_input_sources_update": "Update a credential input source",
"credential_types_credentials_create": "Create a credential of a credential type",
"credential_types_credentials_list": "List credentials of a credential type",
"credential_types_retrieve": "Retrieve a credential type",
"credential_types_test_retrieve": "Retrieve single test for a credential_type",
"credentials_destroy": "Delete a credential",
"credentials_input_sources_create": "Create new source for a credential",
"credentials_input_sources_list": "List all sources for a credential",
"credentials_object_roles_list": "List roles of a credential",
"credentials_owner_teams_list": "List all teams for a credential",
"credentials_owner_users_list": "List all users for a credential",
"credentials_partial_update": "Update a credential",
"credentials_retrieve": "Retrieve a credential",
"credentials_test_retrieve": "Retrieve a test external credential",
"credentials_update": "Update a credential",
"execution_environments_activity_stream_list": "List activity stream of an execution environment",
"execution_environments_copy_create": "Create new copy for an execution_environment",
"execution_environments_copy_retrieve": "Retrieve single copy for an execution_environment",
"execution_environments_retrieve": "Retrieve an execution environment",
"execution_environments_unified_job_templates_list": "List unified job templates using this execution environment",
"feature_flags_state_retrieve": "Retrieve single feature flags state",
"feature_flags_states_list": "List all feature flags states",
"feature_flags_states_retrieve": "Retrieve single feature flags state",
"groups_activity_stream_list": "List activity stream for a group",
"groups_ad_hoc_commands_create": "Create an ad hoc command for a group",
"groups_ad_hoc_commands_list": "List ad hoc commands for a group",
"groups_all_hosts_list": "List all hosts for a group",
"groups_children_create": "Create new child for a group",
"groups_children_list": "List all children for a group",
"groups_destroy": "Delete a group",
"groups_hosts_create": "Create a host of a group",
"groups_hosts_list": "List hosts of a group",
"groups_inventory_sources_list": "List inventory sources of a group",
"groups_job_events_list": "List job events for a group",
"groups_job_host_summaries_list": "List job host summaries for a group",
"groups_partial_update": "Update a group",
"groups_potential_children_list": "List all children for a group",
"groups_retrieve": "Retrieve a group",
"groups_update": "Update a group",
"groups_variable_data_partial_update": "Update a variable datum for a group",
"groups_variable_data_retrieve": "Retrieve a variable datum for a group",
"groups_variable_data_update": "Update a variable datum for a group",
"host_metric_summary_monthly_list": "List monthly summaries for host metrics",
"host_metrics_list": "List host metrics",
"host_metrics_retrieve": "Retrieve a host metric",
"hosts_activity_stream_list": "List activity stream for a host",
"hosts_ad_hoc_command_events_list": "List events of ad hoc command of a host",
"hosts_ad_hoc_commands_create": "Create an ad hoc command of a host",
"hosts_ad_hoc_commands_list": "List ad hoc commands of a host",
"hosts_all_groups_list": "List all groups for a host",
"hosts_create": "Create a host",
"hosts_groups_create": "Create the list of groups a host is directly a member of",
"hosts_groups_list": "List the list of groups a host is directly a member of",
"hosts_inventory_sources_list": "List inventory sources of a host",
"hosts_job_events_list": "List job events of a host",
"hosts_job_host_summaries_list": "List job summaries of a host",
"hosts_partial_update": "Update a host",
"hosts_retrieve": "Retrieve a host",
"hosts_smart_inventories_list": "List all inventories for a host",
"hosts_update": "Update a host",
"hosts_variable_data_partial_update": "Update a variable datum for a host",
"hosts_variable_data_update": "Update a variable datum for a host",
"instance_groups_destroy": "Delete an instance group",
"instance_groups_instances_create": "Create an instance of an instance group",
"instance_groups_instances_list": "List instance of an instance group",
"instance_groups_jobs_list": "List jobs of an instance group",
"instance_groups_object_roles_list": "List all roles for an instance_group",
"instance_groups_partial_update": "Update an instance group",
"instance_groups_retrieve": "Retrieve an instance group",
"instance_groups_update": "Update an instance group",
"instances_instance_groups_create": "Create an instance group of an instance",
"instances_instance_groups_list": "List instance groups of an instance",
"instances_jobs_list": "List jobs executed on an instance",
"instances_list": "List instances",
"instances_partial_update": "Update an instance",
"instances_peers_list": "List all peers for an instance",
"instances_retrieve": "Retrieve an instance",
"instances_update": "Update an instance",
"inventories_access_list_list": "List users who can access the inventory",
"inventories_ad_hoc_commands_create": "Create an ad hoc command for an inventory",
"inventories_ad_hoc_commands_list": "List ad hoc command for an inventory",
"inventories_copy_create": "Create a copy of an inventory",
"inventories_copy_retrieve": "Retrieve a copy of an inventory",
"inventories_create": "Create an inventory",
"inventories_destroy": "Delete an inventory",
"inventories_groups_create": "Create a group of an inventory",
"inventories_groups_list": "List groups of an inventory",
"inventories_hosts_create": "Create a host of an inventory",
"inventories_hosts_list": "List hosts of an inventory",
"inventories_instance_groups_create": "Create an instance group of an inventory",
"inventories_instance_groups_list": "List instance groups of an inventory",
"inventories_inventory_sources_create": "Create an inventory source",
"inventories_inventory_sources_list": "List inventory sources",
"inventories_job_templates_list": "List job templates using an inventory",
"inventories_labels_list": "List labels of an inventory",
"inventories_object_roles_list": "List roles of an inventory",
"inventories_partial_update": "Update an inventory",
"inventories_retrieve": "Retrieve an inventory",
"inventories_update": "Update an inventory",
"inventories_update_inventory_sources_retrieve": "Retrieve single source for an inventory",
"inventories_variable_data_partial_update": "Partially update existing datum for an inventory",
"inventories_variable_data_retrieve": "Retrieve single datum for an inventory",
"inventories_variable_data_update": "Update existing datum for an inventory",
"inventory_sources_activity_stream_list": "List activity stream of an inventory source",
"inventory_sources_create": "Create an inventory source",
"inventory_sources_credentials_create": "Create a credential of an inventory source",
"inventory_sources_credentials_list": "List credentials of an inventory source",
"inventory_sources_destroy": "Delete an inventory source",
"inventory_sources_groups_destroy": "Delete a group of an inventory source",
"inventory_sources_groups_list": "List groups of an inventory source",
"inventory_sources_hosts_destroy": "Delete a host of an inventory source",
"inventory_sources_hosts_list": "List hosts of an inventory source",
"inventory_sources_inventory_updates_list": "List inventory updates of an inventory source",
"inventory_sources_list": "List inventory sources",
"inventory_sources_notification_templates_error_list": "List notification templates triggered on inventory source update error",
"inventory_sources_notification_templates_started_list": "List notification templates triggered on inventory source update start",
"inventory_sources_notification_templates_success_list": "List notification templates triggered on inventory source update success",
"inventory_sources_partial_update": "Update an inventory source",
"inventory_sources_retrieve": "Retrieve an inventory source",
"inventory_sources_schedules_create": "Create a schedule of an inventory source",
"inventory_sources_schedules_list": "List schedules of an inventory source",
"inventory_sources_update": "Update an inventory source",
"inventory_sources_update_retrieve": "Retrieve an update for an inventory source",
"inventory_updates_cancel_create": "Create a cancel for an inventory update",
"inventory_updates_cancel_retrieve": "Retrieve a cancel for an inventory update",
"inventory_updates_credentials_list": "List credentials of an inventory update",
"inventory_updates_destroy": "Delete an inventory update",
"inventory_updates_events_list": "List events of an inventory update",
"inventory_updates_list": "List inventory updates",
"inventory_updates_notifications_list": "List notifications of an inventory update",
"inventory_updates_retrieve": "Retrieve an inventory update",
"inventory_updates_stdout_retrieve": "Retrieve a stdout output of an inventory update",
"job_events_children_list": "List child events of a job event",
"job_events_retrieve": "Retrieve a job event detail",
"job_host_summaries_retrieve": "Retrieve a job host summary detail",
"job_templates_access_list_list": "List users who can access a job template",
"job_templates_activity_stream_list": "List activity stream of a job template",
"job_templates_copy_create": "Create a copy a job template",
"job_templates_copy_retrieve": "Retrieve a copy a job template",
"job_templates_create": "Create a job template",
"job_templates_credentials_create": "Create a credential of a job template",
"job_templates_credentials_list": "List credentials of a job template",
"job_templates_destroy": "Delete a job template",
"job_templates_instance_groups_create": "Create an instance group of a job template",
"job_templates_instance_groups_list": "List instance groups of a job template",
"job_templates_jobs_list": "List jobs of a job template",
"job_templates_labels_list": "List labels of a job template",
"job_templates_launch_retrieve": "Retrieve single launch for a job_template",
"job_templates_notification_templates_error_create": "Create a notification templates triggered on job error",
"job_templates_notification_templates_error_list": "List notification templates triggered on job error",
"job_templates_notification_templates_started_create": "Create a notification templates triggered on job start",
"job_templates_notification_templates_started_list": "List notification templates triggered on job start",
"job_templates_notification_templates_success_create": "Create a notification templates triggered on job success",
"job_templates_notification_templates_success_list": "List notification templates triggered on job success",
"job_templates_object_roles_list": "List roles of a job template",
"job_templates_partial_update": "Update a job template",
"job_templates_retrieve": "Retrieve a job template",
"job_templates_schedules_create": "Create a schedule of a job template",
"job_templates_schedules_list": "List schedules of a job template",
"job_templates_slice_workflow_jobs_create": "Create new job for a job_template",
"job_templates_slice_workflow_jobs_list": "List all jobs for a job_template",
"job_templates_update": "Update a job template",
"jobs_activity_stream_list": "List activity stream of a job",
"jobs_cancel_retrieve": "Retrieve a cancel for a job",
"jobs_create_schedule_retrieve": "Retrieve single schedule for a job",
"jobs_credentials_list": "List credentials of a job",
"jobs_destroy": "Delete a job",
"jobs_job_events_list": "List job events of a job",
"jobs_job_host_summaries_list": "List job host summaries of a job",
"jobs_labels_list": "List labels of a job",
"jobs_notifications_list": "List notifications of a job",
"jobs_relaunch_retrieve": "Retrieve single relaunch for a job",
"jobs_retrieve": "Retrieve a job",
"labels_create": "Create a label",
"labels_list": "List labels",
"labels_partial_update": "Update a label",
"labels_retrieve": "Retrieve a label",
"labels_update": "Update a label",
"me_list": "List current authenticated user",
"notification_templates_copy_create": "Create a copy a notification template",
"notification_templates_copy_retrieve": "Retrieve a copy a notification template",
"notification_templates_notifications_list": "List notifications of a notification template",
"notification_templates_retrieve": "Retrieve a notification template",
"notifications_list": "List notifications",
"notifications_retrieve": "Retrieve a notification",
"organizations_access_list_list": "List users who can access the organization",
"organizations_activity_stream_list": "List activity stream for an organization",
"organizations_admins_create": "Create new admin for an organization",
"organizations_admins_list": "List all admins for an organization",
"organizations_create": "Create an organization",
"organizations_credentials_create": "Create a credential of an organization",
"organizations_credentials_list": "List credentials of an organization",
"organizations_destroy": "Delete an organization",
"organizations_execution_environments_create": "Create an execution environment of an organization",
"organizations_execution_environments_list": "List execution environments of an organization",
"organizations_galaxy_credentials_create": "Create new credential for an organization",
"organizations_galaxy_credentials_list": "List all credentials for an organization",
"organizations_instance_groups_create": "Create an instance group of an organization",
"organizations_instance_groups_list": "List instance groups of an organization",
"organizations_inventories_list": "List inventories of an organization",
"organizations_job_templates_create": "Create a job template of an organization",
"organizations_job_templates_list": "List job templates of an organization",
"organizations_notification_templates_approvals_create": "Create new approval for an organization",
"organizations_notification_templates_approvals_list": "List all approvals for an organization",
"organizations_notification_templates_create": "Create a notification template of an organization",
"organizations_notification_templates_error_create": "Create new error for an organization",
"organizations_notification_templates_error_list": "List all error for an organization",
"organizations_notification_templates_list": "List notification templates of an organization",
"organizations_notification_templates_started_create": "Create new started for an organization",
"organizations_notification_templates_started_list": "List all started for an organization",
"organizations_notification_templates_success_create": "Create new success for an organization",
"organizations_notification_templates_success_list": "List all success for an organization",
"organizations_object_roles_list": "List roles of an organization",
"organizations_partial_update": "Update an organization",
"organizations_projects_create": "Create a project of an organization",
"organizations_projects_list": "List projects of an organization",
"organizations_retrieve": "Retrieve an organization",
"organizations_retrieve_2": "Retrieve an organization",
"organizations_teams_create": "Create a team of an organization",
"organizations_teams_list": "List teams of an organization",
"organizations_update": "Update an organization",
"organizations_users_create": "Create a user of an organization",
"organizations_users_list": "List users of an organization",
"organizations_workflow_job_templates_create": "Create a workflow job template of an organization",
"organizations_workflow_job_templates_list": "List workflow job templates of an organization",
"project_updates_cancel_create": "Create new cancel for a project_update",
"project_updates_cancel_retrieve": "Retrieve single cancel for a project_update",
"project_updates_destroy": "Delete a project update",
"project_updates_events_list": "List all events for a project_update",
"project_updates_list": "List project updates",
"project_updates_notifications_list": "List notifications of a project update",
"project_updates_retrieve": "Retrieve a project update",
"project_updates_scm_inventory_updates_list": "List all updates for a project_update",
"project_updates_stdout_retrieve": "Retrieve single stdout for a project_update",
"projects_access_list_list": "List users who can access the project",
"projects_activity_stream_list": "List activity stream for a project",
"projects_copy_create": "Create a copy of a project",
"projects_copy_retrieve": "Retrieve a copy of a project",
"projects_create": "Create a project",
"projects_destroy": "Delete a project",
"projects_inventories_retrieve": "Retrieve an inventory from a project",
"projects_notification_templates_error_create": "Create a notification template for project error events",
"projects_notification_templates_error_list": "List notification templates for project error events",
"projects_notification_templates_started_create": "Create a notification template for project started events",
"projects_notification_templates_started_list": "List notification templates for project started events",
"projects_notification_templates_success_create": "Create a notification template for project success events",
"projects_notification_templates_success_list": "List notification templates for project success events",
"projects_object_roles_list": "List roles of a project",
"projects_partial_update": "Update a project",
"projects_playbooks_retrieve": "Retrieve single playbook for a project",
"projects_project_updates_list": "List project updates of a project",
"projects_retrieve": "Retrieve a project",
"projects_schedules_create": "Create a schedule of a project",
"projects_schedules_list": "List schedules of a project",
"projects_scm_inventory_sources_list": "List all sources for a project",
"projects_teams_list": "List teams with access to a project",
"projects_update": "Update a project",
"projects_update_retrieve": "Retrieve single update for a project",
"receptor_addresses_list": "List receptor addresses",
"receptor_addresses_retrieve": "Retrieve a receptor address",
"role_definitions_create": "Create a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_destroy": "Delete a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_list": "List RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_partial_update": "Update a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_retrieve": "Retrieve a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_team_assignments_list": "List all assignments for a role_definition",
"role_definitions_update": "Update a RBAC roles defining permissions that can be managed and assigned to users and teams",
"role_definitions_user_assignments_list": "List all assignments for a role_definition",
"role_metadata_retrieve": "Retrieve single role metadatum",
"role_team_access_list": "List all role team access",
"role_team_access_list_2": "List all role team access",
"role_team_assignments_create": "Create a RBAC role grants assigning permissions to team for specific resources",
"role_team_assignments_destroy": "Delete a RBAC role grants assigning permissions to team for specific resources",
"role_team_assignments_list": "List RBAC role grants assigning permissions to teams for specific resources",
"role_team_assignments_retrieve": "Retrieve a RBAC role grants assigning permissions to team for specific resources",
"role_user_access_list": "List all role user access",
"role_user_access_list_2": "List all role user access",
"role_user_assignments_create": "Create a RBAC role grants assigning permissions to user for specific resources",
"role_user_assignments_destroy": "Delete a RBAC role grants assigning permissions to user for specific resources",
"role_user_assignments_list": "List RBAC role grants assigning permissions to users for specific resources",
"role_user_assignments_retrieve": "Retrieve a RBAC role grants assigning permissions to user for specific resources",
"roles_list": "List roles",
"roles_retrieve": "Retrieve a role",
"roles_teams_list": "List teams with a role",
"roles_users_list": "List users with a role",
"schedules_create": "Create a schedule",
"schedules_credentials_create": "Create a credential of a schedule",
"schedules_credentials_list": "List credentials of a schedule",
"schedules_destroy": "Delete a schedule",
"schedules_instance_groups_create": "Create an instance group of a schedule",
"schedules_instance_groups_list": "List instance groups of a schedule",
"schedules_jobs_list": "List jobs created by a schedule",
"schedules_labels_list": "List labels of a schedule",
"schedules_list": "List schedules",
"schedules_partial_update": "Update a schedule",
"schedules_retrieve": "Retrieve a schedule",
"schedules_update": "Update a schedule",
"service_index_metadata_retrieve": "Retrieve single service index metadatum",
"service_index_resource_types_list": "List all service index resource types",
"service_index_resource_types_manifest_retrieve": "Retrieve single manifest for a resource-type",
"service_index_resource_types_retrieve": "Retrieve single service index resource type",
"service_index_resources_create": "Create new service index resource",
"service_index_resources_destroy": "Delete existing service index resource",
"service_index_resources_list": "List all service index resources",
"service_index_resources_partial_update": "Partially update existing service index resource",
"service_index_resources_retrieve": "Retrieve single service index resource",
"service_index_resources_update": "Update existing service index resource",
"service_index_retrieve": "Retrieve single service index",
"service_index_role_permissions_list": "List all service index role permissions",
"service_index_role_team_assignments_assign_create": "Create new service index role team assignments assign",
"service_index_role_team_assignments_list": "List all service index role team assignments",
"service_index_role_team_assignments_unassign_create": "Create new service index role team assignments unassign",
"service_index_role_types_list": "List all service index role types",
"service_index_role_user_assignments_assign_create": "Create new service index role user assignments assign",
"service_index_role_user_assignments_list": "List all service index role user assignments",
"service_index_role_user_assignments_unassign_create": "Create new service index role user assignments unassign",
"settings_destroy": "Delete existing setting",
"settings_logging_test_create": "Create new settings logging test",
"settings_retrieve": "Retrieve single setting",
"settings_update": "Update existing setting",
"system_job_templates_jobs_list": "List system jobs of a system job template",
"system_job_templates_notification_templates_error_create": "Create a notification templates triggered on system job error",
"system_job_templates_notification_templates_error_list": "List notification templates triggered on system job error",
"system_job_templates_notification_templates_started_create": "Create a notification templates triggered on system job start",
"system_job_templates_notification_templates_started_list": "List notification templates triggered on system job start",
"system_job_templates_notification_templates_success_create": "Create a notification templates triggered on system job success",
"system_job_templates_notification_templates_success_list": "List notification templates triggered on system job success",
"system_job_templates_retrieve": "Retrieve a system job template",
"system_job_templates_schedules_create": "Create a schedule of a system job template",
"system_job_templates_schedules_list": "List schedules of a system job template",
"system_jobs_cancel_create": "Create a cancel for a system job",
"system_jobs_cancel_retrieve": "Retrieve a cancel for a system job",
"system_jobs_destroy": "Delete a system job",
"system_jobs_events_list": "List events of a system job",
"system_jobs_notifications_list": "List notifications of a system job",
"system_jobs_retrieve": "Retrieve a system job",
"teams_access_list_list": "List users who can access the team",
"teams_activity_stream_list": "List activity stream for a team",
"teams_create": "Create a team",
"teams_credentials_create": "Create a credentials owned by a team",
"teams_credentials_list": "List credentials owned by a team",
"teams_destroy": "Delete a team",
"teams_list": "List teams",
"teams_object_roles_list": "List object roles of a team",
"teams_partial_update": "Update a team",
"teams_projects_list": "List projects accessible to a team",
"teams_retrieve": "Retrieve a team",
"teams_roles_list": "List roles of a team",
"teams_update": "Update a team",
"teams_users_create": "Create a user of a team",
"teams_users_list": "List users of a team",
"unified_job_templates_list": "List unified job templates",
"unified_jobs_list": "List unified jobs",
"users_access_list_list": "List users who can access the user",
"users_activity_stream_list": "List activity stream for a user",
"users_admin_of_organizations_retrieve": "Retrieve single organization for an user",
"users_create": "Create a user",
"users_credentials_create": "Create a credentials owned by a user",
"users_credentials_list": "List credentials owned by a user",
"users_destroy": "Delete a user",
"users_list": "List users",
"users_organizations_retrieve": "Retrieve an organization of a user",
"users_partial_update": "Update a user",
"users_projects_list": "List projects accessible to a user",
"users_retrieve": "Retrieve a user",
"users_roles_list": "List roles of a user",
"users_teams_list": "List teams of a user",
"users_update": "Update a user",
"workflow_approval_templates_approvals_list": "List all approvals for a workflow_approval_template",
"workflow_approval_templates_destroy": "Delete a workflow approval template detail",
"workflow_approval_templates_partial_update": "Update a workflow approval template detail",
"workflow_approval_templates_retrieve": "Retrieve a workflow approval template detail",
"workflow_approval_templates_update": "Update a workflow approval template detail",
"workflow_approvals_approve_retrieve": "Retrieve single approve for a workflow_approval",
"workflow_approvals_deny_retrieve": "Retrieve single deny for a workflow_approval",
"workflow_approvals_destroy": "Delete a workflow approval",
"workflow_approvals_retrieve": "Retrieve a workflow approval",
"workflow_job_nodes_always_nodes_list": "List always nodes of a workflow job node",
"workflow_job_nodes_credentials_list": "List credentials of a workflow job node",
"workflow_job_nodes_failure_nodes_list": "List failure nodes of a workflow job node",
"workflow_job_nodes_instance_groups_create": "Create an instance group of a workflow job node",
"workflow_job_nodes_instance_groups_list": "List instance groups of a workflow job node",
"workflow_job_nodes_labels_list": "List labels of a workflow job node",
"workflow_job_nodes_list": "List workflow job nodes",
"workflow_job_nodes_retrieve": "Retrieve a workflow job node",
"workflow_job_nodes_success_nodes_list": "List success nodes of a workflow job node",
"workflow_job_template_nodes_always_nodes_create": "Create new node for a workflow_job_template_node",
"workflow_job_template_nodes_always_nodes_list": "List all nodes for a workflow_job_template_node",
"workflow_job_template_nodes_create": "Create a workflow job template node",
"workflow_job_template_nodes_create_approval_template_retrieve": "Retrieve single template for a workflow_job_template_node",
"workflow_job_template_nodes_credentials_create": "Create a credential of a workflow job template node",
"workflow_job_template_nodes_credentials_list": "List credentials of a workflow job template node",
"workflow_job_template_nodes_destroy": "Delete a workflow job template node",
"workflow_job_template_nodes_failure_nodes_create": "Create new node for a workflow_job_template_node",
"workflow_job_template_nodes_failure_nodes_list": "List all nodes for a workflow_job_template_node",
"workflow_job_template_nodes_instance_groups_create": "Create an instance group of a workflow job template node",
"workflow_job_template_nodes_instance_groups_list": "List instance groups of a workflow job template node",
"workflow_job_template_nodes_labels_list": "List labels of a workflow job template node",
"workflow_job_template_nodes_list": "List workflow job template nodes",
"workflow_job_template_nodes_partial_update": "Update a workflow job template node",
"workflow_job_template_nodes_retrieve": "Retrieve a workflow job template node",
"workflow_job_template_nodes_success_nodes_create": "Create new node for a workflow_job_template_node",
"workflow_job_template_nodes_success_nodes_list": "List all nodes for a workflow_job_template_node",
"workflow_job_template_nodes_update": "Update a workflow job template node",
"workflow_job_templates_access_list_list": "List users who can access a workflow job template",
"workflow_job_templates_activity_stream_list": "List activity stream of a workflow job template",
"workflow_job_templates_copy_create": "Create a copy a workflow job template",
"workflow_job_templates_create": "Create a workflow job template",
"workflow_job_templates_destroy": "Delete a workflow job template",
"workflow_job_templates_labels_list": "List labels of a workflow job template",
"workflow_job_templates_launch_retrieve": "Retrieve a launch a workflow job from a workflow job template",
"workflow_job_templates_notification_templates_approvals_create": "Create a notification templates triggered on workflow approval",
"workflow_job_templates_notification_templates_approvals_list": "List notification templates triggered on workflow approval",
"workflow_job_templates_notification_templates_error_create": "Create a notification templates triggered on workflow job error",
"workflow_job_templates_notification_templates_error_list": "List notification templates triggered on workflow job error",
"workflow_job_templates_notification_templates_started_create": "Create a notification templates triggered on workflow job start",
"workflow_job_templates_notification_templates_started_list": "List notification templates triggered on workflow job start",
"workflow_job_templates_notification_templates_success_create": "Create a notification templates triggered on workflow job success",
"workflow_job_templates_notification_templates_success_list": "List notification templates triggered on workflow job success",
"workflow_job_templates_object_roles_list": "List roles of a workflow job template",
"workflow_job_templates_partial_update": "Update a workflow job template",
"workflow_job_templates_retrieve": "Retrieve a workflow job template",
"workflow_job_templates_schedules_create": "Create a schedule of a workflow job template",
"workflow_job_templates_schedules_list": "List schedules of a workflow job template",
"workflow_job_templates_update": "Update a workflow job template",
"workflow_job_templates_workflow_jobs_list": "List workflow jobs of a workflow job template",
"workflow_job_templates_workflow_nodes_create": "Create new node for a workflow_job_template",
"workflow_job_templates_workflow_nodes_list": "List all nodes for a workflow_job_template",
"workflow_jobs_activity_stream_list": "List activity stream of a workflow job",
"workflow_jobs_cancel_retrieve": "Retrieve a cancel for a workflow job",
"workflow_jobs_destroy": "Delete a workflow job",
"workflow_jobs_labels_list": "List labels of a workflow job",
"workflow_jobs_notifications_list": "List notifications of a workflow job",
"workflow_jobs_retrieve": "Retrieve a workflow job",
"workflow_jobs_workflow_nodes_list": "List workflow nodes of a workflow job"
}

View File

@@ -111,7 +111,7 @@ class UnifiedJobEventPagination(Pagination):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.use_limit_paginator = False self.use_limit_paginator = False
self.limit_pagination = LimitPagination() self.limit_pagination = LimitPagination()
super().__init__(*args, **kwargs) return super().__init__(*args, **kwargs)
def paginate_queryset(self, queryset, request, view=None): def paginate_queryset(self, queryset, request, view=None):
if 'limit' in request.query_params: if 'limit' in request.query_params:

View File

@@ -10,7 +10,7 @@ from rest_framework import permissions
# AWX # AWX
from awx.main.access import check_user_access from awx.main.access import check_user_access
from awx.main.models import Inventory, UnifiedJob, Organization from awx.main.models import Inventory, UnifiedJob
from awx.main.utils import get_object_or_400 from awx.main.utils import get_object_or_400
logger = logging.getLogger('awx.api.permissions') logger = logging.getLogger('awx.api.permissions')
@@ -228,19 +228,12 @@ class InventoryInventorySourcesUpdatePermission(ModelAccessPermission):
class UserPermission(ModelAccessPermission): class UserPermission(ModelAccessPermission):
def check_post_permissions(self, request, view, obj=None): def check_post_permissions(self, request, view, obj=None):
if not request.data: if not request.data:
return Organization.access_qs(request.user, 'change').exists() return request.user.admin_of_organizations.exists()
elif request.user.is_superuser: elif request.user.is_superuser:
return True return True
raise PermissionDenied() raise PermissionDenied()
class IsSystemAdmin(permissions.BasePermission):
def has_permission(self, request, view):
if not (request.user and request.user.is_authenticated):
return False
return request.user.is_superuser
class IsSystemAdminOrAuditor(permissions.BasePermission): class IsSystemAdminOrAuditor(permissions.BasePermission):
""" """
Allows write access only to system admin users. Allows write access only to system admin users.

View File

@@ -1,152 +0,0 @@
import json
import os
import warnings
from rest_framework.permissions import IsAuthenticated
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
def filter_credential_type_schema(
result,
generator, # NOSONAR
request, # NOSONAR
public, # NOSONAR
):
"""
Postprocessing hook to filter CredentialType kind enum values.
For CredentialTypeRequest and PatchedCredentialTypeRequest schemas (POST/PUT/PATCH),
filter the 'kind' enum to only show 'cloud' and 'net' values.
This ensures the OpenAPI schema accurately reflects that only 'cloud' and 'net'
credential types can be created or modified via the API, matching the validation
in CredentialTypeSerializer.validate().
Args:
result: The OpenAPI schema dict to be modified
generator, request, public: Required by drf-spectacular interface (unused)
Returns:
The modified OpenAPI schema dict
"""
schemas = result.get('components', {}).get('schemas', {})
# Filter CredentialTypeRequest (POST/PUT) - field is required
if 'CredentialTypeRequest' in schemas:
kind_prop = schemas['CredentialTypeRequest'].get('properties', {}).get('kind', {})
if 'enum' in kind_prop:
# Filter to only cloud and net (no None - field is required)
kind_prop['enum'] = ['cloud', 'net']
kind_prop['description'] = "* `cloud` - Cloud\\n* `net` - Network"
# Filter PatchedCredentialTypeRequest (PATCH) - field is optional
if 'PatchedCredentialTypeRequest' in schemas:
kind_prop = schemas['PatchedCredentialTypeRequest'].get('properties', {}).get('kind', {})
if 'enum' in kind_prop:
# Filter to only cloud and net (None allowed - field can be omitted in PATCH)
kind_prop['enum'] = ['cloud', 'net', None]
kind_prop['description'] = "* `cloud` - Cloud\\n* `net` - Network"
return result
def inject_ai_descriptions(
result,
generator, # NOSONAR
request, # NOSONAR
public, # NOSONAR
):
"""
Inject x-ai-description into operations from the overlay file.
Many endpoints have human-readable AI descriptions that were added
downstream but not backported as @extend_schema_if_available decorators.
This hook merges them from a JSON file keyed by operationId.
"""
overlay_path = os.path.join(os.path.dirname(__file__), 'openapi_ai_descriptions.json')
try:
with open(overlay_path) as f:
descriptions = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return result
for path_item in result.get('paths', {}).values():
for operation in path_item.values():
if not isinstance(operation, dict):
continue
op_id = operation.get('operationId')
if op_id and op_id in descriptions and 'x-ai-description' not in operation:
operation['x-ai-description'] = descriptions[op_id]
return result
class CustomAutoSchema(AutoSchema):
"""Custom AutoSchema to add swagger_topic to tags and handle deprecated endpoints."""
def get_tags(self):
tags = []
try:
if hasattr(self.view, 'get_serializer'):
serializer = self.view.get_serializer()
else:
serializer = None
except Exception:
serializer = None
warnings.warn(
'{}.get_serializer() raised an exception during '
'schema generation. Serializer fields will not be '
'generated for this view.'.format(self.view.__class__.__name__)
)
if hasattr(self.view, 'swagger_topic'):
tags.append(str(self.view.swagger_topic).title())
elif serializer and hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'model'):
tags.append(str(serializer.Meta.model._meta.verbose_name_plural).title())
elif hasattr(self.view, 'model'):
tags.append(str(self.view.model._meta.verbose_name_plural).title())
else:
tags = super().get_tags() # Use default drf-spectacular behavior
if not tags:
warnings.warn(f'Could not determine tags for {self.view.__class__.__name__}')
tags = ['api'] # Fallback to default value
return tags
def is_deprecated(self):
"""Return `True` if this operation is to be marked as deprecated."""
return getattr(self.view, 'deprecated', False)
class AuthenticatedSpectacularAPIView(SpectacularAPIView):
"""SpectacularAPIView that requires authentication."""
permission_classes = [IsAuthenticated]
class AuthenticatedSpectacularSwaggerView(SpectacularSwaggerView):
"""SpectacularSwaggerView that requires authentication."""
permission_classes = [IsAuthenticated]
class AuthenticatedSpectacularRedocView(SpectacularRedocView):
"""SpectacularRedocView that requires authentication."""
permission_classes = [IsAuthenticated]
# Schema view (returns OpenAPI schema JSON/YAML)
schema_view = AuthenticatedSpectacularAPIView.as_view()
# Swagger UI view
swagger_ui_view = AuthenticatedSpectacularSwaggerView.as_view(url_name='api:schema-json')
# ReDoc UI view
redoc_view = AuthenticatedSpectacularRedocView.as_view(url_name='api:schema-json')

View File

@@ -120,7 +120,8 @@ from awx.main.utils.named_url_graph import reset_counters
from awx.main.utils.inventory_vars import update_group_variables from awx.main.utils.inventory_vars import update_group_variables
from awx.main.scheduler.task_manager_models import TaskManagerModels from awx.main.scheduler.task_manager_models import TaskManagerModels
from awx.main.redact import UriCleaner, REPLACE_STR from awx.main.redact import UriCleaner, REPLACE_STR
from awx.main.tasks.system import update_inventory_computed_fields from awx.main.signals import update_inventory_computed_fields
from awx.main.validators import vars_validate_or_raise from awx.main.validators import vars_validate_or_raise
@@ -174,8 +175,8 @@ SUMMARIZABLE_FK_FIELDS = {
'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout',), 'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout',),
'schedule': DEFAULT_SUMMARY_FIELDS + ('next_run',), 'schedule': DEFAULT_SUMMARY_FIELDS + ('next_run',),
'unified_job_template': DEFAULT_SUMMARY_FIELDS + ('unified_job_type',), 'unified_job_template': DEFAULT_SUMMARY_FIELDS + ('unified_job_type',),
# last_job and last_job_host_summary are derived from JobHostSummary in HostSerializer, 'last_job': DEFAULT_SUMMARY_FIELDS + ('finished', 'status', 'failed', 'license_error', 'canceled_on'),
# not from the stale FK fields on Host. 'last_job_host_summary': DEFAULT_SUMMARY_FIELDS + ('failed',),
'last_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'last_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
'current_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'current_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
@@ -962,13 +963,13 @@ class UnifiedJobSerializer(BaseSerializer):
class UnifiedJobListSerializer(UnifiedJobSerializer): class UnifiedJobListSerializer(UnifiedJobSerializer):
class Meta: class Meta:
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished', '-artifacts') fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished')
def get_field_names(self, declared_fields, info): def get_field_names(self, declared_fields, info):
field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info) field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info)
# Meta multiple inheritance and -field_name options don't seem to be # Meta multiple inheritance and -field_name options don't seem to be
# taking effect above, so remove the undesired fields here. # taking effect above, so remove the undesired fields here.
return tuple(x for x in field_names if x not in ('job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished', 'artifacts')) return tuple(x for x in field_names if x not in ('job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished'))
def get_types(self): def get_types(self):
if type(self) is UnifiedJobListSerializer: if type(self) is UnifiedJobListSerializer:
@@ -1021,7 +1022,7 @@ class UnifiedJobStdoutSerializer(UnifiedJobSerializer):
class UserSerializer(BaseSerializer): class UserSerializer(BaseSerializer):
password = serializers.CharField(required=False, default='', allow_blank=True, help_text=_('Field used to change the password.')) password = serializers.CharField(required=False, default='', help_text=_('Field used to change the password.'))
is_system_auditor = serializers.BooleanField(default=False) is_system_auditor = serializers.BooleanField(default=False)
show_capabilities = ['edit', 'delete'] show_capabilities = ['edit', 'delete']
@@ -1229,7 +1230,7 @@ class OrganizationSerializer(BaseSerializer, OpaQueryPathMixin):
# to a team. This provides a hint to the ui so it can know to not # to a team. This provides a hint to the ui so it can know to not
# display these roles for team role selection. # display these roles for team role selection.
for key in ('admin_role', 'member_role'): for key in ('admin_role', 'member_role'):
if summary_dict and key in summary_dict.get('object_roles', {}): if key in summary_dict.get('object_roles', {}):
summary_dict['object_roles'][key]['user_only'] = True summary_dict['object_roles'][key]['user_only'] = True
return summary_dict return summary_dict
@@ -1837,35 +1838,19 @@ class HostSerializer(BaseSerializerWithVariables):
res['ansible_facts'] = self.reverse('api:host_ansible_facts_detail', kwargs={'pk': obj.instance_id}) res['ansible_facts'] = self.reverse('api:host_ansible_facts_detail', kwargs={'pk': obj.instance_id})
if obj.inventory: if obj.inventory:
res['inventory'] = self.reverse('api:inventory_detail', kwargs={'pk': obj.inventory.pk}) res['inventory'] = self.reverse('api:inventory_detail', kwargs={'pk': obj.inventory.pk})
last_summary = obj.latest_summary if obj.last_job:
if last_summary: res['last_job'] = self.reverse('api:job_detail', kwargs={'pk': obj.last_job.pk})
res['last_job_host_summary'] = self.reverse('api:job_host_summary_detail', kwargs={'pk': last_summary.pk}) if obj.last_job_host_summary:
if last_summary.job_id: res['last_job_host_summary'] = self.reverse('api:job_host_summary_detail', kwargs={'pk': obj.last_job_host_summary.pk})
res['last_job'] = self.reverse('api:job_detail', kwargs={'pk': last_summary.job_id})
return res return res
def get_summary_fields(self, obj): def get_summary_fields(self, obj):
d = super(HostSerializer, self).get_summary_fields(obj) d = super(HostSerializer, self).get_summary_fields(obj)
last_summary = obj.latest_summary try:
if last_summary: d['last_job']['job_template_id'] = obj.last_job.job_template.id
d['last_job_host_summary'] = OrderedDict() d['last_job']['job_template_name'] = obj.last_job.job_template.name
d['last_job_host_summary']['id'] = last_summary.id except (KeyError, AttributeError):
d['last_job_host_summary']['failed'] = last_summary.failed pass
try:
last_job = last_summary.job
d['last_job'] = OrderedDict()
for field in DEFAULT_SUMMARY_FIELDS + ('finished', 'status', 'failed', 'canceled_on'):
fval = getattr(last_job, field, None)
if fval is not None:
d['last_job'][field] = fval
if last_job.job_template:
d['last_job']['job_template_id'] = last_job.job_template.id
d['last_job']['job_template_name'] = last_job.job_template.name
except ObjectDoesNotExist:
pass
else:
d.pop('last_job', None)
d.pop('last_job_host_summary', None)
if has_model_field_prefetched(obj, 'groups'): if has_model_field_prefetched(obj, 'groups'):
group_list = sorted([{'id': g.id, 'name': g.name} for g in obj.groups.all()], key=lambda x: x['id'])[:5] group_list = sorted([{'id': g.id, 'name': g.name} for g in obj.groups.all()], key=lambda x: x['id'])[:5]
else: else:
@@ -1940,16 +1925,14 @@ class HostSerializer(BaseSerializerWithVariables):
return ret return ret
if 'inventory' in ret and not obj.inventory: if 'inventory' in ret and not obj.inventory:
ret['inventory'] = None ret['inventory'] = None
last_summary = obj.latest_summary if 'last_job' in ret and not obj.last_job:
if 'last_job' in ret: ret['last_job'] = None
ret['last_job'] = last_summary.job_id if last_summary else None if 'last_job_host_summary' in ret and not obj.last_job_host_summary:
if 'last_job_host_summary' in ret: ret['last_job_host_summary'] = None
ret['last_job_host_summary'] = last_summary.pk if last_summary else None
return ret return ret
def get_has_active_failures(self, obj): def get_has_active_failures(self, obj):
last_summary = obj.latest_summary return bool(obj.last_job_host_summary and obj.last_job_host_summary.failed)
return bool(last_summary and last_summary.failed)
def get_has_inventory_sources(self, obj): def get_has_inventory_sources(self, obj):
return obj.inventory_sources.exists() return obj.inventory_sources.exists()
@@ -2096,17 +2079,9 @@ class BulkHostCreateSerializer(serializers.Serializer):
if request and not request.user.is_superuser: if request and not request.user.is_superuser:
if request.user not in inv.admin_role: if request.user not in inv.admin_role:
raise serializers.ValidationError(_(f'Inventory with id {inv.id} not found or lack permissions to add hosts.')) raise serializers.ValidationError(_(f'Inventory with id {inv.id} not found or lack permissions to add hosts.'))
current_hostnames = set(inv.hosts.values_list('name', flat=True))
# Performance optimization (AAP-67978): Instead of loading ALL host names from
# the inventory, only check if the specific new names already exist in the database.
new_names = [host['name'] for host in attrs['hosts']] new_names = [host['name'] for host in attrs['hosts']]
duplicate_new_names = [n for n in new_names if n in current_hostnames or new_names.count(n) > 1]
new_name_counts = Counter(new_names)
duplicates_in_new = [name for name, count in new_name_counts.items() if count > 1]
unique_new_names = list(new_name_counts.keys())
existing_duplicates = list(Host.objects.filter(inventory=inv, name__in=unique_new_names).values_list('name', flat=True))
duplicate_new_names = list(set(duplicates_in_new + existing_duplicates))
if duplicate_new_names: if duplicate_new_names:
raise serializers.ValidationError(_(f'Hostnames must be unique in an inventory. Duplicates found: {duplicate_new_names}')) raise serializers.ValidationError(_(f'Hostnames must be unique in an inventory. Duplicates found: {duplicate_new_names}'))
@@ -2190,13 +2165,13 @@ class BulkHostDeleteSerializer(serializers.Serializer):
attrs['hosts_data'] = attrs['host_qs'].values() attrs['hosts_data'] = attrs['host_qs'].values()
if len(attrs['host_qs']) == 0: if len(attrs['host_qs']) == 0:
error_hosts = dict.fromkeys(attrs['hosts'], "Hosts do not exist or you lack permission to delete it") error_hosts = {host: "Hosts do not exist or you lack permission to delete it" for host in attrs['hosts']}
raise serializers.ValidationError({'hosts': error_hosts}) raise serializers.ValidationError({'hosts': error_hosts})
if len(attrs['host_qs']) < len(attrs['hosts']): if len(attrs['host_qs']) < len(attrs['hosts']):
hosts_exists = [host['id'] for host in attrs['hosts_data']] hosts_exists = [host['id'] for host in attrs['hosts_data']]
failed_hosts = list(set(attrs['hosts']).difference(hosts_exists)) failed_hosts = list(set(attrs['hosts']).difference(hosts_exists))
error_hosts = dict.fromkeys(failed_hosts, "Hosts do not exist or you lack permission to delete it") error_hosts = {host: "Hosts do not exist or you lack permission to delete it" for host in failed_hosts}
raise serializers.ValidationError({'hosts': error_hosts}) raise serializers.ValidationError({'hosts': error_hosts})
# Getting all inventories that the hosts can be in # Getting all inventories that the hosts can be in
@@ -2864,7 +2839,7 @@ class ResourceAccessListElementSerializer(UserSerializer):
{ {
"role": { "role": {
"id": None, "id": None,
"name": _("Platform Auditor"), "name": _("Controller System Auditor"),
"description": _("Can view all aspects of the system"), "description": _("Can view all aspects of the system"),
"user_capabilities": {"unattach": False}, "user_capabilities": {"unattach": False},
}, },
@@ -2957,19 +2932,6 @@ class CredentialTypeSerializer(BaseSerializer):
field['label'] = _(field['label']) field['label'] = _(field['label'])
if 'help_text' in field: if 'help_text' in field:
field['help_text'] = _(field['help_text']) field['help_text'] = _(field['help_text'])
# Deep copy inputs to avoid modifying the original model data
inputs = value.get('inputs')
if not isinstance(inputs, dict):
inputs = {}
value['inputs'] = copy.deepcopy(inputs)
fields = value['inputs'].get('fields', [])
if not isinstance(fields, list):
fields = []
# Normalize fields and filter out internal fields
value['inputs']['fields'] = [f for f in fields if not f.get('internal')]
return value return value
def filter_field_metadata(self, fields, method): def filter_field_metadata(self, fields, method):
@@ -3065,6 +3027,11 @@ class CredentialSerializer(BaseSerializer):
ret.remove(field) ret.remove(field)
return ret return ret
def validate_organization(self, org):
if self.instance and (not self.instance.managed) and self.instance.credential_type.kind == 'galaxy' and org is None:
raise serializers.ValidationError(_("Galaxy credentials must be owned by an Organization."))
return org
def validate_credential_type(self, credential_type): def validate_credential_type(self, credential_type):
if self.instance and credential_type.pk != self.instance.credential_type.pk: if self.instance and credential_type.pk != self.instance.credential_type.pk:
for related_objects in ( for related_objects in (
@@ -3140,6 +3107,9 @@ class CredentialSerializerCreate(CredentialSerializer):
if attrs.get('team'): if attrs.get('team'):
attrs['organization'] = attrs['team'].organization attrs['organization'] = attrs['team'].organization
if 'credential_type' in attrs and attrs['credential_type'].kind == 'galaxy' and list(owner_fields) != ['organization']:
raise serializers.ValidationError({"organization": _("Galaxy credentials must be owned by an Organization.")})
return super(CredentialSerializerCreate, self).validate(attrs) return super(CredentialSerializerCreate, self).validate(attrs)
def create(self, validated_data): def create(self, validated_data):
@@ -3565,7 +3535,7 @@ class JobRelaunchSerializer(BaseSerializer):
choices=NEW_JOB_TYPE_CHOICES, choices=NEW_JOB_TYPE_CHOICES,
write_only=True, write_only=True,
) )
credential_passwords = VerbatimField(required=False, write_only=True) credential_passwords = VerbatimField(required=True, write_only=True)
class Meta: class Meta:
model = Job model = Job
@@ -4160,28 +4130,9 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
attrs['extra_data'][key] = db_extra_data[key] attrs['extra_data'][key] = db_extra_data[key]
# Build unsaved version of this config, use it to detect prompts errors # Build unsaved version of this config, use it to detect prompts errors
# Capture keys before _build_mock_obj pops pseudo-fields from attrs
incoming_attr_keys = set(attrs.keys())
mock_obj = self._build_mock_obj(attrs) mock_obj = self._build_mock_obj(attrs)
ask_mapping_keys = set(ujt.get_ask_mapping().keys()) if set(list(ujt.get_ask_mapping().keys()) + ['extra_data']) & set(attrs.keys()):
requested_prompt_fields = incoming_attr_keys & ask_mapping_keys accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **mock_obj.prompts_dict())
if 'extra_data' in incoming_attr_keys:
requested_prompt_fields.add('extra_vars')
requested_prompt_fields.add('survey_passwords')
# prompts_dict() pulls persisted M2M state (labels, credentials,
# instance_groups) via the instance pk. Only re-validate the full prompt
# state when the caller is switching the underlying template; otherwise
# restrict validation to the fields the request explicitly provided.
if 'unified_job_template' in attrs:
prompts_to_validate = mock_obj.prompts_dict()
elif requested_prompt_fields:
prompts_to_validate = {k: v for k, v in mock_obj.prompts_dict().items() if k in requested_prompt_fields}
else:
prompts_to_validate = None
if prompts_to_validate is not None:
accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **prompts_to_validate)
else: else:
# Only perform validation of prompts if prompts fields are provided # Only perform validation of prompts if prompts fields are provided
errors = {} errors = {}
@@ -5450,11 +5401,7 @@ class SchedulePreviewSerializer(BaseSerializer):
for a_rule in match_multiple_rrule: for a_rule in match_multiple_rrule:
if 'interval' not in a_rule.lower(): if 'interval' not in a_rule.lower():
errors.append("{0}: {1}".format(_('INTERVAL required in rrule'), a_rule)) errors.append("{0}: {1}".format(_('INTERVAL required in rrule'), a_rule))
else: elif 'secondly' in a_rule.lower():
match_interval = re.match(r".*?INTERVAL=([0-9]+)", a_rule)
if match_interval and int(match_interval.group(1)) < 1:
errors.append("{0}: {1}".format(_("INTERVAL must be a positive integer"), a_rule))
if 'secondly' in a_rule.lower():
errors.append("{0}: {1}".format(_('SECONDLY is not supported'), a_rule)) errors.append("{0}: {1}".format(_('SECONDLY is not supported'), a_rule))
if re.match(by_day_with_numeric_prefix, a_rule): if re.match(by_day_with_numeric_prefix, a_rule):
errors.append("{0}: {1}".format(_("BYDAY with numeric prefix not supported"), a_rule)) errors.append("{0}: {1}".format(_("BYDAY with numeric prefix not supported"), a_rule))
@@ -6059,7 +6006,7 @@ class InstanceGroupSerializer(BaseSerializer):
if self.instance and not self.instance.is_container_group: if self.instance and not self.instance.is_container_group:
raise serializers.ValidationError(_('pod_spec_override is only valid for container groups')) raise serializers.ValidationError(_('pod_spec_override is only valid for container groups'))
pod_spec_override_json = {} pod_spec_override_json = None
# defect if the value is yaml or json if yaml convert to json # defect if the value is yaml or json if yaml convert to json
try: try:
# convert yaml to json # convert yaml to json

55
awx/api/swagger.py Normal file
View File

@@ -0,0 +1,55 @@
import warnings
from rest_framework.permissions import AllowAny
from drf_yasg import openapi
from drf_yasg.inspectors import SwaggerAutoSchema
from drf_yasg.views import get_schema_view
class CustomSwaggerAutoSchema(SwaggerAutoSchema):
"""Custom SwaggerAutoSchema to add swagger_topic to tags."""
def get_tags(self, operation_keys=None):
tags = []
try:
if hasattr(self.view, 'get_serializer'):
serializer = self.view.get_serializer()
else:
serializer = None
except Exception:
serializer = None
warnings.warn(
'{}.get_serializer() raised an exception during '
'schema generation. Serializer fields will not be '
'generated for {}.'.format(self.view.__class__.__name__, operation_keys)
)
if hasattr(self.view, 'swagger_topic'):
tags.append(str(self.view.swagger_topic).title())
elif serializer and hasattr(serializer, 'Meta'):
tags.append(str(serializer.Meta.model._meta.verbose_name_plural).title())
elif hasattr(self.view, 'model'):
tags.append(str(self.view.model._meta.verbose_name_plural).title())
else:
tags = ['api'] # Fallback to default value
if not tags:
warnings.warn(f'Could not determine tags for {self.view.__class__.__name__}')
return tags
def is_deprecated(self):
"""Return `True` if this operation is to be marked as deprecated."""
return getattr(self.view, 'deprecated', False)
schema_view = get_schema_view(
openapi.Info(
title='AWX API',
default_version='v2',
description='AWX API Documentation',
terms_of_service='https://www.google.com/policies/terms/',
contact=openapi.Contact(email='contact@snippets.local'),
license=openapi.License(name='Apache License'),
),
public=True,
permission_classes=[AllowAny],
)

View File

@@ -1,6 +1,6 @@
{% if content_only %}<div class="nocode ansi_fore ansi_back{% if dark %} ansi_dark{% endif %}">{% else %} {% if content_only %}<div class="nocode ansi_fore ansi_back{% if dark %} ansi_dark{% endif %}">{% else %}
<!DOCTYPE HTML> <!DOCTYPE HTML>
<html lang="en"> <html>
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{{ title }}</title> <title>{{ title }}</title>

View File

@@ -1,4 +1,4 @@
--- ---
collections: collections:
- name: ansible.receptor - name: ansible.receptor
version: 2.0.8 version: 2.0.3

0
awx/api/urls/Pipfile Normal file
View File

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import ActivityStreamList, ActivityStreamDetail from awx.api.views import ActivityStreamList, ActivityStreamDetail
urls = [ urls = [
re_path(r'^$', ActivityStreamList.as_view(), name='activity_stream_list'), re_path(r'^$', ActivityStreamList.as_view(), name='activity_stream_list'),
re_path(r'^(?P<pk>[0-9]+)/$', ActivityStreamDetail.as_view(), name='activity_stream_detail'), re_path(r'^(?P<pk>[0-9]+)/$', ActivityStreamDetail.as_view(), name='activity_stream_detail'),

View File

@@ -14,6 +14,7 @@ from awx.api.views import (
AdHocCommandStdout, AdHocCommandStdout,
) )
urls = [ urls = [
re_path(r'^$', AdHocCommandList.as_view(), name='ad_hoc_command_list'), re_path(r'^$', AdHocCommandList.as_view(), name='ad_hoc_command_list'),
re_path(r'^(?P<pk>[0-9]+)/$', AdHocCommandDetail.as_view(), name='ad_hoc_command_detail'), re_path(r'^(?P<pk>[0-9]+)/$', AdHocCommandDetail.as_view(), name='ad_hoc_command_detail'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import AdHocCommandEventDetail from awx.api.views import AdHocCommandEventDetail
urls = [ urls = [
re_path(r'^(?P<pk>[0-9]+)/$', AdHocCommandEventDetail.as_view(), name='ad_hoc_command_event_detail'), re_path(r'^(?P<pk>[0-9]+)/$', AdHocCommandEventDetail.as_view(), name='ad_hoc_command_event_detail'),
] ]

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
import awx.api.views.analytics as analytics import awx.api.views.analytics as analytics
urls = [ urls = [
re_path(r'^$', analytics.AnalyticsRootView.as_view(), name='analytics_root_view'), re_path(r'^$', analytics.AnalyticsRootView.as_view(), name='analytics_root_view'),
re_path(r'^authorized/$', analytics.AnalyticsAuthorizedView.as_view(), name='analytics_authorized'), re_path(r'^authorized/$', analytics.AnalyticsAuthorizedView.as_view(), name='analytics_authorized'),

View File

@@ -16,6 +16,7 @@ from awx.api.views import (
CredentialExternalTest, CredentialExternalTest,
) )
urls = [ urls = [
re_path(r'^$', CredentialList.as_view(), name='credential_list'), re_path(r'^$', CredentialList.as_view(), name='credential_list'),
re_path(r'^(?P<pk>[0-9]+)/activity_stream/$', CredentialActivityStreamList.as_view(), name='credential_activity_stream_list'), re_path(r'^(?P<pk>[0-9]+)/activity_stream/$', CredentialActivityStreamList.as_view(), name='credential_activity_stream_list'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import CredentialInputSourceDetail, CredentialInputSourceList from awx.api.views import CredentialInputSourceDetail, CredentialInputSourceList
urls = [ urls = [
re_path(r'^$', CredentialInputSourceList.as_view(), name='credential_input_source_list'), re_path(r'^$', CredentialInputSourceList.as_view(), name='credential_input_source_list'),
re_path(r'^(?P<pk>[0-9]+)/$', CredentialInputSourceDetail.as_view(), name='credential_input_source_detail'), re_path(r'^(?P<pk>[0-9]+)/$', CredentialInputSourceDetail.as_view(), name='credential_input_source_detail'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import CredentialTypeList, CredentialTypeDetail, CredentialTypeCredentialList, CredentialTypeActivityStreamList, CredentialTypeExternalTest from awx.api.views import CredentialTypeList, CredentialTypeDetail, CredentialTypeCredentialList, CredentialTypeActivityStreamList, CredentialTypeExternalTest
urls = [ urls = [
re_path(r'^$', CredentialTypeList.as_view(), name='credential_type_list'), re_path(r'^$', CredentialTypeList.as_view(), name='credential_type_list'),
re_path(r'^(?P<pk>[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'), re_path(r'^(?P<pk>[0-9]+)/$', CredentialTypeDetail.as_view(), name='credential_type_detail'),

View File

@@ -8,6 +8,7 @@ from awx.api.views import (
ExecutionEnvironmentActivityStreamList, ExecutionEnvironmentActivityStreamList,
) )
urls = [ urls = [
re_path(r'^$', ExecutionEnvironmentList.as_view(), name='execution_environment_list'), re_path(r'^$', ExecutionEnvironmentList.as_view(), name='execution_environment_list'),
re_path(r'^(?P<pk>[0-9]+)/$', ExecutionEnvironmentDetail.as_view(), name='execution_environment_detail'), re_path(r'^(?P<pk>[0-9]+)/$', ExecutionEnvironmentDetail.as_view(), name='execution_environment_detail'),

View File

@@ -18,6 +18,7 @@ from awx.api.views import (
GroupAdHocCommandsList, GroupAdHocCommandsList,
) )
urls = [ urls = [
re_path(r'^$', GroupList.as_view(), name='group_list'), re_path(r'^$', GroupList.as_view(), name='group_list'),
re_path(r'^(?P<pk>[0-9]+)/$', GroupDetail.as_view(), name='group_detail'), re_path(r'^(?P<pk>[0-9]+)/$', GroupDetail.as_view(), name='group_detail'),

View File

@@ -18,6 +18,7 @@ from awx.api.views import (
HostAdHocCommandEventsList, HostAdHocCommandEventsList,
) )
urls = [ urls = [
re_path(r'^$', HostList.as_view(), name='host_list'), re_path(r'^$', HostList.as_view(), name='host_list'),
re_path(r'^(?P<pk>[0-9]+)/$', HostDetail.as_view(), name='host_detail'), re_path(r'^(?P<pk>[0-9]+)/$', HostDetail.as_view(), name='host_detail'),

View File

@@ -14,6 +14,7 @@ from awx.api.views import (
) )
from awx.api.views.instance_install_bundle import InstanceInstallBundle from awx.api.views.instance_install_bundle import InstanceInstallBundle
urls = [ urls = [
re_path(r'^$', InstanceList.as_view(), name='instance_list'), re_path(r'^$', InstanceList.as_view(), name='instance_list'),
re_path(r'^(?P<pk>[0-9]+)/$', InstanceDetail.as_view(), name='instance_detail'), re_path(r'^(?P<pk>[0-9]+)/$', InstanceDetail.as_view(), name='instance_detail'),

View File

@@ -12,6 +12,7 @@ from awx.api.views import (
InstanceGroupObjectRolesList, InstanceGroupObjectRolesList,
) )
urls = [ urls = [
re_path(r'^$', InstanceGroupList.as_view(), name='instance_group_list'), re_path(r'^$', InstanceGroupList.as_view(), name='instance_group_list'),
re_path(r'^(?P<pk>[0-9]+)/$', InstanceGroupDetail.as_view(), name='instance_group_detail'), re_path(r'^(?P<pk>[0-9]+)/$', InstanceGroupDetail.as_view(), name='instance_group_detail'),

View File

@@ -29,6 +29,7 @@ from awx.api.views import (
InventoryVariableData, InventoryVariableData,
) )
urls = [ urls = [
re_path(r'^$', InventoryList.as_view(), name='inventory_list'), re_path(r'^$', InventoryList.as_view(), name='inventory_list'),
re_path(r'^(?P<pk>[0-9]+)/$', InventoryDetail.as_view(), name='inventory_detail'), re_path(r'^(?P<pk>[0-9]+)/$', InventoryDetail.as_view(), name='inventory_detail'),

View File

@@ -18,6 +18,7 @@ from awx.api.views import (
InventorySourceNotificationTemplatesSuccessList, InventorySourceNotificationTemplatesSuccessList,
) )
urls = [ urls = [
re_path(r'^$', InventorySourceList.as_view(), name='inventory_source_list'), re_path(r'^$', InventorySourceList.as_view(), name='inventory_source_list'),
re_path(r'^(?P<pk>[0-9]+)/$', InventorySourceDetail.as_view(), name='inventory_source_detail'), re_path(r'^(?P<pk>[0-9]+)/$', InventorySourceDetail.as_view(), name='inventory_source_detail'),

View File

@@ -15,6 +15,7 @@ from awx.api.views import (
InventoryUpdateCredentialsList, InventoryUpdateCredentialsList,
) )
urls = [ urls = [
re_path(r'^$', InventoryUpdateList.as_view(), name='inventory_update_list'), re_path(r'^$', InventoryUpdateList.as_view(), name='inventory_update_list'),
re_path(r'^(?P<pk>[0-9]+)/$', InventoryUpdateDetail.as_view(), name='inventory_update_detail'), re_path(r'^(?P<pk>[0-9]+)/$', InventoryUpdateDetail.as_view(), name='inventory_update_detail'),

View File

@@ -19,6 +19,7 @@ from awx.api.views import (
JobHostSummaryDetail, JobHostSummaryDetail,
) )
urls = [ urls = [
re_path(r'^$', JobList.as_view(), name='job_list'), re_path(r'^$', JobList.as_view(), name='job_list'),
re_path(r'^(?P<pk>[0-9]+)/$', JobDetail.as_view(), name='job_detail'), re_path(r'^(?P<pk>[0-9]+)/$', JobDetail.as_view(), name='job_detail'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import JobHostSummaryDetail from awx.api.views import JobHostSummaryDetail
urls = [re_path(r'^(?P<pk>[0-9]+)/$', JobHostSummaryDetail.as_view(), name='job_host_summary_detail')] urls = [re_path(r'^(?P<pk>[0-9]+)/$', JobHostSummaryDetail.as_view(), name='job_host_summary_detail')]
__all__ = ['urls'] __all__ = ['urls']

View File

@@ -23,6 +23,7 @@ from awx.api.views import (
JobTemplateCopy, JobTemplateCopy,
) )
urls = [ urls = [
re_path(r'^$', JobTemplateList.as_view(), name='job_template_list'), re_path(r'^$', JobTemplateList.as_view(), name='job_template_list'),
re_path(r'^(?P<pk>[0-9]+)/$', JobTemplateDetail.as_view(), name='job_template_detail'), re_path(r'^(?P<pk>[0-9]+)/$', JobTemplateDetail.as_view(), name='job_template_detail'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views.labels import LabelList, LabelDetail from awx.api.views.labels import LabelList, LabelDetail
urls = [re_path(r'^$', LabelList.as_view(), name='label_list'), re_path(r'^(?P<pk>[0-9]+)/$', LabelDetail.as_view(), name='label_detail')] urls = [re_path(r'^$', LabelList.as_view(), name='label_list'), re_path(r'^(?P<pk>[0-9]+)/$', LabelDetail.as_view(), name='label_detail')]
__all__ = ['urls'] __all__ = ['urls']

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import NotificationList, NotificationDetail from awx.api.views import NotificationList, NotificationDetail
urls = [ urls = [
re_path(r'^$', NotificationList.as_view(), name='notification_list'), re_path(r'^$', NotificationList.as_view(), name='notification_list'),
re_path(r'^(?P<pk>[0-9]+)/$', NotificationDetail.as_view(), name='notification_detail'), re_path(r'^(?P<pk>[0-9]+)/$', NotificationDetail.as_view(), name='notification_detail'),

View File

@@ -11,6 +11,7 @@ from awx.api.views import (
NotificationTemplateCopy, NotificationTemplateCopy,
) )
urls = [ urls = [
re_path(r'^$', NotificationTemplateList.as_view(), name='notification_template_list'), re_path(r'^$', NotificationTemplateList.as_view(), name='notification_template_list'),
re_path(r'^(?P<pk>[0-9]+)/$', NotificationTemplateDetail.as_view(), name='notification_template_detail'), re_path(r'^(?P<pk>[0-9]+)/$', NotificationTemplateDetail.as_view(), name='notification_template_detail'),

View File

@@ -27,6 +27,7 @@ from awx.api.views.organization import (
) )
from awx.api.views import OrganizationCredentialList from awx.api.views import OrganizationCredentialList
urls = [ urls = [
re_path(r'^$', OrganizationList.as_view(), name='organization_list'), re_path(r'^$', OrganizationList.as_view(), name='organization_list'),
re_path(r'^(?P<pk>[0-9]+)/$', OrganizationDetail.as_view(), name='organization_detail'), re_path(r'^(?P<pk>[0-9]+)/$', OrganizationDetail.as_view(), name='organization_detail'),

View File

@@ -22,6 +22,7 @@ from awx.api.views import (
ProjectCopy, ProjectCopy,
) )
urls = [ urls = [
re_path(r'^$', ProjectList.as_view(), name='project_list'), re_path(r'^$', ProjectList.as_view(), name='project_list'),
re_path(r'^(?P<pk>[0-9]+)/$', ProjectDetail.as_view(), name='project_detail'), re_path(r'^(?P<pk>[0-9]+)/$', ProjectDetail.as_view(), name='project_detail'),

View File

@@ -13,6 +13,7 @@ from awx.api.views import (
ProjectUpdateEventsList, ProjectUpdateEventsList,
) )
urls = [ urls = [
re_path(r'^$', ProjectUpdateList.as_view(), name='project_update_list'), re_path(r'^$', ProjectUpdateList.as_view(), name='project_update_list'),
re_path(r'^(?P<pk>[0-9]+)/$', ProjectUpdateDetail.as_view(), name='project_update_detail'), re_path(r'^(?P<pk>[0-9]+)/$', ProjectUpdateDetail.as_view(), name='project_update_detail'),

View File

@@ -8,6 +8,7 @@ from awx.api.views import (
ReceptorAddressDetail, ReceptorAddressDetail,
) )
urls = [ urls = [
re_path(r'^$', ReceptorAddressesList.as_view(), name='receptor_addresses_list'), re_path(r'^$', ReceptorAddressesList.as_view(), name='receptor_addresses_list'),
re_path(r'^(?P<pk>[0-9]+)/$', ReceptorAddressDetail.as_view(), name='receptor_address_detail'), re_path(r'^(?P<pk>[0-9]+)/$', ReceptorAddressDetail.as_view(), name='receptor_address_detail'),

View File

@@ -3,13 +3,16 @@
from django.urls import re_path from django.urls import re_path
from awx.api.views import RoleList, RoleDetail, RoleUsersList, RoleTeamsList from awx.api.views import RoleList, RoleDetail, RoleUsersList, RoleTeamsList, RoleParentsList, RoleChildrenList
urls = [ urls = [
re_path(r'^$', RoleList.as_view(), name='role_list'), re_path(r'^$', RoleList.as_view(), name='role_list'),
re_path(r'^(?P<pk>[0-9]+)/$', RoleDetail.as_view(), name='role_detail'), re_path(r'^(?P<pk>[0-9]+)/$', RoleDetail.as_view(), name='role_detail'),
re_path(r'^(?P<pk>[0-9]+)/users/$', RoleUsersList.as_view(), name='role_users_list'), re_path(r'^(?P<pk>[0-9]+)/users/$', RoleUsersList.as_view(), name='role_users_list'),
re_path(r'^(?P<pk>[0-9]+)/teams/$', RoleTeamsList.as_view(), name='role_teams_list'), re_path(r'^(?P<pk>[0-9]+)/teams/$', RoleTeamsList.as_view(), name='role_teams_list'),
re_path(r'^(?P<pk>[0-9]+)/parents/$', RoleParentsList.as_view(), name='role_parents_list'),
re_path(r'^(?P<pk>[0-9]+)/children/$', RoleChildrenList.as_view(), name='role_children_list'),
] ]
__all__ = ['urls'] __all__ = ['urls']

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import ScheduleList, ScheduleDetail, ScheduleUnifiedJobsList, ScheduleCredentialsList, ScheduleLabelsList, ScheduleInstanceGroupList from awx.api.views import ScheduleList, ScheduleDetail, ScheduleUnifiedJobsList, ScheduleCredentialsList, ScheduleLabelsList, ScheduleInstanceGroupList
urls = [ urls = [
re_path(r'^$', ScheduleList.as_view(), name='schedule_list'), re_path(r'^$', ScheduleList.as_view(), name='schedule_list'),
re_path(r'^(?P<pk>[0-9]+)/$', ScheduleDetail.as_view(), name='schedule_detail'), re_path(r'^(?P<pk>[0-9]+)/$', ScheduleDetail.as_view(), name='schedule_detail'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import SystemJobList, SystemJobDetail, SystemJobCancel, SystemJobNotificationsList, SystemJobEventsList from awx.api.views import SystemJobList, SystemJobDetail, SystemJobCancel, SystemJobNotificationsList, SystemJobEventsList
urls = [ urls = [
re_path(r'^$', SystemJobList.as_view(), name='system_job_list'), re_path(r'^$', SystemJobList.as_view(), name='system_job_list'),
re_path(r'^(?P<pk>[0-9]+)/$', SystemJobDetail.as_view(), name='system_job_detail'), re_path(r'^(?P<pk>[0-9]+)/$', SystemJobDetail.as_view(), name='system_job_detail'),

View File

@@ -14,6 +14,7 @@ from awx.api.views import (
SystemJobTemplateNotificationTemplatesSuccessList, SystemJobTemplateNotificationTemplatesSuccessList,
) )
urls = [ urls = [
re_path(r'^$', SystemJobTemplateList.as_view(), name='system_job_template_list'), re_path(r'^$', SystemJobTemplateList.as_view(), name='system_job_template_list'),
re_path(r'^(?P<pk>[0-9]+)/$', SystemJobTemplateDetail.as_view(), name='system_job_template_detail'), re_path(r'^(?P<pk>[0-9]+)/$', SystemJobTemplateDetail.as_view(), name='system_job_template_detail'),

View File

@@ -15,6 +15,7 @@ from awx.api.views import (
TeamAccessList, TeamAccessList,
) )
urls = [ urls = [
re_path(r'^$', TeamList.as_view(), name='team_list'), re_path(r'^$', TeamList.as_view(), name='team_list'),
re_path(r'^(?P<pk>[0-9]+)/$', TeamDetail.as_view(), name='team_detail'), re_path(r'^(?P<pk>[0-9]+)/$', TeamDetail.as_view(), name='team_detail'),

View File

@@ -4,6 +4,7 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from django.urls import include, re_path from django.urls import include, re_path
from awx import MODE
from awx.api.generics import LoggedLoginView, LoggedLogoutView from awx.api.generics import LoggedLoginView, LoggedLogoutView
from awx.api.views.root import ( from awx.api.views.root import (
ApiRootView, ApiRootView,
@@ -147,15 +148,21 @@ v2_urls = [
app_name = 'api' app_name = 'api'
urlpatterns = [ urlpatterns = [
re_path(r'^$', ApiRootView.as_view(), name='api_root_view'), re_path(r'^$', ApiRootView.as_view(), name='api_root_view'),
re_path(r'^(?P<version>(v2))/', include(v2_urls)), re_path(r'^(?P<version>(v2))/', include(v2_urls)),
re_path(r'^login/$', LoggedLoginView.as_view(template_name='rest_framework/login.html', extra_context={'inside_login_context': True}), name='login'), re_path(r'^login/$', LoggedLoginView.as_view(template_name='rest_framework/login.html', extra_context={'inside_login_context': True}), name='login'),
re_path(r'^logout/$', LoggedLogoutView.as_view(next_page='/api/', redirect_field_name='next'), name='logout'), re_path(r'^logout/$', LoggedLogoutView.as_view(next_page='/api/', redirect_field_name='next'), name='logout'),
# the docs/, schema-related endpoints used to be listed here but now exposed by DAB api_documentation app
] ]
if MODE == 'development':
# Only include these if we are in the development environment
from awx.api.swagger import schema_view
from awx.api.urls.debug import urls as debug_urls from awx.api.urls.debug import urls as debug_urls
urlpatterns += [re_path(r'^debug/', include(debug_urls))] urlpatterns += [re_path(r'^debug/', include(debug_urls))]
urlpatterns += [
re_path(r'^swagger(?P<format>\.json|\.yaml)/$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
]

View File

@@ -2,6 +2,7 @@ from django.urls import re_path
from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver, BitbucketDcWebhookReceiver from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver, BitbucketDcWebhookReceiver
urlpatterns = [ urlpatterns = [
re_path(r'^webhook_key/$', WebhookKeyView.as_view(), name='webhook_key'), re_path(r'^webhook_key/$', WebhookKeyView.as_view(), name='webhook_key'),
re_path(r'^github/$', GithubWebhookReceiver.as_view(), name='webhook_receiver_github'), re_path(r'^github/$', GithubWebhookReceiver.as_view(), name='webhook_receiver_github'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import WorkflowApprovalList, WorkflowApprovalDetail, WorkflowApprovalApprove, WorkflowApprovalDeny from awx.api.views import WorkflowApprovalList, WorkflowApprovalDetail, WorkflowApprovalApprove, WorkflowApprovalDeny
urls = [ urls = [
re_path(r'^$', WorkflowApprovalList.as_view(), name='workflow_approval_list'), re_path(r'^$', WorkflowApprovalList.as_view(), name='workflow_approval_list'),
re_path(r'^(?P<pk>[0-9]+)/$', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'), re_path(r'^(?P<pk>[0-9]+)/$', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'),

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.api.views import WorkflowApprovalTemplateDetail, WorkflowApprovalTemplateJobsList from awx.api.views import WorkflowApprovalTemplateDetail, WorkflowApprovalTemplateJobsList
urls = [ urls = [
re_path(r'^(?P<pk>[0-9]+)/$', WorkflowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), re_path(r'^(?P<pk>[0-9]+)/$', WorkflowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'),
re_path(r'^(?P<pk>[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'), re_path(r'^(?P<pk>[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'),

View File

@@ -14,6 +14,7 @@ from awx.api.views import (
WorkflowJobActivityStreamList, WorkflowJobActivityStreamList,
) )
urls = [ urls = [
re_path(r'^$', WorkflowJobList.as_view(), name='workflow_job_list'), re_path(r'^$', WorkflowJobList.as_view(), name='workflow_job_list'),
re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobDetail.as_view(), name='workflow_job_detail'), re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobDetail.as_view(), name='workflow_job_detail'),

View File

@@ -14,6 +14,7 @@ from awx.api.views import (
WorkflowJobNodeInstanceGroupsList, WorkflowJobNodeInstanceGroupsList,
) )
urls = [ urls = [
re_path(r'^$', WorkflowJobNodeList.as_view(), name='workflow_job_node_list'), re_path(r'^$', WorkflowJobNodeList.as_view(), name='workflow_job_node_list'),
re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobNodeDetail.as_view(), name='workflow_job_node_detail'), re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobNodeDetail.as_view(), name='workflow_job_node_detail'),

View File

@@ -22,6 +22,7 @@ from awx.api.views import (
WorkflowJobTemplateLabelList, WorkflowJobTemplateLabelList,
) )
urls = [ urls = [
re_path(r'^$', WorkflowJobTemplateList.as_view(), name='workflow_job_template_list'), re_path(r'^$', WorkflowJobTemplateList.as_view(), name='workflow_job_template_list'),
re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobTemplateDetail.as_view(), name='workflow_job_template_detail'), re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobTemplateDetail.as_view(), name='workflow_job_template_detail'),

View File

@@ -15,6 +15,7 @@ from awx.api.views import (
WorkflowJobTemplateNodeInstanceGroupsList, WorkflowJobTemplateNodeInstanceGroupsList,
) )
urls = [ urls = [
re_path(r'^$', WorkflowJobTemplateNodeList.as_view(), name='workflow_job_template_node_list'), re_path(r'^$', WorkflowJobTemplateNodeList.as_view(), name='workflow_job_template_node_list'),
re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobTemplateNodeDetail.as_view(), name='workflow_job_template_node_detail'), re_path(r'^(?P<pk>[0-9]+)/$', WorkflowJobTemplateNodeDetail.as_view(), name='workflow_job_template_node_detail'),

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,6 @@ from rest_framework import status
from collections import OrderedDict from collections import OrderedDict
from ansible_base.lib.utils.schema import extend_schema_if_available
AUTOMATION_ANALYTICS_API_URL_PATH = "/api/tower-analytics/v1" AUTOMATION_ANALYTICS_API_URL_PATH = "/api/tower-analytics/v1"
AWX_ANALYTICS_API_PREFIX = 'analytics' AWX_ANALYTICS_API_PREFIX = 'analytics'
@@ -40,8 +38,6 @@ class MissingSettings(Exception):
class GetNotAllowedMixin(object): class GetNotAllowedMixin(object):
skip_ai_description = True
def get(self, request, format=None): def get(self, request, format=None):
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
@@ -49,9 +45,8 @@ class GetNotAllowedMixin(object):
class AnalyticsRootView(APIView): class AnalyticsRootView(APIView):
permission_classes = (AnalyticsPermission,) permission_classes = (AnalyticsPermission,)
name = _('Automation Analytics') name = _('Automation Analytics')
resource_purpose = 'automation analytics endpoints' swagger_topic = 'Automation Analytics'
@extend_schema_if_available(extensions={"x-ai-description": "A list of additional API endpoints related to analytics"})
def get(self, request, format=None): def get(self, request, format=None):
data = OrderedDict() data = OrderedDict()
data['authorized'] = reverse('api:analytics_authorized', request=request) data['authorized'] = reverse('api:analytics_authorized', request=request)
@@ -104,8 +99,6 @@ class AnalyticsGenericView(APIView):
return Response(response.json(), status=response.status_code) return Response(response.json(), status=response.status_code)
""" """
resource_purpose = 'base view for analytics api proxy'
permission_classes = (AnalyticsPermission,) permission_classes = (AnalyticsPermission,)
@staticmethod @staticmethod
@@ -264,90 +257,67 @@ class AnalyticsGenericView(APIView):
class AnalyticsGenericListView(AnalyticsGenericView): class AnalyticsGenericListView(AnalyticsGenericView):
resource_purpose = 'analytics api proxy list view'
@extend_schema_if_available(extensions={"x-ai-description": "Get analytics data from Red Hat Insights"})
def get(self, request, format=None): def get(self, request, format=None):
return self._send_to_analytics(request, method="GET") return self._send_to_analytics(request, method="GET")
@extend_schema_if_available(extensions={"x-ai-description": "Post query to Red Hat Insights analytics"})
def post(self, request, format=None): def post(self, request, format=None):
return self._send_to_analytics(request, method="POST") return self._send_to_analytics(request, method="POST")
@extend_schema_if_available(extensions={"x-ai-description": "Get analytics endpoint options"})
def options(self, request, format=None): def options(self, request, format=None):
return self._send_to_analytics(request, method="OPTIONS") return self._send_to_analytics(request, method="OPTIONS")
class AnalyticsGenericDetailView(AnalyticsGenericView): class AnalyticsGenericDetailView(AnalyticsGenericView):
resource_purpose = 'analytics api proxy detail view'
@extend_schema_if_available(extensions={"x-ai-description": "Get specific analytics resource from Red Hat Insights"})
def get(self, request, slug, format=None): def get(self, request, slug, format=None):
return self._send_to_analytics(request, method="GET") return self._send_to_analytics(request, method="GET")
@extend_schema_if_available(extensions={"x-ai-description": "Post query for specific analytics resource to Red Hat Insights"})
def post(self, request, slug, format=None): def post(self, request, slug, format=None):
return self._send_to_analytics(request, method="POST") return self._send_to_analytics(request, method="POST")
@extend_schema_if_available(extensions={"x-ai-description": "Get options for specific analytics resource"})
def options(self, request, slug, format=None): def options(self, request, slug, format=None):
return self._send_to_analytics(request, method="OPTIONS") return self._send_to_analytics(request, method="OPTIONS")
@extend_schema_if_available(
extensions={'x-ai-description': 'Check if the user has access to Red Hat Insights'},
)
class AnalyticsAuthorizedView(AnalyticsGenericListView): class AnalyticsAuthorizedView(AnalyticsGenericListView):
name = _("Authorized") name = _("Authorized")
resource_purpose = 'red hat insights authorization status'
class AnalyticsReportsList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsReportsList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Reports") name = _("Reports")
resource_purpose = 'automation analytics reports' swagger_topic = "Automation Analytics"
class AnalyticsReportDetail(AnalyticsGenericDetailView): class AnalyticsReportDetail(AnalyticsGenericDetailView):
name = _("Report") name = _("Report")
resource_purpose = 'automation analytics report detail'
class AnalyticsReportOptionsList(AnalyticsGenericListView): class AnalyticsReportOptionsList(AnalyticsGenericListView):
name = _("Report Options") name = _("Report Options")
resource_purpose = 'automation analytics report options'
class AnalyticsAdoptionRateList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsAdoptionRateList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Adoption Rate") name = _("Adoption Rate")
resource_purpose = 'automation analytics adoption rate data'
class AnalyticsEventExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsEventExplorerList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Event Explorer") name = _("Event Explorer")
resource_purpose = 'automation analytics event explorer data'
class AnalyticsHostExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsHostExplorerList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Host Explorer") name = _("Host Explorer")
resource_purpose = 'automation analytics host explorer data'
class AnalyticsJobExplorerList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsJobExplorerList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Job Explorer") name = _("Job Explorer")
resource_purpose = 'automation analytics job explorer data'
class AnalyticsProbeTemplatesList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsProbeTemplatesList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Probe Templates") name = _("Probe Templates")
resource_purpose = 'automation analytics probe templates'
class AnalyticsProbeTemplateForHostsList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsProbeTemplateForHostsList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("Probe Template For Hosts") name = _("Probe Template For Hosts")
resource_purpose = 'automation analytics probe templates for hosts'
class AnalyticsRoiTemplatesList(GetNotAllowedMixin, AnalyticsGenericListView): class AnalyticsRoiTemplatesList(GetNotAllowedMixin, AnalyticsGenericListView):
name = _("ROI Templates") name = _("ROI Templates")
resource_purpose = 'automation analytics roi templates'

View File

@@ -1,7 +1,5 @@
from collections import OrderedDict from collections import OrderedDict
from ansible_base.lib.utils.schema import extend_schema_if_available
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -32,7 +30,6 @@ class BulkView(APIView):
] ]
allowed_methods = ['GET', 'OPTIONS'] allowed_methods = ['GET', 'OPTIONS']
@extend_schema_if_available(extensions={"x-ai-description": "Retrieves a list of available bulk actions"})
def get(self, request, format=None): def get(self, request, format=None):
'''List top level resources''' '''List top level resources'''
data = OrderedDict() data = OrderedDict()
@@ -48,13 +45,11 @@ class BulkJobLaunchView(GenericAPIView):
serializer_class = serializers.BulkJobLaunchSerializer serializer_class = serializers.BulkJobLaunchSerializer
allowed_methods = ['GET', 'POST', 'OPTIONS'] allowed_methods = ['GET', 'POST', 'OPTIONS']
@extend_schema_if_available(extensions={"x-ai-description": "Get information about bulk job launch endpoint"})
def get(self, request): def get(self, request):
data = OrderedDict() data = OrderedDict()
data['detail'] = "Specify a list of unified job templates to launch alongside their launchtime parameters" data['detail'] = "Specify a list of unified job templates to launch alongside their launchtime parameters"
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@extend_schema_if_available(extensions={"x-ai-description": "Bulk launch job templates"})
def post(self, request): def post(self, request):
bulkjob_serializer = serializers.BulkJobLaunchSerializer(data=request.data, context={'request': request}) bulkjob_serializer = serializers.BulkJobLaunchSerializer(data=request.data, context={'request': request})
if bulkjob_serializer.is_valid(): if bulkjob_serializer.is_valid():
@@ -69,11 +64,9 @@ class BulkHostCreateView(GenericAPIView):
serializer_class = serializers.BulkHostCreateSerializer serializer_class = serializers.BulkHostCreateSerializer
allowed_methods = ['GET', 'POST', 'OPTIONS'] allowed_methods = ['GET', 'POST', 'OPTIONS']
@extend_schema_if_available(extensions={"x-ai-description": "Get information about bulk host create endpoint"})
def get(self, request): def get(self, request):
return Response({"detail": "Bulk create hosts with this endpoint"}, status=status.HTTP_200_OK) return Response({"detail": "Bulk create hosts with this endpoint"}, status=status.HTTP_200_OK)
@extend_schema_if_available(extensions={"x-ai-description": "Bulk create hosts"})
def post(self, request): def post(self, request):
serializer = serializers.BulkHostCreateSerializer(data=request.data, context={'request': request}) serializer = serializers.BulkHostCreateSerializer(data=request.data, context={'request': request})
if serializer.is_valid(): if serializer.is_valid():
@@ -88,11 +81,9 @@ class BulkHostDeleteView(GenericAPIView):
serializer_class = serializers.BulkHostDeleteSerializer serializer_class = serializers.BulkHostDeleteSerializer
allowed_methods = ['GET', 'POST', 'OPTIONS'] allowed_methods = ['GET', 'POST', 'OPTIONS']
@extend_schema_if_available(extensions={"x-ai-description": "Get information about bulk host delete endpoint"})
def get(self, request): def get(self, request):
return Response({"detail": "Bulk delete hosts with this endpoint"}, status=status.HTTP_200_OK) return Response({"detail": "Bulk delete hosts with this endpoint"}, status=status.HTTP_200_OK)
@extend_schema_if_available(extensions={"x-ai-description": "Bulk delete hosts"})
def post(self, request): def post(self, request):
serializer = serializers.BulkHostDeleteSerializer(data=request.data, context={'request': request}) serializer = serializers.BulkHostDeleteSerializer(data=request.data, context={'request': request})
if serializer.is_valid(): if serializer.is_valid():

View File

@@ -5,7 +5,6 @@ from django.conf import settings
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from awx.api.generics import APIView from awx.api.generics import APIView
from ansible_base.lib.utils.schema import extend_schema_if_available
from awx.main.scheduler import TaskManager, DependencyManager, WorkflowManager from awx.main.scheduler import TaskManager, DependencyManager, WorkflowManager
@@ -15,9 +14,7 @@ class TaskManagerDebugView(APIView):
exclude_from_schema = True exclude_from_schema = True
permission_classes = [AllowAny] permission_classes = [AllowAny]
prefix = 'Task' prefix = 'Task'
resource_purpose = 'debug task manager'
@extend_schema_if_available(extensions={"x-ai-description": "Trigger task manager scheduling"})
def get(self, request): def get(self, request):
TaskManager().schedule() TaskManager().schedule()
if not settings.AWX_DISABLE_TASK_MANAGERS: if not settings.AWX_DISABLE_TASK_MANAGERS:
@@ -32,9 +29,7 @@ class DependencyManagerDebugView(APIView):
exclude_from_schema = True exclude_from_schema = True
permission_classes = [AllowAny] permission_classes = [AllowAny]
prefix = 'Dependency' prefix = 'Dependency'
resource_purpose = 'debug dependency manager'
@extend_schema_if_available(extensions={"x-ai-description": "Trigger dependency manager scheduling"})
def get(self, request): def get(self, request):
DependencyManager().schedule() DependencyManager().schedule()
if not settings.AWX_DISABLE_TASK_MANAGERS: if not settings.AWX_DISABLE_TASK_MANAGERS:
@@ -49,9 +44,7 @@ class WorkflowManagerDebugView(APIView):
exclude_from_schema = True exclude_from_schema = True
permission_classes = [AllowAny] permission_classes = [AllowAny]
prefix = 'Workflow' prefix = 'Workflow'
resource_purpose = 'debug workflow manager'
@extend_schema_if_available(extensions={"x-ai-description": "Trigger workflow manager scheduling"})
def get(self, request): def get(self, request):
WorkflowManager().schedule() WorkflowManager().schedule()
if not settings.AWX_DISABLE_TASK_MANAGERS: if not settings.AWX_DISABLE_TASK_MANAGERS:
@@ -65,9 +58,7 @@ class DebugRootView(APIView):
_ignore_model_permissions = True _ignore_model_permissions = True
exclude_from_schema = True exclude_from_schema = True
permission_classes = [AllowAny] permission_classes = [AllowAny]
resource_purpose = 'debug endpoints root'
@extend_schema_if_available(extensions={"x-ai-description": "List available debug endpoints"})
def get(self, request, format=None): def get(self, request, format=None):
'''List of available debug urls''' '''List of available debug urls'''
data = OrderedDict() data = OrderedDict()

View File

@@ -10,10 +10,9 @@ import time
import re import re
import asn1 import asn1
from ansible_base.lib.utils.schema import extend_schema_if_available
from awx.api import serializers from awx.api import serializers
from awx.api.generics import GenericAPIView, Response from awx.api.generics import GenericAPIView, Response
from awx.api.permissions import IsSystemAdmin from awx.api.permissions import IsSystemAdminOrAuditor
from awx.main import models from awx.main import models
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
@@ -49,10 +48,8 @@ class InstanceInstallBundle(GenericAPIView):
name = _('Install Bundle') name = _('Install Bundle')
model = models.Instance model = models.Instance
serializer_class = serializers.InstanceSerializer serializer_class = serializers.InstanceSerializer
permission_classes = (IsSystemAdmin,) permission_classes = (IsSystemAdminOrAuditor,)
resource_purpose = 'install bundle'
@extend_schema_if_available(extensions={"x-ai-description": "Generate and download install bundle for an instance"})
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
instance_obj = self.get_object() instance_obj = self.get_object()
@@ -198,8 +195,8 @@ def generate_receptor_tls(instance_obj):
.issuer_name(ca_cert.issuer) .issuer_name(ca_cert.issuer)
.public_key(csr.public_key()) .public_key(csr.public_key())
.serial_number(x509.random_serial_number()) .serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.UTC)) .not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=3650)) .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
.add_extension( .add_extension(
csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value, csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value,
critical=csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical, critical=csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical,

View File

@@ -19,8 +19,6 @@ from rest_framework import serializers
# AWX # AWX
from awx.main.models import ActivityStream, Inventory, JobTemplate, Role, User, InstanceGroup, InventoryUpdateEvent, InventoryUpdate from awx.main.models import ActivityStream, Inventory, JobTemplate, Role, User, InstanceGroup, InventoryUpdateEvent, InventoryUpdate
from ansible_base.lib.utils.schema import extend_schema_if_available
from awx.api.generics import ( from awx.api.generics import (
ListCreateAPIView, ListCreateAPIView,
RetrieveUpdateDestroyAPIView, RetrieveUpdateDestroyAPIView,
@@ -45,6 +43,7 @@ from awx.api.views.mixin import RelatedJobsPreventDeleteMixin
from awx.api.pagination import UnifiedJobEventPagination from awx.api.pagination import UnifiedJobEventPagination
logger = logging.getLogger('awx.api.views.organization') logger = logging.getLogger('awx.api.views.organization')
@@ -56,7 +55,6 @@ class InventoryUpdateEventsList(SubListAPIView):
name = _('Inventory Update Events List') name = _('Inventory Update Events List')
search_fields = ('stdout',) search_fields = ('stdout',)
pagination_class = UnifiedJobEventPagination pagination_class = UnifiedJobEventPagination
resource_purpose = 'events of an inventory update'
def get_queryset(self): def get_queryset(self):
iu = self.get_parent_object() iu = self.get_parent_object()
@@ -71,17 +69,11 @@ class InventoryUpdateEventsList(SubListAPIView):
class InventoryList(ListCreateAPIView): class InventoryList(ListCreateAPIView):
model = Inventory model = Inventory
serializer_class = InventorySerializer serializer_class = InventorySerializer
resource_purpose = 'inventories'
@extend_schema_if_available(extensions={"x-ai-description": "A list of inventories."})
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = Inventory model = Inventory
serializer_class = InventorySerializer serializer_class = InventorySerializer
resource_purpose = 'inventory detail'
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
@@ -108,39 +100,33 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie
class ConstructedInventoryDetail(InventoryDetail): class ConstructedInventoryDetail(InventoryDetail):
serializer_class = ConstructedInventorySerializer serializer_class = ConstructedInventorySerializer
resource_purpose = 'constructed inventory detail'
class ConstructedInventoryList(InventoryList): class ConstructedInventoryList(InventoryList):
serializer_class = ConstructedInventorySerializer serializer_class = ConstructedInventorySerializer
resource_purpose = 'constructed inventories'
def get_queryset(self): def get_queryset(self):
r = super().get_queryset() r = super().get_queryset()
return r.filter(kind='constructed') return r.filter(kind='constructed')
@extend_schema_if_available(extensions={"x-ai-description": "Get or create input inventory inventory"})
class InventoryInputInventoriesList(SubListAttachDetachAPIView): class InventoryInputInventoriesList(SubListAttachDetachAPIView):
model = Inventory model = Inventory
serializer_class = InventorySerializer serializer_class = InventorySerializer
parent_model = Inventory parent_model = Inventory
relationship = 'input_inventories' relationship = 'input_inventories'
resource_purpose = 'input inventories of a constructed inventory'
def is_valid_relation(self, parent, sub, created=False): def is_valid_relation(self, parent, sub, created=False):
if sub.kind == 'constructed': if sub.kind == 'constructed':
raise serializers.ValidationError({'error': 'You cannot add a constructed inventory to another constructed inventory.'}) raise serializers.ValidationError({'error': 'You cannot add a constructed inventory to another constructed inventory.'})
@extend_schema_if_available(extensions={"x-ai-description": "Get activity stream for an inventory"})
class InventoryActivityStreamList(SubListAPIView): class InventoryActivityStreamList(SubListAPIView):
model = ActivityStream model = ActivityStream
serializer_class = ActivityStreamSerializer serializer_class = ActivityStreamSerializer
parent_model = Inventory parent_model = Inventory
relationship = 'activitystream_set' relationship = 'activitystream_set'
search_fields = ('changes',) search_fields = ('changes',)
resource_purpose = 'activity stream for an inventory'
def get_queryset(self): def get_queryset(self):
parent = self.get_parent_object() parent = self.get_parent_object()
@@ -154,13 +140,11 @@ class InventoryInstanceGroupsList(SubListAttachDetachAPIView):
serializer_class = InstanceGroupSerializer serializer_class = InstanceGroupSerializer
parent_model = Inventory parent_model = Inventory
relationship = 'instance_groups' relationship = 'instance_groups'
resource_purpose = 'instance groups of an inventory'
class InventoryAccessList(ResourceAccessList): class InventoryAccessList(ResourceAccessList):
model = User # needs to be User for AccessLists's model = User # needs to be User for AccessLists's
parent_model = Inventory parent_model = Inventory
resource_purpose = 'users who can access the inventory'
class InventoryObjectRolesList(SubListAPIView): class InventoryObjectRolesList(SubListAPIView):
@@ -169,7 +153,6 @@ class InventoryObjectRolesList(SubListAPIView):
parent_model = Inventory parent_model = Inventory
search_fields = ('role_field', 'content_type__model') search_fields = ('role_field', 'content_type__model')
deprecated = True deprecated = True
resource_purpose = 'roles of an inventory'
def get_queryset(self): def get_queryset(self):
po = self.get_parent_object() po = self.get_parent_object()
@@ -182,7 +165,6 @@ class InventoryJobTemplateList(SubListAPIView):
serializer_class = JobTemplateSerializer serializer_class = JobTemplateSerializer
parent_model = Inventory parent_model = Inventory
relationship = 'jobtemplates' relationship = 'jobtemplates'
resource_purpose = 'job templates using an inventory'
def get_queryset(self): def get_queryset(self):
parent = self.get_parent_object() parent = self.get_parent_object()
@@ -193,10 +175,8 @@ class InventoryJobTemplateList(SubListAPIView):
class InventoryLabelList(LabelSubListCreateAttachDetachView): class InventoryLabelList(LabelSubListCreateAttachDetachView):
parent_model = Inventory parent_model = Inventory
resource_purpose = 'labels of an inventory'
class InventoryCopy(CopyAPIView): class InventoryCopy(CopyAPIView):
model = Inventory model = Inventory
copy_return_serializer_class = InventorySerializer copy_return_serializer_class = InventorySerializer
resource_purpose = 'copy of an inventory'

View File

@@ -2,7 +2,6 @@
from awx.api.generics import SubListCreateAttachDetachAPIView, RetrieveUpdateAPIView, ListCreateAPIView from awx.api.generics import SubListCreateAttachDetachAPIView, RetrieveUpdateAPIView, ListCreateAPIView
from awx.main.models import Label from awx.main.models import Label
from awx.api.serializers import LabelSerializer from awx.api.serializers import LabelSerializer
from ansible_base.lib.utils.schema import extend_schema_if_available
# Django # Django
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -25,10 +24,9 @@ class LabelSubListCreateAttachDetachView(SubListCreateAttachDetachAPIView):
model = Label model = Label
serializer_class = LabelSerializer serializer_class = LabelSerializer
relationship = 'labels' relationship = 'labels'
resource_purpose = 'labels of a resource'
def unattach(self, request, *args, **kwargs): def unattach(self, request, *args, **kwargs):
sub_id, res = super().unattach_validate(request) (sub_id, res) = super().unattach_validate(request)
if res: if res:
return res return res
@@ -41,7 +39,6 @@ class LabelSubListCreateAttachDetachView(SubListCreateAttachDetachAPIView):
return res return res
@extend_schema_if_available(extensions={"x-ai-description": "Create or attach a label to a resource"})
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# If a label already exists in the database, attach it instead of erroring out # If a label already exists in the database, attach it instead of erroring out
# that it already exists # that it already exists
@@ -64,11 +61,9 @@ class LabelSubListCreateAttachDetachView(SubListCreateAttachDetachAPIView):
class LabelDetail(RetrieveUpdateAPIView): class LabelDetail(RetrieveUpdateAPIView):
model = Label model = Label
serializer_class = LabelSerializer serializer_class = LabelSerializer
resource_purpose = 'label detail'
class LabelList(ListCreateAPIView): class LabelList(ListCreateAPIView):
name = _("Labels") name = _("Labels")
model = Label model = Label
serializer_class = LabelSerializer serializer_class = LabelSerializer
resource_purpose = 'labels'

View File

@@ -2,7 +2,6 @@
# All Rights Reserved. # All Rights Reserved.
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ansible_base.lib.utils.schema import extend_schema_if_available
from awx.api.generics import APIView, Response from awx.api.generics import APIView, Response
from awx.api.permissions import IsSystemAdminOrAuditor from awx.api.permissions import IsSystemAdminOrAuditor
@@ -14,9 +13,7 @@ class MeshVisualizer(APIView):
name = _("Mesh Visualizer") name = _("Mesh Visualizer")
permission_classes = (IsSystemAdminOrAuditor,) permission_classes = (IsSystemAdminOrAuditor,)
swagger_topic = "System Configuration" swagger_topic = "System Configuration"
resource_purpose = 'mesh network topology visualization data'
@extend_schema_if_available(extensions={"x-ai-description": "Get mesh network topology visualization data"})
def get(self, request, format=None): def get(self, request, format=None):
data = { data = {
'nodes': InstanceNodeSerializer(Instance.objects.all(), many=True).data, 'nodes': InstanceNodeSerializer(Instance.objects.all(), many=True).data,

View File

@@ -7,13 +7,13 @@ import logging
# Django # Django
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ansible_base.lib.utils.schema import extend_schema_if_available
# Django REST Framework # Django REST Framework
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
# AWX # AWX
# from awx.main.analytics import collectors # from awx.main.analytics import collectors
import awx.main.analytics.subsystem_metrics as s_metrics import awx.main.analytics.subsystem_metrics as s_metrics
@@ -22,13 +22,13 @@ from awx.api import renderers
from awx.api.generics import APIView from awx.api.generics import APIView
logger = logging.getLogger('awx.analytics') logger = logging.getLogger('awx.analytics')
class MetricsView(APIView): class MetricsView(APIView):
name = _('Metrics') name = _('Metrics')
swagger_topic = 'Metrics' swagger_topic = 'Metrics'
resource_purpose = 'prometheus metrics data'
renderer_classes = [renderers.PlainTextRenderer, renderers.PrometheusJSONRenderer, renderers.BrowsableAPIRenderer] renderer_classes = [renderers.PlainTextRenderer, renderers.PrometheusJSONRenderer, renderers.BrowsableAPIRenderer]
@@ -37,7 +37,6 @@ class MetricsView(APIView):
self.permission_classes = (AllowAny,) self.permission_classes = (AllowAny,)
return super(APIView, self).initialize_request(request, *args, **kwargs) return super(APIView, self).initialize_request(request, *args, **kwargs)
@extend_schema_if_available(extensions={"x-ai-description": "Get Prometheus metrics data"})
def get(self, request): def get(self, request):
'''Show Metrics Details''' '''Show Metrics Details'''
if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS or request.user.is_superuser or request.user.is_system_auditor: if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS or request.user.is_superuser or request.user.is_system_auditor:

View File

@@ -53,20 +53,21 @@ from awx.api.serializers import (
CredentialSerializer, CredentialSerializer,
) )
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, OrganizationCountsMixin, OrganizationInstanceGroupMembershipMixin from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, OrganizationCountsMixin, OrganizationInstanceGroupMembershipMixin
from awx.api.views import immutablesharedfields
logger = logging.getLogger('awx.api.views.organization') logger = logging.getLogger('awx.api.views.organization')
@immutablesharedfields
class OrganizationList(OrganizationCountsMixin, ListCreateAPIView): class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
model = Organization model = Organization
serializer_class = OrganizationSerializer serializer_class = OrganizationSerializer
resource_purpose = 'organizations'
@immutablesharedfields
class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = Organization model = Organization
serializer_class = OrganizationSerializer serializer_class = OrganizationSerializer
resource_purpose = 'organization detail'
def get_serializer_context(self, *args, **kwargs): def get_serializer_context(self, *args, **kwargs):
full_context = super(OrganizationDetail, self).get_serializer_context(*args, **kwargs) full_context = super(OrganizationDetail, self).get_serializer_context(*args, **kwargs)
@@ -104,25 +105,24 @@ class OrganizationInventoriesList(SubListAPIView):
serializer_class = InventorySerializer serializer_class = InventorySerializer
parent_model = Organization parent_model = Organization
relationship = 'inventories' relationship = 'inventories'
resource_purpose = 'inventories of an organization'
@immutablesharedfields
class OrganizationUsersList(BaseUsersList): class OrganizationUsersList(BaseUsersList):
model = User model = User
serializer_class = UserSerializer serializer_class = UserSerializer
parent_model = Organization parent_model = Organization
relationship = 'member_role.members' relationship = 'member_role.members'
ordering = ('username',) ordering = ('username',)
resource_purpose = 'users of an organization'
@immutablesharedfields
class OrganizationAdminsList(BaseUsersList): class OrganizationAdminsList(BaseUsersList):
model = User model = User
serializer_class = UserSerializer serializer_class = UserSerializer
parent_model = Organization parent_model = Organization
relationship = 'admin_role.members' relationship = 'admin_role.members'
ordering = ('username',) ordering = ('username',)
resource_purpose = 'administrators of an organization'
class OrganizationProjectsList(SubListCreateAPIView): class OrganizationProjectsList(SubListCreateAPIView):
@@ -130,7 +130,6 @@ class OrganizationProjectsList(SubListCreateAPIView):
serializer_class = ProjectSerializer serializer_class = ProjectSerializer
parent_model = Organization parent_model = Organization
parent_key = 'organization' parent_key = 'organization'
resource_purpose = 'projects of an organization'
class OrganizationExecutionEnvironmentsList(SubListCreateAttachDetachAPIView): class OrganizationExecutionEnvironmentsList(SubListCreateAttachDetachAPIView):
@@ -140,7 +139,6 @@ class OrganizationExecutionEnvironmentsList(SubListCreateAttachDetachAPIView):
relationship = 'executionenvironments' relationship = 'executionenvironments'
parent_key = 'organization' parent_key = 'organization'
swagger_topic = "Execution Environments" swagger_topic = "Execution Environments"
resource_purpose = 'execution environments of an organization'
class OrganizationJobTemplatesList(SubListCreateAPIView): class OrganizationJobTemplatesList(SubListCreateAPIView):
@@ -148,7 +146,6 @@ class OrganizationJobTemplatesList(SubListCreateAPIView):
serializer_class = JobTemplateSerializer serializer_class = JobTemplateSerializer
parent_model = Organization parent_model = Organization
parent_key = 'organization' parent_key = 'organization'
resource_purpose = 'job templates of an organization'
class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView): class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView):
@@ -156,16 +153,15 @@ class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView):
serializer_class = WorkflowJobTemplateSerializer serializer_class = WorkflowJobTemplateSerializer
parent_model = Organization parent_model = Organization
parent_key = 'organization' parent_key = 'organization'
resource_purpose = 'workflow job templates of an organization'
@immutablesharedfields
class OrganizationTeamsList(SubListCreateAttachDetachAPIView): class OrganizationTeamsList(SubListCreateAttachDetachAPIView):
model = Team model = Team
serializer_class = TeamSerializer serializer_class = TeamSerializer
parent_model = Organization parent_model = Organization
relationship = 'teams' relationship = 'teams'
parent_key = 'organization' parent_key = 'organization'
resource_purpose = 'teams of an organization'
class OrganizationActivityStreamList(SubListAPIView): class OrganizationActivityStreamList(SubListAPIView):
@@ -174,7 +170,6 @@ class OrganizationActivityStreamList(SubListAPIView):
parent_model = Organization parent_model = Organization
relationship = 'activitystream_set' relationship = 'activitystream_set'
search_fields = ('changes',) search_fields = ('changes',)
resource_purpose = 'activity stream for an organization'
class OrganizationNotificationTemplatesList(SubListCreateAttachDetachAPIView): class OrganizationNotificationTemplatesList(SubListCreateAttachDetachAPIView):
@@ -183,34 +178,28 @@ class OrganizationNotificationTemplatesList(SubListCreateAttachDetachAPIView):
parent_model = Organization parent_model = Organization
relationship = 'notification_templates' relationship = 'notification_templates'
parent_key = 'organization' parent_key = 'organization'
resource_purpose = 'notification templates of an organization'
class OrganizationNotificationTemplatesAnyList(SubListCreateAttachDetachAPIView): class OrganizationNotificationTemplatesAnyList(SubListCreateAttachDetachAPIView):
model = NotificationTemplate model = NotificationTemplate
serializer_class = NotificationTemplateSerializer serializer_class = NotificationTemplateSerializer
parent_model = Organization parent_model = Organization
resource_purpose = 'base view for notification templates of an organization'
class OrganizationNotificationTemplatesStartedList(OrganizationNotificationTemplatesAnyList): class OrganizationNotificationTemplatesStartedList(OrganizationNotificationTemplatesAnyList):
relationship = 'notification_templates_started' relationship = 'notification_templates_started'
resource_purpose = 'notification templates for job started events of an organization'
class OrganizationNotificationTemplatesErrorList(OrganizationNotificationTemplatesAnyList): class OrganizationNotificationTemplatesErrorList(OrganizationNotificationTemplatesAnyList):
relationship = 'notification_templates_error' relationship = 'notification_templates_error'
resource_purpose = 'notification templates for job error events of an organization'
class OrganizationNotificationTemplatesSuccessList(OrganizationNotificationTemplatesAnyList): class OrganizationNotificationTemplatesSuccessList(OrganizationNotificationTemplatesAnyList):
relationship = 'notification_templates_success' relationship = 'notification_templates_success'
resource_purpose = 'notification templates for job success events of an organization'
class OrganizationNotificationTemplatesApprovalList(OrganizationNotificationTemplatesAnyList): class OrganizationNotificationTemplatesApprovalList(OrganizationNotificationTemplatesAnyList):
relationship = 'notification_templates_approvals' relationship = 'notification_templates_approvals'
resource_purpose = 'notification templates for workflow approval events of an organization'
class OrganizationInstanceGroupsList(OrganizationInstanceGroupMembershipMixin, SubListAttachDetachAPIView): class OrganizationInstanceGroupsList(OrganizationInstanceGroupMembershipMixin, SubListAttachDetachAPIView):
@@ -219,7 +208,6 @@ class OrganizationInstanceGroupsList(OrganizationInstanceGroupMembershipMixin, S
parent_model = Organization parent_model = Organization
relationship = 'instance_groups' relationship = 'instance_groups'
filter_read_permission = False filter_read_permission = False
resource_purpose = 'instance groups of an organization'
class OrganizationGalaxyCredentialsList(SubListAttachDetachAPIView): class OrganizationGalaxyCredentialsList(SubListAttachDetachAPIView):
@@ -228,7 +216,6 @@ class OrganizationGalaxyCredentialsList(SubListAttachDetachAPIView):
parent_model = Organization parent_model = Organization
relationship = 'galaxy_credentials' relationship = 'galaxy_credentials'
filter_read_permission = False filter_read_permission = False
resource_purpose = 'galaxy credentials of an organization'
def is_valid_relation(self, parent, sub, created=False): def is_valid_relation(self, parent, sub, created=False):
if sub.kind != 'galaxy_api_token': if sub.kind != 'galaxy_api_token':
@@ -238,7 +225,6 @@ class OrganizationGalaxyCredentialsList(SubListAttachDetachAPIView):
class OrganizationAccessList(ResourceAccessList): class OrganizationAccessList(ResourceAccessList):
model = User # needs to be User for AccessLists's model = User # needs to be User for AccessLists's
parent_model = Organization parent_model = Organization
resource_purpose = 'users who can access the organization'
class OrganizationObjectRolesList(SubListAPIView): class OrganizationObjectRolesList(SubListAPIView):
@@ -247,7 +233,6 @@ class OrganizationObjectRolesList(SubListAPIView):
parent_model = Organization parent_model = Organization
search_fields = ('role_field', 'content_type__model') search_fields = ('role_field', 'content_type__model')
deprecated = True deprecated = True
resource_purpose = 'roles of an organization'
def get_queryset(self): def get_queryset(self):
po = self.get_parent_object() po = self.get_parent_object()

View File

@@ -8,8 +8,6 @@ import operator
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.db import connection
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
@@ -23,16 +21,14 @@ from rest_framework import status
import requests import requests
from ansible_base.lib.utils.schema import extend_schema_if_available from awx import MODE
from awx.api.generics import APIView from awx.api.generics import APIView
from awx.conf.registry import settings_registry from awx.conf.registry import settings_registry
from awx.main.analytics import all_collectors from awx.main.analytics import all_collectors
from awx.main.ha import is_ha_environment from awx.main.ha import is_ha_environment
from awx.main.tasks.system import clear_setting_cache
from awx.main.utils import get_awx_version, get_custom_venv_choices from awx.main.utils import get_awx_version, get_custom_venv_choices
from awx.main.utils.licensing import validate_entitlement_manifest from awx.main.utils.licensing import validate_entitlement_manifest
from awx.api.versioning import URLPathVersioning, reverse from awx.api.versioning import URLPathVersioning, reverse, drf_reverse
from awx.main.constants import PRIVILEGE_ESCALATION_METHODS from awx.main.constants import PRIVILEGE_ESCALATION_METHODS
from awx.main.models import Project, Organization, Instance, InstanceGroup, JobTemplate from awx.main.models import Project, Organization, Instance, InstanceGroup, JobTemplate
from awx.main.utils import set_environ from awx.main.utils import set_environ
@@ -47,10 +43,8 @@ class ApiRootView(APIView):
name = _('REST API') name = _('REST API')
versioning_class = URLPathVersioning versioning_class = URLPathVersioning
swagger_topic = 'Versioning' swagger_topic = 'Versioning'
resource_purpose = 'api root and version information'
@method_decorator(ensure_csrf_cookie) @method_decorator(ensure_csrf_cookie)
@extend_schema_if_available(extensions={"x-ai-description": "List supported API versions"})
def get(self, request, format=None): def get(self, request, format=None):
'''List supported API versions''' '''List supported API versions'''
v2 = reverse('api:api_v2_root_view', request=request, kwargs={'version': 'v2'}) v2 = reverse('api:api_v2_root_view', request=request, kwargs={'version': 'v2'})
@@ -61,15 +55,15 @@ class ApiRootView(APIView):
data['custom_logo'] = settings.CUSTOM_LOGO data['custom_logo'] = settings.CUSTOM_LOGO
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE
if MODE == 'development':
data['swagger'] = drf_reverse('api:schema-swagger-ui')
return Response(data) return Response(data)
class ApiVersionRootView(APIView): class ApiVersionRootView(APIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
swagger_topic = 'Versioning' swagger_topic = 'Versioning'
resource_purpose = 'api top-level resources'
@extend_schema_if_available(extensions={"x-ai-description": "List top-level API resources"})
def get(self, request, format=None): def get(self, request, format=None):
'''List top level resources''' '''List top level resources'''
data = OrderedDict() data = OrderedDict()
@@ -129,7 +123,6 @@ class ApiVersionRootView(APIView):
class ApiV2RootView(ApiVersionRootView): class ApiV2RootView(ApiVersionRootView):
name = _('Version 2') name = _('Version 2')
resource_purpose = 'api v2 root'
class ApiV2PingView(APIView): class ApiV2PingView(APIView):
@@ -141,11 +134,7 @@ class ApiV2PingView(APIView):
authentication_classes = () authentication_classes = ()
name = _('Ping') name = _('Ping')
swagger_topic = 'System Configuration' swagger_topic = 'System Configuration'
resource_purpose = 'basic instance information'
@extend_schema_if_available(
extensions={'x-ai-description': 'Return basic information about this instance'},
)
def get(self, request, format=None): def get(self, request, format=None):
"""Return some basic information about this instance """Return some basic information about this instance
@@ -180,59 +169,24 @@ class ApiV2SubscriptionView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
name = _('Subscriptions') name = _('Subscriptions')
swagger_topic = 'System Configuration' swagger_topic = 'System Configuration'
resource_purpose = 'aap subscription validation'
def check_permissions(self, request): def check_permissions(self, request):
super(ApiV2SubscriptionView, self).check_permissions(request) super(ApiV2SubscriptionView, self).check_permissions(request)
if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}: if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}:
self.permission_denied(request) # Raises PermissionDenied exception. self.permission_denied(request) # Raises PermissionDenied exception.
@extend_schema_if_available(
extensions={'x-ai-description': 'List valid AAP subscriptions'},
)
def post(self, request): def post(self, request):
data = request.data.copy() data = request.data.copy()
if data.get('subscriptions_client_secret') == '$encrypted$':
data['subscriptions_client_secret'] = settings.SUBSCRIPTIONS_CLIENT_SECRET
try: try:
user = None user, pw = data.get('subscriptions_client_id'), data.get('subscriptions_client_secret')
pw = None
basic_auth = False
# determine if the credentials are for basic auth or not
if data.get('subscriptions_client_id'):
user, pw = data.get('subscriptions_client_id'), data.get('subscriptions_client_secret')
if pw == '$encrypted$':
pw = settings.SUBSCRIPTIONS_CLIENT_SECRET
elif data.get('subscriptions_username'):
user, pw = data.get('subscriptions_username'), data.get('subscriptions_password')
if pw == '$encrypted$':
pw = settings.SUBSCRIPTIONS_PASSWORD
basic_auth = True
if not user or not pw:
return Response({"error": _("Missing subscription credentials")}, status=status.HTTP_400_BAD_REQUEST)
with set_environ(**settings.AWX_TASK_ENV): with set_environ(**settings.AWX_TASK_ENV):
validated = get_licenser().validate_rh(user, pw, basic_auth) validated = get_licenser().validate_rh(user, pw)
if user:
# update settings if the credentials were valid settings.SUBSCRIPTIONS_CLIENT_ID = data['subscriptions_client_id']
if basic_auth: if pw:
if user: settings.SUBSCRIPTIONS_CLIENT_SECRET = data['subscriptions_client_secret']
settings.SUBSCRIPTIONS_USERNAME = user
if pw:
settings.SUBSCRIPTIONS_PASSWORD = pw
# mutual exclusion for basic auth and service account
# only one should be set at a given time so that
# config/attach/ knows which credentials to use
settings.SUBSCRIPTIONS_CLIENT_ID = ""
settings.SUBSCRIPTIONS_CLIENT_SECRET = ""
else:
if user:
settings.SUBSCRIPTIONS_CLIENT_ID = user
if pw:
settings.SUBSCRIPTIONS_CLIENT_SECRET = pw
# mutual exclusion for basic auth and service account
settings.SUBSCRIPTIONS_USERNAME = ""
settings.SUBSCRIPTIONS_PASSWORD = ""
except Exception as exc: except Exception as exc:
msg = _("Invalid Subscription") msg = _("Invalid Subscription")
if isinstance(exc, TokenError) or ( if isinstance(exc, TokenError) or (
@@ -256,37 +210,24 @@ class ApiV2AttachView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
name = _('Attach Subscription') name = _('Attach Subscription')
swagger_topic = 'System Configuration' swagger_topic = 'System Configuration'
resource_purpose = 'subscription attachment'
def check_permissions(self, request): def check_permissions(self, request):
super(ApiV2AttachView, self).check_permissions(request) super(ApiV2AttachView, self).check_permissions(request)
if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}: if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}:
self.permission_denied(request) # Raises PermissionDenied exception. self.permission_denied(request) # Raises PermissionDenied exception.
@extend_schema_if_available(
extensions={'x-ai-description': 'Attach a subscription'},
)
def post(self, request): def post(self, request):
data = request.data.copy() data = request.data.copy()
subscription_id = data.get('subscription_id', None) subscription_id = data.get('subscription_id', None)
if not subscription_id: if not subscription_id:
return Response({"error": _("No subscription ID provided.")}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": _("No subscription ID provided.")}, status=status.HTTP_400_BAD_REQUEST)
# Ensure we always use the latest subscription credentials
cache.delete_many(['SUBSCRIPTIONS_CLIENT_ID', 'SUBSCRIPTIONS_CLIENT_SECRET', 'SUBSCRIPTIONS_USERNAME', 'SUBSCRIPTIONS_PASSWORD'])
user = getattr(settings, 'SUBSCRIPTIONS_CLIENT_ID', None) user = getattr(settings, 'SUBSCRIPTIONS_CLIENT_ID', None)
pw = getattr(settings, 'SUBSCRIPTIONS_CLIENT_SECRET', None) pw = getattr(settings, 'SUBSCRIPTIONS_CLIENT_SECRET', None)
basic_auth = False
if not (user and pw):
user = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None)
pw = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None)
basic_auth = True
if not (user and pw):
return Response({"error": _("Missing subscription credentials")}, status=status.HTTP_400_BAD_REQUEST)
if subscription_id and user and pw: if subscription_id and user and pw:
data = request.data.copy() data = request.data.copy()
try: try:
with set_environ(**settings.AWX_TASK_ENV): with set_environ(**settings.AWX_TASK_ENV):
validated = get_licenser().validate_rh(user, pw, basic_auth) validated = get_licenser().validate_rh(user, pw)
except Exception as exc: except Exception as exc:
msg = _("Invalid Subscription") msg = _("Invalid Subscription")
if isinstance(exc, requests.exceptions.HTTPError) and getattr(getattr(exc, 'response', None), 'status_code', None) == 401: if isinstance(exc, requests.exceptions.HTTPError) and getattr(getattr(exc, 'response', None), 'status_code', None) == 401:
@@ -300,12 +241,10 @@ class ApiV2AttachView(APIView):
else: else:
logger.exception(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username)) logger.exception(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username))
return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST)
for sub in validated: for sub in validated:
if sub['subscription_id'] == subscription_id: if sub['subscription_id'] == subscription_id:
sub['valid_key'] = True sub['valid_key'] = True
settings.LICENSE = sub settings.LICENSE = sub
connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE']))
return Response(sub) return Response(sub)
return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST)
@@ -315,20 +254,17 @@ class ApiV2ConfigView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
name = _('Configuration') name = _('Configuration')
swagger_topic = 'System Configuration' swagger_topic = 'System Configuration'
resource_purpose = 'system configuration and license management'
def check_permissions(self, request): def check_permissions(self, request):
super(ApiV2ConfigView, self).check_permissions(request) super(ApiV2ConfigView, self).check_permissions(request)
if not request.user.is_superuser and request.method.lower() not in {'options', 'head', 'get'}: if not request.user.is_superuser and request.method.lower() not in {'options', 'head', 'get'}:
self.permission_denied(request) # Raises PermissionDenied exception. self.permission_denied(request) # Raises PermissionDenied exception.
@extend_schema_if_available(
extensions={'x-ai-description': 'Return various configuration settings'},
)
def get(self, request, format=None): def get(self, request, format=None):
'''Return various sitewide configuration settings''' '''Return various sitewide configuration settings'''
license_data = get_licenser().validate() license_data = get_licenser().validate()
if not license_data.get('valid_key', False): if not license_data.get('valid_key', False):
license_data = {} license_data = {}
@@ -344,22 +280,13 @@ class ApiV2ConfigView(APIView):
become_methods=PRIVILEGE_ESCALATION_METHODS, become_methods=PRIVILEGE_ESCALATION_METHODS,
) )
# Check superuser/auditor first if (
if request.user.is_superuser or request.user.is_system_auditor: request.user.is_superuser
has_org_access = True or request.user.is_system_auditor
else: or Organization.accessible_objects(request.user, 'admin_role').exists()
# Single query checking all three organization role types at once or Organization.accessible_objects(request.user, 'auditor_role').exists()
has_org_access = ( or Organization.accessible_objects(request.user, 'project_admin_role').exists()
( ):
Organization.access_qs(request.user, 'change')
| Organization.access_qs(request.user, 'audit')
| Organization.access_qs(request.user, 'add_project')
)
.distinct()
.exists()
)
if has_org_access:
data.update( data.update(
dict( dict(
project_base_dir=settings.PROJECTS_ROOT, project_base_dir=settings.PROJECTS_ROOT,
@@ -367,14 +294,11 @@ class ApiV2ConfigView(APIView):
custom_virtualenvs=get_custom_venv_choices(), custom_virtualenvs=get_custom_venv_choices(),
) )
) )
else: elif JobTemplate.accessible_objects(request.user, 'admin_role').exists():
# Only check JobTemplate access if org check failed data['custom_virtualenvs'] = get_custom_venv_choices()
if JobTemplate.accessible_objects(request.user, 'admin_role').exists():
data['custom_virtualenvs'] = get_custom_venv_choices()
return Response(data) return Response(data)
@extend_schema_if_available(extensions={"x-ai-description": "Add or update a subscription manifest license"})
def post(self, request): def post(self, request):
if not isinstance(request.data, dict): if not isinstance(request.data, dict):
return Response({"error": _("Invalid subscription data")}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": _("Invalid subscription data")}, status=status.HTTP_400_BAD_REQUEST)
@@ -404,7 +328,6 @@ class ApiV2ConfigView(APIView):
try: try:
license_data_validated = get_licenser().license_from_manifest(license_data) license_data_validated = get_licenser().license_from_manifest(license_data)
connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE']))
except Exception: except Exception:
logger.warning(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username)) logger.warning(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username))
return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST)
@@ -420,13 +343,9 @@ class ApiV2ConfigView(APIView):
logger.warning(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username)) logger.warning(smart_str(u"Invalid subscription submitted."), extra=dict(actor=request.user.username))
return Response({"error": _("Invalid subscription")}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": _("Invalid subscription")}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_if_available(
extensions={'x-ai-description': 'Remove the current subscription'},
)
def delete(self, request): def delete(self, request):
try: try:
settings.LICENSE = {} settings.LICENSE = {}
connection.on_commit(lambda: clear_setting_cache.delay(['LICENSE']))
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except Exception: except Exception:
# FIX: Log # FIX: Log

View File

@@ -11,13 +11,12 @@ from rest_framework import status
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from ansible_base.lib.utils.schema import extend_schema_if_available
from awx.api import serializers from awx.api import serializers
from awx.api.generics import APIView, GenericAPIView from awx.api.generics import APIView, GenericAPIView
from awx.api.permissions import WebhookKeyPermission from awx.api.permissions import WebhookKeyPermission
from awx.main.models import Job, JobTemplate, WorkflowJob, WorkflowJobTemplate from awx.main.models import Job, JobTemplate, WorkflowJob, WorkflowJobTemplate
from awx.main.utils.common import get_job_variable_prefixes from awx.main.constants import JOB_VARIABLE_PREFIXES
logger = logging.getLogger('awx.api.views.webhooks') logger = logging.getLogger('awx.api.views.webhooks')
@@ -25,7 +24,6 @@ logger = logging.getLogger('awx.api.views.webhooks')
class WebhookKeyView(GenericAPIView): class WebhookKeyView(GenericAPIView):
serializer_class = serializers.EmptySerializer serializer_class = serializers.EmptySerializer
permission_classes = (WebhookKeyPermission,) permission_classes = (WebhookKeyPermission,)
resource_purpose = 'webhook key management'
def get_queryset(self): def get_queryset(self):
qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate} qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate}
@@ -33,13 +31,11 @@ class WebhookKeyView(GenericAPIView):
return super().get_queryset() return super().get_queryset()
@extend_schema_if_available(extensions={"x-ai-description": "Get the webhook key for a template"})
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
return Response({'webhook_key': obj.webhook_key}) return Response({'webhook_key': obj.webhook_key})
@extend_schema_if_available(extensions={"x-ai-description": "Rotate the webhook key for a template"})
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
obj.rotate_webhook_key() obj.rotate_webhook_key()
@@ -56,7 +52,6 @@ class WebhookReceiverBase(APIView):
authentication_classes = () authentication_classes = ()
ref_keys = {} ref_keys = {}
resource_purpose = 'webhook receiver for triggering jobs'
def get_queryset(self): def get_queryset(self):
qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate} qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate}
@@ -132,8 +127,7 @@ class WebhookReceiverBase(APIView):
raise PermissionDenied raise PermissionDenied
@csrf_exempt @csrf_exempt
@extend_schema_if_available(extensions={"x-ai-description": "Receive a webhook event and trigger a job"}) def post(self, request, *args, **kwargs):
def post(self, request, *args, **kwargs_in):
# Ensure that the full contents of the request are captured for multiple uses. # Ensure that the full contents of the request are captured for multiple uses.
request.body request.body
@@ -166,7 +160,7 @@ class WebhookReceiverBase(APIView):
'extra_vars': {}, 'extra_vars': {},
} }
for name in get_job_variable_prefixes(): for name in JOB_VARIABLE_PREFIXES:
kwargs['extra_vars']['{}_webhook_event_type'.format(name)] = event_type kwargs['extra_vars']['{}_webhook_event_type'.format(name)] = event_type
kwargs['extra_vars']['{}_webhook_event_guid'.format(name)] = event_guid kwargs['extra_vars']['{}_webhook_event_guid'.format(name)] = event_guid
kwargs['extra_vars']['{}_webhook_event_ref'.format(name)] = event_ref kwargs['extra_vars']['{}_webhook_event_ref'.format(name)] = event_ref
@@ -181,7 +175,6 @@ class WebhookReceiverBase(APIView):
class GithubWebhookReceiver(WebhookReceiverBase): class GithubWebhookReceiver(WebhookReceiverBase):
service = 'github' service = 'github'
resource_purpose = 'github webhook receiver'
ref_keys = { ref_keys = {
'pull_request': 'pull_request.head.sha', 'pull_request': 'pull_request.head.sha',
@@ -219,7 +212,6 @@ class GithubWebhookReceiver(WebhookReceiverBase):
class GitlabWebhookReceiver(WebhookReceiverBase): class GitlabWebhookReceiver(WebhookReceiverBase):
service = 'gitlab' service = 'gitlab'
resource_purpose = 'gitlab webhook receiver'
ref_keys = {'Push Hook': 'checkout_sha', 'Tag Push Hook': 'checkout_sha', 'Merge Request Hook': 'object_attributes.last_commit.id'} ref_keys = {'Push Hook': 'checkout_sha', 'Tag Push Hook': 'checkout_sha', 'Merge Request Hook': 'object_attributes.last_commit.id'}
@@ -258,7 +250,6 @@ class GitlabWebhookReceiver(WebhookReceiverBase):
class BitbucketDcWebhookReceiver(WebhookReceiverBase): class BitbucketDcWebhookReceiver(WebhookReceiverBase):
service = 'bitbucket_dc' service = 'bitbucket_dc'
resource_purpose = 'bitbucket data center webhook receiver'
ref_keys = { ref_keys = {
'repo:refs_changed': 'changes.0.toHash', 'repo:refs_changed': 'changes.0.toHash',

View File

@@ -6,7 +6,7 @@ import urllib.parse as urlparse
from collections import OrderedDict from collections import OrderedDict
# Django # Django
from django.core.validators import URLValidator, DomainNameValidator, _lazy_re_compile from django.core.validators import URLValidator, _lazy_re_compile
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Django REST Framework # Django REST Framework
@@ -160,11 +160,10 @@ class StringListIsolatedPathField(StringListField):
class URLField(CharField): class URLField(CharField):
# these lines set up a custom regex that allow numbers in the # these lines set up a custom regex that allow numbers in the
# top-level domain # top-level domain
tld_re = ( tld_re = (
r'\.' # dot r'\.' # dot
r'(?!-)' # can't start with a dash r'(?!-)' # can't start with a dash
r'(?:[a-z' + DomainNameValidator.ul + r'0-9' + '-]{2,63}' # domain label, this line was changed from the original URLValidator r'(?:[a-z' + URLValidator.ul + r'0-9' + '-]{2,63}' # domain label, this line was changed from the original URLValidator
r'|xn--[a-z0-9]{1,59})' # or punycode label r'|xn--[a-z0-9]{1,59})' # or punycode label
r'(?<!-)' # can't end with a dash r'(?<!-)' # can't end with a dash
r'\.?' # may have a trailing dot r'\.?' # may have a trailing dot

View File

@@ -5,6 +5,7 @@ from django.urls import re_path
from awx.conf.views import SettingCategoryList, SettingSingletonDetail, SettingLoggingTest from awx.conf.views import SettingCategoryList, SettingSingletonDetail, SettingLoggingTest
urlpatterns = [ urlpatterns = [
re_path(r'^$', SettingCategoryList.as_view(), name='setting_category_list'), re_path(r'^$', SettingCategoryList.as_view(), name='setting_category_list'),
re_path(r'^(?P<category_slug>[a-z0-9-]+)/$', SettingSingletonDetail.as_view(), name='setting_singleton_detail'), re_path(r'^(?P<category_slug>[a-z0-9-]+)/$', SettingSingletonDetail.as_view(), name='setting_singleton_detail'),

View File

@@ -31,7 +31,7 @@ from awx.conf.models import Setting
from awx.conf.serializers import SettingCategorySerializer, SettingSingletonSerializer from awx.conf.serializers import SettingCategorySerializer, SettingSingletonSerializer
from awx.conf import settings_registry from awx.conf import settings_registry
from awx.main.utils.external_logging import reconfigure_rsyslog from awx.main.utils.external_logging import reconfigure_rsyslog
from ansible_base.lib.utils.schema import extend_schema_if_available
SettingCategory = collections.namedtuple('SettingCategory', ('url', 'slug', 'name')) SettingCategory = collections.namedtuple('SettingCategory', ('url', 'slug', 'name'))
@@ -42,10 +42,6 @@ class SettingCategoryList(ListAPIView):
filter_backends = [] filter_backends = []
name = _('Setting Categories') name = _('Setting Categories')
@extend_schema_if_available(extensions={"x-ai-description": "A list of additional API endpoints related to settings."})
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
setting_categories = [] setting_categories = []
categories = settings_registry.get_registered_categories() categories = settings_registry.get_registered_categories()
@@ -67,10 +63,6 @@ class SettingSingletonDetail(RetrieveUpdateDestroyAPIView):
filter_backends = [] filter_backends = []
name = _('Setting Detail') name = _('Setting Detail')
@extend_schema_if_available(extensions={"x-ai-description": "Update system settings."})
def patch(self, request, *args, **kwargs):
return super().patch(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
self.category_slug = self.kwargs.get('category_slug', 'all') self.category_slug = self.kwargs.get('category_slug', 'all')
all_category_slugs = list(settings_registry.get_registered_categories().keys()) all_category_slugs = list(settings_registry.get_registered_categories().keys())

View File

@@ -639,9 +639,7 @@ class UserAccess(BaseAccess):
prefetch_related = ('resource',) prefetch_related = ('resource',)
def filtered_queryset(self): def filtered_queryset(self):
if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and ( if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (self.user.admin_of_organizations.exists() or self.user.auditor_of_organizations.exists()):
Organization.access_qs(self.user, 'change').exists() or Organization.access_qs(self.user, 'audit').exists()
):
qs = User.objects.all() qs = User.objects.all()
else: else:
qs = ( qs = (
@@ -897,6 +895,8 @@ class HostAccess(BaseAccess):
'created_by', 'created_by',
'modified_by', 'modified_by',
'inventory', 'inventory',
'last_job__job_template',
'last_job_host_summary__job',
) )
prefetch_related = ('groups', 'inventory_sources') prefetch_related = ('groups', 'inventory_sources')
@@ -1224,9 +1224,7 @@ class TeamAccess(BaseAccess):
) )
def filtered_queryset(self): def filtered_queryset(self):
if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and ( if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (self.user.admin_of_organizations.exists() or self.user.auditor_of_organizations.exists()):
Organization.access_qs(self.user, 'change').exists() or Organization.access_qs(self.user, 'audit').exists()
):
return self.model.objects.all() return self.model.objects.all()
return self.model.objects.filter( return self.model.objects.filter(
Q(organization__in=Organization.accessible_pk_qs(self.user, 'member_role')) | Q(pk__in=self.model.accessible_pk_qs(self.user, 'read_role')) Q(organization__in=Organization.accessible_pk_qs(self.user, 'member_role')) | Q(pk__in=self.model.accessible_pk_qs(self.user, 'read_role'))
@@ -2566,7 +2564,7 @@ class NotificationTemplateAccess(BaseAccess):
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
return self.model.access_qs(self.user, 'view') return self.model.access_qs(self.user, 'view')
return self.model.objects.filter( return self.model.objects.filter(
Q(organization__in=Organization.access_qs(self.user, 'add_notificationtemplate')) | Q(organization__in=Organization.access_qs(self.user, 'audit')) Q(organization__in=Organization.access_qs(self.user, 'add_notificationtemplate')) | Q(organization__in=self.user.auditor_of_organizations)
).distinct() ).distinct()
@check_superuser @check_superuser
@@ -2601,7 +2599,7 @@ class NotificationAccess(BaseAccess):
def filtered_queryset(self): def filtered_queryset(self):
return self.model.objects.filter( return self.model.objects.filter(
Q(notification_template__organization__in=Organization.access_qs(self.user, 'add_notificationtemplate')) Q(notification_template__organization__in=Organization.access_qs(self.user, 'add_notificationtemplate'))
| Q(notification_template__organization__in=Organization.access_qs(self.user, 'audit')) | Q(notification_template__organization__in=self.user.auditor_of_organizations)
).distinct() ).distinct()
def can_delete(self, obj): def can_delete(self, obj):

View File

@@ -1,17 +1,15 @@
# Python # Python
import logging import logging
# Dispatcherd
from dispatcherd.publish import task
# AWX # AWX
from awx.main.analytics.subsystem_metrics import DispatcherMetrics, CallbackReceiverMetrics from awx.main.analytics.subsystem_metrics import DispatcherMetrics, CallbackReceiverMetrics
from awx.main.dispatch.publish import task as task_awx
from awx.main.dispatch import get_task_queuename from awx.main.dispatch import get_task_queuename
logger = logging.getLogger('awx.main.scheduler') logger = logging.getLogger('awx.main.scheduler')
@task(queue=get_task_queuename, timeout=300, on_duplicate='discard') @task_awx(queue=get_task_queuename)
def send_subsystem_metrics(): def send_subsystem_metrics():
DispatcherMetrics().send_metrics() DispatcherMetrics().send_metrics()
CallbackReceiverMetrics().send_metrics() CallbackReceiverMetrics().send_metrics()

View File

@@ -1,6 +1,8 @@
import datetime import datetime
import asyncio import asyncio
import logging import logging
import redis
import redis.asyncio
import re import re
from prometheus_client import ( from prometheus_client import (
@@ -13,7 +15,7 @@ from prometheus_client import (
) )
from django.conf import settings from django.conf import settings
from awx.main.utils.redis import get_redis_client, get_redis_client_async
BROADCAST_WEBSOCKET_REDIS_KEY_NAME = 'broadcast_websocket_stats' BROADCAST_WEBSOCKET_REDIS_KEY_NAME = 'broadcast_websocket_stats'
@@ -64,8 +66,6 @@ class FixedSlidingWindow:
class RelayWebsocketStatsManager: class RelayWebsocketStatsManager:
_redis_client = None # Cached Redis client for get_stats_sync()
def __init__(self, local_hostname): def __init__(self, local_hostname):
self._local_hostname = local_hostname self._local_hostname = local_hostname
self._stats = dict() self._stats = dict()
@@ -80,7 +80,7 @@ class RelayWebsocketStatsManager:
async def run_loop(self): async def run_loop(self):
try: try:
redis_conn = get_redis_client_async() redis_conn = await redis.asyncio.Redis.from_url(settings.BROKER_URL)
while True: while True:
stats_data_str = ''.join(stat.serialize() for stat in self._stats.values()) stats_data_str = ''.join(stat.serialize() for stat in self._stats.values())
await redis_conn.set(self._redis_key, stats_data_str) await redis_conn.set(self._redis_key, stats_data_str)
@@ -103,10 +103,8 @@ class RelayWebsocketStatsManager:
""" """
Stringified verion of all the stats Stringified verion of all the stats
""" """
# Reuse cached Redis client to avoid creating new connection pools on every call redis_conn = redis.Redis.from_url(settings.BROKER_URL)
if cls._redis_client is None: stats_str = redis_conn.get(BROADCAST_WEBSOCKET_REDIS_KEY_NAME) or b''
cls._redis_client = get_redis_client()
stats_str = cls._redis_client.get(BROADCAST_WEBSOCKET_REDIS_KEY_NAME) or b''
return parser.text_string_to_metric_families(stats_str.decode('UTF-8')) return parser.text_string_to_metric_families(stats_str.decode('UTF-8'))

View File

@@ -487,7 +487,9 @@ def unified_jobs_table(since, full_path, until, **kwargs):
OR (main_unifiedjob.finished > '{0}' AND main_unifiedjob.finished <= '{1}')) OR (main_unifiedjob.finished > '{0}' AND main_unifiedjob.finished <= '{1}'))
AND main_unifiedjob.launch_type != 'sync' AND main_unifiedjob.launch_type != 'sync'
ORDER BY main_unifiedjob.id ASC) TO STDOUT WITH CSV HEADER ORDER BY main_unifiedjob.id ASC) TO STDOUT WITH CSV HEADER
'''.format(since.isoformat(), until.isoformat()) '''.format(
since.isoformat(), until.isoformat()
)
return _copy_table(table='unified_jobs', query=unified_job_query, path=full_path) return _copy_table(table='unified_jobs', query=unified_job_query, path=full_path)
@@ -548,7 +550,9 @@ def workflow_job_node_table(since, full_path, until, **kwargs):
) always_nodes ON main_workflowjobnode.id = always_nodes.from_workflowjobnode_id ) always_nodes ON main_workflowjobnode.id = always_nodes.from_workflowjobnode_id
WHERE (main_workflowjobnode.modified > '{}' AND main_workflowjobnode.modified <= '{}') WHERE (main_workflowjobnode.modified > '{}' AND main_workflowjobnode.modified <= '{}')
ORDER BY main_workflowjobnode.id ASC) TO STDOUT WITH CSV HEADER ORDER BY main_workflowjobnode.id ASC) TO STDOUT WITH CSV HEADER
'''.format(since.isoformat(), until.isoformat()) '''.format(
since.isoformat(), until.isoformat()
)
return _copy_table(table='workflow_job_node', query=workflow_job_node_query, path=full_path) return _copy_table(table='workflow_job_node', query=workflow_job_node_query, path=full_path)

View File

@@ -8,7 +8,6 @@ import pathlib
import shutil import shutil
import tarfile import tarfile
import tempfile import tempfile
from urllib.parse import urlparse, urlunparse
from django.conf import settings from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
@@ -24,8 +23,6 @@ from awx.main.models import Job
from awx.main.access import access_registry from awx.main.access import access_registry
from awx.main.utils import get_awx_http_client_headers, set_environ, datetime_hook from awx.main.utils import get_awx_http_client_headers, set_environ, datetime_hook
from awx.main.utils.analytics_proxy import OIDCClient from awx.main.utils.analytics_proxy import OIDCClient
from awx.main.utils.candlepin import get_or_generate_candlepin_certificate
from awx.main.utils.candlepin.client import _temp_cert_files
__all__ = ['register', 'gather', 'ship'] __all__ = ['register', 'gather', 'ship']
@@ -44,76 +41,6 @@ def _valid_license():
return True return True
def _get_cert_upload_url(url):
"""
Convert analytics URL to use 'cert.' subdomain for mTLS uploads.
Some analytics services use different hostnames for different auth methods:
- cert.example.com - for mTLS (certificate-based) uploads
- example.com - for OIDC (token-based) uploads
Args:
url: Original analytics URL
Returns:
URL with 'cert.' prepended to hostname if not already present
"""
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Only modify if hostname doesn't already start with 'cert.'
if hostname and not hostname.startswith('cert.'):
new_hostname = f'cert.{hostname}'
# Reconstruct URL with new hostname
netloc = new_hostname
if parsed.port:
netloc = f'{new_hostname}:{parsed.port}'
new_parsed = parsed._replace(netloc=netloc)
return urlunparse(new_parsed)
return url
except Exception as e:
logger.warning(f'Could not modify URL for cert upload: {e}, using original URL')
return url
def _get_analytics_credentials():
"""
Get Red Hat Insights credentials from settings.
Attempts to retrieve credentials in the following priority order:
1. REDHAT_USERNAME / REDHAT_PASSWORD
2. SUBSCRIPTIONS_USERNAME / SUBSCRIPTIONS_PASSWORD
3. SUBSCRIPTIONS_CLIENT_ID / SUBSCRIPTIONS_CLIENT_SECRET
Returns:
tuple: (username, password) if credentials are found, (None, None) otherwise
"""
rh_id = getattr(settings, 'REDHAT_USERNAME', None)
rh_secret = getattr(settings, 'REDHAT_PASSWORD', None)
if rh_id and rh_secret:
return rh_id, rh_secret
# Try SUBSCRIPTIONS_USERNAME / SUBSCRIPTIONS_PASSWORD
rh_id = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None)
rh_secret = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None)
if rh_id and rh_secret:
return rh_id, rh_secret
# Try SUBSCRIPTIONS_CLIENT_ID / SUBSCRIPTIONS_CLIENT_SECRET
rh_id = getattr(settings, 'SUBSCRIPTIONS_CLIENT_ID', None)
rh_secret = getattr(settings, 'SUBSCRIPTIONS_CLIENT_SECRET', None)
if rh_id and rh_secret:
return rh_id, rh_secret
return None, None
def all_collectors(): def all_collectors():
from awx.main.analytics import collectors from awx.main.analytics import collectors
@@ -257,8 +184,10 @@ def gather(dest=None, module=None, subset=None, since=None, until=None, collecti
logger.log(log_level, "Automation Analytics not enabled. Use --dry-run to gather locally without sending.") logger.log(log_level, "Automation Analytics not enabled. Use --dry-run to gather locally without sending.")
return None return None
rh_id, rh_secret = _get_analytics_credentials() if not (
if not (settings.AUTOMATION_ANALYTICS_URL and rh_id and rh_secret): settings.AUTOMATION_ANALYTICS_URL
and ((settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD) or (settings.SUBSCRIPTIONS_CLIENT_ID and settings.SUBSCRIPTIONS_CLIENT_SECRET))
):
logger.log(log_level, "Not gathering analytics, configuration is invalid. Use --dry-run to gather locally without sending.") logger.log(log_level, "Not gathering analytics, configuration is invalid. Use --dry-run to gather locally without sending.")
return None return None
@@ -439,14 +368,19 @@ def ship(path):
logger.error('AUTOMATION_ANALYTICS_URL is not set') logger.error('AUTOMATION_ANALYTICS_URL is not set')
return False return False
rh_id, rh_secret = _get_analytics_credentials() rh_id = getattr(settings, 'REDHAT_USERNAME', None)
rh_secret = getattr(settings, 'REDHAT_PASSWORD', None)
if not (rh_id and rh_secret):
rh_id = getattr(settings, 'SUBSCRIPTIONS_CLIENT_ID', None)
rh_secret = getattr(settings, 'SUBSCRIPTIONS_CLIENT_SECRET', None)
if not rh_id: if not rh_id:
logger.error('No valid username found. Tried: REDHAT_USERNAME, SUBSCRIPTIONS_USERNAME, SUBSCRIPTIONS_CLIENT_ID') logger.error('Neither REDHAT_USERNAME nor SUBSCRIPTIONS_CLIENT_ID are set')
return False return False
if not rh_secret: if not rh_secret:
logger.error('No valid password found. Tried: REDHAT_PASSWORD, SUBSCRIPTIONS_PASSWORD, SUBSCRIPTIONS_CLIENT_SECRET') logger.error('Neither REDHAT_PASSWORD nor SUBSCRIPTIONS_CLIENT_SECRET are set')
return False return False
with open(path, 'rb') as f: with open(path, 'rb') as f:
@@ -454,40 +388,17 @@ def ship(path):
s = requests.Session() s = requests.Session()
s.headers = get_awx_http_client_headers() s.headers = get_awx_http_client_headers()
s.headers.pop('Content-Type') s.headers.pop('Content-Type')
with set_environ(**settings.AWX_TASK_ENV): with set_environ(**settings.AWX_TASK_ENV):
# Try Certificate-based mTLS authentication (zero-touch)
cert_pem, key_pem = get_or_generate_candlepin_certificate()
if cert_pem and key_pem:
# Use cert. subdomain for mTLS uploads
cert_url = _get_cert_upload_url(url)
logger.debug("Attempting certificate-based authentication for analytics upload")
try:
with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path):
response = s.post(
cert_url, files=files, cert=(cert_path, key_path), verify=settings.INSIGHTS_CERT_PATH, headers=s.headers, timeout=(31, 31)
)
if response.status_code < 300:
return True
else:
logger.warning(
f'Certificate-based authentication failed with status {response.status_code}, {response.text}. Falling back to OIDC auth'
)
except Exception as e:
logger.warning(f"Certificate-based authentication failed: {e}, falling back to OIDC auth")
# Try OIDC authentication
logger.debug("Attempting OIDC authentication for analytics upload")
f.seek(0) # requests POST may read from the handler, so seek to beginning of file for the next POST attempt
try: try:
client = OIDCClient(rh_id, rh_secret) client = OIDCClient(rh_id, rh_secret)
response = client.make_request("POST", url, headers=s.headers, files=files, verify=settings.INSIGHTS_CERT_PATH, timeout=(31, 31)) response = client.make_request("POST", url, headers=s.headers, files=files, verify=settings.INSIGHTS_CERT_PATH, timeout=(31, 31))
except requests.RequestException:
logger.error("Automation Analytics API request failed, trying base auth method")
response = s.post(url, files=files, verify=settings.INSIGHTS_CERT_PATH, auth=(rh_id, rh_secret), headers=s.headers, timeout=(31, 31))
if response.status_code < 300: # Accept 2XX status_codes
return True if response.status_code >= 300:
else: logger.error('Upload failed with status {}, {}'.format(response.status_code, response.text))
logger.error(f'OIDC authentication failed with status {response.status_code}, {response.text}') return False
return False
except requests.RequestException as e: return True
logger.error(f"OIDC authentication failed: {e}")
return False

View File

@@ -1,41 +0,0 @@
import http.client
import socket
import urllib.error
import urllib.request
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
def get_dispatcherd_metrics(request):
metrics_cfg = settings.METRICS_SUBSYSTEM_CONFIG.get('server', {}).get(settings.METRICS_SERVICE_DISPATCHER, {})
host = metrics_cfg.get('host', 'localhost')
port = metrics_cfg.get('port', 8015)
metrics_filter = []
if request is not None and hasattr(request, "query_params"):
try:
nodes_filter = request.query_params.getlist("node")
except Exception:
nodes_filter = []
if nodes_filter and settings.CLUSTER_HOST_ID not in nodes_filter:
return ''
try:
metrics_filter = request.query_params.getlist("metric")
except Exception:
metrics_filter = []
if metrics_filter:
# Right now we have no way of filtering the dispatcherd metrics
# so just avoid getting in the way if another metric is filtered for
return ''
url = f"http://{host}:{port}/metrics"
try:
with urllib.request.urlopen(url, timeout=1.0) as response:
payload = response.read()
if not payload:
return ''
return payload.decode('utf-8')
except (urllib.error.URLError, UnicodeError, socket.timeout, TimeoutError, http.client.HTTPException) as exc:
logger.debug(f"Failed to collect dispatcherd metrics from {url}: {exc}")
return ''

View File

@@ -14,8 +14,6 @@ from rest_framework.request import Request
from awx.main.consumers import emit_channel_notification from awx.main.consumers import emit_channel_notification
from awx.main.utils import is_testing from awx.main.utils import is_testing
from awx.main.utils.redis import get_redis_client
from .dispatcherd_metrics import get_dispatcherd_metrics
root_key = settings.SUBSYSTEM_METRICS_REDIS_KEY_PREFIX root_key = settings.SUBSYSTEM_METRICS_REDIS_KEY_PREFIX
logger = logging.getLogger('awx.main.analytics') logger = logging.getLogger('awx.main.analytics')
@@ -46,12 +44,11 @@ class MetricsServer(MetricsServerSettings):
class BaseM: class BaseM:
def __init__(self, field, help_text, labels=None): def __init__(self, field, help_text):
self.field = field self.field = field
self.help_text = help_text self.help_text = help_text
self.current_value = 0 self.current_value = 0
self.metric_has_changed = False self.metric_has_changed = False
self.labels = labels or {}
def reset_value(self, conn): def reset_value(self, conn):
conn.hset(root_key, self.field, 0) conn.hset(root_key, self.field, 0)
@@ -72,16 +69,12 @@ class BaseM:
value = conn.hget(root_key, self.field) value = conn.hget(root_key, self.field)
return self.decode_value(value) return self.decode_value(value)
def to_prometheus(self, instance_data, namespace=None): def to_prometheus(self, instance_data):
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} gauge\n" output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} gauge\n"
for instance in instance_data: for instance in instance_data:
if self.field in instance_data[instance]: if self.field in instance_data[instance]:
# Build label string
labels = f'node="{instance}"'
if namespace:
labels += f',subsystem="{namespace}"'
# on upgrade, if there are stale instances, we can end up with issues where new metrics are not present # on upgrade, if there are stale instances, we can end up with issues where new metrics are not present
output_text += f'{self.field}{{{labels}}} {instance_data[instance][self.field]}\n' output_text += f'{self.field}{{node="{instance}"}} {instance_data[instance][self.field]}\n'
return output_text return output_text
@@ -174,17 +167,14 @@ class HistogramM(BaseM):
self.sum.store_value(conn) self.sum.store_value(conn)
self.inf.store_value(conn) self.inf.store_value(conn)
def to_prometheus(self, instance_data, namespace=None): def to_prometheus(self, instance_data):
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} histogram\n" output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} histogram\n"
for instance in instance_data: for instance in instance_data:
# Build label string
node_label = f'node="{instance}"'
subsystem_label = f',subsystem="{namespace}"' if namespace else ''
for i, b in enumerate(self.buckets): for i, b in enumerate(self.buckets):
output_text += f'{self.field}_bucket{{le="{b}",{node_label}{subsystem_label}}} {sum(instance_data[instance][self.field]["counts"][0:i+1])}\n' output_text += f'{self.field}_bucket{{le="{b}",node="{instance}"}} {sum(instance_data[instance][self.field]["counts"][0:i+1])}\n'
output_text += f'{self.field}_bucket{{le="+Inf",{node_label}{subsystem_label}}} {instance_data[instance][self.field]["inf"]}\n' output_text += f'{self.field}_bucket{{le="+Inf",node="{instance}"}} {instance_data[instance][self.field]["inf"]}\n'
output_text += f'{self.field}_count{{{node_label}{subsystem_label}}} {instance_data[instance][self.field]["inf"]}\n' output_text += f'{self.field}_count{{node="{instance}"}} {instance_data[instance][self.field]["inf"]}\n'
output_text += f'{self.field}_sum{{{node_label}{subsystem_label}}} {instance_data[instance][self.field]["sum"]}\n' output_text += f'{self.field}_sum{{node="{instance}"}} {instance_data[instance][self.field]["sum"]}\n'
return output_text return output_text
@@ -200,8 +190,8 @@ class Metrics(MetricsNamespace):
def __init__(self, namespace, auto_pipe_execute=False, instance_name=None, metrics_have_changed=True, **kwargs): def __init__(self, namespace, auto_pipe_execute=False, instance_name=None, metrics_have_changed=True, **kwargs):
MetricsNamespace.__init__(self, namespace) MetricsNamespace.__init__(self, namespace)
self.conn = get_redis_client() self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
self.pipe = self.conn.pipeline() self.conn = redis.Redis.from_url(settings.BROKER_URL)
self.last_pipe_execute = time.time() self.last_pipe_execute = time.time()
# track if metrics have been modified since last saved to redis # track if metrics have been modified since last saved to redis
# start with True so that we get an initial save to redis # start with True so that we get an initial save to redis
@@ -283,22 +273,20 @@ class Metrics(MetricsNamespace):
def pipe_execute(self): def pipe_execute(self):
if self.metrics_have_changed is True: if self.metrics_have_changed is True:
duration_pipe_exec = time.perf_counter() duration_to_save = time.perf_counter()
for m in self.METRICS: for m in self.METRICS:
self.METRICS[m].store_value(self.pipe) self.METRICS[m].store_value(self.pipe)
self.pipe.execute() self.pipe.execute()
self.last_pipe_execute = time.time() self.last_pipe_execute = time.time()
self.metrics_have_changed = False self.metrics_have_changed = False
duration_pipe_exec = time.perf_counter() - duration_pipe_exec duration_to_save = time.perf_counter() - duration_to_save
self.METRICS['subsystem_metrics_pipe_execute_seconds'].inc(duration_to_save)
duration_send_metrics = time.perf_counter()
self.send_metrics()
duration_send_metrics = time.perf_counter() - duration_send_metrics
# Increment operational metrics
self.METRICS['subsystem_metrics_pipe_execute_seconds'].inc(duration_pipe_exec)
self.METRICS['subsystem_metrics_pipe_execute_calls'].inc(1) self.METRICS['subsystem_metrics_pipe_execute_calls'].inc(1)
self.METRICS['subsystem_metrics_send_metrics_seconds'].inc(duration_send_metrics)
duration_to_save = time.perf_counter()
self.send_metrics()
duration_to_save = time.perf_counter() - duration_to_save
self.METRICS['subsystem_metrics_send_metrics_seconds'].inc(duration_to_save)
def send_metrics(self): def send_metrics(self):
# more than one thread could be calling this at the same time, so should # more than one thread could be calling this at the same time, so should
@@ -364,13 +352,7 @@ class Metrics(MetricsNamespace):
if instance_data: if instance_data:
for field in self.METRICS: for field in self.METRICS:
if len(metrics_filter) == 0 or field in metrics_filter: if len(metrics_filter) == 0 or field in metrics_filter:
# Add subsystem label only for operational metrics output_text += self.METRICS[field].to_prometheus(instance_data)
namespace = (
self._namespace
if field in ['subsystem_metrics_pipe_execute_seconds', 'subsystem_metrics_pipe_execute_calls', 'subsystem_metrics_send_metrics_seconds']
else None
)
output_text += self.METRICS[field].to_prometheus(instance_data, namespace)
return output_text return output_text
@@ -399,6 +381,11 @@ class DispatcherMetrics(Metrics):
SetFloatM('workflow_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'), SetFloatM('workflow_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
SetFloatM('workflow_manager_spawn_workflow_graph_jobs_seconds', 'Time spent spawning workflow tasks'), SetFloatM('workflow_manager_spawn_workflow_graph_jobs_seconds', 'Time spent spawning workflow tasks'),
SetFloatM('workflow_manager_get_tasks_seconds', 'Time spent loading workflow tasks from db'), SetFloatM('workflow_manager_get_tasks_seconds', 'Time spent loading workflow tasks from db'),
# dispatcher subsystem metrics
SetIntM('dispatcher_pool_scale_up_events', 'Number of times local dispatcher scaled up a worker since startup'),
SetIntM('dispatcher_pool_active_task_count', 'Number of active tasks in the worker pool when last task was submitted'),
SetIntM('dispatcher_pool_max_worker_count', 'Highest number of workers in worker pool in last collection interval, about 20s'),
SetFloatM('dispatcher_availability', 'Fraction of time (in last collection interval) dispatcher was able to receive messages'),
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -426,12 +413,8 @@ class CallbackReceiverMetrics(Metrics):
def metrics(request): def metrics(request):
output_text = '' output_text = ''
output_text += DispatcherMetrics().generate_metrics(request) for m in [DispatcherMetrics(), CallbackReceiverMetrics()]:
output_text += CallbackReceiverMetrics().generate_metrics(request) output_text += m.generate_metrics(request)
dispatcherd_metrics = get_dispatcherd_metrics(request)
if dispatcherd_metrics:
output_text += dispatcherd_metrics
return output_text return output_text
@@ -457,10 +440,7 @@ class CustomToPrometheusMetricsCollector(prometheus_client.registry.Collector):
logger.debug(f"No metric data not found in redis for metric namespace '{self._metrics._namespace}'") logger.debug(f"No metric data not found in redis for metric namespace '{self._metrics._namespace}'")
return None return None
if not (host_metrics := instance_data.get(my_hostname)): host_metrics = instance_data.get(my_hostname)
logger.debug(f"Metric data for this node '{my_hostname}' not found in redis for metric namespace '{self._metrics._namespace}'")
return None
for _, metric in self._metrics.METRICS.items(): for _, metric in self._metrics.METRICS.items():
entry = host_metrics.get(metric.field) entry = host_metrics.get(metric.field)
if not entry: if not entry:
@@ -481,6 +461,13 @@ class CallbackReceiverMetricsServer(MetricsServer):
super().__init__(settings.METRICS_SERVICE_CALLBACK_RECEIVER, registry) super().__init__(settings.METRICS_SERVICE_CALLBACK_RECEIVER, registry)
class DispatcherMetricsServer(MetricsServer):
def __init__(self):
registry = CollectorRegistry(auto_describe=True)
registry.register(CustomToPrometheusMetricsCollector(DispatcherMetrics(metrics_have_changed=False)))
super().__init__(settings.METRICS_SERVICE_DISPATCHER, registry)
class WebsocketsMetricsServer(MetricsServer): class WebsocketsMetricsServer(MetricsServer):
def __init__(self): def __init__(self):
registry = CollectorRegistry(auto_describe=True) registry = CollectorRegistry(auto_describe=True)

View File

@@ -1,25 +1,22 @@
import os
from dispatcherd.config import setup as dispatcher_setup from dispatcherd.config import setup as dispatcher_setup
from django.apps import AppConfig from django.apps import AppConfig
from django.db import connection from django.db import connection
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.management.base import CommandError from awx.main.utils.common import bypass_in_test, load_all_entry_points_for
from django.db.models.signals import pre_migrate from awx.main.utils.migration import is_database_synchronized
from awx.main.utils.named_url_graph import _customize_graph, generate_graph from awx.main.utils.named_url_graph import _customize_graph, generate_graph
from awx.main.utils.db import db_requirement_violations
from awx.conf import register, fields from awx.conf import register, fields
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
class MainConfig(AppConfig): class MainConfig(AppConfig):
name = 'awx.main' name = 'awx.main'
verbose_name = _('Main') verbose_name = _('Main')
def check_db_requirement(self, *args, **kwargs):
violations = db_requirement_violations()
if violations:
raise CommandError(violations)
def load_named_url_feature(self): def load_named_url_feature(self):
models = [m for m in self.get_models() if hasattr(m, 'get_absolute_url')] models = [m for m in self.get_models() if hasattr(m, 'get_absolute_url')]
generate_graph(models) generate_graph(models)
@@ -46,10 +43,46 @@ class MainConfig(AppConfig):
category_slug='named-url', category_slug='named-url',
) )
def _load_credential_types_feature(self):
"""
Create CredentialType records for any discovered credentials.
Note that Django docs advise _against_ interacting with the database using
the ORM models in the ready() path. Specifically, during testing.
However, we explicitly use the @bypass_in_test decorator to avoid calling this
method during testing.
Django also advises against running pattern because it runs everywhere i.e.
every management command. We use an advisory lock to ensure correctness and
we will deal performance if it becomes an issue.
"""
from awx.main.models.credential import CredentialType
if is_database_synchronized():
CredentialType.setup_tower_managed_defaults(app_config=self)
@bypass_in_test
def load_credential_types_feature(self):
from awx.main.models.credential import load_credentials
load_credentials()
return self._load_credential_types_feature()
def load_inventory_plugins(self):
from awx.main.models.inventory import InventorySourceOptions
is_awx = detect_server_product_name() == 'AWX'
extra_entry_point_groups = () if is_awx else ('inventory.supported',)
entry_points = load_all_entry_points_for(['inventory', *extra_entry_point_groups])
for entry_point_name, entry_point in entry_points.items():
cls = entry_point.load()
InventorySourceOptions.injectors[entry_point_name] = cls
def configure_dispatcherd(self): def configure_dispatcherd(self):
"""This implements the default configuration for dispatcherd """This implements the default configuration for dispatcherd
If running the tasking service like awx-manage dispatcherd, If running the tasking service like awx-manage run_dispatcher,
some additional config will be applied on top of this. some additional config will be applied on top of this.
This configuration provides the minimum such that code can submit This configuration provides the minimum such that code can submit
tasks to pg_notify to run those tasks. tasks to pg_notify to run those tasks.
@@ -67,5 +100,13 @@ class MainConfig(AppConfig):
super().ready() super().ready()
self.configure_dispatcherd() self.configure_dispatcherd()
"""
Credential loading triggers database operations. There are cases we want to call
awx-manage collectstatic without a database. All management commands invoke the ready() code
path. Using settings.AWX_SKIP_CREDENTIAL_TYPES_DISCOVER _could_ invoke a database operation.
"""
if not os.environ.get('AWX_SKIP_CREDENTIAL_TYPES_DISCOVER', None):
self.load_credential_types_feature()
self.load_named_url_feature() self.load_named_url_feature()
pre_migrate.connect(self.check_db_requirement, sender=self) self.load_inventory_plugins()

87
awx/main/cache.py Normal file
View File

@@ -0,0 +1,87 @@
import functools
from django.conf import settings
from django.core.cache.backends.base import DEFAULT_TIMEOUT
from django.core.cache.backends.redis import RedisCache
from redis.exceptions import ConnectionError, ResponseError, TimeoutError
import socket
# This list comes from what django-redis ignores and the behavior we are trying
# to retain while dropping the dependency on django-redis.
IGNORED_EXCEPTIONS = (TimeoutError, ResponseError, ConnectionError, socket.timeout)
CONNECTION_INTERRUPTED_SENTINEL = object()
def optionally_ignore_exceptions(func=None, return_value=None):
if func is None:
return functools.partial(optionally_ignore_exceptions, return_value=return_value)
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except IGNORED_EXCEPTIONS as e:
if settings.DJANGO_REDIS_IGNORE_EXCEPTIONS:
return return_value
raise e.__cause__ or e
return wrapper
class AWXRedisCache(RedisCache):
"""
We just want to wrap the upstream RedisCache class so that we can ignore
the exceptions that it raises when the cache is unavailable.
"""
@optionally_ignore_exceptions
def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
return super().add(key, value, timeout, version)
@optionally_ignore_exceptions(return_value=CONNECTION_INTERRUPTED_SENTINEL)
def _get(self, key, default=None, version=None):
return super().get(key, default, version)
def get(self, key, default=None, version=None):
value = self._get(key, default, version)
if value is CONNECTION_INTERRUPTED_SENTINEL:
return default
return value
@optionally_ignore_exceptions
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
return super().set(key, value, timeout, version)
@optionally_ignore_exceptions
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
return super().touch(key, timeout, version)
@optionally_ignore_exceptions
def delete(self, key, version=None):
return super().delete(key, version)
@optionally_ignore_exceptions
def get_many(self, keys, version=None):
return super().get_many(keys, version)
@optionally_ignore_exceptions
def has_key(self, key, version=None):
return super().has_key(key, version)
@optionally_ignore_exceptions
def incr(self, key, delta=1, version=None):
return super().incr(key, delta, version)
@optionally_ignore_exceptions
def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None):
return super().set_many(data, timeout, version)
@optionally_ignore_exceptions
def delete_many(self, keys, version=None):
return super().delete_many(keys, version)
@optionally_ignore_exceptions
def clear(self):
return super().clear()

Some files were not shown because too many files have changed in this diff Show More