Compare commits

..

95 Commits

Author SHA1 Message Date
Ratan Gulati
40c2b700fe Fix: #14523 Add alt-text codeblock to Images for workflow_template.rst (#14604)
* add alt to images in workflow_templates.rst

Signed-off-by: Ratan Gulati <ratangulati.dev@gmail.com>

* add alt to images in workflow_templates.rst

Signed-off-by: Ratan Gulati <ratangulati.dev@gmail.com>

* Update workflow_templates.rst

* Revised proposed alt text for workflow_templates.rst

---------

Signed-off-by: Ratan Gulati <ratangulati.dev@gmail.com>
Co-authored-by: TVo <thavo@redhat.com>
2023-11-07 10:51:11 -07:00
Thanhnguyet Vo
71d548f9e5 Removed references to images that were deleted. 2023-11-07 08:55:27 -07:00
Thanhnguyet Vo
dd98963f86 Updated images - Workflow Templates chapter of Userguide. 2023-11-07 08:55:27 -07:00
TVo
4b467dfd8d Revised proposed alt text for insights.rst 2023-11-07 08:14:34 -07:00
BHANUTEJA
456b56778e Update insights.rst 2023-11-07 08:14:34 -07:00
BHANUTEJA
5b3cb20f92 Update insights.rst 2023-11-07 08:14:34 -07:00
TVo
d7086a3c88 Revised the proposed Alt text for main_menu.rst 2023-11-06 13:09:26 -07:00
Ratan Gulati
21e7ab078c Fix: #14511 Add alt-text codeblock to Images for Userguide: main_menu.rst
Signed-off-by: Ratan Gulati <ratangulati.dev@gmail.com>
2023-11-06 13:09:26 -07:00
Elijah DeLee
946ca0b3b8 fix wsrelay connection in ipv6 environments 2023-11-06 13:58:41 -05:00
TVo
b831dbd608 Removed mailing list from triage_replies.md 2023-11-03 14:30:30 -06:00
Thanhnguyet Vo
943e455f9d Re-do for PR #14595 to fix CI issues. 2023-11-03 08:35:22 -06:00
Seth Foster
53bc88abe2 Fix python_paths error in CI(#14622)
Remove outdated lines from pytest.ini

Was causing KeyError 'python_paths' in CI

Signed-off-by: Seth Foster <fosterbseth@gmail.com>
2023-11-03 09:36:21 -04:00
Rick Elrod
3b4d95633e [rsyslog] remove main_queue, add more action queue params (#14532)
* [rsyslog] remove main_queue, add more action queue params

Signed-off-by: Rick Elrod <rick@elrod.me>

* Remove now-unused LOG_AGGREGATOR_MAX_DISK_USAGE_GB, add LOG_AGGREGATOR_ACTION_QUEUE_SIZE

Signed-off-by: Rick Elrod <rick@elrod.me>

---------

Signed-off-by: Rick Elrod <rick@elrod.me>
2023-10-31 14:49:17 -04:00
Alan Rominger
93c329d9d5 Fix cancel bug - WorkflowManager cancel in transaction (#14608)
This fixes a bug where jobs within a workflow job were not canceled
  when the workflow job was canceled by the user

The fix is to submit the cancel request as a part of the
  transaction that WorkflowManager commits its work in
  this requires that we send the message without expecting a reply
  so this changes the control-with-reply cancel to just a control function
2023-10-30 15:30:18 -04:00
Hao Liu
f4c53aaf22 Update receptor-collection version to 2.0.2 (#14613) 2023-10-30 17:24:02 +00:00
Alan Rominger
333ef76cbd Send notifications for dependency failures (#14603)
* Send notifications for dependency failures

* Delete tests for deleted method

* Remove another test for removed method
2023-10-30 10:42:37 -04:00
Alan Rominger
fc0b58fd04 Fix bug that prevented dispatcher exit with downed DB (#14469)
* Separate handling of original sitTERM and sigINT
2023-10-26 14:34:25 -04:00
Andrii Zakurenyi
bef0a8b23a Fix DevOps Secrets Vault credential plugin to work with python-dsv-sdk>=1.0.4
Signed-off-by: Andrii Zakurenyi <andrii.zakurenyi@c.delinea.com>
2023-10-25 15:48:24 -04:00
lmo5
a5f33456b6 Fix missing service account secret in docker-compose-minikube role (#14596)
* Fix missing service account secret

Co-authored-by: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com>
2023-10-25 19:27:21 +00:00
Surav Shrestha
21fb395912 fix typos in docs/development/minikube.md 2023-10-25 15:23:23 -04:00
jessicamack
44255f378d Fix extra_vars bug in ansible.controller.ad_hoc_command (#14585)
* convert to valid type for serializer

* check that extra_vars are in request

* remove doubled line

* add integration test for change

* move change to the ad_hoc_command module

Signed-off-by: jessicamack <jmack@redhat.com>

* fix imports

Signed-off-by: jessicamack <jmack@redhat.com>

---------

Signed-off-by: jessicamack <jmack@redhat.com>
2023-10-25 10:38:45 -04:00
Parikshit Adhikari
71a6d48612 Fix: typos inside /docs directory (#14594)
fix typos inside docs
2023-10-24 19:01:21 +00:00
nmiah1
b7e5f5d1e1 Typo in export.py example (#14598) 2023-10-24 18:33:38 +00:00
Alan Rominger
b6b167627c Fix Boolean values defaulting to False in collection (#14493)
* Fix Boolean values defaulting to False in collection

* Remove null values in other cases, fix null handling for WFJT nodes

* Only remove null values if it is a boolean field

* Reset changes to WFJT node field processing

* Use test content from sean-m-sullivan to fix lookups in assert
2023-10-24 14:29:16 -04:00
Hao Liu
20f5b255c9 Fix "upgrade in progress" status page not showing up while migration is in progress (#14579)
Web container does not need to wait for migration

if the database is running and responsive, but migrations have not finished, it will start serving, and users will get the upgrading page

wait-for-migration prevent nginix and uwsgi from starting up to serve the "upgrade in progress" status page
2023-10-24 14:27:09 -04:00
Oleksii Baranov
3bcf46555d Fix swagger generation on rhel (#14317) (#14589) 2023-10-24 14:19:02 -04:00
Don Naro
94703ccf84 Pip compile docsite requirements (#14449)
Co-authored-by: Sviatoslav Sydorenko <578543+webknjaz@users.noreply.github.com>
Co-authored-by: Sviatoslav Sydorenko <wk.cvs.github@sydorenko.org.ua>
2023-10-24 12:53:41 -04:00
BHANUTEJA
6cdea1909d Alt text for Execution Env section of Userguide (#14576)
Co-authored-by: TVo <thavo@redhat.com>
2023-10-20 18:48:07 +00:00
Mike Mwanje
f133580172 Adds alt text to instance_groups.rst images (#14571)
Co-authored-by: TVo <thavo@redhat.com>
2023-10-20 16:11:17 +00:00
Kishan Mehta
4b90a7fcd1 Add alt text for image directives in credential_types.rst (#14551)
Co-authored-by: Kishan Mehta <kishan@scrapinghub.com>
Co-authored-by: TVo <thavo@redhat.com>
2023-10-20 09:36:05 -06:00
Marliana Lara
95bfedad5b Format constructed inventory hint example as valid YAML (#14568) 2023-10-20 10:24:47 -04:00
Kishan Mehta
1081f2d8e9 Add alt text for image directives in credentials.rst (#14550)
Co-authored-by: Kishan Mehta <kishan@scrapinghub.com>
Co-authored-by: TVo <thavo@redhat.com>
2023-10-20 14:13:49 +00:00
Kishan Mehta
c4ab54d7f3 Add alt text for image directives in job_capacity.rst & job_slices.rst (#14549)
Co-authored-by: Kishan Mehta <kishan@scrapinghub.com>
Co-authored-by: TVo <thavo@redhat.com>
2023-10-20 13:34:04 +00:00
Hao Liu
bcefcd8cf8 Remove specific version for receptorctl (#14593) 2023-10-19 22:49:42 -04:00
Kishan Mehta
0bd057529d Add alt text for image directives in job_templates.rst (#14548)
Co-authored-by: Kishan Mehta <kishan@scrapinghub.com>
2023-10-19 20:24:32 +00:00
Sayyed Faisal Ali
a82c03e2e2 added alt-text in projects.rst (#14544)
Signed-off-by: c0de-slayer <fsali315@gmail.com>
Co-authored-by: TVo <thavo@redhat.com>
2023-10-19 12:39:58 -06:00
TVo
447ac77535 Corrected missing text replacement directives (#14592) 2023-10-19 16:36:41 +00:00
Andrew Klychkov
72d0928f1b [DOCS] EE guide: fix a ref to Get started with EE (#14587) 2023-10-19 03:30:21 -04:00
Deepshri M
6d727d4bc4 Adding alt text for image (#14541)
Signed-off-by: Deepshri M <deepshrim613@gmail.com>
2023-10-17 14:53:18 -06:00
Rohit Raj
6040e44d9d docs: Update teams.rst (#14539)
Co-authored-by: TVo <thavo@redhat.com>
2023-10-17 20:16:09 +00:00
Rohit Raj
b99ce5cd62 docs: Update users.rst (#14538)
Co-authored-by: TVo <thavo@redhat.com>
2023-10-17 14:58:40 +00:00
Rohit Raj
ba8a90c55f docs: Update security.rst (#14540)
Co-authored-by: Don Naro <dnaro@redhat.com>
2023-10-16 17:56:46 -06:00
Sayyed Faisal Ali
7ee2172517 added alt-text in project-sign.rst (#14545)
Signed-off-by: c0de-slayer <fsali315@gmail.com>
Co-authored-by: TVo <thavo@redhat.com>
2023-10-16 09:25:34 -06:00
Alan Rominger
07f49f5925 AAP-16926 Delete unpartitioned tables in a separate transaction (#14572) 2023-10-13 15:50:51 -04:00
Hao Liu
376993077a Removing mailing list from get involved (#14580) 2023-10-13 17:49:34 +00:00
Hao Liu
48f586bac4 Make wait-for-migrations wait forever (#14566) 2023-10-13 13:48:12 +00:00
Surendran
16dab57c63 Added alt-text for images in notifications.rst (#14555)
Signed-off-by: Surendran Gokul <surendrangokul55@gmail.com>
Co-authored-by: Don Naro <dnaro@redhat.com>
2023-10-12 15:22:37 -06:00
Surendran
75a71492fd Added alt-text for images in organizations.rst (#14556)
Signed-off-by: Surendran Gokul <surendrangokul55@gmail.com>
Co-authored-by: Don Naro <dnaro@redhat.com>
2023-10-12 15:15:45 -06:00
Hao Liu
e9bd99c1ff Fix CVE-2023-43665 (#14561) 2023-10-12 14:00:32 -04:00
Daniel Gonçalves
56878b4910 Add customizable batch_size for cleanup_activitystream and cleanup_jobs (#14412)
Signed-off-by: Daniel Gonçalves <daniel.gonc@lves.fr>
2023-10-11 20:09:16 +00:00
Alan Rominger
19ca480078 Upgrade client library for dsv since tss already landed (#14362) 2023-10-11 16:01:22 -04:00
Steffen Scheib
64eb963025 Cleaning SOS report passwords (#14557) 2023-10-11 19:54:28 +00:00
Will Thames
dc34d0887a Execution environment image should not be required (#14488) 2023-10-11 15:39:51 -04:00
Andrew Klychkov
160634fb6f ee_reference.rst: refert to Builder's definition docs instead of duplicating its content (#14562) 2023-10-11 13:54:12 +01:00
Alan Rominger
9745058546 Only block commits if black fails for certain paths (#14531) 2023-10-10 10:12:57 -04:00
Aviral Katiyar
c97a48b165 Fix: #14510 Add alt-text codeblock to Images for Userguide: jobs.rst (#14530)
Signed-off-by: maskboyAvi <aviralofficial1729@gmail.com>
Co-authored-by: Don Naro <dnaro@redhat.com>
2023-10-09 16:40:56 -06:00
Rohit Raj
259bca0113 docs: Update workflows.rst (#14537) 2023-10-06 15:30:47 -06:00
Aviral Katiyar
92c2b4e983 Fix: #14500 Added alt text to images for Userguide: credential_plugins.rst (#14527)
Signed-off-by: maskboyAvi <aviralofficial1729@gmail.com>
Co-authored-by: Don Naro <dnaro@redhat.com>
2023-10-06 14:53:23 -06:00
Seth Foster
127a0cff23 Set ip_address to empty string
ip_address cannot be null, so set to
empty instead of None

Signed-off-by: Seth Foster <fosterbseth@gmail.com>
2023-10-05 22:53:16 -04:00
Aviral Katiyar
a0ef25006a Fix: #14499 Added alt text to images for Userguide: applications_auth.rst (#14526)
Signed-off-by: maskboyAvi <aviralofficial1729@gmail.com>
Co-authored-by: Don Naro <dnaro@redhat.com>
2023-10-05 14:22:10 -06:00
Chris Meyers
50c98a52f7 Update setting_up.rst (#14542) 2023-10-05 15:06:40 -04:00
Michelle McCausland
4008d72af6 issue-14522: Add alt-text codeblock to Images for Userguide: webhooks.rst (#14529)
Signed-off-by: Michelle McCausland <mmccausl@redhat.com>
2023-10-05 17:40:07 +01:00
Alan Rominger
e72e9f94b9 Fix collection test flake due to successful canceled command (#14519) 2023-10-04 09:09:29 -04:00
Sasa Jovicic
9d60b0b9c6 Fix #12815 Direct links to AWX do not reroute the user after authentication (#14399)
Signed-off-by: Sasa993 <jovicic.sasa@hotmail.com>
Co-authored-by: Sasa Jovicic <sjovicic@anexia-it.com>
2023-10-03 16:55:22 -04:00
Aviral Katiyar
05b58c4df6 Fix : #14490 Fixed the required spelling errors (#14507)
Signed-off-by: maskboyAvi <aviralofficial1729@gmail.com>
2023-10-03 14:15:13 -06:00
TVo
b1b960fd17 Updated Forum terminology and removed mailing list (#14491) 2023-10-03 19:24:19 +01:00
Jakub Laskowski
3c8f71e559 Fixed wrong arguments order in DomainPasswordGrantAuthorizer (#14441)
Signed-off-by: Jakub Laskowski <jakub.laskowski9@gmail.com>
Co-authored-by: Seth Foster <fosterseth@users.noreply.github.com>
2023-10-03 11:54:57 -04:00
Alan Rominger
f5922f76fa DROP unnecessary unpartioned event tables (#14055) 2023-10-03 11:49:23 -04:00
kurokobo
05582702c6 fix: make type conversions work correctly (related #14487) (#14489)
Signed-off-by: kurokobo <2920259+kurokobo@users.noreply.github.com>
Co-authored-by: Alan Rominger <arominge@redhat.com>
2023-09-30 04:02:10 +00:00
Alan Rominger
1d340c5b4e Add a section for postgres max_connections value (#14482) 2023-09-28 10:28:52 -04:00
TVo
15925f1416 Simplified release notes for AWX (#14485) 2023-09-27 14:50:57 -06:00
Salma Kochay
6e06a20cca add subscription usage page 2023-09-27 10:57:04 -04:00
Hao Liu
bb3acbb8ad Debug log for scheduler commit duration (#14035)
Co-authored-by: Alan Rominger <arominge@redhat.com>
2023-09-27 09:46:55 -04:00
Hao Liu
a88e47930c Update django version to address CVE-2023-41164 (#14460) 2023-09-27 09:36:02 -04:00
Hao Liu
a0d4515ba4 Explicitly set collection version during promotion (#14484) 2023-09-26 14:19:22 -04:00
Alan Rominger
770cc10a78 Get rid of names_digest hack no longer needed (#14459) 2023-09-26 12:09:30 -04:00
Alan Rominger
159dd62d84 Add null value handling in create_partition (#14480) 2023-09-25 18:28:44 -04:00
TVo
640e5db9c6 Removed references of IRC and fixed formatting in "Work Items" section. (#14478)
Co-authored-by: Don Naro <dnaro@redhat.com>
2023-09-25 11:24:39 -06:00
Alan Rominger
9ed527eb26 Consolidate image and server setup in several checks (#14477) 2023-09-25 09:02:20 -04:00
Alan Rominger
29ad6e1eaa Fix bug, None was used instead of empty for DB outage (#14463) 2023-09-21 14:30:25 -04:00
Alan Rominger
3e607f8964 AAP-15927 Use ATTACH PARTITION to avoid exclusive table lock for events (#14433) 2023-09-21 14:27:04 -04:00
TVo
c9d1a4d063 Added release notes for version 23.1.0 (#14471) 2023-09-21 11:02:38 -06:00
Hao Liu
a290b082db Use ldap container hostname for LDAP config (#14473) 2023-09-21 11:31:51 -04:00
Hao Liu
6d3c22e801 Update how to get involved with matrix and forum (#14472) 2023-09-20 18:33:04 +00:00
Michael Abashian
1f91773a3c Simplify docs string base generation 2023-09-20 13:16:54 -04:00
Hao Liu
7b846e1e49 Add makefile target to load dev image into Kind (#13775)
Signed-off-by: Rick Elrod <rick@elrod.me>
Co-authored-by: Rick Elrod <rick@elrod.me>
2023-09-19 13:34:10 -04:00
Don Naro
f7a2de8a07 Contributor guide and adjusted titles (#14447)
Co-authored-by: Thanhnguyet Vo <tvo@ansible.com>
2023-09-18 10:40:47 -06:00
Andrew Klychkov
194c214f03 userguide/execution_environments.rst: replace building paragraphs with ref to Get started EE guide (#14429) 2023-09-15 10:20:46 -04:00
Christian Adams
77e30dd4b2 Add link to script for publishing operator on OperatorHub (#14442) 2023-09-15 09:32:19 -04:00
jessicamack
9d7421b9bc Update README (#14452)
Signed-off-by: jessicamack <jmack@redhat.com>
2023-09-14 20:20:06 +00:00
Alan Rominger
3b8e662916 Remove conditional paths due to conflict with required checks (#14450) 2023-09-14 16:19:42 -04:00
Alan Rominger
aa3228eec9 Fix continue-on-error GH actions bug, always run archive step instead 2023-09-14 19:45:07 +00:00
Alan Rominger
7b0598c7d8 Continue workflow steps to save logs from failed tests (#14448) 2023-09-14 18:23:22 +00:00
Ivan Aragonés Muniesa
49832d6379 don't pass the 'organization' or other fields to the search of the instance group or execution environments (#14223) 2023-09-14 09:31:05 -04:00
Alan Rominger
8feeb5f1fa Allow saving github creds in user folder (#14435) 2023-09-12 15:47:12 -04:00
164 changed files with 2365 additions and 1282 deletions

View 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

View File

@@ -1,12 +1,8 @@
# This currently *always* uses the "warm build cache" image
# 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)
name: Run AWX docker-compose
description: Runs AWX with `make docker-compose`
inputs:
github-token:
description: GitHub Token for registry access
description: GitHub Token to pass to awx_devel_image
required: true
build-ui:
description: Should the UI be built?
@@ -23,9 +19,10 @@ outputs:
runs:
using: composite
steps:
- name: Get python version from Makefile
shell: bash
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
- name: Build awx_devel image for running checks
uses: ./.github/actions/awx_devel_image
with:
github-token: ${{ inputs.github-token }}
- name: Upgrade ansible-core
shell: bash
@@ -35,19 +32,6 @@ runs:
shell: bash
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
shell: bash
run: |

View File

@@ -7,8 +7,8 @@
## PRs/Issues
### Visit our mailing list
- 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.
### 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 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

View File

@@ -7,9 +7,6 @@ env:
COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
on:
pull_request:
paths-ignore:
- 'docs/**'
- '.github/workflows/docs.yml'
jobs:
common-tests:
name: ${{ matrix.tests.name }}
@@ -40,16 +37,27 @@ jobs:
steps:
- 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 }}
run: AWX_DOCKER_CMD='${{ matrix.tests.command }}' make github_ci_runner
run: AWX_DOCKER_CMD='${{ matrix.tests.command }}' make docker-runner
dev-env:
runs-on: ubuntu-latest
steps:
- 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
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:
runs-on: ubuntu-latest
@@ -159,11 +167,13 @@ jobs:
# Upload coverage report as artifact
- uses: actions/upload-artifact@v3
if: always()
with:
name: coverage-${{ matrix.target-regex.name }}
path: ~/.ansible/collections/ansible_collections/awx/awx/tests/output/coverage/
- uses: ./.github/actions/upload_awx_devel_logs
if: always()
with:
log-filename: collection-integration-${{ matrix.target-regex.name }}.log

View File

@@ -2,9 +2,6 @@
name: Docsite CI
on:
pull_request:
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
jobs:
docsite-build:
name: docsite test build

View File

@@ -26,7 +26,6 @@ jobs:
with:
build-ui: true
github-token: ${{ secrets.GITHUB_TOKEN }}
log-filename: e2e-${{ matrix.job }}.log
- name: Pull awx_cypress_base image
run: |
@@ -71,5 +70,6 @@ jobs:
awx-pf-tests run --project .
- uses: ./.github/actions/upload_awx_devel_logs
if: always()
with:
log-filename: e2e-${{ matrix.job }}.log

View File

@@ -40,8 +40,12 @@ jobs:
if: ${{ github.repository_owner != 'ansible' }}
- 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: |
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 \
echo "Galaxy release already done"; \
else \

5
.pip-tools.toml Normal file
View File

@@ -0,0 +1,5 @@
[tool.pip-tools]
resolver = "backtracking"
allow-unsafe = true
strip-extras = true
quiet = true

View File

@@ -6,6 +6,7 @@ DOCKER_COMPOSE ?= docker-compose
OFFICIAL ?= no
NODE ?= node
NPM_BIN ?= npm
KIND_BIN ?= $(shell which kind)
CHROMIUM_BIN=/tmp/chrome-linux/chrome
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
MANAGEMENT_COMMAND ?= awx-manage
@@ -78,7 +79,7 @@ I18N_FLAG_FILE = .i18n_built
sdist \
ui-release ui-devel \
VERSION PYTHON_VERSION docker-compose-sources \
.git/hooks/pre-commit github_ci_setup github_ci_runner
.git/hooks/pre-commit
clean-tmp:
rm -rf tmp/
@@ -323,21 +324,10 @@ test:
cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3
awx-manage check_migrations --dry-run --check -n 'missing_migration_file'
## Login to Github container image registry, pull image, then build image.
github_ci_setup:
# GITHUB_ACTOR is automatic github actions env var
# CI_GITHUB_TOKEN is defined in .github files
echo $(CI_GITHUB_TOKEN) | docker login ghcr.io -u $(GITHUB_ACTOR) --password-stdin
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.
docker-runner:
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:
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
if [ "$(VENV_BASE)" ]; then \
@@ -664,6 +654,9 @@ awx-kube-dev-build: Dockerfile.kube-dev
-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
# --------------------------------------

View File

@@ -1,5 +1,5 @@
[![CI](https://github.com/ansible/awx/actions/workflows/ci.yml/badge.svg?branch=devel)](https://github.com/ansible/awx/actions/workflows/ci.yml) [![Code of Conduct](https://img.shields.io/badge/code%20of%20conduct-Ansible-yellow.svg)](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) [![Apache v2 License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/ansible/awx/blob/devel/LICENSE.md) [![AWX Mailing List](https://img.shields.io/badge/mailing%20list-AWX-orange.svg)](https://groups.google.com/g/awx-project)
[![IRC Chat - #ansible-awx](https://img.shields.io/badge/IRC-%23ansible--awx-blueviolet.svg)](https://libera.chat)
[![Ansible Matrix](https://img.shields.io/badge/matrix-Ansible%20Community-blueviolet.svg?logo=matrix)](https://chat.ansible.im/#/welcome) [![Ansible Discourse](https://img.shields.io/badge/discourse-Ansible%20Community-yellowgreen.svg?logo=discourse)](https://forum.ansible.com)
<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" />
@@ -30,12 +30,12 @@ If you're experiencing a problem that you feel is a bug in AWX or have ideas for
Code of Conduct
---------------
We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
Get Involved
------------
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 [mailing list](https://groups.google.com/forum/#!forum/awx-project)
- Join the [Ansible AWX channel on Matrix](https://matrix.to/#/#awx:ansible.com)
- Join the [Ansible Community Forum](https://forum.ansible.com)

View File

@@ -52,39 +52,14 @@ try:
except ImportError: # pragma: no cover
MODE = 'production'
import hashlib
try:
import django # noqa: F401
HAS_DJANGO = True
except ImportError:
HAS_DJANGO = False
pass
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
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):
# Modified version of function from django/core/management/__init__.py.

View File

@@ -1,4 +1,4 @@
---
collections:
- name: ansible.receptor
version: 2.0.0
version: 2.0.2

View File

@@ -418,6 +418,10 @@ class SettingsWrapper(UserSettingsHolder):
"""Get value while accepting the in-memory cache if key is available"""
with _ctit_db_wrapper(trans_safe=True):
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):
value = empty

View File

@@ -13,6 +13,7 @@ from unittest import mock
from django.conf import LazySettings
from django.core.cache.backends.locmem import LocMemCache
from django.core.exceptions import ImproperlyConfigured
from django.db.utils import Error as DBError
from django.utils.translation import gettext_lazy as _
import pytest
@@ -331,3 +332,18 @@ def test_in_memory_cache_works(settings):
with mock.patch.object(settings, '_get_local') as mock_get:
assert settings.AWX_VAR == 'DEFAULT'
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 == []

View File

@@ -694,16 +694,18 @@ register(
category_slug='logging',
)
register(
'LOG_AGGREGATOR_MAX_DISK_USAGE_GB',
'LOG_AGGREGATOR_ACTION_QUEUE_SIZE',
field_class=fields.IntegerField,
default=1,
default=131072,
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=_(
'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).'
'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).'
),
category=_('Logging'),
category_slug='logging',
@@ -718,8 +720,7 @@ register(
'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.'
'It stores files in the directory specified by LOG_AGGREGATOR_MAX_DISK_USAGE_PATH.'
),
category=_('Logging'),
category_slug='logging',

View File

@@ -2,25 +2,28 @@ from .plugin import CredentialPlugin
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from thycotic.secrets.vault import SecretsVault
from delinea.secrets.vault import PasswordGrantAuthorizer, SecretsVault
dsv_inputs = {
'fields': [
{
'id': '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',
},
{
'id': 'tld',
'label': _('Top-level Domain (TLD)'),
'help_text': _('The TLD of the tenant e.g. "com" when the URL is https://ex.secretservercloud.com'),
'choices': ['ca', 'com', 'com.au', 'com.sg', 'eu'],
'help_text': _('The TLD of the tenant e.g. "com" when the URL is https://ex.secretsvaultcloud.com'),
'choices': ['ca', 'com', 'com.au', 'eu'],
'default': 'com',
},
{'id': 'client_id', 'label': _('Client ID'), 'type': 'string'},
{
'id': 'client_id',
'label': _('Client ID'),
'type': 'string',
},
{
'id': 'client_secret',
'label': _('Client Secret'),
@@ -51,12 +54,26 @@ if settings.DEBUG:
'id': 'url_template',
'label': _('URL template'),
'type': 'string',
'default': 'https://{}.secretsvaultcloud.{}/v1',
'default': 'https://{}.secretsvaultcloud.{}',
}
)
dsv_plugin = CredentialPlugin(
'Thycotic DevOps Secrets Vault',
dsv_inputs,
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
)
def dsv_backend(**kwargs):
tenant_name = kwargs['tenant']
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']
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)
return dsv_secret['data'][secret_field]
dsv_plugin = CredentialPlugin(name='Thycotic DevOps Secrets Vault', inputs=dsv_inputs, backend=dsv_backend)

View File

@@ -54,7 +54,9 @@ tss_inputs = {
def tss_backend(**kwargs):
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:
authorizer = PasswordGrantAuthorizer(kwargs['server_url'], kwargs['username'], kwargs['password'])
secret_server = SecretServer(kwargs['server_url'], authorizer)

View File

@@ -37,8 +37,11 @@ class Control(object):
def running(self, *args, **kwargs):
return self.control_with_reply('running', *args, **kwargs)
def cancel(self, task_ids, *args, **kwargs):
return self.control_with_reply('cancel', *args, extra_data={'task_ids': task_ids}, **kwargs)
def cancel(self, task_ids, with_reply=True):
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):
return self.control_with_reply('schedule', *args, **kwargs)

View File

@@ -89,8 +89,9 @@ class AWXConsumerBase(object):
if task_ids and not msg:
logger.info(f'Could not locate running tasks to cancel with ids={task_ids}')
with pg_bus_conn() as conn:
conn.notify(reply_queue, json.dumps(msg))
if reply_queue is not None:
with pg_bus_conn() as conn:
conn.notify(reply_queue, json.dumps(msg))
elif control == 'reload':
for worker in self.pool.workers:
worker.quit()

View File

@@ -24,6 +24,9 @@ class Command(BaseCommand):
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('--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):
log_levels = dict(enumerate([logging.ERROR, logging.INFO, logging.DEBUG, 0]))
@@ -48,7 +51,7 @@ class Command(BaseCommand):
else:
pks_to_delete.add(asobj.pk)
# 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()
n_deleted_items += len(pks_to_delete)
pks_to_delete.clear()
@@ -63,4 +66,5 @@ class Command(BaseCommand):
self.days = int(options.get('days', 30))
self.cutoff = now() - datetime.timedelta(days=self.days)
self.dry_run = bool(options.get('dry_run', False))
self.batch_size = int(options.get('batch_size', 500))
self.cleanup_activitystream()

View File

@@ -9,6 +9,7 @@ import re
# Django
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction, connection
from django.db.models import Min, Max
@@ -150,6 +151,9 @@ class Command(BaseCommand):
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('--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('--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')
@@ -195,18 +199,58 @@ class Command(BaseCommand):
delete_meta.delete_jobs()
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:
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))
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):
batch_size = 100000
# Hack to avoid doing N+1 queries as each item in the Job query set does
# an individual query to get the underlying UnifiedJob.
Job.polymorphic_super_sub_accessors_replaced = True
@@ -221,13 +265,14 @@ class Command(BaseCommand):
deleted = 0
info = qs.aggregate(min=Min('id'), max=Max('id'))
if info['min'] is not None:
for start in range(info['min'], info['max'] + 1, batch_size):
qs_batch = qs.filter(id__gte=start, id__lte=start + batch_size)
for start in range(info['min'], info['max'] + 1, self.batch_size):
qs_batch = qs.filter(id__gte=start, id__lte=start + self.batch_size)
pk_list = qs_batch.values_list('id', flat=True)
_, results = qs_batch.delete()
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
@@ -250,7 +295,7 @@ class Command(BaseCommand):
deleted += 1
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()
return skipped, deleted
@@ -278,7 +323,7 @@ class Command(BaseCommand):
deleted += 1
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()
return skipped, deleted
@@ -306,7 +351,7 @@ class Command(BaseCommand):
deleted += 1
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()
return skipped, deleted
@@ -330,7 +375,7 @@ class Command(BaseCommand):
deleted += 1
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()
return skipped, deleted
@@ -375,12 +420,12 @@ class Command(BaseCommand):
skipped += Notification.objects.filter(created__gte=self.cutoff).count()
return skipped, deleted
@transaction.atomic
def handle(self, *args, **options):
self.verbosity = int(options.get('verbosity', 1))
self.init_logging()
self.days = int(options.get('days', 90))
self.dry_run = bool(options.get('dry_run', False))
self.batch_size = int(options.get('batch_size', 100000))
try:
self.cutoff = now() - datetime.timedelta(days=self.days)
except OverflowError:
@@ -402,19 +447,29 @@ class Command(BaseCommand):
del s.receivers[:]
s.sender_receivers_cache.clear()
for m in model_names:
if m not in models_to_cleanup:
continue
with transaction.atomic():
for m in models_to_cleanup:
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 func:
skipped_partition, deleted_partition = func()
skipped += skipped_partition
deleted += deleted_partition
if self.dry_run:
self.logger.log(99, '%s: %d would be deleted, %d would be skipped.', m.replace('_', ' '), deleted, skipped)
else:
self.logger.log(99, '%s: %d deleted, %d skipped.', m.replace('_', ' '), deleted, skipped)
if self.dry_run:
self.logger.log(99, '%s: %d would be deleted, %d would be skipped.', m.replace('_', ' '), deleted, skipped)
else:
self.logger.log(99, '%s: %d deleted, %d skipped.', m.replace('_', ' '), deleted, skipped)
# Deleting unpartitioned tables cannot be done in same transaction as updates to related tables
if not self.dry_run:
with transaction.atomic():
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)

View File

@@ -125,14 +125,15 @@ class InstanceManager(models.Manager):
with advisory_lock('instance_registration_%s' % hostname):
if settings.AWX_AUTO_DEPROVISION_INSTANCES:
# detect any instances with the same IP address.
# if one exists, set it to None
inst_conflicting_ip = self.filter(ip_address=ip_address).exclude(hostname=hostname)
if inst_conflicting_ip.exists():
for other_inst in inst_conflicting_ip:
other_hostname = other_inst.hostname
other_inst.ip_address = None
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))
# if one exists, set it to ""
if ip_address:
inst_conflicting_ip = self.filter(ip_address=ip_address).exclude(hostname=hostname)
if inst_conflicting_ip.exists():
for other_inst in inst_conflicting_ip:
other_hostname = other_inst.hostname
other_inst.ip_address = ""
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)
if node_uuid is not None and node_uuid != UUID_DEFAULT and self.filter(uuid=node_uuid).exists():

View File

@@ -1439,6 +1439,11 @@ class UnifiedJob(
if not self.celery_task_id:
return
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:
# Use control and reply mechanism to cancel and obtain confirmation
timeout = 5

View File

@@ -124,6 +124,13 @@ class TaskBase:
self.record_aggregate_metrics()
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):
# Always be able to restore the original signal handler if we finish
original_sigusr1 = signal.getsignal(signal.SIGUSR1)
@@ -146,10 +153,14 @@ class TaskBase:
signal.signal(signal.SIGUSR1, original_sigusr1)
commit_start = time.time()
logger.debug(f"Commiting {self.prefix} Scheduler changes")
if self.prefix == "task_manager":
self.subsystem_metrics.set(f"{self.prefix}_commit_seconds", time.time() - commit_start)
local_metrics = self.get_local_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):
@@ -259,6 +270,9 @@ class WorkflowManager(TaskBase):
job.status = 'failed'
job.save(update_fields=['status', 'job_explanation'])
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()
# 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.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):
# 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
@@ -430,20 +463,6 @@ class TaskManager(TaskBase):
for dep in task.dependent_jobs.all():
if dep.status in ACTIVE_STATES:
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
@@ -463,7 +482,6 @@ class TaskManager(TaskBase):
if self.start_task_limit == 0:
# schedule another run immediately after this task manager
ScheduleTaskManager().schedule()
from awx.main.tasks.system import handle_work_error, handle_work_success
task.status = 'waiting'
@@ -474,7 +492,7 @@ class TaskManager(TaskBase):
task.job_explanation += ' '
task.job_explanation += 'Task failed pre-start check.'
task.save()
# TODO: run error handler to fail sub-tasks and send notifications
self.pre_start_failed.append(task.id)
else:
if type(task) is WorkflowJob:
task.status = 'running'
@@ -496,19 +514,16 @@ class TaskManager(TaskBase):
# 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
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.apply_async(
[task.pk],
opts,
queue=task.get_queue_name(),
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
# for jobs going into waiting, we omit this because of performance issues, as it should go to running quickly
# 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
if task.status != 'waiting':
task.websocket_emit_status(task.status) # adds to on_commit
@@ -529,6 +544,11 @@ class TaskManager(TaskBase):
if self.timed_out():
logger.warning("Task manager has reached time out while processing pending jobs, exiting loop early")
break
has_failed = self.process_job_dep_failures(task)
if has_failed:
continue
blocked_by = self.job_blocked_by(task)
if blocked_by:
self.subsystem_metrics.inc(f"{self.prefix}_tasks_blocked", 1)
@@ -642,6 +662,11 @@ class TaskManager(TaskBase):
reap_job(j, 'failed')
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']]
self.process_running_tasks(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.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):
if self.timed_out():
logger.warning("Task manager has reached time out while processing approval nodes, exiting loop early")

View File

@@ -74,6 +74,8 @@ from awx.main.utils.common import (
extract_ansible_vars,
get_awx_version,
create_partition,
ScheduleWorkflowManager,
ScheduleTaskManager,
)
from awx.conf.license import get_license
from awx.main.utils.handlers import SpecialInventoryHandler
@@ -450,6 +452,12 @@ class BaseTask(object):
instance.ansible_version = ansible_version_info
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):
return False
@@ -1873,6 +1881,8 @@ class RunSystemJob(BaseTask):
if system_job.job_type in ('cleanup_jobs', 'cleanup_activitystream'):
if 'days' in json_vars:
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']:
args.extend(['--dry-run'])
if system_job.job_type == 'cleanup_jobs':

View File

@@ -16,7 +16,9 @@ class SignalExit(Exception):
class SignalState:
def reset(self):
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_sigint = None
self.raise_exception = False
@@ -24,23 +26,36 @@ class SignalState:
def __init__(self):
self.reset()
def set_flag(self, *args):
"""Method to pass into the python signal.signal method to receive signals"""
self.sigterm_flag = True
def raise_if_needed(self):
if self.raise_exception:
self.raise_exception = False # so it is not raised a second time in error handling
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):
self.original_sigterm = signal.getsignal(signal.SIGTERM)
self.original_sigint = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGTERM, self.set_flag)
signal.signal(signal.SIGINT, self.set_flag)
signal.signal(signal.SIGTERM, self.set_sigterm_flag)
signal.signal(signal.SIGINT, self.set_sigint_flag)
self.is_active = True
def restore_signals(self):
signal.signal(signal.SIGTERM, self.original_sigterm)
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()
@@ -48,7 +63,7 @@ signal_state = SignalState()
def signal_callback():
return signal_state.sigterm_flag
return bool(signal_state.sigterm_flag or signal_state.sigint_flag)
def with_signal_handling(f):

View File

@@ -53,13 +53,7 @@ from awx.main.models import (
from awx.main.constants import ACTIVE_STATES
from awx.main.dispatch.publish import task
from awx.main.dispatch import get_task_queuename, reaper
from awx.main.utils.common import (
get_type_for_model,
ignore_inventory_computed_fields,
ignore_inventory_group_removal,
ScheduleWorkflowManager,
ScheduleTaskManager,
)
from awx.main.utils.common import ignore_inventory_computed_fields, ignore_inventory_group_removal
from awx.main.utils.reload import stop_local_services
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"))
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)
def handle_work_success(task_actual):
try:
instance = UnifiedJob.get_instance_by_type(task_actual['type'], task_actual['id'])
except ObjectDoesNotExist:
logger.warning('Missing {} `{}` in success callback.'.format(task_actual['type'], task_actual['id']))
return
if not instance:
return
schedule_manager_success_or_error(instance)
@task(queue=get_task_queuename)
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)
def handle_failure_notifications(task_ids):
"""A task-ified version of the method that sends notifications."""
found_task_ids = set()
for instance in UnifiedJob.objects.filter(id__in=task_ids):
found_task_ids.add(instance.id)
try:
instance.send_notification_templates('failed')
except Exception:
logger.exception(f'Error preparing notifications for task {instance.id}')
deleted_tasks = set(task_ids) - found_task_ids
if deleted_tasks:
logger.warning(f'Could not send notifications for {deleted_tasks} because they were not found in the database')
@task(queue=get_task_queuename)

View File

@@ -76,3 +76,24 @@ def test_hashivault_handle_auth_kubernetes():
def test_hashivault_handle_auth_not_enough_args():
with pytest.raises(Exception):
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'

View File

@@ -5,8 +5,8 @@ import tempfile
import shutil
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.models import Instance, Job, InventoryUpdate, ProjectUpdate
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files
from awx.main.models import Instance, Job
@pytest.fixture
@@ -73,17 +73,3 @@ def test_does_not_run_reaped_job(mocker, mock_me):
job.refresh_from_db()
assert job.status == 'failed'
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}"}}'

View File

@@ -47,7 +47,7 @@ data_loggly = {
'\n'.join(
[
'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(
[
'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(
[
'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(
[
'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(
[
'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(
[
'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(
[
'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(
[
'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(
[
'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(
[
'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
]
),
),

View File

@@ -1,8 +1,43 @@
import signal
import functools
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():
"""
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
def f1():
assert signal_callback() is False
signal_state.set_flag()
signal_state.set_sigterm_flag()
assert signal_callback()
f2()
original_sigterm = signal.getsignal(signal.SIGTERM)
assert signal_callback() is False
assert pytest_sigterm.called_count == 0
assert pytest_sigint.called_count == 0
f1()
assert signal_callback() is False
assert signal.getsignal(signal.SIGTERM) is original_sigterm
assert pytest_sigterm.called_count == 1
assert pytest_sigint.called_count == 0
@tmp_signals_for_test
def test_inner_outer_signal_handling():
"""
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
def f2():
assert signal_callback() is False
signal_state.set_flag()
signal_state.set_sigint_flag()
assert signal_callback()
@with_signal_handling
@@ -45,6 +85,10 @@ def test_inner_outer_signal_handling():
original_sigterm = signal.getsignal(signal.SIGTERM)
assert signal_callback() is False
assert pytest_sigterm.called_count == 0
assert pytest_sigint.called_count == 0
f1()
assert signal_callback() is False
assert signal.getsignal(signal.SIGTERM) is original_sigterm
assert pytest_sigterm.called_count == 0
assert pytest_sigint.called_count == 1

View File

@@ -143,13 +143,6 @@ def test_send_notifications_job_id(mocker):
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.Notification.objects.filter')
def test_send_notifications_list(mock_notifications_filter, mock_job_get, mocker):

View File

@@ -23,7 +23,7 @@ from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _
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_descriptors import ForwardManyToOneDescriptor, ManyToManyDescriptor
from django.db.models.query import QuerySet
@@ -1164,13 +1164,24 @@ def create_partition(tblname, start=None):
try:
with transaction.atomic():
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(
f'CREATE TABLE IF NOT EXISTS {tblname}_{partition_label} '
f'PARTITION OF {tblname} '
f'FOR VALUES FROM (\'{start_timestamp}\') to (\'{end_timestamp}\');'
f'CREATE TABLE {tblname}_{partition_label} (LIKE {tblname} INCLUDING DEFAULTS INCLUDING CONSTRAINTS); '
f'ALTER TABLE {tblname} ATTACH PARTITION {tblname}_{partition_label} '
f'FOR VALUES FROM (\'{start_timestamp}\') TO (\'{end_timestamp}\');'
)
except ProgrammingError as e:
logger.debug(f'Caught known error due to existing partition: {e}')
except (ProgrammingError, IntegrityError) as 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):

View File

@@ -17,11 +17,26 @@ def construct_rsyslog_conf_template(settings=settings):
port = getattr(settings, 'LOG_AGGREGATOR_PORT', '')
protocol = getattr(settings, 'LOG_AGGREGATOR_PROTOCOL', '')
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)
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', '')
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):
spool_directory = '/var/lib/awx'
@@ -33,7 +48,6 @@ def construct_rsyslog_conf_template(settings=settings):
'$WorkDirectory /var/lib/awx/rsyslog',
f'$MaxMessageSize {max_bytes}',
'$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")',
'input(type="imuxsock" Socket="' + settings.LOGGING['handlers']['external_logger']['address'] + '" unlink="on" RateLimit.Burst="0")',
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
@@ -79,12 +93,7 @@ def construct_rsyslog_conf_template(settings=settings):
'action.resumeRetryCount="-1"',
'template="awx"',
f'action.resumeInterval="{timeout}"',
f'queue.spoolDirectory="{spool_directory}"',
'queue.filename="awx-external-logger-action-queue"',
f'queue.maxdiskspace="{max_disk_space_action_queue}g"',
'queue.type="LinkedList"',
'queue.saveOnShutdown="on"',
]
] + queue_options
if error_log_file:
params.append(f'errorfile="{error_log_file}"')
if parsed.path:
@@ -112,9 +121,18 @@ def construct_rsyslog_conf_template(settings=settings):
params = ' '.join(params)
parts.extend(['module(load="omhttp")', f'action({params})'])
elif protocol and host and port:
parts.append(
f'action(type="omfwd" target="{host}" port="{port}" protocol="{protocol}" action.resumeRetryCount="-1" action.resumeInterval="{timeout}" template="awx")' # noqa
)
params = [
'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:
parts.append('action(type="omfile" file="/dev/null")') # rsyslog needs *at least* one valid action to start
tmpl = '\n'.join(parts)

View File

@@ -3,6 +3,8 @@ import logging
import asyncio
from typing import Dict
import ipaddress
import aiohttp
from aiohttp import client_exceptions
import aioredis
@@ -71,7 +73,16 @@ class WebsocketRelayConnection:
if not self.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)
secret_val = WebsocketSecretAuthHelper.construct_secret()

View File

@@ -796,7 +796,7 @@ LOG_AGGREGATOR_ENABLED = False
LOG_AGGREGATOR_TCP_TIMEOUT = 5
LOG_AGGREGATOR_VERIFY_CERT = True
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_MAX_DISK_USAGE_PATH = '/var/lib/awx'
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False

View File

@@ -33,6 +33,7 @@ import Roles from './models/Roles';
import Root from './models/Root';
import Schedules from './models/Schedules';
import Settings from './models/Settings';
import SubscriptionUsage from './models/SubscriptionUsage';
import SystemJobs from './models/SystemJobs';
import SystemJobTemplates from './models/SystemJobTemplates';
import Teams from './models/Teams';
@@ -82,6 +83,7 @@ const RolesAPI = new Roles();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SettingsAPI = new Settings();
const SubscriptionUsageAPI = new SubscriptionUsage();
const SystemJobsAPI = new SystemJobs();
const SystemJobTemplatesAPI = new SystemJobTemplates();
const TeamsAPI = new Teams();
@@ -132,6 +134,7 @@ export {
RootAPI,
SchedulesAPI,
SettingsAPI,
SubscriptionUsageAPI,
SystemJobsAPI,
SystemJobTemplatesAPI,
TeamsAPI,

View 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;

View File

@@ -75,6 +75,7 @@ function SessionProvider({ children }) {
const [sessionCountdown, setSessionCountdown] = useState(0);
const [authRedirectTo, setAuthRedirectTo] = useState('/');
const [isUserBeingLoggedOut, setIsUserBeingLoggedOut] = useState(false);
const [isRedirectLinkReceived, setIsRedirectLinkReceived] = useState(false);
const {
request: fetchLoginRedirectOverride,
@@ -99,6 +100,7 @@ function SessionProvider({ children }) {
const logout = useCallback(async () => {
setIsUserBeingLoggedOut(true);
setIsRedirectLinkReceived(false);
if (!isSessionExpired.current) {
setAuthRedirectTo('/logout');
window.localStorage.setItem(SESSION_USER_ID, null);
@@ -112,6 +114,18 @@ function SessionProvider({ children }) {
return <Redirect to="/login" />;
}, [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(() => {
if (!isAuthenticated(document.cookie)) {
return () => {};
@@ -176,6 +190,8 @@ function SessionProvider({ children }) {
logout,
sessionCountdown,
setAuthRedirectTo,
isRedirectLinkReceived,
setIsRedirectLinkReceived,
}),
[
authRedirectTo,
@@ -186,6 +202,8 @@ function SessionProvider({ children }) {
logout,
sessionCountdown,
setAuthRedirectTo,
isRedirectLinkReceived,
setIsRedirectLinkReceived,
]
);

View File

@@ -17,6 +17,7 @@ import Organizations from 'screens/Organization';
import Projects from 'screens/Project';
import Schedules from 'screens/Schedule';
import Settings from 'screens/Setting';
import SubscriptionUsage from 'screens/SubscriptionUsage/SubscriptionUsage';
import Teams from 'screens/Team';
import Templates from 'screens/Template';
import TopologyView from 'screens/TopologyView';
@@ -61,6 +62,11 @@ function getRouteConfig(userProfile = {}) {
path: '/host_metrics',
screen: HostMetrics,
},
{
title: <Trans>Subscription Usage</Trans>,
path: '/subscription_usage',
screen: SubscriptionUsage,
},
],
},
{
@@ -189,6 +195,7 @@ function getRouteConfig(userProfile = {}) {
'unique_managed_hosts'
) {
deleteRoute('host_metrics');
deleteRoute('subscription_usage');
}
if (userProfile?.isSuperUser || userProfile?.isSystemAuditor)
return routeConfig;
@@ -197,6 +204,7 @@ function getRouteConfig(userProfile = {}) {
deleteRoute('management_jobs');
deleteRoute('topology_view');
deleteRoute('instances');
deleteRoute('subscription_usage');
if (userProfile?.isOrgAdmin) return routeConfig;
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');

View File

@@ -31,6 +31,7 @@ describe('getRouteConfig', () => {
'/activity_stream',
'/workflow_approvals',
'/host_metrics',
'/subscription_usage',
'/templates',
'/credentials',
'/projects',
@@ -61,6 +62,7 @@ describe('getRouteConfig', () => {
'/activity_stream',
'/workflow_approvals',
'/host_metrics',
'/subscription_usage',
'/templates',
'/credentials',
'/projects',

View File

@@ -302,9 +302,9 @@ function HostsByProcessorTypeExample() {
const hostsByProcessorLimit = `intel_hosts`;
const hostsByProcessorSourceVars = `plugin: constructed
strict: true
groups:
intel_hosts: "GenuineIntel" in ansible_processor`;
strict: true
groups:
intel_hosts: "'GenuineIntel' in ansible_processor"`;
return (
<FormFieldGroupExpandable

View File

@@ -45,7 +45,7 @@ describe('<ConstructedInventoryHint />', () => {
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining(
'intel_hosts: "GenuineIntel" in ansible_processor'
`intel_hosts: \"'GenuineIntel' in ansible_processor\"`
)
);
});

View File

@@ -45,7 +45,8 @@ const Login = styled(PFLogin)`
function AWXLogin({ alt, isAuthenticated }) {
const [userId, setUserId] = useState(null);
const { authRedirectTo, isSessionExpired } = useSession();
const { authRedirectTo, isSessionExpired, isRedirectLinkReceived } =
useSession();
const isNewUser = useRef(true);
const hasVerifiedUser = useRef(false);
@@ -179,7 +180,8 @@ function AWXLogin({ alt, isAuthenticated }) {
return <LoadingSpinner />;
}
if (userId && hasVerifiedUser.current) {
const redirect = isNewUser.current ? '/home' : authRedirectTo;
const redirect =
isNewUser.current && !isRedirectLinkReceived ? '/home' : authRedirectTo;
return <Redirect to={redirect} />;
}

View File

@@ -29,7 +29,7 @@ SettingsAPI.readCategory.mockResolvedValue({
LOG_AGGREGATOR_TCP_TIMEOUT: 5,
LOG_AGGREGATOR_VERIFY_CERT: true,
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_MAX_DISK_USAGE_PATH: '/var/lib/awx',
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,

View File

@@ -31,7 +31,7 @@ const mockSettings = {
LOG_AGGREGATOR_TCP_TIMEOUT: 123,
LOG_AGGREGATOR_VERIFY_CERT: true,
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_MAX_DISK_USAGE_PATH: '/var/lib/awx',
LOG_AGGREGATOR_RSYSLOGD_DEBUG: false,

View File

@@ -659,21 +659,21 @@
]
]
},
"LOG_AGGREGATOR_MAX_DISK_USAGE_GB": {
"LOG_AGGREGATOR_ACTION_QUEUE_SIZE": {
"type": "integer",
"required": false,
"label": "Maximum disk persistence for external log aggregation (in GB)",
"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).",
"label": "Maximum number of messages that can be stored in the log action queue",
"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,
"category": "Logging",
"category_slug": "logging",
"default": 1
"default": 131072
},
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": {
"type": "integer",
"required": false,
"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,
"category": "Logging",
"category_slug": "logging",
@@ -5016,10 +5016,10 @@
]
]
},
"LOG_AGGREGATOR_MAX_DISK_USAGE_GB": {
"LOG_AGGREGATOR_ACTION_QUEUE_SIZE": {
"type": "integer",
"label": "Maximum disk persistence for external log aggregation (in GB)",
"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).",
"label": "Maximum number of messages that can be stored in the log action queue",
"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,
"category": "Logging",
"category_slug": "logging",
@@ -5028,7 +5028,7 @@
"LOG_AGGREGATOR_ACTION_MAX_DISK_USAGE_GB": {
"type": "integer",
"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,
"category": "Logging",
"category_slug": "logging",

View File

@@ -70,7 +70,7 @@
"LOG_AGGREGATOR_TCP_TIMEOUT": 5,
"LOG_AGGREGATOR_VERIFY_CERT": true,
"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_MAX_DISK_USAGE_PATH": "/var/lib/awx",
"LOG_AGGREGATOR_RSYSLOGD_DEBUG": false,
@@ -548,4 +548,4 @@
"adj_list": []
}
}
}
}

View File

@@ -15,7 +15,7 @@
"LOG_AGGREGATOR_TCP_TIMEOUT": 5,
"LOG_AGGREGATOR_VERIFY_CERT": true,
"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_MAX_DISK_USAGE_PATH": "/var/lib/awx",
"LOG_AGGREGATOR_RSYSLOGD_DEBUG": false,

View File

@@ -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);

View File

@@ -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;

View 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;

View 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;

View File

@@ -2,16 +2,9 @@ export default function getDocsBaseUrl(config) {
let version = 'latest';
const licenseType = config?.license_info?.license_type;
if (licenseType && licenseType !== 'open') {
if (config?.version) {
if (parseFloat(config?.version.split('-')[0]) >= 4.3) {
version = parseFloat(config?.version.split('-')[0]);
} else {
version = config?.version.split('-')[0];
}
}
} else {
version = 'latest';
if (licenseType && licenseType !== 'open' && config?.version) {
version = parseFloat(config?.version.split('-')[0]).toFixed(1);
}
return `https://docs.ansible.com/automation-controller/${version}`;
}

View File

@@ -6,7 +6,7 @@ describe('getDocsBaseUrl', () => {
license_info: {
license_type: 'open',
},
version: '18.0.0',
version: '18.4.4',
});
expect(result).toEqual(
@@ -19,11 +19,11 @@ describe('getDocsBaseUrl', () => {
license_info: {
license_type: 'enterprise',
},
version: '4.0.0',
version: '18.4.4',
});
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_type: 'enterprise',
},
version: '4.0.0-beta',
version: '7.0.0-beta',
});
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', () => {
const result = getDocsBaseUrl({
version: '18.0.0',
version: '18.4.4',
});
expect(result).toEqual(

View File

@@ -980,6 +980,15 @@ class ControllerAPIModule(ControllerModule):
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
):
# 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:
return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations)
else:

View File

@@ -118,6 +118,7 @@ status:
'''
from ..module_utils.controller_api import ControllerAPIModule
import json
def main():
@@ -161,7 +162,11 @@ def main():
}
for arg in ['job_type', 'limit', 'forks', 'verbosity', 'extra_vars', 'become_enabled', 'diff_mode']:
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)
post_data['inventory'] = module.resolve_name_to_id('inventories', inventory)

View File

@@ -33,7 +33,6 @@ options:
image:
description:
- The fully qualified url of the container image.
required: True
type: str
description:
description:
@@ -79,7 +78,7 @@ def main():
argument_spec = dict(
name=dict(required=True),
new_name=dict(),
image=dict(required=True),
image=dict(),
description=dict(),
organization=dict(),
credential=dict(),

View File

@@ -115,7 +115,7 @@ EXAMPLES = '''
- name: Export a job template named "My Template" and all Credentials
export:
job_templates: "My Template"
credential: 'all'
credentials: 'all'
- name: Export a list of inventories
export:

View File

@@ -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
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 = {}
if credentials is not None:
@@ -280,9 +300,9 @@ def main():
for item in credentials:
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
if 'name' in search_fields:
del search_fields['name']
# 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 'organization' in search_fields:
del search_fields['organization']
if labels is not None:
association_fields['labels'] = []
@@ -302,8 +322,6 @@ def main():
else:
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:
new_fields['rrule'] = rrule
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:
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
module.create_or_update_if_needed(
existing_item,

View File

@@ -89,7 +89,7 @@ def coerce_type(module, value):
if not HAS_YAML:
module.fail_json(msg="yaml is not installed, try 'pip install pyyaml'")
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()]
try:
return int(value)

View File

@@ -72,6 +72,21 @@
- "result is changed"
- "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
ad_hoc_command:
inventory: "Demo Inventory"

View File

@@ -108,8 +108,9 @@
- assert:
that:
- wait_results is successful
- 'wait_results.status == "successful"'
- 'wait_results.status in ["successful", "canceled"]'
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
credential:

View File

@@ -42,6 +42,16 @@
that:
- "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
host:
name: "{{ host_name }}"
@@ -68,6 +78,15 @@
that:
- "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
host:
name: "{{ result.id }}"

View File

@@ -76,6 +76,15 @@
that:
- 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
schedule:
name: "{{ sched1 }}"
@@ -225,6 +234,7 @@
schedule:
name: "{{ sched2 }}"
state: present
organization: Default
unified_job_template: "{{ jt1 }}"
rrule: "DTSTART:20191219T130551Z RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1"
description: "This hopefully will work"

View File

@@ -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
settings:
name: AWX_ISOLATION_SHOW_PATHS

View File

@@ -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/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

View File

@@ -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
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
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
@@ -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.
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.
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.

View File

@@ -45,7 +45,7 @@ of the awx-operator repo. If not, continue to the next section.
```
# in awx-operator repo on the branch you want to use
$ export IMAGE_TAG_BASE=quay.io/<username>/awx-operator
$ export VERSION=<cusom-tag>
$ export VERSION=<custom-tag>
$ make docker-build
$ docker push ${IMAGE_TAG_BASE}:${VERSION}
```
@@ -118,7 +118,7 @@ To access via the web browser, run the following command:
$ minikube service awx-service --url
```
To retreive your admin password
To retrieve your admin password
```
$ kubectl get secrets awx-admin-password -o json | jq '.data.password' | xargs | base64 -d
```

View File

@@ -0,0 +1,7 @@
# This requirements file is used for AWX latest doc builds.
sphinx # Tooling to build HTML from RST source.
sphinx-ansible-theme # Ansible community theme for Sphinx doc builds.
docutils # Tooling for RST processing and the swagger extension.
Jinja2 # Requires investiation. Possibly inherited from previous repo with a custom theme.
PyYaml # Requires investigation. Possibly used as tooling for swagger API reference content.

View File

@@ -1,5 +1,74 @@
sphinx==5.1.1
sphinx-ansible-theme==0.9.1
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --allow-unsafe --output-file=docs/docsite/requirements.txt --strip-extras docs/docsite/requirements.in
#
alabaster==0.7.13
# via sphinx
ansible-pygments==0.1.1
# via sphinx-ansible-theme
babel==2.12.1
# via sphinx
certifi==2023.7.22
# via requests
charset-normalizer==3.2.0
# via requests
docutils==0.16
Jinja2<3.1
PyYaml
# via
# -r docs/docsite/requirements.in
# sphinx
# sphinx-rtd-theme
idna==3.4
# via requests
imagesize==1.4.1
# via sphinx
jinja2==3.0.3
# via
# -r docs/docsite/requirements.in
# sphinx
markupsafe==2.1.3
# via jinja2
packaging==23.1
# via sphinx
pygments==2.16.1
# via
# ansible-pygments
# sphinx
pyyaml==6.0.1
# via -r docs/docsite/requirements.in
requests==2.31.0
# via sphinx
snowballstemmer==2.2.0
# via sphinx
sphinx==5.1.1
# via
# -r docs/docsite/requirements.in
# sphinx-ansible-theme
# sphinx-rtd-theme
# sphinxcontrib-applehelp
# sphinxcontrib-devhelp
# sphinxcontrib-htmlhelp
# sphinxcontrib-jquery
# sphinxcontrib-qthelp
# sphinxcontrib-serializinghtml
sphinx-ansible-theme==0.9.1
# via -r docs/docsite/requirements.in
sphinx-rtd-theme==1.3.0
# via sphinx-ansible-theme
sphinxcontrib-applehelp==1.0.7
# via sphinx
sphinxcontrib-devhelp==1.0.5
# via sphinx
sphinxcontrib-htmlhelp==2.0.4
# via sphinx
sphinxcontrib-jquery==4.1
# via sphinx-rtd-theme
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-qthelp==1.0.6
# via sphinx
sphinxcontrib-serializinghtml==1.1.9
# via sphinx
urllib3==2.0.4
# via requests

View File

@@ -23,6 +23,7 @@ The default length of time, in seconds, that your supplied token is valid can be
4. Enter the timeout period in seconds in the **Idle Time Force Log Out** text field.
.. image:: ../common/images/configure-awx-system-timeout.png
:alt: Miscellaneous Authentication settings showing the Idle Time Force Logout field where you can adjust the token validity period.
4. Click **Save** to apply your changes.

View File

@@ -1,4 +1,3 @@
.. _ag_clustering:
Clustering
@@ -11,7 +10,7 @@ Clustering
Clustering is sharing load between hosts. Each instance should be able to act as an entry point for UI and API access. This should enable AWX administrators to use load balancers in front of as many instances as they wish and maintain good data visibility.
.. note::
Load balancing is optional and is entirely possible to have ingress on one or all instances as needed. The ``CSRF_TRUSTED_ORIGIN`` setting may be required if you are using AWX behind a load balancer. See :ref:`ki_csrf_trusted_origin_setting` for more detail.
Load balancing is optional and is entirely possible to have ingress on one or all instances as needed. The ``CSRF_TRUSTED_ORIGIN`` setting may be required if you are using AWX behind a load balancer. See :ref:`ki_csrf_trusted_origin_setting` for more detail.
Each instance should be able to join AWX cluster and expand its ability to execute jobs. This is a simple system where jobs can and will run anywhere rather than be directed on where to run. Also, clustered instances can be grouped into different pools/queues, called :ref:`ag_instance_groups`.
@@ -107,61 +106,61 @@ Example of customization could be:
::
---
spec:
...
node_selector: |
disktype: ssd
kubernetes.io/arch: amd64
kubernetes.io/os: linux
topology_spread_constraints: |
- maxSkew: 100
topologyKey: "topology.kubernetes.io/zone"
whenUnsatisfiable: "ScheduleAnyway"
labelSelector:
matchLabels:
app.kubernetes.io/name: "<resourcename>"
tolerations: |
- key: "dedicated"
operator: "Equal"
value: "AWX"
effect: "NoSchedule"
task_tolerations: |
- key: "dedicated"
operator: "Equal"
value: "AWX_task"
effect: "NoSchedule"
postgres_selector: |
disktype: ssd
kubernetes.io/arch: amd64
kubernetes.io/os: linux
postgres_tolerations: |
- key: "dedicated"
operator: "Equal"
value: "AWX"
effect: "NoSchedule"
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: another-node-label-key
operator: In
values:
- another-node-label-value
- another-node-label-value
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: security
operator: In
values:
- S2
topologyKey: topology.kubernetes.io/zone
---
spec:
...
node_selector: |
disktype: ssd
kubernetes.io/arch: amd64
kubernetes.io/os: linux
topology_spread_constraints: |
- maxSkew: 100
topologyKey: "topology.kubernetes.io/zone"
whenUnsatisfiable: "ScheduleAnyway"
labelSelector:
matchLabels:
app.kubernetes.io/name: "<resourcename>"
tolerations: |
- key: "dedicated"
operator: "Equal"
value: "AWX"
effect: "NoSchedule"
task_tolerations: |
- key: "dedicated"
operator: "Equal"
value: "AWX_task"
effect: "NoSchedule"
postgres_selector: |
disktype: ssd
kubernetes.io/arch: amd64
kubernetes.io/os: linux
postgres_tolerations: |
- key: "dedicated"
operator: "Equal"
value: "AWX"
effect: "NoSchedule"
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: another-node-label-key
operator: In
values:
- another-node-label-value
- another-node-label-value
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: security
operator: In
values:
- S2
topologyKey: topology.kubernetes.io/zone
Status and Monitoring via Browser API
@@ -204,6 +203,7 @@ The way jobs are run and reported to a 'normal' user of AWX does not change. On
- When a job is submitted from the API interface it gets pushed into the dispatcher queue. Each AWX instance will connect to and receive jobs from that queue using a particular scheduling algorithm. Any instance in the cluster is just as likely to receive the work and execute the task. If a instance fails while executing jobs, then the work is marked as permanently failed.
.. image:: ../common/images/clustering-visual.png
:alt: An illustration depicting job distribution in an AWX cluster.
- Project updates run successfully on any instance that could potentially run a job. Projects will sync themselves to the correct version on the instance immediately prior to running the job. If the needed revision is already locally checked out and Galaxy or Collections updates are not needed, then a sync may not be performed.
@@ -218,5 +218,3 @@ Job Runs
By default, when a job is submitted to the AWX queue, it can be picked up by any of the workers. However, you can control where a particular job runs, such as restricting the instances from which a job runs on.
In order to support temporarily taking an instance offline, there is a property enabled defined on each instance. When this property is disabled, no jobs will be assigned to that instance. Existing jobs will finish, but no new work will be assigned.

View File

@@ -11,6 +11,7 @@ AWX Configuration
You can configure various AWX settings within the Settings screen in the following tabs:
.. image:: ../common/images/ug-settings-menu-screen.png
:alt: Screenshot of the AWX settings menu screen.
Each tab contains fields with a **Reset** button, allowing you to revert any value entered back to the default value. **Reset All** allows you to revert all the values to their factory default values.
@@ -47,6 +48,7 @@ The Jobs tab allows you to configure the types of modules that are allowed to be
The values for all the timeouts are in seconds.
.. image:: ../common/images/configure-awx-jobs.png
:alt: Screenshot of the AWX job configuration settings.
3. Click **Save** to apply the settings or **Cancel** to abandon the changes.
@@ -69,6 +71,7 @@ The System tab allows you to define the base URL for the AWX host, configure ale
- **Logging settings**: configure logging options based on the type you choose:
.. image:: ../common/images/configure-awx-system-logging-types.png
:alt: Logging settings shown with the list of options for Logging Aggregator Types.
For more information about each of the logging aggregation types, refer to the :ref:`ag_logging` section of the |ata|.
@@ -78,6 +81,7 @@ The System tab allows you to define the base URL for the AWX host, configure ale
.. |help| image:: ../common/images/tooltips-icon.png
.. image:: ../common/images/configure-awx-system.png
:alt: Miscellaneous System settings window showing all possible configurable options.
.. note::

View File

@@ -140,6 +140,7 @@ The Instance Groups list view from the |at| User Interface provides a summary of
|Instance Group policy example|
.. |Instance Group policy example| image:: ../common/images/instance-groups_list_view.png
:alt: Instance Group list view with example instance group and container groups.
See :ref:`ug_instance_groups_create` for further detail.
@@ -231,6 +232,7 @@ Likewise, an administrator could assign multiple groups to each organization as
|Instance Group example|
.. |Instance Group example| image:: ../common/images/instance-groups-scenarios.png
:alt: Illustration showing grouping scenarios.
Arranging resources in this way offers a lot of flexibility. Also, you can create instance groups with only one instance, thus allowing you to direct work towards a very specific Host in the AWX cluster.
@@ -327,6 +329,7 @@ To create a container group:
|IG - create new CG|
.. |IG - create new CG| image:: ../common/images/instance-group-create-new-cg.png
:alt: Create new container group form.
4. Enter a name for your new container group and select the credential previously created to associate it to the container group.
@@ -342,10 +345,12 @@ To customize the Pod spec, specify the namespace in the **Pod Spec Override** fi
|IG - CG customize pod|
.. |IG - CG customize pod| image:: ../common/images/instance-group-customize-cg-pod.png
:alt: Create new container group form with the option to custom the pod spec.
You may provide additional customizations, if needed. Click **Expand** to view the entire customization window.
.. image:: ../common/images/instance-group-customize-cg-pod-expanded.png
:alt: The expanded view for customizing the pod spec.
.. note::
@@ -354,10 +359,12 @@ You may provide additional customizations, if needed. Click **Expand** to view t
Once the container group is successfully created, the **Details** tab of the newly created container group remains, which allows you to review and edit your container group information. This is the same menu that is opened if the Edit (|edit-button|) button is clicked from the **Instance Group** link. You can also edit **Instances** and review **Jobs** associated with this instance group.
.. |edit-button| image:: ../common/images/edit-button.png
:alt: Edit button.
|IG - example CG successfully created|
.. |IG - example CG successfully created| image:: ../common/images/instance-group-example-cg-successfully-created.png
:alt: Example of the successfully created instance group as shown in the Jobs tab of the Instance groups window.
Container groups and instance groups are labeled accordingly.
@@ -375,6 +382,7 @@ To verify the deployment and termination of your container:
|Dummy inventory|
.. |Dummy inventory| image:: ../common/images/inventories-create-new-cg-test-inventory.png
:alt: Example of creating a new container group test inventory.
2. Create "localhost" host in inventory with variables:
@@ -385,6 +393,7 @@ To verify the deployment and termination of your container:
|Inventory with localhost|
.. |Inventory with localhost| image:: ../common/images/inventories-create-new-cg-test-localhost.png
:alt: The new container group test inventory showing the populated variables.
3. Launch an ad hoc job against the localhost using the *ping* or *setup* module. Even though the **Machine Credential** field is required, it does not matter which one is selected for this simple test.
@@ -393,13 +402,14 @@ To verify the deployment and termination of your container:
.. |Launch inventory with localhost| image:: ../common/images/inventories-launch-adhoc-cg-test-localhost.png
.. image:: ../common/images/inventories-launch-adhoc-cg-test-localhost2.png
:alt: Launching a Ping adhoc command on the newly created inventory with localhost.
You can see in the jobs detail view the container was reached successfully using one of ad hoc jobs.
|Inventory with localhost ping success|
.. |Inventory with localhost ping success| image:: ../common/images/inventories-launch-adhoc-cg-test-localhost-success.png
:alt: Jobs output view showing a successfully ran adhoc job.
If you have an OpenShift UI, you can see Pods appear and disappear as they deploy and terminate. Alternatively, you can use the CLI to perform a ``get pod`` operation on your namespace to watch these same events occurring in real-time.
@@ -412,6 +422,7 @@ When you run a job associated with a container group, you can see the details of
|IG - instances jobs|
.. |IG - instances jobs| image:: ../common/images/instance-group-job-details-with-cgs.png
:alt: Example Job details window showing the associated execution environment and container group.
Kubernetes API failure conditions

View File

@@ -55,6 +55,7 @@ To set up enterprise authentication for Microsoft Azure Active Directory (AD), y
8. To verify that the authentication was configured correctly, logout of AWX and the login screen will now display the Microsoft Azure logo to allow logging in with those credentials.
.. image:: ../common/images/configure-awx-auth-azure-logo.png
:alt: AWX login screen displaying the Microsoft Azure logo for authentication.
For application registering basics in Azure AD, refer to the `Azure AD Identity Platform (v2)`_ overview.
@@ -102,6 +103,7 @@ SAML settings
SAML allows the exchange of authentication and authorization data between an Identity Provider (IdP - a system of servers that provide the Single Sign On service) and a Service Provider (in this case, AWX). AWX can be configured to talk with SAML in order to authenticate (create/login/logout) AWX users. User Team and Organization membership can be embedded in the SAML response to AWX.
.. image:: ../common/images/configure-awx-auth-saml-topology.png
:alt: Diagram depicting SAML topology for AWX.
The following instructions describe AWX as the service provider.
@@ -122,6 +124,7 @@ To setup SAML authentication:
In this example, the Service Provider is the AWX cluster, and therefore, the ID is set to the AWX Cluster FQDN.
.. image:: ../common/images/configure-awx-auth-saml-spentityid.png
:alt: Configuring SAML Service Provider Entity ID in AWX.
5. Create a server certificate for the Ansible cluster. Typically when an Ansible cluster is configured, AWX nodes will be configured to handle HTTP traffic only and the load balancer will be an SSL Termination Point. In this case, an SSL certificate is required for the load balancer, and not for the individual AWX Cluster Nodes. SSL can either be enabled or disabled per individual AWX node, but should be disabled when using an SSL terminated load balancer. It is recommended to use a non-expiring self signed certificate to avoid periodically updating certificates. This way, authentication will not fail in case someone forgets to update the certificate.
@@ -132,6 +135,7 @@ In this example, the Service Provider is the AWX cluster, and therefore, the ID
If you are using a CA bundle with your certificate, include the entire bundle in this field.
.. image:: ../common/images/configure-awx-auth-saml-cert.png
:alt: Configuring SAML Service Provider Public Certificate in AWX.
As an example for public certs:
@@ -167,6 +171,7 @@ As an example for private keys:
For example:
.. image:: ../common/images/configure-awx-auth-saml-org-info.png
:alt: Configuring SAML Organization information in AWX.
.. note::
These fields are required in order to properly configure SAML within AWX.
@@ -183,6 +188,7 @@ For example:
For example:
.. image:: ../common/images/configure-awx-auth-saml-techcontact-info.png
:alt: Configuring SAML Technical Contact information in AWX.
9. Provide the IdP with the support contact information in the **SAML Service Provider Support Contact** field. Do not remove the contents of this field.
@@ -196,6 +202,7 @@ For example:
For example:
.. image:: ../common/images/configure-awx-auth-saml-suppcontact-info.png
:alt: Configuring SAML Support Contact information in AWX.
10. In the **SAML Enabled Identity Providers** field, provide information on how to connect to each Identity Provider listed. AWX expects the following SAML attributes in the example below:
@@ -238,6 +245,7 @@ Configure the required keys for each IDp:
}
.. image:: ../common/images/configure-awx-auth-saml-idps.png
:alt: Configuring SAML Identity Providers (IdPs) in AWX.
.. warning::
@@ -249,6 +257,7 @@ Configure the required keys for each IDp:
The IdP provides the email, last name and firstname using the well known SAML urn. The IdP uses a custom SAML attribute to identify a user, which is an attribute that AWX is unable to read. Instead, AWX can understand the unique identifier name, which is the URN. Use the URN listed in the SAML “Name” attribute for the user attributes as shown in the example below.
.. image:: ../common/images/configure-awx-auth-saml-idps-urn.png
:alt: Configuring SAML Identity Providers (IdPs) in AWX using URNs.
11. Optionally provide the **SAML Organization Map**. For further detail, see :ref:`ag_org_team_maps`.
@@ -479,6 +488,7 @@ Example::
Alternatively, logout of AWX and the login screen will now display the SAML logo to indicate it as a alternate method of logging into AWX.
.. image:: ../common/images/configure-awx-auth-saml-logo.png
:alt: AWX login screen displaying the SAML logo for authentication.
Transparent SAML Logins
@@ -495,6 +505,7 @@ For transparent logins to work, you must first get IdP-initiated logins to work.
2. Once this is working, specify the redirect URL for non-logged-in users to somewhere other than the default AWX login page by using the **Login redirect override URL** field in the Miscellaneous Authentication settings window of the **Settings** menu, accessible from the left navigation bar. This should be set to ``/sso/login/saml/?idp=<name-of-your-idp>`` for transparent SAML login, as shown in the example.
.. image:: ../common/images/configure-awx-system-login-redirect-url.png
:alt: Configuring the login redirect URL in AWX Miscellaneous Authentication Settings.
.. note::
@@ -537,6 +548,7 @@ Terminal Access Controller Access-Control System Plus (TACACS+) is a protocol th
- **TACACS+ Authentication Protocol**: The protocol used by TACACS+ client. Options are **ascii** or **pap**.
.. image:: ../common/images/configure-awx-auth-tacacs.png
:alt: TACACS+ configuration details in AWX settings.
4. Click **Save** when done.
@@ -563,6 +575,7 @@ To configure OIDC in AWX:
The example below shows specific values associated to GitHub as the generic IdP:
.. image:: ../common/images/configure-awx-auth-oidc.png
:alt: OpenID Connect (OIDC) configuration details in AWX settings.
4. Click **Save** when done.
@@ -574,4 +587,4 @@ The example below shows specific values associated to GitHub as the generic IdP:
5. To verify that the authentication was configured correctly, logout of AWX and the login screen will now display the OIDC logo to indicate it as a alternate method of logging into AWX.
.. image:: ../common/images/configure-awx-auth-oidc-logo.png
:alt: AWX login screen displaying the OpenID Connect (OIDC) logo for authentication.

View File

@@ -1,10 +1,22 @@
.. _ag_start:
==================
AWX Administration
==================
=============================
Administering AWX Deployments
=============================
Learn how to administer AWX deployments through custom scripts, management jobs, and DevOps workflows.
This guide assumes at least basic understanding of the systems that you manage and maintain with AWX.
This guide applies to the latest version of AWX only.
The content in this guide is updated frequently and might contain functionality that is not available in previous versions.
Likewise content in this guide can be removed or replaced if it applies to functionality that is no longer available in the latest version.
**Join us online**
We talk about AWX documentation on Matrix at `#docs:ansible.im <https://matrix.to/#/#docs:ansible.im>`_ and on libera IRC at ``#ansible-docs`` if you ever want to join us and chat about the docs!
You can also find lots of AWX discussion and get answers to questions at `forum.ansible.com <https://forum.ansible.com/>`_.
AWX Administration
.. toctree::
:maxdepth: 2

View File

@@ -1,4 +1,3 @@
.. _ag_instances:
Managing Capacity With Instances
@@ -58,6 +57,7 @@ Manage instances
Click **Instances** from the left side navigation menu to access the Instances list.
.. image:: ../common/images/instances_list_view.png
:alt: List view of instances in AWX
The Instances list displays all the current nodes in your topology, along with relevant details:
@@ -83,6 +83,7 @@ The Instances list displays all the current nodes in your topology, along with r
From this page, you can add, remove or run health checks on your nodes. Use the check boxes next to an instance to select it to remove or run a health check against. When a button is grayed-out, you do not have permission for that particular action. Contact your Administrator to grant you the required level of access. If you are able to remove an instance, you will receive a prompt for confirmation, like the one below:
.. image:: ../common/images/instances_delete_prompt.png
:alt: Prompt for deleting instances in AWX.
.. note::
@@ -95,6 +96,7 @@ Click **Remove** to confirm.
If running a health check on an instance, at the top of the Details page, a message displays that the health check is in progress.
.. image:: ../common/images/instances_health_check.png
:alt: Health check for instances in AWX
Click **Reload** to refresh the instance status.
@@ -103,10 +105,12 @@ Click **Reload** to refresh the instance status.
Health checks are ran asynchronously, and may take up to a minute for the instance status to update, even with a refresh. The status may or may not change after the health check. At the bottom of the Details page, a timer/clock icon displays next to the last known health check date and time stamp if the health check task is currently running.
.. image:: ../common/images/instances_health_check_pending.png
:alt: Health check for instance still in pending state.
The example health check shows the status updates with an error on node 'one':
.. image:: ../common/images/topology-viewer-instance-with-errors.png
:alt: Health check showing an error in one of the instances.
Add an instance
@@ -119,6 +123,7 @@ One of the ways to expand capacity is to create an instance, which serves as a n
2. In the Instances list view, click the **Add** button and the Create new Instance window opens.
.. image:: ../common/images/instances_create_new.png
:alt: Create a new instance form.
An instance has several attributes that may be configured:
@@ -134,6 +139,7 @@ An instance has several attributes that may be configured:
Upon successful creation, the Details of the created instance opens.
.. image:: ../common/images/instances_create_details.png
:alt: Details of the newly created instance.
.. note::
@@ -142,6 +148,7 @@ Upon successful creation, the Details of the created instance opens.
4. Click the download button next to the **Install Bundle** field to download the tarball that includes this new instance and the files relevant to install the node into the mesh.
.. image:: ../common/images/instances_install_bundle.png
:alt: Instance details showing the Download button in the Install Bundle field of the Details tab.
5. Extract the downloaded ``tar.gz`` file from the location you downloaded it. The install bundle contains yaml files, certificates, and keys that will be used in the installation process.
@@ -179,6 +186,7 @@ The content of the ``inventory.yml`` file serves as a template and contains vari
.. image:: ../common/images/instances_peers_tab.png
:alt: "Peers" tab showing two peers.
You may run a health check by selecting the node and clicking the **Run health check** button from its Details page.

View File

@@ -26,7 +26,7 @@ Vertical scaling improvements
.. index::
pair: improvements; scaling
Control nodes are responsible for processing the output of jobs and writing them to the database. The process that does this is called the callback receiver. The callback receiver has a configurable number of workers, controlled by the setting ``JOB_EVENT_WORKERS``. In the past, the default for this setting was always 4, regardless of the CPU or memory capacity of the node. Now, in traditional virtual machines, the ``JOB_EVENT_WORKERS`` will be set to the same as the number of CPU if that is greater than 4. This means administrators that provision larger control nodes will see greater ability for those nodes to keep up with the job output created by jobs without having to manually adjust ``JOB_EVENT_WORKERS``.
Control nodes are responsible for processing the output of jobs and writing them to the database. The process that does this is called the callback receiver. The callback receiver has a configurable number of workers, controlled by the setting ``JOB_EVENT_WORKERS``. In the past, the default for this setting was always 4, regardless of the CPU or memory capacity of the node. Now, in traditional virtual machines, the ``JOB_EVENT_WORKERS`` will be set to the same as the number of CPU if that is greater than 4. This means administrators that provision larger control nodes will see greater ability for those nodes to keep up with the job output created by jobs without having to manually adjust ``JOB_EVENT_WORKERS``.
Job scheduling improvements
@@ -34,9 +34,9 @@ Job scheduling improvements
.. index::
pair: improvements; scheduling
When jobs are created either via a schedule, a workflow, the UI or the API, they are first created in Pending state. To determine when and where to run this job, a background task called the Task Manager collects all pending and running jobs and determines where capacity is available to run the job. In previous versions of AWX, scheduling slowed as the number of pending and running jobs increased, and the Task Manager was vulnerable to timing out without having made any progress. The scenario exhibits symptoms of having thousands of pending jobs, available capacity, but no jobs starting.
When jobs are created either via a schedule, a workflow, the UI or the API, they are first created in Pending state. To determine when and where to run this job, a background task called the Task Manager collects all pending and running jobs and determines where capacity is available to run the job. In previous versions of AWX, scheduling slowed as the number of pending and running jobs increased, and the Task Manager was vulnerable to timing out without having made any progress. The scenario exhibits symptoms of having thousands of pending jobs, available capacity, but no jobs starting.
Optimizations in the job scheduler have made scheduling faster, as well as safeguards to better ensure the scheduler commits its progress even if it is nearing time out. Additionally, work that previously occurred in the Task Manager that blocked its progress has been decoupled into separate, non-blocking work units executed by the Dispatcher.
Optimizations in the job scheduler have made scheduling faster, as well as safeguards to better ensure the scheduler commits its progress even if it is nearing time out. Additionally, work that previously occurred in the Task Manager that blocked its progress has been decoupled into separate, non-blocking work units executed by the Dispatcher.
Database resource usage improvements
@@ -47,7 +47,7 @@ Database resource usage improvements
The use of database connections by running jobs has dramatically decreased, which removes a previous limit to concurrent running jobs, as well reduces pressure on memory consumption of PostgreSQL.
Each job in AWX has a worker process, called the dispatch worker, on the control node that started the process, which submits the work to the execution node via the Receptor, as well as consumes the output of the job and puts it in the Redis queue for the callback receiver to serialize the output and write it to the database as job events.
Each job in AWX has a worker process, called the dispatch worker, on the control node that started the process, which submits the work to the execution node via the Receptor, as well as consumes the output of the job and puts it in the Redis queue for the callback receiver to serialize the output and write it to the database as job events.
The dispatch worker is also responsible for noticing if the job has been canceled by the user in order to then cancel the receptor work unit. In the past, the worker maintained multiple open database connections per job. This caused two main problems:
@@ -98,7 +98,7 @@ Capacity Planning
Example capacity planning exercise
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. index::
pair: exercise; capacity planning
pair: exercise; capacity planning
Determining the number and size of instances to support the desired workload must take into account the following:
@@ -183,13 +183,13 @@ Control nodes
^^^^^^^^^^^^^^
Vertically scaling a control node increases the number of jobs it can perform control tasks for, which requires both more CPU and memory. In general, scaling CPU alongside memory in the same proportion is recommended (e.g. 1 CPU: 4GB RAM). Even in the case where memory consumption is observed to be high, increasing the CPU of an instance can often relieve pressure, as most memory consumption of control nodes is usually from unprocessed events.
As mentioned in the :ref:`ag_performance_improvements` section, increasing the number of CPU can also increase the job event processing rate of a control node. At this time, vertically scaling a control node does not increase the number of workers that handle web requests, so horizontally scaling is more effective, if the desire is to increase the API availability.
As mentioned in the :ref:`ag_performance_improvements` section, increasing the number of CPU can also increase the job event processing rate of a control node. At this time, vertically scaling a control node does not increase the number of workers that handle web requests, so horizontally scaling is more effective, if the desire is to increase the API availability.
Execution Nodes
^^^^^^^^^^^^^^^^
Vertical scaling an execution node will provide more forks for job execution. As mentioned in the example, a host with 16 GB of memory will by default, be assigned the capacity to run 137 “forks”, which at the default setting of 5 forks/job, will be able to run around 22 jobs concurrently. In general, scaling CPU alongside memory in the same proportion is recommended. Like control and hybrid nodes, there is a “capacity adjustment” on each execution instance that can be used to align actual utilization with the estimation of capacity consumption AWX makes. By default, all nodes are set to the top range of the capacity AWX estimates the node to have. If actual monitoring data reveals the node to be over-utilized, decreasing the capacity adjustment can help bring this in line with actual usage.
Vertically scaling execution will do exactly what the user expects and increase the number of concurrent jobs an instance can run. One downside is that concurrently running jobs on the same execution node, while isolated from each other in the sense that they cannot access the others data, can impact the other's performance, if a particular job is very resource-consumptive and overwhelms the node to the extent that it degrades performance of the entire node. Horizontal scaling the execution plane (e.g deploying more execution nodes) can provide some additional isolation of workloads, as well as allowing administrators to assign different instances to different instance groups, which can then be assigned to Organizations, Inventories, or Job Templates. This can enable something like an instance group that can only be used for running jobs against a “production” Inventory, this way jobs for development do not end up eating up capacity and causing higher priority jobs to queue waiting for capacity.
Vertically scaling execution will do exactly what the user expects and increase the number of concurrent jobs an instance can run. One downside is that concurrently running jobs on the same execution node, while isolated from each other in the sense that they cannot access the others data, can impact the other's performance, if a particular job is very resource-consumptive and overwhelms the node to the extent that it degrades performance of the entire node. Horizontal scaling the execution plane (e.g deploying more execution nodes) can provide some additional isolation of workloads, as well as allowing administrators to assign different instances to different instance groups, which can then be assigned to Organizations, Inventories, or Job Templates. This can enable something like an instance group that can only be used for running jobs against a “production” Inventory, this way jobs for development do not end up eating up capacity and causing higher priority jobs to queue waiting for capacity.
Hop Nodes
@@ -198,7 +198,7 @@ Hop nodes have very low memory and CPU utilization and there is no significant m
Hybrid nodes
^^^^^^^^^^^^^
Hybrid nodes perform both execution and control tasks, so vertically scaling these nodes both increases the number of jobs they can run, and now in 4.3.0, how many events they can process.
Hybrid nodes perform both execution and control tasks, so vertically scaling these nodes both increases the number of jobs they can run, and now in 4.3.0, how many events they can process.
Capacity planning for Operator based Deployments
@@ -240,23 +240,23 @@ The following are configurable settings in the database that may help improve pe
- ``work_mem`` (integer)
- ``maintenance_work_mem`` (integer)
All of these parameters reside under the ``postgresql.conf`` file (inside ``$PDATA`` directory), which manages the configurations of the database server.
All of these parameters reside under the ``postgresql.conf`` file (inside ``$PDATA`` directory), which manages the configurations of the database server.
The **shared_buffers** parameter determines how much memory is dedicated to the server for caching data. Set in ``postgresql.conf``, the default value for this parameter is::
#sharedPostgres_buffers = 128MB
The value should be set at 15%-25% of the machines total RAM. For example: if your machines RAM size is 32 GB, then the recommended value for ``shared_buffers`` is 8 GB. Please note that the database server needs to be restarted after this change.
The **work_mem** parameter basically provides the amount of memory to be used by internal sort operations and hash tables before writing to temporary disk files. Sort operations are used for order by, distinct, and merge join operations. Hash tables are used in hash joins and hash based aggregation. Set in ``postgresql.conf``, the default value for this parameter is::
#work_mem = 4MB
Setting the correct value of ``work_mem`` parameter can result in less disk-swapping, and therefore far quicker queries.
Setting the correct value of ``work_mem`` parameter can result in less disk-swapping, and therefore far quicker queries.
We can use the formula below to calculate the optimal ``work_mem`` value for the database server::
Total RAM * 0.25 / max_connections
Total RAM * 0.25 / max_connections
The ``max_connections`` parameter is one of the GUC parameters to specify the maximum number of concurrent connections to the database server. Please note setting a large ``work_mem`` can cause issues like PostgreSQL server going out of memory (OOM), if there are too many open connections to the database.
@@ -264,10 +264,40 @@ The **maintenance_work_mem** parameter basically provides the maximum amount of
#maintenance_work_mem = 64MB
It is recommended to set this value higher than ``work_mem``; this can improve performance for vacuuming. In general, it should calculated as::
It is recommended to set this value higher than ``work_mem``; this can improve performance for vacuuming. In general, it should calculated as::
Total RAM * 0.05
Max Connections
~~~~~~~~~~~~~~~~~~~~~
For a realistic method of determining a value of ``max_connections``, a ballpark formula for AWX is outlined here.
Database connections will scale with the number of control and hybrid nodes.
Per-node connection needs are listed here.
* Callback Receiver workers: 4 connections per node or the number of CPUs per node, whichever is larger
* Dispatcher Workers: instance (forks) capacity plus 7
* uWSGI workers: 16 connections per node
* Listeners and auxiliary services: 4 connections per node
* Reserve for installer and other actions: 5 connections in total
Each of these points represent maximum expected connection use in high-load circumstances.
To apply this, consider a cluster with 3 hybrid nodes, each with 8 CPUs and 16 GB of RAM.
The capacity formula will determine a capacity of 132 forks per node based on the memory and capacity formula.
(3 nodes) x (
(8 CPUs / node) x (1 connection / CPU) +
(132 forks / node) x (1 connection / fork) + (7 connections / node) +
(16 connections / node) +
(4 connections / node)
) + (5 connections)
Adding up all the components comes out to 506 for this example cluster.
Practically, this means that the max_connections should be set to something higher than this.
Additional connections should be added to account for other platform components.
This calculation is most sensitive to the number of forks per node. Database connections are briefly opened at the start of and end of jobs. Environments where bursts of many jobs start at once will be most likely to reach the theoretical max number of open database connections.
The max number of jobs that would be started concurrently can be adjusted by modifying the effective capacity of the instances. This can be done with the SYSTEM_TASK_ABS_MEM setting, the capacity adjustment on instances, or with instance groups max jobs or max forks.
AWX Settings
~~~~~~~~~~~~~~~~~~~~~
@@ -332,7 +362,7 @@ Task Manager (Job Scheduling) Settings
pair: settings; job scheduling
The task manager is a periodic task that collects tasks that need to be scheduled and determines what instances have capacity and are eligible for running them. Its job is to find and assign the control and execution instances, update the jobs status to waiting, and send the message to the control node via ``pg_notify`` for the dispatcher to pick up the task and start running it.
As mentioned in the :ref:`ag_performance_improvements` section, a number of optimizations and refactors of this process were implemented in version 4.3. One such refactor was to fix a defect that when the task manager did reach its timeout, it was terminated in such a way that it did not make any progress. Multiple changes were implemented to fix this, so that as the task manager approaches its timeout, it makes an effort to exit and commit any progress made on that run. These issues generally arise when there are thousands of pending jobs, so may not be applicable to your use case.
The first “short-circuit” available to limit how much work the task manager attempts to do in one run is ``START_TASK_LIMIT``. The default is 100 jobs, which is a safe default. If there are remaining jobs to schedule, a new run of the task manager will be scheduled to run immediately after the current run. Users who are willing to risk potentially longer individual runs of the task manager in order to start more jobs in individual run may consider increasing the ``START_TASK_LIMIT``. One metric, the Prometheus metrics, available in ``/api/v2/metrics`` observes how long individual runs of the task manager take is “task_manager__schedule_seconds”.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 47 KiB

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