mirror of
https://github.com/ansible/awx.git
synced 2026-08-01 10:29:56 -02:30
Compare commits
116 Commits
23.1.0
...
feature_ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a689f87f1c | ||
|
|
7501ad6836 | ||
|
|
dd00bbba42 | ||
|
|
fe6bac6d9e | ||
|
|
87abbd4b10 | ||
|
|
fb04e5d9f6 | ||
|
|
478e2cb28d | ||
|
|
2ac304d289 | ||
|
|
3e5851f3af | ||
|
|
adb1b12074 | ||
|
|
8fae20c48a | ||
|
|
ec364cc60e | ||
|
|
1cfd51764e | ||
|
|
0b8fedfd04 | ||
|
|
72a8173462 | ||
|
|
873b1fbe07 | ||
|
|
1f36e84b45 | ||
|
|
8c4bff2b86 | ||
|
|
14f636af84 | ||
|
|
0057c8daf6 | ||
|
|
d8a28b3c06 | ||
|
|
40c2b700fe | ||
|
|
71d548f9e5 | ||
|
|
dd98963f86 | ||
|
|
4b467dfd8d | ||
|
|
456b56778e | ||
|
|
5b3cb20f92 | ||
|
|
d7086a3c88 | ||
|
|
21e7ab078c | ||
|
|
946ca0b3b8 | ||
|
|
b831dbd608 | ||
|
|
943e455f9d | ||
|
|
53bc88abe2 | ||
|
|
3b4d95633e | ||
|
|
93c329d9d5 | ||
|
|
f4c53aaf22 | ||
|
|
333ef76cbd | ||
|
|
fc0b58fd04 | ||
|
|
bef0a8b23a | ||
|
|
a5f33456b6 | ||
|
|
21fb395912 | ||
|
|
44255f378d | ||
|
|
71a6d48612 | ||
|
|
b7e5f5d1e1 | ||
|
|
b6b167627c | ||
|
|
20f5b255c9 | ||
|
|
3bcf46555d | ||
|
|
94703ccf84 | ||
|
|
6cdea1909d | ||
|
|
f133580172 | ||
|
|
4b90a7fcd1 | ||
|
|
95bfedad5b | ||
|
|
1081f2d8e9 | ||
|
|
c4ab54d7f3 | ||
|
|
bcefcd8cf8 | ||
|
|
0bd057529d | ||
|
|
a82c03e2e2 | ||
|
|
447ac77535 | ||
|
|
72d0928f1b | ||
|
|
6d727d4bc4 | ||
|
|
6040e44d9d | ||
|
|
b99ce5cd62 | ||
|
|
ba8a90c55f | ||
|
|
7ee2172517 | ||
|
|
07f49f5925 | ||
|
|
376993077a | ||
|
|
48f586bac4 | ||
|
|
16dab57c63 | ||
|
|
75a71492fd | ||
|
|
e9bd99c1ff | ||
|
|
56878b4910 | ||
|
|
19ca480078 | ||
|
|
64eb963025 | ||
|
|
dc34d0887a | ||
|
|
160634fb6f | ||
|
|
9745058546 | ||
|
|
c97a48b165 | ||
|
|
259bca0113 | ||
|
|
92c2b4e983 | ||
|
|
127a0cff23 | ||
|
|
a0ef25006a | ||
|
|
50c98a52f7 | ||
|
|
4008d72af6 | ||
|
|
e72e9f94b9 | ||
|
|
9d60b0b9c6 | ||
|
|
05b58c4df6 | ||
|
|
b1b960fd17 | ||
|
|
3c8f71e559 | ||
|
|
f5922f76fa | ||
|
|
05582702c6 | ||
|
|
1d340c5b4e | ||
|
|
15925f1416 | ||
|
|
6e06a20cca | ||
|
|
bb3acbb8ad | ||
|
|
a88e47930c | ||
|
|
a0d4515ba4 | ||
|
|
770cc10a78 | ||
|
|
159dd62d84 | ||
|
|
640e5db9c6 | ||
|
|
9ed527eb26 | ||
|
|
29ad6e1eaa | ||
|
|
3e607f8964 | ||
|
|
c9d1a4d063 | ||
|
|
a290b082db | ||
|
|
6d3c22e801 | ||
|
|
1f91773a3c | ||
|
|
7b846e1e49 | ||
|
|
f7a2de8a07 | ||
|
|
194c214f03 | ||
|
|
77e30dd4b2 | ||
|
|
9d7421b9bc | ||
|
|
3b8e662916 | ||
|
|
aa3228eec9 | ||
|
|
7b0598c7d8 | ||
|
|
49832d6379 | ||
|
|
8feeb5f1fa |
28
.github/actions/awx_devel_image/action.yml
vendored
Normal file
28
.github/actions/awx_devel_image/action.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Setup images for AWX
|
||||||
|
description: Builds new awx_devel image
|
||||||
|
inputs:
|
||||||
|
github-token:
|
||||||
|
description: GitHub Token for registry access
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Get python version from Makefile
|
||||||
|
shell: bash
|
||||||
|
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "${{ inputs.github-token }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Pre-pull latest devel image to warm cache
|
||||||
|
shell: bash
|
||||||
|
run: docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ github.base_ref }}
|
||||||
|
|
||||||
|
- name: Build image for current source checkout
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
DEV_DOCKER_TAG_BASE=ghcr.io/${{ github.repository_owner }} \
|
||||||
|
COMPOSE_TAG=${{ github.base_ref }} \
|
||||||
|
make docker-compose-build
|
||||||
40
.github/actions/run_awx_devel/action.yml
vendored
40
.github/actions/run_awx_devel/action.yml
vendored
@@ -1,12 +1,8 @@
|
|||||||
# This currently *always* uses the "warm build cache" image
|
name: Run AWX docker-compose
|
||||||
# We should do something to allow forcing a rebuild, probably by looking for
|
|
||||||
# some string in the commit message or something.
|
|
||||||
|
|
||||||
name: Run AWX (devel environment)
|
|
||||||
description: Runs AWX with `make docker-compose`
|
description: Runs AWX with `make docker-compose`
|
||||||
inputs:
|
inputs:
|
||||||
github-token:
|
github-token:
|
||||||
description: GitHub Token for registry access
|
description: GitHub Token to pass to awx_devel_image
|
||||||
required: true
|
required: true
|
||||||
build-ui:
|
build-ui:
|
||||||
description: Should the UI be built?
|
description: Should the UI be built?
|
||||||
@@ -23,9 +19,10 @@ outputs:
|
|||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Get python version from Makefile
|
- name: Build awx_devel image for running checks
|
||||||
shell: bash
|
uses: ./.github/actions/awx_devel_image
|
||||||
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
|
with:
|
||||||
|
github-token: ${{ inputs.github-token }}
|
||||||
|
|
||||||
- name: Upgrade ansible-core
|
- name: Upgrade ansible-core
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -35,19 +32,6 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: sudo apt-get install -y gettext
|
run: sudo apt-get install -y gettext
|
||||||
|
|
||||||
- name: Log in to registry
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "${{ inputs.github-token }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
|
||||||
|
|
||||||
- name: Pre-pull latest available devel image and build HEAD on top of it
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ github.base_ref }}
|
|
||||||
DEV_DOCKER_TAG_BASE=ghcr.io/${{ github.repository_owner }} \
|
|
||||||
COMPOSE_TAG=${{ github.base_ref }} \
|
|
||||||
make docker-compose-build
|
|
||||||
|
|
||||||
- name: Start AWX
|
- name: Start AWX
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -59,10 +43,14 @@ runs:
|
|||||||
- name: Update default AWX password
|
- name: Update default AWX password
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' -k https://localhost:8043/api/v2/ping/)" != "200" ]]
|
SECONDS=0
|
||||||
do
|
while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' -k https://localhost:8043/api/v2/ping/)" != "200" ]]; do
|
||||||
echo "Waiting for AWX..."
|
if [[ $SECONDS -gt 600 ]]; then
|
||||||
sleep 5
|
echo "Timing out, AWX never came up"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for AWX..."
|
||||||
|
sleep 5
|
||||||
done
|
done
|
||||||
echo "AWX is up, updating the password..."
|
echo "AWX is up, updating the password..."
|
||||||
docker exec -i tools_awx_1 sh <<-EOSH
|
docker exec -i tools_awx_1 sh <<-EOSH
|
||||||
|
|||||||
4
.github/triage_replies.md
vendored
4
.github/triage_replies.md
vendored
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
## PRs/Issues
|
## PRs/Issues
|
||||||
|
|
||||||
### Visit our mailing list
|
### Visit the Forum or Matrix
|
||||||
- Hello, this appears to be less of a bug report or feature request and more of a question. Could you please ask this on our mailing list? See https://github.com/ansible/awx/#get-involved for information for ways to connect with us.
|
- Hello, this appears to be less of a bug report or feature request and more of a question. Could you please ask this on either the [Ansible AWX channel on Matrix](https://matrix.to/#/#awx:ansible.com) or the [Ansible Community Forum](https://forum.ansible.com/tag/awx)?
|
||||||
|
|
||||||
### Denied Submission
|
### Denied Submission
|
||||||
|
|
||||||
|
|||||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -7,13 +7,11 @@ env:
|
|||||||
COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
|
COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
|
||||||
- 'docs/**'
|
|
||||||
- '.github/workflows/docs.yml'
|
|
||||||
jobs:
|
jobs:
|
||||||
common-tests:
|
common-tests:
|
||||||
name: ${{ matrix.tests.name }}
|
name: ${{ matrix.tests.name }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -23,6 +21,8 @@ jobs:
|
|||||||
tests:
|
tests:
|
||||||
- name: api-test
|
- name: api-test
|
||||||
command: /start_tests.sh
|
command: /start_tests.sh
|
||||||
|
- name: api-migrations
|
||||||
|
command: /start_tests.sh test_migrations
|
||||||
- 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
|
||||||
- name: api-swagger
|
- name: api-swagger
|
||||||
@@ -40,19 +40,32 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build awx_devel image for running checks
|
||||||
|
uses: ./.github/actions/awx_devel_image
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Run check ${{ matrix.tests.name }}
|
- name: Run check ${{ matrix.tests.name }}
|
||||||
run: AWX_DOCKER_CMD='${{ matrix.tests.command }}' make github_ci_runner
|
run: AWX_DOCKER_CMD='${{ matrix.tests.command }}' make docker-runner
|
||||||
|
|
||||||
dev-env:
|
dev-env:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: ./.github/actions/run_awx_devel
|
||||||
|
id: awx
|
||||||
|
with:
|
||||||
|
build-ui: false
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Run smoke test
|
- name: Run smoke test
|
||||||
run: make github_ci_setup && ansible-playbook tools/docker-compose/ansible/smoke-test.yml -v
|
run: ansible-playbook tools/docker-compose/ansible/smoke-test.yml -v
|
||||||
|
|
||||||
awx-operator:
|
awx-operator:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout awx
|
- name: Checkout awx
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -102,6 +115,7 @@ jobs:
|
|||||||
collection-sanity:
|
collection-sanity:
|
||||||
name: awx_collection sanity
|
name: awx_collection sanity
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
@@ -121,6 +135,7 @@ jobs:
|
|||||||
collection-integration:
|
collection-integration:
|
||||||
name: awx_collection integration
|
name: awx_collection integration
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -159,17 +174,20 @@ jobs:
|
|||||||
|
|
||||||
# Upload coverage report as artifact
|
# Upload coverage report as artifact
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ matrix.target-regex.name }}
|
name: coverage-${{ matrix.target-regex.name }}
|
||||||
path: ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage/
|
path: ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage/
|
||||||
|
|
||||||
- uses: ./.github/actions/upload_awx_devel_logs
|
- uses: ./.github/actions/upload_awx_devel_logs
|
||||||
|
if: always()
|
||||||
with:
|
with:
|
||||||
log-filename: collection-integration-${{ matrix.target-regex.name }}.log
|
log-filename: collection-integration-${{ matrix.target-regex.name }}.log
|
||||||
|
|
||||||
collection-integration-coverage-combine:
|
collection-integration-coverage-combine:
|
||||||
name: combine awx_collection integration coverage
|
name: combine awx_collection integration coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
needs:
|
needs:
|
||||||
- collection-integration
|
- collection-integration
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
1
.github/workflows/devel_images.yml
vendored
1
.github/workflows/devel_images.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
|||||||
push:
|
push:
|
||||||
if: endsWith(github.repository, '/awx') || startsWith(github.ref, 'refs/heads/release_')
|
if: endsWith(github.repository, '/awx') || startsWith(github.ref, 'refs/heads/release_')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -2,13 +2,11 @@
|
|||||||
name: Docsite CI
|
name: Docsite CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- 'docs/**'
|
|
||||||
- '.github/workflows/docs.yml'
|
|
||||||
jobs:
|
jobs:
|
||||||
docsite-build:
|
docsite-build:
|
||||||
name: docsite test build
|
name: docsite test build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/e2e_test.yml
vendored
2
.github/workflows/e2e_test.yml
vendored
@@ -26,7 +26,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
build-ui: true
|
build-ui: true
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
log-filename: e2e-${{ matrix.job }}.log
|
|
||||||
|
|
||||||
- name: Pull awx_cypress_base image
|
- name: Pull awx_cypress_base image
|
||||||
run: |
|
run: |
|
||||||
@@ -71,5 +70,6 @@ jobs:
|
|||||||
awx-pf-tests run --project .
|
awx-pf-tests run --project .
|
||||||
|
|
||||||
- uses: ./.github/actions/upload_awx_devel_logs
|
- uses: ./.github/actions/upload_awx_devel_logs
|
||||||
|
if: always()
|
||||||
with:
|
with:
|
||||||
log-filename: e2e-${{ matrix.job }}.log
|
log-filename: e2e-${{ matrix.job }}.log
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
push:
|
push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
2
.github/workflows/label_issue.yml
vendored
2
.github/workflows/label_issue.yml
vendored
@@ -13,6 +13,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
triage:
|
triage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
name: Label Issue
|
name: Label Issue
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -26,6 +27,7 @@ jobs:
|
|||||||
|
|
||||||
community:
|
community:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
name: Label Issue - Community
|
name: Label Issue - Community
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
2
.github/workflows/label_pr.yml
vendored
2
.github/workflows/label_pr.yml
vendored
@@ -14,6 +14,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
triage:
|
triage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
name: Label PR
|
name: Label PR
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -25,6 +26,7 @@ jobs:
|
|||||||
|
|
||||||
community:
|
community:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
name: Label PR - Community
|
name: Label PR - Community
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
1
.github/workflows/pr_body_check.yml
vendored
1
.github/workflows/pr_body_check.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'ansible' && endsWith(github.repository, 'awx')
|
if: github.repository_owner == 'ansible' && endsWith(github.repository, 'awx')
|
||||||
name: Scan PR description for semantic versioning keywords
|
name: Scan PR description for semantic versioning keywords
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
9
.github/workflows/promote.yml
vendored
9
.github/workflows/promote.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
|||||||
promote:
|
promote:
|
||||||
if: endsWith(github.repository, '/awx')
|
if: endsWith(github.repository, '/awx')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout awx
|
- name: Checkout awx
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -40,9 +41,13 @@ jobs:
|
|||||||
if: ${{ github.repository_owner != 'ansible' }}
|
if: ${{ github.repository_owner != 'ansible' }}
|
||||||
|
|
||||||
- name: Build collection and publish to galaxy
|
- name: Build collection and publish to galaxy
|
||||||
|
env:
|
||||||
|
COLLECTION_NAMESPACE: ${{ env.collection_namespace }}
|
||||||
|
COLLECTION_VERSION: ${{ github.event.release.tag_name }}
|
||||||
|
COLLECTION_TEMPLATE_VERSION: true
|
||||||
run: |
|
run: |
|
||||||
COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
|
make build_collection
|
||||||
if [ "$(curl --head -sw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz | tail -1)" == "302" ] ; then \
|
if [ "$(curl -L --head -sw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz | tail -1)" == "302" ] ; then \
|
||||||
echo "Galaxy release already done"; \
|
echo "Galaxy release already done"; \
|
||||||
else \
|
else \
|
||||||
ansible-galaxy collection publish \
|
ansible-galaxy collection publish \
|
||||||
|
|||||||
1
.github/workflows/stage.yml
vendored
1
.github/workflows/stage.yml
vendored
@@ -23,6 +23,7 @@ jobs:
|
|||||||
stage:
|
stage:
|
||||||
if: endsWith(github.repository, '/awx')
|
if: endsWith(github.repository, '/awx')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
1
.github/workflows/update_dependabot_prs.yml
vendored
1
.github/workflows/update_dependabot_prs.yml
vendored
@@ -9,6 +9,7 @@ jobs:
|
|||||||
name: Update Dependabot Prs
|
name: Update Dependabot Prs
|
||||||
if: contains(github.event.pull_request.labels.*.name, 'dependencies') && contains(github.event.pull_request.labels.*.name, 'component:ui')
|
if: contains(github.event.pull_request.labels.*.name, 'dependencies') && contains(github.event.pull_request.labels.*.name, 'component:ui')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout branch
|
- name: Checkout branch
|
||||||
|
|||||||
1
.github/workflows/upload_schema.yml
vendored
1
.github/workflows/upload_schema.yml
vendored
@@ -13,6 +13,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
push:
|
push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
5
.pip-tools.toml
Normal file
5
.pip-tools.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[tool.pip-tools]
|
||||||
|
resolver = "backtracking"
|
||||||
|
allow-unsafe = true
|
||||||
|
strip-extras = true
|
||||||
|
quiet = true
|
||||||
@@ -10,6 +10,7 @@ build:
|
|||||||
3.11
|
3.11
|
||||||
commands:
|
commands:
|
||||||
- pip install --user tox
|
- pip install --user tox
|
||||||
- python3 -m tox -e docs
|
- python3 -m tox -e docs --notest -v
|
||||||
|
- python3 -m tox -e docs --skip-pkg-install -q
|
||||||
- mkdir -p _readthedocs/html/
|
- mkdir -p _readthedocs/html/
|
||||||
- mv docs/docsite/build/html/* _readthedocs/html/
|
- mv docs/docsite/build/html/* _readthedocs/html/
|
||||||
|
|||||||
21
Makefile
21
Makefile
@@ -6,6 +6,7 @@ DOCKER_COMPOSE ?= docker-compose
|
|||||||
OFFICIAL ?= no
|
OFFICIAL ?= no
|
||||||
NODE ?= node
|
NODE ?= node
|
||||||
NPM_BIN ?= npm
|
NPM_BIN ?= npm
|
||||||
|
KIND_BIN ?= $(shell which kind)
|
||||||
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
||||||
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
MANAGEMENT_COMMAND ?= awx-manage
|
MANAGEMENT_COMMAND ?= awx-manage
|
||||||
@@ -78,7 +79,7 @@ I18N_FLAG_FILE = .i18n_built
|
|||||||
sdist \
|
sdist \
|
||||||
ui-release ui-devel \
|
ui-release ui-devel \
|
||||||
VERSION PYTHON_VERSION docker-compose-sources \
|
VERSION PYTHON_VERSION docker-compose-sources \
|
||||||
.git/hooks/pre-commit github_ci_setup github_ci_runner
|
.git/hooks/pre-commit
|
||||||
|
|
||||||
clean-tmp:
|
clean-tmp:
|
||||||
rm -rf tmp/
|
rm -rf tmp/
|
||||||
@@ -323,21 +324,16 @@ test:
|
|||||||
cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3
|
cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3
|
||||||
awx-manage check_migrations --dry-run --check -n 'missing_migration_file'
|
awx-manage check_migrations --dry-run --check -n 'missing_migration_file'
|
||||||
|
|
||||||
## Login to Github container image registry, pull image, then build image.
|
test_migrations:
|
||||||
github_ci_setup:
|
if [ "$(VENV_BASE)" ]; then \
|
||||||
# GITHUB_ACTOR is automatic github actions env var
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
# CI_GITHUB_TOKEN is defined in .github files
|
fi; \
|
||||||
echo $(CI_GITHUB_TOKEN) | docker login ghcr.io -u $(GITHUB_ACTOR) --password-stdin
|
PYTHONDONTWRITEBYTECODE=1 py.test -p no:cacheprovider --migrations -m migration_test $(PYTEST_ARGS) $(TEST_DIRS)
|
||||||
docker pull $(DEVEL_IMAGE_NAME) || : # Pre-pull image to warm build cache
|
|
||||||
$(MAKE) docker-compose-build
|
|
||||||
|
|
||||||
## Runs AWX_DOCKER_CMD inside a new docker container.
|
## Runs AWX_DOCKER_CMD inside a new docker container.
|
||||||
docker-runner:
|
docker-runner:
|
||||||
docker run -u $(shell id -u) --rm -v $(shell pwd):/awx_devel/:Z --workdir=/awx_devel $(DEVEL_IMAGE_NAME) $(AWX_DOCKER_CMD)
|
docker run -u $(shell id -u) --rm -v $(shell pwd):/awx_devel/:Z --workdir=/awx_devel $(DEVEL_IMAGE_NAME) $(AWX_DOCKER_CMD)
|
||||||
|
|
||||||
## Builds image and runs AWX_DOCKER_CMD in it, mainly for .github checks.
|
|
||||||
github_ci_runner: github_ci_setup docker-runner
|
|
||||||
|
|
||||||
test_collection:
|
test_collection:
|
||||||
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
|
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
|
||||||
if [ "$(VENV_BASE)" ]; then \
|
if [ "$(VENV_BASE)" ]; then \
|
||||||
@@ -664,6 +660,9 @@ awx-kube-dev-build: Dockerfile.kube-dev
|
|||||||
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
||||||
|
|
||||||
|
|
||||||
|
kind-dev-load: awx-kube-dev-build
|
||||||
|
$(KIND_BIN) load docker-image $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG)
|
||||||
|
|
||||||
# Translation TASKS
|
# Translation TASKS
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/ansible/awx/actions/workflows/ci.yml) [](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) [](https://github.com/ansible/awx/blob/devel/LICENSE.md) [](https://groups.google.com/g/awx-project)
|
[](https://github.com/ansible/awx/actions/workflows/ci.yml) [](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) [](https://github.com/ansible/awx/blob/devel/LICENSE.md) [](https://groups.google.com/g/awx-project)
|
||||||
[](https://libera.chat)
|
[](https://chat.ansible.im/#/welcome) [](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" />
|
||||||
|
|
||||||
@@ -37,5 +37,5 @@ Get Involved
|
|||||||
|
|
||||||
We welcome your feedback and ideas. Here's how to reach us with feedback and questions:
|
We welcome your feedback and ideas. Here's how to reach us with feedback and questions:
|
||||||
|
|
||||||
- Join the `#ansible-awx` channel on irc.libera.chat
|
- Join the [Ansible AWX channel on Matrix](https://matrix.to/#/#awx:ansible.com)
|
||||||
- Join the [mailing list](https://groups.google.com/forum/#!forum/awx-project)
|
- Join the [Ansible Community Forum](https://forum.ansible.com)
|
||||||
|
|||||||
@@ -52,39 +52,14 @@ try:
|
|||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
MODE = 'production'
|
MODE = 'production'
|
||||||
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import django # noqa: F401
|
import django # noqa: F401
|
||||||
|
|
||||||
HAS_DJANGO = True
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_DJANGO = False
|
pass
|
||||||
else:
|
else:
|
||||||
from django.db.backends.base import schema
|
|
||||||
from django.db.models import indexes
|
|
||||||
from django.db.backends.utils import names_digest
|
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
if HAS_DJANGO is True:
|
|
||||||
# See upgrade blocker note in requirements/README.md
|
|
||||||
try:
|
|
||||||
names_digest('foo', 'bar', 'baz', length=8)
|
|
||||||
except ValueError:
|
|
||||||
|
|
||||||
def names_digest(*args, length):
|
|
||||||
"""
|
|
||||||
Generate a 32-bit digest of a set of arguments that can be used to shorten
|
|
||||||
identifying names. Support for use in FIPS environments.
|
|
||||||
"""
|
|
||||||
h = hashlib.md5(usedforsecurity=False)
|
|
||||||
for arg in args:
|
|
||||||
h.update(arg.encode())
|
|
||||||
return h.hexdigest()[:length]
|
|
||||||
|
|
||||||
schema.names_digest = names_digest
|
|
||||||
indexes.names_digest = names_digest
|
|
||||||
|
|
||||||
|
|
||||||
def find_commands(management_dir):
|
def find_commands(management_dir):
|
||||||
# Modified version of function from django/core/management/__init__.py.
|
# Modified version of function from django/core/management/__init__.py.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
---
|
---
|
||||||
collections:
|
collections:
|
||||||
- name: ansible.receptor
|
- name: ansible.receptor
|
||||||
version: 2.0.0
|
version: 2.0.2
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ logger = logging.getLogger('awx.api.views')
|
|||||||
|
|
||||||
|
|
||||||
def unpartitioned_event_horizon(cls):
|
def unpartitioned_event_horizon(cls):
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(f"SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE table_name = '_unpartitioned_{cls._meta.db_table}';")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
return 0
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
try:
|
try:
|
||||||
cursor.execute(f'SELECT MAX(id) FROM _unpartitioned_{cls._meta.db_table}')
|
cursor.execute(f'SELECT MAX(id) FROM _unpartitioned_{cls._meta.db_table}')
|
||||||
|
|||||||
@@ -418,6 +418,10 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
"""Get value while accepting the in-memory cache if key is available"""
|
"""Get value while accepting the in-memory cache if key is available"""
|
||||||
with _ctit_db_wrapper(trans_safe=True):
|
with _ctit_db_wrapper(trans_safe=True):
|
||||||
return self._get_local(name)
|
return self._get_local(name)
|
||||||
|
# If the last line did not return, that means we hit a database error
|
||||||
|
# in that case, we should not have a local cache value
|
||||||
|
# thus, return empty as a signal to use the default
|
||||||
|
return empty
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
value = empty
|
value = empty
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from unittest import mock
|
|||||||
from django.conf import LazySettings
|
from django.conf import LazySettings
|
||||||
from django.core.cache.backends.locmem import LocMemCache
|
from django.core.cache.backends.locmem import LocMemCache
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.db.utils import Error as DBError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -331,3 +332,18 @@ def test_in_memory_cache_works(settings):
|
|||||||
with mock.patch.object(settings, '_get_local') as mock_get:
|
with mock.patch.object(settings, '_get_local') as mock_get:
|
||||||
assert settings.AWX_VAR == 'DEFAULT'
|
assert settings.AWX_VAR == 'DEFAULT'
|
||||||
mock_get.assert_not_called()
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.defined_in_file(AWX_VAR=[])
|
||||||
|
def test_getattr_with_database_error(settings):
|
||||||
|
"""
|
||||||
|
If a setting is defined via the registry and has a null-ish default which is not None
|
||||||
|
then referencing that setting during a database outage should give that default
|
||||||
|
this is regression testing for a bug where it would return None
|
||||||
|
"""
|
||||||
|
settings.registry.register('AWX_VAR', field_class=fields.StringListField, default=[], category=_('System'), category_slug='system')
|
||||||
|
settings._awx_conf_memoizedcache.clear()
|
||||||
|
|
||||||
|
with mock.patch('django.db.backends.base.base.BaseDatabaseWrapper.ensure_connection') as mock_ensure:
|
||||||
|
mock_ensure.side_effect = DBError('for test')
|
||||||
|
assert settings.AWX_VAR == []
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ __all__ = [
|
|||||||
'get_user_queryset',
|
'get_user_queryset',
|
||||||
'check_user_access',
|
'check_user_access',
|
||||||
'check_user_access_with_errors',
|
'check_user_access_with_errors',
|
||||||
'user_accessible_objects',
|
|
||||||
'consumer_access',
|
'consumer_access',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -136,10 +135,6 @@ def register_access(model_class, access_class):
|
|||||||
access_registry[model_class] = access_class
|
access_registry[model_class] = access_class
|
||||||
|
|
||||||
|
|
||||||
def user_accessible_objects(user, role_name):
|
|
||||||
return ResourceMixin._accessible_objects(User, user, role_name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_queryset(user, model_class):
|
def get_user_queryset(user, model_class):
|
||||||
"""
|
"""
|
||||||
Return a queryset for the given model_class containing only the instances
|
Return a queryset for the given model_class containing only the instances
|
||||||
|
|||||||
@@ -694,16 +694,18 @@ register(
|
|||||||
category_slug='logging',
|
category_slug='logging',
|
||||||
)
|
)
|
||||||
register(
|
register(
|
||||||
'LOG_AGGREGATOR_MAX_DISK_USAGE_GB',
|
'LOG_AGGREGATOR_ACTION_QUEUE_SIZE',
|
||||||
field_class=fields.IntegerField,
|
field_class=fields.IntegerField,
|
||||||
default=1,
|
default=131072,
|
||||||
min_value=1,
|
min_value=1,
|
||||||
label=_('Maximum disk persistence for external log aggregation (in GB)'),
|
label=_('Maximum number of messages that can be stored in the log action queue'),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
'Amount of data to store (in gigabytes) during an outage of '
|
'Defines how large the rsyslog action queue can grow in number of messages '
|
||||||
'the external log aggregator (defaults to 1). '
|
'stored. This can have an impact on memory utilization. When the queue '
|
||||||
'Equivalent to the rsyslogd queue.maxdiskspace setting for main_queue. '
|
'reaches 75% of this number, the queue will start writing to disk '
|
||||||
'Notably, this is used for the rsyslogd main queue (for input messages).'
|
'(queue.highWatermark in rsyslog). When it reaches 90%, NOTICE, INFO, and '
|
||||||
|
'DEBUG messages will start to be discarded (queue.discardMark with '
|
||||||
|
'queue.discardSeverity=5).'
|
||||||
),
|
),
|
||||||
category=_('Logging'),
|
category=_('Logging'),
|
||||||
category_slug='logging',
|
category_slug='logging',
|
||||||
@@ -718,8 +720,7 @@ register(
|
|||||||
'Amount of data to store (in gigabytes) if an rsyslog action takes time '
|
'Amount of data to store (in gigabytes) if an rsyslog action takes time '
|
||||||
'to process an incoming message (defaults to 1). '
|
'to process an incoming message (defaults to 1). '
|
||||||
'Equivalent to the rsyslogd queue.maxdiskspace setting on the action (e.g. omhttp). '
|
'Equivalent to the rsyslogd queue.maxdiskspace setting on the action (e.g. omhttp). '
|
||||||
'Like LOG_AGGREGATOR_MAX_DISK_USAGE_GB, it stores files in the directory specified '
|
'It stores files in the directory specified by LOG_AGGREGATOR_MAX_DISK_USAGE_PATH.'
|
||||||
'by LOG_AGGREGATOR_MAX_DISK_USAGE_PATH.'
|
|
||||||
),
|
),
|
||||||
category=_('Logging'),
|
category=_('Logging'),
|
||||||
category_slug='logging',
|
category_slug='logging',
|
||||||
|
|||||||
@@ -2,25 +2,29 @@ from .plugin import CredentialPlugin
|
|||||||
|
|
||||||
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 thycotic.secrets.vault import SecretsVault
|
from delinea.secrets.vault import PasswordGrantAuthorizer, SecretsVault
|
||||||
|
from base64 import b64decode
|
||||||
|
|
||||||
dsv_inputs = {
|
dsv_inputs = {
|
||||||
'fields': [
|
'fields': [
|
||||||
{
|
{
|
||||||
'id': 'tenant',
|
'id': 'tenant',
|
||||||
'label': _('Tenant'),
|
'label': _('Tenant'),
|
||||||
'help_text': _('The tenant e.g. "ex" when the URL is https://ex.secretservercloud.com'),
|
'help_text': _('The tenant e.g. "ex" when the URL is https://ex.secretsvaultcloud.com'),
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'tld',
|
'id': 'tld',
|
||||||
'label': _('Top-level Domain (TLD)'),
|
'label': _('Top-level Domain (TLD)'),
|
||||||
'help_text': _('The TLD of the tenant e.g. "com" when the URL is https://ex.secretservercloud.com'),
|
'help_text': _('The TLD of the tenant e.g. "com" when the URL is https://ex.secretsvaultcloud.com'),
|
||||||
'choices': ['ca', 'com', 'com.au', 'com.sg', 'eu'],
|
'choices': ['ca', 'com', 'com.au', 'eu'],
|
||||||
'default': 'com',
|
'default': 'com',
|
||||||
},
|
},
|
||||||
{'id': 'client_id', 'label': _('Client ID'), 'type': 'string'},
|
{
|
||||||
|
'id': 'client_id',
|
||||||
|
'label': _('Client ID'),
|
||||||
|
'type': 'string',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'id': 'client_secret',
|
'id': 'client_secret',
|
||||||
'label': _('Client Secret'),
|
'label': _('Client Secret'),
|
||||||
@@ -41,8 +45,16 @@ dsv_inputs = {
|
|||||||
'help_text': _('The field to extract from the secret'),
|
'help_text': _('The field to extract from the secret'),
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'id': 'secret_decoding',
|
||||||
|
'label': _('Should the secret be base64 decoded?'),
|
||||||
|
'help_text': _('Specify whether the secret should be base64 decoded, typically used for storing files, such as SSH keys'),
|
||||||
|
'choices': ['No Decoding', 'Decode Base64'],
|
||||||
|
'type': 'string',
|
||||||
|
'default': 'No Decoding',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'required': ['tenant', 'client_id', 'client_secret', 'path', 'secret_field'],
|
'required': ['tenant', 'client_id', 'client_secret', 'path', 'secret_field', 'secret_decoding'],
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
@@ -51,12 +63,32 @@ if settings.DEBUG:
|
|||||||
'id': 'url_template',
|
'id': 'url_template',
|
||||||
'label': _('URL template'),
|
'label': _('URL template'),
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'default': 'https://{}.secretsvaultcloud.{}/v1',
|
'default': 'https://{}.secretsvaultcloud.{}',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
dsv_plugin = CredentialPlugin(
|
|
||||||
'Thycotic DevOps Secrets Vault',
|
def dsv_backend(**kwargs):
|
||||||
dsv_inputs,
|
tenant_name = kwargs['tenant']
|
||||||
lambda **kwargs: SecretsVault(**{k: v for (k, v) in kwargs.items() if k in [field['id'] for field in dsv_inputs['fields']]}).get_secret(kwargs['path'])['data'][kwargs['secret_field']], # fmt: skip
|
tenant_tld = kwargs.get('tld', 'com')
|
||||||
)
|
tenant_url_template = kwargs.get('url_template', 'https://{}.secretsvaultcloud.{}')
|
||||||
|
client_id = kwargs['client_id']
|
||||||
|
client_secret = kwargs['client_secret']
|
||||||
|
secret_path = kwargs['path']
|
||||||
|
secret_field = kwargs['secret_field']
|
||||||
|
# providing a default value to remain backward compatible for secrets that have not specified this option
|
||||||
|
secret_decoding = kwargs.get('secret_decoding', 'No Decoding')
|
||||||
|
|
||||||
|
tenant_url = tenant_url_template.format(tenant_name, tenant_tld.strip("."))
|
||||||
|
|
||||||
|
authorizer = PasswordGrantAuthorizer(tenant_url, client_id, client_secret)
|
||||||
|
dsv_secret = SecretsVault(tenant_url, authorizer).get_secret(secret_path)
|
||||||
|
|
||||||
|
# files can be uploaded base64 decoded to DSV and thus decoding it only, when asked for
|
||||||
|
if secret_decoding == 'Decode Base64':
|
||||||
|
return b64decode(dsv_secret['data'][secret_field]).decode()
|
||||||
|
|
||||||
|
return dsv_secret['data'][secret_field]
|
||||||
|
|
||||||
|
|
||||||
|
dsv_plugin = CredentialPlugin(name='Thycotic DevOps Secrets Vault', inputs=dsv_inputs, backend=dsv_backend)
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ tss_inputs = {
|
|||||||
|
|
||||||
def tss_backend(**kwargs):
|
def tss_backend(**kwargs):
|
||||||
if kwargs.get("domain"):
|
if kwargs.get("domain"):
|
||||||
authorizer = DomainPasswordGrantAuthorizer(kwargs['server_url'], kwargs['username'], kwargs['password'], kwargs['domain'])
|
authorizer = DomainPasswordGrantAuthorizer(
|
||||||
|
base_url=kwargs['server_url'], username=kwargs['username'], domain=kwargs['domain'], password=kwargs['password']
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
authorizer = PasswordGrantAuthorizer(kwargs['server_url'], kwargs['username'], kwargs['password'])
|
authorizer = PasswordGrantAuthorizer(kwargs['server_url'], kwargs['username'], kwargs['password'])
|
||||||
secret_server = SecretServer(kwargs['server_url'], authorizer)
|
secret_server = SecretServer(kwargs['server_url'], authorizer)
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ class Control(object):
|
|||||||
def running(self, *args, **kwargs):
|
def running(self, *args, **kwargs):
|
||||||
return self.control_with_reply('running', *args, **kwargs)
|
return self.control_with_reply('running', *args, **kwargs)
|
||||||
|
|
||||||
def cancel(self, task_ids, *args, **kwargs):
|
def cancel(self, task_ids, with_reply=True):
|
||||||
return self.control_with_reply('cancel', *args, extra_data={'task_ids': task_ids}, **kwargs)
|
if with_reply:
|
||||||
|
return self.control_with_reply('cancel', extra_data={'task_ids': task_ids})
|
||||||
|
else:
|
||||||
|
self.control({'control': 'cancel', 'task_ids': task_ids, 'reply_to': None}, extra_data={'task_ids': task_ids})
|
||||||
|
|
||||||
def schedule(self, *args, **kwargs):
|
def schedule(self, *args, **kwargs):
|
||||||
return self.control_with_reply('schedule', *args, **kwargs)
|
return self.control_with_reply('schedule', *args, **kwargs)
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ class AWXConsumerBase(object):
|
|||||||
if task_ids and not msg:
|
if task_ids and not msg:
|
||||||
logger.info(f'Could not locate running tasks to cancel with ids={task_ids}')
|
logger.info(f'Could not locate running tasks to cancel with ids={task_ids}')
|
||||||
|
|
||||||
with pg_bus_conn() as conn:
|
if reply_queue is not None:
|
||||||
conn.notify(reply_queue, json.dumps(msg))
|
with pg_bus_conn() as conn:
|
||||||
|
conn.notify(reply_queue, json.dumps(msg))
|
||||||
elif control == 'reload':
|
elif control == 'reload':
|
||||||
for worker in self.pool.workers:
|
for worker in self.pool.workers:
|
||||||
worker.quit()
|
worker.quit()
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class Command(BaseCommand):
|
|||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--days', dest='days', type=int, default=90, metavar='N', help='Remove activity stream events more than N days old')
|
parser.add_argument('--days', dest='days', type=int, default=90, metavar='N', help='Remove activity stream events more than N days old')
|
||||||
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would be removed)')
|
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would be removed)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--batch-size', dest='batch_size', type=int, default=500, metavar='X', help='Remove activity stream events in batch of X events. Defaults to 500.'
|
||||||
|
)
|
||||||
|
|
||||||
def init_logging(self):
|
def init_logging(self):
|
||||||
log_levels = dict(enumerate([logging.ERROR, logging.INFO, logging.DEBUG, 0]))
|
log_levels = dict(enumerate([logging.ERROR, logging.INFO, logging.DEBUG, 0]))
|
||||||
@@ -48,7 +51,7 @@ class Command(BaseCommand):
|
|||||||
else:
|
else:
|
||||||
pks_to_delete.add(asobj.pk)
|
pks_to_delete.add(asobj.pk)
|
||||||
# Cleanup objects in batches instead of deleting each one individually.
|
# Cleanup objects in batches instead of deleting each one individually.
|
||||||
if len(pks_to_delete) >= 500:
|
if len(pks_to_delete) >= self.batch_size:
|
||||||
ActivityStream.objects.filter(pk__in=pks_to_delete).delete()
|
ActivityStream.objects.filter(pk__in=pks_to_delete).delete()
|
||||||
n_deleted_items += len(pks_to_delete)
|
n_deleted_items += len(pks_to_delete)
|
||||||
pks_to_delete.clear()
|
pks_to_delete.clear()
|
||||||
@@ -63,4 +66,5 @@ class Command(BaseCommand):
|
|||||||
self.days = int(options.get('days', 30))
|
self.days = int(options.get('days', 30))
|
||||||
self.cutoff = now() - datetime.timedelta(days=self.days)
|
self.cutoff = now() - datetime.timedelta(days=self.days)
|
||||||
self.dry_run = bool(options.get('dry_run', False))
|
self.dry_run = bool(options.get('dry_run', False))
|
||||||
|
self.batch_size = int(options.get('batch_size', 500))
|
||||||
self.cleanup_activitystream()
|
self.cleanup_activitystream()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import re
|
|||||||
|
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
|
from django.apps import apps
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction, connection
|
from django.db import transaction, connection
|
||||||
from django.db.models import Min, Max
|
from django.db.models import Min, Max
|
||||||
@@ -150,6 +151,9 @@ class Command(BaseCommand):
|
|||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--days', dest='days', type=int, default=90, metavar='N', help='Remove jobs/updates executed more than N days ago. Defaults to 90.')
|
parser.add_argument('--days', dest='days', type=int, default=90, metavar='N', help='Remove jobs/updates executed more than N days ago. Defaults to 90.')
|
||||||
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would be removed)')
|
parser.add_argument('--dry-run', dest='dry_run', action='store_true', default=False, help='Dry run mode (show items that would be removed)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--batch-size', dest='batch_size', type=int, default=100000, metavar='X', help='Remove jobs in batch of X jobs. Defaults to 100000.'
|
||||||
|
)
|
||||||
parser.add_argument('--jobs', dest='only_jobs', action='store_true', default=False, help='Remove jobs')
|
parser.add_argument('--jobs', dest='only_jobs', action='store_true', default=False, help='Remove jobs')
|
||||||
parser.add_argument('--ad-hoc-commands', dest='only_ad_hoc_commands', action='store_true', default=False, help='Remove ad hoc commands')
|
parser.add_argument('--ad-hoc-commands', dest='only_ad_hoc_commands', action='store_true', default=False, help='Remove ad hoc commands')
|
||||||
parser.add_argument('--project-updates', dest='only_project_updates', action='store_true', default=False, help='Remove project updates')
|
parser.add_argument('--project-updates', dest='only_project_updates', action='store_true', default=False, help='Remove project updates')
|
||||||
@@ -195,18 +199,58 @@ class Command(BaseCommand):
|
|||||||
delete_meta.delete_jobs()
|
delete_meta.delete_jobs()
|
||||||
return (delete_meta.jobs_no_delete_count, delete_meta.jobs_to_delete_count)
|
return (delete_meta.jobs_no_delete_count, delete_meta.jobs_to_delete_count)
|
||||||
|
|
||||||
def _cascade_delete_job_events(self, model, pk_list):
|
def has_unpartitioned_table(self, model):
|
||||||
|
tblname = unified_job_class_to_event_table_name(model)
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(f"SELECT 1 FROM pg_tables WHERE tablename = '_unpartitioned_{tblname}';")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _delete_unpartitioned_table(self, model):
|
||||||
|
"If the unpartitioned table is no longer necessary, it will drop the table"
|
||||||
|
tblname = unified_job_class_to_event_table_name(model)
|
||||||
|
if not self.has_unpartitioned_table(model):
|
||||||
|
self.logger.debug(f'Table _unpartitioned_{tblname} does not exist, you are fully migrated.')
|
||||||
|
return
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
# same as UnpartitionedJobEvent.objects.aggregate(Max('created'))
|
||||||
|
cursor.execute(f'SELECT MAX("_unpartitioned_{tblname}"."created") FROM "_unpartitioned_{tblname}";')
|
||||||
|
row = cursor.fetchone()
|
||||||
|
last_created = row[0]
|
||||||
|
|
||||||
|
if last_created:
|
||||||
|
self.logger.info(f'Last event created in _unpartitioned_{tblname} was {last_created.isoformat()}')
|
||||||
|
else:
|
||||||
|
self.logger.info(f'Table _unpartitioned_{tblname} has no events in it')
|
||||||
|
|
||||||
|
if (last_created is None) or (last_created < self.cutoff):
|
||||||
|
self.logger.warning(
|
||||||
|
f'Dropping table _unpartitioned_{tblname} since no records are newer than {self.cutoff}\n'
|
||||||
|
'WARNING - this will happen in a separate transaction so a failure will not roll back prior cleanup'
|
||||||
|
)
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(f'DROP TABLE _unpartitioned_{tblname};')
|
||||||
|
|
||||||
|
def _delete_unpartitioned_events(self, model, pk_list):
|
||||||
|
"If unpartitioned job events remain, it will cascade those from jobs in pk_list"
|
||||||
|
tblname = unified_job_class_to_event_table_name(model)
|
||||||
|
rel_name = model().event_parent_key
|
||||||
|
|
||||||
|
# Bail if the unpartitioned table does not exist anymore
|
||||||
|
if not self.has_unpartitioned_table(model):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Table still exists, delete individual unpartitioned events
|
||||||
if pk_list:
|
if pk_list:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
tblname = unified_job_class_to_event_table_name(model)
|
self.logger.debug(f'Deleting {len(pk_list)} events from _unpartitioned_{tblname}, use a longer cleanup window to delete the table.')
|
||||||
|
|
||||||
pk_list_csv = ','.join(map(str, pk_list))
|
pk_list_csv = ','.join(map(str, pk_list))
|
||||||
rel_name = model().event_parent_key
|
cursor.execute(f"DELETE FROM _unpartitioned_{tblname} WHERE {rel_name} IN ({pk_list_csv});")
|
||||||
cursor.execute(f"DELETE FROM _unpartitioned_{tblname} WHERE {rel_name} IN ({pk_list_csv})")
|
|
||||||
|
|
||||||
def cleanup_jobs(self):
|
def cleanup_jobs(self):
|
||||||
batch_size = 100000
|
|
||||||
|
|
||||||
# Hack to avoid doing N+1 queries as each item in the Job query set does
|
# Hack to avoid doing N+1 queries as each item in the Job query set does
|
||||||
# an individual query to get the underlying UnifiedJob.
|
# an individual query to get the underlying UnifiedJob.
|
||||||
Job.polymorphic_super_sub_accessors_replaced = True
|
Job.polymorphic_super_sub_accessors_replaced = True
|
||||||
@@ -221,13 +265,14 @@ class Command(BaseCommand):
|
|||||||
deleted = 0
|
deleted = 0
|
||||||
info = qs.aggregate(min=Min('id'), max=Max('id'))
|
info = qs.aggregate(min=Min('id'), max=Max('id'))
|
||||||
if info['min'] is not None:
|
if info['min'] is not None:
|
||||||
for start in range(info['min'], info['max'] + 1, batch_size):
|
for start in range(info['min'], info['max'] + 1, self.batch_size):
|
||||||
qs_batch = qs.filter(id__gte=start, id__lte=start + batch_size)
|
qs_batch = qs.filter(id__gte=start, id__lte=start + self.batch_size)
|
||||||
pk_list = qs_batch.values_list('id', flat=True)
|
pk_list = qs_batch.values_list('id', flat=True)
|
||||||
|
|
||||||
_, results = qs_batch.delete()
|
_, results = qs_batch.delete()
|
||||||
deleted += results['main.Job']
|
deleted += results['main.Job']
|
||||||
self._cascade_delete_job_events(Job, pk_list)
|
# Avoid dropping the job event table in case we have interacted with it already
|
||||||
|
self._delete_unpartitioned_events(Job, pk_list)
|
||||||
|
|
||||||
return skipped, deleted
|
return skipped, deleted
|
||||||
|
|
||||||
@@ -250,7 +295,7 @@ class Command(BaseCommand):
|
|||||||
deleted += 1
|
deleted += 1
|
||||||
|
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
self._cascade_delete_job_events(AdHocCommand, pk_list)
|
self._delete_unpartitioned_events(AdHocCommand, pk_list)
|
||||||
|
|
||||||
skipped += AdHocCommand.objects.filter(created__gte=self.cutoff).count()
|
skipped += AdHocCommand.objects.filter(created__gte=self.cutoff).count()
|
||||||
return skipped, deleted
|
return skipped, deleted
|
||||||
@@ -278,7 +323,7 @@ class Command(BaseCommand):
|
|||||||
deleted += 1
|
deleted += 1
|
||||||
|
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
self._cascade_delete_job_events(ProjectUpdate, pk_list)
|
self._delete_unpartitioned_events(ProjectUpdate, pk_list)
|
||||||
|
|
||||||
skipped += ProjectUpdate.objects.filter(created__gte=self.cutoff).count()
|
skipped += ProjectUpdate.objects.filter(created__gte=self.cutoff).count()
|
||||||
return skipped, deleted
|
return skipped, deleted
|
||||||
@@ -306,7 +351,7 @@ class Command(BaseCommand):
|
|||||||
deleted += 1
|
deleted += 1
|
||||||
|
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
self._cascade_delete_job_events(InventoryUpdate, pk_list)
|
self._delete_unpartitioned_events(InventoryUpdate, pk_list)
|
||||||
|
|
||||||
skipped += InventoryUpdate.objects.filter(created__gte=self.cutoff).count()
|
skipped += InventoryUpdate.objects.filter(created__gte=self.cutoff).count()
|
||||||
return skipped, deleted
|
return skipped, deleted
|
||||||
@@ -330,7 +375,7 @@ class Command(BaseCommand):
|
|||||||
deleted += 1
|
deleted += 1
|
||||||
|
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
self._cascade_delete_job_events(SystemJob, pk_list)
|
self._delete_unpartitioned_events(SystemJob, pk_list)
|
||||||
|
|
||||||
skipped += SystemJob.objects.filter(created__gte=self.cutoff).count()
|
skipped += SystemJob.objects.filter(created__gte=self.cutoff).count()
|
||||||
return skipped, deleted
|
return skipped, deleted
|
||||||
@@ -375,12 +420,12 @@ class Command(BaseCommand):
|
|||||||
skipped += Notification.objects.filter(created__gte=self.cutoff).count()
|
skipped += Notification.objects.filter(created__gte=self.cutoff).count()
|
||||||
return skipped, deleted
|
return skipped, deleted
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.verbosity = int(options.get('verbosity', 1))
|
self.verbosity = int(options.get('verbosity', 1))
|
||||||
self.init_logging()
|
self.init_logging()
|
||||||
self.days = int(options.get('days', 90))
|
self.days = int(options.get('days', 90))
|
||||||
self.dry_run = bool(options.get('dry_run', False))
|
self.dry_run = bool(options.get('dry_run', False))
|
||||||
|
self.batch_size = int(options.get('batch_size', 100000))
|
||||||
try:
|
try:
|
||||||
self.cutoff = now() - datetime.timedelta(days=self.days)
|
self.cutoff = now() - datetime.timedelta(days=self.days)
|
||||||
except OverflowError:
|
except OverflowError:
|
||||||
@@ -402,19 +447,29 @@ class Command(BaseCommand):
|
|||||||
del s.receivers[:]
|
del s.receivers[:]
|
||||||
s.sender_receivers_cache.clear()
|
s.sender_receivers_cache.clear()
|
||||||
|
|
||||||
for m in model_names:
|
with transaction.atomic():
|
||||||
if m not in models_to_cleanup:
|
for m in models_to_cleanup:
|
||||||
continue
|
skipped, deleted = getattr(self, 'cleanup_%s' % m)()
|
||||||
|
|
||||||
skipped, deleted = getattr(self, 'cleanup_%s' % m)()
|
func = getattr(self, 'cleanup_%s_partition' % m, None)
|
||||||
|
if func:
|
||||||
|
skipped_partition, deleted_partition = func()
|
||||||
|
skipped += skipped_partition
|
||||||
|
deleted += deleted_partition
|
||||||
|
|
||||||
func = getattr(self, 'cleanup_%s_partition' % m, None)
|
if self.dry_run:
|
||||||
if func:
|
self.logger.log(99, '%s: %d would be deleted, %d would be skipped.', m.replace('_', ' '), deleted, skipped)
|
||||||
skipped_partition, deleted_partition = func()
|
else:
|
||||||
skipped += skipped_partition
|
self.logger.log(99, '%s: %d deleted, %d skipped.', m.replace('_', ' '), deleted, skipped)
|
||||||
deleted += deleted_partition
|
|
||||||
|
|
||||||
if self.dry_run:
|
# Deleting unpartitioned tables cannot be done in same transaction as updates to related tables
|
||||||
self.logger.log(99, '%s: %d would be deleted, %d would be skipped.', m.replace('_', ' '), deleted, skipped)
|
if not self.dry_run:
|
||||||
else:
|
with transaction.atomic():
|
||||||
self.logger.log(99, '%s: %d deleted, %d skipped.', m.replace('_', ' '), deleted, skipped)
|
for m in models_to_cleanup:
|
||||||
|
unified_job_class_name = m[:-1].title().replace('Management', 'System').replace('_', '')
|
||||||
|
unified_job_class = apps.get_model('main', unified_job_class_name)
|
||||||
|
try:
|
||||||
|
unified_job_class().event_class
|
||||||
|
except (NotImplementedError, AttributeError):
|
||||||
|
continue # no need to run this for models without events
|
||||||
|
self._delete_unpartitioned_table(unified_job_class)
|
||||||
|
|||||||
@@ -125,14 +125,15 @@ class InstanceManager(models.Manager):
|
|||||||
with advisory_lock('instance_registration_%s' % hostname):
|
with advisory_lock('instance_registration_%s' % hostname):
|
||||||
if settings.AWX_AUTO_DEPROVISION_INSTANCES:
|
if settings.AWX_AUTO_DEPROVISION_INSTANCES:
|
||||||
# detect any instances with the same IP address.
|
# detect any instances with the same IP address.
|
||||||
# if one exists, set it to None
|
# if one exists, set it to ""
|
||||||
inst_conflicting_ip = self.filter(ip_address=ip_address).exclude(hostname=hostname)
|
if ip_address:
|
||||||
if inst_conflicting_ip.exists():
|
inst_conflicting_ip = self.filter(ip_address=ip_address).exclude(hostname=hostname)
|
||||||
for other_inst in inst_conflicting_ip:
|
if inst_conflicting_ip.exists():
|
||||||
other_hostname = other_inst.hostname
|
for other_inst in inst_conflicting_ip:
|
||||||
other_inst.ip_address = None
|
other_hostname = other_inst.hostname
|
||||||
other_inst.save(update_fields=['ip_address'])
|
other_inst.ip_address = ""
|
||||||
logger.warning("IP address {0} conflict detected, ip address unset for host {1}.".format(ip_address, other_hostname))
|
other_inst.save(update_fields=['ip_address'])
|
||||||
|
logger.warning("IP address {0} conflict detected, ip address unset for host {1}.".format(ip_address, other_hostname))
|
||||||
|
|
||||||
# Return existing instance that matches hostname or UUID (default to UUID)
|
# Return existing instance that matches hostname or UUID (default to UUID)
|
||||||
if node_uuid is not None and node_uuid != UUID_DEFAULT and self.filter(uuid=node_uuid).exists():
|
if node_uuid is not None and node_uuid != UUID_DEFAULT and self.filter(uuid=node_uuid).exists():
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.conf import settings
|
|||||||
# AWX
|
# AWX
|
||||||
import awx.main.fields
|
import awx.main.fields
|
||||||
from awx.main.models import Host
|
from awx.main.models import Host
|
||||||
|
from ._sqlite_helper import dbawaremigrations
|
||||||
|
|
||||||
|
|
||||||
def replaces():
|
def replaces():
|
||||||
@@ -131,9 +132,11 @@ class Migration(migrations.Migration):
|
|||||||
help_text='If enabled, Tower will act as an Ansible Fact Cache Plugin; persisting facts at the end of a playbook run to the database and caching facts for use by Ansible.',
|
help_text='If enabled, Tower will act as an Ansible Fact Cache Plugin; persisting facts at the end of a playbook run to the database and caching facts for use by Ansible.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
sql="CREATE INDEX host_ansible_facts_default_gin ON {} USING gin(ansible_facts jsonb_path_ops);".format(Host._meta.db_table),
|
sql="CREATE INDEX host_ansible_facts_default_gin ON {} USING gin(ansible_facts jsonb_path_ops);".format(Host._meta.db_table),
|
||||||
reverse_sql='DROP INDEX host_ansible_facts_default_gin;',
|
reverse_sql='DROP INDEX host_ansible_facts_default_gin;',
|
||||||
|
sqlite_sql=dbawaremigrations.RunSQL.noop,
|
||||||
|
sqlite_reverse_sql=dbawaremigrations.RunSQL.noop,
|
||||||
),
|
),
|
||||||
# SCM file-based inventories
|
# SCM file-based inventories
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
|
|||||||
@@ -3,24 +3,27 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
from ._sqlite_helper import dbawaremigrations
|
||||||
|
|
||||||
|
tables_to_drop = [
|
||||||
|
'celery_taskmeta',
|
||||||
|
'celery_tasksetmeta',
|
||||||
|
'djcelery_crontabschedule',
|
||||||
|
'djcelery_intervalschedule',
|
||||||
|
'djcelery_periodictask',
|
||||||
|
'djcelery_periodictasks',
|
||||||
|
'djcelery_taskstate',
|
||||||
|
'djcelery_workerstate',
|
||||||
|
'djkombu_message',
|
||||||
|
'djkombu_queue',
|
||||||
|
]
|
||||||
|
postgres_sql = ([("DROP TABLE IF EXISTS {} CASCADE;".format(table))] for table in tables_to_drop)
|
||||||
|
sqlite_sql = ([("DROP TABLE IF EXISTS {};".format(table))] for table in tables_to_drop)
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('main', '0049_v330_validate_instance_capacity_adjustment'),
|
('main', '0049_v330_validate_instance_capacity_adjustment'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [dbawaremigrations.RunSQL(p, sqlite_sql=s) for p, s in zip(postgres_sql, sqlite_sql)]
|
||||||
migrations.RunSQL([("DROP TABLE IF EXISTS {} CASCADE;".format(table))])
|
|
||||||
for table in (
|
|
||||||
'celery_taskmeta',
|
|
||||||
'celery_tasksetmeta',
|
|
||||||
'djcelery_crontabschedule',
|
|
||||||
'djcelery_intervalschedule',
|
|
||||||
'djcelery_periodictask',
|
|
||||||
'djcelery_periodictasks',
|
|
||||||
'djcelery_taskstate',
|
|
||||||
'djcelery_workerstate',
|
|
||||||
'djkombu_message',
|
|
||||||
'djkombu_queue',
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from django.db import migrations, models, connection
|
from django.db import migrations, models, connection
|
||||||
|
|
||||||
|
from ._sqlite_helper import dbawaremigrations
|
||||||
|
|
||||||
|
|
||||||
def migrate_event_data(apps, schema_editor):
|
def migrate_event_data(apps, schema_editor):
|
||||||
# see: https://github.com/ansible/awx/issues/6010
|
# see: https://github.com/ansible/awx/issues/6010
|
||||||
@@ -24,6 +26,11 @@ def migrate_event_data(apps, schema_editor):
|
|||||||
cursor.execute(f'ALTER TABLE {tblname} ALTER COLUMN id TYPE bigint USING id::bigint;')
|
cursor.execute(f'ALTER TABLE {tblname} ALTER COLUMN id TYPE bigint USING id::bigint;')
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_event_data_sqlite(apps, schema_editor):
|
||||||
|
# TODO: cmeyers fill this in
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
class FakeAlterField(migrations.AlterField):
|
class FakeAlterField(migrations.AlterField):
|
||||||
def database_forwards(self, *args):
|
def database_forwards(self, *args):
|
||||||
# this is intentionally left blank, because we're
|
# this is intentionally left blank, because we're
|
||||||
@@ -37,7 +44,7 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(migrate_event_data),
|
dbawaremigrations.RunPython(migrate_event_data, sqlite_code=migrate_event_data_sqlite),
|
||||||
FakeAlterField(
|
FakeAlterField(
|
||||||
model_name='adhoccommandevent',
|
model_name='adhoccommandevent',
|
||||||
name='id',
|
name='id',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.db import migrations, models, connection
|
from django.db import migrations, models, connection
|
||||||
|
|
||||||
|
from ._sqlite_helper import dbawaremigrations
|
||||||
|
|
||||||
|
|
||||||
def migrate_event_data(apps, schema_editor):
|
def migrate_event_data(apps, schema_editor):
|
||||||
# see: https://github.com/ansible/awx/issues/9039
|
# see: https://github.com/ansible/awx/issues/9039
|
||||||
@@ -59,6 +61,10 @@ def migrate_event_data(apps, schema_editor):
|
|||||||
cursor.execute('DROP INDEX IF EXISTS main_jobevent_job_id_idx')
|
cursor.execute('DROP INDEX IF EXISTS main_jobevent_job_id_idx')
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_event_data_sqlite(apps, schema_editor):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class FakeAddField(migrations.AddField):
|
class FakeAddField(migrations.AddField):
|
||||||
def database_forwards(self, *args):
|
def database_forwards(self, *args):
|
||||||
# this is intentionally left blank, because we're
|
# this is intentionally left blank, because we're
|
||||||
@@ -72,7 +78,7 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(migrate_event_data),
|
dbawaremigrations.RunPython(migrate_event_data, sqlite_code=migrate_event_data_sqlite),
|
||||||
FakeAddField(
|
FakeAddField(
|
||||||
model_name='jobevent',
|
model_name='jobevent',
|
||||||
name='job_created',
|
name='job_created',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import awx.main.models.notifications
|
import awx.main.models.notifications
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from ._sqlite_helper import dbawaremigrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -104,11 +106,12 @@ class Migration(migrations.Migration):
|
|||||||
name='deleted_actor',
|
name='deleted_actor',
|
||||||
field=models.JSONField(null=True),
|
field=models.JSONField(null=True),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_activitystream RENAME setting TO setting_old;
|
ALTER TABLE main_activitystream RENAME setting TO setting_old;
|
||||||
ALTER TABLE main_activitystream ALTER COLUMN setting_old DROP NOT NULL;
|
ALTER TABLE main_activitystream ALTER COLUMN setting_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_activitystream RENAME setting TO setting_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='activitystream',
|
model_name='activitystream',
|
||||||
@@ -121,11 +124,12 @@ class Migration(migrations.Migration):
|
|||||||
name='setting',
|
name='setting',
|
||||||
field=models.JSONField(blank=True, default=dict),
|
field=models.JSONField(blank=True, default=dict),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_job RENAME survey_passwords TO survey_passwords_old;
|
ALTER TABLE main_job RENAME survey_passwords TO survey_passwords_old;
|
||||||
ALTER TABLE main_job ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
ALTER TABLE main_job ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_job RENAME survey_passwords TO survey_passwords_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='job',
|
model_name='job',
|
||||||
@@ -138,11 +142,12 @@ class Migration(migrations.Migration):
|
|||||||
name='survey_passwords',
|
name='survey_passwords',
|
||||||
field=models.JSONField(blank=True, default=dict, editable=False),
|
field=models.JSONField(blank=True, default=dict, editable=False),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_joblaunchconfig RENAME char_prompts TO char_prompts_old;
|
ALTER TABLE main_joblaunchconfig RENAME char_prompts TO char_prompts_old;
|
||||||
ALTER TABLE main_joblaunchconfig ALTER COLUMN char_prompts_old DROP NOT NULL;
|
ALTER TABLE main_joblaunchconfig ALTER COLUMN char_prompts_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_joblaunchconfig RENAME char_prompts TO char_prompts_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='joblaunchconfig',
|
model_name='joblaunchconfig',
|
||||||
@@ -155,11 +160,12 @@ class Migration(migrations.Migration):
|
|||||||
name='char_prompts',
|
name='char_prompts',
|
||||||
field=models.JSONField(blank=True, default=dict),
|
field=models.JSONField(blank=True, default=dict),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_joblaunchconfig RENAME survey_passwords TO survey_passwords_old;
|
ALTER TABLE main_joblaunchconfig RENAME survey_passwords TO survey_passwords_old;
|
||||||
ALTER TABLE main_joblaunchconfig ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
ALTER TABLE main_joblaunchconfig ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_joblaunchconfig RENAME survey_passwords TO survey_passwords_old;",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='joblaunchconfig',
|
model_name='joblaunchconfig',
|
||||||
@@ -172,11 +178,12 @@ class Migration(migrations.Migration):
|
|||||||
name='survey_passwords',
|
name='survey_passwords',
|
||||||
field=models.JSONField(blank=True, default=dict, editable=False),
|
field=models.JSONField(blank=True, default=dict, editable=False),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_notification RENAME body TO body_old;
|
ALTER TABLE main_notification RENAME body TO body_old;
|
||||||
ALTER TABLE main_notification ALTER COLUMN body_old DROP NOT NULL;
|
ALTER TABLE main_notification ALTER COLUMN body_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_notification RENAME body TO body_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='notification',
|
model_name='notification',
|
||||||
@@ -189,11 +196,12 @@ class Migration(migrations.Migration):
|
|||||||
name='body',
|
name='body',
|
||||||
field=models.JSONField(blank=True, default=dict),
|
field=models.JSONField(blank=True, default=dict),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_unifiedjob RENAME job_env TO job_env_old;
|
ALTER TABLE main_unifiedjob RENAME job_env TO job_env_old;
|
||||||
ALTER TABLE main_unifiedjob ALTER COLUMN job_env_old DROP NOT NULL;
|
ALTER TABLE main_unifiedjob ALTER COLUMN job_env_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_unifiedjob RENAME job_env TO job_env_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='unifiedjob',
|
model_name='unifiedjob',
|
||||||
@@ -206,11 +214,12 @@ class Migration(migrations.Migration):
|
|||||||
name='job_env',
|
name='job_env',
|
||||||
field=models.JSONField(blank=True, default=dict, editable=False),
|
field=models.JSONField(blank=True, default=dict, editable=False),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_workflowjob RENAME char_prompts TO char_prompts_old;
|
ALTER TABLE main_workflowjob RENAME char_prompts TO char_prompts_old;
|
||||||
ALTER TABLE main_workflowjob ALTER COLUMN char_prompts_old DROP NOT NULL;
|
ALTER TABLE main_workflowjob ALTER COLUMN char_prompts_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_workflowjob RENAME char_prompts TO char_prompts_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='workflowjob',
|
model_name='workflowjob',
|
||||||
@@ -223,11 +232,12 @@ class Migration(migrations.Migration):
|
|||||||
name='char_prompts',
|
name='char_prompts',
|
||||||
field=models.JSONField(blank=True, default=dict),
|
field=models.JSONField(blank=True, default=dict),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_workflowjob RENAME survey_passwords TO survey_passwords_old;
|
ALTER TABLE main_workflowjob RENAME survey_passwords TO survey_passwords_old;
|
||||||
ALTER TABLE main_workflowjob ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
ALTER TABLE main_workflowjob ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_workflowjob RENAME survey_passwords TO survey_passwords_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='workflowjob',
|
model_name='workflowjob',
|
||||||
@@ -240,11 +250,12 @@ class Migration(migrations.Migration):
|
|||||||
name='survey_passwords',
|
name='survey_passwords',
|
||||||
field=models.JSONField(blank=True, default=dict, editable=False),
|
field=models.JSONField(blank=True, default=dict, editable=False),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_workflowjobnode RENAME char_prompts TO char_prompts_old;
|
ALTER TABLE main_workflowjobnode RENAME char_prompts TO char_prompts_old;
|
||||||
ALTER TABLE main_workflowjobnode ALTER COLUMN char_prompts_old DROP NOT NULL;
|
ALTER TABLE main_workflowjobnode ALTER COLUMN char_prompts_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_workflowjobnode RENAME char_prompts TO char_prompts_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='workflowjobnode',
|
model_name='workflowjobnode',
|
||||||
@@ -257,11 +268,12 @@ class Migration(migrations.Migration):
|
|||||||
name='char_prompts',
|
name='char_prompts',
|
||||||
field=models.JSONField(blank=True, default=dict),
|
field=models.JSONField(blank=True, default=dict),
|
||||||
),
|
),
|
||||||
migrations.RunSQL(
|
dbawaremigrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE main_workflowjobnode RENAME survey_passwords TO survey_passwords_old;
|
ALTER TABLE main_workflowjobnode RENAME survey_passwords TO survey_passwords_old;
|
||||||
ALTER TABLE main_workflowjobnode ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
ALTER TABLE main_workflowjobnode ALTER COLUMN survey_passwords_old DROP NOT NULL;
|
||||||
""",
|
""",
|
||||||
|
sqlite_sql="ALTER TABLE main_workflowjobnode RENAME survey_passwords TO survey_passwords_old",
|
||||||
state_operations=[
|
state_operations=[
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='workflowjobnode',
|
model_name='workflowjobnode',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
from ._sqlite_helper import dbawaremigrations
|
||||||
|
|
||||||
|
|
||||||
def delete_taggit_contenttypes(apps, schema_editor):
|
def delete_taggit_contenttypes(apps, schema_editor):
|
||||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||||
@@ -20,8 +22,8 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunSQL("DROP TABLE IF EXISTS taggit_tag CASCADE;"),
|
dbawaremigrations.RunSQL("DROP TABLE IF EXISTS taggit_tag CASCADE;", sqlite_sql="DROP TABLE IF EXISTS taggit_tag;"),
|
||||||
migrations.RunSQL("DROP TABLE IF EXISTS taggit_taggeditem CASCADE;"),
|
dbawaremigrations.RunSQL("DROP TABLE IF EXISTS taggit_taggeditem CASCADE;", sqlite_sql="DROP TABLE IF EXISTS taggit_taggeditem;"),
|
||||||
migrations.RunPython(delete_taggit_contenttypes),
|
migrations.RunPython(delete_taggit_contenttypes),
|
||||||
migrations.RunPython(delete_taggit_migration_records),
|
migrations.RunPython(delete_taggit_migration_records),
|
||||||
]
|
]
|
||||||
|
|||||||
61
awx/main/migrations/_sqlite_helper.py
Normal file
61
awx/main/migrations/_sqlite_helper.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class RunSQL(migrations.operations.special.RunSQL):
|
||||||
|
"""
|
||||||
|
Bit of a hack here. Django actually wants this decision made in the router
|
||||||
|
and we can pass **hints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
if 'sqlite_sql' not in kwargs:
|
||||||
|
raise ValueError("sqlite_sql parameter required")
|
||||||
|
sqlite_sql = kwargs.pop('sqlite_sql')
|
||||||
|
|
||||||
|
self.sqlite_sql = sqlite_sql
|
||||||
|
self.sqlite_reverse_sql = kwargs.pop('sqlite_reverse_sql', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
if not schema_editor.connection.vendor.startswith('postgres'):
|
||||||
|
self.sql = self.sqlite_sql or migrations.RunSQL.noop
|
||||||
|
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||||
|
|
||||||
|
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
if not schema_editor.connection.vendor.startswith('postgres'):
|
||||||
|
self.reverse_sql = self.sqlite_reverse_sql or migrations.RunSQL.noop
|
||||||
|
super().database_backwards(app_label, schema_editor, from_state, to_state)
|
||||||
|
|
||||||
|
|
||||||
|
class RunPython(migrations.operations.special.RunPython):
|
||||||
|
"""
|
||||||
|
Bit of a hack here. Django actually wants this decision made in the router
|
||||||
|
and we can pass **hints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
if 'sqlite_code' not in kwargs:
|
||||||
|
raise ValueError("sqlite_code parameter required")
|
||||||
|
sqlite_code = kwargs.pop('sqlite_code')
|
||||||
|
|
||||||
|
self.sqlite_code = sqlite_code
|
||||||
|
self.sqlite_reverse_code = kwargs.pop('sqlite_reverse_code', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
if not schema_editor.connection.vendor.startswith('postgres'):
|
||||||
|
self.code = self.sqlite_code or migrations.RunPython.noop
|
||||||
|
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||||
|
|
||||||
|
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
if not schema_editor.connection.vendor.startswith('postgres'):
|
||||||
|
self.reverse_code = self.sqlite_reverse_code or migrations.RunPython.noop
|
||||||
|
super().database_backwards(app_label, schema_editor, from_state, to_state)
|
||||||
|
|
||||||
|
|
||||||
|
class _sqlitemigrations:
|
||||||
|
RunPython = RunPython
|
||||||
|
RunSQL = RunSQL
|
||||||
|
|
||||||
|
|
||||||
|
dbawaremigrations = _sqlitemigrations()
|
||||||
@@ -57,7 +57,6 @@ from awx.main.models.ha import ( # noqa
|
|||||||
from awx.main.models.rbac import ( # noqa
|
from awx.main.models.rbac import ( # noqa
|
||||||
Role,
|
Role,
|
||||||
batch_role_ancestor_rebuilding,
|
batch_role_ancestor_rebuilding,
|
||||||
get_roles_on_resource,
|
|
||||||
role_summary_fields_generator,
|
role_summary_fields_generator,
|
||||||
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||||
ROLE_SINGLETON_SYSTEM_AUDITOR,
|
ROLE_SINGLETON_SYSTEM_AUDITOR,
|
||||||
@@ -91,13 +90,12 @@ from oauth2_provider.models import Grant, RefreshToken # noqa -- needed django-
|
|||||||
|
|
||||||
# Add custom methods to User model for permissions checks.
|
# Add custom methods to User model for permissions checks.
|
||||||
from django.contrib.auth.models import User # noqa
|
from django.contrib.auth.models import User # noqa
|
||||||
from awx.main.access import get_user_queryset, check_user_access, check_user_access_with_errors, user_accessible_objects # noqa
|
from awx.main.access import get_user_queryset, check_user_access, check_user_access_with_errors # noqa
|
||||||
|
|
||||||
|
|
||||||
User.add_to_class('get_queryset', get_user_queryset)
|
User.add_to_class('get_queryset', get_user_queryset)
|
||||||
User.add_to_class('can_access', check_user_access)
|
User.add_to_class('can_access', check_user_access)
|
||||||
User.add_to_class('can_access_with_errors', check_user_access_with_errors)
|
User.add_to_class('can_access_with_errors', check_user_access_with_errors)
|
||||||
User.add_to_class('accessible_objects', user_accessible_objects)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_jsonfields():
|
def convert_jsonfields():
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models.base import prevent_search
|
from awx.main.models.base import prevent_search
|
||||||
from awx.main.models.rbac import Role, RoleAncestorEntry, get_roles_on_resource
|
from awx.main.models.rbac import Role, RoleAncestorEntry
|
||||||
from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices, get_licenser, polymorphic
|
from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices, get_licenser, polymorphic
|
||||||
from awx.main.utils.execution_environments import get_default_execution_environment
|
from awx.main.utils.execution_environments import get_default_execution_environment
|
||||||
from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted
|
from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted
|
||||||
@@ -54,10 +54,7 @@ class ResourceMixin(models.Model):
|
|||||||
Use instead of `MyModel.objects` when you want to only consider
|
Use instead of `MyModel.objects` when you want to only consider
|
||||||
resources that a user has specific permissions for. For example:
|
resources that a user has specific permissions for. For example:
|
||||||
MyModel.accessible_objects(user, 'read_role').filter(name__istartswith='bar');
|
MyModel.accessible_objects(user, 'read_role').filter(name__istartswith='bar');
|
||||||
NOTE: This should only be used for list type things. If you have a
|
NOTE: This should only be used for list type things.
|
||||||
specific resource you want to check permissions on, it is more
|
|
||||||
performant to resolve the resource in question then call
|
|
||||||
`myresource.get_permissions(user)`.
|
|
||||||
"""
|
"""
|
||||||
return ResourceMixin._accessible_objects(cls, accessor, role_field)
|
return ResourceMixin._accessible_objects(cls, accessor, role_field)
|
||||||
|
|
||||||
@@ -86,15 +83,6 @@ class ResourceMixin(models.Model):
|
|||||||
def _accessible_objects(cls, accessor, role_field):
|
def _accessible_objects(cls, accessor, role_field):
|
||||||
return cls.objects.filter(pk__in=ResourceMixin._accessible_pk_qs(cls, accessor, role_field))
|
return cls.objects.filter(pk__in=ResourceMixin._accessible_pk_qs(cls, accessor, role_field))
|
||||||
|
|
||||||
def get_permissions(self, accessor):
|
|
||||||
"""
|
|
||||||
Returns a string list of the roles a accessor has for a given resource.
|
|
||||||
An accessor can be either a User, Role, or an arbitrary resource that
|
|
||||||
contains one or more Roles associated with it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return get_roles_on_resource(self, accessor)
|
|
||||||
|
|
||||||
|
|
||||||
class SurveyJobTemplateMixin(models.Model):
|
class SurveyJobTemplateMixin(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1439,6 +1439,11 @@ class UnifiedJob(
|
|||||||
if not self.celery_task_id:
|
if not self.celery_task_id:
|
||||||
return
|
return
|
||||||
canceled = []
|
canceled = []
|
||||||
|
if not connection.get_autocommit():
|
||||||
|
# this condition is purpose-written for the task manager, when it cancels jobs in workflows
|
||||||
|
ControlDispatcher('dispatcher', self.controller_node).cancel([self.celery_task_id], with_reply=False)
|
||||||
|
return True # task manager itself needs to act under assumption that cancel was received
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use control and reply mechanism to cancel and obtain confirmation
|
# Use control and reply mechanism to cancel and obtain confirmation
|
||||||
timeout = 5
|
timeout = 5
|
||||||
|
|||||||
@@ -124,6 +124,13 @@ class TaskBase:
|
|||||||
self.record_aggregate_metrics()
|
self.record_aggregate_metrics()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
def get_local_metrics(self):
|
||||||
|
data = {}
|
||||||
|
for k, metric in self.subsystem_metrics.METRICS.items():
|
||||||
|
if k.startswith(self.prefix) and metric.metric_has_changed:
|
||||||
|
data[k[len(self.prefix) + 1 :]] = metric.current_value
|
||||||
|
return data
|
||||||
|
|
||||||
def schedule(self):
|
def schedule(self):
|
||||||
# Always be able to restore the original signal handler if we finish
|
# Always be able to restore the original signal handler if we finish
|
||||||
original_sigusr1 = signal.getsignal(signal.SIGUSR1)
|
original_sigusr1 = signal.getsignal(signal.SIGUSR1)
|
||||||
@@ -146,10 +153,14 @@ class TaskBase:
|
|||||||
signal.signal(signal.SIGUSR1, original_sigusr1)
|
signal.signal(signal.SIGUSR1, original_sigusr1)
|
||||||
commit_start = time.time()
|
commit_start = time.time()
|
||||||
|
|
||||||
|
logger.debug(f"Commiting {self.prefix} Scheduler changes")
|
||||||
|
|
||||||
if self.prefix == "task_manager":
|
if self.prefix == "task_manager":
|
||||||
self.subsystem_metrics.set(f"{self.prefix}_commit_seconds", time.time() - commit_start)
|
self.subsystem_metrics.set(f"{self.prefix}_commit_seconds", time.time() - commit_start)
|
||||||
|
local_metrics = self.get_local_metrics()
|
||||||
self.record_aggregate_metrics()
|
self.record_aggregate_metrics()
|
||||||
logger.debug(f"Finishing {self.prefix} Scheduler")
|
|
||||||
|
logger.debug(f"Finished {self.prefix} Scheduler, timing data:\n{local_metrics}")
|
||||||
|
|
||||||
|
|
||||||
class WorkflowManager(TaskBase):
|
class WorkflowManager(TaskBase):
|
||||||
@@ -259,6 +270,9 @@ class WorkflowManager(TaskBase):
|
|||||||
job.status = 'failed'
|
job.status = 'failed'
|
||||||
job.save(update_fields=['status', 'job_explanation'])
|
job.save(update_fields=['status', 'job_explanation'])
|
||||||
job.websocket_emit_status('failed')
|
job.websocket_emit_status('failed')
|
||||||
|
# NOTE: sending notification templates here is slightly worse performance
|
||||||
|
# this is not yet optimized in the same way as for the TaskManager
|
||||||
|
job.send_notification_templates('failed')
|
||||||
ScheduleWorkflowManager().schedule()
|
ScheduleWorkflowManager().schedule()
|
||||||
|
|
||||||
# TODO: should we emit a status on the socket here similar to tasks.py awx_periodic_scheduler() ?
|
# TODO: should we emit a status on the socket here similar to tasks.py awx_periodic_scheduler() ?
|
||||||
@@ -419,6 +433,25 @@ class TaskManager(TaskBase):
|
|||||||
self.tm_models = TaskManagerModels()
|
self.tm_models = TaskManagerModels()
|
||||||
self.controlplane_ig = self.tm_models.instance_groups.controlplane_ig
|
self.controlplane_ig = self.tm_models.instance_groups.controlplane_ig
|
||||||
|
|
||||||
|
def process_job_dep_failures(self, task):
|
||||||
|
"""If job depends on a job that has failed, mark as failed and handle misc stuff."""
|
||||||
|
for dep in task.dependent_jobs.all():
|
||||||
|
# if we detect a failed or error dependency, go ahead and fail this task.
|
||||||
|
if dep.status in ("error", "failed"):
|
||||||
|
task.status = 'failed'
|
||||||
|
logger.warning(f'Previous task failed task: {task.id} dep: {dep.id} task manager')
|
||||||
|
task.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
||||||
|
get_type_for_model(type(dep)),
|
||||||
|
dep.name,
|
||||||
|
dep.id,
|
||||||
|
)
|
||||||
|
task.save(update_fields=['status', 'job_explanation'])
|
||||||
|
task.websocket_emit_status('failed')
|
||||||
|
self.pre_start_failed.append(task.id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def job_blocked_by(self, task):
|
def job_blocked_by(self, task):
|
||||||
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
|
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
|
||||||
# in the old task manager this was handled as a method on each task object outside of the graph and
|
# in the old task manager this was handled as a method on each task object outside of the graph and
|
||||||
@@ -430,20 +463,6 @@ class TaskManager(TaskBase):
|
|||||||
for dep in task.dependent_jobs.all():
|
for dep in task.dependent_jobs.all():
|
||||||
if dep.status in ACTIVE_STATES:
|
if dep.status in ACTIVE_STATES:
|
||||||
return dep
|
return dep
|
||||||
# if we detect a failed or error dependency, go ahead and fail this
|
|
||||||
# task. The errback on the dependency takes some time to trigger,
|
|
||||||
# and we don't want the task to enter running state if its
|
|
||||||
# dependency has failed or errored.
|
|
||||||
elif dep.status in ("error", "failed"):
|
|
||||||
task.status = 'failed'
|
|
||||||
task.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
|
||||||
get_type_for_model(type(dep)),
|
|
||||||
dep.name,
|
|
||||||
dep.id,
|
|
||||||
)
|
|
||||||
task.save(update_fields=['status', 'job_explanation'])
|
|
||||||
task.websocket_emit_status('failed')
|
|
||||||
return dep
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -463,7 +482,6 @@ class TaskManager(TaskBase):
|
|||||||
if self.start_task_limit == 0:
|
if self.start_task_limit == 0:
|
||||||
# schedule another run immediately after this task manager
|
# schedule another run immediately after this task manager
|
||||||
ScheduleTaskManager().schedule()
|
ScheduleTaskManager().schedule()
|
||||||
from awx.main.tasks.system import handle_work_error, handle_work_success
|
|
||||||
|
|
||||||
task.status = 'waiting'
|
task.status = 'waiting'
|
||||||
|
|
||||||
@@ -474,7 +492,7 @@ class TaskManager(TaskBase):
|
|||||||
task.job_explanation += ' '
|
task.job_explanation += ' '
|
||||||
task.job_explanation += 'Task failed pre-start check.'
|
task.job_explanation += 'Task failed pre-start check.'
|
||||||
task.save()
|
task.save()
|
||||||
# TODO: run error handler to fail sub-tasks and send notifications
|
self.pre_start_failed.append(task.id)
|
||||||
else:
|
else:
|
||||||
if type(task) is WorkflowJob:
|
if type(task) is WorkflowJob:
|
||||||
task.status = 'running'
|
task.status = 'running'
|
||||||
@@ -496,19 +514,16 @@ class TaskManager(TaskBase):
|
|||||||
# apply_async does a NOTIFY to the channel dispatcher is listening to
|
# apply_async does a NOTIFY to the channel dispatcher is listening to
|
||||||
# postgres will treat this as part of the transaction, which is what we want
|
# postgres will treat this as part of the transaction, which is what we want
|
||||||
if task.status != 'failed' and type(task) is not WorkflowJob:
|
if task.status != 'failed' and type(task) is not WorkflowJob:
|
||||||
task_actual = {'type': get_type_for_model(type(task)), 'id': task.id}
|
|
||||||
task_cls = task._get_task_class()
|
task_cls = task._get_task_class()
|
||||||
task_cls.apply_async(
|
task_cls.apply_async(
|
||||||
[task.pk],
|
[task.pk],
|
||||||
opts,
|
opts,
|
||||||
queue=task.get_queue_name(),
|
queue=task.get_queue_name(),
|
||||||
uuid=task.celery_task_id,
|
uuid=task.celery_task_id,
|
||||||
callbacks=[{'task': handle_work_success.name, 'kwargs': {'task_actual': task_actual}}],
|
|
||||||
errbacks=[{'task': handle_work_error.name, 'kwargs': {'task_actual': task_actual}}],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# In exception cases, like a job failing pre-start checks, we send the websocket status message
|
# In exception cases, like a job failing pre-start checks, we send the websocket status message.
|
||||||
# for jobs going into waiting, we omit this because of performance issues, as it should go to running quickly
|
# For jobs going into waiting, we omit this because of performance issues, as it should go to running quickly
|
||||||
if task.status != 'waiting':
|
if task.status != 'waiting':
|
||||||
task.websocket_emit_status(task.status) # adds to on_commit
|
task.websocket_emit_status(task.status) # adds to on_commit
|
||||||
|
|
||||||
@@ -529,6 +544,11 @@ class TaskManager(TaskBase):
|
|||||||
if self.timed_out():
|
if self.timed_out():
|
||||||
logger.warning("Task manager has reached time out while processing pending jobs, exiting loop early")
|
logger.warning("Task manager has reached time out while processing pending jobs, exiting loop early")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
has_failed = self.process_job_dep_failures(task)
|
||||||
|
if has_failed:
|
||||||
|
continue
|
||||||
|
|
||||||
blocked_by = self.job_blocked_by(task)
|
blocked_by = self.job_blocked_by(task)
|
||||||
if blocked_by:
|
if blocked_by:
|
||||||
self.subsystem_metrics.inc(f"{self.prefix}_tasks_blocked", 1)
|
self.subsystem_metrics.inc(f"{self.prefix}_tasks_blocked", 1)
|
||||||
@@ -642,6 +662,11 @@ class TaskManager(TaskBase):
|
|||||||
reap_job(j, 'failed')
|
reap_job(j, 'failed')
|
||||||
|
|
||||||
def process_tasks(self):
|
def process_tasks(self):
|
||||||
|
# maintain a list of jobs that went to an early failure state,
|
||||||
|
# meaning the dispatcher never got these jobs,
|
||||||
|
# that means we have to handle notifications for those
|
||||||
|
self.pre_start_failed = []
|
||||||
|
|
||||||
running_tasks = [t for t in self.all_tasks if t.status in ['waiting', 'running']]
|
running_tasks = [t for t in self.all_tasks if t.status in ['waiting', 'running']]
|
||||||
self.process_running_tasks(running_tasks)
|
self.process_running_tasks(running_tasks)
|
||||||
self.subsystem_metrics.inc(f"{self.prefix}_running_processed", len(running_tasks))
|
self.subsystem_metrics.inc(f"{self.prefix}_running_processed", len(running_tasks))
|
||||||
@@ -651,6 +676,11 @@ class TaskManager(TaskBase):
|
|||||||
self.process_pending_tasks(pending_tasks)
|
self.process_pending_tasks(pending_tasks)
|
||||||
self.subsystem_metrics.inc(f"{self.prefix}_pending_processed", len(pending_tasks))
|
self.subsystem_metrics.inc(f"{self.prefix}_pending_processed", len(pending_tasks))
|
||||||
|
|
||||||
|
if self.pre_start_failed:
|
||||||
|
from awx.main.tasks.system import handle_failure_notifications
|
||||||
|
|
||||||
|
handle_failure_notifications.delay(self.pre_start_failed)
|
||||||
|
|
||||||
def timeout_approval_node(self, task):
|
def timeout_approval_node(self, task):
|
||||||
if self.timed_out():
|
if self.timed_out():
|
||||||
logger.warning("Task manager has reached time out while processing approval nodes, exiting loop early")
|
logger.warning("Task manager has reached time out while processing approval nodes, exiting loop early")
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ from awx.main.utils.common import (
|
|||||||
extract_ansible_vars,
|
extract_ansible_vars,
|
||||||
get_awx_version,
|
get_awx_version,
|
||||||
create_partition,
|
create_partition,
|
||||||
|
ScheduleWorkflowManager,
|
||||||
|
ScheduleTaskManager,
|
||||||
)
|
)
|
||||||
from awx.conf.license import get_license
|
from awx.conf.license import get_license
|
||||||
from awx.main.utils.handlers import SpecialInventoryHandler
|
from awx.main.utils.handlers import SpecialInventoryHandler
|
||||||
@@ -450,6 +452,12 @@ class BaseTask(object):
|
|||||||
instance.ansible_version = ansible_version_info
|
instance.ansible_version = ansible_version_info
|
||||||
instance.save(update_fields=['ansible_version'])
|
instance.save(update_fields=['ansible_version'])
|
||||||
|
|
||||||
|
# Run task manager appropriately for speculative dependencies
|
||||||
|
if instance.unifiedjob_blocked_jobs.exists():
|
||||||
|
ScheduleTaskManager().schedule()
|
||||||
|
if instance.spawned_by_workflow:
|
||||||
|
ScheduleWorkflowManager().schedule()
|
||||||
|
|
||||||
def should_use_fact_cache(self):
|
def should_use_fact_cache(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1873,6 +1881,8 @@ class RunSystemJob(BaseTask):
|
|||||||
if system_job.job_type in ('cleanup_jobs', 'cleanup_activitystream'):
|
if system_job.job_type in ('cleanup_jobs', 'cleanup_activitystream'):
|
||||||
if 'days' in json_vars:
|
if 'days' in json_vars:
|
||||||
args.extend(['--days', str(json_vars.get('days', 60))])
|
args.extend(['--days', str(json_vars.get('days', 60))])
|
||||||
|
if 'batch_size' in json_vars:
|
||||||
|
args.extend(['--batch-size', str(json_vars['batch_size'])])
|
||||||
if 'dry_run' in json_vars and json_vars['dry_run']:
|
if 'dry_run' in json_vars and json_vars['dry_run']:
|
||||||
args.extend(['--dry-run'])
|
args.extend(['--dry-run'])
|
||||||
if system_job.job_type == 'cleanup_jobs':
|
if system_job.job_type == 'cleanup_jobs':
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ class SignalExit(Exception):
|
|||||||
class SignalState:
|
class SignalState:
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self.sigterm_flag = False
|
self.sigterm_flag = False
|
||||||
self.is_active = False
|
self.sigint_flag = False
|
||||||
|
|
||||||
|
self.is_active = False # for nested context managers
|
||||||
self.original_sigterm = None
|
self.original_sigterm = None
|
||||||
self.original_sigint = None
|
self.original_sigint = None
|
||||||
self.raise_exception = False
|
self.raise_exception = False
|
||||||
@@ -24,23 +26,36 @@ class SignalState:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def set_flag(self, *args):
|
def raise_if_needed(self):
|
||||||
"""Method to pass into the python signal.signal method to receive signals"""
|
|
||||||
self.sigterm_flag = True
|
|
||||||
if self.raise_exception:
|
if self.raise_exception:
|
||||||
self.raise_exception = False # so it is not raised a second time in error handling
|
self.raise_exception = False # so it is not raised a second time in error handling
|
||||||
raise SignalExit()
|
raise SignalExit()
|
||||||
|
|
||||||
|
def set_sigterm_flag(self, *args):
|
||||||
|
self.sigterm_flag = True
|
||||||
|
self.raise_if_needed()
|
||||||
|
|
||||||
|
def set_sigint_flag(self, *args):
|
||||||
|
self.sigint_flag = True
|
||||||
|
self.raise_if_needed()
|
||||||
|
|
||||||
def connect_signals(self):
|
def connect_signals(self):
|
||||||
self.original_sigterm = signal.getsignal(signal.SIGTERM)
|
self.original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
self.original_sigint = signal.getsignal(signal.SIGINT)
|
self.original_sigint = signal.getsignal(signal.SIGINT)
|
||||||
signal.signal(signal.SIGTERM, self.set_flag)
|
signal.signal(signal.SIGTERM, self.set_sigterm_flag)
|
||||||
signal.signal(signal.SIGINT, self.set_flag)
|
signal.signal(signal.SIGINT, self.set_sigint_flag)
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
|
||||||
def restore_signals(self):
|
def restore_signals(self):
|
||||||
signal.signal(signal.SIGTERM, self.original_sigterm)
|
signal.signal(signal.SIGTERM, self.original_sigterm)
|
||||||
signal.signal(signal.SIGINT, self.original_sigint)
|
signal.signal(signal.SIGINT, self.original_sigint)
|
||||||
|
# if we got a signal while context manager was active, call parent methods.
|
||||||
|
if self.sigterm_flag:
|
||||||
|
if callable(self.original_sigterm):
|
||||||
|
self.original_sigterm()
|
||||||
|
if self.sigint_flag:
|
||||||
|
if callable(self.original_sigint):
|
||||||
|
self.original_sigint()
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +63,7 @@ signal_state = SignalState()
|
|||||||
|
|
||||||
|
|
||||||
def signal_callback():
|
def signal_callback():
|
||||||
return signal_state.sigterm_flag
|
return bool(signal_state.sigterm_flag or signal_state.sigint_flag)
|
||||||
|
|
||||||
|
|
||||||
def with_signal_handling(f):
|
def with_signal_handling(f):
|
||||||
|
|||||||
@@ -53,13 +53,7 @@ from awx.main.models import (
|
|||||||
from awx.main.constants import ACTIVE_STATES
|
from awx.main.constants import ACTIVE_STATES
|
||||||
from awx.main.dispatch.publish import task
|
from awx.main.dispatch.publish import task
|
||||||
from awx.main.dispatch import get_task_queuename, reaper
|
from awx.main.dispatch import get_task_queuename, reaper
|
||||||
from awx.main.utils.common import (
|
from awx.main.utils.common import ignore_inventory_computed_fields, ignore_inventory_group_removal
|
||||||
get_type_for_model,
|
|
||||||
ignore_inventory_computed_fields,
|
|
||||||
ignore_inventory_group_removal,
|
|
||||||
ScheduleWorkflowManager,
|
|
||||||
ScheduleTaskManager,
|
|
||||||
)
|
|
||||||
|
|
||||||
from awx.main.utils.reload import stop_local_services
|
from awx.main.utils.reload import stop_local_services
|
||||||
from awx.main.utils.pglock import advisory_lock
|
from awx.main.utils.pglock import advisory_lock
|
||||||
@@ -765,63 +759,19 @@ def awx_periodic_scheduler():
|
|||||||
emit_channel_notification('schedules-changed', dict(id=schedule.id, group_name="schedules"))
|
emit_channel_notification('schedules-changed', dict(id=schedule.id, group_name="schedules"))
|
||||||
|
|
||||||
|
|
||||||
def schedule_manager_success_or_error(instance):
|
|
||||||
if instance.unifiedjob_blocked_jobs.exists():
|
|
||||||
ScheduleTaskManager().schedule()
|
|
||||||
if instance.spawned_by_workflow:
|
|
||||||
ScheduleWorkflowManager().schedule()
|
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_task_queuename)
|
@task(queue=get_task_queuename)
|
||||||
def handle_work_success(task_actual):
|
def handle_failure_notifications(task_ids):
|
||||||
try:
|
"""A task-ified version of the method that sends notifications."""
|
||||||
instance = UnifiedJob.get_instance_by_type(task_actual['type'], task_actual['id'])
|
found_task_ids = set()
|
||||||
except ObjectDoesNotExist:
|
for instance in UnifiedJob.objects.filter(id__in=task_ids):
|
||||||
logger.warning('Missing {} `{}` in success callback.'.format(task_actual['type'], task_actual['id']))
|
found_task_ids.add(instance.id)
|
||||||
return
|
try:
|
||||||
if not instance:
|
instance.send_notification_templates('failed')
|
||||||
return
|
except Exception:
|
||||||
schedule_manager_success_or_error(instance)
|
logger.exception(f'Error preparing notifications for task {instance.id}')
|
||||||
|
deleted_tasks = set(task_ids) - found_task_ids
|
||||||
|
if deleted_tasks:
|
||||||
@task(queue=get_task_queuename)
|
logger.warning(f'Could not send notifications for {deleted_tasks} because they were not found in the database')
|
||||||
def handle_work_error(task_actual):
|
|
||||||
try:
|
|
||||||
instance = UnifiedJob.get_instance_by_type(task_actual['type'], task_actual['id'])
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
logger.warning('Missing {} `{}` in error callback.'.format(task_actual['type'], task_actual['id']))
|
|
||||||
return
|
|
||||||
if not instance:
|
|
||||||
return
|
|
||||||
|
|
||||||
subtasks = instance.get_jobs_fail_chain() # reverse of dependent_jobs mostly
|
|
||||||
logger.debug(f'Executing error task id {task_actual["id"]}, subtasks: {[subtask.id for subtask in subtasks]}')
|
|
||||||
|
|
||||||
deps_of_deps = {}
|
|
||||||
|
|
||||||
for subtask in subtasks:
|
|
||||||
if subtask.celery_task_id != instance.celery_task_id and not subtask.cancel_flag and not subtask.status in ('successful', 'failed'):
|
|
||||||
# If there are multiple in the dependency chain, A->B->C, and this was called for A, blame B for clarity
|
|
||||||
blame_job = deps_of_deps.get(subtask.id, instance)
|
|
||||||
subtask.status = 'failed'
|
|
||||||
subtask.failed = True
|
|
||||||
if not subtask.job_explanation:
|
|
||||||
subtask.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
|
||||||
get_type_for_model(type(blame_job)),
|
|
||||||
blame_job.name,
|
|
||||||
blame_job.id,
|
|
||||||
)
|
|
||||||
subtask.save()
|
|
||||||
subtask.websocket_emit_status("failed")
|
|
||||||
|
|
||||||
for sub_subtask in subtask.get_jobs_fail_chain():
|
|
||||||
deps_of_deps[sub_subtask.id] = subtask
|
|
||||||
|
|
||||||
# We only send 1 job complete message since all the job completion message
|
|
||||||
# handling does is trigger the scheduler. If we extend the functionality of
|
|
||||||
# what the job complete message handler does then we may want to send a
|
|
||||||
# completion event for each job here.
|
|
||||||
schedule_manager_success_or_error(instance)
|
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_task_queuename)
|
@task(queue=get_task_queuename)
|
||||||
|
|||||||
@@ -76,3 +76,24 @@ def test_hashivault_handle_auth_kubernetes():
|
|||||||
def test_hashivault_handle_auth_not_enough_args():
|
def test_hashivault_handle_auth_not_enough_args():
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
hashivault.handle_auth()
|
hashivault.handle_auth()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelineaImports:
|
||||||
|
"""
|
||||||
|
These module have a try-except for ImportError which will allow using the older library
|
||||||
|
but we do not want the awx_devel image to have the older library,
|
||||||
|
so these tests are designed to fail if these wind up using the fallback import
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_dsv_import(self):
|
||||||
|
from awx.main.credential_plugins.dsv import SecretsVault # noqa
|
||||||
|
|
||||||
|
# assert this module as opposed to older thycotic.secrets.vault
|
||||||
|
assert SecretsVault.__module__ == 'delinea.secrets.vault'
|
||||||
|
|
||||||
|
def test_tss_import(self):
|
||||||
|
from awx.main.credential_plugins.tss import DomainPasswordGrantAuthorizer, PasswordGrantAuthorizer, SecretServer, ServerSecret # noqa
|
||||||
|
|
||||||
|
for cls in (DomainPasswordGrantAuthorizer, PasswordGrantAuthorizer, SecretServer, ServerSecret):
|
||||||
|
# assert this module as opposed to older thycotic.secrets.server
|
||||||
|
assert cls.__module__ == 'delinea.secrets.server'
|
||||||
|
|||||||
44
awx/main/tests/functional/test_migrations.py
Normal file
44
awx/main/tests/functional/test_migrations.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from django_test_migrations.plan import all_migrations, nodes_to_tuples
|
||||||
|
|
||||||
|
"""
|
||||||
|
Most tests that live in here can probably be deleted at some point. They are mainly
|
||||||
|
for a developer. When AWX versions that users upgrade from falls out of support that
|
||||||
|
is when migration tests can be deleted. This is also a good time to squash. Squashing
|
||||||
|
will likely mess with the tests that live here.
|
||||||
|
|
||||||
|
The smoke test should be kept in here. The smoke test ensures that our migrations
|
||||||
|
continue to work when sqlite is the backing database (vs. the default DB of postgres).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestMigrationSmoke:
|
||||||
|
def test_happy_path(self, migrator):
|
||||||
|
"""
|
||||||
|
This smoke test runs all the migrations.
|
||||||
|
|
||||||
|
Example of how to use django-test-migration to invoke particular migration(s)
|
||||||
|
while weaving in object creation and assertions.
|
||||||
|
|
||||||
|
Note that this is more than just an example. It is a smoke test because it runs ALL
|
||||||
|
the migrations. Our "normal" unit tests subvert the migrations running because it is slow.
|
||||||
|
"""
|
||||||
|
migration_nodes = all_migrations('default')
|
||||||
|
migration_tuples = nodes_to_tuples(migration_nodes)
|
||||||
|
final_migration = migration_tuples[-1]
|
||||||
|
|
||||||
|
migrator.apply_initial_migration(('main', None))
|
||||||
|
# I just picked a newish migration at the time of writing this.
|
||||||
|
# If someone from the future finds themselves here because the are squashing migrations
|
||||||
|
# it is fine to change the 0180_... below to some other newish migration
|
||||||
|
intermediate_state = migrator.apply_tested_migration(('main', '0180_add_hostmetric_fields'))
|
||||||
|
|
||||||
|
Instance = intermediate_state.apps.get_model('main', 'Instance')
|
||||||
|
# Create any old object in the database
|
||||||
|
Instance.objects.create(hostname='foobar', node_type='control')
|
||||||
|
|
||||||
|
final_state = migrator.apply_tested_migration(final_migration)
|
||||||
|
Instance = final_state.apps.get_model('main', 'Instance')
|
||||||
|
assert Instance.objects.filter(hostname='foobar').count() == 1
|
||||||
@@ -122,25 +122,6 @@ def test_team_org_resource_role(ext_auth, organization, rando, org_admin, team):
|
|||||||
] == [True for i in range(2)]
|
] == [True for i in range(2)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_user_accessible_objects(user, organization):
|
|
||||||
"""
|
|
||||||
We cannot directly use accessible_objects for User model because
|
|
||||||
both editing and read permissions are obligated to complex business logic
|
|
||||||
"""
|
|
||||||
admin = user('admin', False)
|
|
||||||
u = user('john', False)
|
|
||||||
access = UserAccess(admin)
|
|
||||||
assert access.get_queryset().count() == 1 # can only see himself
|
|
||||||
|
|
||||||
organization.member_role.members.add(u)
|
|
||||||
organization.member_role.members.add(admin)
|
|
||||||
assert access.get_queryset().count() == 2
|
|
||||||
|
|
||||||
organization.member_role.members.remove(u)
|
|
||||||
assert access.get_queryset().count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_org_admin_create_sys_auditor(org_admin):
|
def test_org_admin_create_sys_auditor(org_admin):
|
||||||
access = UserAccess(org_admin)
|
access = UserAccess(org_admin)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import tempfile
|
|||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from awx.main.tasks.jobs import RunJob
|
from awx.main.tasks.jobs import RunJob
|
||||||
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files, handle_work_error
|
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files
|
||||||
from awx.main.models import Instance, Job, InventoryUpdate, ProjectUpdate
|
from awx.main.models import Instance, Job
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -73,17 +73,3 @@ def test_does_not_run_reaped_job(mocker, mock_me):
|
|||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == 'failed'
|
assert job.status == 'failed'
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_handle_work_error_nested(project, inventory_source):
|
|
||||||
pu = ProjectUpdate.objects.create(status='failed', project=project, celery_task_id='1234')
|
|
||||||
iu = InventoryUpdate.objects.create(status='pending', inventory_source=inventory_source, source='scm')
|
|
||||||
job = Job.objects.create(status='pending')
|
|
||||||
iu.dependent_jobs.add(pu)
|
|
||||||
job.dependent_jobs.add(pu, iu)
|
|
||||||
handle_work_error({'type': 'project_update', 'id': pu.id})
|
|
||||||
iu.refresh_from_db()
|
|
||||||
job.refresh_from_db()
|
|
||||||
assert iu.job_explanation == f'Previous Task Failed: {{"job_type": "project_update", "job_name": "", "job_id": "{pu.id}"}}'
|
|
||||||
assert job.job_explanation == f'Previous Task Failed: {{"job_type": "inventory_update", "job_name": "", "job_id": "{iu.id}"}}'
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
|
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -61,7 +61,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
||||||
'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -75,7 +75,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
||||||
'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -89,7 +89,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -103,7 +103,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -117,7 +117,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -131,7 +131,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -145,7 +145,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -159,7 +159,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -173,7 +173,7 @@ data_loggly = {
|
|||||||
'\n'.join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxdiskspace="1g" queue.type="LinkedList" queue.saveOnShutdown="on" errorfile="/var/log/tower/rsyslog.err" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
|
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" queue.spoolDirectory="/var/lib/awx" queue.filename="awx-external-logger-action-queue" queue.maxDiskSpace="1g" queue.maxFileSize="100m" queue.type="LinkedList" queue.saveOnShutdown="on" queue.syncqueuefiles="on" queue.checkpointInterval="1000" queue.size="131072" queue.highwaterMark="98304" queue.discardMark="117964" queue.discardSeverity="5" errorfile="/var/log/tower/rsyslog.err" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,8 +1,43 @@
|
|||||||
import signal
|
import signal
|
||||||
|
import functools
|
||||||
|
|
||||||
from awx.main.tasks.signals import signal_state, signal_callback, with_signal_handling
|
from awx.main.tasks.signals import signal_state, signal_callback, with_signal_handling
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sigint():
|
||||||
|
pytest_sigint.called_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sigterm():
|
||||||
|
pytest_sigterm.called_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
def tmp_signals_for_test(func):
|
||||||
|
"""
|
||||||
|
When we run our internal signal handlers, it will call the original signal
|
||||||
|
handlers when its own work is finished.
|
||||||
|
This would crash the test runners normally, because those methods will
|
||||||
|
shut down the process.
|
||||||
|
So this is a decorator to safely replace existing signal handlers
|
||||||
|
with new signal handlers that do nothing so that tests do not crash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper():
|
||||||
|
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
|
original_sigint = signal.getsignal(signal.SIGINT)
|
||||||
|
signal.signal(signal.SIGTERM, pytest_sigterm)
|
||||||
|
signal.signal(signal.SIGINT, pytest_sigint)
|
||||||
|
pytest_sigterm.called_count = 0
|
||||||
|
pytest_sigint.called_count = 0
|
||||||
|
func()
|
||||||
|
signal.signal(signal.SIGTERM, original_sigterm)
|
||||||
|
signal.signal(signal.SIGINT, original_sigint)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@tmp_signals_for_test
|
||||||
def test_outer_inner_signal_handling():
|
def test_outer_inner_signal_handling():
|
||||||
"""
|
"""
|
||||||
Even if the flag is set in the outer context, its value should persist in the inner context
|
Even if the flag is set in the outer context, its value should persist in the inner context
|
||||||
@@ -15,17 +50,22 @@ def test_outer_inner_signal_handling():
|
|||||||
@with_signal_handling
|
@with_signal_handling
|
||||||
def f1():
|
def f1():
|
||||||
assert signal_callback() is False
|
assert signal_callback() is False
|
||||||
signal_state.set_flag()
|
signal_state.set_sigterm_flag()
|
||||||
assert signal_callback()
|
assert signal_callback()
|
||||||
f2()
|
f2()
|
||||||
|
|
||||||
original_sigterm = signal.getsignal(signal.SIGTERM)
|
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
assert signal_callback() is False
|
assert signal_callback() is False
|
||||||
|
assert pytest_sigterm.called_count == 0
|
||||||
|
assert pytest_sigint.called_count == 0
|
||||||
f1()
|
f1()
|
||||||
assert signal_callback() is False
|
assert signal_callback() is False
|
||||||
assert signal.getsignal(signal.SIGTERM) is original_sigterm
|
assert signal.getsignal(signal.SIGTERM) is original_sigterm
|
||||||
|
assert pytest_sigterm.called_count == 1
|
||||||
|
assert pytest_sigint.called_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@tmp_signals_for_test
|
||||||
def test_inner_outer_signal_handling():
|
def test_inner_outer_signal_handling():
|
||||||
"""
|
"""
|
||||||
Even if the flag is set in the inner context, its value should persist in the outer context
|
Even if the flag is set in the inner context, its value should persist in the outer context
|
||||||
@@ -34,7 +74,7 @@ def test_inner_outer_signal_handling():
|
|||||||
@with_signal_handling
|
@with_signal_handling
|
||||||
def f2():
|
def f2():
|
||||||
assert signal_callback() is False
|
assert signal_callback() is False
|
||||||
signal_state.set_flag()
|
signal_state.set_sigint_flag()
|
||||||
assert signal_callback()
|
assert signal_callback()
|
||||||
|
|
||||||
@with_signal_handling
|
@with_signal_handling
|
||||||
@@ -45,6 +85,10 @@ def test_inner_outer_signal_handling():
|
|||||||
|
|
||||||
original_sigterm = signal.getsignal(signal.SIGTERM)
|
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
assert signal_callback() is False
|
assert signal_callback() is False
|
||||||
|
assert pytest_sigterm.called_count == 0
|
||||||
|
assert pytest_sigint.called_count == 0
|
||||||
f1()
|
f1()
|
||||||
assert signal_callback() is False
|
assert signal_callback() is False
|
||||||
assert signal.getsignal(signal.SIGTERM) is original_sigterm
|
assert signal.getsignal(signal.SIGTERM) is original_sigterm
|
||||||
|
assert pytest_sigterm.called_count == 0
|
||||||
|
assert pytest_sigint.called_count == 1
|
||||||
|
|||||||
@@ -143,13 +143,6 @@ def test_send_notifications_job_id(mocker):
|
|||||||
assert UnifiedJob.objects.get.called_with(id=1)
|
assert UnifiedJob.objects.get.called_with(id=1)
|
||||||
|
|
||||||
|
|
||||||
def test_work_success_callback_missing_job():
|
|
||||||
task_data = {'type': 'project_update', 'id': 9999}
|
|
||||||
with mock.patch('django.db.models.query.QuerySet.get') as get_mock:
|
|
||||||
get_mock.side_effect = ProjectUpdate.DoesNotExist()
|
|
||||||
assert system.handle_work_success(task_data) is None
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch('awx.main.models.UnifiedJob.objects.get')
|
@mock.patch('awx.main.models.UnifiedJob.objects.get')
|
||||||
@mock.patch('awx.main.models.Notification.objects.filter')
|
@mock.patch('awx.main.models.Notification.objects.filter')
|
||||||
def test_send_notifications_list(mock_notifications_filter, mock_job_get, mocker):
|
def test_send_notifications_list(mock_notifications_filter, mock_job_get, mocker):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
|||||||
from django.utils.dateparse import parse_datetime
|
from django.utils.dateparse import parse_datetime
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.db import connection, transaction, ProgrammingError
|
from django.db import connection, transaction, ProgrammingError, IntegrityError
|
||||||
from django.db.models.fields.related import ForeignObjectRel, ManyToManyField
|
from django.db.models.fields.related import ForeignObjectRel, ManyToManyField
|
||||||
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor, ManyToManyDescriptor
|
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor, ManyToManyDescriptor
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
@@ -1164,13 +1164,24 @@ def create_partition(tblname, start=None):
|
|||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(f"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '{tblname}_{partition_label}');")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is not None:
|
||||||
|
for val in row: # should only have 1
|
||||||
|
if val is True:
|
||||||
|
logger.debug(f'Event partition table {tblname}_{partition_label} already exists')
|
||||||
|
return
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
f'CREATE TABLE IF NOT EXISTS {tblname}_{partition_label} '
|
f'CREATE TABLE {tblname}_{partition_label} (LIKE {tblname} INCLUDING DEFAULTS INCLUDING CONSTRAINTS); '
|
||||||
f'PARTITION OF {tblname} '
|
f'ALTER TABLE {tblname} ATTACH PARTITION {tblname}_{partition_label} '
|
||||||
f'FOR VALUES FROM (\'{start_timestamp}\') to (\'{end_timestamp}\');'
|
f'FOR VALUES FROM (\'{start_timestamp}\') TO (\'{end_timestamp}\');'
|
||||||
)
|
)
|
||||||
except ProgrammingError as e:
|
except (ProgrammingError, IntegrityError) as e:
|
||||||
logger.debug(f'Caught known error due to existing partition: {e}')
|
if 'already exists' in str(e):
|
||||||
|
logger.info(f'Caught known error due to partition creation race: {e}')
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def cleanup_new_process(func):
|
def cleanup_new_process(func):
|
||||||
|
|||||||
@@ -17,11 +17,26 @@ def construct_rsyslog_conf_template(settings=settings):
|
|||||||
port = getattr(settings, 'LOG_AGGREGATOR_PORT', '')
|
port = getattr(settings, 'LOG_AGGREGATOR_PORT', '')
|
||||||
protocol = getattr(settings, 'LOG_AGGREGATOR_PROTOCOL', '')
|
protocol = getattr(settings, 'LOG_AGGREGATOR_PROTOCOL', '')
|
||||||
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
|
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
|
||||||
max_disk_space_main_queue = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1)
|
action_queue_size = getattr(settings, 'LOG_AGGREGATOR_ACTION_QUEUE_SIZE', 131072)
|
||||||
max_disk_space_action_queue = getattr(settings, 'LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB', 1)
|
max_disk_space_action_queue = getattr(settings, 'LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB', 1)
|
||||||
spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/')
|
spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/')
|
||||||
error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '')
|
error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '')
|
||||||
|
|
||||||
|
queue_options = [
|
||||||
|
f'queue.spoolDirectory="{spool_directory}"',
|
||||||
|
'queue.filename="awx-external-logger-action-queue"',
|
||||||
|
f'queue.maxDiskSpace="{max_disk_space_action_queue}g"', # overall disk space for all queue files
|
||||||
|
'queue.maxFileSize="100m"', # individual file size
|
||||||
|
'queue.type="LinkedList"',
|
||||||
|
'queue.saveOnShutdown="on"',
|
||||||
|
'queue.syncqueuefiles="on"', # (f)sync when checkpoint occurs
|
||||||
|
'queue.checkpointInterval="1000"', # Update disk queue every 1000 messages
|
||||||
|
f'queue.size="{action_queue_size}"', # max number of messages in queue
|
||||||
|
f'queue.highwaterMark="{int(action_queue_size * 0.75)}"', # 75% of queue.size
|
||||||
|
f'queue.discardMark="{int(action_queue_size * 0.9)}"', # 90% of queue.size
|
||||||
|
'queue.discardSeverity="5"', # Only discard notice, info, debug if we must discard anything
|
||||||
|
]
|
||||||
|
|
||||||
if not os.access(spool_directory, os.W_OK):
|
if not os.access(spool_directory, os.W_OK):
|
||||||
spool_directory = '/var/lib/awx'
|
spool_directory = '/var/lib/awx'
|
||||||
|
|
||||||
@@ -33,7 +48,6 @@ def construct_rsyslog_conf_template(settings=settings):
|
|||||||
'$WorkDirectory /var/lib/awx/rsyslog',
|
'$WorkDirectory /var/lib/awx/rsyslog',
|
||||||
f'$MaxMessageSize {max_bytes}',
|
f'$MaxMessageSize {max_bytes}',
|
||||||
'$IncludeConfig /var/lib/awx/rsyslog/conf.d/*.conf',
|
'$IncludeConfig /var/lib/awx/rsyslog/conf.d/*.conf',
|
||||||
f'main_queue(queue.spoolDirectory="{spool_directory}" queue.maxdiskspace="{max_disk_space_main_queue}g" queue.type="Disk" queue.filename="awx-external-logger-backlog")', # noqa
|
|
||||||
'module(load="imuxsock" SysSock.Use="off")',
|
'module(load="imuxsock" SysSock.Use="off")',
|
||||||
'input(type="imuxsock" Socket="' + settings.LOGGING['handlers']['external_logger']['address'] + '" unlink="on" RateLimit.Burst="0")',
|
'input(type="imuxsock" Socket="' + settings.LOGGING['handlers']['external_logger']['address'] + '" unlink="on" RateLimit.Burst="0")',
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
||||||
@@ -79,12 +93,7 @@ def construct_rsyslog_conf_template(settings=settings):
|
|||||||
'action.resumeRetryCount="-1"',
|
'action.resumeRetryCount="-1"',
|
||||||
'template="awx"',
|
'template="awx"',
|
||||||
f'action.resumeInterval="{timeout}"',
|
f'action.resumeInterval="{timeout}"',
|
||||||
f'queue.spoolDirectory="{spool_directory}"',
|
] + queue_options
|
||||||
'queue.filename="awx-external-logger-action-queue"',
|
|
||||||
f'queue.maxdiskspace="{max_disk_space_action_queue}g"',
|
|
||||||
'queue.type="LinkedList"',
|
|
||||||
'queue.saveOnShutdown="on"',
|
|
||||||
]
|
|
||||||
if error_log_file:
|
if error_log_file:
|
||||||
params.append(f'errorfile="{error_log_file}"')
|
params.append(f'errorfile="{error_log_file}"')
|
||||||
if parsed.path:
|
if parsed.path:
|
||||||
@@ -112,9 +121,18 @@ def construct_rsyslog_conf_template(settings=settings):
|
|||||||
params = ' '.join(params)
|
params = ' '.join(params)
|
||||||
parts.extend(['module(load="omhttp")', f'action({params})'])
|
parts.extend(['module(load="omhttp")', f'action({params})'])
|
||||||
elif protocol and host and port:
|
elif protocol and host and port:
|
||||||
parts.append(
|
params = [
|
||||||
f'action(type="omfwd" target="{host}" port="{port}" protocol="{protocol}" action.resumeRetryCount="-1" action.resumeInterval="{timeout}" template="awx")' # noqa
|
'type="omfwd"',
|
||||||
)
|
f'target="{host}"',
|
||||||
|
f'port="{port}"',
|
||||||
|
f'protocol="{protocol}"',
|
||||||
|
'action.resumeRetryCount="-1"',
|
||||||
|
f'action.resumeInterval="{timeout}"',
|
||||||
|
'template="awx"',
|
||||||
|
] + queue_options
|
||||||
|
params = ' '.join(params)
|
||||||
|
parts.append(f'action({params})')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
parts.append('action(type="omfile" file="/dev/null")') # rsyslog needs *at least* one valid action to start
|
parts.append('action(type="omfile" file="/dev/null")') # rsyslog needs *at least* one valid action to start
|
||||||
tmpl = '\n'.join(parts)
|
tmpl = '\n'.join(parts)
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ class Licenser(object):
|
|||||||
license['support_level'] = attr.get('value')
|
license['support_level'] = attr.get('value')
|
||||||
elif attr.get('name') == 'usage':
|
elif attr.get('name') == 'usage':
|
||||||
license['usage'] = attr.get('value')
|
license['usage'] = attr.get('value')
|
||||||
|
elif attr.get('name') == 'ph_product_name' and attr.get('value') == 'RHEL Developer':
|
||||||
|
license['license_type'] = 'developer'
|
||||||
|
|
||||||
if not license:
|
if not license:
|
||||||
logger.error("No valid subscriptions found in manifest")
|
logger.error("No valid subscriptions found in manifest")
|
||||||
@@ -322,7 +324,9 @@ class Licenser(object):
|
|||||||
def generate_license_options_from_entitlements(self, json):
|
def generate_license_options_from_entitlements(self, json):
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
|
||||||
ValidSub = collections.namedtuple('ValidSub', 'sku name support_level end_date trial quantity pool_id satellite subscription_id account_number usage')
|
ValidSub = collections.namedtuple(
|
||||||
|
'ValidSub', 'sku name support_level end_date trial developer_license quantity pool_id satellite subscription_id account_number usage'
|
||||||
|
)
|
||||||
valid_subs = []
|
valid_subs = []
|
||||||
for sub in json:
|
for sub in json:
|
||||||
satellite = sub.get('satellite')
|
satellite = sub.get('satellite')
|
||||||
@@ -350,6 +354,7 @@ class Licenser(object):
|
|||||||
|
|
||||||
sku = sub['productId']
|
sku = sub['productId']
|
||||||
trial = sku.startswith('S') # i.e.,, SER/SVC
|
trial = sku.startswith('S') # i.e.,, SER/SVC
|
||||||
|
developer_license = False
|
||||||
support_level = ''
|
support_level = ''
|
||||||
usage = ''
|
usage = ''
|
||||||
pool_id = sub['id']
|
pool_id = sub['id']
|
||||||
@@ -364,9 +369,24 @@ class Licenser(object):
|
|||||||
support_level = attr.get('value')
|
support_level = attr.get('value')
|
||||||
elif attr.get('name') == 'usage':
|
elif attr.get('name') == 'usage':
|
||||||
usage = attr.get('value')
|
usage = attr.get('value')
|
||||||
|
elif attr.get('name') == 'ph_product_name' and attr.get('value') == 'RHEL Developer':
|
||||||
|
developer_license = True
|
||||||
|
|
||||||
valid_subs.append(
|
valid_subs.append(
|
||||||
ValidSub(sku, sub['productName'], support_level, end_date, trial, quantity, pool_id, satellite, subscription_id, account_number, usage)
|
ValidSub(
|
||||||
|
sku,
|
||||||
|
sub['productName'],
|
||||||
|
support_level,
|
||||||
|
end_date,
|
||||||
|
trial,
|
||||||
|
developer_license,
|
||||||
|
quantity,
|
||||||
|
pool_id,
|
||||||
|
satellite,
|
||||||
|
subscription_id,
|
||||||
|
account_number,
|
||||||
|
usage,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if valid_subs:
|
if valid_subs:
|
||||||
@@ -381,6 +401,8 @@ class Licenser(object):
|
|||||||
if sub.trial:
|
if sub.trial:
|
||||||
license._attrs['trial'] = True
|
license._attrs['trial'] = True
|
||||||
license._attrs['license_type'] = 'trial'
|
license._attrs['license_type'] = 'trial'
|
||||||
|
if sub.developer_license:
|
||||||
|
license._attrs['license_type'] = 'developer'
|
||||||
license._attrs['instance_count'] = min(MAX_INSTANCES, license._attrs['instance_count'])
|
license._attrs['instance_count'] = min(MAX_INSTANCES, license._attrs['instance_count'])
|
||||||
human_instances = license._attrs['instance_count']
|
human_instances = license._attrs['instance_count']
|
||||||
if human_instances == MAX_INSTANCES:
|
if human_instances == MAX_INSTANCES:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import client_exceptions
|
from aiohttp import client_exceptions
|
||||||
import aioredis
|
import aioredis
|
||||||
@@ -71,7 +73,16 @@ class WebsocketRelayConnection:
|
|||||||
if not self.channel_layer:
|
if not self.channel_layer:
|
||||||
self.channel_layer = get_channel_layer()
|
self.channel_layer = get_channel_layer()
|
||||||
|
|
||||||
uri = f"{self.protocol}://{self.remote_host}:{self.remote_port}/websocket/relay/"
|
# figure out if what we have is an ipaddress, IPv6 Addresses must have brackets added for uri
|
||||||
|
uri_hostname = self.remote_host
|
||||||
|
try:
|
||||||
|
# Throws ValueError if self.remote_host is a hostname like example.com, not an IPv4 or IPv6 ip address
|
||||||
|
if isinstance(ipaddress.ip_address(uri_hostname), ipaddress.IPv6Address):
|
||||||
|
uri_hostname = f"[{uri_hostname}]"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
uri = f"{self.protocol}://{uri_hostname}:{self.remote_port}/websocket/relay/"
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
|
||||||
secret_val = WebsocketSecretAuthHelper.construct_secret()
|
secret_val = WebsocketSecretAuthHelper.construct_secret()
|
||||||
@@ -216,7 +227,8 @@ class WebSocketRelayManager(object):
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
if not notif.payload or notif.channel != "web_ws_heartbeat":
|
if not notif.payload or notif.channel != "web_ws_heartbeat":
|
||||||
return
|
logger.warning(f"Unexpected channel or missing payload. {notif.channel}, {notif.payload}")
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(notif.payload)
|
payload = json.loads(notif.payload)
|
||||||
@@ -224,13 +236,15 @@ class WebSocketRelayManager(object):
|
|||||||
logmsg = "Failed to decode message from pg_notify channel `web_ws_heartbeat`"
|
logmsg = "Failed to decode message from pg_notify channel `web_ws_heartbeat`"
|
||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
logmsg = "{} {}".format(logmsg, payload)
|
logmsg = "{} {}".format(logmsg, payload)
|
||||||
logger.warning(logmsg)
|
logger.warning(logmsg)
|
||||||
return
|
continue
|
||||||
|
|
||||||
# Skip if the message comes from the same host we are running on
|
# Skip if the message comes from the same host we are running on
|
||||||
# In this case, we'll be sharing a redis, no need to relay.
|
# In this case, we'll be sharing a redis, no need to relay.
|
||||||
if payload.get("hostname") == self.local_hostname:
|
if payload.get("hostname") == self.local_hostname:
|
||||||
return
|
hostname = payload.get("hostname")
|
||||||
|
logger.debug("Received a heartbeat request for {hostname}. Skipping as we use redis for local host.")
|
||||||
|
continue
|
||||||
|
|
||||||
action = payload.get("action")
|
action = payload.get("action")
|
||||||
|
|
||||||
@@ -239,7 +253,7 @@ class WebSocketRelayManager(object):
|
|||||||
ip = payload.get("ip") or hostname # try back to hostname if ip isn't supplied
|
ip = payload.get("ip") or hostname # try back to hostname if ip isn't supplied
|
||||||
if ip is None:
|
if ip is None:
|
||||||
logger.warning(f"Received invalid {action} ws_heartbeat, missing hostname and ip: {payload}")
|
logger.warning(f"Received invalid {action} ws_heartbeat, missing hostname and ip: {payload}")
|
||||||
return
|
continue
|
||||||
logger.debug(f"Web host {hostname} ({ip}) {action} heartbeat received.")
|
logger.debug(f"Web host {hostname} ({ip}) {action} heartbeat received.")
|
||||||
|
|
||||||
if action == "online":
|
if action == "online":
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ INSTALLED_APPS = [
|
|||||||
'awx.ui',
|
'awx.ui',
|
||||||
'awx.sso',
|
'awx.sso',
|
||||||
'solo',
|
'solo',
|
||||||
|
'ansible_base',
|
||||||
]
|
]
|
||||||
|
|
||||||
INTERNAL_IPS = ('127.0.0.1',)
|
INTERNAL_IPS = ('127.0.0.1',)
|
||||||
@@ -796,7 +797,7 @@ LOG_AGGREGATOR_ENABLED = False
|
|||||||
LOG_AGGREGATOR_TCP_TIMEOUT = 5
|
LOG_AGGREGATOR_TCP_TIMEOUT = 5
|
||||||
LOG_AGGREGATOR_VERIFY_CERT = True
|
LOG_AGGREGATOR_VERIFY_CERT = True
|
||||||
LOG_AGGREGATOR_LEVEL = 'INFO'
|
LOG_AGGREGATOR_LEVEL = 'INFO'
|
||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1 # Main queue
|
LOG_AGGREGATOR_ACTION_QUEUE_SIZE = 131072
|
||||||
LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB = 1 # Action queue
|
LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB = 1 # Action queue
|
||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
|
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
|
||||||
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False
|
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import Roles from './models/Roles';
|
|||||||
import Root from './models/Root';
|
import Root from './models/Root';
|
||||||
import Schedules from './models/Schedules';
|
import Schedules from './models/Schedules';
|
||||||
import Settings from './models/Settings';
|
import Settings from './models/Settings';
|
||||||
|
import SubscriptionUsage from './models/SubscriptionUsage';
|
||||||
import SystemJobs from './models/SystemJobs';
|
import SystemJobs from './models/SystemJobs';
|
||||||
import SystemJobTemplates from './models/SystemJobTemplates';
|
import SystemJobTemplates from './models/SystemJobTemplates';
|
||||||
import Teams from './models/Teams';
|
import Teams from './models/Teams';
|
||||||
@@ -82,6 +83,7 @@ const RolesAPI = new Roles();
|
|||||||
const RootAPI = new Root();
|
const RootAPI = new Root();
|
||||||
const SchedulesAPI = new Schedules();
|
const SchedulesAPI = new Schedules();
|
||||||
const SettingsAPI = new Settings();
|
const SettingsAPI = new Settings();
|
||||||
|
const SubscriptionUsageAPI = new SubscriptionUsage();
|
||||||
const SystemJobsAPI = new SystemJobs();
|
const SystemJobsAPI = new SystemJobs();
|
||||||
const SystemJobTemplatesAPI = new SystemJobTemplates();
|
const SystemJobTemplatesAPI = new SystemJobTemplates();
|
||||||
const TeamsAPI = new Teams();
|
const TeamsAPI = new Teams();
|
||||||
@@ -132,6 +134,7 @@ export {
|
|||||||
RootAPI,
|
RootAPI,
|
||||||
SchedulesAPI,
|
SchedulesAPI,
|
||||||
SettingsAPI,
|
SettingsAPI,
|
||||||
|
SubscriptionUsageAPI,
|
||||||
SystemJobsAPI,
|
SystemJobsAPI,
|
||||||
SystemJobTemplatesAPI,
|
SystemJobTemplatesAPI,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
|
|||||||
16
awx/ui/src/api/models/SubscriptionUsage.js
Normal file
16
awx/ui/src/api/models/SubscriptionUsage.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Base from '../Base';
|
||||||
|
|
||||||
|
class SubscriptionUsage extends Base {
|
||||||
|
constructor(http) {
|
||||||
|
super(http);
|
||||||
|
this.baseUrl = 'api/v2/host_metric_summary_monthly/';
|
||||||
|
}
|
||||||
|
|
||||||
|
readSubscriptionUsageChart(dateRange) {
|
||||||
|
return this.http.get(
|
||||||
|
`${this.baseUrl}?date__gte=${dateRange}&order_by=date&page_size=100`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionUsage;
|
||||||
@@ -75,6 +75,7 @@ function SessionProvider({ children }) {
|
|||||||
const [sessionCountdown, setSessionCountdown] = useState(0);
|
const [sessionCountdown, setSessionCountdown] = useState(0);
|
||||||
const [authRedirectTo, setAuthRedirectTo] = useState('/');
|
const [authRedirectTo, setAuthRedirectTo] = useState('/');
|
||||||
const [isUserBeingLoggedOut, setIsUserBeingLoggedOut] = useState(false);
|
const [isUserBeingLoggedOut, setIsUserBeingLoggedOut] = useState(false);
|
||||||
|
const [isRedirectLinkReceived, setIsRedirectLinkReceived] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
request: fetchLoginRedirectOverride,
|
request: fetchLoginRedirectOverride,
|
||||||
@@ -99,6 +100,7 @@ function SessionProvider({ children }) {
|
|||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
setIsUserBeingLoggedOut(true);
|
setIsUserBeingLoggedOut(true);
|
||||||
|
setIsRedirectLinkReceived(false);
|
||||||
if (!isSessionExpired.current) {
|
if (!isSessionExpired.current) {
|
||||||
setAuthRedirectTo('/logout');
|
setAuthRedirectTo('/logout');
|
||||||
window.localStorage.setItem(SESSION_USER_ID, null);
|
window.localStorage.setItem(SESSION_USER_ID, null);
|
||||||
@@ -112,6 +114,18 @@ function SessionProvider({ children }) {
|
|||||||
return <Redirect to="/login" />;
|
return <Redirect to="/login" />;
|
||||||
}, [setSessionTimeout, setSessionCountdown]);
|
}, [setSessionTimeout, setSessionCountdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = history.listen((location, action) => {
|
||||||
|
if (action === 'POP') {
|
||||||
|
setIsRedirectLinkReceived(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten(); // ensure that the listener is removed when the component unmounts
|
||||||
|
};
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated(document.cookie)) {
|
if (!isAuthenticated(document.cookie)) {
|
||||||
return () => {};
|
return () => {};
|
||||||
@@ -176,6 +190,8 @@ function SessionProvider({ children }) {
|
|||||||
logout,
|
logout,
|
||||||
sessionCountdown,
|
sessionCountdown,
|
||||||
setAuthRedirectTo,
|
setAuthRedirectTo,
|
||||||
|
isRedirectLinkReceived,
|
||||||
|
setIsRedirectLinkReceived,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
authRedirectTo,
|
authRedirectTo,
|
||||||
@@ -186,6 +202,8 @@ function SessionProvider({ children }) {
|
|||||||
logout,
|
logout,
|
||||||
sessionCountdown,
|
sessionCountdown,
|
||||||
setAuthRedirectTo,
|
setAuthRedirectTo,
|
||||||
|
isRedirectLinkReceived,
|
||||||
|
setIsRedirectLinkReceived,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import Organizations from 'screens/Organization';
|
|||||||
import Projects from 'screens/Project';
|
import Projects from 'screens/Project';
|
||||||
import Schedules from 'screens/Schedule';
|
import Schedules from 'screens/Schedule';
|
||||||
import Settings from 'screens/Setting';
|
import Settings from 'screens/Setting';
|
||||||
|
import SubscriptionUsage from 'screens/SubscriptionUsage/SubscriptionUsage';
|
||||||
import Teams from 'screens/Team';
|
import Teams from 'screens/Team';
|
||||||
import Templates from 'screens/Template';
|
import Templates from 'screens/Template';
|
||||||
import TopologyView from 'screens/TopologyView';
|
import TopologyView from 'screens/TopologyView';
|
||||||
@@ -61,6 +62,11 @@ function getRouteConfig(userProfile = {}) {
|
|||||||
path: '/host_metrics',
|
path: '/host_metrics',
|
||||||
screen: HostMetrics,
|
screen: HostMetrics,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: <Trans>Subscription Usage</Trans>,
|
||||||
|
path: '/subscription_usage',
|
||||||
|
screen: SubscriptionUsage,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -189,6 +195,7 @@ function getRouteConfig(userProfile = {}) {
|
|||||||
'unique_managed_hosts'
|
'unique_managed_hosts'
|
||||||
) {
|
) {
|
||||||
deleteRoute('host_metrics');
|
deleteRoute('host_metrics');
|
||||||
|
deleteRoute('subscription_usage');
|
||||||
}
|
}
|
||||||
if (userProfile?.isSuperUser || userProfile?.isSystemAuditor)
|
if (userProfile?.isSuperUser || userProfile?.isSystemAuditor)
|
||||||
return routeConfig;
|
return routeConfig;
|
||||||
@@ -197,6 +204,7 @@ function getRouteConfig(userProfile = {}) {
|
|||||||
deleteRoute('management_jobs');
|
deleteRoute('management_jobs');
|
||||||
deleteRoute('topology_view');
|
deleteRoute('topology_view');
|
||||||
deleteRoute('instances');
|
deleteRoute('instances');
|
||||||
|
deleteRoute('subscription_usage');
|
||||||
if (userProfile?.isOrgAdmin) return routeConfig;
|
if (userProfile?.isOrgAdmin) return routeConfig;
|
||||||
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
|
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('getRouteConfig', () => {
|
|||||||
'/activity_stream',
|
'/activity_stream',
|
||||||
'/workflow_approvals',
|
'/workflow_approvals',
|
||||||
'/host_metrics',
|
'/host_metrics',
|
||||||
|
'/subscription_usage',
|
||||||
'/templates',
|
'/templates',
|
||||||
'/credentials',
|
'/credentials',
|
||||||
'/projects',
|
'/projects',
|
||||||
@@ -61,6 +62,7 @@ describe('getRouteConfig', () => {
|
|||||||
'/activity_stream',
|
'/activity_stream',
|
||||||
'/workflow_approvals',
|
'/workflow_approvals',
|
||||||
'/host_metrics',
|
'/host_metrics',
|
||||||
|
'/subscription_usage',
|
||||||
'/templates',
|
'/templates',
|
||||||
'/credentials',
|
'/credentials',
|
||||||
'/projects',
|
'/projects',
|
||||||
|
|||||||
@@ -302,9 +302,9 @@ function HostsByProcessorTypeExample() {
|
|||||||
|
|
||||||
const hostsByProcessorLimit = `intel_hosts`;
|
const hostsByProcessorLimit = `intel_hosts`;
|
||||||
const hostsByProcessorSourceVars = `plugin: constructed
|
const hostsByProcessorSourceVars = `plugin: constructed
|
||||||
strict: true
|
strict: true
|
||||||
groups:
|
groups:
|
||||||
intel_hosts: "GenuineIntel" in ansible_processor`;
|
intel_hosts: "'GenuineIntel' in ansible_processor"`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormFieldGroupExpandable
|
<FormFieldGroupExpandable
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe('<ConstructedInventoryHint />', () => {
|
|||||||
);
|
);
|
||||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||||
expect.stringContaining(
|
expect.stringContaining(
|
||||||
'intel_hosts: "GenuineIntel" in ansible_processor'
|
`intel_hosts: \"'GenuineIntel' in ansible_processor\"`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ const Login = styled(PFLogin)`
|
|||||||
|
|
||||||
function AWXLogin({ alt, isAuthenticated }) {
|
function AWXLogin({ alt, isAuthenticated }) {
|
||||||
const [userId, setUserId] = useState(null);
|
const [userId, setUserId] = useState(null);
|
||||||
const { authRedirectTo, isSessionExpired } = useSession();
|
const { authRedirectTo, isSessionExpired, isRedirectLinkReceived } =
|
||||||
|
useSession();
|
||||||
const isNewUser = useRef(true);
|
const isNewUser = useRef(true);
|
||||||
const hasVerifiedUser = useRef(false);
|
const hasVerifiedUser = useRef(false);
|
||||||
|
|
||||||
@@ -179,7 +180,8 @@ function AWXLogin({ alt, isAuthenticated }) {
|
|||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
if (userId && hasVerifiedUser.current) {
|
if (userId && hasVerifiedUser.current) {
|
||||||
const redirect = isNewUser.current ? '/home' : authRedirectTo;
|
const redirect =
|
||||||
|
isNewUser.current && !isRedirectLinkReceived ? '/home' : authRedirectTo;
|
||||||
|
|
||||||
return <Redirect to={redirect} />;
|
return <Redirect to={redirect} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ SettingsAPI.readCategory.mockResolvedValue({
|
|||||||
LOG_AGGREGATOR_TCP_TIMEOUT: 5,
|
LOG_AGGREGATOR_TCP_TIMEOUT: 5,
|
||||||
LOG_AGGREGATOR_VERIFY_CERT: true,
|
LOG_AGGREGATOR_VERIFY_CERT: true,
|
||||||
LOG_AGGREGATOR_LEVEL: 'INFO',
|
LOG_AGGREGATOR_LEVEL: 'INFO',
|
||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1,
|
LOG_AGGREGATOR_ACTION_QUEUE_SIZE: 131072,
|
||||||
LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB: 1,
|
LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB: 1,
|
||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx',
|
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx',
|
||||||
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,
|
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const mockSettings = {
|
|||||||
LOG_AGGREGATOR_TCP_TIMEOUT: 123,
|
LOG_AGGREGATOR_TCP_TIMEOUT: 123,
|
||||||
LOG_AGGREGATOR_VERIFY_CERT: true,
|
LOG_AGGREGATOR_VERIFY_CERT: true,
|
||||||
LOG_AGGREGATOR_LEVEL: 'ERROR',
|
LOG_AGGREGATOR_LEVEL: 'ERROR',
|
||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_GB: 1,
|
LOG_AGGREGATOR_ACTION_QUEUE_SIZE: 131072,
|
||||||
LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB: 1,
|
LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB: 1,
|
||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx',
|
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH: '/var/lib/awx',
|
||||||
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,
|
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,
|
||||||
|
|||||||
@@ -659,21 +659,21 @@
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"LOG_AGGREGATOR_MAX_DISK_USAGE_GB": {
|
"LOG_AGGREGATOR_ACTION_QUEUE_SIZE": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"required": false,
|
"required": false,
|
||||||
"label": "Maximum disk persistence for external log aggregation (in GB)",
|
"label": "Maximum number of messages that can be stored in the log action queue",
|
||||||
"help_text": "Amount of data to store (in gigabytes) during an outage of the external log aggregator (defaults to 1). Equivalent to the rsyslogd queue.maxdiskspace setting for main_queue. Notably, this is used for the rsyslogd main queue (for input messages).",
|
"help_text": "Defines how large the rsyslog action queue can grow in number of messages stored. This can have an impact on memory utilization. When the queue reaches 75% of this number, the queue will start writing to disk (queue.highWatermark in rsyslog). When it reaches 90%, NOTICE, INFO, and DEBUG messages will start to be discarded (queue.discardMark with queue.discardSeverity=5).",
|
||||||
"min_value": 1,
|
"min_value": 1,
|
||||||
"category": "Logging",
|
"category": "Logging",
|
||||||
"category_slug": "logging",
|
"category_slug": "logging",
|
||||||
"default": 1
|
"default": 131072
|
||||||
},
|
},
|
||||||
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": {
|
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"required": false,
|
"required": false,
|
||||||
"label": "Maximum disk persistence for rsyslogd action queuing (in GB)",
|
"label": "Maximum disk persistence for rsyslogd action queuing (in GB)",
|
||||||
"help_text": "Amount of data to store (in gigabytes) if an rsyslog action takes time to process an incoming message (defaults to 1). Equivalent to the rsyslogd queue.maxdiskspace setting on the action (e.g. omhttp). Like LOG_AGGREGATOR_MAX_DISK_USAGE_GB, it stores files in the directory specified by LOG_AGGREGATOR_MAX_DISK_USAGE_PATH.",
|
"help_text": "Amount of data to store (in gigabytes) if an rsyslog action takes time to process an incoming message (defaults to 1). Equivalent to the rsyslogd queue.maxdiskspace setting on the action (e.g. omhttp). It stores files in the directory specified by LOG_AGGREGATOR_MAX_DISK_USAGE_PATH.",
|
||||||
"min_value": 1,
|
"min_value": 1,
|
||||||
"category": "Logging",
|
"category": "Logging",
|
||||||
"category_slug": "logging",
|
"category_slug": "logging",
|
||||||
@@ -5016,10 +5016,10 @@
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"LOG_AGGREGATOR_MAX_DISK_USAGE_GB": {
|
"LOG_AGGREGATOR_ACTION_QUEUE_SIZE": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"label": "Maximum disk persistence for external log aggregation (in GB)",
|
"label": "Maximum number of messages that can be stored in the log action queue",
|
||||||
"help_text": "Amount of data to store (in gigabytes) during an outage of the external log aggregator (defaults to 1). Equivalent to the rsyslogd queue.maxdiskspace setting for main_queue. Notably, this is used for the rsyslogd main queue (for input messages).",
|
"help_text": "Defines how large the rsyslog action queue can grow in number of messages stored. This can have an impact on memory utilization. When the queue reaches 75% of this number, the queue will start writing to disk (queue.highWatermark in rsyslog). When it reaches 90%, NOTICE, INFO, and DEBUG messages will start to be discarded (queue.discardMark with queue.discardSeverity=5).",
|
||||||
"min_value": 1,
|
"min_value": 1,
|
||||||
"category": "Logging",
|
"category": "Logging",
|
||||||
"category_slug": "logging",
|
"category_slug": "logging",
|
||||||
@@ -5028,7 +5028,7 @@
|
|||||||
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": {
|
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"label": "Maximum disk persistence for rsyslogd action queuing (in GB)",
|
"label": "Maximum disk persistence for rsyslogd action queuing (in GB)",
|
||||||
"help_text": "Amount of data to store (in gigabytes) if an rsyslog action takes time to process an incoming message (defaults to 1). Equivalent to the rsyslogd queue.maxdiskspace setting on the action (e.g. omhttp). Like LOG_AGGREGATOR_MAX_DISK_USAGE_GB, it stores files in the directory specified by LOG_AGGREGATOR_MAX_DISK_USAGE_PATH.",
|
"help_text": "Amount of data to store (in gigabytes) if an rsyslog action takes time to process an incoming message (defaults to 1). Equivalent to the rsyslogd queue.maxdiskspace setting on the action (e.g. omhttp). It stores files in the directory specified by LOG_AGGREGATOR_MAX_DISK_USAGE_PATH.",
|
||||||
"min_value": 1,
|
"min_value": 1,
|
||||||
"category": "Logging",
|
"category": "Logging",
|
||||||
"category_slug": "logging",
|
"category_slug": "logging",
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
"LOG_AGGREGATOR_TCP_TIMEOUT": 5,
|
"LOG_AGGREGATOR_TCP_TIMEOUT": 5,
|
||||||
"LOG_AGGREGATOR_VERIFY_CERT": true,
|
"LOG_AGGREGATOR_VERIFY_CERT": true,
|
||||||
"LOG_AGGREGATOR_LEVEL": "INFO",
|
"LOG_AGGREGATOR_LEVEL": "INFO",
|
||||||
"LOG_AGGREGATOR_MAX_DISK_USAGE_GB": 1,
|
"LOG_AGGREGATOR_ACTION_QUEUE_SIZE": 131072,
|
||||||
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": 1,
|
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": 1,
|
||||||
"LOG_AGGREGATOR_MAX_DISK_USAGE_PATH": "/var/lib/awx",
|
"LOG_AGGREGATOR_MAX_DISK_USAGE_PATH": "/var/lib/awx",
|
||||||
"LOG_AGGREGATOR_RSYSLOGD_DEBUG": false,
|
"LOG_AGGREGATOR_RSYSLOGD_DEBUG": false,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"LOG_AGGREGATOR_TCP_TIMEOUT": 5,
|
"LOG_AGGREGATOR_TCP_TIMEOUT": 5,
|
||||||
"LOG_AGGREGATOR_VERIFY_CERT": true,
|
"LOG_AGGREGATOR_VERIFY_CERT": true,
|
||||||
"LOG_AGGREGATOR_LEVEL": "INFO",
|
"LOG_AGGREGATOR_LEVEL": "INFO",
|
||||||
"LOG_AGGREGATOR_MAX_DISK_USAGE_GB": 1,
|
"LOG_AGGREGATOR_ACTION_QUEUE_SIZE": 131072,
|
||||||
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": 1,
|
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": 1,
|
||||||
"LOG_AGGREGATOR_MAX_DISK_USAGE_PATH": "/var/lib/awx",
|
"LOG_AGGREGATOR_MAX_DISK_USAGE_PATH": "/var/lib/awx",
|
||||||
"LOG_AGGREGATOR_RSYSLOGD_DEBUG": false,
|
"LOG_AGGREGATOR_RSYSLOGD_DEBUG": false,
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import { string, number, shape, arrayOf } from 'prop-types';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { PageContextConsumer } from '@patternfly/react-core';
|
||||||
|
import UsageChartTooltip from './UsageChartTooltip';
|
||||||
|
|
||||||
|
function UsageChart({ id, data, height, pageContext }) {
|
||||||
|
const { isNavOpen } = pageContext;
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const margin = { top: 15, right: 25, bottom: 105, left: 70 };
|
||||||
|
|
||||||
|
const getWidth = () => {
|
||||||
|
let width;
|
||||||
|
// This is in an a try/catch due to an error from jest.
|
||||||
|
// Even though the d3.select returns a valid selector with
|
||||||
|
// style function, it says it is null in the test
|
||||||
|
try {
|
||||||
|
width =
|
||||||
|
parseInt(d3.select(`#${id}`).style('width'), 10) -
|
||||||
|
margin.left -
|
||||||
|
margin.right || 700;
|
||||||
|
} catch (error) {
|
||||||
|
width = 700;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear our chart container element first
|
||||||
|
d3.selectAll(`#${id} > *`).remove();
|
||||||
|
const width = getWidth();
|
||||||
|
|
||||||
|
function transition(path) {
|
||||||
|
path.transition().duration(1000).attrTween('stroke-dasharray', tweenDash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tweenDash(...params) {
|
||||||
|
const l = params[2][params[1]].getTotalLength();
|
||||||
|
const i = d3.interpolateString(`0,${l}`, `${l},${l}`);
|
||||||
|
return (val) => i(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = d3.scaleTime().rangeRound([0, width]);
|
||||||
|
const y = d3.scaleLinear().range([height, 0]);
|
||||||
|
|
||||||
|
// [consumed, capacity]
|
||||||
|
const colors = d3.scaleOrdinal(['#06C', '#C9190B']);
|
||||||
|
const svg = d3
|
||||||
|
.select(`#${id}`)
|
||||||
|
.append('svg')
|
||||||
|
.attr('width', width + margin.left + margin.right)
|
||||||
|
.attr('height', height + margin.top + margin.bottom)
|
||||||
|
.attr('z', 100)
|
||||||
|
.append('g')
|
||||||
|
.attr('id', 'chart-container')
|
||||||
|
.attr('transform', `translate(${margin.left}, ${margin.top})`);
|
||||||
|
// Tooltip
|
||||||
|
const tooltip = new UsageChartTooltip({
|
||||||
|
svg: `#${id}`,
|
||||||
|
colors,
|
||||||
|
label: t`Hosts`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseTime = d3.timeParse('%Y-%m-%d');
|
||||||
|
|
||||||
|
const formattedData = data?.reduce(
|
||||||
|
(formatted, { date, license_consumed, license_capacity }) => {
|
||||||
|
const MONTH = parseTime(date);
|
||||||
|
const CONSUMED = +license_consumed;
|
||||||
|
const CAPACITY = +license_capacity;
|
||||||
|
return formatted.concat({ MONTH, CONSUMED, CAPACITY });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scale the range of the data
|
||||||
|
const largestY = formattedData?.reduce((a_max, b) => {
|
||||||
|
const b_max = Math.max(b.CONSUMED > b.CAPACITY ? b.CONSUMED : b.CAPACITY);
|
||||||
|
return a_max > b_max ? a_max : b_max;
|
||||||
|
}, 0);
|
||||||
|
x.domain(d3.extent(formattedData, (d) => d.MONTH));
|
||||||
|
y.domain([
|
||||||
|
0,
|
||||||
|
largestY > 4 ? largestY + Math.max(largestY / 10, 1) : 5,
|
||||||
|
]).nice();
|
||||||
|
|
||||||
|
const capacityLine = d3
|
||||||
|
.line()
|
||||||
|
.curve(d3.curveMonotoneX)
|
||||||
|
.x((d) => x(d.MONTH))
|
||||||
|
.y((d) => y(d.CAPACITY));
|
||||||
|
|
||||||
|
const consumedLine = d3
|
||||||
|
.line()
|
||||||
|
.curve(d3.curveMonotoneX)
|
||||||
|
.x((d) => x(d.MONTH))
|
||||||
|
.y((d) => y(d.CONSUMED));
|
||||||
|
|
||||||
|
// Add the Y Axis
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'y-axis')
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisLeft(y)
|
||||||
|
.ticks(
|
||||||
|
largestY > 3
|
||||||
|
? Math.min(largestY + Math.max(largestY / 10, 1), 10)
|
||||||
|
: 5
|
||||||
|
)
|
||||||
|
.tickSize(-width)
|
||||||
|
.tickFormat(d3.format('d'))
|
||||||
|
)
|
||||||
|
.selectAll('line')
|
||||||
|
.attr('stroke', '#d7d7d7');
|
||||||
|
svg.selectAll('.y-axis .tick text').attr('x', -5).attr('font-size', '14');
|
||||||
|
|
||||||
|
// text label for the y axis
|
||||||
|
svg
|
||||||
|
.append('text')
|
||||||
|
.attr('transform', 'rotate(-90)')
|
||||||
|
.attr('y', 0 - margin.left)
|
||||||
|
.attr('x', 0 - height / 2)
|
||||||
|
.attr('dy', '1em')
|
||||||
|
.style('text-anchor', 'middle')
|
||||||
|
.text(t`Unique Hosts`);
|
||||||
|
|
||||||
|
// Add the X Axis
|
||||||
|
let ticks;
|
||||||
|
const maxTicks = Math.round(
|
||||||
|
formattedData.length / (formattedData.length / 2)
|
||||||
|
);
|
||||||
|
ticks = formattedData.map((d) => d.MONTH);
|
||||||
|
if (formattedData.length === 13) {
|
||||||
|
ticks = formattedData
|
||||||
|
.map((d, i) => (i % maxTicks === 0 ? d.MONTH : undefined))
|
||||||
|
.filter((item) => item);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.select('.domain').attr('stroke', '#d7d7d7');
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'x-axis')
|
||||||
|
.attr('transform', `translate(0, ${height})`)
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.tickValues(ticks)
|
||||||
|
.tickSize(-height)
|
||||||
|
.tickFormat(d3.timeFormat('%m/%y'))
|
||||||
|
)
|
||||||
|
.selectAll('line')
|
||||||
|
.attr('stroke', '#d7d7d7');
|
||||||
|
|
||||||
|
svg
|
||||||
|
.selectAll('.x-axis .tick text')
|
||||||
|
.attr('x', -25)
|
||||||
|
.attr('font-size', '14')
|
||||||
|
.attr('transform', 'rotate(-65)');
|
||||||
|
|
||||||
|
// text label for the x axis
|
||||||
|
svg
|
||||||
|
.append('text')
|
||||||
|
.attr(
|
||||||
|
'transform',
|
||||||
|
`translate(${width / 2} , ${height + margin.top + 50})`
|
||||||
|
)
|
||||||
|
.style('text-anchor', 'middle')
|
||||||
|
.text(t`Month`);
|
||||||
|
const vertical = svg
|
||||||
|
.append('path')
|
||||||
|
.attr('class', 'mouse-line')
|
||||||
|
.style('stroke', 'black')
|
||||||
|
.style('stroke-width', '3px')
|
||||||
|
.style('stroke-dasharray', '3, 3')
|
||||||
|
.style('opacity', '0');
|
||||||
|
|
||||||
|
const handleMouseOver = (event, d) => {
|
||||||
|
tooltip.handleMouseOver(event, d);
|
||||||
|
// show vertical line
|
||||||
|
vertical.transition().style('opacity', '1');
|
||||||
|
};
|
||||||
|
const handleMouseMove = function mouseMove(event) {
|
||||||
|
const [pointerX] = d3.pointer(event);
|
||||||
|
vertical.attr('d', () => `M${pointerX},${height} ${pointerX},${0}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseOut = () => {
|
||||||
|
// hide tooltip
|
||||||
|
tooltip.handleMouseOut();
|
||||||
|
// hide vertical line
|
||||||
|
vertical.transition().style('opacity', 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormat = d3.timeFormat('%m/%y');
|
||||||
|
|
||||||
|
// Add the consumed line path
|
||||||
|
svg
|
||||||
|
.append('path')
|
||||||
|
.data([formattedData])
|
||||||
|
.attr('class', 'line')
|
||||||
|
.style('fill', 'none')
|
||||||
|
.style('stroke', () => colors(1))
|
||||||
|
.attr('stroke-width', 2)
|
||||||
|
.attr('d', consumedLine)
|
||||||
|
.call(transition);
|
||||||
|
|
||||||
|
// create our consumed line circles
|
||||||
|
|
||||||
|
svg
|
||||||
|
.selectAll('dot')
|
||||||
|
.data(formattedData)
|
||||||
|
.enter()
|
||||||
|
.append('circle')
|
||||||
|
.attr('r', 3)
|
||||||
|
.style('stroke', () => colors(1))
|
||||||
|
.style('fill', () => colors(1))
|
||||||
|
.attr('cx', (d) => x(d.MONTH))
|
||||||
|
.attr('cy', (d) => y(d.CONSUMED))
|
||||||
|
.attr('id', (d) => `consumed-dot-${dateFormat(d.MONTH)}`)
|
||||||
|
.on('mouseover', (event, d) => handleMouseOver(event, d))
|
||||||
|
.on('mousemove', handleMouseMove)
|
||||||
|
.on('mouseout', handleMouseOut);
|
||||||
|
|
||||||
|
// Add the capacity line path
|
||||||
|
svg
|
||||||
|
.append('path')
|
||||||
|
.data([formattedData])
|
||||||
|
.attr('class', 'line')
|
||||||
|
.style('fill', 'none')
|
||||||
|
.style('stroke', () => colors(0))
|
||||||
|
.attr('stroke-width', 2)
|
||||||
|
.attr('d', capacityLine)
|
||||||
|
.call(transition);
|
||||||
|
|
||||||
|
// create our capacity line circles
|
||||||
|
|
||||||
|
svg
|
||||||
|
.selectAll('dot')
|
||||||
|
.data(formattedData)
|
||||||
|
.enter()
|
||||||
|
.append('circle')
|
||||||
|
.attr('r', 3)
|
||||||
|
.style('stroke', () => colors(0))
|
||||||
|
.style('fill', () => colors(0))
|
||||||
|
.attr('cx', (d) => x(d.MONTH))
|
||||||
|
.attr('cy', (d) => y(d.CAPACITY))
|
||||||
|
.attr('id', (d) => `capacity-dot-${dateFormat(d.MONTH)}`)
|
||||||
|
.on('mouseover', handleMouseOver)
|
||||||
|
.on('mousemove', handleMouseMove)
|
||||||
|
.on('mouseout', handleMouseOut);
|
||||||
|
|
||||||
|
// Create legend
|
||||||
|
const legend_keys = [t`Subscriptions consumed`, t`Subscription capacity`];
|
||||||
|
let totalWidth = width / 2 - 175;
|
||||||
|
|
||||||
|
const lineLegend = svg
|
||||||
|
.selectAll('.lineLegend')
|
||||||
|
.data(legend_keys)
|
||||||
|
.enter()
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'lineLegend')
|
||||||
|
.each(function formatLegend() {
|
||||||
|
const current = d3.select(this);
|
||||||
|
current.attr('transform', `translate(${totalWidth}, ${height + 90})`);
|
||||||
|
totalWidth += 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
lineLegend
|
||||||
|
.append('text')
|
||||||
|
.text((d) => d)
|
||||||
|
.attr('font-size', '14')
|
||||||
|
.attr('transform', 'translate(15,9)'); // align texts with boxes
|
||||||
|
|
||||||
|
lineLegend
|
||||||
|
.append('rect')
|
||||||
|
.attr('fill', (d) => colors(d))
|
||||||
|
.attr('width', 10)
|
||||||
|
.attr('height', 10);
|
||||||
|
}, [data, height, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draw();
|
||||||
|
}, [draw, isNavOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [draw]);
|
||||||
|
|
||||||
|
return <div id={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
UsageChart.propTypes = {
|
||||||
|
id: string.isRequired,
|
||||||
|
data: arrayOf(shape({})).isRequired,
|
||||||
|
height: number.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withPageContext = (Component) =>
|
||||||
|
function contextComponent(props) {
|
||||||
|
return (
|
||||||
|
<PageContextConsumer>
|
||||||
|
{(pageContext) => <Component {...props} pageContext={pageContext} />}
|
||||||
|
</PageContextConsumer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withPageContext(UsageChart);
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import * as d3 from 'd3';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
|
class UsageChartTooltip {
|
||||||
|
constructor(opts) {
|
||||||
|
this.label = opts.label;
|
||||||
|
this.svg = opts.svg;
|
||||||
|
this.colors = opts.colors;
|
||||||
|
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
this.toolTipBase = d3.select(`${this.svg} > svg`).append('g');
|
||||||
|
this.toolTipBase.attr('id', 'chart-tooltip');
|
||||||
|
this.toolTipBase.attr('overflow', 'visible');
|
||||||
|
this.toolTipBase.style('opacity', 0);
|
||||||
|
this.toolTipBase.style('pointer-events', 'none');
|
||||||
|
this.toolTipBase.attr('transform', 'translate(100, 100)');
|
||||||
|
this.boxWidth = 200;
|
||||||
|
this.textWidthThreshold = 20;
|
||||||
|
|
||||||
|
this.toolTipPoint = this.toolTipBase
|
||||||
|
.append('rect')
|
||||||
|
.attr('transform', 'translate(10, -10) rotate(45)')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', 0)
|
||||||
|
.attr('height', 20)
|
||||||
|
.attr('width', 20)
|
||||||
|
.attr('fill', '#393f44');
|
||||||
|
this.boundingBox = this.toolTipBase
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', 10)
|
||||||
|
.attr('y', -41)
|
||||||
|
.attr('rx', 2)
|
||||||
|
.attr('height', 82)
|
||||||
|
.attr('width', this.boxWidth)
|
||||||
|
.attr('fill', '#393f44');
|
||||||
|
this.circleBlue = this.toolTipBase
|
||||||
|
.append('circle')
|
||||||
|
.attr('cx', 26)
|
||||||
|
.attr('cy', 0)
|
||||||
|
.attr('r', 7)
|
||||||
|
.attr('stroke', 'white')
|
||||||
|
.attr('fill', this.colors(1));
|
||||||
|
this.circleRed = this.toolTipBase
|
||||||
|
.append('circle')
|
||||||
|
.attr('cx', 26)
|
||||||
|
.attr('cy', 26)
|
||||||
|
.attr('r', 7)
|
||||||
|
.attr('stroke', 'white')
|
||||||
|
.attr('fill', this.colors(0));
|
||||||
|
this.consumedText = this.toolTipBase
|
||||||
|
.append('text')
|
||||||
|
.attr('x', 43)
|
||||||
|
.attr('y', 4)
|
||||||
|
.attr('font-size', 12)
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.text(t`Subscriptions consumed`);
|
||||||
|
this.capacityText = this.toolTipBase
|
||||||
|
.append('text')
|
||||||
|
.attr('x', 43)
|
||||||
|
.attr('y', 28)
|
||||||
|
.attr('font-size', 12)
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.text(t`Subscription capacity`);
|
||||||
|
this.icon = this.toolTipBase
|
||||||
|
.append('text')
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.attr('stroke', 'white')
|
||||||
|
.attr('x', 24)
|
||||||
|
.attr('y', 30)
|
||||||
|
.attr('font-size', 12);
|
||||||
|
this.consumed = this.toolTipBase
|
||||||
|
.append('text')
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.attr('font-size', 12)
|
||||||
|
.attr('x', 122)
|
||||||
|
.attr('y', 4)
|
||||||
|
.attr('id', 'consumed-count')
|
||||||
|
.text('0');
|
||||||
|
this.capacity = this.toolTipBase
|
||||||
|
.append('text')
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.attr('font-size', 12)
|
||||||
|
.attr('x', 122)
|
||||||
|
.attr('y', 28)
|
||||||
|
.attr('id', 'capacity-count')
|
||||||
|
.text('0');
|
||||||
|
this.date = this.toolTipBase
|
||||||
|
.append('text')
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.attr('stroke', 'white')
|
||||||
|
.attr('x', 20)
|
||||||
|
.attr('y', -21)
|
||||||
|
.attr('font-size', 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseOver = (event, data) => {
|
||||||
|
let consumed = 0;
|
||||||
|
let capacity = 0;
|
||||||
|
const [x, y] = d3.pointer(event);
|
||||||
|
const tooltipPointerX = x + 75;
|
||||||
|
|
||||||
|
const formatTooltipDate = d3.timeFormat('%m/%y');
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolTipWidth = this.toolTipBase.node().getBoundingClientRect().width;
|
||||||
|
const chartWidth = d3
|
||||||
|
.select(`${this.svg}> svg`)
|
||||||
|
.node()
|
||||||
|
.getBoundingClientRect().width;
|
||||||
|
const overflow = 100 - (toolTipWidth / chartWidth) * 100;
|
||||||
|
const flipped = overflow < (tooltipPointerX / chartWidth) * 100;
|
||||||
|
if (data) {
|
||||||
|
consumed = data.CONSUMED || 0;
|
||||||
|
capacity = data.CAPACITY || 0;
|
||||||
|
this.date.text(formatTooltipDate(data.MONTH || null));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.capacity.text(`${capacity}`);
|
||||||
|
this.consumed.text(`${consumed}`);
|
||||||
|
this.consumedTextWidth = this.consumed.node().getComputedTextLength();
|
||||||
|
this.capacityTextWidth = this.capacity.node().getComputedTextLength();
|
||||||
|
|
||||||
|
const maxTextPerc = (this.jobsWidth / this.boxWidth) * 100;
|
||||||
|
const threshold = 40;
|
||||||
|
const overage = maxTextPerc / threshold;
|
||||||
|
let adjustedWidth;
|
||||||
|
if (maxTextPerc > threshold) {
|
||||||
|
adjustedWidth = this.boxWidth * overage;
|
||||||
|
} else {
|
||||||
|
adjustedWidth = this.boxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boundingBox.attr('width', adjustedWidth);
|
||||||
|
this.toolTipBase.attr('transform', `translate(${tooltipPointerX}, ${y})`);
|
||||||
|
if (flipped) {
|
||||||
|
this.toolTipPoint.attr('transform', 'translate(-20, -10) rotate(45)');
|
||||||
|
this.boundingBox.attr('x', -adjustedWidth - 20);
|
||||||
|
this.circleBlue.attr('cx', -adjustedWidth);
|
||||||
|
this.circleRed.attr('cx', -adjustedWidth);
|
||||||
|
this.icon.attr('x', -adjustedWidth - 2);
|
||||||
|
this.consumedText.attr('x', -adjustedWidth + 17);
|
||||||
|
this.capacityText.attr('x', -adjustedWidth + 17);
|
||||||
|
this.consumed.attr('x', -this.consumedTextWidth - 20 - 12);
|
||||||
|
this.capacity.attr('x', -this.capacityTextWidth - 20 - 12);
|
||||||
|
this.date.attr('x', -adjustedWidth - 5);
|
||||||
|
} else {
|
||||||
|
this.toolTipPoint.attr('transform', 'translate(10, -10) rotate(45)');
|
||||||
|
this.boundingBox.attr('x', 10);
|
||||||
|
this.circleBlue.attr('cx', 26);
|
||||||
|
this.circleRed.attr('cx', 26);
|
||||||
|
this.icon.attr('x', 24);
|
||||||
|
this.consumedText.attr('x', 43);
|
||||||
|
this.capacityText.attr('x', 43);
|
||||||
|
this.consumed.attr('x', adjustedWidth - this.consumedTextWidth);
|
||||||
|
this.capacity.attr('x', adjustedWidth - this.capacityTextWidth);
|
||||||
|
this.date.attr('x', 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toolTipBase.style('opacity', 1);
|
||||||
|
this.toolTipBase.interrupt();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseOut = () => {
|
||||||
|
this.toolTipBase
|
||||||
|
.transition()
|
||||||
|
.delay(15)
|
||||||
|
.style('opacity', 0)
|
||||||
|
.style('pointer-events', 'none');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsageChartTooltip;
|
||||||
53
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js
Normal file
53
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsage.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { t, Trans } from '@lingui/macro';
|
||||||
|
import { Banner, Card, PageSection } from '@patternfly/react-core';
|
||||||
|
import { InfoCircleIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
|
import { useConfig } from 'contexts/Config';
|
||||||
|
import useBrandName from 'hooks/useBrandName';
|
||||||
|
import ScreenHeader from 'components/ScreenHeader';
|
||||||
|
import SubscriptionUsageChart from './SubscriptionUsageChart';
|
||||||
|
|
||||||
|
const MainPageSection = styled(PageSection)`
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
|
& .spacer {
|
||||||
|
margin-bottom: var(--pf-global--spacer--lg);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function SubscriptionUsage() {
|
||||||
|
const config = useConfig();
|
||||||
|
const brandName = useBrandName();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{config?.ui_next && (
|
||||||
|
<Banner variant="info">
|
||||||
|
<Trans>
|
||||||
|
<p>
|
||||||
|
<InfoCircleIcon /> A tech preview of the new {brandName} user
|
||||||
|
interface can be found <a href="/ui_next/dashboard">here</a>.
|
||||||
|
</p>
|
||||||
|
</Trans>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
<ScreenHeader
|
||||||
|
streamType="all"
|
||||||
|
breadcrumbConfig={{ '/subscription_usage': t`Subscription Usage` }}
|
||||||
|
/>
|
||||||
|
<MainPageSection>
|
||||||
|
<div className="spacer">
|
||||||
|
<Card id="dashboard-main-container">
|
||||||
|
<SubscriptionUsageChart />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</MainPageSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SubscriptionUsage;
|
||||||
167
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js
Normal file
167
awx/ui/src/screens/SubscriptionUsage/SubscriptionUsageChart.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardActions,
|
||||||
|
CardBody,
|
||||||
|
CardTitle,
|
||||||
|
Flex,
|
||||||
|
FlexItem,
|
||||||
|
PageSection,
|
||||||
|
Select,
|
||||||
|
SelectVariant,
|
||||||
|
SelectOption,
|
||||||
|
Text,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
|
||||||
|
import useRequest from 'hooks/useRequest';
|
||||||
|
import { SubscriptionUsageAPI } from 'api';
|
||||||
|
import { useUserProfile } from 'contexts/Config';
|
||||||
|
import ContentLoading from 'components/ContentLoading';
|
||||||
|
import UsageChart from './ChartComponents/UsageChart';
|
||||||
|
|
||||||
|
const GraphCardHeader = styled(CardHeader)`
|
||||||
|
margin-bottom: var(--pf-global--spacer--lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChartCardTitle = styled(CardTitle)`
|
||||||
|
padding-right: 24px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: var(--pf-c-title--m-xl--FontWeight);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardText = styled(Text)`
|
||||||
|
padding-right: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GraphCardActions = styled(CardActions)`
|
||||||
|
margin-left: initial;
|
||||||
|
padding-left: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function SubscriptionUsageChart() {
|
||||||
|
const [isPeriodDropdownOpen, setIsPeriodDropdownOpen] = useState(false);
|
||||||
|
const [periodSelection, setPeriodSelection] = useState('year');
|
||||||
|
const userProfile = useUserProfile();
|
||||||
|
|
||||||
|
const calculateDateRange = () => {
|
||||||
|
const today = new Date();
|
||||||
|
let date = '';
|
||||||
|
switch (periodSelection) {
|
||||||
|
case 'year':
|
||||||
|
date =
|
||||||
|
today.getMonth() < 10
|
||||||
|
? `${today.getFullYear() - 1}-0${today.getMonth() + 1}-01`
|
||||||
|
: `${today.getFullYear() - 1}-${today.getMonth() + 1}-01`;
|
||||||
|
break;
|
||||||
|
case 'two_years':
|
||||||
|
date =
|
||||||
|
today.getMonth() < 10
|
||||||
|
? `${today.getFullYear() - 2}-0${today.getMonth() + 1}-01`
|
||||||
|
: `${today.getFullYear() - 2}-${today.getMonth() + 1}-01`;
|
||||||
|
break;
|
||||||
|
case 'three_years':
|
||||||
|
date =
|
||||||
|
today.getMonth() < 10
|
||||||
|
? `${today.getFullYear() - 3}-0${today.getMonth() + 1}-01`
|
||||||
|
: `${today.getFullYear() - 3}-${today.getMonth() + 1}-01`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
date =
|
||||||
|
today.getMonth() < 10
|
||||||
|
? `${today.getFullYear() - 1}-0${today.getMonth() + 1}-01`
|
||||||
|
: `${today.getFullYear() - 1}-${today.getMonth() + 1}-01`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
result: subscriptionUsageChartData,
|
||||||
|
request: fetchSubscriptionUsageChart,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const data = await SubscriptionUsageAPI.readSubscriptionUsageChart(
|
||||||
|
calculateDateRange()
|
||||||
|
);
|
||||||
|
return data.data.results;
|
||||||
|
}, [periodSelection]),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSubscriptionUsageChart();
|
||||||
|
}, [fetchSubscriptionUsageChart, periodSelection]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageSection>
|
||||||
|
<Card>
|
||||||
|
<ContentLoading />
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Flex style={{ justifyContent: 'space-between' }}>
|
||||||
|
<FlexItem>
|
||||||
|
<ChartCardTitle>{t`Subscription Compliance`}</ChartCardTitle>
|
||||||
|
</FlexItem>
|
||||||
|
<FlexItem>
|
||||||
|
<CardText component="small">
|
||||||
|
{t`Last recalculation date:`}{' '}
|
||||||
|
{userProfile.systemConfig.HOST_METRIC_SUMMARY_TASK_LAST_TS.slice(
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
)}
|
||||||
|
</CardText>
|
||||||
|
</FlexItem>
|
||||||
|
</Flex>
|
||||||
|
<GraphCardHeader>
|
||||||
|
<GraphCardActions>
|
||||||
|
<Select
|
||||||
|
variant={SelectVariant.single}
|
||||||
|
placeholderText={t`Select period`}
|
||||||
|
aria-label={t`Select period`}
|
||||||
|
typeAheadAriaLabel={t`Select period`}
|
||||||
|
className="periodSelect"
|
||||||
|
onToggle={setIsPeriodDropdownOpen}
|
||||||
|
onSelect={(event, selection) => {
|
||||||
|
setIsPeriodDropdownOpen(false);
|
||||||
|
setPeriodSelection(selection);
|
||||||
|
}}
|
||||||
|
selections={periodSelection}
|
||||||
|
isOpen={isPeriodDropdownOpen}
|
||||||
|
noResultsFoundText={t`No results found`}
|
||||||
|
ouiaId="subscription-usage-period-select"
|
||||||
|
>
|
||||||
|
<SelectOption key="year" value="year">
|
||||||
|
{t`Past year`}
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption key="two_years" value="two_years">
|
||||||
|
{t`Past two years`}
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption key="three_years" value="three_years">
|
||||||
|
{t`Past three years`}
|
||||||
|
</SelectOption>
|
||||||
|
</Select>
|
||||||
|
</GraphCardActions>
|
||||||
|
</GraphCardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<UsageChart
|
||||||
|
period={periodSelection}
|
||||||
|
height={600}
|
||||||
|
id="d3-usage-line-chart-root"
|
||||||
|
data={subscriptionUsageChartData}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default SubscriptionUsageChart;
|
||||||
@@ -2,16 +2,9 @@ export default function getDocsBaseUrl(config) {
|
|||||||
let version = 'latest';
|
let version = 'latest';
|
||||||
const licenseType = config?.license_info?.license_type;
|
const licenseType = config?.license_info?.license_type;
|
||||||
|
|
||||||
if (licenseType && licenseType !== 'open') {
|
if (licenseType && licenseType !== 'open' && config?.version) {
|
||||||
if (config?.version) {
|
version = parseFloat(config?.version.split('-')[0]).toFixed(1);
|
||||||
if (parseFloat(config?.version.split('-')[0]) >= 4.3) {
|
|
||||||
version = parseFloat(config?.version.split('-')[0]);
|
|
||||||
} else {
|
|
||||||
version = config?.version.split('-')[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
version = 'latest';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `https://docs.ansible.com/automation-controller/${version}`;
|
return `https://docs.ansible.com/automation-controller/${version}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ describe('getDocsBaseUrl', () => {
|
|||||||
license_info: {
|
license_info: {
|
||||||
license_type: 'open',
|
license_type: 'open',
|
||||||
},
|
},
|
||||||
version: '18.0.0',
|
version: '18.4.4',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
@@ -19,11 +19,11 @@ describe('getDocsBaseUrl', () => {
|
|||||||
license_info: {
|
license_info: {
|
||||||
license_type: 'enterprise',
|
license_type: 'enterprise',
|
||||||
},
|
},
|
||||||
version: '4.0.0',
|
version: '18.4.4',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
'https://docs.ansible.com/automation-controller/4.0.0'
|
'https://docs.ansible.com/automation-controller/18.4'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,17 +32,17 @@ describe('getDocsBaseUrl', () => {
|
|||||||
license_info: {
|
license_info: {
|
||||||
license_type: 'enterprise',
|
license_type: 'enterprise',
|
||||||
},
|
},
|
||||||
version: '4.0.0-beta',
|
version: '7.0.0-beta',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
'https://docs.ansible.com/automation-controller/4.0.0'
|
'https://docs.ansible.com/automation-controller/7.0'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return latest version if license info missing', () => {
|
it('should return latest version if license info missing', () => {
|
||||||
const result = getDocsBaseUrl({
|
const result = getDocsBaseUrl({
|
||||||
version: '18.0.0',
|
version: '18.4.4',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
|
|||||||
@@ -980,6 +980,15 @@ class ControllerAPIModule(ControllerModule):
|
|||||||
def create_or_update_if_needed(
|
def create_or_update_if_needed(
|
||||||
self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None
|
self, existing_item, new_item, endpoint=None, item_type='unknown', on_create=None, on_update=None, auto_exit=True, associations=None
|
||||||
):
|
):
|
||||||
|
# Remove boolean values of certain specific types
|
||||||
|
# this is needed so that boolean fields will not get a false value when not provided
|
||||||
|
for key in list(new_item.keys()):
|
||||||
|
if key in self.argument_spec:
|
||||||
|
param_spec = self.argument_spec[key]
|
||||||
|
if 'type' in param_spec and param_spec['type'] == 'bool':
|
||||||
|
if new_item[key] is None:
|
||||||
|
new_item.pop(key)
|
||||||
|
|
||||||
if existing_item:
|
if existing_item:
|
||||||
return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations)
|
return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ status:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
from ..module_utils.controller_api import ControllerAPIModule
|
from ..module_utils.controller_api import ControllerAPIModule
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -161,7 +162,11 @@ def main():
|
|||||||
}
|
}
|
||||||
for arg in ['job_type', 'limit', 'forks', 'verbosity', 'extra_vars', 'become_enabled', 'diff_mode']:
|
for arg in ['job_type', 'limit', 'forks', 'verbosity', 'extra_vars', 'become_enabled', 'diff_mode']:
|
||||||
if module.params.get(arg):
|
if module.params.get(arg):
|
||||||
post_data[arg] = module.params.get(arg)
|
# extra_var can receive a dict or a string, if a dict covert it to a string
|
||||||
|
if arg == 'extra_vars' and type(module.params.get(arg)) is not str:
|
||||||
|
post_data[arg] = json.dumps(module.params.get(arg))
|
||||||
|
else:
|
||||||
|
post_data[arg] = module.params.get(arg)
|
||||||
|
|
||||||
# Attempt to look up the related items the user specified (these will fail the module if not found)
|
# Attempt to look up the related items the user specified (these will fail the module if not found)
|
||||||
post_data['inventory'] = module.resolve_name_to_id('inventories', inventory)
|
post_data['inventory'] = module.resolve_name_to_id('inventories', inventory)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ options:
|
|||||||
Insights, Machine, Microsoft Azure Key Vault, Microsoft Azure Resource Manager, Network, OpenShift or Kubernetes API
|
Insights, Machine, Microsoft Azure Key Vault, Microsoft Azure Resource Manager, Network, OpenShift or Kubernetes API
|
||||||
Bearer Token, OpenStack, Red Hat Ansible Automation Platform, Red Hat Satellite 6, Red Hat Virtualization, Source Control,
|
Bearer Token, OpenStack, Red Hat Ansible Automation Platform, Red Hat Satellite 6, Red Hat Virtualization, Source Control,
|
||||||
Thycotic DevOps Secrets Vault, Thycotic Secret Server, Vault, VMware vCenter, or a custom credential type
|
Thycotic DevOps Secrets Vault, Thycotic Secret Server, Vault, VMware vCenter, or a custom credential type
|
||||||
|
required: True
|
||||||
type: str
|
type: str
|
||||||
inputs:
|
inputs:
|
||||||
description:
|
description:
|
||||||
@@ -214,7 +215,7 @@ def main():
|
|||||||
copy_from=dict(),
|
copy_from=dict(),
|
||||||
description=dict(),
|
description=dict(),
|
||||||
organization=dict(),
|
organization=dict(),
|
||||||
credential_type=dict(),
|
credential_type=dict(required=True),
|
||||||
inputs=dict(type='dict', no_log=True),
|
inputs=dict(type='dict', no_log=True),
|
||||||
update_secrets=dict(type='bool', default=True, no_log=False),
|
update_secrets=dict(type='bool', default=True, no_log=False),
|
||||||
user=dict(),
|
user=dict(),
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ options:
|
|||||||
image:
|
image:
|
||||||
description:
|
description:
|
||||||
- The fully qualified url of the container image.
|
- The fully qualified url of the container image.
|
||||||
required: True
|
|
||||||
type: str
|
type: str
|
||||||
description:
|
description:
|
||||||
description:
|
description:
|
||||||
@@ -79,7 +78,7 @@ def main():
|
|||||||
argument_spec = dict(
|
argument_spec = dict(
|
||||||
name=dict(required=True),
|
name=dict(required=True),
|
||||||
new_name=dict(),
|
new_name=dict(),
|
||||||
image=dict(required=True),
|
image=dict(),
|
||||||
description=dict(),
|
description=dict(),
|
||||||
organization=dict(),
|
organization=dict(),
|
||||||
credential=dict(),
|
credential=dict(),
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ EXAMPLES = '''
|
|||||||
- name: Export a job template named "My Template" and all Credentials
|
- name: Export a job template named "My Template" and all Credentials
|
||||||
export:
|
export:
|
||||||
job_templates: "My Template"
|
job_templates: "My Template"
|
||||||
credential: 'all'
|
credentials: 'all'
|
||||||
|
|
||||||
- name: Export a list of inventories
|
- name: Export a list of inventories
|
||||||
export:
|
export:
|
||||||
|
|||||||
@@ -273,6 +273,26 @@ def main():
|
|||||||
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
|
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
|
||||||
module.delete_if_needed(existing_item)
|
module.delete_if_needed(existing_item)
|
||||||
|
|
||||||
|
# We need to clear out the name from the search fields so we can use name_or_id in the following searches
|
||||||
|
if 'name' in search_fields:
|
||||||
|
del search_fields['name']
|
||||||
|
|
||||||
|
# Create the data that gets sent for create and update
|
||||||
|
new_fields = {}
|
||||||
|
if execution_environment is not None:
|
||||||
|
if execution_environment == '':
|
||||||
|
new_fields['execution_environment'] = ''
|
||||||
|
else:
|
||||||
|
ee = module.get_one('execution_environments', name_or_id=execution_environment, **{'data': search_fields})
|
||||||
|
if ee is None:
|
||||||
|
ee2 = module.get_one('execution_environments', name_or_id=execution_environment)
|
||||||
|
if ee2 is None or ee2['organization'] is not None:
|
||||||
|
module.fail_json(msg='could not find execution_environment entry with name {0}'.format(execution_environment))
|
||||||
|
else:
|
||||||
|
new_fields['execution_environment'] = ee2['id']
|
||||||
|
else:
|
||||||
|
new_fields['execution_environment'] = ee['id']
|
||||||
|
|
||||||
association_fields = {}
|
association_fields = {}
|
||||||
|
|
||||||
if credentials is not None:
|
if credentials is not None:
|
||||||
@@ -280,9 +300,9 @@ def main():
|
|||||||
for item in credentials:
|
for item in credentials:
|
||||||
association_fields['credentials'].append(module.resolve_name_to_id('credentials', item))
|
association_fields['credentials'].append(module.resolve_name_to_id('credentials', item))
|
||||||
|
|
||||||
# We need to clear out the name from the search fields so we can use name_or_id in the following searches
|
# We need to clear out the organization from the search fields the searches for labels and instance_groups doesnt support it and won't be needed anymore
|
||||||
if 'name' in search_fields:
|
if 'organization' in search_fields:
|
||||||
del search_fields['name']
|
del search_fields['organization']
|
||||||
|
|
||||||
if labels is not None:
|
if labels is not None:
|
||||||
association_fields['labels'] = []
|
association_fields['labels'] = []
|
||||||
@@ -302,8 +322,6 @@ def main():
|
|||||||
else:
|
else:
|
||||||
association_fields['instance_groups'].append(instance_group_id['id'])
|
association_fields['instance_groups'].append(instance_group_id['id'])
|
||||||
|
|
||||||
# Create the data that gets sent for create and update
|
|
||||||
new_fields = {}
|
|
||||||
if rrule is not None:
|
if rrule is not None:
|
||||||
new_fields['rrule'] = rrule
|
new_fields['rrule'] = rrule
|
||||||
new_fields['name'] = new_name if new_name else (module.get_item_name(existing_item) if existing_item else name)
|
new_fields['name'] = new_name if new_name else (module.get_item_name(existing_item) if existing_item else name)
|
||||||
@@ -338,16 +356,6 @@ def main():
|
|||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
new_fields['timeout'] = timeout
|
new_fields['timeout'] = timeout
|
||||||
|
|
||||||
if execution_environment is not None:
|
|
||||||
if execution_environment == '':
|
|
||||||
new_fields['execution_environment'] = ''
|
|
||||||
else:
|
|
||||||
ee = module.get_one('execution_environments', name_or_id=execution_environment, **{'data': search_fields})
|
|
||||||
if ee is None:
|
|
||||||
module.fail_json(msg='could not find execution_environment entry with name {0}'.format(execution_environment))
|
|
||||||
else:
|
|
||||||
new_fields['execution_environment'] = ee['id']
|
|
||||||
|
|
||||||
# If the state was present and we can let the module build or update the existing item, this will return on its own
|
# If the state was present and we can let the module build or update the existing item, this will return on its own
|
||||||
module.create_or_update_if_needed(
|
module.create_or_update_if_needed(
|
||||||
existing_item,
|
existing_item,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def coerce_type(module, value):
|
|||||||
if not HAS_YAML:
|
if not HAS_YAML:
|
||||||
module.fail_json(msg="yaml is not installed, try 'pip install pyyaml'")
|
module.fail_json(msg="yaml is not installed, try 'pip install pyyaml'")
|
||||||
return yaml.safe_load(value)
|
return yaml.safe_load(value)
|
||||||
elif value.lower in ('true', 'false', 't', 'f'):
|
elif value.lower() in ('true', 'false', 't', 'f'):
|
||||||
return {'t': True, 'f': False}[value[0].lower()]
|
return {'t': True, 'f': False}[value[0].lower()]
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|||||||
@@ -72,6 +72,21 @@
|
|||||||
- "result is changed"
|
- "result is changed"
|
||||||
- "result.status == 'successful'"
|
- "result.status == 'successful'"
|
||||||
|
|
||||||
|
- name: Launch an Ad Hoc Command with extra_vars
|
||||||
|
ad_hoc_command:
|
||||||
|
inventory: "Demo Inventory"
|
||||||
|
credential: "{{ ssh_cred_name }}"
|
||||||
|
module_name: "ping"
|
||||||
|
extra_vars:
|
||||||
|
var1: "test var"
|
||||||
|
wait: true
|
||||||
|
register: result
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- "result is changed"
|
||||||
|
- "result.status == 'successful'"
|
||||||
|
|
||||||
- name: Launch an Ad Hoc Command with Execution Environment specified
|
- name: Launch an Ad Hoc Command with Execution Environment specified
|
||||||
ad_hoc_command:
|
ad_hoc_command:
|
||||||
inventory: "Demo Inventory"
|
inventory: "Demo Inventory"
|
||||||
|
|||||||
@@ -108,8 +108,9 @@
|
|||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- wait_results is successful
|
- 'wait_results.status in ["successful", "canceled"]'
|
||||||
- 'wait_results.status == "successful"'
|
fail_msg: "Ad hoc command stdout: {{ lookup('awx.awx.controller_api', 'ad_hoc_commands/' + command.id | string + '/stdout/?format=json') }}"
|
||||||
|
success_msg: "Ad hoc command finished with status {{ wait_results.status }}"
|
||||||
|
|
||||||
- name: Delete the Credential
|
- name: Delete the Credential
|
||||||
credential:
|
credential:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
- result['job_info']['skip_tags'] == "skipbaz"
|
- result['job_info']['skip_tags'] == "skipbaz"
|
||||||
- result['job_info']['limit'] == "localhost"
|
- result['job_info']['limit'] == "localhost"
|
||||||
- result['job_info']['job_tags'] == "Hello World"
|
- result['job_info']['job_tags'] == "Hello World"
|
||||||
- result['job_info']['inventory'] == {{ inventory_id }}
|
- result['job_info']['inventory'] == inventory_id | int
|
||||||
- "result['job_info']['extra_vars'] == '{\"animal\": \"bear\", \"food\": \"carrot\"}'"
|
- "result['job_info']['extra_vars'] == '{\"animal\": \"bear\", \"food\": \"carrot\"}'"
|
||||||
|
|
||||||
# cleanup
|
# cleanup
|
||||||
|
|||||||
@@ -71,6 +71,19 @@
|
|||||||
that:
|
that:
|
||||||
- "result is changed"
|
- "result is changed"
|
||||||
|
|
||||||
|
- name: Delete a credential without credential_type
|
||||||
|
credential:
|
||||||
|
name: "{{ ssh_cred_name1 }}"
|
||||||
|
organization: Default
|
||||||
|
state: absent
|
||||||
|
register: result
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- "result is failed"
|
||||||
|
|
||||||
|
|
||||||
- name: Create an Org-specific credential with an ID with exists
|
- name: Create an Org-specific credential with an ID with exists
|
||||||
credential:
|
credential:
|
||||||
name: "{{ ssh_cred_name1 }}"
|
name: "{{ ssh_cred_name1 }}"
|
||||||
|
|||||||
@@ -42,6 +42,16 @@
|
|||||||
that:
|
that:
|
||||||
- "result is not changed"
|
- "result is not changed"
|
||||||
|
|
||||||
|
- name: Modify the host as a no-op
|
||||||
|
host:
|
||||||
|
name: "{{ host_name }}"
|
||||||
|
inventory: "{{ inv_name }}"
|
||||||
|
register: result
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- "result is not changed"
|
||||||
|
|
||||||
- name: Delete a Host
|
- name: Delete a Host
|
||||||
host:
|
host:
|
||||||
name: "{{ host_name }}"
|
name: "{{ host_name }}"
|
||||||
@@ -68,6 +78,15 @@
|
|||||||
that:
|
that:
|
||||||
- "result is changed"
|
- "result is changed"
|
||||||
|
|
||||||
|
- name: Use lookup to check that host was enabled
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
host_enabled_test: "lookup('awx.awx.controller_api', 'hosts/{{result.id}}/').enabled"
|
||||||
|
|
||||||
|
- name: Newly created host should have API default value for enabled
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- host_enabled_test
|
||||||
|
|
||||||
- name: Delete a Host
|
- name: Delete a Host
|
||||||
host:
|
host:
|
||||||
name: "{{ result.id }}"
|
name: "{{ result.id }}"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "{{ matching_jobs.count }} == 1"
|
- matching_jobs.count == 1
|
||||||
|
|
||||||
- name: List failed jobs (which don't exist)
|
- name: List failed jobs (which don't exist)
|
||||||
job_list:
|
job_list:
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "{{ successful_jobs.count }} == 0"
|
- successful_jobs.count == 0
|
||||||
|
|
||||||
- name: Get ALL result pages!
|
- name: Get ALL result pages!
|
||||||
job_list:
|
job_list:
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
that:
|
that:
|
||||||
- wait_results is failed
|
- wait_results is failed
|
||||||
- 'wait_results.status == "canceled"'
|
- 'wait_results.status == "canceled"'
|
||||||
- "wait_results.msg == 'Job with id {{ job.id }} failed' or 'Job with id={{ job.id }} failed, error: Job failed.'"
|
- "'Job with id ~ job.id failed' or 'Job with id= ~ job.id failed, error: Job failed.' is in wait_results.msg"
|
||||||
|
|
||||||
# workflow wait test
|
# workflow wait test
|
||||||
- name: Generate a random string for test
|
- name: Generate a random string for test
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
|
|
||||||
- name: Get the ID of the first user created and verify that it is correct
|
- name: Get the ID of the first user created and verify that it is correct
|
||||||
assert:
|
assert:
|
||||||
that: "{{ query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_ids=True)[0] }} == {{ user_creation_results['results'][0]['id'] }}"
|
that: "query(plugin_name, 'users', query_params={ 'username' : user_creation_results['results'][0]['item'] }, return_ids=True)[0] == user_creation_results['results'][0]['id'] | string"
|
||||||
|
|
||||||
- name: Try to get an ID of someone who does not exist
|
- name: Try to get an ID of someone who does not exist
|
||||||
set_fact:
|
set_fact:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
- name: Call ruleset with no rules
|
- name: Call ruleset with no rules
|
||||||
set_fact:
|
set_fact:
|
||||||
complex_rule: "{{ query(ruleset_plugin_name, '2022-04-30 10:30:45') }}"
|
complex_rule: "{{ query(ruleset_plugin_name | string, '2022-04-30 10:30:45') }}"
|
||||||
ignore_errors: True
|
ignore_errors: True
|
||||||
register: results
|
register: results
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- result is failed
|
- result is failed
|
||||||
- "'Unable to create schedule {{ sched1 }}' in result.msg"
|
- "'Unable to create schedule '~ sched1 in result.msg"
|
||||||
|
|
||||||
- name: Create with options that the JT does not support
|
- name: Create with options that the JT does not support
|
||||||
schedule:
|
schedule:
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- result is failed
|
- result is failed
|
||||||
- "'Unable to create schedule {{ sched1 }}' in result.msg"
|
- "'Unable to create schedule '~ sched1 in result.msg"
|
||||||
|
|
||||||
- name: Build a real schedule
|
- name: Build a real schedule
|
||||||
schedule:
|
schedule:
|
||||||
@@ -76,6 +76,15 @@
|
|||||||
that:
|
that:
|
||||||
- result is changed
|
- result is changed
|
||||||
|
|
||||||
|
- name: Use lookup to check that schedules was enabled
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
schedules_enabled_test: "lookup('awx.awx.controller_api', 'schedules/{{result.id}}/').enabled"
|
||||||
|
|
||||||
|
- name: Newly created schedules should have API default value for enabled
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- schedules_enabled_test
|
||||||
|
|
||||||
- name: Build a real schedule with exists
|
- name: Build a real schedule with exists
|
||||||
schedule:
|
schedule:
|
||||||
name: "{{ sched1 }}"
|
name: "{{ sched1 }}"
|
||||||
@@ -225,6 +234,7 @@
|
|||||||
schedule:
|
schedule:
|
||||||
name: "{{ sched2 }}"
|
name: "{{ sched2 }}"
|
||||||
state: present
|
state: present
|
||||||
|
organization: Default
|
||||||
unified_job_template: "{{ jt1 }}"
|
unified_job_template: "{{ jt1 }}"
|
||||||
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
|
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
|
||||||
description: "This hopefully will work"
|
description: "This hopefully will work"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
- name: Test too many params (failure from validation of terms)
|
- name: Test too many params (failure from validation of terms)
|
||||||
debug:
|
debug:
|
||||||
msg: "{{ query(plugin_name, 'none', 'weekly', start_date='2020-4-16 03:45:07') }}"
|
msg: "{{ query(plugin_name | string, 'none', 'weekly', start_date='2020-4-16 03:45:07') }}"
|
||||||
ignore_errors: true
|
ignore_errors: true
|
||||||
register: result
|
register: result
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,42 @@
|
|||||||
---
|
---
|
||||||
|
- name: Initialize starting project vvv setting to false
|
||||||
|
awx.awx.settings:
|
||||||
|
name: "PROJECT_UPDATE_VVV"
|
||||||
|
value: false
|
||||||
|
|
||||||
|
- name: Change project vvv setting to true
|
||||||
|
awx.awx.settings:
|
||||||
|
name: "PROJECT_UPDATE_VVV"
|
||||||
|
value: true
|
||||||
|
register: result
|
||||||
|
|
||||||
|
- name: Changing setting to true should have changed the value
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- "result is changed"
|
||||||
|
|
||||||
|
- name: Change project vvv setting to true
|
||||||
|
awx.awx.settings:
|
||||||
|
name: "PROJECT_UPDATE_VVV"
|
||||||
|
value: true
|
||||||
|
register: result
|
||||||
|
|
||||||
|
- name: Changing setting to true again should not change the value
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- "result is not changed"
|
||||||
|
|
||||||
|
- name: Change project vvv setting back to false
|
||||||
|
awx.awx.settings:
|
||||||
|
name: "PROJECT_UPDATE_VVV"
|
||||||
|
value: false
|
||||||
|
register: result
|
||||||
|
|
||||||
|
- name: Changing setting back to false should have changed the value
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- "result is changed"
|
||||||
|
|
||||||
- name: Set the value of AWX_ISOLATION_SHOW_PATHS to a baseline
|
- name: Set the value of AWX_ISOLATION_SHOW_PATHS to a baseline
|
||||||
settings:
|
settings:
|
||||||
name: AWX_ISOLATION_SHOW_PATHS
|
name: AWX_ISOLATION_SHOW_PATHS
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- result is failed
|
- result is failed
|
||||||
- "'Monitoring of Workflow Job - {{ wfjt_name1 }} aborted due to timeout' in result.msg"
|
- "'Monitoring of Workflow Job - '~ wfjt_name1 ~ ' aborted due to timeout' in result.msg"
|
||||||
|
|
||||||
- name: Kick off a workflow and wait for it
|
- name: Kick off a workflow and wait for it
|
||||||
workflow_launch:
|
workflow_launch:
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Bulk API endpoints allows to perform bulk operations in single web request. Ther
|
|||||||
- /api/v2/bulk/job_launch
|
- /api/v2/bulk/job_launch
|
||||||
- /api/v2/bulk/host_create
|
- /api/v2/bulk/host_create
|
||||||
|
|
||||||
Making individual API calls in rapid succession or at high concurrency can overwhelm AWX's ability to serve web requests. When the application's ability to serve is exausted, clients often receive 504 timeout errors.
|
Making individual API calls in rapid succession or at high concurrency can overwhelm AWX's ability to serve web requests. When the application's ability to serve is exhausted, clients often receive 504 timeout errors.
|
||||||
|
|
||||||
Allowing the client combine actions into fewer requests allows for launching more jobs or adding more hosts with fewer requests and less time without exauhsting Controller's ability to serve requests, making excessive and repetitive database queries, or using excessive database connections (each web request opens a seperate database connection).
|
Allowing the client combine actions into fewer requests allows for launching more jobs or adding more hosts with fewer requests and less time without exhauhsting Controller's ability to serve requests, making excessive and repetitive database queries, or using excessive database connections (each web request opens a separate database connection).
|
||||||
|
|
||||||
## Bulk Job Launch
|
## Bulk Job Launch
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ Given settings.AWX_CONTROL_NODE_TASK_IMPACT is 1:
|
|||||||
|
|
||||||
This setting allows you to determine how much impact controlling jobs has. This
|
This setting allows you to determine how much impact controlling jobs has. This
|
||||||
can be helpful if you notice symptoms of your control plane exceeding desired
|
can be helpful if you notice symptoms of your control plane exceeding desired
|
||||||
CPU or memory usage, as it effectivly throttles how many jobs can be run
|
CPU or memory usage, as it effectively throttles how many jobs can be run
|
||||||
concurrently by your control plane. This is usually a concern with container
|
concurrently by your control plane. This is usually a concern with container
|
||||||
groups, which at this time effectively have infinite capacity, so it is easy to
|
groups, which at this time effectively have infinite capacity, so it is easy to
|
||||||
end up with too many jobs running concurrently, overwhelming the control plane
|
end up with too many jobs running concurrently, overwhelming the control plane
|
||||||
@@ -130,10 +130,10 @@ be `18`:
|
|||||||
|
|
||||||
By default, only Instances have capacity and we only track capacity consumed per instance. With the max_forks and max_concurrent_jobs fields now available on Instance Groups, we additionally can limit how many jobs or forks are allowed to be concurrently consumed across an entire Instance Group or Container Group.
|
By default, only Instances have capacity and we only track capacity consumed per instance. With the max_forks and max_concurrent_jobs fields now available on Instance Groups, we additionally can limit how many jobs or forks are allowed to be concurrently consumed across an entire Instance Group or Container Group.
|
||||||
|
|
||||||
This is especially useful for Container Groups where previously, there was no limit to how many jobs we would submit to a Container Group, which made it impossible to "overflow" job loads from one Container Group to another container group, which may be on a different Kubenetes cluster or namespace.
|
This is especially useful for Container Groups where previously, there was no limit to how many jobs we would submit to a Container Group, which made it impossible to "overflow" job loads from one Container Group to another container group, which may be on a different Kubernetes cluster or namespace.
|
||||||
|
|
||||||
One way to calculate what max_concurrent_jobs is desirable to set on a Container Group is to consider the pod_spec for that container group. In the pod_spec we indicate the resource requests and limits for the automation job pod. If you pod_spec indicates that a pod with 100MB of memory will be provisioned, and you know your Kubernetes cluster has 1 worker node with 8GB of RAM, you know that the maximum number of jobs that you would ideally start would be around 81 jobs, calculated by taking (8GB memory on node * 1024 MB) // 100 MB memory/job pod which with floor division comes out to 81.
|
One way to calculate what max_concurrent_jobs is desirable to set on a Container Group is to consider the pod_spec for that container group. In the pod_spec we indicate the resource requests and limits for the automation job pod. If you pod_spec indicates that a pod with 100MB of memory will be provisioned, and you know your Kubernetes cluster has 1 worker node with 8GB of RAM, you know that the maximum number of jobs that you would ideally start would be around 81 jobs, calculated by taking (8GB memory on node * 1024 MB) // 100 MB memory/job pod which with floor division comes out to 81.
|
||||||
|
|
||||||
Alternatively, instead of considering the number of job pods and the resources requested, we can consider the memory consumption of the forks in the jobs. We normally consider that 100MB of memory will be used by each fork of ansible. Therefore we also know that our 8 GB worker node should also only run 81 forks of ansible at a time -- which depending on the forks and inventory settings of the job templates, could be consumed by anywhere from 1 job to 81 jobs. So we can also set max_forks = 81. This way, either 39 jobs with 1 fork can run (task impact is always forks + 1), or 2 jobs with forks set to 39 can run.
|
Alternatively, instead of considering the number of job pods and the resources requested, we can consider the memory consumption of the forks in the jobs. We normally consider that 100MB of memory will be used by each fork of ansible. Therefore we also know that our 8 GB worker node should also only run 81 forks of ansible at a time -- which depending on the forks and inventory settings of the job templates, could be consumed by anywhere from 1 job to 81 jobs. So we can also set max_forks = 81. This way, either 39 jobs with 1 fork can run (task impact is always forks + 1), or 2 jobs with forks set to 39 can run.
|
||||||
|
|
||||||
While this feature is most useful for Container Groups where there is no other way to limit job execution, this feature is avialable for use on any instance group. This can be useful if for other business reasons you want to set a InstanceGroup wide limit on concurrent jobs. For example, if you have a job template that you only want 10 copies of running at a time -- you could create a dedicated instance group for that job template and set max_concurrent_jobs to 10.
|
While this feature is most useful for Container Groups where there is no other way to limit job execution, this feature is available for use on any instance group. This can be useful if for other business reasons you want to set a InstanceGroup wide limit on concurrent jobs. For example, if you have a job template that you only want 10 copies of running at a time -- you could create a dedicated instance group for that job template and set max_concurrent_jobs to 10.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user