mirror of
https://github.com/ansible/awx.git
synced 2026-03-31 15:55:07 -02:30
Compare commits
1 Commits
daoneill-i
...
stale-acti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8369c43123 |
10
.github/actions/awx_devel_image/action.yml
vendored
10
.github/actions/awx_devel_image/action.yml
vendored
@@ -11,12 +11,6 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
|
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set lower case owner name
|
|
||||||
shell: bash
|
|
||||||
run: echo "OWNER_LC=${OWNER,,}" >> $GITHUB_ENV
|
|
||||||
env:
|
|
||||||
OWNER: '${{ github.repository_owner }}'
|
|
||||||
|
|
||||||
- name: Log in to registry
|
- name: Log in to registry
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -24,11 +18,11 @@ runs:
|
|||||||
|
|
||||||
- name: Pre-pull latest devel image to warm cache
|
- name: Pre-pull latest devel image to warm cache
|
||||||
shell: bash
|
shell: bash
|
||||||
run: docker pull ghcr.io/${OWNER_LC}/awx_devel:${{ github.base_ref }}
|
run: docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ github.base_ref }}
|
||||||
|
|
||||||
- name: Build image for current source checkout
|
- name: Build image for current source checkout
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER_LC} \
|
DEV_DOCKER_TAG_BASE=ghcr.io/${{ github.repository_owner }} \
|
||||||
COMPOSE_TAG=${{ github.base_ref }} \
|
COMPOSE_TAG=${{ github.base_ref }} \
|
||||||
make docker-compose-build
|
make docker-compose-build
|
||||||
|
|||||||
40
.github/actions/issue_metrics/issue_metrics.yml
vendored
40
.github/actions/issue_metrics/issue_metrics.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: Monthly issue metrics
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: '3 2 1 * *'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: issue metrics
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Get dates for last month
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Calculate the first day of the previous month
|
|
||||||
first_day=$(date -d "last month" +%Y-%m-01)
|
|
||||||
|
|
||||||
# Calculate the last day of the previous month
|
|
||||||
last_day=$(date -d "$first_day +1 month -1 day" +%Y-%m-%d)
|
|
||||||
|
|
||||||
#Set an environment variable with the date range
|
|
||||||
echo "$first_day..$last_day"
|
|
||||||
echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Run issue-metrics tool
|
|
||||||
uses: github/issue-metrics@v2
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
SEARCH_QUERY: 'repo:ansible/awx is:issue created:${{ env.last_month }} -reason:"not planned"'
|
|
||||||
|
|
||||||
- name: Create issue
|
|
||||||
uses: peter-evans/create-issue-from-file@v4
|
|
||||||
with:
|
|
||||||
title: Monthly issue metrics report
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
content-filepath: ./issue_metrics.md
|
|
||||||
2
.github/actions/run_awx_devel/action.yml
vendored
2
.github/actions/run_awx_devel/action.yml
vendored
@@ -35,7 +35,7 @@ runs:
|
|||||||
- name: Start AWX
|
- name: Start AWX
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
DEV_DOCKER_OWNER=${{ github.repository_owner }} \
|
DEV_DOCKER_TAG_BASE=ghcr.io/${{ github.repository_owner }} \
|
||||||
COMPOSE_TAG=${{ github.base_ref }} \
|
COMPOSE_TAG=${{ github.base_ref }} \
|
||||||
COMPOSE_UP_OPTS="-d" \
|
COMPOSE_UP_OPTS="-d" \
|
||||||
make docker-compose
|
make docker-compose
|
||||||
|
|||||||
31
.github/actions/stale/stale.yml
vendored
Normal file
31
.github/actions/stale/stale.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: 'Close stale issues and PRs'
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '30 1 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
|
||||||
|
debug-only: true
|
||||||
|
operations-per-run: 30
|
||||||
|
|
||||||
|
days-before-stale: 30
|
||||||
|
days-before-close: 5
|
||||||
|
|
||||||
|
days-before-issue-stale: 180
|
||||||
|
days-before-issue-close: 14
|
||||||
|
stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||||
|
close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.'
|
||||||
|
exempt-issue-labels: 'needs_triage'
|
||||||
|
stale-issue-label: 'no_issue_activity'
|
||||||
|
|
||||||
|
days-before-pr-stale: 90
|
||||||
|
days-before-pr-close: 14
|
||||||
|
stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.'
|
||||||
|
close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.'
|
||||||
|
exempt-pr-labels: 'needs_triage'
|
||||||
|
stale-pr-label: 'no_pr_activity'
|
||||||
3
.github/pr_labeler.yml
vendored
3
.github/pr_labeler.yml
vendored
@@ -15,4 +15,5 @@
|
|||||||
|
|
||||||
"dependencies":
|
"dependencies":
|
||||||
- any: ["awx/ui/package.json"]
|
- any: ["awx/ui/package.json"]
|
||||||
- any: ["requirements/*"]
|
- any: ["requirements/*.txt"]
|
||||||
|
- any: ["requirements/requirements.in"]
|
||||||
|
|||||||
65
.github/workflows/devel_images.yml
vendored
65
.github/workflows/devel_images.yml
vendored
@@ -9,43 +9,22 @@ on:
|
|||||||
- release_*
|
- release_*
|
||||||
- feature_*
|
- feature_*
|
||||||
jobs:
|
jobs:
|
||||||
push-development-images:
|
push:
|
||||||
|
if: endsWith(github.repository, '/awx') || startsWith(github.ref, 'refs/heads/release_')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 120
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
build-targets:
|
|
||||||
- image-name: awx_devel
|
|
||||||
make-target: docker-compose-buildx
|
|
||||||
- image-name: awx_kube_devel
|
|
||||||
make-target: awx-kube-dev-buildx
|
|
||||||
- image-name: awx
|
|
||||||
make-target: awx-kube-buildx
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Skipping build of awx image for non-awx repository
|
|
||||||
run: |
|
|
||||||
echo "Skipping build of awx image for non-awx repository"
|
|
||||||
exit 0
|
|
||||||
if: matrix.build-targets.image-name == 'awx' && !endsWith(github.repository, '/awx')
|
|
||||||
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Get python version from Makefile
|
||||||
uses: docker/setup-qemu-action@v3
|
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set lower case owner name
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Set GITHUB_ENV variables
|
|
||||||
run: |
|
run: |
|
||||||
echo "DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER,,}" >> $GITHUB_ENV
|
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||||
echo "COMPOSE_TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV
|
|
||||||
echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
|
|
||||||
env:
|
env:
|
||||||
OWNER: '${{ github.repository_owner }}'
|
OWNER: '${{ github.repository_owner }}'
|
||||||
|
|
||||||
@@ -58,19 +37,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
- name: Setup node and npm
|
- name: Pre-pull image to warm build cache
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: '16.13.1'
|
|
||||||
if: matrix.build-targets.image-name == 'awx'
|
|
||||||
|
|
||||||
- name: Prebuild UI for awx image (to speed up build process)
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get install gettext
|
docker pull ghcr.io/${OWNER_LC}/awx_devel:${GITHUB_REF##*/} || :
|
||||||
make ui-release
|
docker pull ghcr.io/${OWNER_LC}/awx_kube_devel:${GITHUB_REF##*/} || :
|
||||||
make ui-next
|
docker pull ghcr.io/${OWNER_LC}/awx:${GITHUB_REF##*/} || :
|
||||||
if: matrix.build-targets.image-name == 'awx'
|
|
||||||
|
|
||||||
- name: Build and push AWX devel images
|
- name: Build images
|
||||||
run: |
|
run: |
|
||||||
make ${{ matrix.build-targets.make-target }}
|
DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER_LC} COMPOSE_TAG=${GITHUB_REF##*/} make docker-compose-build
|
||||||
|
DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER_LC} COMPOSE_TAG=${GITHUB_REF##*/} make awx-kube-dev-build
|
||||||
|
DEV_DOCKER_TAG_BASE=ghcr.io/${OWNER_LC} COMPOSE_TAG=${GITHUB_REF##*/} make awx-kube-build
|
||||||
|
|
||||||
|
- name: Push development images
|
||||||
|
run: |
|
||||||
|
docker push ghcr.io/${OWNER_LC}/awx_devel:${GITHUB_REF##*/}
|
||||||
|
docker push ghcr.io/${OWNER_LC}/awx_kube_devel:${GITHUB_REF##*/}
|
||||||
|
|
||||||
|
- name: Push AWX k8s image, only for upstream and feature branches
|
||||||
|
run: docker push ghcr.io/${OWNER_LC}/awx:${GITHUB_REF##*/}
|
||||||
|
if: endsWith(github.repository, '/awx')
|
||||||
|
|||||||
12
.github/workflows/feature_branch_deletion.yml
vendored
12
.github/workflows/feature_branch_deletion.yml
vendored
@@ -2,10 +2,12 @@
|
|||||||
name: Feature branch deletion cleanup
|
name: Feature branch deletion cleanup
|
||||||
env:
|
env:
|
||||||
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
|
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
|
||||||
on: delete
|
on:
|
||||||
|
delete:
|
||||||
|
branches:
|
||||||
|
- feature_**
|
||||||
jobs:
|
jobs:
|
||||||
branch_delete:
|
push:
|
||||||
if: ${{ github.event.ref_type == 'branch' && startsWith(github.event.ref, 'feature_') }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
@@ -20,4 +22,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}"
|
ansible localhost -c local, -m command -a "{{ ansible_python_interpreter + ' -m pip install boto3'}}"
|
||||||
ansible localhost -c local -m aws_s3 \
|
ansible localhost -c local -m aws_s3 \
|
||||||
-a "bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=delobj permission=public-read"
|
-a "bucket=awx-public-ci-files object=${GITHUB_REF##*/}/schema.json mode=delete permission=public-read"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
28
.github/workflows/stage.yml
vendored
28
.github/workflows/stage.yml
vendored
@@ -86,19 +86,13 @@ jobs:
|
|||||||
-e push=yes \
|
-e push=yes \
|
||||||
-e awx_official=yes
|
-e awx_official=yes
|
||||||
|
|
||||||
- name: Log into registry ghcr.io
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
run: |
|
||||||
with:
|
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Log into registry quay.io
|
- name: Log in to Quay
|
||||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
run: |
|
||||||
with:
|
echo ${{ secrets.QUAY_TOKEN }} | docker login quay.io -u ${{ secrets.QUAY_USER }} --password-stdin
|
||||||
registry: quay.io
|
|
||||||
username: ${{ secrets.QUAY_USER }}
|
|
||||||
password: ${{ secrets.QUAY_TOKEN }}
|
|
||||||
|
|
||||||
- name: tag awx-ee:latest with version input
|
- name: tag awx-ee:latest with version input
|
||||||
run: |
|
run: |
|
||||||
@@ -106,13 +100,13 @@ jobs:
|
|||||||
docker tag quay.io/ansible/awx-ee:latest ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
|
docker tag quay.io/ansible/awx-ee:latest ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
|
||||||
docker push ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
|
docker push ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
|
||||||
|
|
||||||
- name: Stage awx-operator image
|
- name: Build and stage awx-operator
|
||||||
working-directory: awx-operator
|
working-directory: awx-operator
|
||||||
run: |
|
run: |
|
||||||
BUILD_ARGS="--build-arg DEFAULT_AWX_VERSION=${{ github.event.inputs.version}} \
|
BUILD_ARGS="--build-arg DEFAULT_AWX_VERSION=${{ github.event.inputs.version }} \
|
||||||
--build-arg OPERATOR_VERSION=${{ github.event.inputs.operator_version }}" \
|
--build-arg OPERATOR_VERSION=${{ github.event.inputs.operator_version }}" \
|
||||||
IMG=ghcr.io/${{ github.repository_owner }}/awx-operator:${{ github.event.inputs.operator_version }} \
|
IMAGE_TAG_BASE=ghcr.io/${{ github.repository_owner }}/awx-operator \
|
||||||
make docker-buildx
|
VERSION=${{ github.event.inputs.operator_version }} make docker-build docker-push
|
||||||
|
|
||||||
- name: Run test deployment with awx-operator
|
- name: Run test deployment with awx-operator
|
||||||
working-directory: awx-operator
|
working-directory: awx-operator
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -169,6 +169,3 @@ awx/ui_next/build
|
|||||||
# Docs build stuff
|
# Docs build stuff
|
||||||
docs/docsite/build/
|
docs/docsite/build/
|
||||||
_readthedocs/
|
_readthedocs/
|
||||||
|
|
||||||
# Pyenv
|
|
||||||
.python-version
|
|
||||||
|
|||||||
54
Makefile
54
Makefile
@@ -10,7 +10,7 @@ KIND_BIN ?= $(shell which kind)
|
|||||||
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
||||||
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
MANAGEMENT_COMMAND ?= awx-manage
|
MANAGEMENT_COMMAND ?= awx-manage
|
||||||
VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py 2> /dev/null)
|
VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py)
|
||||||
|
|
||||||
# ansible-test requires semver compatable version, so we allow overrides to hack it
|
# ansible-test requires semver compatable version, so we allow overrides to hack it
|
||||||
COLLECTION_VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
|
COLLECTION_VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
|
||||||
@@ -75,9 +75,6 @@ SDIST_TAR_FILE ?= $(SDIST_TAR_NAME).tar.gz
|
|||||||
|
|
||||||
I18N_FLAG_FILE = .i18n_built
|
I18N_FLAG_FILE = .i18n_built
|
||||||
|
|
||||||
## PLATFORMS defines the target platforms for the manager image be build to provide support to multiple
|
|
||||||
PLATFORMS ?= linux/amd64,linux/arm64 # linux/ppc64le,linux/s390x
|
|
||||||
|
|
||||||
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
|
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
|
||||||
develop refresh adduser migrate dbchange \
|
develop refresh adduser migrate dbchange \
|
||||||
receiver test test_unit test_coverage coverage_html \
|
receiver test test_unit test_coverage coverage_html \
|
||||||
@@ -535,14 +532,13 @@ docker-compose-sources: .git/hooks/pre-commit
|
|||||||
-e enable_vault=$(VAULT) \
|
-e enable_vault=$(VAULT) \
|
||||||
-e vault_tls=$(VAULT_TLS) \
|
-e vault_tls=$(VAULT_TLS) \
|
||||||
-e enable_tacacs=$(TACACS) \
|
-e enable_tacacs=$(TACACS) \
|
||||||
$(EXTRA_SOURCES_ANSIBLE_OPTS)
|
$(EXTRA_SOURCES_ANSIBLE_OPTS)
|
||||||
|
|
||||||
docker-compose: awx/projects docker-compose-sources
|
docker-compose: awx/projects docker-compose-sources
|
||||||
ansible-galaxy install --ignore-certs -r tools/docker-compose/ansible/requirements.yml;
|
ansible-galaxy install --ignore-certs -r tools/docker-compose/ansible/requirements.yml;
|
||||||
ansible-playbook -i tools/docker-compose/inventory tools/docker-compose/ansible/initialize_containers.yml \
|
ansible-playbook -i tools/docker-compose/inventory tools/docker-compose/ansible/initialize_containers.yml \
|
||||||
-e enable_vault=$(VAULT) \
|
-e enable_vault=$(VAULT) \
|
||||||
-e vault_tls=$(VAULT_TLS) \
|
-e vault_tls=$(VAULT_TLS);
|
||||||
-e enable_ldap=$(LDAP);
|
|
||||||
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) up $(COMPOSE_UP_OPTS) --remove-orphans
|
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) up $(COMPOSE_UP_OPTS) --remove-orphans
|
||||||
|
|
||||||
docker-compose-credential-plugins: awx/projects docker-compose-sources
|
docker-compose-credential-plugins: awx/projects docker-compose-sources
|
||||||
@@ -589,27 +585,12 @@ docker-compose-build: Dockerfile.dev
|
|||||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
||||||
|
|
||||||
|
|
||||||
.PHONY: docker-compose-buildx
|
|
||||||
## Build awx_devel image for docker compose development environment for multiple architectures
|
|
||||||
docker-compose-buildx: Dockerfile.dev
|
|
||||||
- docker buildx create --name docker-compose-buildx
|
|
||||||
docker buildx use docker-compose-buildx
|
|
||||||
- docker buildx build \
|
|
||||||
--push \
|
|
||||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
|
||||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) \
|
|
||||||
--platform=$(PLATFORMS) \
|
|
||||||
--tag $(DEVEL_IMAGE_NAME) \
|
|
||||||
-f Dockerfile.dev .
|
|
||||||
- docker buildx rm docker-compose-buildx
|
|
||||||
|
|
||||||
docker-clean:
|
docker-clean:
|
||||||
-$(foreach container_id,$(shell docker ps -f name=tools_awx -aq && docker ps -f name=tools_receptor -aq),docker stop $(container_id); docker rm -f $(container_id);)
|
-$(foreach container_id,$(shell docker ps -f name=tools_awx -aq && docker ps -f name=tools_receptor -aq),docker stop $(container_id); docker rm -f $(container_id);)
|
||||||
-$(foreach image_id,$(shell docker images --filter=reference='*/*/*awx_devel*' --filter=reference='*/*awx_devel*' --filter=reference='*awx_devel*' -aq),docker rmi --force $(image_id);)
|
-$(foreach image_id,$(shell docker images --filter=reference='*/*/*awx_devel*' --filter=reference='*/*awx_devel*' --filter=reference='*awx_devel*' -aq),docker rmi --force $(image_id);)
|
||||||
|
|
||||||
docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean
|
docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean
|
||||||
docker volume rm -f tools_awx_db tools_vault_1 tools_ldap_1 tools_grafana_storage tools_prometheus_storage $(docker volume ls --filter name=tools_redis_socket_ -q)
|
docker volume rm -f tools_awx_db tools_vault_1 tools_grafana_storage tools_prometheus_storage $(docker volume ls --filter name=tools_redis_socket_ -q)
|
||||||
|
|
||||||
docker-refresh: docker-clean docker-compose
|
docker-refresh: docker-clean docker-compose
|
||||||
|
|
||||||
@@ -666,21 +647,6 @@ awx-kube-build: Dockerfile
|
|||||||
--build-arg HEADLESS=$(HEADLESS) \
|
--build-arg HEADLESS=$(HEADLESS) \
|
||||||
-t $(DEV_DOCKER_TAG_BASE)/awx:$(COMPOSE_TAG) .
|
-t $(DEV_DOCKER_TAG_BASE)/awx:$(COMPOSE_TAG) .
|
||||||
|
|
||||||
## Build multi-arch awx image for deployment on Kubernetes environment.
|
|
||||||
awx-kube-buildx: Dockerfile
|
|
||||||
- docker buildx create --name awx-kube-buildx
|
|
||||||
docker buildx use awx-kube-buildx
|
|
||||||
- docker buildx build \
|
|
||||||
--push \
|
|
||||||
--build-arg VERSION=$(VERSION) \
|
|
||||||
--build-arg SETUPTOOLS_SCM_PRETEND_VERSION=$(VERSION) \
|
|
||||||
--build-arg HEADLESS=$(HEADLESS) \
|
|
||||||
--platform=$(PLATFORMS) \
|
|
||||||
--tag $(DEV_DOCKER_TAG_BASE)/awx:$(COMPOSE_TAG) \
|
|
||||||
-f Dockerfile .
|
|
||||||
- docker buildx rm awx-kube-buildx
|
|
||||||
|
|
||||||
|
|
||||||
.PHONY: Dockerfile.kube-dev
|
.PHONY: Dockerfile.kube-dev
|
||||||
## Generate Docker.kube-dev for awx_kube_devel image
|
## Generate Docker.kube-dev for awx_kube_devel image
|
||||||
Dockerfile.kube-dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
|
Dockerfile.kube-dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
|
||||||
@@ -697,18 +663,6 @@ awx-kube-dev-build: Dockerfile.kube-dev
|
|||||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) \
|
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) \
|
||||||
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
||||||
|
|
||||||
## Build and push multi-arch awx_kube_devel image for development on local Kubernetes environment.
|
|
||||||
awx-kube-dev-buildx: Dockerfile.kube-dev
|
|
||||||
- docker buildx create --name awx-kube-dev-buildx
|
|
||||||
docker buildx use awx-kube-dev-buildx
|
|
||||||
- docker buildx build \
|
|
||||||
--push \
|
|
||||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
|
||||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) \
|
|
||||||
--platform=$(PLATFORMS) \
|
|
||||||
--tag $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) \
|
|
||||||
-f Dockerfile.kube-dev .
|
|
||||||
- docker buildx rm awx-kube-dev-buildx
|
|
||||||
|
|
||||||
kind-dev-load: awx-kube-dev-build
|
kind-dev-load: awx-kube-dev-build
|
||||||
$(KIND_BIN) load docker-image $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG)
|
$(KIND_BIN) load docker-image $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections import Counter, OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -82,7 +82,6 @@ from awx.main.models import (
|
|||||||
Project,
|
Project,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
ProjectUpdateEvent,
|
ProjectUpdateEvent,
|
||||||
ReceptorAddress,
|
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
Role,
|
Role,
|
||||||
Schedule,
|
Schedule,
|
||||||
@@ -637,7 +636,7 @@ class BaseSerializer(serializers.ModelSerializer, metaclass=BaseSerializerMetacl
|
|||||||
exclusions = self.get_validation_exclusions(self.instance)
|
exclusions = self.get_validation_exclusions(self.instance)
|
||||||
obj = self.instance or self.Meta.model()
|
obj = self.instance or self.Meta.model()
|
||||||
for k, v in attrs.items():
|
for k, v in attrs.items():
|
||||||
if k not in exclusions and k != 'canonical_address_port':
|
if k not in exclusions:
|
||||||
setattr(obj, k, v)
|
setattr(obj, k, v)
|
||||||
obj.full_clean(exclude=exclusions)
|
obj.full_clean(exclude=exclusions)
|
||||||
# full_clean may modify values on the instance; copy those changes
|
# full_clean may modify values on the instance; copy those changes
|
||||||
@@ -5177,21 +5176,16 @@ class NotificationTemplateSerializer(BaseSerializer):
|
|||||||
body = messages[event].get('body', {})
|
body = messages[event].get('body', {})
|
||||||
if body:
|
if body:
|
||||||
try:
|
try:
|
||||||
sandbox.ImmutableSandboxedEnvironment(undefined=DescriptiveUndefined).from_string(body).render(JobNotificationMixin.context_stub())
|
rendered_body = (
|
||||||
|
sandbox.ImmutableSandboxedEnvironment(undefined=DescriptiveUndefined).from_string(body).render(JobNotificationMixin.context_stub())
|
||||||
# https://github.com/ansible/awx/issues/14410
|
)
|
||||||
|
potential_body = json.loads(rendered_body)
|
||||||
# When rendering something such as "{{ job.id }}"
|
if not isinstance(potential_body, dict):
|
||||||
# the return type is not a dict, unlike "{{ job_metadata }}" which is a dict
|
error_list.append(
|
||||||
|
_("Webhook body for '{}' should be a json dictionary. Found type '{}'.".format(event, type(potential_body).__name__))
|
||||||
# potential_body = json.loads(rendered_body)
|
)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
# if not isinstance(potential_body, dict):
|
error_list.append(_("Webhook body for '{}' is not a valid json dictionary ({}).".format(event, exc)))
|
||||||
# error_list.append(
|
|
||||||
# _("Webhook body for '{}' should be a json dictionary. Found type '{}'.".format(event, type(potential_body).__name__))
|
|
||||||
# )
|
|
||||||
except Exception as exc:
|
|
||||||
error_list.append(_("Webhook body for '{}' is not valid. The following gave an error ({}).".format(event, exc)))
|
|
||||||
|
|
||||||
if error_list:
|
if error_list:
|
||||||
raise serializers.ValidationError(error_list)
|
raise serializers.ValidationError(error_list)
|
||||||
@@ -5464,25 +5458,17 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
|
|||||||
class InstanceLinkSerializer(BaseSerializer):
|
class InstanceLinkSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InstanceLink
|
model = InstanceLink
|
||||||
fields = ('id', 'related', 'source', 'target', 'target_full_address', 'link_state')
|
fields = ('id', 'url', 'related', 'source', 'target', 'link_state')
|
||||||
|
|
||||||
source = serializers.SlugRelatedField(slug_field="hostname", queryset=Instance.objects.all())
|
source = serializers.SlugRelatedField(slug_field="hostname", queryset=Instance.objects.all())
|
||||||
|
target = serializers.SlugRelatedField(slug_field="hostname", queryset=Instance.objects.all())
|
||||||
target = serializers.SerializerMethodField()
|
|
||||||
target_full_address = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
def get_related(self, obj):
|
def get_related(self, obj):
|
||||||
res = super(InstanceLinkSerializer, self).get_related(obj)
|
res = super(InstanceLinkSerializer, self).get_related(obj)
|
||||||
res['source_instance'] = self.reverse('api:instance_detail', kwargs={'pk': obj.source.id})
|
res['source_instance'] = self.reverse('api:instance_detail', kwargs={'pk': obj.source.id})
|
||||||
res['target_address'] = self.reverse('api:receptor_address_detail', kwargs={'pk': obj.target.id})
|
res['target_instance'] = self.reverse('api:instance_detail', kwargs={'pk': obj.target.id})
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def get_target(self, obj):
|
|
||||||
return obj.target.instance.hostname
|
|
||||||
|
|
||||||
def get_target_full_address(self, obj):
|
|
||||||
return obj.target.get_full_address()
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceNodeSerializer(BaseSerializer):
|
class InstanceNodeSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -5490,29 +5476,6 @@ class InstanceNodeSerializer(BaseSerializer):
|
|||||||
fields = ('id', 'hostname', 'node_type', 'node_state', 'enabled')
|
fields = ('id', 'hostname', 'node_type', 'node_state', 'enabled')
|
||||||
|
|
||||||
|
|
||||||
class ReceptorAddressSerializer(BaseSerializer):
|
|
||||||
full_address = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ReceptorAddress
|
|
||||||
fields = (
|
|
||||||
'id',
|
|
||||||
'url',
|
|
||||||
'address',
|
|
||||||
'port',
|
|
||||||
'protocol',
|
|
||||||
'websocket_path',
|
|
||||||
'is_internal',
|
|
||||||
'canonical',
|
|
||||||
'instance',
|
|
||||||
'peers_from_control_nodes',
|
|
||||||
'full_address',
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_full_address(self, obj):
|
|
||||||
return obj.get_full_address()
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceSerializer(BaseSerializer):
|
class InstanceSerializer(BaseSerializer):
|
||||||
show_capabilities = ['edit']
|
show_capabilities = ['edit']
|
||||||
|
|
||||||
@@ -5521,17 +5484,11 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True)
|
jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True)
|
||||||
jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True)
|
jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True)
|
||||||
health_check_pending = serializers.SerializerMethodField()
|
health_check_pending = serializers.SerializerMethodField()
|
||||||
peers = serializers.PrimaryKeyRelatedField(
|
peers = serializers.SlugRelatedField(many=True, required=False, slug_field="hostname", queryset=Instance.objects.all())
|
||||||
help_text=_('Primary keys of receptor addresses to peer to.'), many=True, required=False, queryset=ReceptorAddress.objects.all()
|
|
||||||
)
|
|
||||||
reverse_peers = serializers.SerializerMethodField()
|
|
||||||
listener_port = serializers.IntegerField(source='canonical_address_port', required=False, allow_null=True)
|
|
||||||
peers_from_control_nodes = serializers.BooleanField(source='canonical_address_peers_from_control_nodes', required=False)
|
|
||||||
protocol = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Instance
|
model = Instance
|
||||||
read_only_fields = ('ip_address', 'uuid', 'version', 'managed', 'reverse_peers')
|
read_only_fields = ('ip_address', 'uuid', 'version')
|
||||||
fields = (
|
fields = (
|
||||||
'id',
|
'id',
|
||||||
'hostname',
|
'hostname',
|
||||||
@@ -5562,13 +5519,10 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
'managed_by_policy',
|
'managed_by_policy',
|
||||||
'node_type',
|
'node_type',
|
||||||
'node_state',
|
'node_state',
|
||||||
'managed',
|
|
||||||
'ip_address',
|
'ip_address',
|
||||||
'peers',
|
|
||||||
'reverse_peers',
|
|
||||||
'listener_port',
|
'listener_port',
|
||||||
|
'peers',
|
||||||
'peers_from_control_nodes',
|
'peers_from_control_nodes',
|
||||||
'protocol',
|
|
||||||
)
|
)
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'node_type': {'initial': Instance.Types.EXECUTION, 'default': Instance.Types.EXECUTION},
|
'node_type': {'initial': Instance.Types.EXECUTION, 'default': Instance.Types.EXECUTION},
|
||||||
@@ -5590,54 +5544,16 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
|
|
||||||
def get_related(self, obj):
|
def get_related(self, obj):
|
||||||
res = super(InstanceSerializer, self).get_related(obj)
|
res = super(InstanceSerializer, self).get_related(obj)
|
||||||
res['receptor_addresses'] = self.reverse('api:instance_receptor_addresses_list', kwargs={'pk': obj.pk})
|
|
||||||
res['jobs'] = self.reverse('api:instance_unified_jobs_list', kwargs={'pk': obj.pk})
|
res['jobs'] = self.reverse('api:instance_unified_jobs_list', kwargs={'pk': obj.pk})
|
||||||
res['peers'] = self.reverse('api:instance_peers_list', kwargs={"pk": obj.pk})
|
|
||||||
res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk})
|
res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk})
|
||||||
if obj.node_type in [Instance.Types.EXECUTION, Instance.Types.HOP] and not obj.managed:
|
if obj.node_type in [Instance.Types.EXECUTION, Instance.Types.HOP]:
|
||||||
res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk})
|
res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk})
|
||||||
|
res['peers'] = self.reverse('api:instance_peers_list', kwargs={"pk": obj.pk})
|
||||||
if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor:
|
if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor:
|
||||||
if obj.node_type == 'execution':
|
if obj.node_type == 'execution':
|
||||||
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})
|
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def create_or_update(self, validated_data, obj=None, create=True):
|
|
||||||
# create a managed receptor address if listener port is defined
|
|
||||||
port = validated_data.pop('listener_port', -1)
|
|
||||||
peers_from_control_nodes = validated_data.pop('peers_from_control_nodes', -1)
|
|
||||||
|
|
||||||
# delete the receptor address if the port is explicitly set to None
|
|
||||||
if obj and port == None:
|
|
||||||
obj.receptor_addresses.filter(address=obj.hostname).delete()
|
|
||||||
|
|
||||||
if create:
|
|
||||||
instance = super(InstanceSerializer, self).create(validated_data)
|
|
||||||
else:
|
|
||||||
instance = super(InstanceSerializer, self).update(obj, validated_data)
|
|
||||||
instance.refresh_from_db() # instance canonical address lookup is deferred, so needs to be reloaded
|
|
||||||
|
|
||||||
# only create or update if port is defined in validated_data or already exists in the
|
|
||||||
# canonical address
|
|
||||||
# this prevents creating a receptor address if peers_from_control_nodes is in
|
|
||||||
# validated_data but a port is not set
|
|
||||||
if (port != None and port != -1) or instance.canonical_address_port:
|
|
||||||
kwargs = {}
|
|
||||||
if port != -1:
|
|
||||||
kwargs['port'] = port
|
|
||||||
if peers_from_control_nodes != -1:
|
|
||||||
kwargs['peers_from_control_nodes'] = peers_from_control_nodes
|
|
||||||
if kwargs:
|
|
||||||
kwargs['canonical'] = True
|
|
||||||
instance.receptor_addresses.update_or_create(address=instance.hostname, defaults=kwargs)
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
return self.create_or_update(validated_data, create=True)
|
|
||||||
|
|
||||||
def update(self, obj, validated_data):
|
|
||||||
return self.create_or_update(validated_data, obj, create=False)
|
|
||||||
|
|
||||||
def get_summary_fields(self, obj):
|
def get_summary_fields(self, obj):
|
||||||
summary = super().get_summary_fields(obj)
|
summary = super().get_summary_fields(obj)
|
||||||
|
|
||||||
@@ -5647,16 +5563,6 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
def get_reverse_peers(self, obj):
|
|
||||||
return Instance.objects.prefetch_related('peers').filter(peers__in=obj.receptor_addresses.all()).values_list('id', flat=True)
|
|
||||||
|
|
||||||
def get_protocol(self, obj):
|
|
||||||
# note: don't create a different query for receptor addresses, as this is prefetched on the View for optimization
|
|
||||||
for addr in obj.receptor_addresses.all():
|
|
||||||
if addr.canonical:
|
|
||||||
return addr.protocol
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def get_consumed_capacity(self, obj):
|
def get_consumed_capacity(self, obj):
|
||||||
return obj.consumed_capacity
|
return obj.consumed_capacity
|
||||||
|
|
||||||
@@ -5670,20 +5576,47 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
return obj.health_check_pending
|
return obj.health_check_pending
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
# Oddly, using 'source' on a DRF field populates attrs with the source name, so we should rename it back
|
def get_field_from_model_or_attrs(fd):
|
||||||
if 'canonical_address_port' in attrs:
|
return attrs.get(fd, self.instance and getattr(self.instance, fd) or None)
|
||||||
attrs['listener_port'] = attrs.pop('canonical_address_port')
|
|
||||||
if 'canonical_address_peers_from_control_nodes' in attrs:
|
def check_peers_changed():
|
||||||
attrs['peers_from_control_nodes'] = attrs.pop('canonical_address_peers_from_control_nodes')
|
'''
|
||||||
|
return True if
|
||||||
|
- 'peers' in attrs
|
||||||
|
- instance peers matches peers in attrs
|
||||||
|
'''
|
||||||
|
return self.instance and 'peers' in attrs and set(self.instance.peers.all()) != set(attrs['peers'])
|
||||||
|
|
||||||
if not self.instance and not settings.IS_K8S:
|
if not self.instance and not settings.IS_K8S:
|
||||||
raise serializers.ValidationError(_("Can only create instances on Kubernetes or OpenShift."))
|
raise serializers.ValidationError(_("Can only create instances on Kubernetes or OpenShift."))
|
||||||
|
|
||||||
# cannot enable peers_from_control_nodes if listener_port is not set
|
node_type = get_field_from_model_or_attrs("node_type")
|
||||||
if attrs.get('peers_from_control_nodes'):
|
peers_from_control_nodes = get_field_from_model_or_attrs("peers_from_control_nodes")
|
||||||
port = attrs.get('listener_port', -1) # -1 denotes missing, None denotes explicit null
|
listener_port = get_field_from_model_or_attrs("listener_port")
|
||||||
if (port is None) or (port == -1 and self.instance and self.instance.canonical_address is None):
|
peers = attrs.get('peers', [])
|
||||||
raise serializers.ValidationError(_("Cannot enable peers_from_control_nodes if listener_port is not set."))
|
|
||||||
|
if peers_from_control_nodes and node_type not in (Instance.Types.EXECUTION, Instance.Types.HOP):
|
||||||
|
raise serializers.ValidationError(_("peers_from_control_nodes can only be enabled for execution or hop nodes."))
|
||||||
|
|
||||||
|
if node_type in [Instance.Types.CONTROL, Instance.Types.HYBRID]:
|
||||||
|
if check_peers_changed():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
_("Setting peers manually for control nodes is not allowed. Enable peers_from_control_nodes on the hop and execution nodes instead.")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not listener_port and peers_from_control_nodes:
|
||||||
|
raise serializers.ValidationError(_("Field listener_port must be a valid integer when peers_from_control_nodes is enabled."))
|
||||||
|
|
||||||
|
if not listener_port and self.instance and self.instance.peers_from.exists():
|
||||||
|
raise serializers.ValidationError(_("Field listener_port must be a valid integer when other nodes peer to it."))
|
||||||
|
|
||||||
|
for peer in peers:
|
||||||
|
if peer.listener_port is None:
|
||||||
|
raise serializers.ValidationError(_("Field listener_port must be set on peer ") + peer.hostname + ".")
|
||||||
|
|
||||||
|
if not settings.IS_K8S:
|
||||||
|
if check_peers_changed():
|
||||||
|
raise serializers.ValidationError(_("Cannot change peers."))
|
||||||
|
|
||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
@@ -5703,8 +5636,8 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
raise serializers.ValidationError(_("Can only change the state on Kubernetes or OpenShift."))
|
raise serializers.ValidationError(_("Can only change the state on Kubernetes or OpenShift."))
|
||||||
if value != Instance.States.DEPROVISIONING:
|
if value != Instance.States.DEPROVISIONING:
|
||||||
raise serializers.ValidationError(_("Can only change instances to the 'deprovisioning' state."))
|
raise serializers.ValidationError(_("Can only change instances to the 'deprovisioning' state."))
|
||||||
if self.instance.managed:
|
if self.instance.node_type not in (Instance.Types.EXECUTION, Instance.Types.HOP):
|
||||||
raise serializers.ValidationError(_("Cannot deprovision managed nodes."))
|
raise serializers.ValidationError(_("Can only deprovision execution or hop nodes."))
|
||||||
else:
|
else:
|
||||||
if value and value != Instance.States.INSTALLED:
|
if value and value != Instance.States.INSTALLED:
|
||||||
raise serializers.ValidationError(_("Can only create instances in the 'installed' state."))
|
raise serializers.ValidationError(_("Can only create instances in the 'installed' state."))
|
||||||
@@ -5723,48 +5656,18 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
def validate_listener_port(self, value):
|
def validate_listener_port(self, value):
|
||||||
"""
|
"""
|
||||||
Cannot change listener port, unless going from none to integer, and vice versa
|
Cannot change listener port, unless going from none to integer, and vice versa
|
||||||
If instance is managed, cannot change listener port at all
|
|
||||||
"""
|
"""
|
||||||
if self.instance:
|
if value and self.instance and self.instance.listener_port and self.instance.listener_port != value:
|
||||||
canonical_address_port = self.instance.canonical_address_port
|
raise serializers.ValidationError(_("Cannot change listener port."))
|
||||||
if value and canonical_address_port and canonical_address_port != value:
|
|
||||||
raise serializers.ValidationError(_("Cannot change listener port."))
|
|
||||||
if self.instance.managed and value != canonical_address_port:
|
|
||||||
raise serializers.ValidationError(_("Cannot change listener port for managed nodes."))
|
|
||||||
return value
|
|
||||||
|
|
||||||
def validate_peers(self, value):
|
|
||||||
# cannot peer to an instance more than once
|
|
||||||
peers_instances = Counter(p.instance_id for p in value)
|
|
||||||
if any(count > 1 for count in peers_instances.values()):
|
|
||||||
raise serializers.ValidationError(_("Cannot peer to the same instance more than once."))
|
|
||||||
|
|
||||||
if self.instance:
|
|
||||||
instance_addresses = set(self.instance.receptor_addresses.all())
|
|
||||||
setting_peers = set(value)
|
|
||||||
peers_changed = set(self.instance.peers.all()) != setting_peers
|
|
||||||
|
|
||||||
if not settings.IS_K8S and peers_changed:
|
|
||||||
raise serializers.ValidationError(_("Cannot change peers."))
|
|
||||||
|
|
||||||
if self.instance.managed and peers_changed:
|
|
||||||
raise serializers.ValidationError(_("Setting peers manually for managed nodes is not allowed."))
|
|
||||||
|
|
||||||
# cannot peer to self
|
|
||||||
if instance_addresses & setting_peers:
|
|
||||||
raise serializers.ValidationError(_("Instance cannot peer to its own address."))
|
|
||||||
|
|
||||||
# cannot peer to an instance that is already peered to this instance
|
|
||||||
if instance_addresses:
|
|
||||||
for p in setting_peers:
|
|
||||||
if set(p.instance.peers.all()) & instance_addresses:
|
|
||||||
raise serializers.ValidationError(_(f"Instance {p.instance.hostname} is already peered to this instance."))
|
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_peers_from_control_nodes(self, value):
|
def validate_peers_from_control_nodes(self, value):
|
||||||
if self.instance and self.instance.managed and self.instance.canonical_address_peers_from_control_nodes != value:
|
"""
|
||||||
raise serializers.ValidationError(_("Cannot change peers_from_control_nodes for managed nodes."))
|
Can only enable for K8S based deployments
|
||||||
|
"""
|
||||||
|
if value and not settings.IS_K8S:
|
||||||
|
raise serializers.ValidationError(_("Can only be enabled on Kubernetes or Openshift."))
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,19 @@ custom_worksign_public_keyfile: receptor/work_public_key.pem
|
|||||||
custom_tls_certfile: receptor/tls/receptor.crt
|
custom_tls_certfile: receptor/tls/receptor.crt
|
||||||
custom_tls_keyfile: receptor/tls/receptor.key
|
custom_tls_keyfile: receptor/tls/receptor.key
|
||||||
custom_ca_certfile: receptor/tls/ca/mesh-CA.crt
|
custom_ca_certfile: receptor/tls/ca/mesh-CA.crt
|
||||||
{% if listener_port %}
|
receptor_protocol: 'tcp'
|
||||||
receptor_protocol: {{ listener_protocol }}
|
{% if instance.listener_port %}
|
||||||
receptor_listener: true
|
receptor_listener: true
|
||||||
receptor_port: {{ listener_port }}
|
receptor_port: {{ instance.listener_port }}
|
||||||
{% else %}
|
{% else %}
|
||||||
receptor_listener: false
|
receptor_listener: false
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if peers %}
|
{% if peers %}
|
||||||
receptor_peers:
|
receptor_peers:
|
||||||
{% for peer in peers %}
|
{% for peer in peers %}
|
||||||
- address: {{ peer.address }}
|
- host: {{ peer.host }}
|
||||||
protocol: {{ peer.protocol }}
|
port: {{ peer.port }}
|
||||||
|
protocol: tcp
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% verbatim %}
|
{% verbatim %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
---
|
---
|
||||||
collections:
|
collections:
|
||||||
- name: ansible.receptor
|
- name: ansible.receptor
|
||||||
version: 2.0.3
|
version: 2.0.2
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from awx.api.views import (
|
|||||||
InstanceInstanceGroupsList,
|
InstanceInstanceGroupsList,
|
||||||
InstanceHealthCheck,
|
InstanceHealthCheck,
|
||||||
InstancePeersList,
|
InstancePeersList,
|
||||||
InstanceReceptorAddressesList,
|
|
||||||
)
|
)
|
||||||
from awx.api.views.instance_install_bundle import InstanceInstallBundle
|
from awx.api.views.instance_install_bundle import InstanceInstallBundle
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ urls = [
|
|||||||
re_path(r'^(?P<pk>[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'),
|
re_path(r'^(?P<pk>[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'),
|
||||||
re_path(r'^(?P<pk>[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'),
|
re_path(r'^(?P<pk>[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'),
|
||||||
re_path(r'^(?P<pk>[0-9]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'),
|
re_path(r'^(?P<pk>[0-9]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'),
|
||||||
re_path(r'^(?P<pk>[0-9]+)/receptor_addresses/$', InstanceReceptorAddressesList.as_view(), name='instance_receptor_addresses_list'),
|
|
||||||
re_path(r'^(?P<pk>[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'),
|
re_path(r'^(?P<pk>[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# Copyright (c) 2017 Ansible, Inc.
|
|
||||||
# All Rights Reserved.
|
|
||||||
|
|
||||||
from django.urls import re_path
|
|
||||||
|
|
||||||
from awx.api.views import (
|
|
||||||
ReceptorAddressesList,
|
|
||||||
ReceptorAddressDetail,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
urls = [
|
|
||||||
re_path(r'^$', ReceptorAddressesList.as_view(), name='receptor_addresses_list'),
|
|
||||||
re_path(r'^(?P<pk>[0-9]+)/$', ReceptorAddressDetail.as_view(), name='receptor_address_detail'),
|
|
||||||
]
|
|
||||||
|
|
||||||
__all__ = ['urls']
|
|
||||||
@@ -85,7 +85,6 @@ from .oauth2_root import urls as oauth2_root_urls
|
|||||||
from .workflow_approval_template import urls as workflow_approval_template_urls
|
from .workflow_approval_template import urls as workflow_approval_template_urls
|
||||||
from .workflow_approval import urls as workflow_approval_urls
|
from .workflow_approval import urls as workflow_approval_urls
|
||||||
from .analytics import urls as analytics_urls
|
from .analytics import urls as analytics_urls
|
||||||
from .receptor_address import urls as receptor_address_urls
|
|
||||||
|
|
||||||
v2_urls = [
|
v2_urls = [
|
||||||
re_path(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'),
|
re_path(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'),
|
||||||
@@ -156,7 +155,6 @@ v2_urls = [
|
|||||||
re_path(r'^bulk/host_create/$', BulkHostCreateView.as_view(), name='bulk_host_create'),
|
re_path(r'^bulk/host_create/$', BulkHostCreateView.as_view(), name='bulk_host_create'),
|
||||||
re_path(r'^bulk/host_delete/$', BulkHostDeleteView.as_view(), name='bulk_host_delete'),
|
re_path(r'^bulk/host_delete/$', BulkHostDeleteView.as_view(), name='bulk_host_delete'),
|
||||||
re_path(r'^bulk/job_launch/$', BulkJobLaunchView.as_view(), name='bulk_job_launch'),
|
re_path(r'^bulk/job_launch/$', BulkJobLaunchView.as_view(), name='bulk_job_launch'),
|
||||||
re_path(r'^receptor_addresses/', include(receptor_address_urls)),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -272,24 +272,16 @@ class DashboardJobsGraphView(APIView):
|
|||||||
|
|
||||||
success_query = user_unified_jobs.filter(status='successful')
|
success_query = user_unified_jobs.filter(status='successful')
|
||||||
failed_query = user_unified_jobs.filter(status='failed')
|
failed_query = user_unified_jobs.filter(status='failed')
|
||||||
canceled_query = user_unified_jobs.filter(status='canceled')
|
|
||||||
error_query = user_unified_jobs.filter(status='error')
|
|
||||||
|
|
||||||
if job_type == 'inv_sync':
|
if job_type == 'inv_sync':
|
||||||
success_query = success_query.filter(instance_of=models.InventoryUpdate)
|
success_query = success_query.filter(instance_of=models.InventoryUpdate)
|
||||||
failed_query = failed_query.filter(instance_of=models.InventoryUpdate)
|
failed_query = failed_query.filter(instance_of=models.InventoryUpdate)
|
||||||
canceled_query = canceled_query.filter(instance_of=models.InventoryUpdate)
|
|
||||||
error_query = error_query.filter(instance_of=models.InventoryUpdate)
|
|
||||||
elif job_type == 'playbook_run':
|
elif job_type == 'playbook_run':
|
||||||
success_query = success_query.filter(instance_of=models.Job)
|
success_query = success_query.filter(instance_of=models.Job)
|
||||||
failed_query = failed_query.filter(instance_of=models.Job)
|
failed_query = failed_query.filter(instance_of=models.Job)
|
||||||
canceled_query = canceled_query.filter(instance_of=models.Job)
|
|
||||||
error_query = error_query.filter(instance_of=models.Job)
|
|
||||||
elif job_type == 'scm_update':
|
elif job_type == 'scm_update':
|
||||||
success_query = success_query.filter(instance_of=models.ProjectUpdate)
|
success_query = success_query.filter(instance_of=models.ProjectUpdate)
|
||||||
failed_query = failed_query.filter(instance_of=models.ProjectUpdate)
|
failed_query = failed_query.filter(instance_of=models.ProjectUpdate)
|
||||||
canceled_query = canceled_query.filter(instance_of=models.ProjectUpdate)
|
|
||||||
error_query = error_query.filter(instance_of=models.ProjectUpdate)
|
|
||||||
|
|
||||||
end = now()
|
end = now()
|
||||||
interval = 'day'
|
interval = 'day'
|
||||||
@@ -305,12 +297,10 @@ class DashboardJobsGraphView(APIView):
|
|||||||
else:
|
else:
|
||||||
return Response({'error': _('Unknown period "%s"') % str(period)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': _('Unknown period "%s"') % str(period)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
dashboard_data = {"jobs": {"successful": [], "failed": [], "canceled": [], "error": []}}
|
dashboard_data = {"jobs": {"successful": [], "failed": []}}
|
||||||
|
|
||||||
succ_list = dashboard_data['jobs']['successful']
|
succ_list = dashboard_data['jobs']['successful']
|
||||||
fail_list = dashboard_data['jobs']['failed']
|
fail_list = dashboard_data['jobs']['failed']
|
||||||
canceled_list = dashboard_data['jobs']['canceled']
|
|
||||||
error_list = dashboard_data['jobs']['error']
|
|
||||||
|
|
||||||
qs_s = (
|
qs_s = (
|
||||||
success_query.filter(finished__range=(start, end))
|
success_query.filter(finished__range=(start, end))
|
||||||
@@ -328,22 +318,6 @@ class DashboardJobsGraphView(APIView):
|
|||||||
.annotate(agg=Count('id', distinct=True))
|
.annotate(agg=Count('id', distinct=True))
|
||||||
)
|
)
|
||||||
data_f = {item['d']: item['agg'] for item in qs_f}
|
data_f = {item['d']: item['agg'] for item in qs_f}
|
||||||
qs_c = (
|
|
||||||
canceled_query.filter(finished__range=(start, end))
|
|
||||||
.annotate(d=Trunc('finished', interval, tzinfo=end.tzinfo))
|
|
||||||
.order_by()
|
|
||||||
.values('d')
|
|
||||||
.annotate(agg=Count('id', distinct=True))
|
|
||||||
)
|
|
||||||
data_c = {item['d']: item['agg'] for item in qs_c}
|
|
||||||
qs_e = (
|
|
||||||
error_query.filter(finished__range=(start, end))
|
|
||||||
.annotate(d=Trunc('finished', interval, tzinfo=end.tzinfo))
|
|
||||||
.order_by()
|
|
||||||
.values('d')
|
|
||||||
.annotate(agg=Count('id', distinct=True))
|
|
||||||
)
|
|
||||||
data_e = {item['d']: item['agg'] for item in qs_e}
|
|
||||||
|
|
||||||
start_date = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
start_date = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
for d in itertools.count():
|
for d in itertools.count():
|
||||||
@@ -352,8 +326,6 @@ class DashboardJobsGraphView(APIView):
|
|||||||
break
|
break
|
||||||
succ_list.append([time.mktime(date.timetuple()), data_s.get(date, 0)])
|
succ_list.append([time.mktime(date.timetuple()), data_s.get(date, 0)])
|
||||||
fail_list.append([time.mktime(date.timetuple()), data_f.get(date, 0)])
|
fail_list.append([time.mktime(date.timetuple()), data_f.get(date, 0)])
|
||||||
canceled_list.append([time.mktime(date.timetuple()), data_c.get(date, 0)])
|
|
||||||
error_list.append([time.mktime(date.timetuple()), data_e.get(date, 0)])
|
|
||||||
|
|
||||||
return Response(dashboard_data)
|
return Response(dashboard_data)
|
||||||
|
|
||||||
@@ -365,20 +337,12 @@ class InstanceList(ListCreateAPIView):
|
|||||||
search_fields = ('hostname',)
|
search_fields = ('hostname',)
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = super().get_queryset().prefetch_related('receptor_addresses')
|
|
||||||
return qs
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceDetail(RetrieveUpdateAPIView):
|
class InstanceDetail(RetrieveUpdateAPIView):
|
||||||
name = _("Instance Detail")
|
name = _("Instance Detail")
|
||||||
model = models.Instance
|
model = models.Instance
|
||||||
serializer_class = serializers.InstanceSerializer
|
serializer_class = serializers.InstanceSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = super().get_queryset().prefetch_related('receptor_addresses')
|
|
||||||
return qs
|
|
||||||
|
|
||||||
def update_raw_data(self, data):
|
def update_raw_data(self, data):
|
||||||
# these fields are only valid on creation of an instance, so they unwanted on detail view
|
# these fields are only valid on creation of an instance, so they unwanted on detail view
|
||||||
data.pop('node_type', None)
|
data.pop('node_type', None)
|
||||||
@@ -411,37 +375,13 @@ class InstanceUnifiedJobsList(SubListAPIView):
|
|||||||
|
|
||||||
|
|
||||||
class InstancePeersList(SubListAPIView):
|
class InstancePeersList(SubListAPIView):
|
||||||
name = _("Peers")
|
name = _("Instance Peers")
|
||||||
model = models.ReceptorAddress
|
|
||||||
serializer_class = serializers.ReceptorAddressSerializer
|
|
||||||
parent_model = models.Instance
|
parent_model = models.Instance
|
||||||
|
model = models.Instance
|
||||||
|
serializer_class = serializers.InstanceSerializer
|
||||||
parent_access = 'read'
|
parent_access = 'read'
|
||||||
|
search_fields = {'hostname'}
|
||||||
relationship = 'peers'
|
relationship = 'peers'
|
||||||
search_fields = ('address',)
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceReceptorAddressesList(SubListAPIView):
|
|
||||||
name = _("Receptor Addresses")
|
|
||||||
model = models.ReceptorAddress
|
|
||||||
parent_key = 'instance'
|
|
||||||
parent_model = models.Instance
|
|
||||||
serializer_class = serializers.ReceptorAddressSerializer
|
|
||||||
search_fields = ('address',)
|
|
||||||
|
|
||||||
|
|
||||||
class ReceptorAddressesList(ListAPIView):
|
|
||||||
name = _("Receptor Addresses")
|
|
||||||
model = models.ReceptorAddress
|
|
||||||
serializer_class = serializers.ReceptorAddressSerializer
|
|
||||||
search_fields = ('address',)
|
|
||||||
|
|
||||||
|
|
||||||
class ReceptorAddressDetail(RetrieveAPIView):
|
|
||||||
name = _("Receptor Address Detail")
|
|
||||||
model = models.ReceptorAddress
|
|
||||||
serializer_class = serializers.ReceptorAddressSerializer
|
|
||||||
parent_model = models.Instance
|
|
||||||
relationship = 'receptor_addresses'
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView):
|
class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView):
|
||||||
|
|||||||
@@ -124,19 +124,10 @@ def generate_inventory_yml(instance_obj):
|
|||||||
|
|
||||||
|
|
||||||
def generate_group_vars_all_yml(instance_obj):
|
def generate_group_vars_all_yml(instance_obj):
|
||||||
# get peers
|
|
||||||
peers = []
|
peers = []
|
||||||
for addr in instance_obj.peers.select_related('instance'):
|
for instance in instance_obj.peers.all():
|
||||||
peers.append(dict(address=addr.get_full_address(), protocol=addr.protocol))
|
peers.append(dict(host=instance.hostname, port=instance.listener_port))
|
||||||
context = dict(instance=instance_obj, peers=peers)
|
all_yaml = render_to_string("instance_install_bundle/group_vars/all.yml", context=dict(instance=instance_obj, peers=peers))
|
||||||
|
|
||||||
canonical_addr = instance_obj.canonical_address
|
|
||||||
if canonical_addr:
|
|
||||||
context['listener_port'] = canonical_addr.port
|
|
||||||
protocol = canonical_addr.protocol if canonical_addr.protocol != 'wss' else 'ws'
|
|
||||||
context['listener_protocol'] = protocol
|
|
||||||
|
|
||||||
all_yaml = render_to_string("instance_install_bundle/group_vars/all.yml", context=context)
|
|
||||||
# convert consecutive newlines with a single newline
|
# convert consecutive newlines with a single newline
|
||||||
return re.sub(r'\n+', '\n', all_yaml)
|
return re.sub(r'\n+', '\n', all_yaml)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class MeshVisualizer(APIView):
|
|||||||
def get(self, request, format=None):
|
def get(self, request, format=None):
|
||||||
data = {
|
data = {
|
||||||
'nodes': InstanceNodeSerializer(Instance.objects.all(), many=True).data,
|
'nodes': InstanceNodeSerializer(Instance.objects.all(), many=True).data,
|
||||||
'links': InstanceLinkSerializer(InstanceLink.objects.select_related('target__instance', 'source'), many=True).data,
|
'links': InstanceLinkSerializer(InstanceLink.objects.select_related('target', 'source'), many=True).data,
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ class ApiVersionRootView(APIView):
|
|||||||
data['ping'] = reverse('api:api_v2_ping_view', request=request)
|
data['ping'] = reverse('api:api_v2_ping_view', request=request)
|
||||||
data['instances'] = reverse('api:instance_list', request=request)
|
data['instances'] = reverse('api:instance_list', request=request)
|
||||||
data['instance_groups'] = reverse('api:instance_group_list', request=request)
|
data['instance_groups'] = reverse('api:instance_group_list', request=request)
|
||||||
data['receptor_addresses'] = reverse('api:receptor_addresses_list', request=request)
|
|
||||||
data['config'] = reverse('api:api_v2_config_view', request=request)
|
data['config'] = reverse('api:api_v2_config_view', request=request)
|
||||||
data['settings'] = reverse('api:setting_category_list', request=request)
|
data['settings'] = reverse('api:setting_category_list', request=request)
|
||||||
data['me'] = reverse('api:user_me_list', request=request)
|
data['me'] = reverse('api:user_me_list', request=request)
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ from awx.main.models import (
|
|||||||
Project,
|
Project,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
ProjectUpdateEvent,
|
ProjectUpdateEvent,
|
||||||
ReceptorAddress,
|
|
||||||
Role,
|
Role,
|
||||||
Schedule,
|
Schedule,
|
||||||
SystemJob,
|
SystemJob,
|
||||||
@@ -2431,29 +2430,6 @@ class InventoryUpdateEventAccess(BaseAccess):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ReceptorAddressAccess(BaseAccess):
|
|
||||||
"""
|
|
||||||
I can see receptor address records whenever I can access the instance
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = ReceptorAddress
|
|
||||||
|
|
||||||
def filtered_queryset(self):
|
|
||||||
return self.model.objects.filter(Q(instance__in=Instance.accessible_pk_qs(self.user, 'read_role')))
|
|
||||||
|
|
||||||
@check_superuser
|
|
||||||
def can_add(self, data):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@check_superuser
|
|
||||||
def can_change(self, obj, data):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@check_superuser
|
|
||||||
def can_delete(self, obj):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class SystemJobEventAccess(BaseAccess):
|
class SystemJobEventAccess(BaseAccess):
|
||||||
"""
|
"""
|
||||||
I can only see manage System Jobs events if I'm a super user
|
I can only see manage System Jobs events if I'm a super user
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.analytics.subsystem_metrics import DispatcherMetrics, CallbackReceiverMetrics
|
from awx.main.analytics.subsystem_metrics import Metrics
|
||||||
from awx.main.dispatch.publish import task
|
from awx.main.dispatch.publish import task
|
||||||
from awx.main.dispatch import get_task_queuename
|
from awx.main.dispatch import get_task_queuename
|
||||||
|
|
||||||
@@ -11,5 +11,4 @@ logger = logging.getLogger('awx.main.scheduler')
|
|||||||
|
|
||||||
@task(queue=get_task_queuename)
|
@task(queue=get_task_queuename)
|
||||||
def send_subsystem_metrics():
|
def send_subsystem_metrics():
|
||||||
DispatcherMetrics().send_metrics()
|
Metrics().send_metrics()
|
||||||
CallbackReceiverMetrics().send_metrics()
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import itertools
|
|
||||||
import redis
|
import redis
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import prometheus_client
|
|
||||||
from prometheus_client.core import GaugeMetricFamily, HistogramMetricFamily
|
|
||||||
from prometheus_client.registry import CollectorRegistry
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpRequest
|
from django.apps import apps
|
||||||
from rest_framework.request import Request
|
|
||||||
|
|
||||||
from awx.main.consumers import emit_channel_notification
|
from awx.main.consumers import emit_channel_notification
|
||||||
from awx.main.utils import is_testing
|
from awx.main.utils import is_testing
|
||||||
@@ -18,30 +13,6 @@ root_key = settings.SUBSYSTEM_METRICS_REDIS_KEY_PREFIX
|
|||||||
logger = logging.getLogger('awx.main.analytics')
|
logger = logging.getLogger('awx.main.analytics')
|
||||||
|
|
||||||
|
|
||||||
class MetricsNamespace:
|
|
||||||
def __init__(self, namespace):
|
|
||||||
self._namespace = namespace
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsServerSettings(MetricsNamespace):
|
|
||||||
def port(self):
|
|
||||||
return settings.METRICS_SUBSYSTEM_CONFIG['server'][self._namespace]['port']
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsServer(MetricsServerSettings):
|
|
||||||
def __init__(self, namespace, registry):
|
|
||||||
MetricsNamespace.__init__(self, namespace)
|
|
||||||
self._registry = registry
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
try:
|
|
||||||
# TODO: addr for ipv6 ?
|
|
||||||
prometheus_client.start_http_server(self.port(), addr='localhost', registry=self._registry)
|
|
||||||
except Exception:
|
|
||||||
logger.error(f"MetricsServer failed to start for service '{self._namespace}.")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
class BaseM:
|
class BaseM:
|
||||||
def __init__(self, field, help_text):
|
def __init__(self, field, help_text):
|
||||||
self.field = field
|
self.field = field
|
||||||
@@ -177,40 +148,76 @@ class HistogramM(BaseM):
|
|||||||
return output_text
|
return output_text
|
||||||
|
|
||||||
|
|
||||||
class Metrics(MetricsNamespace):
|
class Metrics:
|
||||||
# metric name, help_text
|
def __init__(self, auto_pipe_execute=False, instance_name=None):
|
||||||
METRICSLIST = []
|
|
||||||
_METRICSLIST = [
|
|
||||||
FloatM('subsystem_metrics_pipe_execute_seconds', 'Time spent saving metrics to redis'),
|
|
||||||
IntM('subsystem_metrics_pipe_execute_calls', 'Number of calls to pipe_execute'),
|
|
||||||
FloatM('subsystem_metrics_send_metrics_seconds', 'Time spent sending metrics to other nodes'),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, namespace, auto_pipe_execute=False, instance_name=None, metrics_have_changed=True, **kwargs):
|
|
||||||
MetricsNamespace.__init__(self, namespace)
|
|
||||||
|
|
||||||
self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
|
self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
|
||||||
self.conn = redis.Redis.from_url(settings.BROKER_URL)
|
self.conn = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
self.last_pipe_execute = time.time()
|
self.last_pipe_execute = time.time()
|
||||||
# track if metrics have been modified since last saved to redis
|
# track if metrics have been modified since last saved to redis
|
||||||
# start with True so that we get an initial save to redis
|
# start with True so that we get an initial save to redis
|
||||||
self.metrics_have_changed = metrics_have_changed
|
self.metrics_have_changed = True
|
||||||
self.pipe_execute_interval = settings.SUBSYSTEM_METRICS_INTERVAL_SAVE_TO_REDIS
|
self.pipe_execute_interval = settings.SUBSYSTEM_METRICS_INTERVAL_SAVE_TO_REDIS
|
||||||
self.send_metrics_interval = settings.SUBSYSTEM_METRICS_INTERVAL_SEND_METRICS
|
self.send_metrics_interval = settings.SUBSYSTEM_METRICS_INTERVAL_SEND_METRICS
|
||||||
# auto pipe execute will commit transaction of metric data to redis
|
# auto pipe execute will commit transaction of metric data to redis
|
||||||
# at a regular interval (pipe_execute_interval). If set to False,
|
# at a regular interval (pipe_execute_interval). If set to False,
|
||||||
# the calling function should call .pipe_execute() explicitly
|
# the calling function should call .pipe_execute() explicitly
|
||||||
self.auto_pipe_execute = auto_pipe_execute
|
self.auto_pipe_execute = auto_pipe_execute
|
||||||
|
Instance = apps.get_model('main', 'Instance')
|
||||||
if instance_name:
|
if instance_name:
|
||||||
self.instance_name = instance_name
|
self.instance_name = instance_name
|
||||||
elif is_testing():
|
elif is_testing():
|
||||||
self.instance_name = "awx_testing"
|
self.instance_name = "awx_testing"
|
||||||
else:
|
else:
|
||||||
self.instance_name = settings.CLUSTER_HOST_ID # Same as Instance.objects.my_hostname() BUT we do not need to import Instance
|
self.instance_name = Instance.objects.my_hostname()
|
||||||
|
|
||||||
|
# metric name, help_text
|
||||||
|
METRICSLIST = [
|
||||||
|
SetIntM('callback_receiver_events_queue_size_redis', 'Current number of events in redis queue'),
|
||||||
|
IntM('callback_receiver_events_popped_redis', 'Number of events popped from redis'),
|
||||||
|
IntM('callback_receiver_events_in_memory', 'Current number of events in memory (in transfer from redis to db)'),
|
||||||
|
IntM('callback_receiver_batch_events_errors', 'Number of times batch insertion failed'),
|
||||||
|
FloatM('callback_receiver_events_insert_db_seconds', 'Total time spent saving events to database'),
|
||||||
|
IntM('callback_receiver_events_insert_db', 'Number of events batch inserted into database'),
|
||||||
|
IntM('callback_receiver_events_broadcast', 'Number of events broadcast to other control plane nodes'),
|
||||||
|
HistogramM(
|
||||||
|
'callback_receiver_batch_events_insert_db', 'Number of events batch inserted into database', settings.SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS
|
||||||
|
),
|
||||||
|
SetFloatM('callback_receiver_event_processing_avg_seconds', 'Average processing time per event per callback receiver batch'),
|
||||||
|
FloatM('subsystem_metrics_pipe_execute_seconds', 'Time spent saving metrics to redis'),
|
||||||
|
IntM('subsystem_metrics_pipe_execute_calls', 'Number of calls to pipe_execute'),
|
||||||
|
FloatM('subsystem_metrics_send_metrics_seconds', 'Time spent sending metrics to other nodes'),
|
||||||
|
SetFloatM('task_manager_get_tasks_seconds', 'Time spent in loading tasks from db'),
|
||||||
|
SetFloatM('task_manager_start_task_seconds', 'Time spent starting task'),
|
||||||
|
SetFloatM('task_manager_process_running_tasks_seconds', 'Time spent processing running tasks'),
|
||||||
|
SetFloatM('task_manager_process_pending_tasks_seconds', 'Time spent processing pending tasks'),
|
||||||
|
SetFloatM('task_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
||||||
|
IntM('task_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
||||||
|
SetFloatM('task_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
||||||
|
SetIntM('task_manager_tasks_started', 'Number of tasks started'),
|
||||||
|
SetIntM('task_manager_running_processed', 'Number of running tasks processed'),
|
||||||
|
SetIntM('task_manager_pending_processed', 'Number of pending tasks processed'),
|
||||||
|
SetIntM('task_manager_tasks_blocked', 'Number of tasks blocked from running'),
|
||||||
|
SetFloatM('task_manager_commit_seconds', 'Time spent in db transaction, including on_commit calls'),
|
||||||
|
SetFloatM('dependency_manager_get_tasks_seconds', 'Time spent loading pending tasks from db'),
|
||||||
|
SetFloatM('dependency_manager_generate_dependencies_seconds', 'Time spent generating dependencies for pending tasks'),
|
||||||
|
SetFloatM('dependency_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
||||||
|
IntM('dependency_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
||||||
|
SetFloatM('dependency_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
||||||
|
SetIntM('dependency_manager_pending_processed', 'Number of pending tasks processed'),
|
||||||
|
SetFloatM('workflow_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
||||||
|
IntM('workflow_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
||||||
|
SetFloatM('workflow_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
||||||
|
SetFloatM('workflow_manager_spawn_workflow_graph_jobs_seconds', 'Time spent spawning workflow tasks'),
|
||||||
|
SetFloatM('workflow_manager_get_tasks_seconds', 'Time spent loading workflow tasks from db'),
|
||||||
|
# dispatcher subsystem metrics
|
||||||
|
SetIntM('dispatcher_pool_scale_up_events', 'Number of times local dispatcher scaled up a worker since startup'),
|
||||||
|
SetIntM('dispatcher_pool_active_task_count', 'Number of active tasks in the worker pool when last task was submitted'),
|
||||||
|
SetIntM('dispatcher_pool_max_worker_count', 'Highest number of workers in worker pool in last collection interval, about 20s'),
|
||||||
|
SetFloatM('dispatcher_availability', 'Fraction of time (in last collection interval) dispatcher was able to receive messages'),
|
||||||
|
]
|
||||||
# turn metric list into dictionary with the metric name as a key
|
# turn metric list into dictionary with the metric name as a key
|
||||||
self.METRICS = {}
|
self.METRICS = {}
|
||||||
for m in itertools.chain(self.METRICSLIST, self._METRICSLIST):
|
for m in METRICSLIST:
|
||||||
self.METRICS[m.field] = m
|
self.METRICS[m.field] = m
|
||||||
|
|
||||||
# track last time metrics were sent to other nodes
|
# track last time metrics were sent to other nodes
|
||||||
@@ -223,7 +230,7 @@ class Metrics(MetricsNamespace):
|
|||||||
m.reset_value(self.conn)
|
m.reset_value(self.conn)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
self.conn.delete(root_key + "_lock")
|
self.conn.delete(root_key + "_lock")
|
||||||
for m in self.conn.scan_iter(root_key + '-' + self._namespace + '_instance_*'):
|
for m in self.conn.scan_iter(root_key + '_instance_*'):
|
||||||
self.conn.delete(m)
|
self.conn.delete(m)
|
||||||
|
|
||||||
def inc(self, field, value):
|
def inc(self, field, value):
|
||||||
@@ -290,7 +297,7 @@ class Metrics(MetricsNamespace):
|
|||||||
def send_metrics(self):
|
def send_metrics(self):
|
||||||
# more than one thread could be calling this at the same time, so should
|
# more than one thread could be calling this at the same time, so should
|
||||||
# acquire redis lock before sending metrics
|
# acquire redis lock before sending metrics
|
||||||
lock = self.conn.lock(root_key + '-' + self._namespace + '_lock')
|
lock = self.conn.lock(root_key + '_lock')
|
||||||
if not lock.acquire(blocking=False):
|
if not lock.acquire(blocking=False):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -300,10 +307,9 @@ class Metrics(MetricsNamespace):
|
|||||||
payload = {
|
payload = {
|
||||||
'instance': self.instance_name,
|
'instance': self.instance_name,
|
||||||
'metrics': serialized_metrics,
|
'metrics': serialized_metrics,
|
||||||
'metrics_namespace': self._namespace,
|
|
||||||
}
|
}
|
||||||
# store the serialized data locally as well, so that load_other_metrics will read it
|
# store the serialized data locally as well, so that load_other_metrics will read it
|
||||||
self.conn.set(root_key + '-' + self._namespace + '_instance_' + self.instance_name, serialized_metrics)
|
self.conn.set(root_key + '_instance_' + self.instance_name, serialized_metrics)
|
||||||
emit_channel_notification("metrics", payload)
|
emit_channel_notification("metrics", payload)
|
||||||
|
|
||||||
self.previous_send_metrics.set(current_time)
|
self.previous_send_metrics.set(current_time)
|
||||||
@@ -325,14 +331,14 @@ class Metrics(MetricsNamespace):
|
|||||||
instances_filter = request.query_params.getlist("node")
|
instances_filter = request.query_params.getlist("node")
|
||||||
# get a sorted list of instance names
|
# get a sorted list of instance names
|
||||||
instance_names = [self.instance_name]
|
instance_names = [self.instance_name]
|
||||||
for m in self.conn.scan_iter(root_key + '-' + self._namespace + '_instance_*'):
|
for m in self.conn.scan_iter(root_key + '_instance_*'):
|
||||||
instance_names.append(m.decode('UTF-8').split('_instance_')[1])
|
instance_names.append(m.decode('UTF-8').split('_instance_')[1])
|
||||||
instance_names.sort()
|
instance_names.sort()
|
||||||
# load data, including data from the this local instance
|
# load data, including data from the this local instance
|
||||||
instance_data = {}
|
instance_data = {}
|
||||||
for instance in instance_names:
|
for instance in instance_names:
|
||||||
if len(instances_filter) == 0 or instance in instances_filter:
|
if len(instances_filter) == 0 or instance in instances_filter:
|
||||||
instance_data_from_redis = self.conn.get(root_key + '-' + self._namespace + '_instance_' + instance)
|
instance_data_from_redis = self.conn.get(root_key + '_instance_' + instance)
|
||||||
# data from other instances may not be available. That is OK.
|
# data from other instances may not be available. That is OK.
|
||||||
if instance_data_from_redis:
|
if instance_data_from_redis:
|
||||||
instance_data[instance] = json.loads(instance_data_from_redis.decode('UTF-8'))
|
instance_data[instance] = json.loads(instance_data_from_redis.decode('UTF-8'))
|
||||||
@@ -351,120 +357,6 @@ class Metrics(MetricsNamespace):
|
|||||||
return output_text
|
return output_text
|
||||||
|
|
||||||
|
|
||||||
class DispatcherMetrics(Metrics):
|
|
||||||
METRICSLIST = [
|
|
||||||
SetFloatM('task_manager_get_tasks_seconds', 'Time spent in loading tasks from db'),
|
|
||||||
SetFloatM('task_manager_start_task_seconds', 'Time spent starting task'),
|
|
||||||
SetFloatM('task_manager_process_running_tasks_seconds', 'Time spent processing running tasks'),
|
|
||||||
SetFloatM('task_manager_process_pending_tasks_seconds', 'Time spent processing pending tasks'),
|
|
||||||
SetFloatM('task_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
|
||||||
IntM('task_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
|
||||||
SetFloatM('task_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
|
||||||
SetIntM('task_manager_tasks_started', 'Number of tasks started'),
|
|
||||||
SetIntM('task_manager_running_processed', 'Number of running tasks processed'),
|
|
||||||
SetIntM('task_manager_pending_processed', 'Number of pending tasks processed'),
|
|
||||||
SetIntM('task_manager_tasks_blocked', 'Number of tasks blocked from running'),
|
|
||||||
SetFloatM('task_manager_commit_seconds', 'Time spent in db transaction, including on_commit calls'),
|
|
||||||
SetFloatM('dependency_manager_get_tasks_seconds', 'Time spent loading pending tasks from db'),
|
|
||||||
SetFloatM('dependency_manager_generate_dependencies_seconds', 'Time spent generating dependencies for pending tasks'),
|
|
||||||
SetFloatM('dependency_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
|
||||||
IntM('dependency_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
|
||||||
SetFloatM('dependency_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
|
||||||
SetIntM('dependency_manager_pending_processed', 'Number of pending tasks processed'),
|
|
||||||
SetFloatM('workflow_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
|
||||||
IntM('workflow_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
|
||||||
SetFloatM('workflow_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
|
||||||
SetFloatM('workflow_manager_spawn_workflow_graph_jobs_seconds', 'Time spent spawning workflow tasks'),
|
|
||||||
SetFloatM('workflow_manager_get_tasks_seconds', 'Time spent loading workflow tasks from db'),
|
|
||||||
# dispatcher subsystem metrics
|
|
||||||
SetIntM('dispatcher_pool_scale_up_events', 'Number of times local dispatcher scaled up a worker since startup'),
|
|
||||||
SetIntM('dispatcher_pool_active_task_count', 'Number of active tasks in the worker pool when last task was submitted'),
|
|
||||||
SetIntM('dispatcher_pool_max_worker_count', 'Highest number of workers in worker pool in last collection interval, about 20s'),
|
|
||||||
SetFloatM('dispatcher_availability', 'Fraction of time (in last collection interval) dispatcher was able to receive messages'),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(settings.METRICS_SERVICE_DISPATCHER, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class CallbackReceiverMetrics(Metrics):
|
|
||||||
METRICSLIST = [
|
|
||||||
SetIntM('callback_receiver_events_queue_size_redis', 'Current number of events in redis queue'),
|
|
||||||
IntM('callback_receiver_events_popped_redis', 'Number of events popped from redis'),
|
|
||||||
IntM('callback_receiver_events_in_memory', 'Current number of events in memory (in transfer from redis to db)'),
|
|
||||||
IntM('callback_receiver_batch_events_errors', 'Number of times batch insertion failed'),
|
|
||||||
FloatM('callback_receiver_events_insert_db_seconds', 'Total time spent saving events to database'),
|
|
||||||
IntM('callback_receiver_events_insert_db', 'Number of events batch inserted into database'),
|
|
||||||
IntM('callback_receiver_events_broadcast', 'Number of events broadcast to other control plane nodes'),
|
|
||||||
HistogramM(
|
|
||||||
'callback_receiver_batch_events_insert_db', 'Number of events batch inserted into database', settings.SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS
|
|
||||||
),
|
|
||||||
SetFloatM('callback_receiver_event_processing_avg_seconds', 'Average processing time per event per callback receiver batch'),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(settings.METRICS_SERVICE_CALLBACK_RECEIVER, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def metrics(request):
|
def metrics(request):
|
||||||
output_text = ''
|
m = Metrics()
|
||||||
for m in [DispatcherMetrics(), CallbackReceiverMetrics()]:
|
return m.generate_metrics(request)
|
||||||
output_text += m.generate_metrics(request)
|
|
||||||
return output_text
|
|
||||||
|
|
||||||
|
|
||||||
class CustomToPrometheusMetricsCollector(prometheus_client.registry.Collector):
|
|
||||||
"""
|
|
||||||
Takes the metric data from redis -> our custom metric fields -> prometheus
|
|
||||||
library metric fields.
|
|
||||||
|
|
||||||
The plan is to get rid of the use of redis, our custom metric fields, and
|
|
||||||
to switch fully to the prometheus library. At that point, this translation
|
|
||||||
code will be deleted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, metrics_obj, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._metrics = metrics_obj
|
|
||||||
|
|
||||||
def collect(self):
|
|
||||||
my_hostname = settings.CLUSTER_HOST_ID
|
|
||||||
|
|
||||||
instance_data = self._metrics.load_other_metrics(Request(HttpRequest()))
|
|
||||||
if not instance_data:
|
|
||||||
logger.debug(f"No metric data not found in redis for metric namespace '{self._metrics._namespace}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
host_metrics = instance_data.get(my_hostname)
|
|
||||||
for _, metric in self._metrics.METRICS.items():
|
|
||||||
entry = host_metrics.get(metric.field)
|
|
||||||
if not entry:
|
|
||||||
logger.debug(f"{self._metrics._namespace} metric '{metric.field}' not found in redis data payload {json.dumps(instance_data, indent=2)}")
|
|
||||||
continue
|
|
||||||
if isinstance(metric, HistogramM):
|
|
||||||
buckets = list(zip(metric.buckets, entry['counts']))
|
|
||||||
buckets = [[str(i[0]), str(i[1])] for i in buckets]
|
|
||||||
yield HistogramMetricFamily(metric.field, metric.help_text, buckets=buckets, sum_value=entry['sum'])
|
|
||||||
else:
|
|
||||||
yield GaugeMetricFamily(metric.field, metric.help_text, value=entry)
|
|
||||||
|
|
||||||
|
|
||||||
class CallbackReceiverMetricsServer(MetricsServer):
|
|
||||||
def __init__(self):
|
|
||||||
registry = CollectorRegistry(auto_describe=True)
|
|
||||||
registry.register(CustomToPrometheusMetricsCollector(DispatcherMetrics(metrics_have_changed=False)))
|
|
||||||
super().__init__(settings.METRICS_SERVICE_CALLBACK_RECEIVER, registry)
|
|
||||||
|
|
||||||
|
|
||||||
class DispatcherMetricsServer(MetricsServer):
|
|
||||||
def __init__(self):
|
|
||||||
registry = CollectorRegistry(auto_describe=True)
|
|
||||||
registry.register(CustomToPrometheusMetricsCollector(CallbackReceiverMetrics(metrics_have_changed=False)))
|
|
||||||
super().__init__(settings.METRICS_SERVICE_DISPATCHER, registry)
|
|
||||||
|
|
||||||
|
|
||||||
class WebsocketsMetricsServer(MetricsServer):
|
|
||||||
def __init__(self):
|
|
||||||
registry = CollectorRegistry(auto_describe=True)
|
|
||||||
# registry.register()
|
|
||||||
super().__init__(settings.METRICS_SERVICE_WEBSOCKETS, registry)
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class RelayConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
if group == "metrics":
|
if group == "metrics":
|
||||||
message = json.loads(message['text'])
|
message = json.loads(message['text'])
|
||||||
conn = redis.Redis.from_url(settings.BROKER_URL)
|
conn = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
conn.set(settings.SUBSYSTEM_METRICS_REDIS_KEY_PREFIX + "-" + message['metrics_namespace'] + "_instance_" + message['instance'], message['metrics'])
|
conn.set(settings.SUBSYSTEM_METRICS_REDIS_KEY_PREFIX + "_instance_" + message['instance'], message['metrics'])
|
||||||
else:
|
else:
|
||||||
await self.channel_layer.group_send(group, message)
|
await self.channel_layer.group_send(group, message)
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,7 @@ def create_listener_connection():
|
|||||||
for k, v in settings.LISTENER_DATABASES.get('default', {}).get('OPTIONS', {}).items():
|
for k, v in settings.LISTENER_DATABASES.get('default', {}).get('OPTIONS', {}).items():
|
||||||
conf['OPTIONS'][k] = v
|
conf['OPTIONS'][k] = v
|
||||||
|
|
||||||
# Allow password-less authentication
|
connection_data = f"dbname={conf['NAME']} host={conf['HOST']} user={conf['USER']} password={conf['PASSWORD']} port={conf['PORT']}"
|
||||||
if 'PASSWORD' in conf:
|
|
||||||
conf['OPTIONS']['password'] = conf.pop('PASSWORD')
|
|
||||||
|
|
||||||
connection_data = f"dbname={conf['NAME']} host={conf['HOST']} user={conf['USER']} port={conf['PORT']}"
|
|
||||||
return psycopg.connect(connection_data, autocommit=True, **conf['OPTIONS'])
|
return psycopg.connect(connection_data, autocommit=True, **conf['OPTIONS'])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -162,13 +162,13 @@ class AWXConsumerRedis(AWXConsumerBase):
|
|||||||
class AWXConsumerPG(AWXConsumerBase):
|
class AWXConsumerPG(AWXConsumerBase):
|
||||||
def __init__(self, *args, schedule=None, **kwargs):
|
def __init__(self, *args, schedule=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.pg_max_wait = getattr(settings, 'DISPATCHER_DB_DOWNTOWN_TOLLERANCE', settings.DISPATCHER_DB_DOWNTIME_TOLERANCE)
|
self.pg_max_wait = settings.DISPATCHER_DB_DOWNTIME_TOLERANCE
|
||||||
# if no successful loops have ran since startup, then we should fail right away
|
# if no successful loops have ran since startup, then we should fail right away
|
||||||
self.pg_is_down = True # set so that we fail if we get database errors on startup
|
self.pg_is_down = True # set so that we fail if we get database errors on startup
|
||||||
init_time = time.time()
|
init_time = time.time()
|
||||||
self.pg_down_time = init_time - self.pg_max_wait # allow no grace period
|
self.pg_down_time = init_time - self.pg_max_wait # allow no grace period
|
||||||
self.last_cleanup = init_time
|
self.last_cleanup = init_time
|
||||||
self.subsystem_metrics = s_metrics.DispatcherMetrics(auto_pipe_execute=False)
|
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
||||||
self.last_metrics_gather = init_time
|
self.last_metrics_gather = init_time
|
||||||
self.listen_cumulative_time = 0.0
|
self.listen_cumulative_time = 0.0
|
||||||
if schedule:
|
if schedule:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.buff = {}
|
self.buff = {}
|
||||||
self.redis = redis.Redis.from_url(settings.BROKER_URL)
|
self.redis = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
self.subsystem_metrics = s_metrics.CallbackReceiverMetrics(auto_pipe_execute=False)
|
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
||||||
self.queue_pop = 0
|
self.queue_pop = 0
|
||||||
self.queue_name = settings.CALLBACK_QUEUE
|
self.queue_name = settings.CALLBACK_QUEUE
|
||||||
self.prof = AWXProfiler("CallbackBrokerWorker")
|
self.prof = AWXProfiler("CallbackBrokerWorker")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from jinja2 import sandbox, StrictUndefined
|
from jinja2 import sandbox, StrictUndefined
|
||||||
@@ -407,13 +406,11 @@ class SmartFilterField(models.TextField):
|
|||||||
# https://docs.python.org/2/library/stdtypes.html#truth-value-testing
|
# https://docs.python.org/2/library/stdtypes.html#truth-value-testing
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
# avoid doing too much during migrations
|
value = urllib.parse.unquote(value)
|
||||||
if 'migrate' not in sys.argv:
|
try:
|
||||||
value = urllib.parse.unquote(value)
|
SmartFilter().query_from_string(value)
|
||||||
try:
|
except RuntimeError as e:
|
||||||
SmartFilter().query_from_string(value)
|
raise models.base.ValidationError(e)
|
||||||
except RuntimeError as e:
|
|
||||||
raise models.base.ValidationError(e)
|
|
||||||
return super(SmartFilterField, self).get_prep_value(value)
|
return super(SmartFilterField, self).get_prep_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# Copyright (c) 2015 Ansible, Inc.
|
|
||||||
# All Rights Reserved
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from awx.main.models import Instance, ReceptorAddress
|
|
||||||
|
|
||||||
|
|
||||||
def add_address(**kwargs):
|
|
||||||
try:
|
|
||||||
instance = Instance.objects.get(hostname=kwargs.pop('instance'))
|
|
||||||
kwargs['instance'] = instance
|
|
||||||
|
|
||||||
if kwargs.get('canonical') and instance.receptor_addresses.filter(canonical=True).exclude(address=kwargs['address']).exists():
|
|
||||||
print(f"Instance {instance.hostname} already has a canonical address, skipping")
|
|
||||||
return False
|
|
||||||
# if ReceptorAddress already exists with address, just update
|
|
||||||
# otherwise, create new ReceptorAddress
|
|
||||||
addr, _ = ReceptorAddress.objects.update_or_create(address=kwargs.pop('address'), defaults=kwargs)
|
|
||||||
print(f"Successfully added receptor address {addr.get_full_address()}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error adding receptor address: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""
|
|
||||||
Internal controller command.
|
|
||||||
Register receptor address to an already-registered instance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
help = "Add receptor address to an instance."
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument('--instance', dest='instance', required=True, type=str, help="Instance hostname this address is added to")
|
|
||||||
parser.add_argument('--address', dest='address', required=True, type=str, help="Receptor address")
|
|
||||||
parser.add_argument('--port', dest='port', type=int, help="Receptor listener port")
|
|
||||||
parser.add_argument('--websocket_path', dest='websocket_path', type=str, default="", help="Path for websockets")
|
|
||||||
parser.add_argument('--is_internal', action='store_true', help="If true, address only resolvable within the Kubernetes cluster")
|
|
||||||
parser.add_argument('--protocol', type=str, default='tcp', choices=['tcp', 'ws', 'wss'], help="Protocol to use for the Receptor listener")
|
|
||||||
parser.add_argument('--canonical', action='store_true', help="If true, address is the canonical address for the instance")
|
|
||||||
parser.add_argument('--peers_from_control_nodes', action='store_true', help="If true, control nodes will peer to this address")
|
|
||||||
|
|
||||||
def handle(self, **options):
|
|
||||||
address_options = {
|
|
||||||
k: options[k]
|
|
||||||
for k in ('instance', 'address', 'port', 'websocket_path', 'is_internal', 'protocol', 'peers_from_control_nodes', 'canonical')
|
|
||||||
if options[k]
|
|
||||||
}
|
|
||||||
changed = add_address(**address_options)
|
|
||||||
if changed:
|
|
||||||
print("(changed: True)")
|
|
||||||
@@ -55,7 +55,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
capacity = f' capacity={x.capacity}' if x.node_type != 'hop' else ''
|
capacity = f' capacity={x.capacity}' if x.node_type != 'hop' else ''
|
||||||
version = f" version={x.version or '?'}" if x.node_type != 'hop' else ''
|
version = f" version={x.version or '?'}" if x.node_type != 'hop' else ''
|
||||||
heartbeat = f' heartbeat="{x.last_seen:%Y-%m-%d %H:%M:%S}"' if x.last_seen else ''
|
heartbeat = f' heartbeat="{x.last_seen:%Y-%m-%d %H:%M:%S}"' if x.capacity or x.node_type == 'hop' else ''
|
||||||
print(f'\t{color}{x.hostname}{capacity} node_type={x.node_type}{version}{heartbeat}{end_color}')
|
print(f'\t{color}{x.hostname}{capacity} node_type={x.node_type}{version}{heartbeat}{end_color}')
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -25,17 +25,20 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--hostname', dest='hostname', type=str, help="Hostname used during provisioning")
|
parser.add_argument('--hostname', dest='hostname', type=str, help="Hostname used during provisioning")
|
||||||
|
parser.add_argument('--listener_port', dest='listener_port', type=int, help="Receptor listener port")
|
||||||
parser.add_argument('--node_type', type=str, default='hybrid', choices=['control', 'execution', 'hop', 'hybrid'], help="Instance Node type")
|
parser.add_argument('--node_type', type=str, default='hybrid', choices=['control', 'execution', 'hop', 'hybrid'], help="Instance Node type")
|
||||||
parser.add_argument('--uuid', type=str, help="Instance UUID")
|
parser.add_argument('--uuid', type=str, help="Instance UUID")
|
||||||
|
|
||||||
def _register_hostname(self, hostname, node_type, uuid):
|
def _register_hostname(self, hostname, node_type, uuid, listener_port):
|
||||||
if not hostname:
|
if not hostname:
|
||||||
if not settings.AWX_AUTO_DEPROVISION_INSTANCES:
|
if not settings.AWX_AUTO_DEPROVISION_INSTANCES:
|
||||||
raise CommandError('Registering with values from settings only intended for use in K8s installs')
|
raise CommandError('Registering with values from settings only intended for use in K8s installs')
|
||||||
|
|
||||||
from awx.main.management.commands.register_queue import RegisterQueue
|
from awx.main.management.commands.register_queue import RegisterQueue
|
||||||
|
|
||||||
(changed, instance) = Instance.objects.register(ip_address=os.environ.get('MY_POD_IP'), node_type='control', node_uuid=settings.SYSTEM_UUID)
|
(changed, instance) = Instance.objects.register(
|
||||||
|
ip_address=os.environ.get('MY_POD_IP'), listener_port=listener_port, node_type='control', node_uuid=settings.SYSTEM_UUID
|
||||||
|
)
|
||||||
RegisterQueue(settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME, 100, 0, [], is_container_group=False).register()
|
RegisterQueue(settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME, 100, 0, [], is_container_group=False).register()
|
||||||
RegisterQueue(
|
RegisterQueue(
|
||||||
settings.DEFAULT_EXECUTION_QUEUE_NAME,
|
settings.DEFAULT_EXECUTION_QUEUE_NAME,
|
||||||
@@ -48,17 +51,16 @@ class Command(BaseCommand):
|
|||||||
max_concurrent_jobs=settings.DEFAULT_EXECUTION_QUEUE_MAX_CONCURRENT_JOBS,
|
max_concurrent_jobs=settings.DEFAULT_EXECUTION_QUEUE_MAX_CONCURRENT_JOBS,
|
||||||
).register()
|
).register()
|
||||||
else:
|
else:
|
||||||
(changed, instance) = Instance.objects.register(hostname=hostname, node_type=node_type, node_uuid=uuid)
|
(changed, instance) = Instance.objects.register(hostname=hostname, node_type=node_type, node_uuid=uuid, listener_port=listener_port)
|
||||||
if changed:
|
if changed:
|
||||||
print("Successfully registered instance {}".format(hostname))
|
print("Successfully registered instance {}".format(hostname))
|
||||||
else:
|
else:
|
||||||
print("Instance already registered {}".format(instance.hostname))
|
print("Instance already registered {}".format(instance.hostname))
|
||||||
|
|
||||||
self.changed = changed
|
self.changed = changed
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def handle(self, **options):
|
def handle(self, **options):
|
||||||
self.changed = False
|
self.changed = False
|
||||||
self._register_hostname(options.get('hostname'), options.get('node_type'), options.get('uuid'))
|
self._register_hostname(options.get('hostname'), options.get('node_type'), options.get('uuid'), options.get('listener_port'))
|
||||||
if self.changed:
|
if self.changed:
|
||||||
print("(changed: True)")
|
print("(changed: True)")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import warnings
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from awx.main.models import Instance, InstanceLink, ReceptorAddress
|
from awx.main.models import Instance, InstanceLink
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -26,9 +28,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, **options):
|
def handle(self, **options):
|
||||||
# provides a mapping of hostname to Instance objects
|
# provides a mapping of hostname to Instance objects
|
||||||
nodes = Instance.objects.all().in_bulk(field_name='hostname')
|
nodes = Instance.objects.in_bulk(field_name='hostname')
|
||||||
# provides a mapping of address to ReceptorAddress objects
|
|
||||||
addresses = ReceptorAddress.objects.all().in_bulk(field_name='address')
|
|
||||||
|
|
||||||
if options['source'] not in nodes:
|
if options['source'] not in nodes:
|
||||||
raise CommandError(f"Host {options['source']} is not a registered instance.")
|
raise CommandError(f"Host {options['source']} is not a registered instance.")
|
||||||
@@ -39,14 +39,6 @@ class Command(BaseCommand):
|
|||||||
if options['exact'] is not None and options['disconnect']:
|
if options['exact'] is not None and options['disconnect']:
|
||||||
raise CommandError("The option --disconnect may not be used with --exact.")
|
raise CommandError("The option --disconnect may not be used with --exact.")
|
||||||
|
|
||||||
# make sure each target has a receptor address
|
|
||||||
peers = options['peers'] or []
|
|
||||||
disconnect = options['disconnect'] or []
|
|
||||||
exact = options['exact'] or []
|
|
||||||
for peer in peers + disconnect + exact:
|
|
||||||
if peer not in addresses:
|
|
||||||
raise CommandError(f"Peer {peer} does not have a receptor address.")
|
|
||||||
|
|
||||||
# No 1-cycles
|
# No 1-cycles
|
||||||
for collection in ('peers', 'disconnect', 'exact'):
|
for collection in ('peers', 'disconnect', 'exact'):
|
||||||
if options[collection] is not None and options['source'] in options[collection]:
|
if options[collection] is not None and options['source'] in options[collection]:
|
||||||
@@ -55,12 +47,9 @@ class Command(BaseCommand):
|
|||||||
# No 2-cycles
|
# No 2-cycles
|
||||||
if options['peers'] or options['exact'] is not None:
|
if options['peers'] or options['exact'] is not None:
|
||||||
peers = set(options['peers'] or options['exact'])
|
peers = set(options['peers'] or options['exact'])
|
||||||
if options['source'] in addresses:
|
incoming = set(InstanceLink.objects.filter(target=nodes[options['source']]).values_list('source__hostname', flat=True))
|
||||||
incoming = set(InstanceLink.objects.filter(target=addresses[options['source']]).values_list('source__hostname', flat=True))
|
|
||||||
else:
|
|
||||||
incoming = set()
|
|
||||||
if peers & incoming:
|
if peers & incoming:
|
||||||
raise CommandError(f"Source node {options['source']} should not link to nodes already peering to it: {peers & incoming}.")
|
warnings.warn(f"Source node {options['source']} should not link to nodes already peering to it: {peers & incoming}.")
|
||||||
|
|
||||||
if options['peers']:
|
if options['peers']:
|
||||||
missing_peers = set(options['peers']) - set(nodes)
|
missing_peers = set(options['peers']) - set(nodes)
|
||||||
@@ -71,7 +60,7 @@ class Command(BaseCommand):
|
|||||||
results = 0
|
results = 0
|
||||||
for target in options['peers']:
|
for target in options['peers']:
|
||||||
_, created = InstanceLink.objects.update_or_create(
|
_, created = InstanceLink.objects.update_or_create(
|
||||||
source=nodes[options['source']], target=addresses[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
|
source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
results += 1
|
results += 1
|
||||||
@@ -81,9 +70,9 @@ class Command(BaseCommand):
|
|||||||
if options['disconnect']:
|
if options['disconnect']:
|
||||||
results = 0
|
results = 0
|
||||||
for target in options['disconnect']:
|
for target in options['disconnect']:
|
||||||
if target not in addresses: # Be permissive, the node might have already been de-registered.
|
if target not in nodes: # Be permissive, the node might have already been de-registered.
|
||||||
continue
|
continue
|
||||||
n, _ = InstanceLink.objects.filter(source=nodes[options['source']], target=addresses[target]).delete()
|
n, _ = InstanceLink.objects.filter(source=nodes[options['source']], target=nodes[target]).delete()
|
||||||
results += n
|
results += n
|
||||||
|
|
||||||
print(f"{results} peer links removed from the database.")
|
print(f"{results} peer links removed from the database.")
|
||||||
@@ -92,11 +81,11 @@ class Command(BaseCommand):
|
|||||||
additions = 0
|
additions = 0
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
peers = set(options['exact'])
|
peers = set(options['exact'])
|
||||||
links = set(InstanceLink.objects.filter(source=nodes[options['source']]).values_list('target__address', flat=True))
|
links = set(InstanceLink.objects.filter(source=nodes[options['source']]).values_list('target__hostname', flat=True))
|
||||||
removals, _ = InstanceLink.objects.filter(source=nodes[options['source']], target__instance__hostname__in=links - peers).delete()
|
removals, _ = InstanceLink.objects.filter(source=nodes[options['source']], target__hostname__in=links - peers).delete()
|
||||||
for target in peers - links:
|
for target in peers - links:
|
||||||
_, created = InstanceLink.objects.update_or_create(
|
_, created = InstanceLink.objects.update_or_create(
|
||||||
source=nodes[options['source']], target=addresses[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
|
source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
additions += 1
|
additions += 1
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Copyright (c) 2015 Ansible, Inc.
|
|
||||||
# All Rights Reserved
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from awx.main.models import ReceptorAddress
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""
|
|
||||||
Internal controller command.
|
|
||||||
Delete a receptor address.
|
|
||||||
"""
|
|
||||||
|
|
||||||
help = "Add receptor address to an instance."
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument('--address', dest='address', type=str, help="Receptor address to remove")
|
|
||||||
|
|
||||||
def handle(self, **options):
|
|
||||||
deleted = ReceptorAddress.objects.filter(address=options['address']).delete()
|
|
||||||
if deleted[0]:
|
|
||||||
print(f"Successfully removed {options['address']}")
|
|
||||||
print("(changed: True)")
|
|
||||||
else:
|
|
||||||
print(f"Did not remove {options['address']}, not found")
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from awx.main.analytics.subsystem_metrics import CallbackReceiverMetricsServer
|
|
||||||
|
|
||||||
from awx.main.dispatch.control import Control
|
from awx.main.dispatch.control import Control
|
||||||
from awx.main.dispatch.worker import AWXConsumerRedis, CallbackBrokerWorker
|
from awx.main.dispatch.worker import AWXConsumerRedis, CallbackBrokerWorker
|
||||||
@@ -26,9 +25,6 @@ class Command(BaseCommand):
|
|||||||
print(Control('callback_receiver').status())
|
print(Control('callback_receiver').status())
|
||||||
return
|
return
|
||||||
consumer = None
|
consumer = None
|
||||||
|
|
||||||
CallbackReceiverMetricsServer().start()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
consumer = AWXConsumerRedis(
|
consumer = AWXConsumerRedis(
|
||||||
'callback_receiver',
|
'callback_receiver',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from awx.main.dispatch import get_task_queuename
|
|||||||
from awx.main.dispatch.control import Control
|
from awx.main.dispatch.control import Control
|
||||||
from awx.main.dispatch.pool import AutoscalePool
|
from awx.main.dispatch.pool import AutoscalePool
|
||||||
from awx.main.dispatch.worker import AWXConsumerPG, TaskWorker
|
from awx.main.dispatch.worker import AWXConsumerPG, TaskWorker
|
||||||
from awx.main.analytics.subsystem_metrics import DispatcherMetricsServer
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.dispatch')
|
logger = logging.getLogger('awx.main.dispatch')
|
||||||
|
|
||||||
@@ -63,8 +62,6 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
consumer = None
|
consumer = None
|
||||||
|
|
||||||
DispatcherMetricsServer().start()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
queues = ['tower_broadcast_all', 'tower_settings_change', get_task_queuename()]
|
queues = ['tower_broadcast_all', 'tower_settings_change', get_task_queuename()]
|
||||||
consumer = AWXConsumerPG('dispatcher', TaskWorker(), queues, AutoscalePool(min_workers=4), schedule=settings.CELERYBEAT_SCHEDULE)
|
consumer = AWXConsumerPG('dispatcher', TaskWorker(), queues, AutoscalePool(min_workers=4), schedule=settings.CELERYBEAT_SCHEDULE)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from awx.main.analytics.broadcast_websocket import (
|
|||||||
RelayWebsocketStatsManager,
|
RelayWebsocketStatsManager,
|
||||||
safe_name,
|
safe_name,
|
||||||
)
|
)
|
||||||
from awx.main.analytics.subsystem_metrics import WebsocketsMetricsServer
|
|
||||||
from awx.main.wsrelay import WebSocketRelayManager
|
from awx.main.wsrelay import WebSocketRelayManager
|
||||||
|
|
||||||
|
|
||||||
@@ -92,8 +91,6 @@ class Command(BaseCommand):
|
|||||||
return host_stats
|
return host_stats
|
||||||
|
|
||||||
def handle(self, *arg, **options):
|
def handle(self, *arg, **options):
|
||||||
WebsocketsMetricsServer().start()
|
|
||||||
|
|
||||||
# it's necessary to delay this import in case
|
# it's necessary to delay this import in case
|
||||||
# database migrations are still running
|
# database migrations are still running
|
||||||
from awx.main.models.ha import Instance
|
from awx.main.models.ha import Instance
|
||||||
|
|||||||
@@ -115,14 +115,7 @@ class InstanceManager(models.Manager):
|
|||||||
return node[0]
|
return node[0]
|
||||||
raise RuntimeError("No instance found with the current cluster host id")
|
raise RuntimeError("No instance found with the current cluster host id")
|
||||||
|
|
||||||
def register(
|
def register(self, node_uuid=None, hostname=None, ip_address="", listener_port=None, node_type='hybrid', defaults=None):
|
||||||
self,
|
|
||||||
node_uuid=None,
|
|
||||||
hostname=None,
|
|
||||||
ip_address="",
|
|
||||||
node_type='hybrid',
|
|
||||||
defaults=None,
|
|
||||||
):
|
|
||||||
if not hostname:
|
if not hostname:
|
||||||
hostname = settings.CLUSTER_HOST_ID
|
hostname = settings.CLUSTER_HOST_ID
|
||||||
|
|
||||||
@@ -168,6 +161,9 @@ class InstanceManager(models.Manager):
|
|||||||
if instance.node_type != node_type:
|
if instance.node_type != node_type:
|
||||||
instance.node_type = node_type
|
instance.node_type = node_type
|
||||||
update_fields.append('node_type')
|
update_fields.append('node_type')
|
||||||
|
if instance.listener_port != listener_port:
|
||||||
|
instance.listener_port = listener_port
|
||||||
|
update_fields.append('listener_port')
|
||||||
if update_fields:
|
if update_fields:
|
||||||
instance.save(update_fields=update_fields)
|
instance.save(update_fields=update_fields)
|
||||||
return (True, instance)
|
return (True, instance)
|
||||||
@@ -178,13 +174,11 @@ class InstanceManager(models.Manager):
|
|||||||
create_defaults = {
|
create_defaults = {
|
||||||
'node_state': Instance.States.INSTALLED,
|
'node_state': Instance.States.INSTALLED,
|
||||||
'capacity': 0,
|
'capacity': 0,
|
||||||
'managed': True,
|
|
||||||
}
|
}
|
||||||
if defaults is not None:
|
if defaults is not None:
|
||||||
create_defaults.update(defaults)
|
create_defaults.update(defaults)
|
||||||
uuid_option = {'uuid': node_uuid if node_uuid is not None else uuid.uuid4()}
|
uuid_option = {'uuid': node_uuid if node_uuid is not None else uuid.uuid4()}
|
||||||
if node_type == 'execution' and 'version' not in create_defaults:
|
if node_type == 'execution' and 'version' not in create_defaults:
|
||||||
create_defaults['version'] = RECEPTOR_PENDING
|
create_defaults['version'] = RECEPTOR_PENDING
|
||||||
instance = self.create(hostname=hostname, ip_address=ip_address, node_type=node_type, **create_defaults, **uuid_option)
|
instance = self.create(hostname=hostname, ip_address=ip_address, listener_port=listener_port, node_type=node_type, **create_defaults, **uuid_option)
|
||||||
|
|
||||||
return (True, instance)
|
return (True, instance)
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import logout
|
from django.contrib.auth import logout
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.migrations.recorder import MigrationRecorder
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
@@ -18,11 +17,9 @@ from django.utils.deprecation import MiddlewareMixin
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.urls import reverse, resolve
|
from django.urls import reverse, resolve
|
||||||
|
|
||||||
from awx.main import migrations
|
|
||||||
from awx.main.utils.named_url_graph import generate_graph, GraphNode
|
from awx.main.utils.named_url_graph import generate_graph, GraphNode
|
||||||
from awx.conf import fields, register
|
from awx.conf import fields, register
|
||||||
from awx.main.utils.profiling import AWXProfiler
|
from awx.main.utils.profiling import AWXProfiler
|
||||||
from awx.main.utils.common import memoize
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.middleware')
|
logger = logging.getLogger('awx.main.middleware')
|
||||||
@@ -201,22 +198,9 @@ class URLModificationMiddleware(MiddlewareMixin):
|
|||||||
request.path_info = new_path
|
request.path_info = new_path
|
||||||
|
|
||||||
|
|
||||||
@memoize(ttl=20)
|
|
||||||
def is_migrating():
|
|
||||||
latest_number = 0
|
|
||||||
latest_name = ''
|
|
||||||
for migration_path in Path(migrations.__path__[0]).glob('[0-9]*.py'):
|
|
||||||
try:
|
|
||||||
migration_number = int(migration_path.name.split('_', 1)[0])
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if migration_number > latest_number:
|
|
||||||
latest_number = migration_number
|
|
||||||
latest_name = migration_path.name[: -len('.py')]
|
|
||||||
return not MigrationRecorder(connection).migration_qs.filter(app='main', name=latest_name).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class MigrationRanCheckMiddleware(MiddlewareMixin):
|
class MigrationRanCheckMiddleware(MiddlewareMixin):
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
if is_migrating() and getattr(resolve(request.path), 'url_name', '') != 'migrations_notran':
|
executor = MigrationExecutor(connection)
|
||||||
|
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
|
||||||
|
if bool(plan) and getattr(resolve(request.path), 'url_name', '') != 'migrations_notran':
|
||||||
return redirect(reverse("ui:migrations_notran"))
|
return redirect(reverse("ui:migrations_notran"))
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
# Generated by Django 4.2.6 on 2024-01-19 19:24
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
def create_receptor_addresses(apps, schema_editor):
|
|
||||||
"""
|
|
||||||
If listener_port was defined on an instance, create a receptor address for it
|
|
||||||
"""
|
|
||||||
Instance = apps.get_model('main', 'Instance')
|
|
||||||
ReceptorAddress = apps.get_model('main', 'ReceptorAddress')
|
|
||||||
for instance in Instance.objects.exclude(listener_port=None):
|
|
||||||
ReceptorAddress.objects.create(
|
|
||||||
instance=instance,
|
|
||||||
address=instance.hostname,
|
|
||||||
port=instance.listener_port,
|
|
||||||
peers_from_control_nodes=instance.peers_from_control_nodes,
|
|
||||||
protocol='tcp',
|
|
||||||
is_internal=False,
|
|
||||||
canonical=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def link_to_receptor_addresses(apps, schema_editor):
|
|
||||||
"""
|
|
||||||
Modify each InstanceLink to point to the newly created
|
|
||||||
ReceptorAddresses, using the new target field
|
|
||||||
"""
|
|
||||||
InstanceLink = apps.get_model('main', 'InstanceLink')
|
|
||||||
for link in InstanceLink.objects.all():
|
|
||||||
link.target = link.target_old.receptor_addresses.get()
|
|
||||||
link.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
('main', '0188_add_bitbucket_dc_webhook'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ReceptorAddress',
|
|
||||||
fields=[
|
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('address', models.CharField(help_text='Routable address for this instance.', max_length=255)),
|
|
||||||
(
|
|
||||||
'port',
|
|
||||||
models.IntegerField(
|
|
||||||
default=27199,
|
|
||||||
help_text='Port for the address.',
|
|
||||||
validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(65535)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
('websocket_path', models.CharField(blank=True, default='', help_text='Websocket path.', max_length=255)),
|
|
||||||
(
|
|
||||||
'protocol',
|
|
||||||
models.CharField(
|
|
||||||
choices=[('tcp', 'TCP'), ('ws', 'WS'), ('wss', 'WSS')],
|
|
||||||
default='tcp',
|
|
||||||
help_text="Protocol to use for the Receptor listener, 'tcp', 'wss', or 'ws'.",
|
|
||||||
max_length=10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
('is_internal', models.BooleanField(default=False, help_text='If True, only routable within the Kubernetes cluster.')),
|
|
||||||
('canonical', models.BooleanField(default=False, help_text='If True, this address is the canonical address for the instance.')),
|
|
||||||
(
|
|
||||||
'peers_from_control_nodes',
|
|
||||||
models.BooleanField(default=False, help_text='If True, control plane cluster nodes should automatically peer to it.'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name='instancelink',
|
|
||||||
name='source_and_target_can_not_be_equal',
|
|
||||||
),
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name='instancelink',
|
|
||||||
old_name='target',
|
|
||||||
new_name='target_old',
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name='instancelink',
|
|
||||||
unique_together=set(),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='instance',
|
|
||||||
name='managed',
|
|
||||||
field=models.BooleanField(default=False, editable=False, help_text='If True, this instance is managed by the control plane.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='instancelink',
|
|
||||||
name='source',
|
|
||||||
field=models.ForeignKey(help_text='The source instance of this peer link.', on_delete=django.db.models.deletion.CASCADE, to='main.instance'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='receptoraddress',
|
|
||||||
name='instance',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receptor_addresses', to='main.instance'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='activitystream',
|
|
||||||
name='receptor_address',
|
|
||||||
field=models.ManyToManyField(blank=True, to='main.receptoraddress'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='receptoraddress',
|
|
||||||
constraint=models.UniqueConstraint(fields=('address',), name='unique_receptor_address', violation_error_message='Receptor address must be unique.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='instancelink',
|
|
||||||
name='target',
|
|
||||||
field=models.ForeignKey(
|
|
||||||
help_text='The target receptor address of this peer link.', null=True, on_delete=django.db.models.deletion.CASCADE, to='main.receptoraddress'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RunPython(create_receptor_addresses),
|
|
||||||
migrations.RunPython(link_to_receptor_addresses),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='instance',
|
|
||||||
name='peers_from_control_nodes',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='instance',
|
|
||||||
name='listener_port',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='instancelink',
|
|
||||||
name='target_old',
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='instance',
|
|
||||||
name='peers',
|
|
||||||
field=models.ManyToManyField(related_name='peers_from', through='main.InstanceLink', to='main.receptoraddress'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='instancelink',
|
|
||||||
name='target',
|
|
||||||
field=models.ForeignKey(
|
|
||||||
help_text='The target receptor address of this peer link.', on_delete=django.db.models.deletion.CASCADE, to='main.receptoraddress'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='instancelink',
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=('source', 'target'), name='unique_source_target', violation_error_message='Field source and target must be unique together.'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -14,7 +14,6 @@ from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate, StdoutM
|
|||||||
from awx.main.models.organization import Organization, Profile, Team, UserSessionMembership # noqa
|
from awx.main.models.organization import Organization, Profile, Team, UserSessionMembership # noqa
|
||||||
from awx.main.models.credential import Credential, CredentialType, CredentialInputSource, ManagedCredentialType, build_safe_env # noqa
|
from awx.main.models.credential import Credential, CredentialType, CredentialInputSource, ManagedCredentialType, build_safe_env # noqa
|
||||||
from awx.main.models.projects import Project, ProjectUpdate # noqa
|
from awx.main.models.projects import Project, ProjectUpdate # noqa
|
||||||
from awx.main.models.receptor_address import ReceptorAddress # noqa
|
|
||||||
from awx.main.models.inventory import ( # noqa
|
from awx.main.models.inventory import ( # noqa
|
||||||
CustomInventoryScript,
|
CustomInventoryScript,
|
||||||
Group,
|
Group,
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ class ActivityStream(models.Model):
|
|||||||
notification_template = models.ManyToManyField("NotificationTemplate", blank=True)
|
notification_template = models.ManyToManyField("NotificationTemplate", blank=True)
|
||||||
notification = models.ManyToManyField("Notification", blank=True)
|
notification = models.ManyToManyField("Notification", blank=True)
|
||||||
label = models.ManyToManyField("Label", blank=True)
|
label = models.ManyToManyField("Label", blank=True)
|
||||||
receptor_address = models.ManyToManyField("ReceptorAddress", blank=True)
|
|
||||||
role = models.ManyToManyField("Role", blank=True)
|
role = models.ManyToManyField("Role", blank=True)
|
||||||
instance = models.ManyToManyField("Instance", blank=True)
|
instance = models.ManyToManyField("Instance", blank=True)
|
||||||
instance_group = models.ManyToManyField("InstanceGroup", blank=True)
|
instance_group = models.ManyToManyField("InstanceGroup", blank=True)
|
||||||
|
|||||||
@@ -1216,26 +1216,6 @@ ManagedCredentialType(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
ManagedCredentialType(
|
|
||||||
namespace='terraform',
|
|
||||||
kind='cloud',
|
|
||||||
name=gettext_noop('Terraform backend configuration'),
|
|
||||||
managed=True,
|
|
||||||
inputs={
|
|
||||||
'fields': [
|
|
||||||
{
|
|
||||||
'id': 'configuration',
|
|
||||||
'label': gettext_noop('Backend configuration'),
|
|
||||||
'type': 'string',
|
|
||||||
'secret': True,
|
|
||||||
'multiline': True,
|
|
||||||
'help_text': gettext_noop('Terraform backend config as Hashicorp configuration language.'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'required': ['configuration'],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CredentialInputSource(PrimordialModel):
|
class CredentialInputSource(PrimordialModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -122,11 +122,3 @@ def kubernetes_bearer_token(cred, env, private_data_dir):
|
|||||||
env['K8S_AUTH_SSL_CA_CERT'] = to_container_path(path, private_data_dir)
|
env['K8S_AUTH_SSL_CA_CERT'] = to_container_path(path, private_data_dir)
|
||||||
else:
|
else:
|
||||||
env['K8S_AUTH_VERIFY_SSL'] = 'False'
|
env['K8S_AUTH_VERIFY_SSL'] = 'False'
|
||||||
|
|
||||||
|
|
||||||
def terraform(cred, env, private_data_dir):
|
|
||||||
handle, path = tempfile.mkstemp(dir=os.path.join(private_data_dir, 'env'))
|
|
||||||
with os.fdopen(handle, 'w') as f:
|
|
||||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
||||||
f.write(cred.get_input('configuration'))
|
|
||||||
env['TF_BACKEND_CONFIG_FILE'] = to_container_path(path, private_data_dir)
|
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ class BasePlaybookEvent(CreatedModifiedModel):
|
|||||||
'parent_uuid',
|
'parent_uuid',
|
||||||
'start_line',
|
'start_line',
|
||||||
'end_line',
|
'end_line',
|
||||||
|
'host_id',
|
||||||
|
'host_name',
|
||||||
'verbosity',
|
'verbosity',
|
||||||
]
|
]
|
||||||
WRAPUP_EVENT = 'playbook_on_stats'
|
WRAPUP_EVENT = 'playbook_on_stats'
|
||||||
@@ -471,7 +473,7 @@ class JobEvent(BasePlaybookEvent):
|
|||||||
An event/message logged from the callback when running a job.
|
An event/message logged from the callback when running a job.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['job_id', 'workflow_job_id', 'job_created', 'host_id', 'host_name']
|
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['job_id', 'workflow_job_id', 'job_created']
|
||||||
JOB_REFERENCE = 'job_id'
|
JOB_REFERENCE = 'job_id'
|
||||||
|
|
||||||
objects = DeferJobCreatedManager()
|
objects = DeferJobCreatedManager()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from decimal import Decimal
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from django.db import models, connection
|
from django.db import models, connection
|
||||||
from django.db.models.signals import post_save, post_delete
|
from django.db.models.signals import post_save, post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -34,7 +34,6 @@ from awx.main.models.rbac import (
|
|||||||
from awx.main.models.unified_jobs import UnifiedJob
|
from awx.main.models.unified_jobs import UnifiedJob
|
||||||
from awx.main.utils.common import get_corrected_cpu, get_cpu_effective_capacity, get_corrected_memory, get_mem_effective_capacity
|
from awx.main.utils.common import get_corrected_cpu, get_cpu_effective_capacity, get_corrected_memory, get_mem_effective_capacity
|
||||||
from awx.main.models.mixins import RelatedJobsMixin, ResourceMixin
|
from awx.main.models.mixins import RelatedJobsMixin, ResourceMixin
|
||||||
from awx.main.models.receptor_address import ReceptorAddress
|
|
||||||
|
|
||||||
# ansible-runner
|
# ansible-runner
|
||||||
from ansible_runner.utils.capacity import get_cpu_count, get_mem_in_bytes
|
from ansible_runner.utils.capacity import get_cpu_count, get_mem_in_bytes
|
||||||
@@ -65,19 +64,8 @@ class HasPolicyEditsMixin(HasEditsMixin):
|
|||||||
|
|
||||||
|
|
||||||
class InstanceLink(BaseModel):
|
class InstanceLink(BaseModel):
|
||||||
class Meta:
|
source = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='+')
|
||||||
ordering = ("id",)
|
target = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='reverse_peers')
|
||||||
# add constraint for source and target to be unique together
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["source", "target"],
|
|
||||||
name="unique_source_target",
|
|
||||||
violation_error_message=_("Field source and target must be unique together."),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
source = models.ForeignKey('Instance', on_delete=models.CASCADE, help_text=_("The source instance of this peer link."))
|
|
||||||
target = models.ForeignKey('ReceptorAddress', on_delete=models.CASCADE, help_text=_("The target receptor address of this peer link."))
|
|
||||||
|
|
||||||
class States(models.TextChoices):
|
class States(models.TextChoices):
|
||||||
ADDING = 'adding', _('Adding')
|
ADDING = 'adding', _('Adding')
|
||||||
@@ -88,6 +76,11 @@ class InstanceLink(BaseModel):
|
|||||||
choices=States.choices, default=States.ADDING, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.")
|
choices=States.choices, default=States.ADDING, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('source', 'target')
|
||||||
|
ordering = ("id",)
|
||||||
|
constraints = [models.CheckConstraint(check=~models.Q(source=models.F('target')), name='source_and_target_can_not_be_equal')]
|
||||||
|
|
||||||
|
|
||||||
class Instance(HasPolicyEditsMixin, BaseModel):
|
class Instance(HasPolicyEditsMixin, BaseModel):
|
||||||
"""A model representing an AWX instance running against this database."""
|
"""A model representing an AWX instance running against this database."""
|
||||||
@@ -117,7 +110,6 @@ class Instance(HasPolicyEditsMixin, BaseModel):
|
|||||||
default="",
|
default="",
|
||||||
max_length=50,
|
max_length=50,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-fields, implementation is different from BaseModel
|
# Auto-fields, implementation is different from BaseModel
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
modified = models.DateTimeField(auto_now=True)
|
modified = models.DateTimeField(auto_now=True)
|
||||||
@@ -193,9 +185,16 @@ class Instance(HasPolicyEditsMixin, BaseModel):
|
|||||||
node_state = models.CharField(
|
node_state = models.CharField(
|
||||||
choices=States.choices, default=States.READY, max_length=16, help_text=_("Indicates the current life cycle stage of this instance.")
|
choices=States.choices, default=States.READY, max_length=16, help_text=_("Indicates the current life cycle stage of this instance.")
|
||||||
)
|
)
|
||||||
|
listener_port = models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
validators=[MinValueValidator(1024), MaxValueValidator(65535)],
|
||||||
|
help_text=_("Port that Receptor will listen for incoming connections on."),
|
||||||
|
)
|
||||||
|
|
||||||
managed = models.BooleanField(help_text=_("If True, this instance is managed by the control plane."), default=False, editable=False)
|
peers = models.ManyToManyField('self', symmetrical=False, through=InstanceLink, through_fields=('source', 'target'), related_name='peers_from')
|
||||||
peers = models.ManyToManyField('ReceptorAddress', through=InstanceLink, through_fields=('source', 'target'), related_name='peers_from')
|
peers_from_control_nodes = models.BooleanField(default=False, help_text=_("If True, control plane cluster nodes should automatically peer to it."))
|
||||||
|
|
||||||
POLICY_FIELDS = frozenset(('managed_by_policy', 'hostname', 'capacity_adjustment'))
|
POLICY_FIELDS = frozenset(('managed_by_policy', 'hostname', 'capacity_adjustment'))
|
||||||
|
|
||||||
@@ -242,26 +241,6 @@ class Instance(HasPolicyEditsMixin, BaseModel):
|
|||||||
return True
|
return True
|
||||||
return self.health_check_started > self.last_health_check
|
return self.health_check_started > self.last_health_check
|
||||||
|
|
||||||
@property
|
|
||||||
def canonical_address(self):
|
|
||||||
return self.receptor_addresses.filter(canonical=True).first()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def canonical_address_port(self):
|
|
||||||
# note: don't create a different query for receptor addresses, as this is prefetched on the View for optimization
|
|
||||||
for addr in self.receptor_addresses.all():
|
|
||||||
if addr.canonical:
|
|
||||||
return addr.port
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def canonical_address_peers_from_control_nodes(self):
|
|
||||||
# note: don't create a different query for receptor addresses, as this is prefetched on the View for optimization
|
|
||||||
for addr in self.receptor_addresses.all():
|
|
||||||
if addr.canonical:
|
|
||||||
return addr.peers_from_control_nodes
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_cleanup_task_kwargs(self, **kwargs):
|
def get_cleanup_task_kwargs(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Produce options to use for the command: ansible-runner worker cleanup
|
Produce options to use for the command: ansible-runner worker cleanup
|
||||||
@@ -522,35 +501,6 @@ def schedule_write_receptor_config(broadcast=True):
|
|||||||
write_receptor_config() # just run locally
|
write_receptor_config() # just run locally
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=ReceptorAddress)
|
|
||||||
def receptor_address_saved(sender, instance, **kwargs):
|
|
||||||
from awx.main.signals import disable_activity_stream
|
|
||||||
|
|
||||||
address = instance
|
|
||||||
|
|
||||||
control_instances = set(Instance.objects.filter(node_type__in=[Instance.Types.CONTROL, Instance.Types.HYBRID]))
|
|
||||||
if address.peers_from_control_nodes:
|
|
||||||
# if control_instances is not a subset of current peers of address, then
|
|
||||||
# that means we need to add some InstanceLinks
|
|
||||||
if not control_instances <= set(address.peers_from.all()):
|
|
||||||
with disable_activity_stream():
|
|
||||||
for control_instance in control_instances:
|
|
||||||
InstanceLink.objects.update_or_create(source=control_instance, target=address)
|
|
||||||
schedule_write_receptor_config()
|
|
||||||
else:
|
|
||||||
if address.peers_from.exists():
|
|
||||||
with disable_activity_stream():
|
|
||||||
address.peers_from.remove(*control_instances)
|
|
||||||
schedule_write_receptor_config()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=ReceptorAddress)
|
|
||||||
def receptor_address_deleted(sender, instance, **kwargs):
|
|
||||||
address = instance
|
|
||||||
if address.peers_from_control_nodes:
|
|
||||||
schedule_write_receptor_config()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Instance)
|
@receiver(post_save, sender=Instance)
|
||||||
def on_instance_saved(sender, instance, created=False, raw=False, **kwargs):
|
def on_instance_saved(sender, instance, created=False, raw=False, **kwargs):
|
||||||
'''
|
'''
|
||||||
@@ -561,14 +511,11 @@ def on_instance_saved(sender, instance, created=False, raw=False, **kwargs):
|
|||||||
2. a node changes its value of peers_from_control_nodes
|
2. a node changes its value of peers_from_control_nodes
|
||||||
3. a new control node comes online and has instances to peer to
|
3. a new control node comes online and has instances to peer to
|
||||||
'''
|
'''
|
||||||
from awx.main.signals import disable_activity_stream
|
|
||||||
|
|
||||||
if created and settings.IS_K8S and instance.node_type in [Instance.Types.CONTROL, Instance.Types.HYBRID]:
|
if created and settings.IS_K8S and instance.node_type in [Instance.Types.CONTROL, Instance.Types.HYBRID]:
|
||||||
peers_addresses = ReceptorAddress.objects.filter(peers_from_control_nodes=True)
|
inst = Instance.objects.filter(peers_from_control_nodes=True)
|
||||||
if peers_addresses.exists():
|
if set(instance.peers.all()) != set(inst):
|
||||||
with disable_activity_stream():
|
instance.peers.set(inst)
|
||||||
instance.peers.add(*peers_addresses)
|
schedule_write_receptor_config(broadcast=False)
|
||||||
schedule_write_receptor_config(broadcast=False)
|
|
||||||
|
|
||||||
if settings.IS_K8S and instance.node_type in [Instance.Types.HOP, Instance.Types.EXECUTION]:
|
if settings.IS_K8S and instance.node_type in [Instance.Types.HOP, Instance.Types.EXECUTION]:
|
||||||
if instance.node_state == Instance.States.DEPROVISIONING:
|
if instance.node_state == Instance.States.DEPROVISIONING:
|
||||||
@@ -577,6 +524,16 @@ def on_instance_saved(sender, instance, created=False, raw=False, **kwargs):
|
|||||||
# wait for jobs on the node to complete, then delete the
|
# wait for jobs on the node to complete, then delete the
|
||||||
# node and kick off write_receptor_config
|
# node and kick off write_receptor_config
|
||||||
connection.on_commit(lambda: remove_deprovisioned_node.apply_async([instance.hostname]))
|
connection.on_commit(lambda: remove_deprovisioned_node.apply_async([instance.hostname]))
|
||||||
|
else:
|
||||||
|
control_instances = set(Instance.objects.filter(node_type__in=[Instance.Types.CONTROL, Instance.Types.HYBRID]))
|
||||||
|
if instance.peers_from_control_nodes:
|
||||||
|
if (control_instances & set(instance.peers_from.all())) != set(control_instances):
|
||||||
|
instance.peers_from.add(*control_instances)
|
||||||
|
schedule_write_receptor_config() # keep method separate to make pytest mocking easier
|
||||||
|
else:
|
||||||
|
if set(control_instances) & set(instance.peers_from.all()):
|
||||||
|
instance.peers_from.remove(*control_instances)
|
||||||
|
schedule_write_receptor_config()
|
||||||
|
|
||||||
if created or instance.has_policy_changes():
|
if created or instance.has_policy_changes():
|
||||||
schedule_policy_task()
|
schedule_policy_task()
|
||||||
@@ -591,6 +548,8 @@ def on_instance_group_deleted(sender, instance, using, **kwargs):
|
|||||||
@receiver(post_delete, sender=Instance)
|
@receiver(post_delete, sender=Instance)
|
||||||
def on_instance_deleted(sender, instance, using, **kwargs):
|
def on_instance_deleted(sender, instance, using, **kwargs):
|
||||||
schedule_policy_task()
|
schedule_policy_task()
|
||||||
|
if settings.IS_K8S and instance.node_type in (Instance.Types.EXECUTION, Instance.Types.HOP) and instance.peers_from_control_nodes:
|
||||||
|
schedule_write_receptor_config()
|
||||||
|
|
||||||
|
|
||||||
class UnifiedJobTemplateInstanceGroupMembership(models.Model):
|
class UnifiedJobTemplateInstanceGroupMembership(models.Model):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from copy import deepcopy
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import traceback
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -485,29 +484,14 @@ class JobNotificationMixin(object):
|
|||||||
if msg_template:
|
if msg_template:
|
||||||
try:
|
try:
|
||||||
msg = env.from_string(msg_template).render(**context)
|
msg = env.from_string(msg_template).render(**context)
|
||||||
except (TemplateSyntaxError, UndefinedError, SecurityError) as e:
|
except (TemplateSyntaxError, UndefinedError, SecurityError):
|
||||||
msg = '\r\n'.join([e.message, ''.join(traceback.format_exception(None, e, e.__traceback__).replace('\n', '\r\n'))])
|
msg = ''
|
||||||
|
|
||||||
if body_template:
|
if body_template:
|
||||||
try:
|
try:
|
||||||
body = env.from_string(body_template).render(**context)
|
body = env.from_string(body_template).render(**context)
|
||||||
except (TemplateSyntaxError, UndefinedError, SecurityError) as e:
|
except (TemplateSyntaxError, UndefinedError, SecurityError):
|
||||||
body = '\r\n'.join([e.message, ''.join(traceback.format_exception(None, e, e.__traceback__).replace('\n', '\r\n'))])
|
body = ''
|
||||||
|
|
||||||
# https://datatracker.ietf.org/doc/html/rfc2822#section-2.2
|
|
||||||
# Body should have at least 2 CRLF, some clients will interpret
|
|
||||||
# the email incorrectly with blank body. So we will check that
|
|
||||||
|
|
||||||
if len(body.strip().splitlines()) <= 2:
|
|
||||||
# blank body
|
|
||||||
body = '\r\n'.join(
|
|
||||||
[
|
|
||||||
"The template rendering return a blank body.",
|
|
||||||
"Please check the template.",
|
|
||||||
"Refer to https://github.com/ansible/awx/issues/13983",
|
|
||||||
"for further information.",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (msg, body)
|
return (msg, body)
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from awx.api.versioning import reverse
|
|
||||||
|
|
||||||
|
|
||||||
class Protocols(models.TextChoices):
|
|
||||||
TCP = 'tcp', 'TCP'
|
|
||||||
WS = 'ws', 'WS'
|
|
||||||
WSS = 'wss', 'WSS'
|
|
||||||
|
|
||||||
|
|
||||||
class ReceptorAddress(models.Model):
|
|
||||||
class Meta:
|
|
||||||
app_label = 'main'
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["address"],
|
|
||||||
name="unique_receptor_address",
|
|
||||||
violation_error_message=_("Receptor address must be unique."),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
address = models.CharField(help_text=_("Routable address for this instance."), max_length=255)
|
|
||||||
port = models.IntegerField(help_text=_("Port for the address."), default=27199, validators=[MinValueValidator(0), MaxValueValidator(65535)])
|
|
||||||
websocket_path = models.CharField(help_text=_("Websocket path."), max_length=255, default="", blank=True)
|
|
||||||
protocol = models.CharField(
|
|
||||||
help_text=_("Protocol to use for the Receptor listener, 'tcp', 'wss', or 'ws'."), max_length=10, default=Protocols.TCP, choices=Protocols.choices
|
|
||||||
)
|
|
||||||
is_internal = models.BooleanField(help_text=_("If True, only routable within the Kubernetes cluster."), default=False)
|
|
||||||
canonical = models.BooleanField(help_text=_("If True, this address is the canonical address for the instance."), default=False)
|
|
||||||
peers_from_control_nodes = models.BooleanField(help_text=_("If True, control plane cluster nodes should automatically peer to it."), default=False)
|
|
||||||
instance = models.ForeignKey(
|
|
||||||
'Instance',
|
|
||||||
related_name='receptor_addresses',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.get_full_address()
|
|
||||||
|
|
||||||
def get_full_address(self):
|
|
||||||
scheme = ""
|
|
||||||
path = ""
|
|
||||||
port = ""
|
|
||||||
if self.protocol == "ws":
|
|
||||||
scheme = "wss://"
|
|
||||||
|
|
||||||
if self.protocol == "ws" and self.websocket_path:
|
|
||||||
path = f"/{self.websocket_path}"
|
|
||||||
|
|
||||||
if self.port:
|
|
||||||
port = f":{self.port}"
|
|
||||||
|
|
||||||
return f"{scheme}{self.address}{port}{path}"
|
|
||||||
|
|
||||||
def get_peer_type(self):
|
|
||||||
if self.protocol == 'tcp':
|
|
||||||
return 'tcp-peer'
|
|
||||||
elif self.protocol in ['ws', 'wss']:
|
|
||||||
return 'ws-peer'
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_absolute_url(self, request=None):
|
|
||||||
return reverse('api:receptor_address_detail', kwargs={'pk': self.pk}, request=request)
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# Copyright (c) 2019 Ansible, Inc.
|
# Copyright (c) 2019 Ansible, Inc.
|
||||||
# All Rights Reserved.
|
# All Rights Reserved.
|
||||||
# -*-coding:utf-8-*-
|
|
||||||
|
|
||||||
|
|
||||||
class CustomNotificationBase(object):
|
class CustomNotificationBase(object):
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import logging
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from channels.auth import AuthMiddlewareStack
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
|
||||||
from ansible_base.lib.channels.middleware import DrfAuthMiddlewareStack
|
|
||||||
|
|
||||||
from . import consumers
|
from . import consumers
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.routing')
|
logger = logging.getLogger('awx.main.routing')
|
||||||
_application = None
|
|
||||||
|
|
||||||
|
|
||||||
class AWXProtocolTypeRouter(ProtocolTypeRouter):
|
class AWXProtocolTypeRouter(ProtocolTypeRouter):
|
||||||
@@ -28,91 +26,13 @@ class AWXProtocolTypeRouter(ProtocolTypeRouter):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MultipleURLRouterAdapter:
|
|
||||||
"""
|
|
||||||
Django channels doesn't nicely support Auth_1(urls_1), Auth_2(urls_2), ..., Auth_n(urls_n)
|
|
||||||
This class allows assocating a websocket url with an auth
|
|
||||||
Ordering matters. The first matching url will be used.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *auths):
|
|
||||||
self._auths = [a for a in auths]
|
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
|
||||||
"""
|
|
||||||
Loop through the list of passed in URLRouter's (they may or may not be wrapped by auth).
|
|
||||||
We know we have exhausted the list of URLRouter patterns when we get a
|
|
||||||
ValueError('No route found for path %s'). When that happens, move onto the next
|
|
||||||
URLRouter.
|
|
||||||
If the final URLRouter raises an error, re-raise it in the end.
|
|
||||||
|
|
||||||
We know that we found a match when no error is raised, end the loop.
|
|
||||||
"""
|
|
||||||
last_index = len(self._auths) - 1
|
|
||||||
for i, auth in enumerate(self._auths):
|
|
||||||
try:
|
|
||||||
return await auth.__call__(scope, receive, send)
|
|
||||||
except ValueError as e:
|
|
||||||
if str(e).startswith('No route found for path'):
|
|
||||||
# Only surface the error if on the last URLRouter
|
|
||||||
if i == last_index:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
websocket_urlpatterns = [
|
||||||
re_path(r'api/websocket/$', consumers.EventConsumer.as_asgi()),
|
|
||||||
re_path(r'websocket/$', consumers.EventConsumer.as_asgi()),
|
re_path(r'websocket/$', consumers.EventConsumer.as_asgi()),
|
||||||
]
|
|
||||||
websocket_relay_urlpatterns = [
|
|
||||||
re_path(r'websocket/relay/$', consumers.RelayConsumer.as_asgi()),
|
re_path(r'websocket/relay/$', consumers.RelayConsumer.as_asgi()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
application = AWXProtocolTypeRouter(
|
||||||
def application_func(cls=AWXProtocolTypeRouter) -> ProtocolTypeRouter:
|
{
|
||||||
return cls(
|
'websocket': AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
|
||||||
{
|
}
|
||||||
'websocket': MultipleURLRouterAdapter(
|
)
|
||||||
URLRouter(websocket_relay_urlpatterns),
|
|
||||||
DrfAuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> ProtocolTypeRouter:
|
|
||||||
"""
|
|
||||||
Defer instantiating application.
|
|
||||||
For testing, we just need it to NOT run on import.
|
|
||||||
|
|
||||||
https://peps.python.org/pep-0562/#specification
|
|
||||||
|
|
||||||
Normally, someone would get application from this module via:
|
|
||||||
from awx.main.routing import application
|
|
||||||
|
|
||||||
and do something with the application:
|
|
||||||
application.do_something()
|
|
||||||
|
|
||||||
What does the callstack look like when the import runs?
|
|
||||||
...
|
|
||||||
awx.main.routing.__getattribute__(...) # <-- we don't define this so NOOP as far as we are concerned
|
|
||||||
if '__getattr__' in awx.main.routing.__dict__: # <-- this triggers the function we are in
|
|
||||||
return awx.main.routing.__dict__.__getattr__("application")
|
|
||||||
|
|
||||||
Why isn't this function simply implemented as:
|
|
||||||
def __getattr__(name):
|
|
||||||
if not _application:
|
|
||||||
_application = application_func()
|
|
||||||
return _application
|
|
||||||
|
|
||||||
It could. I manually tested it and it passes test_routing.py.
|
|
||||||
|
|
||||||
But my understanding after reading the PEP-0562 specification link above is that
|
|
||||||
performance would be a bit worse due to the extra __getattribute__ calls when
|
|
||||||
we reference non-global variables.
|
|
||||||
"""
|
|
||||||
if name == "application":
|
|
||||||
globs = globals()
|
|
||||||
if not globs['_application']:
|
|
||||||
globs['_application'] = application_func()
|
|
||||||
return globs['_application']
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class TaskBase:
|
|||||||
# initialize each metric to 0 and force metric_has_changed to true. This
|
# initialize each metric to 0 and force metric_has_changed to true. This
|
||||||
# ensures each task manager metric will be overridden when pipe_execute
|
# ensures each task manager metric will be overridden when pipe_execute
|
||||||
# is called later.
|
# is called later.
|
||||||
self.subsystem_metrics = s_metrics.DispatcherMetrics(auto_pipe_execute=False)
|
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
||||||
self.start_time = time.time()
|
self.start_time = time.time()
|
||||||
|
|
||||||
# We want to avoid calling settings in loops, so cache these settings at init time
|
# We want to avoid calling settings in loops, so cache these settings at init time
|
||||||
@@ -105,7 +105,7 @@ class TaskBase:
|
|||||||
try:
|
try:
|
||||||
# increment task_manager_schedule_calls regardless if the other
|
# increment task_manager_schedule_calls regardless if the other
|
||||||
# metrics are recorded
|
# metrics are recorded
|
||||||
s_metrics.DispatcherMetrics(auto_pipe_execute=True).inc(f"{self.prefix}__schedule_calls", 1)
|
s_metrics.Metrics(auto_pipe_execute=True).inc(f"{self.prefix}__schedule_calls", 1)
|
||||||
# Only record metrics if the last time recording was more
|
# Only record metrics if the last time recording was more
|
||||||
# than SUBSYSTEM_METRICS_TASK_MANAGER_RECORD_INTERVAL ago.
|
# than SUBSYSTEM_METRICS_TASK_MANAGER_RECORD_INTERVAL ago.
|
||||||
# Prevents a short-duration task manager that runs directly after a
|
# Prevents a short-duration task manager that runs directly after a
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class RunnerCallback:
|
|||||||
self.safe_env = {}
|
self.safe_env = {}
|
||||||
self.event_ct = 0
|
self.event_ct = 0
|
||||||
self.model = model
|
self.model = model
|
||||||
self.update_attempts = int(getattr(settings, 'DISPATCHER_DB_DOWNTOWN_TOLLERANCE', settings.DISPATCHER_DB_DOWNTIME_TOLERANCE) / 5)
|
self.update_attempts = int(settings.DISPATCHER_DB_DOWNTIME_TOLERANCE / 5)
|
||||||
self.wrapup_event_dispatched = False
|
self.wrapup_event_dispatched = False
|
||||||
self.artifacts_processed = False
|
self.artifacts_processed = False
|
||||||
self.extra_update_fields = {}
|
self.extra_update_fields = {}
|
||||||
@@ -95,17 +95,17 @@ class RunnerCallback:
|
|||||||
if self.parent_workflow_job_id:
|
if self.parent_workflow_job_id:
|
||||||
event_data['workflow_job_id'] = self.parent_workflow_job_id
|
event_data['workflow_job_id'] = self.parent_workflow_job_id
|
||||||
event_data['job_created'] = self.job_created
|
event_data['job_created'] = self.job_created
|
||||||
|
if self.host_map:
|
||||||
host = event_data.get('event_data', {}).get('host', '').strip()
|
host = event_data.get('event_data', {}).get('host', '').strip()
|
||||||
if host:
|
if host:
|
||||||
event_data['host_name'] = host
|
event_data['host_name'] = host
|
||||||
if host in self.host_map:
|
if host in self.host_map:
|
||||||
event_data['host_id'] = self.host_map[host]
|
event_data['host_id'] = self.host_map[host]
|
||||||
else:
|
else:
|
||||||
event_data['host_name'] = ''
|
event_data['host_name'] = ''
|
||||||
event_data['host_id'] = ''
|
event_data['host_id'] = ''
|
||||||
if event_data.get('event') == 'playbook_on_stats':
|
if event_data.get('event') == 'playbook_on_stats':
|
||||||
event_data['host_map'] = self.host_map
|
event_data['host_map'] = self.host_map
|
||||||
|
|
||||||
if isinstance(self, RunnerCallbackForProjectUpdate):
|
if isinstance(self, RunnerCallbackForProjectUpdate):
|
||||||
# need a better way to have this check.
|
# need a better way to have this check.
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class BaseTask(object):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.cleanup_paths = []
|
self.cleanup_paths = []
|
||||||
self.update_attempts = int(getattr(settings, 'DISPATCHER_DB_DOWNTOWN_TOLLERANCE', settings.DISPATCHER_DB_DOWNTIME_TOLERANCE) / 5)
|
self.update_attempts = int(settings.DISPATCHER_DB_DOWNTIME_TOLERANCE / 5)
|
||||||
self.runner_callback = self.callback_class(model=self.model)
|
self.runner_callback = self.callback_class(model=self.model)
|
||||||
|
|
||||||
def update_model(self, pk, _attempt=0, **updates):
|
def update_model(self, pk, _attempt=0, **updates):
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from awx.main.utils.common import (
|
|||||||
)
|
)
|
||||||
from awx.main.constants import MAX_ISOLATED_PATH_COLON_DELIMITER
|
from awx.main.constants import MAX_ISOLATED_PATH_COLON_DELIMITER
|
||||||
from awx.main.tasks.signals import signal_state, signal_callback, SignalExit
|
from awx.main.tasks.signals import signal_state, signal_callback, SignalExit
|
||||||
from awx.main.models import Instance, InstanceLink, UnifiedJob, ReceptorAddress
|
from awx.main.models import Instance, InstanceLink, UnifiedJob
|
||||||
from awx.main.dispatch import get_task_queuename
|
from awx.main.dispatch import get_task_queuename
|
||||||
from awx.main.dispatch.publish import task
|
from awx.main.dispatch.publish import task
|
||||||
from awx.main.utils.pglock import advisory_lock
|
from awx.main.utils.pglock import advisory_lock
|
||||||
@@ -676,44 +676,36 @@ RECEPTOR_CONFIG_STARTER = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def should_update_config(new_config):
|
def should_update_config(instances):
|
||||||
'''
|
'''
|
||||||
checks that the list of instances matches the list of
|
checks that the list of instances matches the list of
|
||||||
tcp-peers in the config
|
tcp-peers in the config
|
||||||
'''
|
'''
|
||||||
|
|
||||||
current_config = read_receptor_config() # this gets receptor conf lock
|
current_config = read_receptor_config() # this gets receptor conf lock
|
||||||
|
current_peers = []
|
||||||
for config_entry in current_config:
|
for config_entry in current_config:
|
||||||
if config_entry not in new_config:
|
for key, value in config_entry.items():
|
||||||
logger.warning(f"{config_entry} should not be in receptor config. Updating.")
|
if key.endswith('-peer'):
|
||||||
return True
|
current_peers.append(value['address'])
|
||||||
for config_entry in new_config:
|
intended_peers = [f"{i.hostname}:{i.listener_port}" for i in instances]
|
||||||
if config_entry not in current_config:
|
logger.debug(f"Peers current {current_peers} intended {intended_peers}")
|
||||||
logger.warning(f"{config_entry} missing from receptor config. Updating.")
|
if set(current_peers) == set(intended_peers):
|
||||||
return True
|
return False # config file is already update to date
|
||||||
|
|
||||||
return False
|
return True
|
||||||
|
|
||||||
|
|
||||||
def generate_config_data():
|
def generate_config_data():
|
||||||
# returns two values
|
# returns two values
|
||||||
# receptor config - based on current database peers
|
# receptor config - based on current database peers
|
||||||
# should_update - If True, receptor_config differs from the receptor conf file on disk
|
# should_update - If True, receptor_config differs from the receptor conf file on disk
|
||||||
addresses = ReceptorAddress.objects.filter(peers_from_control_nodes=True)
|
instances = Instance.objects.filter(node_type__in=(Instance.Types.EXECUTION, Instance.Types.HOP), peers_from_control_nodes=True)
|
||||||
|
|
||||||
receptor_config = list(RECEPTOR_CONFIG_STARTER)
|
receptor_config = list(RECEPTOR_CONFIG_STARTER)
|
||||||
for address in addresses:
|
for instance in instances:
|
||||||
if address.get_peer_type():
|
peer = {'tcp-peer': {'address': f'{instance.hostname}:{instance.listener_port}', 'tls': 'tlsclient'}}
|
||||||
peer = {
|
receptor_config.append(peer)
|
||||||
f'{address.get_peer_type()}': {
|
should_update = should_update_config(instances)
|
||||||
'address': f'{address.get_full_address()}',
|
|
||||||
'tls': 'tlsclient',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
receptor_config.append(peer)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Receptor address {address} has unsupported peer type, skipping.")
|
|
||||||
should_update = should_update_config(receptor_config)
|
|
||||||
return receptor_config, should_update
|
return receptor_config, should_update
|
||||||
|
|
||||||
|
|
||||||
@@ -755,13 +747,14 @@ def write_receptor_config():
|
|||||||
with lock:
|
with lock:
|
||||||
with open(__RECEPTOR_CONF, 'w') as file:
|
with open(__RECEPTOR_CONF, 'w') as file:
|
||||||
yaml.dump(receptor_config, file, default_flow_style=False)
|
yaml.dump(receptor_config, file, default_flow_style=False)
|
||||||
|
|
||||||
reload_receptor()
|
reload_receptor()
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_task_queuename)
|
@task(queue=get_task_queuename)
|
||||||
def remove_deprovisioned_node(hostname):
|
def remove_deprovisioned_node(hostname):
|
||||||
InstanceLink.objects.filter(source__hostname=hostname).update(link_state=InstanceLink.States.REMOVING)
|
InstanceLink.objects.filter(source__hostname=hostname).update(link_state=InstanceLink.States.REMOVING)
|
||||||
InstanceLink.objects.filter(target__instance__hostname=hostname).update(link_state=InstanceLink.States.REMOVING)
|
InstanceLink.objects.filter(target__hostname=hostname).update(link_state=InstanceLink.States.REMOVING)
|
||||||
|
|
||||||
node_jobs = UnifiedJob.objects.filter(
|
node_jobs = UnifiedJob.objects.filter(
|
||||||
execution_node=hostname,
|
execution_node=hostname,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ from awx.main.tasks.receptor import get_receptor_ctl, worker_info, worker_cleanu
|
|||||||
from awx.main.consumers import emit_channel_notification
|
from awx.main.consumers import emit_channel_notification
|
||||||
from awx.main import analytics
|
from awx.main import analytics
|
||||||
from awx.conf import settings_registry
|
from awx.conf import settings_registry
|
||||||
from awx.main.analytics.subsystem_metrics import DispatcherMetrics
|
from awx.main.analytics.subsystem_metrics import Metrics
|
||||||
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ def dispatch_startup():
|
|||||||
cluster_node_heartbeat()
|
cluster_node_heartbeat()
|
||||||
reaper.startup_reaping()
|
reaper.startup_reaping()
|
||||||
reaper.reap_waiting(grace_period=0)
|
reaper.reap_waiting(grace_period=0)
|
||||||
m = DispatcherMetrics()
|
m = Metrics()
|
||||||
m.reset_values()
|
m.reset_values()
|
||||||
|
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ def inspect_established_receptor_connections(mesh_status):
|
|||||||
update_links = []
|
update_links = []
|
||||||
for link in all_links:
|
for link in all_links:
|
||||||
if link.link_state != InstanceLink.States.REMOVING:
|
if link.link_state != InstanceLink.States.REMOVING:
|
||||||
if link.target.instance.hostname in active_receptor_conns.get(link.source.hostname, {}):
|
if link.target.hostname in active_receptor_conns.get(link.source.hostname, {}):
|
||||||
if link.link_state is not InstanceLink.States.ESTABLISHED:
|
if link.link_state is not InstanceLink.States.ESTABLISHED:
|
||||||
link.link_state = InstanceLink.States.ESTABLISHED
|
link.link_state = InstanceLink.States.ESTABLISHED
|
||||||
update_links.append(link)
|
update_links.append(link)
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
import itertools
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
|
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
from awx.main.models import Instance, ReceptorAddress
|
from awx.main.models import Instance
|
||||||
from awx.api.views.instance_install_bundle import generate_group_vars_all_yml
|
from awx.api.views.instance_install_bundle import generate_group_vars_all_yml
|
||||||
|
|
||||||
|
|
||||||
def has_peer(group_vars, peer):
|
def has_peer(group_vars, peer):
|
||||||
peers = group_vars.get('receptor_peers', [])
|
peers = group_vars.get('receptor_peers', [])
|
||||||
for p in peers:
|
for p in peers:
|
||||||
if p['address'] == peer:
|
if f"{p['host']}:{p['port']}" == peer:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -21,314 +24,119 @@ class TestPeers:
|
|||||||
def configure_settings(self, settings):
|
def configure_settings(self, settings):
|
||||||
settings.IS_K8S = True
|
settings.IS_K8S = True
|
||||||
|
|
||||||
@pytest.mark.parametrize('node_type', ['hop', 'execution'])
|
@pytest.mark.parametrize('node_type', ['control', 'hybrid'])
|
||||||
def test_peering_to_self(self, node_type, admin_user, patch):
|
def test_prevent_peering_to_self(self, node_type):
|
||||||
"""
|
"""
|
||||||
cannot peer to self
|
cannot peer to self
|
||||||
"""
|
"""
|
||||||
instance = Instance.objects.create(hostname='abc', node_type=node_type)
|
control_instance = Instance.objects.create(hostname='abc', node_type=node_type)
|
||||||
addr = ReceptorAddress.objects.create(instance=instance, address='abc', canonical=True)
|
with pytest.raises(IntegrityError):
|
||||||
resp = patch(
|
control_instance.peers.add(control_instance)
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': instance.pk}),
|
|
||||||
data={"hostname": "abc", "node_type": node_type, "peers": [addr.id]},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
assert 'Instance cannot peer to its own address.' in str(resp.data)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'hop', 'execution'])
|
@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'hop', 'execution'])
|
||||||
def test_creating_node(self, node_type, admin_user, post):
|
def test_creating_node(self, node_type, admin_user, post):
|
||||||
"""
|
"""
|
||||||
can only add hop and execution nodes via API
|
can only add hop and execution nodes via API
|
||||||
"""
|
"""
|
||||||
resp = post(
|
post(
|
||||||
url=reverse('api:instance_list'),
|
url=reverse('api:instance_list'),
|
||||||
data={"hostname": "abc", "node_type": node_type},
|
data={"hostname": "abc", "node_type": node_type},
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=400 if node_type in ['control', 'hybrid'] else 201,
|
expect=400 if node_type in ['control', 'hybrid'] else 201,
|
||||||
)
|
)
|
||||||
if resp.status_code == 400:
|
|
||||||
assert 'Can only create execution or hop nodes.' in str(resp.data)
|
|
||||||
|
|
||||||
def test_changing_node_type(self, admin_user, patch):
|
def test_changing_node_type(self, admin_user, patch):
|
||||||
"""
|
"""
|
||||||
cannot change node type
|
cannot change node type
|
||||||
"""
|
"""
|
||||||
hop = Instance.objects.create(hostname='abc', node_type="hop")
|
hop = Instance.objects.create(hostname='abc', node_type="hop")
|
||||||
resp = patch(
|
patch(
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
||||||
data={"node_type": "execution"},
|
data={"node_type": "execution"},
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=400,
|
expect=400,
|
||||||
)
|
)
|
||||||
assert 'Cannot change node type.' in str(resp.data)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize('node_type', ['hop', 'execution'])
|
||||||
'payload_port, payload_peers_from, initial_port, initial_peers_from',
|
def test_listener_port_null(self, node_type, admin_user, post):
|
||||||
[
|
"""
|
||||||
(-1, -1, None, None),
|
listener_port can be None
|
||||||
(-1, -1, 27199, False),
|
"""
|
||||||
(-1, -1, 27199, True),
|
post(
|
||||||
(None, -1, None, None),
|
url=reverse('api:instance_list'),
|
||||||
(None, False, None, None),
|
data={"hostname": "abc", "node_type": node_type, "listener_port": None},
|
||||||
(-1, False, None, None),
|
|
||||||
(27199, True, 27199, True),
|
|
||||||
(27199, False, 27199, False),
|
|
||||||
(27199, -1, 27199, True),
|
|
||||||
(27199, -1, 27199, False),
|
|
||||||
(-1, True, 27199, True),
|
|
||||||
(-1, False, 27199, False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_no_op(self, payload_port, payload_peers_from, initial_port, initial_peers_from, admin_user, patch):
|
|
||||||
node = Instance.objects.create(hostname='abc', node_type='hop')
|
|
||||||
if initial_port is not None:
|
|
||||||
ReceptorAddress.objects.create(address=node.hostname, port=initial_port, canonical=True, peers_from_control_nodes=initial_peers_from, instance=node)
|
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 1
|
|
||||||
else:
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 0
|
|
||||||
|
|
||||||
data = {'enabled': True} # Just to have something to post.
|
|
||||||
if payload_port != -1:
|
|
||||||
data['listener_port'] = payload_port
|
|
||||||
if payload_peers_from != -1:
|
|
||||||
data['peers_from_control_nodes'] = payload_peers_from
|
|
||||||
|
|
||||||
patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': node.pk}),
|
|
||||||
data=data,
|
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=200,
|
expect=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == (0 if initial_port is None else 1)
|
@pytest.mark.parametrize('node_type, allowed', [('control', False), ('hybrid', False), ('hop', True), ('execution', True)])
|
||||||
if initial_port is not None:
|
def test_peers_from_control_nodes_allowed(self, node_type, allowed, post, admin_user):
|
||||||
ra = ReceptorAddress.objects.get(instance=node, canonical=True)
|
"""
|
||||||
assert ra.port == initial_port
|
only hop and execution nodes can have peers_from_control_nodes set to True
|
||||||
assert ra.peers_from_control_nodes == initial_peers_from
|
"""
|
||||||
|
post(
|
||||||
@pytest.mark.parametrize(
|
url=reverse('api:instance_list'),
|
||||||
'payload_port, payload_peers_from',
|
data={"hostname": "abc", "peers_from_control_nodes": True, "node_type": node_type, "listener_port": 6789},
|
||||||
[
|
|
||||||
(27199, True),
|
|
||||||
(27199, False),
|
|
||||||
(27199, -1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_creates_canonical_address(self, payload_port, payload_peers_from, admin_user, patch):
|
|
||||||
node = Instance.objects.create(hostname='abc', node_type='hop')
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 0
|
|
||||||
|
|
||||||
data = {'enabled': True} # Just to have something to post.
|
|
||||||
if payload_port != -1:
|
|
||||||
data['listener_port'] = payload_port
|
|
||||||
if payload_peers_from != -1:
|
|
||||||
data['peers_from_control_nodes'] = payload_peers_from
|
|
||||||
|
|
||||||
patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': node.pk}),
|
|
||||||
data=data,
|
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=200,
|
expect=201 if allowed else 400,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 1
|
def test_listener_port_is_required(self, admin_user, post):
|
||||||
ra = ReceptorAddress.objects.get(instance=node, canonical=True)
|
"""
|
||||||
assert ra.port == payload_port
|
if adding instance to peers list, that instance must have listener_port set
|
||||||
assert ra.peers_from_control_nodes == (payload_peers_from if payload_peers_from != -1 else False)
|
"""
|
||||||
|
Instance.objects.create(hostname='abc', node_type="hop", listener_port=None)
|
||||||
@pytest.mark.parametrize(
|
post(
|
||||||
'payload_port, payload_peers_from, initial_port, initial_peers_from',
|
url=reverse('api:instance_list'),
|
||||||
[
|
data={"hostname": "ex", "peers_from_control_nodes": False, "node_type": "execution", "listener_port": None, "peers": ["abc"]},
|
||||||
(None, False, 27199, True),
|
|
||||||
(None, -1, 27199, True),
|
|
||||||
(None, False, 27199, False),
|
|
||||||
(None, -1, 27199, False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_deletes_canonical_address(self, payload_port, payload_peers_from, initial_port, initial_peers_from, admin_user, patch):
|
|
||||||
node = Instance.objects.create(hostname='abc', node_type='hop')
|
|
||||||
ReceptorAddress.objects.create(address=node.hostname, port=initial_port, canonical=True, peers_from_control_nodes=initial_peers_from, instance=node)
|
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 1
|
|
||||||
|
|
||||||
data = {'enabled': True} # Just to have something to post.
|
|
||||||
if payload_port != -1:
|
|
||||||
data['listener_port'] = payload_port
|
|
||||||
if payload_peers_from != -1:
|
|
||||||
data['peers_from_control_nodes'] = payload_peers_from
|
|
||||||
|
|
||||||
patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': node.pk}),
|
|
||||||
data=data,
|
|
||||||
user=admin_user,
|
|
||||||
expect=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'payload_port, payload_peers_from, initial_port, initial_peers_from',
|
|
||||||
[
|
|
||||||
(27199, True, 27199, False),
|
|
||||||
(27199, False, 27199, True),
|
|
||||||
(-1, True, 27199, False),
|
|
||||||
(-1, False, 27199, True),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_updates_canonical_address(self, payload_port, payload_peers_from, initial_port, initial_peers_from, admin_user, patch):
|
|
||||||
node = Instance.objects.create(hostname='abc', node_type='hop')
|
|
||||||
ReceptorAddress.objects.create(address=node.hostname, port=initial_port, canonical=True, peers_from_control_nodes=initial_peers_from, instance=node)
|
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 1
|
|
||||||
|
|
||||||
data = {'enabled': True} # Just to have something to post.
|
|
||||||
if payload_port != -1:
|
|
||||||
data['listener_port'] = payload_port
|
|
||||||
if payload_peers_from != -1:
|
|
||||||
data['peers_from_control_nodes'] = payload_peers_from
|
|
||||||
|
|
||||||
patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': node.pk}),
|
|
||||||
data=data,
|
|
||||||
user=admin_user,
|
|
||||||
expect=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 1
|
|
||||||
ra = ReceptorAddress.objects.get(instance=node, canonical=True)
|
|
||||||
assert ra.port == initial_port # At the present time, changing ports is not allowed
|
|
||||||
assert ra.peers_from_control_nodes == payload_peers_from
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'payload_port, payload_peers_from, initial_port, initial_peers_from, error_msg',
|
|
||||||
[
|
|
||||||
(-1, True, None, None, "Cannot enable peers_from_control_nodes"),
|
|
||||||
(None, True, None, None, "Cannot enable peers_from_control_nodes"),
|
|
||||||
(None, True, 21799, True, "Cannot enable peers_from_control_nodes"),
|
|
||||||
(None, True, 21799, False, "Cannot enable peers_from_control_nodes"),
|
|
||||||
(21800, -1, 21799, True, "Cannot change listener port"),
|
|
||||||
(21800, True, 21799, True, "Cannot change listener port"),
|
|
||||||
(21800, False, 21799, True, "Cannot change listener port"),
|
|
||||||
(21800, -1, 21799, False, "Cannot change listener port"),
|
|
||||||
(21800, True, 21799, False, "Cannot change listener port"),
|
|
||||||
(21800, False, 21799, False, "Cannot change listener port"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_canonical_address_validation_error(self, payload_port, payload_peers_from, initial_port, initial_peers_from, error_msg, admin_user, patch):
|
|
||||||
node = Instance.objects.create(hostname='abc', node_type='hop')
|
|
||||||
if initial_port is not None:
|
|
||||||
ReceptorAddress.objects.create(address=node.hostname, port=initial_port, canonical=True, peers_from_control_nodes=initial_peers_from, instance=node)
|
|
||||||
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 1
|
|
||||||
else:
|
|
||||||
assert ReceptorAddress.objects.filter(instance=node).count() == 0
|
|
||||||
|
|
||||||
data = {'enabled': True} # Just to have something to post.
|
|
||||||
if payload_port != -1:
|
|
||||||
data['listener_port'] = payload_port
|
|
||||||
if payload_peers_from != -1:
|
|
||||||
data['peers_from_control_nodes'] = payload_peers_from
|
|
||||||
|
|
||||||
resp = patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': node.pk}),
|
|
||||||
data=data,
|
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=400,
|
expect=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert error_msg in str(resp.data)
|
def test_peers_from_control_nodes_listener_port_enabled(self, admin_user, post):
|
||||||
|
|
||||||
def test_changing_managed_listener_port(self, admin_user, patch):
|
|
||||||
"""
|
"""
|
||||||
if instance is managed, cannot change listener port at all
|
if peers_from_control_nodes is True, listener_port must an integer
|
||||||
|
Assert that all other combinations are allowed
|
||||||
"""
|
"""
|
||||||
hop = Instance.objects.create(hostname='abc', node_type="hop", managed=True)
|
for index, item in enumerate(itertools.product(['hop', 'execution'], [True, False], [None, 6789])):
|
||||||
resp = patch(
|
node_type, peers_from, listener_port = item
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
# only disallowed case is when peers_from is True and listener port is None
|
||||||
data={"listener_port": 5678},
|
disallowed = peers_from and not listener_port
|
||||||
user=admin_user,
|
post(
|
||||||
expect=400, # cannot set port
|
url=reverse('api:instance_list'),
|
||||||
)
|
data={"hostname": f"abc{index}", "peers_from_control_nodes": peers_from, "node_type": node_type, "listener_port": listener_port},
|
||||||
assert 'Cannot change listener port for managed nodes.' in str(resp.data)
|
user=admin_user,
|
||||||
ReceptorAddress.objects.create(instance=hop, address='hop', port=27199, canonical=True)
|
expect=400 if disallowed else 201,
|
||||||
resp = patch(
|
)
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
|
||||||
data={"listener_port": None},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400, # cannot unset port
|
|
||||||
)
|
|
||||||
assert 'Cannot change listener port for managed nodes.' in str(resp.data)
|
|
||||||
|
|
||||||
def test_bidirectional_peering(self, admin_user, patch):
|
|
||||||
"""
|
|
||||||
cannot peer to node that is already to peered to it
|
|
||||||
if A -> B, then disallow B -> A
|
|
||||||
"""
|
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
|
||||||
hop1addr = ReceptorAddress.objects.create(instance=hop1, address='hop1', canonical=True)
|
|
||||||
hop2 = Instance.objects.create(hostname='hop2', node_type='hop')
|
|
||||||
hop2addr = ReceptorAddress.objects.create(instance=hop2, address='hop2', canonical=True)
|
|
||||||
hop1.peers.add(hop2addr)
|
|
||||||
resp = patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop2.pk}),
|
|
||||||
data={"peers": [hop1addr.id]},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
assert 'Instance hop1 is already peered to this instance.' in str(resp.data)
|
|
||||||
|
|
||||||
def test_multiple_peers_same_instance(self, admin_user, patch):
|
|
||||||
"""
|
|
||||||
cannot peer to more than one address of the same instance
|
|
||||||
"""
|
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
|
||||||
hop1addr1 = ReceptorAddress.objects.create(instance=hop1, address='hop1', canonical=True)
|
|
||||||
hop1addr2 = ReceptorAddress.objects.create(instance=hop1, address='hop1alternate')
|
|
||||||
hop2 = Instance.objects.create(hostname='hop2', node_type='hop')
|
|
||||||
resp = patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop2.pk}),
|
|
||||||
data={"peers": [hop1addr1.id, hop1addr2.id]},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
assert 'Cannot peer to the same instance more than once.' in str(resp.data)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('node_type', ['control', 'hybrid'])
|
@pytest.mark.parametrize('node_type', ['control', 'hybrid'])
|
||||||
def test_changing_peers_control_nodes(self, node_type, admin_user, patch):
|
def test_disallow_modifying_peers_control_nodes(self, node_type, admin_user, patch):
|
||||||
"""
|
"""
|
||||||
for control nodes, peers field should not be
|
for control nodes, peers field should not be
|
||||||
modified directly via patch.
|
modified directly via patch.
|
||||||
"""
|
"""
|
||||||
control = Instance.objects.create(hostname='abc', node_type=node_type, managed=True)
|
control = Instance.objects.create(hostname='abc', node_type=node_type)
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
hop1 = Instance.objects.create(hostname='hop1', node_type='hop', peers_from_control_nodes=True, listener_port=6789)
|
||||||
hop1addr = ReceptorAddress.objects.create(instance=hop1, address='hop1', peers_from_control_nodes=True, canonical=True)
|
hop2 = Instance.objects.create(hostname='hop2', node_type='hop', peers_from_control_nodes=False, listener_port=6789)
|
||||||
hop2 = Instance.objects.create(hostname='hop2', node_type='hop')
|
assert [hop1] == list(control.peers.all()) # only hop1 should be peered
|
||||||
hop2addr = ReceptorAddress.objects.create(instance=hop2, address='hop2', canonical=True)
|
|
||||||
assert [hop1addr] == list(control.peers.all()) # only hop1addr should be peered
|
|
||||||
resp = patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
|
||||||
data={"peers": [hop2addr.id]},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400, # cannot add peers manually
|
|
||||||
)
|
|
||||||
assert 'Setting peers manually for managed nodes is not allowed.' in str(resp.data)
|
|
||||||
|
|
||||||
patch(
|
patch(
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
||||||
data={"peers": [hop1addr.id]},
|
data={"peers": ["hop2"]},
|
||||||
|
user=admin_user,
|
||||||
|
expect=400, # cannot add peers directly
|
||||||
|
)
|
||||||
|
patch(
|
||||||
|
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
||||||
|
data={"peers": ["hop1"]},
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=200, # patching with current peers list should be okay
|
expect=200, # patching with current peers list should be okay
|
||||||
)
|
)
|
||||||
resp = patch(
|
patch(
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
||||||
data={"peers": []},
|
data={"peers": []},
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=400, # cannot remove peers directly
|
expect=400, # cannot remove peers directly
|
||||||
)
|
)
|
||||||
assert 'Setting peers manually for managed nodes is not allowed.' in str(resp.data)
|
|
||||||
|
|
||||||
patch(
|
patch(
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
url=reverse('api:instance_detail', kwargs={'pk': control.pk}),
|
||||||
data={},
|
data={},
|
||||||
@@ -340,25 +148,23 @@ class TestPeers:
|
|||||||
url=reverse('api:instance_detail', kwargs={'pk': hop2.pk}),
|
url=reverse('api:instance_detail', kwargs={'pk': hop2.pk}),
|
||||||
data={"peers_from_control_nodes": True},
|
data={"peers_from_control_nodes": True},
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=200,
|
expect=200, # patching without data should be fine too
|
||||||
)
|
)
|
||||||
assert {hop1addr, hop2addr} == set(control.peers.all()) # hop1 and hop2 should now be peered from control node
|
assert {hop1, hop2} == set(control.peers.all()) # hop1 and hop2 should now be peered from control node
|
||||||
|
|
||||||
def test_changing_hostname(self, admin_user, patch):
|
def test_disallow_changing_hostname(self, admin_user, patch):
|
||||||
"""
|
"""
|
||||||
cannot change hostname
|
cannot change hostname
|
||||||
"""
|
"""
|
||||||
hop = Instance.objects.create(hostname='hop', node_type='hop')
|
hop = Instance.objects.create(hostname='hop', node_type='hop')
|
||||||
resp = patch(
|
patch(
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
||||||
data={"hostname": "hop2"},
|
data={"hostname": "hop2"},
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=400,
|
expect=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert 'Cannot change hostname.' in str(resp.data)
|
def test_disallow_changing_node_state(self, admin_user, patch):
|
||||||
|
|
||||||
def test_changing_node_state(self, admin_user, patch):
|
|
||||||
"""
|
"""
|
||||||
only allow setting to deprovisioning
|
only allow setting to deprovisioning
|
||||||
"""
|
"""
|
||||||
@@ -369,54 +175,12 @@ class TestPeers:
|
|||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=200,
|
expect=200,
|
||||||
)
|
)
|
||||||
resp = patch(
|
patch(
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
||||||
data={"node_state": "ready"},
|
data={"node_state": "ready"},
|
||||||
user=admin_user,
|
user=admin_user,
|
||||||
expect=400,
|
expect=400,
|
||||||
)
|
)
|
||||||
assert "Can only change instances to the 'deprovisioning' state." in str(resp.data)
|
|
||||||
|
|
||||||
def test_changing_managed_node_state(self, admin_user, patch):
|
|
||||||
"""
|
|
||||||
cannot change node state of managed node
|
|
||||||
"""
|
|
||||||
hop = Instance.objects.create(hostname='hop', node_type='hop', managed=True)
|
|
||||||
resp = patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
|
||||||
data={"node_state": "deprovisioning"},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert 'Cannot deprovision managed nodes.' in str(resp.data)
|
|
||||||
|
|
||||||
def test_changing_managed_peers_from_control_nodes(self, admin_user, patch):
|
|
||||||
"""
|
|
||||||
cannot change peers_from_control_nodes of managed node
|
|
||||||
"""
|
|
||||||
hop = Instance.objects.create(hostname='hop', node_type='hop', managed=True)
|
|
||||||
ReceptorAddress.objects.create(instance=hop, address='hop', peers_from_control_nodes=True, canonical=True)
|
|
||||||
resp = patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
|
||||||
data={"peers_from_control_nodes": False},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert 'Cannot change peers_from_control_nodes for managed nodes.' in str(resp.data)
|
|
||||||
|
|
||||||
hop.peers_from_control_nodes = False
|
|
||||||
hop.save()
|
|
||||||
|
|
||||||
resp = patch(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop.pk}),
|
|
||||||
data={"peers_from_control_nodes": False},
|
|
||||||
user=admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert 'Cannot change peers_from_control_nodes for managed nodes.' in str(resp.data)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('node_type', ['control', 'hybrid'])
|
@pytest.mark.parametrize('node_type', ['control', 'hybrid'])
|
||||||
def test_control_node_automatically_peers(self, node_type):
|
def test_control_node_automatically_peers(self, node_type):
|
||||||
@@ -427,10 +191,9 @@ class TestPeers:
|
|||||||
peer to hop should be removed if hop is deleted
|
peer to hop should be removed if hop is deleted
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hop = Instance.objects.create(hostname='hop', node_type='hop')
|
hop = Instance.objects.create(hostname='hop', node_type='hop', peers_from_control_nodes=True, listener_port=6789)
|
||||||
hopaddr = ReceptorAddress.objects.create(instance=hop, address='hop', peers_from_control_nodes=True, canonical=True)
|
|
||||||
control = Instance.objects.create(hostname='abc', node_type=node_type)
|
control = Instance.objects.create(hostname='abc', node_type=node_type)
|
||||||
assert hopaddr in control.peers.all()
|
assert hop in control.peers.all()
|
||||||
hop.delete()
|
hop.delete()
|
||||||
assert not control.peers.exists()
|
assert not control.peers.exists()
|
||||||
|
|
||||||
@@ -440,50 +203,26 @@ class TestPeers:
|
|||||||
if a new node comes online, other peer relationships should
|
if a new node comes online, other peer relationships should
|
||||||
remain intact
|
remain intact
|
||||||
"""
|
"""
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
hop1 = Instance.objects.create(hostname='hop1', node_type='hop', listener_port=6789, peers_from_control_nodes=True)
|
||||||
hop2 = Instance.objects.create(hostname='hop2', node_type='hop')
|
hop2 = Instance.objects.create(hostname='hop2', node_type='hop', listener_port=6789, peers_from_control_nodes=False)
|
||||||
hop2addr = ReceptorAddress.objects.create(instance=hop2, address='hop2', canonical=True)
|
hop1.peers.add(hop2)
|
||||||
hop1.peers.add(hop2addr)
|
|
||||||
|
|
||||||
# a control node is added
|
# a control node is added
|
||||||
Instance.objects.create(hostname='control', node_type=node_type)
|
Instance.objects.create(hostname='control', node_type=node_type, listener_port=None)
|
||||||
|
|
||||||
assert hop1.peers.exists()
|
assert hop1.peers.exists()
|
||||||
|
|
||||||
def test_reverse_peers(self, admin_user, get):
|
def test_group_vars(self, get, admin_user):
|
||||||
"""
|
|
||||||
if hop1 peers to hop2, hop1 should
|
|
||||||
be in hop2's reverse_peers list
|
|
||||||
"""
|
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
|
||||||
hop2 = Instance.objects.create(hostname='hop2', node_type='hop')
|
|
||||||
hop2addr = ReceptorAddress.objects.create(instance=hop2, address='hop2', canonical=True)
|
|
||||||
hop1.peers.add(hop2addr)
|
|
||||||
|
|
||||||
resp = get(
|
|
||||||
url=reverse('api:instance_detail', kwargs={'pk': hop2.pk}),
|
|
||||||
user=admin_user,
|
|
||||||
expect=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert hop1.pk in resp.data['reverse_peers']
|
|
||||||
|
|
||||||
def test_group_vars(self):
|
|
||||||
"""
|
"""
|
||||||
control > hop1 > hop2 < execution
|
control > hop1 > hop2 < execution
|
||||||
"""
|
"""
|
||||||
control = Instance.objects.create(hostname='control', node_type='control')
|
control = Instance.objects.create(hostname='control', node_type='control', listener_port=None)
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
hop1 = Instance.objects.create(hostname='hop1', node_type='hop', listener_port=6789, peers_from_control_nodes=True)
|
||||||
ReceptorAddress.objects.create(instance=hop1, address='hop1', peers_from_control_nodes=True, port=6789, canonical=True)
|
hop2 = Instance.objects.create(hostname='hop2', node_type='hop', listener_port=6789, peers_from_control_nodes=False)
|
||||||
|
execution = Instance.objects.create(hostname='execution', node_type='execution', listener_port=6789)
|
||||||
|
|
||||||
hop2 = Instance.objects.create(hostname='hop2', node_type='hop')
|
execution.peers.add(hop2)
|
||||||
hop2addr = ReceptorAddress.objects.create(instance=hop2, address='hop2', peers_from_control_nodes=False, port=6789, canonical=True)
|
hop1.peers.add(hop2)
|
||||||
|
|
||||||
execution = Instance.objects.create(hostname='execution', node_type='execution')
|
|
||||||
ReceptorAddress.objects.create(instance=execution, address='execution', peers_from_control_nodes=False, port=6789, canonical=True)
|
|
||||||
|
|
||||||
execution.peers.add(hop2addr)
|
|
||||||
hop1.peers.add(hop2addr)
|
|
||||||
|
|
||||||
control_vars = yaml.safe_load(generate_group_vars_all_yml(control))
|
control_vars = yaml.safe_load(generate_group_vars_all_yml(control))
|
||||||
hop1_vars = yaml.safe_load(generate_group_vars_all_yml(hop1))
|
hop1_vars = yaml.safe_load(generate_group_vars_all_yml(hop1))
|
||||||
@@ -526,15 +265,13 @@ class TestPeers:
|
|||||||
control = Instance.objects.create(hostname='control1', node_type='control')
|
control = Instance.objects.create(hostname='control1', node_type='control')
|
||||||
write_method.assert_not_called()
|
write_method.assert_not_called()
|
||||||
|
|
||||||
# new address with peers_from_control_nodes False (no)
|
# new hop node with peers_from_control_nodes False (no)
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
hop1 = Instance.objects.create(hostname='hop1', node_type='hop', listener_port=6789, peers_from_control_nodes=False)
|
||||||
hop1addr = ReceptorAddress.objects.create(instance=hop1, address='hop1', peers_from_control_nodes=False, canonical=True)
|
|
||||||
hop1.delete()
|
hop1.delete()
|
||||||
write_method.assert_not_called()
|
write_method.assert_not_called()
|
||||||
|
|
||||||
# new address with peers_from_control_nodes True (yes)
|
# new hop node with peers_from_control_nodes True (yes)
|
||||||
hop1 = Instance.objects.create(hostname='hop1', node_type='hop')
|
hop1 = Instance.objects.create(hostname='hop1', node_type='hop', listener_port=6789, peers_from_control_nodes=True)
|
||||||
hop1addr = ReceptorAddress.objects.create(instance=hop1, address='hop1', peers_from_control_nodes=True, canonical=True)
|
|
||||||
write_method.assert_called()
|
write_method.assert_called()
|
||||||
write_method.reset_mock()
|
write_method.reset_mock()
|
||||||
|
|
||||||
@@ -543,21 +280,20 @@ class TestPeers:
|
|||||||
write_method.assert_called()
|
write_method.assert_called()
|
||||||
write_method.reset_mock()
|
write_method.reset_mock()
|
||||||
|
|
||||||
# new address with peers_from_control_nodes False and peered to another hop node (no)
|
# new hop node with peers_from_control_nodes False and peered to another hop node (no)
|
||||||
hop2 = Instance.objects.create(hostname='hop2', node_type='hop')
|
hop2 = Instance.objects.create(hostname='hop2', node_type='hop', listener_port=6789, peers_from_control_nodes=False)
|
||||||
ReceptorAddress.objects.create(instance=hop2, address='hop2', peers_from_control_nodes=False, canonical=True)
|
hop2.peers.add(hop1)
|
||||||
hop2.peers.add(hop1addr)
|
|
||||||
hop2.delete()
|
hop2.delete()
|
||||||
write_method.assert_not_called()
|
write_method.assert_not_called()
|
||||||
|
|
||||||
# changing peers_from_control_nodes to False (yes)
|
# changing peers_from_control_nodes to False (yes)
|
||||||
hop1addr.peers_from_control_nodes = False
|
hop1.peers_from_control_nodes = False
|
||||||
hop1addr.save()
|
hop1.save()
|
||||||
write_method.assert_called()
|
write_method.assert_called()
|
||||||
write_method.reset_mock()
|
write_method.reset_mock()
|
||||||
|
|
||||||
# deleting address that has peers_from_control_nodes to False (no)
|
# deleting hop node that has peers_from_control_nodes to False (no)
|
||||||
hop1.delete() # cascade deletes to hop1addr
|
hop1.delete()
|
||||||
write_method.assert_not_called()
|
write_method.assert_not_called()
|
||||||
|
|
||||||
# deleting control nodes (no)
|
# deleting control nodes (no)
|
||||||
@@ -579,8 +315,8 @@ class TestPeers:
|
|||||||
|
|
||||||
# not peered, so config file should not be updated
|
# not peered, so config file should not be updated
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
inst = Instance.objects.create(hostname=f"exNo-{i}", node_type='execution')
|
Instance.objects.create(hostname=f"exNo-{i}", node_type='execution', listener_port=6789, peers_from_control_nodes=False)
|
||||||
ReceptorAddress.objects.create(instance=inst, address=f"exNo-{i}", port=6789, peers_from_control_nodes=False, canonical=True)
|
|
||||||
_, should_update = generate_config_data()
|
_, should_update = generate_config_data()
|
||||||
assert not should_update
|
assert not should_update
|
||||||
|
|
||||||
@@ -588,13 +324,11 @@ class TestPeers:
|
|||||||
expected_peers = []
|
expected_peers = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
expected_peers.append(f"hop-{i}:6789")
|
expected_peers.append(f"hop-{i}:6789")
|
||||||
inst = Instance.objects.create(hostname=f"hop-{i}", node_type='hop')
|
Instance.objects.create(hostname=f"hop-{i}", node_type='hop', listener_port=6789, peers_from_control_nodes=True)
|
||||||
ReceptorAddress.objects.create(instance=inst, address=f"hop-{i}", port=6789, peers_from_control_nodes=True, canonical=True)
|
|
||||||
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
expected_peers.append(f"exYes-{i}:6789")
|
expected_peers.append(f"exYes-{i}:6789")
|
||||||
inst = Instance.objects.create(hostname=f"exYes-{i}", node_type='execution')
|
Instance.objects.create(hostname=f"exYes-{i}", node_type='execution', listener_port=6789, peers_from_control_nodes=True)
|
||||||
ReceptorAddress.objects.create(instance=inst, address=f"exYes-{i}", port=6789, peers_from_control_nodes=True, canonical=True)
|
|
||||||
|
|
||||||
new_config, should_update = generate_config_data()
|
new_config, should_update = generate_config_data()
|
||||||
assert should_update
|
assert should_update
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ def test_default_cred_types():
|
|||||||
'satellite6',
|
'satellite6',
|
||||||
'scm',
|
'scm',
|
||||||
'ssh',
|
'ssh',
|
||||||
'terraform',
|
|
||||||
'thycotic_dsv',
|
'thycotic_dsv',
|
||||||
'thycotic_tss',
|
'thycotic_tss',
|
||||||
'vault',
|
'vault',
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from awx.main.models import Instance, ReceptorAddress, InstanceLink
|
|
||||||
from awx.main.tasks.system import inspect_established_receptor_connections
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestLinkState:
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def configure_settings(self, settings):
|
|
||||||
settings.IS_K8S = True
|
|
||||||
|
|
||||||
def test_inspect_established_receptor_connections(self):
|
|
||||||
'''
|
|
||||||
Change link state from ADDING to ESTABLISHED
|
|
||||||
if the receptor status KnownConnectionCosts field
|
|
||||||
has an entry for the source and target node.
|
|
||||||
'''
|
|
||||||
hop1 = Instance.objects.create(hostname='hop1')
|
|
||||||
hop2 = Instance.objects.create(hostname='hop2')
|
|
||||||
hop2addr = ReceptorAddress.objects.create(instance=hop2, address='hop2', port=5678)
|
|
||||||
InstanceLink.objects.create(source=hop1, target=hop2addr, link_state=InstanceLink.States.ADDING)
|
|
||||||
|
|
||||||
# calling with empty KnownConnectionCosts should not change the link state
|
|
||||||
inspect_established_receptor_connections({"KnownConnectionCosts": {}})
|
|
||||||
assert InstanceLink.objects.get(source=hop1, target=hop2addr).link_state == InstanceLink.States.ADDING
|
|
||||||
|
|
||||||
mesh_state = {"KnownConnectionCosts": {"hop1": {"hop2": 1}}}
|
|
||||||
inspect_established_receptor_connections(mesh_state)
|
|
||||||
assert InstanceLink.objects.get(source=hop1, target=hop2addr).link_state == InstanceLink.States.ESTABLISHED
|
|
||||||
@@ -42,29 +42,3 @@ class TestMigrationSmoke:
|
|||||||
final_state = migrator.apply_tested_migration(final_migration)
|
final_state = migrator.apply_tested_migration(final_migration)
|
||||||
Instance = final_state.apps.get_model('main', 'Instance')
|
Instance = final_state.apps.get_model('main', 'Instance')
|
||||||
assert Instance.objects.filter(hostname='foobar').count() == 1
|
assert Instance.objects.filter(hostname='foobar').count() == 1
|
||||||
|
|
||||||
def test_receptor_address(self, migrator):
|
|
||||||
old_state = migrator.apply_initial_migration(('main', '0188_add_bitbucket_dc_webhook'))
|
|
||||||
Instance = old_state.apps.get_model('main', 'Instance')
|
|
||||||
for i in range(3):
|
|
||||||
Instance.objects.create(hostname=f'foobar{i}', node_type='hop')
|
|
||||||
foo = Instance.objects.create(hostname='foo', node_type='execution', listener_port=1234)
|
|
||||||
bar = Instance.objects.create(hostname='bar', node_type='execution', listener_port=None)
|
|
||||||
bar.peers.add(foo)
|
|
||||||
|
|
||||||
new_state = migrator.apply_tested_migration(
|
|
||||||
('main', '0189_inbound_hop_nodes'),
|
|
||||||
)
|
|
||||||
Instance = new_state.apps.get_model('main', 'Instance')
|
|
||||||
ReceptorAddress = new_state.apps.get_model('main', 'ReceptorAddress')
|
|
||||||
|
|
||||||
# We can now test how our migration worked, new field is there:
|
|
||||||
assert ReceptorAddress.objects.filter(address='foo', port=1234).count() == 1
|
|
||||||
assert not ReceptorAddress.objects.filter(address='bar').exists()
|
|
||||||
|
|
||||||
bar = Instance.objects.get(hostname='bar')
|
|
||||||
fooaddr = ReceptorAddress.objects.get(address='foo')
|
|
||||||
|
|
||||||
bar_peers = bar.peers.all()
|
|
||||||
assert len(bar_peers) == 1
|
|
||||||
assert fooaddr in bar_peers
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
|
|
||||||
from channels.routing import ProtocolTypeRouter
|
|
||||||
from channels.testing.websocket import WebsocketCommunicator
|
|
||||||
|
|
||||||
|
|
||||||
from awx.main.consumers import WebsocketSecretAuthHelper
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def application():
|
|
||||||
# code in routing hits the db on import because .. settings cache
|
|
||||||
from awx.main.routing import application_func
|
|
||||||
|
|
||||||
yield application_func(ProtocolTypeRouter)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def websocket_server_generator(application):
|
|
||||||
def fn(endpoint):
|
|
||||||
return WebsocketCommunicator(application, endpoint)
|
|
||||||
|
|
||||||
return fn
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestWebsocketRelay:
|
|
||||||
@pytest.fixture
|
|
||||||
def websocket_relay_secret_generator(self, settings):
|
|
||||||
def fn(secret, set_broadcast_websocket_secret=False):
|
|
||||||
secret_backup = settings.BROADCAST_WEBSOCKET_SECRET
|
|
||||||
settings.BROADCAST_WEBSOCKET_SECRET = 'foobar'
|
|
||||||
res = ('secret'.encode('utf-8'), WebsocketSecretAuthHelper.construct_secret().encode('utf-8'))
|
|
||||||
if set_broadcast_websocket_secret is False:
|
|
||||||
settings.BROADCAST_WEBSOCKET_SECRET = secret_backup
|
|
||||||
return res
|
|
||||||
|
|
||||||
return fn
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def websocket_relay_secret(self, settings, websocket_relay_secret_generator):
|
|
||||||
return websocket_relay_secret_generator('foobar', set_broadcast_websocket_secret=True)
|
|
||||||
|
|
||||||
async def test_authorized(self, websocket_server_generator, websocket_relay_secret):
|
|
||||||
server = websocket_server_generator('/websocket/relay/')
|
|
||||||
|
|
||||||
server.scope['headers'] = (websocket_relay_secret,)
|
|
||||||
connected, _ = await server.connect()
|
|
||||||
assert connected is True
|
|
||||||
|
|
||||||
async def test_not_authorized(self, websocket_server_generator):
|
|
||||||
server = websocket_server_generator('/websocket/relay/')
|
|
||||||
connected, _ = await server.connect()
|
|
||||||
assert connected is False, "Connection to the relay websocket without auth. We expected the client to be denied."
|
|
||||||
|
|
||||||
async def test_wrong_secret(self, websocket_server_generator, websocket_relay_secret_generator):
|
|
||||||
server = websocket_server_generator('/websocket/relay/')
|
|
||||||
|
|
||||||
server.scope['headers'] = (websocket_relay_secret_generator('foobar', set_broadcast_websocket_secret=False),)
|
|
||||||
connected, _ = await server.connect()
|
|
||||||
assert connected is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestWebsocketEventConsumer:
|
|
||||||
async def test_unauthorized_anonymous(self, websocket_server_generator):
|
|
||||||
server = websocket_server_generator('/websocket/')
|
|
||||||
|
|
||||||
server.scope['user'] = AnonymousUser()
|
|
||||||
connected, _ = await server.connect()
|
|
||||||
assert connected is False, "Anonymous user should NOT be allowed to login."
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Ran out of coding time.")
|
|
||||||
async def test_authorized(self, websocket_server_generator, application, admin):
|
|
||||||
server = websocket_server_generator('/websocket/')
|
|
||||||
|
|
||||||
"""
|
|
||||||
I ran out of time. Here is what I was thinking ...
|
|
||||||
Inject a valid session into the cookies in the header
|
|
||||||
|
|
||||||
server.scope['headers'] = (
|
|
||||||
(b'cookie', ...),
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
connected, _ = await server.connect()
|
|
||||||
assert connected is True, "User should be allowed in via cookies auth via a session key in the cookies"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from awx.main.models import ReceptorAddress
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ReceptorAddress()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'address, protocol, port, websocket_path, expected',
|
|
||||||
[
|
|
||||||
('foo', 'tcp', 27199, '', 'foo:27199'),
|
|
||||||
('bar', 'ws', 6789, '', 'wss://bar:6789'),
|
|
||||||
('mal', 'ws', 6789, 'path', 'wss://mal:6789/path'),
|
|
||||||
('example.com', 'ws', 443, 'path', 'wss://example.com:443/path'),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_get_full_address(address, protocol, port, websocket_path, expected):
|
|
||||||
receptor_address = ReceptorAddress(address=address, protocol=protocol, port=port, websocket_path=websocket_path)
|
|
||||||
assert receptor_address.get_full_address() == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'protocol, expected',
|
|
||||||
[
|
|
||||||
('tcp', 'tcp-peer'),
|
|
||||||
('ws', 'ws-peer'),
|
|
||||||
('wss', 'ws-peer'),
|
|
||||||
('foo', None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_get_peer_type(protocol, expected):
|
|
||||||
receptor_address = ReceptorAddress(protocol=protocol)
|
|
||||||
assert receptor_address.get_peer_type() == expected
|
|
||||||
@@ -1085,27 +1085,6 @@ class TestJobCredentials(TestJobExecution):
|
|||||||
assert open(env['ANSIBLE_NET_SSH_KEYFILE'], 'r').read() == self.EXAMPLE_PRIVATE_KEY
|
assert open(env['ANSIBLE_NET_SSH_KEYFILE'], 'r').read() == self.EXAMPLE_PRIVATE_KEY
|
||||||
assert safe_env['ANSIBLE_NET_PASSWORD'] == HIDDEN_PASSWORD
|
assert safe_env['ANSIBLE_NET_PASSWORD'] == HIDDEN_PASSWORD
|
||||||
|
|
||||||
def test_terraform_cloud_credentials(self, job, private_data_dir, mock_me):
|
|
||||||
terraform = CredentialType.defaults['terraform']()
|
|
||||||
hcl_config = '''
|
|
||||||
backend "s3" {
|
|
||||||
bucket = "s3_sample_bucket"
|
|
||||||
key = "/tf_state/"
|
|
||||||
region = "us-east-1"
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
credential = Credential(pk=1, credential_type=terraform, inputs={'configuration': hcl_config})
|
|
||||||
credential.inputs['configuration'] = encrypt_field(credential, 'configuration')
|
|
||||||
job.credentials.add(credential)
|
|
||||||
|
|
||||||
env = {}
|
|
||||||
safe_env = {}
|
|
||||||
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
|
|
||||||
|
|
||||||
local_path = to_host_path(env['TF_BACKEND_CONFIG_FILE'], private_data_dir)
|
|
||||||
config = open(local_path, 'r').read()
|
|
||||||
assert config == hcl_config
|
|
||||||
|
|
||||||
def test_custom_environment_injectors_with_jinja_syntax_error(self, private_data_dir, mock_me):
|
def test_custom_environment_injectors_with_jinja_syntax_error(self, private_data_dir, mock_me):
|
||||||
some_cloud = CredentialType(
|
some_cloud = CredentialType(
|
||||||
kind='cloud',
|
kind='cloud',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from awx.main.analytics.broadcast_websocket import (
|
|||||||
RelayWebsocketStats,
|
RelayWebsocketStats,
|
||||||
RelayWebsocketStatsManager,
|
RelayWebsocketStatsManager,
|
||||||
)
|
)
|
||||||
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.wsrelay')
|
logger = logging.getLogger('awx.main.wsrelay')
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ class WebsocketRelayConnection:
|
|||||||
self.protocol = protocol
|
self.protocol = protocol
|
||||||
self.verify_ssl = verify_ssl
|
self.verify_ssl = verify_ssl
|
||||||
self.channel_layer = None
|
self.channel_layer = None
|
||||||
|
self.subsystem_metrics = s_metrics.Metrics(instance_name=name)
|
||||||
self.producers = dict()
|
self.producers = dict()
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
|
||||||
@@ -339,7 +341,7 @@ class WebSocketRelayManager(object):
|
|||||||
|
|
||||||
if deleted_remote_hosts:
|
if deleted_remote_hosts:
|
||||||
logger.info(f"Removing {deleted_remote_hosts} from websocket broadcast list")
|
logger.info(f"Removing {deleted_remote_hosts} from websocket broadcast list")
|
||||||
await asyncio.gather(*[self.cleanup_offline_host(h) for h in deleted_remote_hosts])
|
await asyncio.gather(self.cleanup_offline_host(h) for h in deleted_remote_hosts)
|
||||||
|
|
||||||
if new_remote_hosts:
|
if new_remote_hosts:
|
||||||
logger.info(f"Adding {new_remote_hosts} to websocket broadcast list")
|
logger.info(f"Adding {new_remote_hosts} to websocket broadcast list")
|
||||||
|
|||||||
@@ -216,54 +216,42 @@
|
|||||||
- block:
|
- block:
|
||||||
- name: Fetch galaxy roles from roles/requirements.(yml/yaml)
|
- name: Fetch galaxy roles from roles/requirements.(yml/yaml)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "ansible-galaxy role install -r {{ req_file }} {{ verbosity }}"
|
cmd: "ansible-galaxy role install -r {{ item }} {{ verbosity }}"
|
||||||
register: galaxy_result
|
register: galaxy_result
|
||||||
vars:
|
with_fileglob:
|
||||||
req_file: "{{ lookup('ansible.builtin.first_found', req_candidates, skip=True) }}"
|
- "{{ project_path | quote }}/roles/requirements.yaml"
|
||||||
req_candidates:
|
- "{{ project_path | quote }}/roles/requirements.yml"
|
||||||
- "{{ project_path | quote }}/roles/requirements.yml"
|
|
||||||
- "{{ project_path | quote }}/roles/requirements.yaml"
|
|
||||||
changed_when: "'was installed successfully' in galaxy_result.stdout"
|
changed_when: "'was installed successfully' in galaxy_result.stdout"
|
||||||
when:
|
when: roles_enabled | bool
|
||||||
- roles_enabled | bool
|
|
||||||
- req_file
|
|
||||||
tags:
|
tags:
|
||||||
- install_roles
|
- install_roles
|
||||||
|
|
||||||
- name: Fetch galaxy collections from collections/requirements.(yml/yaml)
|
- name: Fetch galaxy collections from collections/requirements.(yml/yaml)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "ansible-galaxy collection install -r {{ req_file }} {{ verbosity }}"
|
cmd: "ansible-galaxy collection install -r {{ item }} {{ verbosity }}"
|
||||||
register: galaxy_collection_result
|
register: galaxy_collection_result
|
||||||
vars:
|
with_fileglob:
|
||||||
req_file: "{{ lookup('ansible.builtin.first_found', req_candidates, skip=True) }}"
|
- "{{ project_path | quote }}/collections/requirements.yaml"
|
||||||
req_candidates:
|
- "{{ project_path | quote }}/collections/requirements.yml"
|
||||||
- "{{ project_path | quote }}/collections/requirements.yml"
|
|
||||||
- "{{ project_path | quote }}/collections/requirements.yaml"
|
|
||||||
- "{{ project_path | quote }}/requirements.yml"
|
|
||||||
- "{{ project_path | quote }}/requirements.yaml"
|
|
||||||
changed_when: "'Nothing to do.' not in galaxy_collection_result.stdout"
|
changed_when: "'Nothing to do.' not in galaxy_collection_result.stdout"
|
||||||
when:
|
when:
|
||||||
- "ansible_version.full is version_compare('2.9', '>=')"
|
- "ansible_version.full is version_compare('2.9', '>=')"
|
||||||
- collections_enabled | bool
|
- collections_enabled | bool
|
||||||
- req_file
|
|
||||||
tags:
|
tags:
|
||||||
- install_collections
|
- install_collections
|
||||||
|
|
||||||
- name: Fetch galaxy roles and collections from requirements.(yml/yaml)
|
- name: Fetch galaxy roles and collections from requirements.(yml/yaml)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "ansible-galaxy install -r {{ req_file }} {{ verbosity }}"
|
cmd: "ansible-galaxy install -r {{ item }} {{ verbosity }}"
|
||||||
register: galaxy_combined_result
|
register: galaxy_combined_result
|
||||||
vars:
|
with_fileglob:
|
||||||
req_file: "{{ lookup('ansible.builtin.first_found', req_candidates, skip=True) }}"
|
- "{{ project_path | quote }}/requirements.yaml"
|
||||||
req_candidates:
|
- "{{ project_path | quote }}/requirements.yml"
|
||||||
- "{{ project_path | quote }}/requirements.yaml"
|
|
||||||
- "{{ project_path | quote }}/requirements.yml"
|
|
||||||
changed_when: "'Nothing to do.' not in galaxy_combined_result.stdout"
|
changed_when: "'Nothing to do.' not in galaxy_combined_result.stdout"
|
||||||
when:
|
when:
|
||||||
- "ansible_version.full is version_compare('2.10', '>=')"
|
- "ansible_version.full is version_compare('2.10', '>=')"
|
||||||
- collections_enabled | bool
|
- collections_enabled | bool
|
||||||
- roles_enabled | bool
|
- roles_enabled | bool
|
||||||
- req_file
|
|
||||||
tags:
|
tags:
|
||||||
- install_collections
|
- install_collections
|
||||||
- install_roles
|
- install_roles
|
||||||
|
|||||||
@@ -1076,35 +1076,6 @@ HOST_METRIC_SUMMARY_TASK_LAST_TS = None
|
|||||||
HOST_METRIC_SUMMARY_TASK_INTERVAL = 7 # days
|
HOST_METRIC_SUMMARY_TASK_INTERVAL = 7 # days
|
||||||
|
|
||||||
|
|
||||||
# TODO: cmeyers, replace with with register pattern
|
|
||||||
# The register pattern is particularly nice for this because we need
|
|
||||||
# to know the process to start the thread that will be the server.
|
|
||||||
# The registration location should be the same location as we would
|
|
||||||
# call MetricsServer.start()
|
|
||||||
# Note: if we don't get to this TODO, then at least create constants
|
|
||||||
# for the services strings below.
|
|
||||||
# TODO: cmeyers, break this out into a separate django app so other
|
|
||||||
# projects can take advantage.
|
|
||||||
|
|
||||||
METRICS_SERVICE_CALLBACK_RECEIVER = 'callback_receiver'
|
|
||||||
METRICS_SERVICE_DISPATCHER = 'dispatcher'
|
|
||||||
METRICS_SERVICE_WEBSOCKETS = 'websockets'
|
|
||||||
|
|
||||||
METRICS_SUBSYSTEM_CONFIG = {
|
|
||||||
'server': {
|
|
||||||
METRICS_SERVICE_CALLBACK_RECEIVER: {
|
|
||||||
'port': 8014,
|
|
||||||
},
|
|
||||||
METRICS_SERVICE_DISPATCHER: {
|
|
||||||
'port': 8015,
|
|
||||||
},
|
|
||||||
METRICS_SERVICE_WEBSOCKETS: {
|
|
||||||
'port': 8016,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# django-ansible-base
|
# django-ansible-base
|
||||||
ANSIBLE_BASE_TEAM_MODEL = 'main.Team'
|
ANSIBLE_BASE_TEAM_MODEL = 'main.Team'
|
||||||
ANSIBLE_BASE_ORGANIZATION_MODEL = 'main.Organization'
|
ANSIBLE_BASE_ORGANIZATION_MODEL = 'main.Organization'
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from split_settings.tools import optional, include
|
|||||||
from .defaults import * # NOQA
|
from .defaults import * # NOQA
|
||||||
|
|
||||||
# awx-manage shell_plus --notebook
|
# awx-manage shell_plus --notebook
|
||||||
NOTEBOOK_ARGUMENTS = ['--NotebookApp.token=', '--ip', '0.0.0.0', '--port', '9888', '--allow-root', '--no-browser']
|
NOTEBOOK_ARGUMENTS = ['--NotebookApp.token=', '--ip', '0.0.0.0', '--port', '8888', '--allow-root', '--no-browser']
|
||||||
|
|
||||||
# print SQL queries in shell_plus
|
# print SQL queries in shell_plus
|
||||||
SHELL_PLUS_PRINT_SQL = False
|
SHELL_PLUS_PRINT_SQL = False
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import Notifications from './models/Notifications';
|
|||||||
import Organizations from './models/Organizations';
|
import Organizations from './models/Organizations';
|
||||||
import ProjectUpdates from './models/ProjectUpdates';
|
import ProjectUpdates from './models/ProjectUpdates';
|
||||||
import Projects from './models/Projects';
|
import Projects from './models/Projects';
|
||||||
import ReceptorAddresses from './models/Receptor';
|
|
||||||
import Roles from './models/Roles';
|
import Roles from './models/Roles';
|
||||||
import Root from './models/Root';
|
import Root from './models/Root';
|
||||||
import Schedules from './models/Schedules';
|
import Schedules from './models/Schedules';
|
||||||
@@ -80,7 +79,6 @@ const NotificationsAPI = new Notifications();
|
|||||||
const OrganizationsAPI = new Organizations();
|
const OrganizationsAPI = new Organizations();
|
||||||
const ProjectUpdatesAPI = new ProjectUpdates();
|
const ProjectUpdatesAPI = new ProjectUpdates();
|
||||||
const ProjectsAPI = new Projects();
|
const ProjectsAPI = new Projects();
|
||||||
const ReceptorAPI = new ReceptorAddresses();
|
|
||||||
const RolesAPI = new Roles();
|
const RolesAPI = new Roles();
|
||||||
const RootAPI = new Root();
|
const RootAPI = new Root();
|
||||||
const SchedulesAPI = new Schedules();
|
const SchedulesAPI = new Schedules();
|
||||||
@@ -132,7 +130,6 @@ export {
|
|||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
ProjectUpdatesAPI,
|
ProjectUpdatesAPI,
|
||||||
ProjectsAPI,
|
ProjectsAPI,
|
||||||
ReceptorAPI,
|
|
||||||
RolesAPI,
|
RolesAPI,
|
||||||
RootAPI,
|
RootAPI,
|
||||||
SchedulesAPI,
|
SchedulesAPI,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ class Instances extends Base {
|
|||||||
this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this);
|
this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this);
|
||||||
this.healthCheck = this.healthCheck.bind(this);
|
this.healthCheck = this.healthCheck.bind(this);
|
||||||
this.readInstanceGroup = this.readInstanceGroup.bind(this);
|
this.readInstanceGroup = this.readInstanceGroup.bind(this);
|
||||||
this.readReceptorAddresses = this.readReceptorAddresses.bind(this);
|
|
||||||
this.deprovisionInstance = this.deprovisionInstance.bind(this);
|
this.deprovisionInstance = this.deprovisionInstance.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,17 +27,6 @@ class Instances extends Base {
|
|||||||
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
readReceptorAddresses(instanceId) {
|
|
||||||
return this.http.get(`${this.baseUrl}${instanceId}/receptor_addresses/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateReceptorAddresses(instanceId, data) {
|
|
||||||
return this.http.post(
|
|
||||||
`${this.baseUrl}${instanceId}/receptor_addresses/`,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deprovisionInstance(instanceId) {
|
deprovisionInstance(instanceId) {
|
||||||
return this.http.patch(`${this.baseUrl}${instanceId}/`, {
|
return this.http.patch(`${this.baseUrl}${instanceId}/`, {
|
||||||
node_state: 'deprovisioning',
|
node_state: 'deprovisioning',
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import Base from '../Base';
|
|
||||||
|
|
||||||
class ReceptorAddresses extends Base {
|
|
||||||
constructor(http) {
|
|
||||||
super(http);
|
|
||||||
this.baseUrl = 'api/v2/receptor_addresses/';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateReceptorAddresses(instanceId, data) {
|
|
||||||
return this.http.post(`${this.baseUrl}`, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReceptorAddresses;
|
|
||||||
@@ -257,17 +257,12 @@ function PromptDetail({
|
|||||||
numChips={5}
|
numChips={5}
|
||||||
ouiaId="prompt-job-tag-chips"
|
ouiaId="prompt-job-tag-chips"
|
||||||
totalChips={
|
totalChips={
|
||||||
overrides.job_tags === undefined ||
|
!overrides.job_tags || overrides.job_tags === ''
|
||||||
overrides.job_tags === null ||
|
|
||||||
overrides.job_tags === ''
|
|
||||||
? 0
|
? 0
|
||||||
: overrides.job_tags.split(',').length
|
: overrides.job_tags.split(',').length
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{overrides.job_tags !== undefined &&
|
{overrides.job_tags.length > 0 &&
|
||||||
overrides.job_tags !== null &&
|
|
||||||
overrides.job_tags !== '' &&
|
|
||||||
overrides.job_tags.length > 0 &&
|
|
||||||
overrides.job_tags.split(',').map((jobTag) => (
|
overrides.job_tags.split(',').map((jobTag) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={jobTag}
|
key={jobTag}
|
||||||
@@ -289,18 +284,13 @@ function PromptDetail({
|
|||||||
<ChipGroup
|
<ChipGroup
|
||||||
numChips={5}
|
numChips={5}
|
||||||
totalChips={
|
totalChips={
|
||||||
overrides.skip_tags === undefined ||
|
!overrides.skip_tags || overrides.skip_tags === ''
|
||||||
overrides.skip_tags === null ||
|
|
||||||
overrides.skip_tags === ''
|
|
||||||
? 0
|
? 0
|
||||||
: overrides.skip_tags.split(',').length
|
: overrides.skip_tags.split(',').length
|
||||||
}
|
}
|
||||||
ouiaId="prompt-skip-tag-chips"
|
ouiaId="prompt-skip-tag-chips"
|
||||||
>
|
>
|
||||||
{overrides.skip_tags !== undefined &&
|
{overrides.skip_tags.length > 0 &&
|
||||||
overrides.skip_tags !== null &&
|
|
||||||
overrides.skip_tags !== '' &&
|
|
||||||
overrides.skip_tags.length > 0 &&
|
|
||||||
overrides.skip_tags.split(',').map((skipTag) => (
|
overrides.skip_tags.split(',').map((skipTag) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={skipTag}
|
key={skipTag}
|
||||||
|
|||||||
@@ -115,11 +115,8 @@ function SessionProvider({ children }) {
|
|||||||
}, [setSessionTimeout, setSessionCountdown]);
|
}, [setSessionTimeout, setSessionCountdown]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isRedirectCondition = (location, histLength) =>
|
|
||||||
location.pathname === '/login' && histLength === 2;
|
|
||||||
|
|
||||||
const unlisten = history.listen((location, action) => {
|
const unlisten = history.listen((location, action) => {
|
||||||
if (action === 'POP' || isRedirectCondition(location, history.length)) {
|
if (action === 'POP') {
|
||||||
setIsRedirectLinkReceived(true);
|
setIsRedirectLinkReceived(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -784,7 +784,7 @@ msgstr "Branche à utiliser dans l’exécution de la tâche. Projet par défaut
|
|||||||
|
|
||||||
#: screens/Inventory/shared/Inventory.helptext.js:155
|
#: screens/Inventory/shared/Inventory.helptext.js:155
|
||||||
msgid "Branch to use on inventory sync. Project default used if blank. Only allowed if project allow_override field is set to true."
|
msgid "Branch to use on inventory sync. Project default used if blank. Only allowed if project allow_override field is set to true."
|
||||||
msgstr "Branche à utiliser pour la synchronisation de l'inventaire. La valeur par défaut du projet est utilisée si elle est vide. Cette option n'est autorisée que si le champ allow_override du projet est défini sur vrai."
|
msgstr ""
|
||||||
|
|
||||||
#: components/About/About.js:45
|
#: components/About/About.js:45
|
||||||
msgid "Brand Image"
|
msgid "Brand Image"
|
||||||
@@ -2832,7 +2832,7 @@ msgstr "Entrez les variables avec la syntaxe JSON ou YAML. Consultez la documen
|
|||||||
|
|
||||||
#: screens/Inventory/shared/SmartInventoryForm.js:94
|
#: screens/Inventory/shared/SmartInventoryForm.js:94
|
||||||
msgid "Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Controller documentation for example syntax."
|
msgid "Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Controller documentation for example syntax."
|
||||||
msgstr "Entrez les variables d'inventaire en utilisant la syntaxe JSON ou YAML. Utilisez le bouton d'option pour basculer entre les deux. Référez-vous à la documentation du contrôleur Ansible pour les exemples de syntaxe."
|
msgstr ""
|
||||||
|
|
||||||
#: screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.js:87
|
#: screens/CredentialType/CredentialTypeDetails/CredentialTypeDetails.js:87
|
||||||
msgid "Environment variables or extra variables that specify the values a credential type can inject."
|
msgid "Environment variables or extra variables that specify the values a credential type can inject."
|
||||||
@@ -3015,7 +3015,7 @@ msgstr "Recherche exacte sur le champ d'identification."
|
|||||||
|
|
||||||
#: components/Search/RelatedLookupTypeInput.js:38
|
#: components/Search/RelatedLookupTypeInput.js:38
|
||||||
msgid "Exact search on name field."
|
msgid "Exact search on name field."
|
||||||
msgstr "Recherche exacte sur le champ nom."
|
msgstr ""
|
||||||
|
|
||||||
#: screens/Project/shared/Project.helptext.js:23
|
#: screens/Project/shared/Project.helptext.js:23
|
||||||
msgid "Example URLs for GIT Source Control include:"
|
msgid "Example URLs for GIT Source Control include:"
|
||||||
@@ -3242,7 +3242,7 @@ msgstr "Jobs ayant échoué"
|
|||||||
|
|
||||||
#: screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js:262
|
#: screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js:262
|
||||||
msgid "Failed to approve one or more workflow approval."
|
msgid "Failed to approve one or more workflow approval."
|
||||||
msgstr "Échec de l'approbation d'une ou plusieurs validations de flux de travail."
|
msgstr ""
|
||||||
|
|
||||||
#: screens/WorkflowApproval/shared/WorkflowApprovalButton.js:56
|
#: screens/WorkflowApproval/shared/WorkflowApprovalButton.js:56
|
||||||
msgid "Failed to approve {0}."
|
msgid "Failed to approve {0}."
|
||||||
@@ -3474,7 +3474,7 @@ msgstr "N'a pas réussi à supprimer {name}."
|
|||||||
|
|
||||||
#: screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js:263
|
#: screens/WorkflowApproval/WorkflowApprovalList/WorkflowApprovalList.js:263
|
||||||
msgid "Failed to deny one or more workflow approval."
|
msgid "Failed to deny one or more workflow approval."
|
||||||
msgstr "Échec du refus d'une ou plusieurs validations de flux de travail."
|
msgstr ""
|
||||||
|
|
||||||
#: screens/WorkflowApproval/shared/WorkflowDenyButton.js:51
|
#: screens/WorkflowApproval/shared/WorkflowDenyButton.js:51
|
||||||
msgid "Failed to deny {0}."
|
msgid "Failed to deny {0}."
|
||||||
@@ -3520,7 +3520,7 @@ msgstr "Echec du lancement du Job."
|
|||||||
|
|
||||||
#: screens/Inventory/InventoryHosts/InventoryHostItem.js:121
|
#: screens/Inventory/InventoryHosts/InventoryHostItem.js:121
|
||||||
msgid "Failed to load related groups."
|
msgid "Failed to load related groups."
|
||||||
msgstr "Impossible de charger les groupes associés."
|
msgstr ""
|
||||||
|
|
||||||
#: screens/Instances/InstanceDetail/InstanceDetail.js:388
|
#: screens/Instances/InstanceDetail/InstanceDetail.js:388
|
||||||
#: screens/Instances/InstanceList/InstanceList.js:266
|
#: screens/Instances/InstanceList/InstanceList.js:266
|
||||||
@@ -3972,12 +3972,12 @@ msgstr "Demande(s) de bilan de santé soumise(s). Veuillez patienter et recharge
|
|||||||
#: screens/Instances/InstanceDetail/InstanceDetail.js:234
|
#: screens/Instances/InstanceDetail/InstanceDetail.js:234
|
||||||
#: screens/Instances/InstanceList/InstanceListItem.js:242
|
#: screens/Instances/InstanceList/InstanceListItem.js:242
|
||||||
msgid "Health checks are asynchronous tasks. See the"
|
msgid "Health checks are asynchronous tasks. See the"
|
||||||
msgstr "Les bilans de santé sont des tâches asynchrones. Veuillez consulter la documentation pour plus d'informations."
|
msgstr ""
|
||||||
|
|
||||||
#: screens/InstanceGroup/Instances/InstanceList.js:286
|
#: screens/InstanceGroup/Instances/InstanceList.js:286
|
||||||
#: screens/Instances/InstanceList/InstanceList.js:219
|
#: screens/Instances/InstanceList/InstanceList.js:219
|
||||||
msgid "Health checks can only be run on execution nodes."
|
msgid "Health checks can only be run on execution nodes."
|
||||||
msgstr "Les bilans de santé ne peuvent être exécutées que sur les nœuds d'exécution."
|
msgstr ""
|
||||||
|
|
||||||
#: components/StatusLabel/StatusLabel.js:42
|
#: components/StatusLabel/StatusLabel.js:42
|
||||||
msgid "Healthy"
|
msgid "Healthy"
|
||||||
@@ -5048,7 +5048,7 @@ msgstr "Lancer"
|
|||||||
|
|
||||||
#: components/TemplateList/TemplateListItem.js:214
|
#: components/TemplateList/TemplateListItem.js:214
|
||||||
msgid "Launch Template"
|
msgid "Launch Template"
|
||||||
msgstr "Lancer le modèle."
|
msgstr "Lacer le modèle."
|
||||||
|
|
||||||
#: screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.js:32
|
#: screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.js:32
|
||||||
#: screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.js:34
|
#: screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.js:34
|
||||||
@@ -9637,7 +9637,7 @@ msgstr "Utilisateur"
|
|||||||
|
|
||||||
#: components/AppContainer/PageHeaderToolbar.js:160
|
#: components/AppContainer/PageHeaderToolbar.js:160
|
||||||
msgid "User Details"
|
msgid "User Details"
|
||||||
msgstr "Détails de l'utilisateur"
|
msgstr "Détails de l'erreur"
|
||||||
|
|
||||||
#: screens/Setting/SettingList.js:121
|
#: screens/Setting/SettingList.js:121
|
||||||
#: screens/Setting/Settings.js:118
|
#: screens/Setting/Settings.js:118
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { SettingsAPI } from 'api';
|
|||||||
import ContentLoading from 'components/ContentLoading';
|
import ContentLoading from 'components/ContentLoading';
|
||||||
import InstanceDetail from './InstanceDetail';
|
import InstanceDetail from './InstanceDetail';
|
||||||
import InstancePeerList from './InstancePeers';
|
import InstancePeerList from './InstancePeers';
|
||||||
import InstanceListenerAddressList from './InstanceListenerAddressList';
|
|
||||||
|
|
||||||
function Instance({ setBreadcrumb }) {
|
function Instance({ setBreadcrumb }) {
|
||||||
const { me } = useConfig();
|
const { me } = useConfig();
|
||||||
@@ -55,12 +54,7 @@ function Instance({ setBreadcrumb }) {
|
|||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
if (isK8s) {
|
if (isK8s) {
|
||||||
tabsArray.push({
|
tabsArray.push({ name: t`Peers`, link: `${match.url}/peers`, id: 1 });
|
||||||
name: t`Listener Addresses`,
|
|
||||||
link: `${match.url}/listener_addresses`,
|
|
||||||
id: 1,
|
|
||||||
});
|
|
||||||
tabsArray.push({ name: t`Peers`, link: `${match.url}/peers`, id: 2 });
|
|
||||||
}
|
}
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
@@ -78,14 +72,6 @@ function Instance({ setBreadcrumb }) {
|
|||||||
<Route path="/instances/:id/details" key="details">
|
<Route path="/instances/:id/details" key="details">
|
||||||
<InstanceDetail isK8s={isK8s} setBreadcrumb={setBreadcrumb} />
|
<InstanceDetail isK8s={isK8s} setBreadcrumb={setBreadcrumb} />
|
||||||
</Route>
|
</Route>
|
||||||
{isK8s && (
|
|
||||||
<Route
|
|
||||||
path="/instances/:id/listener_addresses"
|
|
||||||
key="listener_addresses"
|
|
||||||
>
|
|
||||||
<InstanceListenerAddressList setBreadcrumb={setBreadcrumb} />
|
|
||||||
</Route>
|
|
||||||
)}
|
|
||||||
{isK8s && (
|
{isK8s && (
|
||||||
<Route path="/instances/:id/peers" key="peers">
|
<Route path="/instances/:id/peers" key="peers">
|
||||||
<InstancePeerList setBreadcrumb={setBreadcrumb} />
|
<InstancePeerList setBreadcrumb={setBreadcrumb} />
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ function InstanceAdd() {
|
|||||||
const [formError, setFormError] = useState();
|
const [formError, setFormError] = useState();
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = async (values) => {
|
||||||
try {
|
try {
|
||||||
if (values.listener_port === undefined) {
|
|
||||||
values.listener_port = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { id },
|
data: { id },
|
||||||
} = await InstancesAPI.create(values);
|
} = await InstancesAPI.create(values);
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ describe('<InstanceAdd />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(InstancesAPI.create).toHaveBeenCalledWith({
|
expect(InstancesAPI.create).toHaveBeenCalledWith({
|
||||||
listener_port: null, // injected if listener_port is not set
|
|
||||||
node_type: 'hop',
|
node_type: 'hop',
|
||||||
});
|
});
|
||||||
expect(history.location.pathname).toBe('/instances/13/details');
|
expect(history.location.pathname).toBe('/instances/13/details');
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
}
|
}
|
||||||
const isHopNode = instance.node_type === 'hop';
|
const isHopNode = instance.node_type === 'hop';
|
||||||
const isExecutionNode = instance.node_type === 'execution';
|
const isExecutionNode = instance.node_type === 'execution';
|
||||||
const isManaged = instance.managed;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -209,31 +208,33 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
<Detail label={t`Node Type`} value={instance.node_type} />
|
<Detail label={t`Node Type`} value={instance.node_type} />
|
||||||
<Detail label={t`Host`} value={instance.ip_address} />
|
<Detail label={t`Host`} value={instance.ip_address} />
|
||||||
<Detail label={t`Listener Port`} value={instance.listener_port} />
|
<Detail label={t`Listener Port`} value={instance.listener_port} />
|
||||||
{!isManaged && instance.related?.install_bundle && (
|
|
||||||
<Detail
|
|
||||||
label={t`Install Bundle`}
|
|
||||||
value={
|
|
||||||
<Tooltip content={t`Click to download bundle`}>
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
isSmall
|
|
||||||
href={`${instance.related?.install_bundle}`}
|
|
||||||
target="_blank"
|
|
||||||
variant="secondary"
|
|
||||||
dataCy="install-bundle-download-button"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(isExecutionNode || isHopNode) && (
|
{(isExecutionNode || isHopNode) && (
|
||||||
<Detail
|
<>
|
||||||
label={t`Peers from control nodes`}
|
{instance.related?.install_bundle && (
|
||||||
value={instance.peers_from_control_nodes ? t`On` : t`Off`}
|
<Detail
|
||||||
/>
|
label={t`Install Bundle`}
|
||||||
|
value={
|
||||||
|
<Tooltip content={t`Click to download bundle`}>
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
isSmall
|
||||||
|
href={`${instance.related?.install_bundle}`}
|
||||||
|
target="_blank"
|
||||||
|
variant="secondary"
|
||||||
|
dataCy="install-bundle-download-button"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Detail
|
||||||
|
label={t`Peers from control nodes`}
|
||||||
|
value={instance.peers_from_control_nodes ? t`On` : t`Off`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!isHopNode && (
|
{!isHopNode && (
|
||||||
<>
|
<>
|
||||||
@@ -293,9 +294,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
value={instance.capacity_adjustment}
|
value={instance.capacity_adjustment}
|
||||||
onChange={handleChangeValue}
|
onChange={handleChangeValue}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
!config?.me?.is_superuser ||
|
!config?.me?.is_superuser || !instance.enabled
|
||||||
!instance.enabled ||
|
|
||||||
!isManaged
|
|
||||||
}
|
}
|
||||||
data-cy="slider"
|
data-cy="slider"
|
||||||
/>
|
/>
|
||||||
@@ -339,31 +338,31 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
)}
|
)}
|
||||||
</DetailList>
|
</DetailList>
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{config?.me?.is_superuser && isK8s && !isManaged && (
|
{config?.me?.is_superuser && isK8s && (isExecutionNode || isHopNode) && (
|
||||||
<>
|
<Button
|
||||||
<Button
|
ouiaId="instance-detail-edit-button"
|
||||||
ouiaId="instance-detail-edit-button"
|
aria-label={t`edit`}
|
||||||
aria-label={t`edit`}
|
component={Link}
|
||||||
component={Link}
|
to={`/instances/${id}/edit`}
|
||||||
to={`/instances/${id}/edit`}
|
>
|
||||||
>
|
{t`Edit`}
|
||||||
{t`Edit`}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
|
{config?.me?.is_superuser &&
|
||||||
|
isK8s &&
|
||||||
|
(isExecutionNode || isHopNode) && (
|
||||||
<RemoveInstanceButton
|
<RemoveInstanceButton
|
||||||
dataCy="remove-instance-button"
|
dataCy="remove-instance-button"
|
||||||
itemsToRemove={[instance]}
|
itemsToRemove={[instance]}
|
||||||
isK8s={isK8s}
|
isK8s={isK8s}
|
||||||
onRemove={removeInstances}
|
onRemove={removeInstances}
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
{isExecutionNode && (
|
{isExecutionNode && (
|
||||||
<Tooltip content={t`Run a health check on the instance`}>
|
<Tooltip content={t`Run a health check on the instance`}>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={
|
isDisabled={
|
||||||
!config?.me?.is_superuser ||
|
!config?.me?.is_superuser || instance.health_check_pending
|
||||||
instance.health_check_pending ||
|
|
||||||
instance.managed
|
|
||||||
}
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
ouiaId="health-check-button"
|
ouiaId="health-check-button"
|
||||||
@@ -377,14 +376,12 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!isHopNode && (
|
<InstanceToggle
|
||||||
<InstanceToggle
|
css="display: inline-flex;"
|
||||||
css="display: inline-flex;"
|
fetchInstances={fetchDetails}
|
||||||
fetchInstances={fetchDetails}
|
instance={instance}
|
||||||
instance={instance}
|
dataCy="enable-instance"
|
||||||
dataCy="enable-instance"
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardActionsRow>
|
</CardActionsRow>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ describe('<InstanceDetail/>', () => {
|
|||||||
cpu_capacity: 32,
|
cpu_capacity: 32,
|
||||||
mem_capacity: 38,
|
mem_capacity: 38,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
managed: false,
|
|
||||||
managed_by_policy: true,
|
managed_by_policy: true,
|
||||||
node_type: 'execution',
|
node_type: 'execution',
|
||||||
node_state: 'ready',
|
node_state: 'ready',
|
||||||
|
|||||||
@@ -114,8 +114,7 @@ function InstanceListItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isHopNode = instance.node_type === 'hop';
|
const isHopNode = instance.node_type === 'hop';
|
||||||
const isManaged = instance.managed;
|
const isExecutionNode = instance.node_type === 'execution';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tr
|
<Tr
|
||||||
@@ -139,7 +138,7 @@ function InstanceListItem({
|
|||||||
rowIndex,
|
rowIndex,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
disable: isManaged,
|
disable: !(isExecutionNode || isHopNode),
|
||||||
}}
|
}}
|
||||||
dataLabel={t`Selected`}
|
dataLabel={t`Selected`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { CardBody } from 'components/Card';
|
|
||||||
import PaginatedTable, {
|
|
||||||
getSearchableKeys,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
// ToolbarAddButton,
|
|
||||||
} from 'components/PaginatedTable';
|
|
||||||
import useToast from 'hooks/useToast';
|
|
||||||
import { getQSConfig } from 'util/qs';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import useRequest from 'hooks/useRequest';
|
|
||||||
import DataListToolbar from 'components/DataListToolbar';
|
|
||||||
import { InstancesAPI, ReceptorAPI } from 'api';
|
|
||||||
import useSelected from 'hooks/useSelected';
|
|
||||||
import InstanceListenerAddressListItem from './InstanceListenerAddressListItem';
|
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('peer', {
|
|
||||||
page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
order_by: 'pk',
|
|
||||||
});
|
|
||||||
|
|
||||||
function InstanceListenerAddressList({ setBreadcrumb }) {
|
|
||||||
const { id } = useParams();
|
|
||||||
const { Toast, toastProps } = useToast();
|
|
||||||
const {
|
|
||||||
isLoading,
|
|
||||||
error: contentError,
|
|
||||||
request: fetchListenerAddresses,
|
|
||||||
result: {
|
|
||||||
instance,
|
|
||||||
listenerAddresses,
|
|
||||||
count,
|
|
||||||
relatedSearchableKeys,
|
|
||||||
searchableKeys,
|
|
||||||
},
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(async () => {
|
|
||||||
const [
|
|
||||||
{ data: detail },
|
|
||||||
{
|
|
||||||
data: { results },
|
|
||||||
},
|
|
||||||
actions,
|
|
||||||
] = await Promise.all([
|
|
||||||
InstancesAPI.readDetail(id),
|
|
||||||
ReceptorAPI.read(),
|
|
||||||
InstancesAPI.readOptions(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const listenerAddress_list = [];
|
|
||||||
|
|
||||||
for (let q = 0; q < results.length; q++) {
|
|
||||||
const receptor = results[q];
|
|
||||||
if (receptor.managed === true) continue;
|
|
||||||
if (id.toString() === receptor.instance.toString()) {
|
|
||||||
receptor.name = detail.hostname;
|
|
||||||
listenerAddress_list.push(receptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
instance: detail,
|
|
||||||
listenerAddresses: listenerAddress_list,
|
|
||||||
count: listenerAddress_list.length,
|
|
||||||
relatedSearchableKeys: (actions?.data?.related_search_fields || []).map(
|
|
||||||
(val) => val.slice(0, -8)
|
|
||||||
),
|
|
||||||
searchableKeys: getSearchableKeys(actions.data.actions?.GET),
|
|
||||||
};
|
|
||||||
}, [id]),
|
|
||||||
{
|
|
||||||
instance: {},
|
|
||||||
listenerAddresses: [],
|
|
||||||
count: 0,
|
|
||||||
relatedSearchableKeys: [],
|
|
||||||
searchableKeys: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchListenerAddresses();
|
|
||||||
}, [fetchListenerAddresses]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (instance) {
|
|
||||||
setBreadcrumb(instance);
|
|
||||||
}
|
|
||||||
}, [instance, setBreadcrumb]);
|
|
||||||
|
|
||||||
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
|
||||||
useSelected(listenerAddresses);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardBody>
|
|
||||||
<PaginatedTable
|
|
||||||
contentError={contentError}
|
|
||||||
hasContentLoading={isLoading}
|
|
||||||
items={listenerAddresses}
|
|
||||||
itemCount={count}
|
|
||||||
pluralizedItemName={t`Listener Addresses`}
|
|
||||||
qsConfig={QS_CONFIG}
|
|
||||||
onRowClick={handleSelect}
|
|
||||||
clearSelected={clearSelected}
|
|
||||||
toolbarSearchableKeys={searchableKeys}
|
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
|
||||||
toolbarSearchColumns={[
|
|
||||||
{
|
|
||||||
name: t`Name`,
|
|
||||||
key: 'hostname__icontains',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSortColumns={[
|
|
||||||
{
|
|
||||||
name: t`Name`,
|
|
||||||
key: 'hostname',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
headerRow={
|
|
||||||
<HeaderRow qsConfig={QS_CONFIG}>
|
|
||||||
<HeaderCell sortKey="address">{t`Address`}</HeaderCell>
|
|
||||||
<HeaderCell sortKey="port">{t`Port`}</HeaderCell>
|
|
||||||
<HeaderCell sortKey="protocol">{t`Protocol`}</HeaderCell>
|
|
||||||
<HeaderCell sortKey="canonical">{t`Canonical`}</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
}
|
|
||||||
renderToolbar={(props) => (
|
|
||||||
<DataListToolbar
|
|
||||||
{...props}
|
|
||||||
isAllSelected={isAllSelected}
|
|
||||||
onSelectAll={selectAll}
|
|
||||||
qsConfig={QS_CONFIG}
|
|
||||||
additionalControls={[]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderRow={(listenerAddress, index) => (
|
|
||||||
<InstanceListenerAddressListItem
|
|
||||||
isSelected={selected.some((row) => row.id === listenerAddress.id)}
|
|
||||||
onSelect={() => handleSelect(listenerAddress)}
|
|
||||||
key={listenerAddress.id}
|
|
||||||
peerListenerAddress={listenerAddress}
|
|
||||||
rowIndex={index}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Toast {...toastProps} />
|
|
||||||
</CardBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InstanceListenerAddressList;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import 'styled-components/macro';
|
|
||||||
import { Tr, Td } from '@patternfly/react-table';
|
|
||||||
|
|
||||||
function InstanceListenerAddressListItem({
|
|
||||||
peerListenerAddress,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
rowIndex,
|
|
||||||
}) {
|
|
||||||
const labelId = `check-action-${peerListenerAddress.id}`;
|
|
||||||
return (
|
|
||||||
<Tr
|
|
||||||
id={`peerListenerAddress-row-${peerListenerAddress.id}`}
|
|
||||||
ouiaId={`peerListenerAddress-row-${peerListenerAddress.id}`}
|
|
||||||
>
|
|
||||||
<Td
|
|
||||||
select={{
|
|
||||||
rowIndex,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}}
|
|
||||||
dataLabel={t`Selected`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Td id={labelId} dataLabel={t`Address`}>
|
|
||||||
{peerListenerAddress.address}
|
|
||||||
</Td>
|
|
||||||
|
|
||||||
<Td id={labelId} dataLabel={t`Port`}>
|
|
||||||
{peerListenerAddress.port}
|
|
||||||
</Td>
|
|
||||||
|
|
||||||
<Td id={labelId} dataLabel={t`Protocol`}>
|
|
||||||
{peerListenerAddress.protocol}
|
|
||||||
</Td>
|
|
||||||
|
|
||||||
<Td id={labelId} dataLabel={t`Canonical`}>
|
|
||||||
{peerListenerAddress.canonical.toString()}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InstanceListenerAddressListItem;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from './InstanceListenerAddressList';
|
|
||||||
@@ -16,7 +16,7 @@ import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
|
|||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
import DataListToolbar from 'components/DataListToolbar';
|
import DataListToolbar from 'components/DataListToolbar';
|
||||||
import { InstancesAPI, ReceptorAPI } from 'api';
|
import { InstancesAPI } from 'api';
|
||||||
import useExpanded from 'hooks/useExpanded';
|
import useExpanded from 'hooks/useExpanded';
|
||||||
import useSelected from 'hooks/useSelected';
|
import useSelected from 'hooks/useSelected';
|
||||||
import InstancePeerListItem from './InstancePeerListItem';
|
import InstancePeerListItem from './InstancePeerListItem';
|
||||||
@@ -24,7 +24,7 @@ import InstancePeerListItem from './InstancePeerListItem';
|
|||||||
const QS_CONFIG = getQSConfig('peer', {
|
const QS_CONFIG = getQSConfig('peer', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
order_by: 'pk',
|
order_by: 'hostname',
|
||||||
});
|
});
|
||||||
|
|
||||||
function InstancePeerList({ setBreadcrumb }) {
|
function InstancePeerList({ setBreadcrumb }) {
|
||||||
@@ -47,35 +47,18 @@ function InstancePeerList({ setBreadcrumb }) {
|
|||||||
const [
|
const [
|
||||||
{ data: detail },
|
{ data: detail },
|
||||||
{
|
{
|
||||||
data: { results },
|
data: { results, count: itemNumber },
|
||||||
},
|
},
|
||||||
actions,
|
actions,
|
||||||
instances,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
InstancesAPI.readDetail(id),
|
InstancesAPI.readDetail(id),
|
||||||
InstancesAPI.readPeers(id, params),
|
InstancesAPI.readPeers(id, params),
|
||||||
InstancesAPI.readOptions(),
|
InstancesAPI.readOptions(),
|
||||||
InstancesAPI.read(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const address_list = [];
|
|
||||||
|
|
||||||
for (let q = 0; q < results.length; q++) {
|
|
||||||
const receptor = results[q];
|
|
||||||
if (receptor.managed === true) continue;
|
|
||||||
const host = instances.data.results.filter(
|
|
||||||
(obj) => obj.id === receptor.instance
|
|
||||||
)[0];
|
|
||||||
const copy = receptor;
|
|
||||||
copy.hostname = host.hostname;
|
|
||||||
copy.node_type = host.node_type;
|
|
||||||
address_list.push(copy);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instance: detail,
|
instance: detail,
|
||||||
peers: address_list,
|
peers: results,
|
||||||
count: address_list.length,
|
count: itemNumber,
|
||||||
relatedSearchableKeys: (actions?.data?.related_search_fields || []).map(
|
relatedSearchableKeys: (actions?.data?.related_search_fields || []).map(
|
||||||
(val) => val.slice(0, -8)
|
(val) => val.slice(0, -8)
|
||||||
),
|
),
|
||||||
@@ -107,73 +90,15 @@ function InstancePeerList({ setBreadcrumb }) {
|
|||||||
useSelected(peers);
|
useSelected(peers);
|
||||||
|
|
||||||
const fetchInstancesToAssociate = useCallback(
|
const fetchInstancesToAssociate = useCallback(
|
||||||
async (params) => {
|
(params) =>
|
||||||
const address_list = [];
|
InstancesAPI.read(
|
||||||
|
|
||||||
const instances = await InstancesAPI.read(
|
|
||||||
mergeParams(params, {
|
mergeParams(params, {
|
||||||
|
...{ not__id: id },
|
||||||
...{ not__node_type: ['control', 'hybrid'] },
|
...{ not__node_type: ['control', 'hybrid'] },
|
||||||
|
...{ not__hostname: instance.peers },
|
||||||
})
|
})
|
||||||
);
|
),
|
||||||
const receptors = (await ReceptorAPI.read()).data.results;
|
[id, instance]
|
||||||
|
|
||||||
// get instance ids of the current peered receptor ids
|
|
||||||
const already_peered_instance_ids = [];
|
|
||||||
for (let h = 0; h < instance.peers.length; h++) {
|
|
||||||
const matched = receptors.filter((obj) => obj.id === instance.peers[h]);
|
|
||||||
matched.forEach((element) => {
|
|
||||||
already_peered_instance_ids.push(element.instance);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let q = 0; q < receptors.length; q++) {
|
|
||||||
const receptor = receptors[q];
|
|
||||||
|
|
||||||
if (already_peered_instance_ids.includes(receptor.instance)) {
|
|
||||||
// ignore reverse peers
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance.peers.includes(receptor.id)) {
|
|
||||||
// no links to existing links
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance.id === receptor.instance) {
|
|
||||||
// no links to thy self
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance.managed) {
|
|
||||||
// no managed nodes
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = instances.data.results.filter(
|
|
||||||
(obj) => obj.id === receptor.instance
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
if (host === undefined) {
|
|
||||||
// no hosts
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receptor.is_internal) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const copy = receptor;
|
|
||||||
copy.hostname = host.hostname;
|
|
||||||
copy.node_type = host.node_type;
|
|
||||||
copy.canonical = copy.canonical.toString();
|
|
||||||
address_list.push(copy);
|
|
||||||
}
|
|
||||||
|
|
||||||
instances.data.results = address_list;
|
|
||||||
|
|
||||||
return instances;
|
|
||||||
},
|
|
||||||
[instance]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -183,15 +108,17 @@ function InstancePeerList({ setBreadcrumb }) {
|
|||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (instancesPeerToAssociate) => {
|
async (instancesPeerToAssociate) => {
|
||||||
const selected_peers = instancesPeerToAssociate.map((obj) => obj.id);
|
const selected_hostname = instancesPeerToAssociate.map(
|
||||||
|
(obj) => obj.hostname
|
||||||
const new_peers = [...new Set([...instance.peers, ...selected_peers])];
|
);
|
||||||
|
const new_peers = [
|
||||||
|
...new Set([...instance.peers, ...selected_hostname]),
|
||||||
|
];
|
||||||
await InstancesAPI.update(instance.id, { peers: new_peers });
|
await InstancesAPI.update(instance.id, { peers: new_peers });
|
||||||
|
|
||||||
fetchPeers();
|
fetchPeers();
|
||||||
addToast({
|
addToast({
|
||||||
id: instancesPeerToAssociate,
|
id: instancesPeerToAssociate,
|
||||||
title: t`Peers update on ${instance.hostname}. Please be sure to run the install bundle for ${instance.hostname} again in order to see changes take effect.`,
|
title: t`${selected_hostname} added as a peer. Please be sure to run the install bundle for ${instance.hostname} again in order to see changes take effect.`,
|
||||||
variant: AlertVariant.success,
|
variant: AlertVariant.success,
|
||||||
hasTimeout: true,
|
hasTimeout: true,
|
||||||
});
|
});
|
||||||
@@ -206,18 +133,17 @@ function InstancePeerList({ setBreadcrumb }) {
|
|||||||
error: disassociateError,
|
error: disassociateError,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
let new_peers = instance.peers;
|
const new_peers = [];
|
||||||
|
const selected_hostname = selected.map((obj) => obj.hostname);
|
||||||
const selected_ids = selected.map((obj) => obj.id);
|
for (let i = 0; i < instance.peers.length; i++) {
|
||||||
|
if (!selected_hostname.includes(instance.peers[i])) {
|
||||||
for (let i = 0; i < selected_ids.length; i++) {
|
new_peers.push(instance.peers[i]);
|
||||||
new_peers = new_peers.filter((s_id) => s_id !== selected_ids[i]);
|
}
|
||||||
}
|
}
|
||||||
await InstancesAPI.update(instance.id, { peers: new_peers });
|
await InstancesAPI.update(instance.id, { peers: new_peers });
|
||||||
|
|
||||||
fetchPeers();
|
fetchPeers();
|
||||||
addToast({
|
addToast({
|
||||||
title: t`Peer removed. Please be sure to run the install bundle for ${instance.hostname} again in order to see changes take effect.`,
|
title: t`${selected_hostname} removed. Please be sure to run the install bundle for ${instance.hostname} again in order to see changes take effect.`,
|
||||||
variant: AlertVariant.success,
|
variant: AlertVariant.success,
|
||||||
hasTimeout: true,
|
hasTimeout: true,
|
||||||
});
|
});
|
||||||
@@ -264,11 +190,9 @@ function InstancePeerList({ setBreadcrumb }) {
|
|||||||
<HeaderCell
|
<HeaderCell
|
||||||
tooltip={t`Cannot run health check on hop nodes.`}
|
tooltip={t`Cannot run health check on hop nodes.`}
|
||||||
sortKey="hostname"
|
sortKey="hostname"
|
||||||
>{t`Instance Name`}</HeaderCell>
|
>{t`Name`}</HeaderCell>
|
||||||
<HeaderCell sortKey="address">{t`Address`}</HeaderCell>
|
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
|
||||||
<HeaderCell sortKey="port">{t`Port`}</HeaderCell>
|
|
||||||
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
|
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
|
||||||
<HeaderCell sortKey="canonical">{t`Canonical`}</HeaderCell>
|
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
}
|
}
|
||||||
renderToolbar={(props) => (
|
renderToolbar={(props) => (
|
||||||
@@ -294,7 +218,7 @@ function InstancePeerList({ setBreadcrumb }) {
|
|||||||
key="disassociate"
|
key="disassociate"
|
||||||
onDisassociate={handlePeersDiassociate}
|
onDisassociate={handlePeersDiassociate}
|
||||||
itemsToDisassociate={selected}
|
itemsToDisassociate={selected}
|
||||||
modalTitle={t`Remove peers?`}
|
modalTitle={t`Remove instance from peers?`}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
]}
|
]}
|
||||||
@@ -319,15 +243,12 @@ function InstancePeerList({ setBreadcrumb }) {
|
|||||||
isModalOpen={isModalOpen}
|
isModalOpen={isModalOpen}
|
||||||
onAssociate={handlePeerAssociate}
|
onAssociate={handlePeerAssociate}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
title={t`Select Peer Addresses`}
|
title={t`Select Instances`}
|
||||||
optionsRequest={readInstancesOptions}
|
optionsRequest={readInstancesOptions}
|
||||||
displayKey="hostname"
|
displayKey="hostname"
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'hostname', name: t`Name` },
|
{ key: 'hostname', name: t`Name` },
|
||||||
{ key: 'address', name: t`Address` },
|
|
||||||
{ key: 'port', name: t`Port` },
|
|
||||||
{ key: 'node_type', name: t`Node Type` },
|
{ key: 'node_type', name: t`Node Type` },
|
||||||
{ key: 'protocol', name: t`Protocol` },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import 'styled-components/macro';
|
import 'styled-components/macro';
|
||||||
|
import { Tooltip } from '@patternfly/react-core';
|
||||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||||
import { formatDateString } from 'util/dates';
|
import { formatDateString } from 'util/dates';
|
||||||
|
import StatusLabel from 'components/StatusLabel';
|
||||||
import { Detail, DetailList } from 'components/DetailList';
|
import { Detail, DetailList } from 'components/DetailList';
|
||||||
|
|
||||||
function InstancePeerListItem({
|
function InstancePeerListItem({
|
||||||
@@ -41,26 +43,29 @@ function InstancePeerListItem({
|
|||||||
}}
|
}}
|
||||||
dataLabel={t`Selected`}
|
dataLabel={t`Selected`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Td id={labelId} dataLabel={t`Name`}>
|
<Td id={labelId} dataLabel={t`Name`}>
|
||||||
<Link to={`/instances/${peerInstance.instance}/details`}>
|
<Link to={`/instances/${peerInstance.id}/details`}>
|
||||||
<b>{peerInstance.hostname}</b>
|
<b>{peerInstance.hostname}</b>
|
||||||
</Link>
|
</Link>
|
||||||
</Td>
|
</Td>
|
||||||
|
|
||||||
<Td id={labelId} dataLabel={t`Address`}>
|
<Td dataLabel={t`Status`}>
|
||||||
{peerInstance.address}
|
<Tooltip
|
||||||
</Td>
|
content={
|
||||||
|
<div>
|
||||||
<Td id={labelId} dataLabel={t`Port`}>
|
{t`Last Health Check`}
|
||||||
{peerInstance.port}
|
|
||||||
|
{formatDateString(
|
||||||
|
peerInstance.last_health_check ?? peerInstance.last_seen
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StatusLabel status={peerInstance.node_state} />
|
||||||
|
</Tooltip>
|
||||||
</Td>
|
</Td>
|
||||||
|
|
||||||
<Td dataLabel={t`Node Type`}>{peerInstance.node_type}</Td>
|
<Td dataLabel={t`Node Type`}>{peerInstance.node_type}</Td>
|
||||||
|
|
||||||
<Td id={labelId} dataLabel={t`Canonical`}>
|
|
||||||
{peerInstance.canonical.toString()}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
</Tr>
|
||||||
{!isHopNode && (
|
{!isHopNode && (
|
||||||
<Tr
|
<Tr
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ function Instances() {
|
|||||||
[`/instances/${instance.id}`]: `${instance.hostname}`,
|
[`/instances/${instance.id}`]: `${instance.hostname}`,
|
||||||
[`/instances/${instance.id}/details`]: t`Details`,
|
[`/instances/${instance.id}/details`]: t`Details`,
|
||||||
[`/instances/${instance.id}/peers`]: t`Peers`,
|
[`/instances/${instance.id}/peers`]: t`Peers`,
|
||||||
[`/instances/${instance.id}/listener_addresses`]: t`Listener Addresses`,
|
|
||||||
[`/instances/${instance.id}/edit`]: t`Edit Instance`,
|
[`/instances/${instance.id}/edit`]: t`Edit Instance`,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Formik, useField } from 'formik';
|
import { Formik, useField, useFormikContext } from 'formik';
|
||||||
import { Form, FormGroup, CardBody } from '@patternfly/react-core';
|
import { Form, FormGroup, CardBody } from '@patternfly/react-core';
|
||||||
import { FormColumnLayout } from 'components/FormLayout';
|
import { FormColumnLayout } from 'components/FormLayout';
|
||||||
import FormField, {
|
import FormField, {
|
||||||
@@ -9,6 +9,7 @@ import FormField, {
|
|||||||
} from 'components/FormField';
|
} from 'components/FormField';
|
||||||
import FormActionGroup from 'components/FormActionGroup';
|
import FormActionGroup from 'components/FormActionGroup';
|
||||||
import AnsibleSelect from 'components/AnsibleSelect';
|
import AnsibleSelect from 'components/AnsibleSelect';
|
||||||
|
import { PeersLookup } from 'components/Lookup';
|
||||||
import { required } from 'util/validators';
|
import { required } from 'util/validators';
|
||||||
|
|
||||||
const INSTANCE_TYPES = [
|
const INSTANCE_TYPES = [
|
||||||
@@ -22,6 +23,16 @@ function InstanceFormFields({ isEdit }) {
|
|||||||
validate: required(t`Set a value for this field`),
|
validate: required(t`Set a value for this field`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { setFieldValue } = useFormikContext();
|
||||||
|
|
||||||
|
const [peersField, peersMeta, peersHelpers] = useField('peers');
|
||||||
|
|
||||||
|
const handlePeersUpdate = useCallback(
|
||||||
|
(value) => {
|
||||||
|
setFieldValue('peers', value);
|
||||||
|
},
|
||||||
|
[setFieldValue]
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -80,6 +91,20 @@ function InstanceFormFields({ isEdit }) {
|
|||||||
isDisabled={isEdit}
|
isDisabled={isEdit}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
<PeersLookup
|
||||||
|
helperTextInvalid={peersMeta.error}
|
||||||
|
isValid={!peersMeta.touched || !peersMeta.error}
|
||||||
|
onBlur={() => peersHelpers.setTouched()}
|
||||||
|
onChange={handlePeersUpdate}
|
||||||
|
value={peersField.value}
|
||||||
|
tooltip={t`Select the Peers Instances.`}
|
||||||
|
fieldName="peers"
|
||||||
|
formLabel={t`Peers`}
|
||||||
|
multiple
|
||||||
|
typePeers
|
||||||
|
id="peers"
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
<FormGroup fieldId="instance-option-checkboxes" label={t`Options`}>
|
<FormGroup fieldId="instance-option-checkboxes" label={t`Options`}>
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
id="enabled"
|
id="enabled"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Modal, Tab, Tabs, TabTitleText } from '@patternfly/react-core';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { encode } from 'html-entities';
|
import { encode } from 'html-entities';
|
||||||
import { jsonToYaml } from 'util/yaml';
|
|
||||||
import StatusLabel from '../../../components/StatusLabel';
|
import StatusLabel from '../../../components/StatusLabel';
|
||||||
import { DetailList, Detail } from '../../../components/DetailList';
|
import { DetailList, Detail } from '../../../components/DetailList';
|
||||||
import ContentEmpty from '../../../components/ContentEmpty';
|
import ContentEmpty from '../../../components/ContentEmpty';
|
||||||
@@ -145,28 +144,9 @@ function HostEventModal({ onClose, hostEvent = {}, isOpen = false }) {
|
|||||||
<ContentEmpty title={t`No JSON Available`} />
|
<ContentEmpty title={t`No JSON Available`} />
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
|
||||||
eventKey={2}
|
|
||||||
title={<TabTitleText>{t`YAML`}</TabTitleText>}
|
|
||||||
aria-label={t`YAML tab`}
|
|
||||||
ouiaId="yaml-tab"
|
|
||||||
>
|
|
||||||
{activeTabKey === 2 && jsonObj ? (
|
|
||||||
<CodeEditor
|
|
||||||
mode="javascript"
|
|
||||||
readOnly
|
|
||||||
value={jsonToYaml(JSON.stringify(jsonObj))}
|
|
||||||
onChange={() => {}}
|
|
||||||
rows={20}
|
|
||||||
hasErrors={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ContentEmpty title={t`No YAML Available`} />
|
|
||||||
)}
|
|
||||||
</Tab>
|
|
||||||
{stdOut?.length ? (
|
{stdOut?.length ? (
|
||||||
<Tab
|
<Tab
|
||||||
eventKey={3}
|
eventKey={2}
|
||||||
title={<TabTitleText>{t`Output`}</TabTitleText>}
|
title={<TabTitleText>{t`Output`}</TabTitleText>}
|
||||||
aria-label={t`Output tab`}
|
aria-label={t`Output tab`}
|
||||||
ouiaId="standard-out-tab"
|
ouiaId="standard-out-tab"
|
||||||
@@ -183,7 +163,7 @@ function HostEventModal({ onClose, hostEvent = {}, isOpen = false }) {
|
|||||||
) : null}
|
) : null}
|
||||||
{stdErr?.length ? (
|
{stdErr?.length ? (
|
||||||
<Tab
|
<Tab
|
||||||
eventKey={4}
|
eventKey={3}
|
||||||
title={<TabTitleText>{t`Standard Error`}</TabTitleText>}
|
title={<TabTitleText>{t`Standard Error`}</TabTitleText>}
|
||||||
aria-label={t`Standard error tab`}
|
aria-label={t`Standard error tab`}
|
||||||
ouiaId="standard-error-tab"
|
ouiaId="standard-error-tab"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import HostEventModal from './HostEventModal';
|
import HostEventModal from './HostEventModal';
|
||||||
import { jsonToYaml } from 'util/yaml';
|
|
||||||
|
|
||||||
const hostEvent = {
|
const hostEvent = {
|
||||||
changed: true,
|
changed: true,
|
||||||
@@ -168,8 +167,6 @@ const jsonValue = `{
|
|||||||
]
|
]
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const yamlValue = jsonToYaml(jsonValue);
|
|
||||||
|
|
||||||
describe('HostEventModal', () => {
|
describe('HostEventModal', () => {
|
||||||
test('initially renders successfully', () => {
|
test('initially renders successfully', () => {
|
||||||
const wrapper = shallow(
|
const wrapper = shallow(
|
||||||
@@ -190,7 +187,7 @@ describe('HostEventModal', () => {
|
|||||||
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(wrapper.find('Tabs Tab').length).toEqual(5);
|
expect(wrapper.find('Tabs Tab').length).toEqual(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should initially show details tab', () => {
|
test('should initially show details tab', () => {
|
||||||
@@ -290,7 +287,7 @@ describe('HostEventModal', () => {
|
|||||||
expect(codeEditor.prop('value')).toEqual(jsonValue);
|
expect(codeEditor.prop('value')).toEqual(jsonValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display YAML tab content on tab click', () => {
|
test('should display Standard Out tab content on tab click', () => {
|
||||||
const wrapper = shallow(
|
const wrapper = shallow(
|
||||||
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
||||||
);
|
);
|
||||||
@@ -302,21 +299,6 @@ describe('HostEventModal', () => {
|
|||||||
const codeEditor = wrapper.find('Tab[eventKey=2] CodeEditor');
|
const codeEditor = wrapper.find('Tab[eventKey=2] CodeEditor');
|
||||||
expect(codeEditor.prop('mode')).toBe('javascript');
|
expect(codeEditor.prop('mode')).toBe('javascript');
|
||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
expect(codeEditor.prop('readOnly')).toBe(true);
|
||||||
expect(codeEditor.prop('value')).toEqual(yamlValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display Standard Out tab content on tab click', () => {
|
|
||||||
const wrapper = shallow(
|
|
||||||
<HostEventModal hostEvent={hostEvent} onClose={() => {}} isOpen />
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
|
||||||
handleTabClick(null, 3);
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const codeEditor = wrapper.find('Tab[eventKey=3] CodeEditor');
|
|
||||||
expect(codeEditor.prop('mode')).toBe('javascript');
|
|
||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
|
||||||
expect(codeEditor.prop('value')).toEqual(hostEvent.event_data.res.stdout);
|
expect(codeEditor.prop('value')).toEqual(hostEvent.event_data.res.stdout);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,10 +316,10 @@ describe('HostEventModal', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
||||||
handleTabClick(null, 4);
|
handleTabClick(null, 3);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
const codeEditor = wrapper.find('Tab[eventKey=4] CodeEditor');
|
const codeEditor = wrapper.find('Tab[eventKey=3] CodeEditor');
|
||||||
expect(codeEditor.prop('mode')).toBe('javascript');
|
expect(codeEditor.prop('mode')).toBe('javascript');
|
||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
expect(codeEditor.prop('readOnly')).toBe(true);
|
||||||
expect(codeEditor.prop('value')).toEqual('error content');
|
expect(codeEditor.prop('value')).toEqual('error content');
|
||||||
@@ -369,10 +351,10 @@ describe('HostEventModal', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
||||||
handleTabClick(null, 3);
|
handleTabClick(null, 2);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
const codeEditor = wrapper.find('Tab[eventKey=3] CodeEditor');
|
const codeEditor = wrapper.find('Tab[eventKey=2] CodeEditor');
|
||||||
expect(codeEditor.prop('mode')).toBe('javascript');
|
expect(codeEditor.prop('mode')).toBe('javascript');
|
||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
expect(codeEditor.prop('readOnly')).toBe(true);
|
||||||
expect(codeEditor.prop('value')).toEqual('foo bar');
|
expect(codeEditor.prop('value')).toEqual('foo bar');
|
||||||
@@ -393,10 +375,10 @@ describe('HostEventModal', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
||||||
handleTabClick(null, 3);
|
handleTabClick(null, 2);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
const codeEditor = wrapper.find('Tab[eventKey=3] CodeEditor');
|
const codeEditor = wrapper.find('Tab[eventKey=2] CodeEditor');
|
||||||
expect(codeEditor.prop('mode')).toBe('javascript');
|
expect(codeEditor.prop('mode')).toBe('javascript');
|
||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
expect(codeEditor.prop('readOnly')).toBe(true);
|
||||||
expect(codeEditor.prop('value')).toEqual('baz\nbar');
|
expect(codeEditor.prop('value')).toEqual('baz\nbar');
|
||||||
@@ -412,10 +394,10 @@ describe('HostEventModal', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
||||||
handleTabClick(null, 3);
|
handleTabClick(null, 2);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
const codeEditor = wrapper.find('Tab[eventKey=3] CodeEditor');
|
const codeEditor = wrapper.find('Tab[eventKey=2] CodeEditor');
|
||||||
expect(codeEditor.prop('mode')).toBe('javascript');
|
expect(codeEditor.prop('mode')).toBe('javascript');
|
||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
expect(codeEditor.prop('readOnly')).toBe(true);
|
||||||
expect(codeEditor.prop('value')).toEqual(
|
expect(codeEditor.prop('value')).toEqual(
|
||||||
|
|||||||
@@ -201,11 +201,7 @@ function NodeViewModal({ readOnly }) {
|
|||||||
overrides.limit = originalNodeObject.limit;
|
overrides.limit = originalNodeObject.limit;
|
||||||
}
|
}
|
||||||
if (launchConfig.ask_verbosity_on_launch) {
|
if (launchConfig.ask_verbosity_on_launch) {
|
||||||
overrides.verbosity =
|
overrides.verbosity = originalNodeObject.verbosity.toString();
|
||||||
originalNodeObject.verbosity !== undefined &&
|
|
||||||
originalNodeObject.verbosity !== null
|
|
||||||
? originalNodeObject.verbosity.toString()
|
|
||||||
: '0';
|
|
||||||
}
|
}
|
||||||
if (launchConfig.ask_credential_on_launch) {
|
if (launchConfig.ask_credential_on_launch) {
|
||||||
overrides.credentials = originalNodeCredentials || [];
|
overrides.credentials = originalNodeCredentials || [];
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ ui-next/src/build: $(UI_NEXT_DIR)/src/build/awx
|
|||||||
## True target for ui-next/src/build. Build ui_next from source.
|
## True target for ui-next/src/build. Build ui_next from source.
|
||||||
$(UI_NEXT_DIR)/src/build/awx: $(UI_NEXT_DIR)/src $(UI_NEXT_DIR)/src/node_modules/webpack
|
$(UI_NEXT_DIR)/src/build/awx: $(UI_NEXT_DIR)/src $(UI_NEXT_DIR)/src/node_modules/webpack
|
||||||
@echo "=== Building ui_next ==="
|
@echo "=== Building ui_next ==="
|
||||||
@cd $(UI_NEXT_DIR)/src && PRODUCT="$(PRODUCT)" PUBLIC_PATH=/static/awx/ ROUTE_PREFIX=/ui_next npm run build:awx
|
@cd $(UI_NEXT_DIR)/src && PRODUCT="$(PRODUCT)" PUBLIC_PATH=/static/awx/ npm run build:awx
|
||||||
@mv $(UI_NEXT_DIR)/src/build/awx/index.html $(UI_NEXT_DIR)/src/build/awx/index_awx.html
|
@mv $(UI_NEXT_DIR)/src/build/awx/index.html $(UI_NEXT_DIR)/src/build/awx/index_awx.html
|
||||||
|
|
||||||
.PHONY: ui-next/src
|
.PHONY: ui-next/src
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ documentation: https://github.com/ansible/awx/blob/devel/awx_collection/README.m
|
|||||||
homepage: https://www.ansible.com/
|
homepage: https://www.ansible.com/
|
||||||
issues: https://github.com/ansible/awx/issues?q=is%3Aissue+label%3Acomponent%3Aawx_collection
|
issues: https://github.com/ansible/awx/issues?q=is%3Aissue+label%3Acomponent%3Aawx_collection
|
||||||
license:
|
license:
|
||||||
- GPL-3.0-or-later
|
- GPL-3.0-only
|
||||||
name: awx
|
name: awx
|
||||||
namespace: awx
|
namespace: awx
|
||||||
readme: README.md
|
readme: README.md
|
||||||
|
|||||||
119
awx_collection/plugins/module_utils/tower_legacy.py
Normal file
119
awx_collection/plugins/module_utils/tower_legacy.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# This code is part of Ansible, but is an independent component.
|
||||||
|
# This particular file snippet, and this file snippet only, is BSD licensed.
|
||||||
|
# Modules you write using this snippet, which is embedded dynamically by Ansible
|
||||||
|
# still belong to the author of the module, and may assign their own license
|
||||||
|
# to the complete work.
|
||||||
|
#
|
||||||
|
# Copyright (c), Wayne Witzel III <wayne@riotousliving.com>
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
# are permitted provided that the following conditions are met:
|
||||||
|
#
|
||||||
|
# * Redistributions of source code must retain the above copyright
|
||||||
|
# notice, this list of conditions and the following disclaimer.
|
||||||
|
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
# this list of conditions and the following disclaimer in the documentation
|
||||||
|
# and/or other materials provided with the distribution.
|
||||||
|
#
|
||||||
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||||
|
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function
|
||||||
|
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
TOWER_CLI_IMP_ERR = None
|
||||||
|
try:
|
||||||
|
import tower_cli.utils.exceptions as exc
|
||||||
|
from tower_cli.utils import parser
|
||||||
|
from tower_cli.api import client
|
||||||
|
|
||||||
|
HAS_TOWER_CLI = True
|
||||||
|
except ImportError:
|
||||||
|
TOWER_CLI_IMP_ERR = traceback.format_exc()
|
||||||
|
HAS_TOWER_CLI = False
|
||||||
|
|
||||||
|
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||||
|
|
||||||
|
|
||||||
|
def tower_auth_config(module):
|
||||||
|
"""
|
||||||
|
`tower_auth_config` attempts to load the tower-cli.cfg file
|
||||||
|
specified from the `tower_config_file` parameter. If found,
|
||||||
|
if returns the contents of the file as a dictionary, else
|
||||||
|
it will attempt to fetch values from the module params and
|
||||||
|
only pass those values that have been set.
|
||||||
|
"""
|
||||||
|
config_file = module.params.pop('tower_config_file', None)
|
||||||
|
if config_file:
|
||||||
|
if not os.path.exists(config_file):
|
||||||
|
module.fail_json(msg='file not found: %s' % config_file)
|
||||||
|
if os.path.isdir(config_file):
|
||||||
|
module.fail_json(msg='directory can not be used as config file: %s' % config_file)
|
||||||
|
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
return parser.string_to_dict(f.read())
|
||||||
|
else:
|
||||||
|
auth_config = {}
|
||||||
|
host = module.params.pop('tower_host', None)
|
||||||
|
if host:
|
||||||
|
auth_config['host'] = host
|
||||||
|
username = module.params.pop('tower_username', None)
|
||||||
|
if username:
|
||||||
|
auth_config['username'] = username
|
||||||
|
password = module.params.pop('tower_password', None)
|
||||||
|
if password:
|
||||||
|
auth_config['password'] = password
|
||||||
|
module.params.pop('tower_verify_ssl', None) # pop alias if used
|
||||||
|
verify_ssl = module.params.pop('validate_certs', None)
|
||||||
|
if verify_ssl is not None:
|
||||||
|
auth_config['verify_ssl'] = verify_ssl
|
||||||
|
return auth_config
|
||||||
|
|
||||||
|
|
||||||
|
def tower_check_mode(module):
|
||||||
|
'''Execute check mode logic for Ansible Tower modules'''
|
||||||
|
if module.check_mode:
|
||||||
|
try:
|
||||||
|
result = client.get('/ping').json()
|
||||||
|
module.exit_json(changed=True, tower_version='{0}'.format(result['version']))
|
||||||
|
except (exc.ServerError, exc.ConnectionError, exc.BadRequest) as excinfo:
|
||||||
|
module.fail_json(changed=False, msg='Failed check mode: {0}'.format(excinfo))
|
||||||
|
|
||||||
|
|
||||||
|
class TowerLegacyModule(AnsibleModule):
|
||||||
|
def __init__(self, argument_spec, **kwargs):
|
||||||
|
args = dict(
|
||||||
|
tower_host=dict(),
|
||||||
|
tower_username=dict(),
|
||||||
|
tower_password=dict(no_log=True),
|
||||||
|
validate_certs=dict(type='bool', aliases=['tower_verify_ssl']),
|
||||||
|
tower_config_file=dict(type='path'),
|
||||||
|
)
|
||||||
|
args.update(argument_spec)
|
||||||
|
|
||||||
|
kwargs.setdefault('mutually_exclusive', [])
|
||||||
|
kwargs['mutually_exclusive'].extend(
|
||||||
|
(
|
||||||
|
('tower_config_file', 'tower_host'),
|
||||||
|
('tower_config_file', 'tower_username'),
|
||||||
|
('tower_config_file', 'tower_password'),
|
||||||
|
('tower_config_file', 'validate_certs'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(argument_spec=args, **kwargs)
|
||||||
|
|
||||||
|
if not HAS_TOWER_CLI:
|
||||||
|
self.fail_json(msg=missing_required_lib('ansible-tower-cli'), exception=TOWER_CLI_IMP_ERR)
|
||||||
@@ -67,8 +67,6 @@ options:
|
|||||||
description:
|
description:
|
||||||
- List of peers to connect outbound to. Only configurable for hop and execution nodes.
|
- List of peers to connect outbound to. Only configurable for hop and execution nodes.
|
||||||
- To remove all current peers, set value to an empty list, [].
|
- To remove all current peers, set value to an empty list, [].
|
||||||
- Each item is an ID or address of a receptor address.
|
|
||||||
- If item is address, it must be unique across all receptor addresses.
|
|
||||||
required: False
|
required: False
|
||||||
type: list
|
type: list
|
||||||
elements: str
|
elements: str
|
||||||
@@ -85,24 +83,12 @@ EXAMPLES = '''
|
|||||||
awx.awx.instance:
|
awx.awx.instance:
|
||||||
hostname: my-instance.prod.example.com
|
hostname: my-instance.prod.example.com
|
||||||
capacity_adjustment: 0.4
|
capacity_adjustment: 0.4
|
||||||
|
listener_port: 31337
|
||||||
|
|
||||||
- name: Deprovision the instance
|
- name: Deprovision the instance
|
||||||
awx.awx.instance:
|
awx.awx.instance:
|
||||||
hostname: my-instance.prod.example.com
|
hostname: my-instance.prod.example.com
|
||||||
node_state: deprovisioning
|
node_state: deprovisioning
|
||||||
|
|
||||||
- name: Create execution node
|
|
||||||
awx.awx.instance:
|
|
||||||
hostname: execution.example.com
|
|
||||||
node_type: execution
|
|
||||||
peers:
|
|
||||||
- 12
|
|
||||||
- route.to.hop.example.com
|
|
||||||
|
|
||||||
- name: Remove peers
|
|
||||||
awx.awx.instance:
|
|
||||||
hostname: execution.example.com
|
|
||||||
peers:
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from ..module_utils.controller_api import ControllerAPIModule
|
from ..module_utils.controller_api import ControllerAPIModule
|
||||||
@@ -138,17 +124,6 @@ def main():
|
|||||||
# Attempt to look up an existing item based on the provided data
|
# Attempt to look up an existing item based on the provided data
|
||||||
existing_item = module.get_one('instances', name_or_id=hostname)
|
existing_item = module.get_one('instances', name_or_id=hostname)
|
||||||
|
|
||||||
# peer item can be an id or address
|
|
||||||
# if address, get the id
|
|
||||||
peers_ids = []
|
|
||||||
if peers:
|
|
||||||
for p in peers:
|
|
||||||
if not p.isdigit():
|
|
||||||
p_id = module.get_one('receptor_addresses', allow_none=False, data={'address': p})
|
|
||||||
peers_ids.append(p_id['id'])
|
|
||||||
else:
|
|
||||||
peers_ids.append(p)
|
|
||||||
|
|
||||||
# Create the data that gets sent for create and update
|
# Create the data that gets sent for create and update
|
||||||
new_fields = {'hostname': hostname}
|
new_fields = {'hostname': hostname}
|
||||||
if capacity_adjustment is not None:
|
if capacity_adjustment is not None:
|
||||||
@@ -164,7 +139,7 @@ def main():
|
|||||||
if listener_port is not None:
|
if listener_port is not None:
|
||||||
new_fields['listener_port'] = listener_port
|
new_fields['listener_port'] = listener_port
|
||||||
if peers is not None:
|
if peers is not None:
|
||||||
new_fields['peers'] = peers_ids
|
new_fields['peers'] = peers
|
||||||
if peers_from_control_nodes is not None:
|
if peers_from_control_nodes is not None:
|
||||||
new_fields['peers_from_control_nodes'] = peers_from_control_nodes
|
new_fields['peers_from_control_nodes'] = peers_from_control_nodes
|
||||||
|
|
||||||
|
|||||||
@@ -181,8 +181,10 @@ def run_module(request, collection_import):
|
|||||||
resource_class = resource_module.ControllerAWXKitModule
|
resource_class = resource_module.ControllerAWXKitModule
|
||||||
elif getattr(resource_module, 'ControllerAPIModule', None):
|
elif getattr(resource_module, 'ControllerAPIModule', None):
|
||||||
resource_class = resource_module.ControllerAPIModule
|
resource_class = resource_module.ControllerAPIModule
|
||||||
|
elif getattr(resource_module, 'TowerLegacyModule', None):
|
||||||
|
resource_class = resource_module.TowerLegacyModule
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("The module has neither a ControllerAWXKitModule or a ControllerAPIModule")
|
raise RuntimeError("The module has neither a TowerLegacyModule, ControllerAWXKitModule or a ControllerAPIModule")
|
||||||
|
|
||||||
with mock.patch.object(resource_class, '_load_params', new=mock_load_params):
|
with mock.patch.object(resource_class, '_load_params', new=mock_load_params):
|
||||||
# Call the test utility (like a mock server) instead of issuing HTTP requests
|
# Call the test utility (like a mock server) instead of issuing HTTP requests
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ extra_endpoints = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Global module parameters we can ignore
|
# Global module parameters we can ignore
|
||||||
ignore_parameters = ['state', 'new_name', 'update_secrets', 'copy_from', 'is_internal']
|
ignore_parameters = ['state', 'new_name', 'update_secrets', 'copy_from']
|
||||||
|
|
||||||
# Some modules take additional parameters that do not appear in the API
|
# Some modules take additional parameters that do not appear in the API
|
||||||
# Add the module name as the key with the value being the list of params to ignore
|
# Add the module name as the key with the value being the list of params to ignore
|
||||||
@@ -248,7 +248,7 @@ def test_completeness(collection_import, request, admin_user, job_template, exec
|
|||||||
singular_endpoint = '{0}'.format(endpoint)
|
singular_endpoint = '{0}'.format(endpoint)
|
||||||
if singular_endpoint.endswith('ies'):
|
if singular_endpoint.endswith('ies'):
|
||||||
singular_endpoint = singular_endpoint[:-3]
|
singular_endpoint = singular_endpoint[:-3]
|
||||||
elif singular_endpoint != 'settings' and singular_endpoint.endswith('s'):
|
if singular_endpoint != 'settings' and singular_endpoint.endswith('s'):
|
||||||
singular_endpoint = singular_endpoint[:-1]
|
singular_endpoint = singular_endpoint[:-1]
|
||||||
module_name = '{0}'.format(singular_endpoint)
|
module_name = '{0}'.format(singular_endpoint)
|
||||||
|
|
||||||
|
|||||||
@@ -134,17 +134,21 @@ def test_export_simple(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_export_system_auditor(run_module, organization, system_auditor): # noqa: F811
|
def test_export_system_auditor(run_module, schedule, system_auditor): # noqa: F811
|
||||||
"""
|
"""
|
||||||
This test illustrates that export of resources can now happen
|
This test illustrates that deficiency of export when ran as non-root user (i.e. system auditor).
|
||||||
when ran as non-root user (i.e. system auditor). The OPTIONS
|
The OPTIONS endpoint does NOT return POST for a system auditor. This is bad for the export code
|
||||||
endpoint does NOT return POST for a system auditor, but now we
|
because it relies on crawling the OPTIONS POST response to determine the fields to export.
|
||||||
make a best-effort to parse the description string, which will
|
|
||||||
often have the fields.
|
|
||||||
"""
|
"""
|
||||||
result = run_module('export', dict(all=True), system_auditor)
|
result = run_module('export', dict(all=True), system_auditor)
|
||||||
assert not result.get('failed', False), result.get('msg', result)
|
assert result.get('failed', False), result.get('msg', result)
|
||||||
assert 'msg' not in result
|
|
||||||
assert 'assets' in result
|
|
||||||
|
|
||||||
find_by(result['assets'], 'organizations', 'name', 'Default')
|
assert 'Failed to export assets substring not found' in result['msg'], (
|
||||||
|
'If you found this error then you have probably fixed a feature! The export code attempts to assertain the POST fields from the `description` field,'
|
||||||
|
' but both the API side and the client inference code are lacking.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# r = result['assets']['schedules'][0]
|
||||||
|
# assert r['natural_key']['name'] == 'test-sched'
|
||||||
|
|
||||||
|
# assert 'rrule' not in r, 'If you found this error then you have probably fixed a feature! We WANT rrule to be found in the export schedule payload.'
|
||||||
|
|||||||
@@ -13,32 +13,39 @@ def test_peers_adding_and_removing(run_module, admin_user):
|
|||||||
with override_settings(IS_K8S=True):
|
with override_settings(IS_K8S=True):
|
||||||
result = run_module(
|
result = run_module(
|
||||||
'instance',
|
'instance',
|
||||||
{'hostname': 'hopnode', 'node_type': 'hop', 'node_state': 'installed', 'listener_port': 6789},
|
{'hostname': 'hopnode1', 'node_type': 'hop', 'peers_from_control_nodes': True, 'node_state': 'installed', 'listener_port': 27199},
|
||||||
admin_user,
|
admin_user,
|
||||||
)
|
)
|
||||||
assert result['changed']
|
assert result['changed']
|
||||||
|
|
||||||
hop_node = Instance.objects.get(pk=result.get('id'))
|
hop_node_1 = Instance.objects.get(pk=result.get('id'))
|
||||||
|
|
||||||
assert hop_node.node_type == 'hop'
|
assert hop_node_1.peers_from_control_nodes is True
|
||||||
|
assert hop_node_1.node_type == 'hop'
|
||||||
address = hop_node.receptor_addresses.get(pk=result.get('id'))
|
|
||||||
assert address.port == 6789
|
|
||||||
|
|
||||||
result = run_module(
|
result = run_module(
|
||||||
'instance',
|
'instance',
|
||||||
{'hostname': 'executionnode', 'node_type': 'execution', 'node_state': 'installed', 'peers': ['hopnode']},
|
{'hostname': 'hopnode2', 'node_type': 'hop', 'peers_from_control_nodes': True, 'node_state': 'installed', 'listener_port': 27199},
|
||||||
|
admin_user,
|
||||||
|
)
|
||||||
|
assert result['changed']
|
||||||
|
|
||||||
|
hop_node_2 = Instance.objects.get(pk=result.get('id'))
|
||||||
|
|
||||||
|
result = run_module(
|
||||||
|
'instance',
|
||||||
|
{'hostname': 'executionnode', 'node_type': 'execution', 'node_state': 'installed', 'listener_port': 27199, 'peers': ['hopnode1', 'hopnode2']},
|
||||||
admin_user,
|
admin_user,
|
||||||
)
|
)
|
||||||
assert result['changed']
|
assert result['changed']
|
||||||
|
|
||||||
execution_node = Instance.objects.get(pk=result.get('id'))
|
execution_node = Instance.objects.get(pk=result.get('id'))
|
||||||
|
|
||||||
assert set(execution_node.peers.all()) == {address}
|
assert set(execution_node.peers.all()) == {hop_node_1, hop_node_2}
|
||||||
|
|
||||||
result = run_module(
|
result = run_module(
|
||||||
'instance',
|
'instance',
|
||||||
{'hostname': 'executionnode', 'node_type': 'execution', 'node_state': 'installed', 'peers': []},
|
{'hostname': 'executionnode', 'node_type': 'execution', 'node_state': 'installed', 'listener_port': 27199, 'peers': []},
|
||||||
admin_user,
|
admin_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -155,4 +155,4 @@ def test_build_notification_message_undefined(run_module, admin_user, organizati
|
|||||||
nt = NotificationTemplate.objects.get(id=result['id'])
|
nt = NotificationTemplate.objects.get(id=result['id'])
|
||||||
|
|
||||||
body = job.build_notification_message(nt, 'running')
|
body = job.build_notification_message(nt, 'running')
|
||||||
assert 'The template rendering return a blank body' in body[1]
|
assert '{"started_by": "My Placeholder"}' in body[1]
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
node_type: execution
|
node_type: execution
|
||||||
node_state: installed
|
node_state: installed
|
||||||
capacity_adjustment: 0.4
|
capacity_adjustment: 0.4
|
||||||
|
listener_port: 31337
|
||||||
register: result
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
@@ -73,9 +74,11 @@
|
|||||||
- block:
|
- block:
|
||||||
- name: Create hop node 1
|
- name: Create hop node 1
|
||||||
awx.awx.instance:
|
awx.awx.instance:
|
||||||
hostname: "{{ hostname1 }}"
|
hostname: hopnode1
|
||||||
node_type: hop
|
node_type: hop
|
||||||
node_state: installed
|
node_state: installed
|
||||||
|
listener_port: 27199
|
||||||
|
peers_from_control_nodes: True
|
||||||
register: result
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
@@ -84,9 +87,11 @@
|
|||||||
|
|
||||||
- name: Create hop node 2
|
- name: Create hop node 2
|
||||||
awx.awx.instance:
|
awx.awx.instance:
|
||||||
hostname: "{{ hostname2 }}"
|
hostname: hopnode2
|
||||||
node_type: hop
|
node_type: hop
|
||||||
node_state: installed
|
node_state: installed
|
||||||
|
listener_port: 27199
|
||||||
|
peers_from_control_nodes: True
|
||||||
register: result
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
@@ -95,12 +100,13 @@
|
|||||||
|
|
||||||
- name: Create execution node
|
- name: Create execution node
|
||||||
awx.awx.instance:
|
awx.awx.instance:
|
||||||
hostname: "{{ hostname3 }}"
|
hostname: executionnode
|
||||||
node_type: execution
|
node_type: execution
|
||||||
node_state: installed
|
node_state: installed
|
||||||
|
listener_port: 27199
|
||||||
peers:
|
peers:
|
||||||
- "{{ hostname1 }}"
|
- "hopnode1"
|
||||||
- "{{ hostname2 }}"
|
- "hopnode2"
|
||||||
register: result
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
@@ -109,9 +115,10 @@
|
|||||||
|
|
||||||
- name: Remove execution node peers
|
- name: Remove execution node peers
|
||||||
awx.awx.instance:
|
awx.awx.instance:
|
||||||
hostname: "{{ hostname3 }}"
|
hostname: executionnode
|
||||||
node_type: execution
|
node_type: execution
|
||||||
node_state: installed
|
node_state: installed
|
||||||
|
listener_port: 27199
|
||||||
peers: []
|
peers: []
|
||||||
register: result
|
register: result
|
||||||
|
|
||||||
@@ -119,15 +126,4 @@
|
|||||||
that:
|
that:
|
||||||
- result is changed
|
- result is changed
|
||||||
|
|
||||||
always:
|
|
||||||
- name: Deprovision the instances
|
|
||||||
awx.awx.instance:
|
|
||||||
hostname: "{{ item }}"
|
|
||||||
node_state: deprovisioning
|
|
||||||
with_items:
|
|
||||||
- "{{ hostname1 }}"
|
|
||||||
- "{{ hostname2 }}"
|
|
||||||
- "{{ hostname3 }}"
|
|
||||||
|
|
||||||
|
|
||||||
when: IS_K8S
|
when: IS_K8S
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ homepage: https://www.ansible.com/
|
|||||||
issues: https://github.com/ansible/awx/issues?q=is%3Aissue+label%3Acomponent%3Aawx_collection
|
issues: https://github.com/ansible/awx/issues?q=is%3Aissue+label%3Acomponent%3Aawx_collection
|
||||||
license:
|
license:
|
||||||
- GPL-3.0-or-later
|
- GPL-3.0-or-later
|
||||||
|
# plugins/module_utils/tower_legacy.py
|
||||||
|
- BSD-2-Clause
|
||||||
name: {{ collection_package }}
|
name: {{ collection_package }}
|
||||||
namespace: {{ collection_namespace }}
|
namespace: {{ collection_namespace }}
|
||||||
readme: README.md
|
readme: README.md
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class Connection(object):
|
|||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.uses_session_cookie = False
|
self.uses_session_cookie = False
|
||||||
|
|
||||||
def get_session_requirements(self, next=config.api_base_path):
|
def get_session_requirements(self, next='/api/'):
|
||||||
self.get(config.api_base_path) # this causes a cookie w/ the CSRF token to be set
|
self.get('/api/') # this causes a cookie w/ the CSRF token to be set
|
||||||
return dict(next=next)
|
return dict(next=next)
|
||||||
|
|
||||||
def login(self, username=None, password=None, token=None, **kwargs):
|
def login(self, username=None, password=None, token=None, **kwargs):
|
||||||
@@ -52,7 +52,7 @@ class Connection(object):
|
|||||||
_next = kwargs.get('next')
|
_next = kwargs.get('next')
|
||||||
if _next:
|
if _next:
|
||||||
headers = self.session.headers.copy()
|
headers = self.session.headers.copy()
|
||||||
response = self.post(f"{config.api_base_path}login/", headers=headers, data=dict(username=username, password=password, next=_next))
|
response = self.post('/api/login/', headers=headers, data=dict(username=username, password=password, next=_next))
|
||||||
# The login causes a redirect so we need to search the history of the request to find the header
|
# The login causes a redirect so we need to search the history of the request to find the header
|
||||||
for historical_response in response.history:
|
for historical_response in response.history:
|
||||||
if 'X-API-Session-Cookie-Name' in historical_response.headers:
|
if 'X-API-Session-Cookie-Name' in historical_response.headers:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import json
|
|||||||
|
|
||||||
from awxkit.utils import poll_until
|
from awxkit.utils import poll_until
|
||||||
from awxkit.exceptions import WaitUntilTimeout
|
from awxkit.exceptions import WaitUntilTimeout
|
||||||
from awxkit.config import config
|
|
||||||
|
|
||||||
|
|
||||||
def bytes_to_str(obj):
|
def bytes_to_str(obj):
|
||||||
@@ -84,7 +83,7 @@ class HasStatus(object):
|
|||||||
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
|
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
|
||||||
try:
|
try:
|
||||||
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
|
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
|
||||||
dependency = self.walk('/{0}v2/{1}s/{2}/'.format(config.api_base_path, data['job_type'], data['job_id']))
|
dependency = self.walk('/api/v2/{0}s/{1}/'.format(data['job_type'], data['job_id']))
|
||||||
if hasattr(dependency, 'failure_output_details'):
|
if hasattr(dependency, 'failure_output_details'):
|
||||||
msg += '\nDependency output:\n{}'.format(dependency.failure_output_details())
|
msg += '\nDependency output:\n{}'.format(dependency.failure_output_details())
|
||||||
else:
|
else:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user