mirror of
https://github.com/ansible/awx.git
synced 2026-06-10 01:16:18 -02:30
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed1b5c5519 | ||
|
|
d2e51c4124 |
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -24,7 +24,7 @@ in as the first entry for your PR title.
|
||||
|
||||
|
||||
|
||||
##### STEPS TO REPRODUCE AND EXTRA INFO
|
||||
##### ADDITIONAL INFORMATION
|
||||
<!---
|
||||
Include additional information to help people understand the change here.
|
||||
For bugs that don't have a linked bug report, a step-by-step reproduction
|
||||
|
||||
48
.github/workflows/ci.yml
vendored
48
.github/workflows/ci.yml
vendored
@@ -4,46 +4,14 @@ env:
|
||||
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
|
||||
CI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEV_DOCKER_OWNER: ${{ github.repository_owner }}
|
||||
COMPOSE_TAG: ${{ github.base_ref || github.ref_name || 'devel' }}
|
||||
COMPOSE_TAG: ${{ github.base_ref || 'devel' }}
|
||||
UPSTREAM_REPOSITORY_ID: 91594105
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- devel # needed to publish code coverage post-merge
|
||||
schedule:
|
||||
- cron: '0 12,18 * * 1-5'
|
||||
workflow_dispatch: {}
|
||||
jobs:
|
||||
trigger-release-branches:
|
||||
name: "Dispatch CI to release branches"
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Trigger CI on release_4.6
|
||||
id: dispatch_release_46
|
||||
continue-on-error: true
|
||||
run: gh workflow run ci.yml --ref release_4.6
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
- name: Trigger CI on stable-2.6
|
||||
id: dispatch_stable_26
|
||||
continue-on-error: true
|
||||
run: gh workflow run ci.yml --ref stable-2.6
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
- name: Check dispatch results
|
||||
if: steps.dispatch_release_46.outcome == 'failure' || steps.dispatch_stable_26.outcome == 'failure'
|
||||
run: |
|
||||
echo "One or more dispatches failed:"
|
||||
echo " release_4.6: ${{ steps.dispatch_release_46.outcome }}"
|
||||
echo " stable-2.6: ${{ steps.dispatch_stable_26.outcome }}"
|
||||
exit 1
|
||||
|
||||
common-tests:
|
||||
name: ${{ matrix.tests.name }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -94,11 +62,7 @@ jobs:
|
||||
run: |
|
||||
if [ -f "reports/coverage.xml" ]; then
|
||||
sed -i '2i<!-- PR ${{ github.event.pull_request.number }} -->' reports/coverage.xml
|
||||
echo "Injected PR number ${{ github.event.pull_request.number }} into reports/coverage.xml"
|
||||
fi
|
||||
if [ -f "awxkit/coverage.xml" ]; then
|
||||
sed -i '2i<!-- PR ${{ github.event.pull_request.number }} -->' awxkit/coverage.xml
|
||||
echo "Injected PR number ${{ github.event.pull_request.number }} into awxkit/coverage.xml"
|
||||
echo "Injected PR number ${{ github.event.pull_request.number }} into coverage.xml"
|
||||
fi
|
||||
|
||||
- name: Upload test coverage to Codecov
|
||||
@@ -145,9 +109,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.tests.name }}-artifacts
|
||||
path: |
|
||||
reports/coverage.xml
|
||||
awxkit/coverage.xml
|
||||
path: reports/coverage.xml
|
||||
retention-days: 5
|
||||
|
||||
- name: >-
|
||||
@@ -160,7 +122,7 @@ jobs:
|
||||
&& github.event_name == 'push'
|
||||
&& env.UPSTREAM_REPOSITORY_ID == github.repository_id
|
||||
&& github.ref_name == github.event.repository.default_branch
|
||||
uses: ansible/gh-action-record-test-results@3784db66a1b7fb3809999a7251c8a7203a7ffbe8
|
||||
uses: ansible/gh-action-record-test-results@cd5956ead39ec66351d0779470c8cff9638dd2b8
|
||||
with:
|
||||
aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}
|
||||
http-auth-password: >-
|
||||
@@ -334,7 +296,7 @@ jobs:
|
||||
&& github.event_name == 'push'
|
||||
&& env.UPSTREAM_REPOSITORY_ID == github.repository_id
|
||||
&& github.ref_name == github.event.repository.default_branch
|
||||
uses: ansible/gh-action-record-test-results@3784db66a1b7fb3809999a7251c8a7203a7ffbe8
|
||||
uses: ansible/gh-action-record-test-results@cd5956ead39ec66351d0779470c8cff9638dd2b8
|
||||
with:
|
||||
aggregation-server-url: ${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}
|
||||
http-auth-password: >-
|
||||
|
||||
10
.github/workflows/devel_images.yml
vendored
10
.github/workflows/devel_images.yml
vendored
@@ -13,10 +13,6 @@ on:
|
||||
- stable-*
|
||||
jobs:
|
||||
push-development-images:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.repository == 'ansible/awx' && (github.ref_name == 'devel' || startsWith(github.ref_name, 'feature_'))) ||
|
||||
(github.repository == 'ansible/tower' && (startsWith(github.ref_name, 'stable-') || startsWith(github.ref_name, 'release_')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
@@ -34,6 +30,12 @@ jobs:
|
||||
make-target: awx-kube-buildx
|
||||
steps:
|
||||
|
||||
- name: Skipping build of awx image for non-awx repository
|
||||
run: |
|
||||
echo "Skipping build of awx image for non-awx repository"
|
||||
exit 0
|
||||
if: matrix.build-targets.image-name == 'awx' && !endsWith(github.repository, '/awx')
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
show-progress: false
|
||||
|
||||
2
.github/workflows/pr_body_check.yml
vendored
2
.github/workflows/pr_body_check.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
packages: read
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check for each of the lines
|
||||
|
||||
6
.github/workflows/spec-sync-on-merge.yml
vendored
6
.github/workflows/spec-sync-on-merge.yml
vendored
@@ -16,15 +16,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- devel
|
||||
- 'stable-2.[6-9]'
|
||||
- 'stable-2.[1-9][0-9]'
|
||||
workflow_dispatch: # Allow manual triggering for testing
|
||||
jobs:
|
||||
sync-openapi-spec:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.repository == 'ansible/awx' && (github.ref_name == 'devel' || startsWith(github.ref_name, 'feature_'))) ||
|
||||
(github.repository == 'ansible/tower' && (startsWith(github.ref_name, 'stable-') || startsWith(github.ref_name, 'release_')))
|
||||
name: Sync OpenAPI spec to central repo
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
4
.github/workflows/upload_schema.yml
vendored
4
.github/workflows/upload_schema.yml
vendored
@@ -14,10 +14,6 @@ on:
|
||||
- stable-**
|
||||
jobs:
|
||||
push:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.repository == 'ansible/awx' && (github.ref_name == 'devel' || startsWith(github.ref_name, 'feature_'))) ||
|
||||
(github.repository == 'ansible/tower' && (startsWith(github.ref_name, 'stable-') || startsWith(github.ref_name, 'release_')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
apiVersion: tekton.dev/v1
|
||||
kind: PipelineRun
|
||||
metadata:
|
||||
name: awx-atf-tests-pull-request
|
||||
annotations:
|
||||
build.appstudio.openshift.io/repo: https://github.com/{{repo_owner}}/{{repo_name}}?rev={{revision}}
|
||||
build.appstudio.redhat.com/commit_sha: '{{revision}}'
|
||||
build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}'
|
||||
build.appstudio.redhat.com/target_branch: '{{target_branch}}'
|
||||
pipelinesascode.tekton.dev/cancel-in-progress: 'true'
|
||||
pipelinesascode.tekton.dev/max-keep-runs: "3"
|
||||
pipelinesascode.tekton.dev/on-comment: "^/run-atf-tests$"
|
||||
pipelinesascode.tekton.dev/target-namespace: ansible-ci-tenant
|
||||
labels:
|
||||
appstudio.openshift.io/application: '{{repo_owner}}'
|
||||
appstudio.openshift.io/component: '{{repo_owner}}-{{repo_name}}'
|
||||
pipelines.appstudio.openshift.io/type: build
|
||||
spec:
|
||||
timeouts:
|
||||
pipeline: "8h"
|
||||
tasks: "7h"
|
||||
finally: "1h"
|
||||
pipelineRef:
|
||||
resolver: bundles
|
||||
params:
|
||||
- name: name
|
||||
value: aap-api-tests
|
||||
- name: bundle
|
||||
value: quay.io/aap-ci/tekton-catalog/pipeline/test/aap-api-tests:0.1@sha256:50aadd6725a239ab53247deb7cf601d1163ceb1792792fd239a3f37d21a490d7
|
||||
- name: kind
|
||||
value: pipeline
|
||||
- name: secret
|
||||
value: quay-aap-ci-viewer
|
||||
|
||||
taskRunTemplate:
|
||||
serviceAccountName: konflux-integration-runner
|
||||
|
||||
params:
|
||||
- name: git-url
|
||||
value: "{{source_url}}"
|
||||
- name: pipeline-github-org
|
||||
value: "{{repo_owner}}"
|
||||
- name: pipeline-github-repo
|
||||
value: "{{repo_name}}"
|
||||
- name: pipeline-github-target-branch
|
||||
value: '{{target_branch}}'
|
||||
- name: pipeline-github-pr-revision
|
||||
value: "{{revision}}"
|
||||
- name: pipeline-github-pr-number
|
||||
value: "{{pull_request_number}}"
|
||||
- name: aap-dev-component-source-name
|
||||
value: "controller"
|
||||
- name: pytest-number-of-parallel-processes
|
||||
value: "6"
|
||||
|
||||
workspaces:
|
||||
- name: workspace
|
||||
volumeClaimTemplate:
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -103,12 +103,6 @@ When necessary, remove any AWX containers and images by running the following:
|
||||
|
||||
### Pre commit hooks
|
||||
|
||||
Install the pre-commit hook before contributing:
|
||||
|
||||
```
|
||||
make pre-commit
|
||||
```
|
||||
|
||||
When you attempt to perform a `git commit` there will be a pre-commit hook that gets run before the commit is allowed to your local repository. For example, python's [black](https://pypi.org/project/black/) will be run to test the formatting of any python files.
|
||||
|
||||
While you can use environment variables to skip the pre-commit hooks GitHub will run similar tests and prevent merging of PRs if the tests do not pass.
|
||||
|
||||
41
Makefile
41
Makefile
@@ -10,7 +10,6 @@ KIND_BIN ?= $(shell which kind)
|
||||
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
||||
GIT_REPO_NAME ?= $(shell basename `git rev-parse --show-toplevel`)
|
||||
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
GIT_IS_WORKTREE := $(shell test -f .git && echo yes)
|
||||
MANAGEMENT_COMMAND ?= awx-manage
|
||||
VERSION ?= $(shell $(PYTHON) tools/scripts/scm_version.py 2> /dev/null)
|
||||
|
||||
@@ -107,15 +106,6 @@ else
|
||||
DOCKER_KUBE_CACHE_FLAG=$(DOCKER_CACHE)
|
||||
endif
|
||||
|
||||
# AWX TUI variables
|
||||
AWX_HOST ?= https://localhost:8043
|
||||
AWX_USER ?= admin
|
||||
AWX_PASSWORD ?= $$(awk -F"'" '/^admin_password:/{print $$2}' tools/docker-compose/_sources/secrets/admin_password.yml 2>/dev/null || echo "admin")
|
||||
AWX_VERIFY_SSL ?= false
|
||||
|
||||
# For git worktree to find the referenced git dir
|
||||
GIT_COMMON_DIR := $(shell git rev-parse --git-common-dir 2>/dev/null || echo .git)
|
||||
|
||||
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
|
||||
update_requirements upgrade_requirements update_requirements_dev \
|
||||
docker_update_requirements docker_upgrade_requirements docker_update_requirements_dev \
|
||||
@@ -123,7 +113,7 @@ GIT_COMMON_DIR := $(shell git rev-parse --git-common-dir 2>/dev/null || echo .gi
|
||||
receiver test test_unit test_coverage coverage_html \
|
||||
sdist \
|
||||
VERSION PYTHON_VERSION docker-compose-sources \
|
||||
pre-commit
|
||||
.git/hooks/pre-commit
|
||||
|
||||
clean-tmp:
|
||||
rm -rf tmp/
|
||||
@@ -352,10 +342,11 @@ black: reports
|
||||
@command -v black >/dev/null 2>&1 || { echo "could not find black on your PATH, you may need to \`pip install black\`, or set AWX_IGNORE_BLACK=1" && exit 1; }
|
||||
@(set -o pipefail && $@ $(BLACK_ARGS) awx awxkit awx_collection | tee reports/$@.report)
|
||||
|
||||
$(GIT_COMMON_DIR)/hooks/pre-commit:
|
||||
ln -sf ../../pre-commit.sh $(GIT_COMMON_DIR)/hooks/pre-commit
|
||||
|
||||
pre-commit: $(GIT_COMMON_DIR)/hooks/pre-commit
|
||||
.git/hooks/pre-commit:
|
||||
@echo "if [ -x pre-commit.sh ]; then" > .git/hooks/pre-commit
|
||||
@echo " ./pre-commit.sh;" >> .git/hooks/pre-commit
|
||||
@echo "fi" >> .git/hooks/pre-commit
|
||||
@chmod +x .git/hooks/pre-commit
|
||||
|
||||
genschema: awx-link reports
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
@@ -530,7 +521,7 @@ ifneq ($(ADMIN_PASSWORD),)
|
||||
EXTRA_SOURCES_ANSIBLE_OPTS := -e admin_password=$(ADMIN_PASSWORD) $(EXTRA_SOURCES_ANSIBLE_OPTS)
|
||||
endif
|
||||
|
||||
docker-compose-sources:
|
||||
docker-compose-sources: .git/hooks/pre-commit
|
||||
@if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\
|
||||
$(ANSIBLE_PLAYBOOK) -i tools/docker-compose/inventory -e minikube_setup=$(MINIKUBE_SETUP) tools/docker-compose-minikube/deploy.yml; \
|
||||
fi;
|
||||
@@ -562,7 +553,7 @@ docker-compose: awx/projects docker-compose-sources
|
||||
$(MAKE) docker-compose-up
|
||||
|
||||
docker-compose-up:
|
||||
$(if $(GIT_IS_WORKTREE),SETUPTOOLS_SCM_PRETEND_VERSION="$(VERSION)") $(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) up $(COMPOSE_UP_OPTS) --remove-orphans
|
||||
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) up $(COMPOSE_UP_OPTS) --remove-orphans
|
||||
|
||||
docker-compose-down:
|
||||
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml $(COMPOSE_OPTS) down --remove-orphans
|
||||
@@ -580,20 +571,6 @@ docker-compose-runtest: awx/projects docker-compose-sources
|
||||
docker-compose-build-schema: awx/projects docker-compose-sources
|
||||
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml run --rm --service-ports --no-deps awx_1 make genschema
|
||||
|
||||
awx-tui:
|
||||
@if ! command -v awx-tui > /dev/null 2>&1; then \
|
||||
$(PYTHON) -m pip install awx-tui; \
|
||||
fi
|
||||
@if [ -f "$(HOME)/.config/awx-tui/config.yaml" ]; then \
|
||||
$(PYTHON) -m awx_tui.main; \
|
||||
else \
|
||||
AWX_HOST=$(AWX_HOST) \
|
||||
AWX_USER=$(AWX_USER) \
|
||||
AWX_PASSWORD=$(AWX_PASSWORD) \
|
||||
AWX_VERIFY_SSL=$(AWX_VERIFY_SSL) \
|
||||
$(PYTHON) -m awx_tui.main --host $(AWX_HOST); \
|
||||
fi
|
||||
|
||||
SCHEMA_DIFF_BASE_FOLDER ?= awx
|
||||
SCHEMA_DIFF_BASE_BRANCH ?= devel
|
||||
detect-schema-change: genschema
|
||||
@@ -604,7 +581,7 @@ detect-schema-change: genschema
|
||||
|
||||
validate-openapi-schema: genschema
|
||||
@echo "Validating OpenAPI schema from schema.json..."
|
||||
@python3 -c "from openapi_spec_validator import validate; import json; spec = json.load(open('schema.json')); validate(spec); print('✓ Schema is valid')"
|
||||
@python3 -c "from openapi_spec_validator import validate; import json; spec = json.load(open('schema.json')); validate(spec); print('✓ OpenAPI Schema is valid!')"
|
||||
|
||||
docker-compose-clean: awx/projects
|
||||
$(DOCKER_COMPOSE) -f tools/docker-compose/_sources/docker-compose.yml rm -sf
|
||||
|
||||
@@ -52,6 +52,14 @@ except ImportError: # pragma: no cover
|
||||
MODE = 'production'
|
||||
|
||||
|
||||
try:
|
||||
import django # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
from django.db import connection
|
||||
|
||||
|
||||
def prepare_env():
|
||||
# Update the default settings environment variable based on current mode.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'awx.settings')
|
||||
@@ -71,6 +79,14 @@ def manage():
|
||||
from django.conf import settings
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
# enforce the postgres version is a minimum of 12 (we need this for partitioning); if not, then terminate program with exit code of 1
|
||||
# In the future if we require a feature of a version of postgres > 12 this should be updated to reflect that.
|
||||
# The return of connection.pg_version is something like 12013
|
||||
if not os.getenv('SKIP_PG_VERSION_CHECK', False) and not MODE == 'development':
|
||||
if (connection.pg_version // 10000) < 12:
|
||||
sys.stderr.write("At a minimum, postgres version 12 is required\n")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) >= 2 and sys.argv[1] in ('version', '--version'): # pragma: no cover
|
||||
sys.stdout.write('%s\n' % __version__)
|
||||
# If running as a user without permission to read settings, display an
|
||||
|
||||
@@ -131,14 +131,8 @@ class LoggedLoginView(auth_views.LoginView):
|
||||
|
||||
|
||||
class LoggedLogoutView(auth_views.LogoutView):
|
||||
# Override http_method_names to allow GET requests (Django 5.2+ defaults to POST only)
|
||||
http_method_names = ["get", "post", "options"]
|
||||
success_url_allowed_hosts = set(settings.LOGOUT_ALLOWED_HOSTS.split(",")) if settings.LOGOUT_ALLOWED_HOSTS else set()
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Handle GET requests for logout (for backward compatibility)."""
|
||||
return self.post(request, *args, **kwargs)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if is_proxied_request():
|
||||
# 1) We intentionally don't obey ?next= here, just always redirect to platform login
|
||||
@@ -272,10 +266,7 @@ class APIView(views.APIView):
|
||||
response = self.handle_exception(self.__init_request_error__)
|
||||
if response.status_code == 401:
|
||||
if response.data and 'detail' in response.data:
|
||||
if getattr(settings, 'RESOURCE_SERVER__URL', None):
|
||||
response.data['detail'] += _(' Direct access is not allowed, authenticate via the platform gateway.')
|
||||
else:
|
||||
response.data['detail'] += _(' To establish a login session, visit') + ' /api/login/.'
|
||||
response.data['detail'] += _(' To establish a login session, visit') + ' /api/login/.'
|
||||
logger.info(status_msg)
|
||||
else:
|
||||
logger.warning(status_msg)
|
||||
|
||||
@@ -120,7 +120,8 @@ from awx.main.utils.named_url_graph import reset_counters
|
||||
from awx.main.utils.inventory_vars import update_group_variables
|
||||
from awx.main.scheduler.task_manager_models import TaskManagerModels
|
||||
from awx.main.redact import UriCleaner, REPLACE_STR
|
||||
from awx.main.tasks.system import update_inventory_computed_fields
|
||||
from awx.main.signals import update_inventory_computed_fields
|
||||
|
||||
|
||||
from awx.main.validators import vars_validate_or_raise
|
||||
|
||||
@@ -174,8 +175,8 @@ SUMMARIZABLE_FK_FIELDS = {
|
||||
'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout',),
|
||||
'schedule': DEFAULT_SUMMARY_FIELDS + ('next_run',),
|
||||
'unified_job_template': DEFAULT_SUMMARY_FIELDS + ('unified_job_type',),
|
||||
# last_job and last_job_host_summary are derived from JobHostSummary in HostSerializer,
|
||||
# not from the stale FK fields on Host.
|
||||
'last_job': DEFAULT_SUMMARY_FIELDS + ('finished', 'status', 'failed', 'license_error', 'canceled_on'),
|
||||
'last_job_host_summary': DEFAULT_SUMMARY_FIELDS + ('failed',),
|
||||
'last_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
|
||||
'current_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
|
||||
'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
|
||||
@@ -1021,7 +1022,7 @@ class UnifiedJobStdoutSerializer(UnifiedJobSerializer):
|
||||
|
||||
|
||||
class UserSerializer(BaseSerializer):
|
||||
password = serializers.CharField(required=False, default='', allow_blank=True, help_text=_('Field used to change the password.'))
|
||||
password = serializers.CharField(required=False, default='', help_text=_('Field used to change the password.'))
|
||||
is_system_auditor = serializers.BooleanField(default=False)
|
||||
show_capabilities = ['edit', 'delete']
|
||||
|
||||
@@ -1837,35 +1838,19 @@ class HostSerializer(BaseSerializerWithVariables):
|
||||
res['ansible_facts'] = self.reverse('api:host_ansible_facts_detail', kwargs={'pk': obj.instance_id})
|
||||
if obj.inventory:
|
||||
res['inventory'] = self.reverse('api:inventory_detail', kwargs={'pk': obj.inventory.pk})
|
||||
last_summary = obj.latest_summary
|
||||
if last_summary:
|
||||
res['last_job_host_summary'] = self.reverse('api:job_host_summary_detail', kwargs={'pk': last_summary.pk})
|
||||
if last_summary.job_id:
|
||||
res['last_job'] = self.reverse('api:job_detail', kwargs={'pk': last_summary.job_id})
|
||||
if obj.last_job:
|
||||
res['last_job'] = self.reverse('api:job_detail', kwargs={'pk': obj.last_job.pk})
|
||||
if obj.last_job_host_summary:
|
||||
res['last_job_host_summary'] = self.reverse('api:job_host_summary_detail', kwargs={'pk': obj.last_job_host_summary.pk})
|
||||
return res
|
||||
|
||||
def get_summary_fields(self, obj):
|
||||
d = super(HostSerializer, self).get_summary_fields(obj)
|
||||
last_summary = obj.latest_summary
|
||||
if last_summary:
|
||||
d['last_job_host_summary'] = OrderedDict()
|
||||
d['last_job_host_summary']['id'] = last_summary.id
|
||||
d['last_job_host_summary']['failed'] = last_summary.failed
|
||||
try:
|
||||
last_job = last_summary.job
|
||||
d['last_job'] = OrderedDict()
|
||||
for field in DEFAULT_SUMMARY_FIELDS + ('finished', 'status', 'failed', 'canceled_on'):
|
||||
fval = getattr(last_job, field, None)
|
||||
if fval is not None:
|
||||
d['last_job'][field] = fval
|
||||
if last_job.job_template:
|
||||
d['last_job']['job_template_id'] = last_job.job_template.id
|
||||
d['last_job']['job_template_name'] = last_job.job_template.name
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
else:
|
||||
d.pop('last_job', None)
|
||||
d.pop('last_job_host_summary', None)
|
||||
try:
|
||||
d['last_job']['job_template_id'] = obj.last_job.job_template.id
|
||||
d['last_job']['job_template_name'] = obj.last_job.job_template.name
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
if has_model_field_prefetched(obj, 'groups'):
|
||||
group_list = sorted([{'id': g.id, 'name': g.name} for g in obj.groups.all()], key=lambda x: x['id'])[:5]
|
||||
else:
|
||||
@@ -1940,16 +1925,14 @@ class HostSerializer(BaseSerializerWithVariables):
|
||||
return ret
|
||||
if 'inventory' in ret and not obj.inventory:
|
||||
ret['inventory'] = None
|
||||
last_summary = obj.latest_summary
|
||||
if 'last_job' in ret:
|
||||
ret['last_job'] = last_summary.job_id if last_summary else None
|
||||
if 'last_job_host_summary' in ret:
|
||||
ret['last_job_host_summary'] = last_summary.pk if last_summary else None
|
||||
if 'last_job' in ret and not obj.last_job:
|
||||
ret['last_job'] = None
|
||||
if 'last_job_host_summary' in ret and not obj.last_job_host_summary:
|
||||
ret['last_job_host_summary'] = None
|
||||
return ret
|
||||
|
||||
def get_has_active_failures(self, obj):
|
||||
last_summary = obj.latest_summary
|
||||
return bool(last_summary and last_summary.failed)
|
||||
return bool(obj.last_job_host_summary and obj.last_job_host_summary.failed)
|
||||
|
||||
def get_has_inventory_sources(self, obj):
|
||||
return obj.inventory_sources.exists()
|
||||
@@ -2096,17 +2079,9 @@ class BulkHostCreateSerializer(serializers.Serializer):
|
||||
if request and not request.user.is_superuser:
|
||||
if request.user not in inv.admin_role:
|
||||
raise serializers.ValidationError(_(f'Inventory with id {inv.id} not found or lack permissions to add hosts.'))
|
||||
|
||||
# Performance optimization (AAP-67978): Instead of loading ALL host names from
|
||||
# the inventory, only check if the specific new names already exist in the database.
|
||||
current_hostnames = set(inv.hosts.values_list('name', flat=True))
|
||||
new_names = [host['name'] for host in attrs['hosts']]
|
||||
|
||||
new_name_counts = Counter(new_names)
|
||||
duplicates_in_new = [name for name, count in new_name_counts.items() if count > 1]
|
||||
unique_new_names = list(new_name_counts.keys())
|
||||
existing_duplicates = list(Host.objects.filter(inventory=inv, name__in=unique_new_names).values_list('name', flat=True))
|
||||
duplicate_new_names = list(set(duplicates_in_new + existing_duplicates))
|
||||
|
||||
duplicate_new_names = [n for n in new_names if n in current_hostnames or new_names.count(n) > 1]
|
||||
if duplicate_new_names:
|
||||
raise serializers.ValidationError(_(f'Hostnames must be unique in an inventory. Duplicates found: {duplicate_new_names}'))
|
||||
|
||||
@@ -2957,19 +2932,6 @@ class CredentialTypeSerializer(BaseSerializer):
|
||||
field['label'] = _(field['label'])
|
||||
if 'help_text' in field:
|
||||
field['help_text'] = _(field['help_text'])
|
||||
|
||||
# Deep copy inputs to avoid modifying the original model data
|
||||
inputs = value.get('inputs')
|
||||
if not isinstance(inputs, dict):
|
||||
inputs = {}
|
||||
value['inputs'] = copy.deepcopy(inputs)
|
||||
fields = value['inputs'].get('fields', [])
|
||||
if not isinstance(fields, list):
|
||||
fields = []
|
||||
|
||||
# Normalize fields and filter out internal fields
|
||||
value['inputs']['fields'] = [f for f in fields if not f.get('internal')]
|
||||
|
||||
return value
|
||||
|
||||
def filter_field_metadata(self, fields, method):
|
||||
@@ -4160,28 +4122,9 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
|
||||
attrs['extra_data'][key] = db_extra_data[key]
|
||||
|
||||
# Build unsaved version of this config, use it to detect prompts errors
|
||||
# Capture keys before _build_mock_obj pops pseudo-fields from attrs
|
||||
incoming_attr_keys = set(attrs.keys())
|
||||
mock_obj = self._build_mock_obj(attrs)
|
||||
ask_mapping_keys = set(ujt.get_ask_mapping().keys())
|
||||
requested_prompt_fields = incoming_attr_keys & ask_mapping_keys
|
||||
if 'extra_data' in incoming_attr_keys:
|
||||
requested_prompt_fields.add('extra_vars')
|
||||
requested_prompt_fields.add('survey_passwords')
|
||||
|
||||
# prompts_dict() pulls persisted M2M state (labels, credentials,
|
||||
# instance_groups) via the instance pk. Only re-validate the full prompt
|
||||
# state when the caller is switching the underlying template; otherwise
|
||||
# restrict validation to the fields the request explicitly provided.
|
||||
if 'unified_job_template' in attrs:
|
||||
prompts_to_validate = mock_obj.prompts_dict()
|
||||
elif requested_prompt_fields:
|
||||
prompts_to_validate = {k: v for k, v in mock_obj.prompts_dict().items() if k in requested_prompt_fields}
|
||||
else:
|
||||
prompts_to_validate = None
|
||||
|
||||
if prompts_to_validate is not None:
|
||||
accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **prompts_to_validate)
|
||||
if set(list(ujt.get_ask_mapping().keys()) + ['extra_data']) & set(attrs.keys()):
|
||||
accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **mock_obj.prompts_dict())
|
||||
else:
|
||||
# Only perform validation of prompts if prompts fields are provided
|
||||
errors = {}
|
||||
@@ -5450,11 +5393,7 @@ class SchedulePreviewSerializer(BaseSerializer):
|
||||
for a_rule in match_multiple_rrule:
|
||||
if 'interval' not in a_rule.lower():
|
||||
errors.append("{0}: {1}".format(_('INTERVAL required in rrule'), a_rule))
|
||||
else:
|
||||
match_interval = re.match(r".*?INTERVAL=([0-9]+)", a_rule)
|
||||
if match_interval and int(match_interval.group(1)) < 1:
|
||||
errors.append("{0}: {1}".format(_("INTERVAL must be a positive integer"), a_rule))
|
||||
if 'secondly' in a_rule.lower():
|
||||
elif 'secondly' in a_rule.lower():
|
||||
errors.append("{0}: {1}".format(_('SECONDLY is not supported'), a_rule))
|
||||
if re.match(by_day_with_numeric_prefix, a_rule):
|
||||
errors.append("{0}: {1}".format(_("BYDAY with numeric prefix not supported"), a_rule))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
collections:
|
||||
- name: ansible.receptor
|
||||
version: 2.0.8
|
||||
version: 2.0.6
|
||||
|
||||
@@ -14,14 +14,13 @@ import sys
|
||||
import time
|
||||
from base64 import b64encode
|
||||
from collections import OrderedDict
|
||||
from jwt import decode as _jwt_decode
|
||||
|
||||
from urllib3.exceptions import ConnectTimeoutError
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ObjectDoesNotExist
|
||||
from django.db.models import Q, Sum, Count, Subquery, OuterRef
|
||||
from django.db.models import Q, Sum, Count
|
||||
from django.db import IntegrityError, ProgrammingError, transaction, connection
|
||||
from django.db.models.fields.related import ManyToManyField, ForeignKey
|
||||
from django.db.models.functions import Trunc
|
||||
@@ -56,16 +55,10 @@ from wsgiref.util import FileWrapper
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.lib.utils.requests import get_remote_hosts
|
||||
from ansible_base.rbac.models import RoleEvaluation
|
||||
from ansible_base.lib.utils.schema import extend_schema_if_available
|
||||
from ansible_base.lib.workload_identity.controller import AutomationControllerJobScope
|
||||
|
||||
# flags
|
||||
from flags.state import flag_enabled
|
||||
|
||||
# AWX
|
||||
from awx.main.utils.workload_identity import retrieve_workload_identity_jwt_with_claims
|
||||
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields
|
||||
from awx.main.access import get_user_queryset
|
||||
from awx.api.generics import (
|
||||
@@ -104,6 +97,7 @@ from awx.main.utils import (
|
||||
from awx.main.utils.encryption import encrypt_value
|
||||
from awx.main.utils.filters import SmartFilter
|
||||
from awx.main.utils.plugins import compute_cloud_inventory_sources
|
||||
from awx.main.utils.proxy import get_first_remote_host_from_headers
|
||||
from awx.main.utils.common import memoize
|
||||
from awx.main.redact import UriCleaner
|
||||
from awx.api.permissions import (
|
||||
@@ -209,12 +203,11 @@ class DashboardView(APIView):
|
||||
groups_inventory_failed = models.Group.objects.filter(inventory_sources__last_job_failed=True).count()
|
||||
data['groups'] = {'url': reverse('api:group_list', request=request), 'total': user_groups.count(), 'inventory_failed': groups_inventory_failed}
|
||||
|
||||
user_hosts = get_user_queryset(request.user, models.Host).exclude(inventory__kind='constructed')
|
||||
latest_summary_failed = Subquery(models.JobHostSummary.objects.filter(host_id=OuterRef('pk')).order_by('-id').values('failed')[:1])
|
||||
user_hosts_failed = user_hosts.annotate(_latest_failed=latest_summary_failed).filter(_latest_failed=True)
|
||||
|
||||
user_hosts = get_user_queryset(request.user, models.Host)
|
||||
user_hosts_failed = user_hosts.filter(last_job_host_summary__failed=True)
|
||||
data['hosts'] = {
|
||||
'url': reverse('api:host_list', request=request),
|
||||
'failures_url': reverse('api:host_list', request=request) + "?last_job_host_summary__failed=True",
|
||||
'total': user_hosts.count(),
|
||||
'failed': user_hosts_failed.count(),
|
||||
}
|
||||
@@ -801,11 +794,22 @@ class TeamRolesList(SubListAttachDetachAPIView):
|
||||
data = dict(msg=_("You cannot grant system-level permissions to a team."))
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.data.get('disassociate'):
|
||||
team = get_object_or_404(models.Team, pk=self.kwargs['pk'])
|
||||
content_object = role.content_object
|
||||
if hasattr(content_object, 'validate_role_assignment'):
|
||||
content_object.validate_role_assignment(team, role_definition=None, requesting_user=request.user)
|
||||
team = get_object_or_404(models.Team, pk=self.kwargs['pk'])
|
||||
credential_content_type = ContentType.objects.get_for_model(models.Credential)
|
||||
if role.content_type == credential_content_type:
|
||||
if not role.content_object.organization:
|
||||
data = dict(
|
||||
msg=_("You cannot grant access to a credential that is not assigned to an organization (private credentials cannot be assigned to teams)")
|
||||
)
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif role.content_object.organization.id != team.organization.id:
|
||||
if not request.user.is_superuser:
|
||||
data = dict(
|
||||
msg=_(
|
||||
"You cannot grant a team access to a credential in a different organization. Only superusers can grant cross-organization credential access to teams"
|
||||
)
|
||||
)
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return super(TeamRolesList, self).post(request, *args, **kwargs)
|
||||
|
||||
@@ -1264,12 +1268,19 @@ class UserRolesList(SubListAttachDetachAPIView):
|
||||
if not sub_id:
|
||||
return super(UserRolesList, self).post(request)
|
||||
|
||||
if not request.data.get('disassociate'):
|
||||
role = get_object_or_400(models.Role, pk=sub_id)
|
||||
user = get_object_or_400(models.User, pk=self.kwargs['pk'])
|
||||
content_object = role.content_object
|
||||
if hasattr(content_object, 'validate_role_assignment'):
|
||||
content_object.validate_role_assignment(user, role_definition=None, requesting_user=request.user)
|
||||
user = get_object_or_400(models.User, pk=self.kwargs['pk'])
|
||||
role = get_object_or_400(models.Role, pk=sub_id)
|
||||
|
||||
content_types = ContentType.objects.get_for_models(models.Organization, models.Team, models.Credential) # dict of {model: content_type}
|
||||
credential_content_type = content_types[models.Credential]
|
||||
if role.content_type == credential_content_type:
|
||||
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
||||
data = dict(msg=_("You cannot grant credential access to a user not in the credentials' organization"))
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not role.content_object.organization and not request.user.is_superuser:
|
||||
data = dict(msg=_("You cannot grant private credential access to another user"))
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return super(UserRolesList, self).post(request, *args, **kwargs)
|
||||
|
||||
@@ -1584,175 +1595,7 @@ class CredentialCopy(CopyAPIView):
|
||||
resource_purpose = 'copy of a credential'
|
||||
|
||||
|
||||
class OIDCCredentialTestMixin:
|
||||
"""
|
||||
Mixin to add OIDC workload identity token support to credential test endpoints.
|
||||
|
||||
This mixin provides methods to handle OIDC-enabled external credentials that use
|
||||
workload identity tokens for authentication.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_workload_identity_token(job_template: models.JobTemplate, audience: str) -> str:
|
||||
"""Generate a workload identity token for a job template.
|
||||
|
||||
Args:
|
||||
job_template: The JobTemplate instance to generate claims for
|
||||
audience: The JWT audience claim value
|
||||
|
||||
Returns:
|
||||
str: The generated JWT token
|
||||
"""
|
||||
claims = {
|
||||
AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: job_template.organization.name,
|
||||
AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: job_template.organization.id,
|
||||
AutomationControllerJobScope.CLAIM_PROJECT_NAME: job_template.project.name,
|
||||
AutomationControllerJobScope.CLAIM_PROJECT_ID: job_template.project.id,
|
||||
AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_NAME: job_template.name,
|
||||
AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_ID: job_template.id,
|
||||
AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME: job_template.playbook,
|
||||
}
|
||||
return retrieve_workload_identity_jwt_with_claims(
|
||||
claims=claims,
|
||||
audience=audience,
|
||||
scope=AutomationControllerJobScope.name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode_jwt_payload_for_display(jwt_token):
|
||||
"""Decode JWT payload for display purposes only (signature not verified).
|
||||
|
||||
This is safe because the JWT was just created by AWX and is only decoded
|
||||
to show the user what claims are being sent to the external system.
|
||||
The external system will perform proper signature verification.
|
||||
|
||||
Args:
|
||||
jwt_token: The JWT token to decode
|
||||
|
||||
Returns:
|
||||
dict: The decoded JWT payload
|
||||
"""
|
||||
return _jwt_decode(jwt_token, algorithms=["RS256"], options={"verify_signature": False}) # NOSONAR python:S5659
|
||||
|
||||
def _has_workload_identity_token(self, credential_type_inputs):
|
||||
"""Check if credential type has an internal workload_identity_token field.
|
||||
|
||||
Args:
|
||||
credential_type_inputs: The inputs dict from a credential type
|
||||
|
||||
Returns:
|
||||
bool: True if the credential type has a workload_identity_token field marked as internal
|
||||
"""
|
||||
fields = credential_type_inputs.get('fields', []) if isinstance(credential_type_inputs, dict) else []
|
||||
return any(field.get('internal') and field.get('id') == 'workload_identity_token' for field in fields)
|
||||
|
||||
def _validate_and_get_job_template(self, job_template_id):
|
||||
"""Validate job template ID and return the JobTemplate instance.
|
||||
|
||||
Args:
|
||||
job_template_id: The job template ID from metadata
|
||||
|
||||
Returns:
|
||||
JobTemplate instance
|
||||
|
||||
Raises:
|
||||
ParseError: If job_template_id is invalid or not found
|
||||
"""
|
||||
if job_template_id is None:
|
||||
raise ParseError(_('Job template ID is required.'))
|
||||
|
||||
try:
|
||||
return models.JobTemplate.objects.get(id=int(job_template_id))
|
||||
except ValueError:
|
||||
raise ParseError(_('Job template ID must be an integer.'))
|
||||
except models.JobTemplate.DoesNotExist:
|
||||
raise ParseError(_('Job template with ID %(id)s does not exist.') % {'id': job_template_id})
|
||||
|
||||
def _handle_oidc_credential_test(self, backend_kwargs):
|
||||
"""
|
||||
Handle OIDC workload identity token generation for external credential test endpoints.
|
||||
|
||||
This method should only be called when FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED is enabled
|
||||
and the credential type has a workload_identity_token field.
|
||||
|
||||
Args:
|
||||
backend_kwargs: The kwargs dict to pass to the backend (will be modified in place)
|
||||
|
||||
Returns:
|
||||
dict: Response body containing details with the sent JWT payload
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If user lacks access to the job template (re-raised for 403 response)
|
||||
|
||||
All other exceptions are caught and converted to 400 responses with error details.
|
||||
|
||||
Modifies backend_kwargs in place to add workload_identity_token.
|
||||
"""
|
||||
# Validate job template
|
||||
job_template_id = backend_kwargs.pop('job_template_id', None)
|
||||
job_template = self._validate_and_get_job_template(job_template_id)
|
||||
|
||||
# Check user access
|
||||
if not self.request.user.can_access(models.JobTemplate, 'start', job_template):
|
||||
raise PermissionDenied(_('You do not have access to job template with id: %(id)s.') % {'id': job_template.id})
|
||||
|
||||
# Generate workload identity token
|
||||
jwt_token = self._get_workload_identity_token(job_template, backend_kwargs.get('url'))
|
||||
backend_kwargs['workload_identity_token'] = jwt_token
|
||||
|
||||
return {'details': {'sent_jwt_payload': self._decode_jwt_payload_for_display(jwt_token)}}
|
||||
|
||||
def _call_backend_with_error_handling(self, plugin, backend_kwargs, response_body):
|
||||
"""Call credential backend and handle errors."""
|
||||
try:
|
||||
with set_environ(**settings.AWX_TASK_ENV):
|
||||
plugin.backend(**backend_kwargs)
|
||||
return Response(response_body, status=status.HTTP_202_ACCEPTED)
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
message = self._extract_http_error_message(exc)
|
||||
self._add_error_to_response(response_body, message)
|
||||
return Response(response_body, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
message = self._extract_generic_error_message(exc)
|
||||
self._add_error_to_response(response_body, message)
|
||||
return Response(response_body, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@staticmethod
|
||||
def _extract_http_error_message(exc):
|
||||
"""Extract error message from HTTPError, checking response JSON and text."""
|
||||
message = str(exc)
|
||||
if not hasattr(exc, 'response') or exc.response is None:
|
||||
return message
|
||||
|
||||
try:
|
||||
error_data = exc.response.json()
|
||||
if 'errors' in error_data and error_data['errors']:
|
||||
return ', '.join(error_data['errors'])
|
||||
if 'error' in error_data:
|
||||
return error_data['error']
|
||||
except (ValueError, KeyError):
|
||||
if exc.response.text:
|
||||
return exc.response.text
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _extract_generic_error_message(exc):
|
||||
"""Extract error message from exception, handling ConnectTimeoutError specially."""
|
||||
message = str(exc) if str(exc) else exc.__class__.__name__
|
||||
for arg in getattr(exc, 'args', []):
|
||||
if isinstance(getattr(arg, 'reason', None), ConnectTimeoutError):
|
||||
return str(arg.reason)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _add_error_to_response(response_body, message):
|
||||
"""Add error message to both 'detail' and 'details.error_message' fields."""
|
||||
response_body['detail'] = message
|
||||
if 'details' in response_body:
|
||||
response_body['details']['error_message'] = message
|
||||
|
||||
|
||||
class CredentialExternalTest(OIDCCredentialTestMixin, SubDetailAPIView):
|
||||
class CredentialExternalTest(SubDetailAPIView):
|
||||
"""
|
||||
Test updates to the input values and metadata of an external credential
|
||||
before saving them.
|
||||
@@ -1772,8 +1615,6 @@ class CredentialExternalTest(OIDCCredentialTestMixin, SubDetailAPIView):
|
||||
It does not support standard credential types such as Machine, SCM, and Cloud."""})
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if obj.credential_type.kind != 'external':
|
||||
raise ParseError(_('Credential is not testable.'))
|
||||
backend_kwargs = {}
|
||||
for field_name, value in obj.inputs.items():
|
||||
backend_kwargs[field_name] = obj.get_input(field_name)
|
||||
@@ -1781,22 +1622,23 @@ class CredentialExternalTest(OIDCCredentialTestMixin, SubDetailAPIView):
|
||||
if value != '$encrypted$':
|
||||
backend_kwargs[field_name] = value
|
||||
backend_kwargs.update(request.data.get('metadata', {}))
|
||||
|
||||
# Handle OIDC workload identity token generation if enabled
|
||||
response_body = {}
|
||||
if flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED') and self._has_workload_identity_token(obj.credential_type.inputs):
|
||||
try:
|
||||
oidc_response_body = self._handle_oidc_credential_test(backend_kwargs)
|
||||
response_body.update(oidc_response_body)
|
||||
except PermissionDenied:
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_message = str(exc.detail) if hasattr(exc, 'detail') else str(exc)
|
||||
response_body['detail'] = error_message
|
||||
response_body['details'] = {'error_message': error_message}
|
||||
return Response(response_body, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return self._call_backend_with_error_handling(obj.credential_type.plugin, backend_kwargs, response_body)
|
||||
try:
|
||||
with set_environ(**settings.AWX_TASK_ENV):
|
||||
obj.credential_type.plugin.backend(**backend_kwargs)
|
||||
return Response({}, status=status.HTTP_202_ACCEPTED)
|
||||
except requests.exceptions.HTTPError:
|
||||
message = """Test operation is not supported for credential type {}.
|
||||
This endpoint only supports credentials that connect to
|
||||
external secret management systems such as CyberArk, HashiCorp
|
||||
Vault, or cloud-based secret managers.""".format(obj.credential_type.kind)
|
||||
return Response({'detail': message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
message = exc.__class__.__name__
|
||||
exc_args = getattr(exc, 'args', [])
|
||||
for a in exc_args:
|
||||
if isinstance(getattr(a, 'reason', None), ConnectTimeoutError):
|
||||
message = str(a.reason)
|
||||
return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class CredentialInputSourceDetail(RetrieveUpdateDestroyAPIView):
|
||||
@@ -1826,7 +1668,7 @@ class CredentialInputSourceSubList(SubListCreateAPIView):
|
||||
parent_key = 'target_credential'
|
||||
|
||||
|
||||
class CredentialTypeExternalTest(OIDCCredentialTestMixin, SubDetailAPIView):
|
||||
class CredentialTypeExternalTest(SubDetailAPIView):
|
||||
"""
|
||||
Test a complete set of input values for an external credential before
|
||||
saving it.
|
||||
@@ -1841,26 +1683,21 @@ class CredentialTypeExternalTest(OIDCCredentialTestMixin, SubDetailAPIView):
|
||||
@extend_schema_if_available(extensions={"x-ai-description": "Test a complete set of input values for an external credential"})
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if obj.kind != 'external':
|
||||
raise ParseError(_('Credential type is not testable.'))
|
||||
backend_kwargs = request.data.get('inputs', {})
|
||||
backend_kwargs.update(request.data.get('metadata', {}))
|
||||
|
||||
# Handle OIDC workload identity token generation if enabled
|
||||
response_body = {}
|
||||
if flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED') and self._has_workload_identity_token(obj.inputs):
|
||||
try:
|
||||
oidc_response_body = self._handle_oidc_credential_test(backend_kwargs)
|
||||
response_body.update(oidc_response_body)
|
||||
except PermissionDenied:
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_message = str(exc.detail) if hasattr(exc, 'detail') else str(exc)
|
||||
response_body['detail'] = error_message
|
||||
response_body['details'] = {'error_message': error_message}
|
||||
return Response(response_body, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return self._call_backend_with_error_handling(obj.plugin, backend_kwargs, response_body)
|
||||
try:
|
||||
obj.plugin.backend(**backend_kwargs)
|
||||
return Response({}, status=status.HTTP_202_ACCEPTED)
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
message = 'HTTP {}'.format(exc.response.status_code)
|
||||
return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
message = exc.__class__.__name__
|
||||
args_exc = getattr(exc, 'args', [])
|
||||
for a in args_exc:
|
||||
if isinstance(getattr(a, 'reason', None), ConnectTimeoutError):
|
||||
message = str(a.reason)
|
||||
return Response({'inputs': message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class HostRelatedSearchMixin(object):
|
||||
@@ -1926,7 +1763,7 @@ class HostList(HostRelatedSearchMixin, ListCreateAPIView):
|
||||
if filter_string:
|
||||
filter_qs = SmartFilter.query_from_string(filter_string)
|
||||
qs &= filter_qs
|
||||
return qs.distinct().with_latest_summary_id()
|
||||
return qs.distinct()
|
||||
|
||||
def list(self, *args, **kwargs):
|
||||
try:
|
||||
@@ -1941,9 +1778,6 @@ class HostDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||
serializer_class = serializers.HostSerializer
|
||||
resource_purpose = 'host detail'
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().with_latest_summary_id()
|
||||
|
||||
@extend_schema_if_available(extensions={"x-ai-description": "Delete a host"})
|
||||
def delete(self, request, *args, **kwargs):
|
||||
if self.get_object().inventory.pending_deletion:
|
||||
@@ -1977,9 +1811,6 @@ class InventoryHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIVie
|
||||
filter_read_permission = False
|
||||
resource_purpose = 'hosts of an inventory'
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().with_latest_summary_id()
|
||||
|
||||
|
||||
class HostGroupsList(SubListCreateAttachDetachAPIView):
|
||||
'''the list of groups a host is directly a member of'''
|
||||
@@ -2163,9 +1994,6 @@ class GroupHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIView):
|
||||
relationship = 'hosts'
|
||||
resource_purpose = 'hosts of a group'
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().with_latest_summary_id()
|
||||
|
||||
def update_raw_data(self, data):
|
||||
data.pop('inventory', None)
|
||||
return super(GroupHostsList, self).update_raw_data(data)
|
||||
@@ -2197,7 +2025,7 @@ class GroupAllHostsList(HostRelatedSearchMixin, SubListAPIView):
|
||||
self.check_parent_access(parent)
|
||||
qs = self.request.user.get_queryset(self.model).distinct() # need distinct for '&' operator
|
||||
sublist_qs = parent.all_hosts.distinct()
|
||||
return (qs & sublist_qs).with_latest_summary_id()
|
||||
return qs & sublist_qs
|
||||
|
||||
|
||||
class GroupInventorySourcesList(SubListAPIView):
|
||||
@@ -2490,9 +2318,6 @@ class InventorySourceHostsList(HostRelatedSearchMixin, SubListDestroyAPIView):
|
||||
check_sub_obj_permission = False
|
||||
resource_purpose = 'hosts of an inventory source'
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().with_latest_summary_id()
|
||||
|
||||
def perform_list_destroy(self, instance_list):
|
||||
inv_source = self.get_parent_object()
|
||||
with ignore_inventory_computed_fields():
|
||||
@@ -3052,7 +2877,8 @@ class JobTemplateCallback(GenericAPIView):
|
||||
host for the current request.
|
||||
"""
|
||||
# Find the list of remote host names/IPs to check.
|
||||
remote_hosts = set(get_remote_hosts(self.request))
|
||||
# Only consider the first entry from each header (for comma-separated values like X-Forwarded-For)
|
||||
remote_hosts = get_first_remote_host_from_headers(self.request, settings.REMOTE_HOST_HEADERS)
|
||||
# Add the reverse lookup of IP addresses.
|
||||
for rh in list(remote_hosts):
|
||||
try:
|
||||
@@ -4870,12 +4696,19 @@ class RoleUsersList(SubListAttachDetachAPIView):
|
||||
if not sub_id:
|
||||
return super(RoleUsersList, self).post(request)
|
||||
|
||||
if not request.data.get('disassociate'):
|
||||
user = get_object_or_400(models.User, pk=sub_id)
|
||||
role = self.get_parent_object()
|
||||
content_object = role.content_object
|
||||
if hasattr(content_object, 'validate_role_assignment'):
|
||||
content_object.validate_role_assignment(user, role_definition=None, requesting_user=request.user)
|
||||
user = get_object_or_400(models.User, pk=sub_id)
|
||||
role = self.get_parent_object()
|
||||
|
||||
content_types = ContentType.objects.get_for_models(models.Organization, models.Team, models.Credential) # dict of {model: content_type}
|
||||
credential_content_type = content_types[models.Credential]
|
||||
if role.content_type == credential_content_type:
|
||||
if 'disassociate' not in request.data and role.content_object.organization and user not in role.content_object.organization.member_role:
|
||||
data = dict(msg=_("You cannot grant credential access to a user not in the credentials' organization"))
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not role.content_object.organization and not request.user.is_superuser:
|
||||
data = dict(msg=_("You cannot grant private credential access to another user"))
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return super(RoleUsersList, self).post(request, *args, **kwargs)
|
||||
|
||||
@@ -4908,6 +4741,24 @@ class RoleTeamsList(SubListAttachDetachAPIView):
|
||||
data = dict(msg=_("You cannot assign an Organization participation role as a child role for a Team."))
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
credential_content_type = ContentType.objects.get_for_model(models.Credential)
|
||||
if role.content_type == credential_content_type:
|
||||
# Private credentials (no organization) are never allowed for teams
|
||||
if not role.content_object.organization:
|
||||
data = dict(
|
||||
msg=_("You cannot grant access to a credential that is not assigned to an organization (private credentials cannot be assigned to teams)")
|
||||
)
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Cross-organization credentials are only allowed for superusers
|
||||
elif role.content_object.organization.id != team.organization.id:
|
||||
if not request.user.is_superuser:
|
||||
data = dict(
|
||||
msg=_(
|
||||
"You cannot grant a team access to a credential in a different organization. Only superusers can grant cross-organization credential access to teams"
|
||||
)
|
||||
)
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
action = 'attach'
|
||||
if request.data.get('disassociate', None):
|
||||
action = 'unattach'
|
||||
@@ -4916,11 +4767,6 @@ class RoleTeamsList(SubListAttachDetachAPIView):
|
||||
data = dict(msg=_("You cannot grant system-level permissions to a team."))
|
||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if action == 'attach':
|
||||
content_object = role.content_object
|
||||
if hasattr(content_object, 'validate_role_assignment'):
|
||||
content_object.validate_role_assignment(team, role_definition=None, requesting_user=request.user)
|
||||
|
||||
if not request.user.can_access(self.parent_model, action, role, team, self.relationship, request.data, skip_sub_obj_read_check=False):
|
||||
raise PermissionDenied()
|
||||
if request.data.get('disassociate', None):
|
||||
|
||||
@@ -49,6 +49,7 @@ class GetNotAllowedMixin(object):
|
||||
class AnalyticsRootView(APIView):
|
||||
permission_classes = (AnalyticsPermission,)
|
||||
name = _('Automation Analytics')
|
||||
swagger_topic = 'Automation Analytics'
|
||||
resource_purpose = 'automation analytics endpoints'
|
||||
|
||||
@extend_schema_if_available(extensions={"x-ai-description": "A list of additional API endpoints related to analytics"})
|
||||
@@ -305,6 +306,7 @@ class AnalyticsAuthorizedView(AnalyticsGenericListView):
|
||||
|
||||
class AnalyticsReportsList(GetNotAllowedMixin, AnalyticsGenericListView):
|
||||
name = _("Reports")
|
||||
swagger_topic = "Automation Analytics"
|
||||
resource_purpose = 'automation analytics reports'
|
||||
|
||||
|
||||
|
||||
@@ -344,22 +344,13 @@ class ApiV2ConfigView(APIView):
|
||||
become_methods=PRIVILEGE_ESCALATION_METHODS,
|
||||
)
|
||||
|
||||
# Check superuser/auditor first
|
||||
if request.user.is_superuser or request.user.is_system_auditor:
|
||||
has_org_access = True
|
||||
else:
|
||||
# Single query checking all three organization role types at once
|
||||
has_org_access = (
|
||||
(
|
||||
Organization.access_qs(request.user, 'change')
|
||||
| Organization.access_qs(request.user, 'audit')
|
||||
| Organization.access_qs(request.user, 'add_project')
|
||||
)
|
||||
.distinct()
|
||||
.exists()
|
||||
)
|
||||
|
||||
if has_org_access:
|
||||
if (
|
||||
request.user.is_superuser
|
||||
or request.user.is_system_auditor
|
||||
or Organization.accessible_objects(request.user, 'admin_role').exists()
|
||||
or Organization.accessible_objects(request.user, 'auditor_role').exists()
|
||||
or Organization.accessible_objects(request.user, 'project_admin_role').exists()
|
||||
):
|
||||
data.update(
|
||||
dict(
|
||||
project_base_dir=settings.PROJECTS_ROOT,
|
||||
@@ -367,10 +358,8 @@ class ApiV2ConfigView(APIView):
|
||||
custom_virtualenvs=get_custom_venv_choices(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Only check JobTemplate access if org check failed
|
||||
if JobTemplate.accessible_objects(request.user, 'admin_role').exists():
|
||||
data['custom_virtualenvs'] = get_custom_venv_choices()
|
||||
elif JobTemplate.accessible_objects(request.user, 'admin_role').exists():
|
||||
data['custom_virtualenvs'] = get_custom_venv_choices()
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from awx.api import serializers
|
||||
from awx.api.generics import APIView, GenericAPIView
|
||||
from awx.api.permissions import WebhookKeyPermission
|
||||
from awx.main.models import Job, JobTemplate, WorkflowJob, WorkflowJobTemplate
|
||||
from awx.main.utils.common import get_job_variable_prefixes
|
||||
from awx.main.constants import JOB_VARIABLE_PREFIXES
|
||||
|
||||
logger = logging.getLogger('awx.api.views.webhooks')
|
||||
|
||||
@@ -166,7 +166,7 @@ class WebhookReceiverBase(APIView):
|
||||
'extra_vars': {},
|
||||
}
|
||||
|
||||
for name in get_job_variable_prefixes():
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
kwargs['extra_vars']['{}_webhook_event_type'.format(name)] = event_type
|
||||
kwargs['extra_vars']['{}_webhook_event_guid'.format(name)] = event_guid
|
||||
kwargs['extra_vars']['{}_webhook_event_ref'.format(name)] = event_ref
|
||||
|
||||
@@ -897,6 +897,8 @@ class HostAccess(BaseAccess):
|
||||
'created_by',
|
||||
'modified_by',
|
||||
'inventory',
|
||||
'last_job__job_template',
|
||||
'last_job_host_summary__job',
|
||||
)
|
||||
prefetch_related = ('groups', 'inventory_sources')
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import pathlib
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
@@ -24,8 +23,6 @@ from awx.main.models import Job
|
||||
from awx.main.access import access_registry
|
||||
from awx.main.utils import get_awx_http_client_headers, set_environ, datetime_hook
|
||||
from awx.main.utils.analytics_proxy import OIDCClient
|
||||
from awx.main.utils.candlepin import get_or_generate_candlepin_certificate
|
||||
from awx.main.utils.candlepin.client import _temp_cert_files
|
||||
|
||||
__all__ = ['register', 'gather', 'ship']
|
||||
|
||||
@@ -44,76 +41,6 @@ def _valid_license():
|
||||
return True
|
||||
|
||||
|
||||
def _get_cert_upload_url(url):
|
||||
"""
|
||||
Convert analytics URL to use 'cert.' subdomain for mTLS uploads.
|
||||
|
||||
Some analytics services use different hostnames for different auth methods:
|
||||
- cert.example.com - for mTLS (certificate-based) uploads
|
||||
- example.com - for OIDC (token-based) uploads
|
||||
|
||||
Args:
|
||||
url: Original analytics URL
|
||||
|
||||
Returns:
|
||||
URL with 'cert.' prepended to hostname if not already present
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
# Only modify if hostname doesn't already start with 'cert.'
|
||||
if hostname and not hostname.startswith('cert.'):
|
||||
new_hostname = f'cert.{hostname}'
|
||||
# Reconstruct URL with new hostname
|
||||
netloc = new_hostname
|
||||
if parsed.port:
|
||||
netloc = f'{new_hostname}:{parsed.port}'
|
||||
|
||||
new_parsed = parsed._replace(netloc=netloc)
|
||||
return urlunparse(new_parsed)
|
||||
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not modify URL for cert upload: {e}, using original URL')
|
||||
return url
|
||||
|
||||
|
||||
def _get_analytics_credentials():
|
||||
"""
|
||||
Get Red Hat Insights credentials from settings.
|
||||
|
||||
Attempts to retrieve credentials in the following priority order:
|
||||
1. REDHAT_USERNAME / REDHAT_PASSWORD
|
||||
2. SUBSCRIPTIONS_USERNAME / SUBSCRIPTIONS_PASSWORD
|
||||
3. SUBSCRIPTIONS_CLIENT_ID / SUBSCRIPTIONS_CLIENT_SECRET
|
||||
|
||||
Returns:
|
||||
tuple: (username, password) if credentials are found, (None, None) otherwise
|
||||
"""
|
||||
rh_id = getattr(settings, 'REDHAT_USERNAME', None)
|
||||
rh_secret = getattr(settings, 'REDHAT_PASSWORD', None)
|
||||
|
||||
if rh_id and rh_secret:
|
||||
return rh_id, rh_secret
|
||||
|
||||
# Try SUBSCRIPTIONS_USERNAME / SUBSCRIPTIONS_PASSWORD
|
||||
rh_id = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None)
|
||||
rh_secret = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None)
|
||||
|
||||
if rh_id and rh_secret:
|
||||
return rh_id, rh_secret
|
||||
|
||||
# Try SUBSCRIPTIONS_CLIENT_ID / SUBSCRIPTIONS_CLIENT_SECRET
|
||||
rh_id = getattr(settings, 'SUBSCRIPTIONS_CLIENT_ID', None)
|
||||
rh_secret = getattr(settings, 'SUBSCRIPTIONS_CLIENT_SECRET', None)
|
||||
|
||||
if rh_id and rh_secret:
|
||||
return rh_id, rh_secret
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def all_collectors():
|
||||
from awx.main.analytics import collectors
|
||||
|
||||
@@ -257,8 +184,10 @@ def gather(dest=None, module=None, subset=None, since=None, until=None, collecti
|
||||
logger.log(log_level, "Automation Analytics not enabled. Use --dry-run to gather locally without sending.")
|
||||
return None
|
||||
|
||||
rh_id, rh_secret = _get_analytics_credentials()
|
||||
if not (settings.AUTOMATION_ANALYTICS_URL and rh_id and rh_secret):
|
||||
if not (
|
||||
settings.AUTOMATION_ANALYTICS_URL
|
||||
and ((settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD) or (settings.SUBSCRIPTIONS_CLIENT_ID and settings.SUBSCRIPTIONS_CLIENT_SECRET))
|
||||
):
|
||||
logger.log(log_level, "Not gathering analytics, configuration is invalid. Use --dry-run to gather locally without sending.")
|
||||
return None
|
||||
|
||||
@@ -439,14 +368,19 @@ def ship(path):
|
||||
logger.error('AUTOMATION_ANALYTICS_URL is not set')
|
||||
return False
|
||||
|
||||
rh_id, rh_secret = _get_analytics_credentials()
|
||||
rh_id = getattr(settings, 'REDHAT_USERNAME', None)
|
||||
rh_secret = getattr(settings, 'REDHAT_PASSWORD', None)
|
||||
|
||||
if not (rh_id and rh_secret):
|
||||
rh_id = getattr(settings, 'SUBSCRIPTIONS_CLIENT_ID', None)
|
||||
rh_secret = getattr(settings, 'SUBSCRIPTIONS_CLIENT_SECRET', None)
|
||||
|
||||
if not rh_id:
|
||||
logger.error('No valid username found. Tried: REDHAT_USERNAME, SUBSCRIPTIONS_USERNAME, SUBSCRIPTIONS_CLIENT_ID')
|
||||
logger.error('Neither REDHAT_USERNAME nor SUBSCRIPTIONS_CLIENT_ID are set')
|
||||
return False
|
||||
|
||||
if not rh_secret:
|
||||
logger.error('No valid password found. Tried: REDHAT_PASSWORD, SUBSCRIPTIONS_PASSWORD, SUBSCRIPTIONS_CLIENT_SECRET')
|
||||
logger.error('Neither REDHAT_PASSWORD nor SUBSCRIPTIONS_CLIENT_SECRET are set')
|
||||
return False
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
@@ -454,40 +388,17 @@ def ship(path):
|
||||
s = requests.Session()
|
||||
s.headers = get_awx_http_client_headers()
|
||||
s.headers.pop('Content-Type')
|
||||
|
||||
with set_environ(**settings.AWX_TASK_ENV):
|
||||
# Try Certificate-based mTLS authentication (zero-touch)
|
||||
cert_pem, key_pem = get_or_generate_candlepin_certificate()
|
||||
if cert_pem and key_pem:
|
||||
# Use cert. subdomain for mTLS uploads
|
||||
cert_url = _get_cert_upload_url(url)
|
||||
logger.debug("Attempting certificate-based authentication for analytics upload")
|
||||
try:
|
||||
with _temp_cert_files(cert_pem, key_pem) as (cert_path, key_path):
|
||||
response = s.post(
|
||||
cert_url, files=files, cert=(cert_path, key_path), verify=settings.INSIGHTS_CERT_PATH, headers=s.headers, timeout=(31, 31)
|
||||
)
|
||||
if response.status_code < 300:
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f'Certificate-based authentication failed with status {response.status_code}, {response.text}. Falling back to OIDC auth'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Certificate-based authentication failed: {e}, falling back to OIDC auth")
|
||||
|
||||
# Try OIDC authentication
|
||||
logger.debug("Attempting OIDC authentication for analytics upload")
|
||||
f.seek(0) # requests POST may read from the handler, so seek to beginning of file for the next POST attempt
|
||||
try:
|
||||
client = OIDCClient(rh_id, rh_secret)
|
||||
response = client.make_request("POST", url, headers=s.headers, files=files, verify=settings.INSIGHTS_CERT_PATH, timeout=(31, 31))
|
||||
except requests.RequestException:
|
||||
logger.error("Automation Analytics API request failed, trying base auth method")
|
||||
response = s.post(url, files=files, verify=settings.INSIGHTS_CERT_PATH, auth=(rh_id, rh_secret), headers=s.headers, timeout=(31, 31))
|
||||
|
||||
if response.status_code < 300:
|
||||
return True
|
||||
else:
|
||||
logger.error(f'OIDC authentication failed with status {response.status_code}, {response.text}')
|
||||
return False
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"OIDC authentication failed: {e}")
|
||||
return False
|
||||
# Accept 2XX status_codes
|
||||
if response.status_code >= 300:
|
||||
logger.error('Upload failed with status {}, {}'.format(response.status_code, response.text))
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import os
|
||||
|
||||
from dispatcherd.config import setup as dispatcher_setup
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db import connection
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.management.base import CommandError
|
||||
from django.db.models.signals import pre_migrate
|
||||
|
||||
from awx.main.utils.common import bypass_in_test, load_all_entry_points_for
|
||||
from awx.main.utils.migration import is_database_synchronized
|
||||
from awx.main.utils.named_url_graph import _customize_graph, generate_graph
|
||||
from awx.main.utils.db import db_requirement_violations
|
||||
from awx.conf import register, fields
|
||||
|
||||
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
|
||||
|
||||
|
||||
class MainConfig(AppConfig):
|
||||
name = 'awx.main'
|
||||
verbose_name = _('Main')
|
||||
|
||||
def check_db_requirement(self, *args, **kwargs):
|
||||
violations = db_requirement_violations()
|
||||
if violations:
|
||||
raise CommandError(violations)
|
||||
|
||||
def load_named_url_feature(self):
|
||||
models = [m for m in self.get_models() if hasattr(m, 'get_absolute_url')]
|
||||
generate_graph(models)
|
||||
@@ -46,6 +43,42 @@ class MainConfig(AppConfig):
|
||||
category_slug='named-url',
|
||||
)
|
||||
|
||||
def _load_credential_types_feature(self):
|
||||
"""
|
||||
Create CredentialType records for any discovered credentials.
|
||||
|
||||
Note that Django docs advise _against_ interacting with the database using
|
||||
the ORM models in the ready() path. Specifically, during testing.
|
||||
However, we explicitly use the @bypass_in_test decorator to avoid calling this
|
||||
method during testing.
|
||||
|
||||
Django also advises against running pattern because it runs everywhere i.e.
|
||||
every management command. We use an advisory lock to ensure correctness and
|
||||
we will deal performance if it becomes an issue.
|
||||
"""
|
||||
from awx.main.models.credential import CredentialType
|
||||
|
||||
if is_database_synchronized():
|
||||
CredentialType.setup_tower_managed_defaults(app_config=self)
|
||||
|
||||
@bypass_in_test
|
||||
def load_credential_types_feature(self):
|
||||
from awx.main.models.credential import load_credentials
|
||||
|
||||
load_credentials()
|
||||
return self._load_credential_types_feature()
|
||||
|
||||
def load_inventory_plugins(self):
|
||||
from awx.main.models.inventory import InventorySourceOptions
|
||||
|
||||
is_awx = detect_server_product_name() == 'AWX'
|
||||
extra_entry_point_groups = () if is_awx else ('inventory.supported',)
|
||||
entry_points = load_all_entry_points_for(['inventory', *extra_entry_point_groups])
|
||||
|
||||
for entry_point_name, entry_point in entry_points.items():
|
||||
cls = entry_point.load()
|
||||
InventorySourceOptions.injectors[entry_point_name] = cls
|
||||
|
||||
def configure_dispatcherd(self):
|
||||
"""This implements the default configuration for dispatcherd
|
||||
|
||||
@@ -67,5 +100,13 @@ class MainConfig(AppConfig):
|
||||
super().ready()
|
||||
|
||||
self.configure_dispatcherd()
|
||||
|
||||
"""
|
||||
Credential loading triggers database operations. There are cases we want to call
|
||||
awx-manage collectstatic without a database. All management commands invoke the ready() code
|
||||
path. Using settings.AWX_SKIP_CREDENTIAL_TYPES_DISCOVER _could_ invoke a database operation.
|
||||
"""
|
||||
if not os.environ.get('AWX_SKIP_CREDENTIAL_TYPES_DISCOVER', None):
|
||||
self.load_credential_types_feature()
|
||||
self.load_named_url_feature()
|
||||
pre_migrate.connect(self.check_db_requirement, sender=self)
|
||||
self.load_inventory_plugins()
|
||||
|
||||
87
awx/main/cache.py
Normal file
87
awx/main/cache.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import functools
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache.backends.base import DEFAULT_TIMEOUT
|
||||
from django.core.cache.backends.redis import RedisCache
|
||||
|
||||
from redis.exceptions import ConnectionError, ResponseError, TimeoutError
|
||||
import socket
|
||||
|
||||
# This list comes from what django-redis ignores and the behavior we are trying
|
||||
# to retain while dropping the dependency on django-redis.
|
||||
IGNORED_EXCEPTIONS = (TimeoutError, ResponseError, ConnectionError, socket.timeout)
|
||||
|
||||
CONNECTION_INTERRUPTED_SENTINEL = object()
|
||||
|
||||
|
||||
def optionally_ignore_exceptions(func=None, return_value=None):
|
||||
if func is None:
|
||||
return functools.partial(optionally_ignore_exceptions, return_value=return_value)
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except IGNORED_EXCEPTIONS as e:
|
||||
if settings.DJANGO_REDIS_IGNORE_EXCEPTIONS:
|
||||
return return_value
|
||||
raise e.__cause__ or e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class AWXRedisCache(RedisCache):
|
||||
"""
|
||||
We just want to wrap the upstream RedisCache class so that we can ignore
|
||||
the exceptions that it raises when the cache is unavailable.
|
||||
"""
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
|
||||
return super().add(key, value, timeout, version)
|
||||
|
||||
@optionally_ignore_exceptions(return_value=CONNECTION_INTERRUPTED_SENTINEL)
|
||||
def _get(self, key, default=None, version=None):
|
||||
return super().get(key, default, version)
|
||||
|
||||
def get(self, key, default=None, version=None):
|
||||
value = self._get(key, default, version)
|
||||
if value is CONNECTION_INTERRUPTED_SENTINEL:
|
||||
return default
|
||||
return value
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
|
||||
return super().set(key, value, timeout, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
|
||||
return super().touch(key, timeout, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def delete(self, key, version=None):
|
||||
return super().delete(key, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def get_many(self, keys, version=None):
|
||||
return super().get_many(keys, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def has_key(self, key, version=None):
|
||||
return super().has_key(key, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def incr(self, key, delta=1, version=None):
|
||||
return super().incr(key, delta, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None):
|
||||
return super().set_many(data, timeout, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def delete_many(self, keys, version=None):
|
||||
return super().delete_many(keys, version)
|
||||
|
||||
@optionally_ignore_exceptions
|
||||
def clear(self):
|
||||
return super().clear()
|
||||
102
awx/main/conf.py
102
awx/main/conf.py
@@ -213,40 +213,6 @@ register(
|
||||
category_slug='system',
|
||||
)
|
||||
|
||||
register(
|
||||
'AWX_ANALYTICS_CANDLEPIN_CA',
|
||||
field_class=fields.CharField,
|
||||
default='/etc/rhsm/ca/redhat-uep.pem',
|
||||
allow_blank=True,
|
||||
label=_('Candlepin CA Certificate Path'),
|
||||
help_text=_('Path to the CA certificate file for verifying TLS connections to Candlepin. Leave blank to use system certificates.'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
)
|
||||
|
||||
register(
|
||||
'AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS',
|
||||
field_class=fields.IntegerField,
|
||||
default=90,
|
||||
min_value=1,
|
||||
label=_('Candlepin Certificate Renewal Threshold'),
|
||||
help_text=_('Number of days before certificate expiry to trigger automatic renewal of Candlepin identity certificates.'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
unit=_('days'),
|
||||
)
|
||||
|
||||
register(
|
||||
'AWX_ANALYTICS_CANDLEPIN_PROXY_URL',
|
||||
field_class=fields.CharField,
|
||||
default='',
|
||||
allow_blank=True,
|
||||
label=_('Candlepin Proxy URL'),
|
||||
help_text=_('HTTP/HTTPS proxy URL for Candlepin API requests (e.g., http://proxy.example.com:8080). Leave blank for no proxy.'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
)
|
||||
|
||||
register(
|
||||
'INSTALL_UUID',
|
||||
field_class=fields.CharField,
|
||||
@@ -325,22 +291,6 @@ register(
|
||||
category_slug='jobs',
|
||||
)
|
||||
|
||||
register(
|
||||
'INCLUDE_DEPRECATED_AWX_VAR_PREFIX',
|
||||
field_class=fields.BooleanField,
|
||||
default=True,
|
||||
label=_('Include Deprecated AWX Variable Prefix'),
|
||||
help_text=_(
|
||||
'When enabled (default), auto-generated job variables are emitted '
|
||||
'with both the tower_ prefix and the deprecated awx_ prefix for '
|
||||
'backward compatibility. Disable to emit only tower_ prefixed '
|
||||
'variables and eliminate duplicates. The awx_ prefix is deprecated '
|
||||
'and this setting will default to False in a future release.'
|
||||
),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
)
|
||||
|
||||
register(
|
||||
'AWX_ISOLATION_BASE_PATH',
|
||||
field_class=fields.CharField,
|
||||
@@ -874,58 +824,6 @@ register(
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
'CANDLEPIN_CONSUMER_UUID',
|
||||
field_class=fields.CharField,
|
||||
default='',
|
||||
allow_blank=True,
|
||||
encrypted=False,
|
||||
label=_('Candlepin Consumer UUID'),
|
||||
help_text=_('UUID of the registered Candlepin consumer for this AAP instance.'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
register(
|
||||
'CANDLEPIN_CERT_PEM',
|
||||
field_class=fields.CharField,
|
||||
default='',
|
||||
allow_blank=True,
|
||||
encrypted=True,
|
||||
label=_('Candlepin Identity Certificate'),
|
||||
help_text=_('PEM-encoded Candlepin identity certificate for mTLS authentication.'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
register(
|
||||
'CANDLEPIN_KEY_PEM',
|
||||
field_class=fields.CharField,
|
||||
default='',
|
||||
allow_blank=True,
|
||||
encrypted=True,
|
||||
label=_('Candlepin Identity Key'),
|
||||
help_text=_('PEM-encoded private key for Candlepin identity certificate.'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
register(
|
||||
'CANDLEPIN_SERIAL_NUMBER',
|
||||
field_class=fields.CharField,
|
||||
default='',
|
||||
allow_blank=True,
|
||||
encrypted=False,
|
||||
label=_('Candlepin Certificate Serial Number'),
|
||||
help_text=_('Serial number of the Candlepin identity certificate for tracking.'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
register(
|
||||
'IS_K8S',
|
||||
field_class=fields.BooleanField,
|
||||
|
||||
@@ -11,7 +11,6 @@ __all__ = [
|
||||
'CAN_CANCEL',
|
||||
'ACTIVE_STATES',
|
||||
'STANDARD_INVENTORY_UPDATE_ENV',
|
||||
'OIDC_CREDENTIAL_TYPE_NAMESPACES',
|
||||
]
|
||||
|
||||
PRIVILEGE_ESCALATION_METHODS = [
|
||||
@@ -100,6 +99,10 @@ MAX_ISOLATED_PATH_COLON_DELIMITER = 2
|
||||
|
||||
SURVEY_TYPE_MAPPING = {'text': str, 'textarea': str, 'password': str, 'multiplechoice': str, 'multiselect': str, 'integer': int, 'float': (float, int)}
|
||||
|
||||
JOB_VARIABLE_PREFIXES = [
|
||||
'awx',
|
||||
'tower',
|
||||
]
|
||||
|
||||
# Note, the \u001b[... are ansi color codes. We don't currenly import any of the python modules which define the codes.
|
||||
# Importing a library just for this message seemed like overkill
|
||||
@@ -137,6 +140,3 @@ org_role_to_permission = {
|
||||
'execution_environment_admin_role': 'add_executionenvironment',
|
||||
'auditor_role': 'view_project', # TODO: also doesnt really work
|
||||
}
|
||||
|
||||
# OIDC credential type namespaces for feature flag filtering
|
||||
OIDC_CREDENTIAL_TYPE_NAMESPACES = ['hashivault-kv-oidc', 'hashivault-ssh-oidc']
|
||||
|
||||
@@ -25,13 +25,8 @@ def get_dispatcherd_config(for_service: bool = False, mock_publish: bool = False
|
||||
"version": 2,
|
||||
"service": {
|
||||
"pool_kwargs": {
|
||||
"min_workers": settings.DISPATCHER_MIN_WORKERS,
|
||||
"min_workers": settings.JOB_EVENT_WORKERS,
|
||||
"max_workers": max_workers,
|
||||
# This must be less than max_workers to make sense, which is usually 4
|
||||
# With reserve of 1, after a burst of tasks, load needs to down to 4-1=3
|
||||
# before we return to min_workers
|
||||
"scaledown_reserve": 1,
|
||||
"worker_max_lifetime_seconds": settings.WORKER_MAX_LIFETIME_SECONDS,
|
||||
},
|
||||
"main_kwargs": {"node_id": settings.CLUSTER_HOST_ID},
|
||||
"process_manager_cls": "ForkServerManager",
|
||||
|
||||
@@ -77,13 +77,13 @@ class CallbackBrokerWorker:
|
||||
|
||||
MAX_RETRIES = 2
|
||||
INDIVIDUAL_EVENT_RETRIES = 3
|
||||
last_stats = time.time()
|
||||
last_flush = time.time()
|
||||
total = 0
|
||||
last_event = ''
|
||||
prof = None
|
||||
|
||||
def __init__(self):
|
||||
self.last_stats = time.time()
|
||||
self.last_flush = time.time()
|
||||
self.buff = {}
|
||||
self.redis = get_redis_client()
|
||||
self.subsystem_metrics = s_metrics.CallbackReceiverMetrics(auto_pipe_execute=False)
|
||||
|
||||
@@ -428,9 +428,6 @@ class CredentialInputField(JSONSchemaField):
|
||||
# determine the defined fields for the associated credential type
|
||||
properties = {}
|
||||
for field in model_instance.credential_type.inputs.get('fields', []):
|
||||
# Prevent users from providing values for internally resolved fields
|
||||
if 'internal' in field:
|
||||
continue
|
||||
field = field.copy()
|
||||
properties[field['id']] = field
|
||||
if field.get('choices', []):
|
||||
@@ -569,7 +566,6 @@ class CredentialTypeInputField(JSONSchemaField):
|
||||
},
|
||||
'label': {'type': 'string'},
|
||||
'help_text': {'type': 'string'},
|
||||
'internal': {'type': 'boolean'},
|
||||
'multiline': {'type': 'boolean'},
|
||||
'secret': {'type': 'boolean'},
|
||||
'ask_at_runtime': {'type': 'boolean'},
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import sys
|
||||
|
||||
from argparse import RawDescriptionHelpFormatter
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from awx.main.utils.candlepin.client import CandlepinClient
|
||||
from awx.main.utils.candlepin.lifecycle import (
|
||||
get_candlepin_ca,
|
||||
get_candlepin_url,
|
||||
get_proxy_url,
|
||||
get_renewal_days,
|
||||
needs_renewal,
|
||||
parse_cert,
|
||||
)
|
||||
from awx.main.utils.candlepin import (
|
||||
_fetch_candlepin_cert_from_db,
|
||||
_save_candlepin_cert_to_db,
|
||||
_save_candlepin_registration_to_db,
|
||||
resolve_registration_credentials,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Manage Candlepin consumer registration and certificate lifecycle.
|
||||
|
||||
Subcommands:
|
||||
register Register this AAP instance as a Candlepin consumer and obtain an
|
||||
identity certificate for mTLS analytics uploads.
|
||||
renew Perform a manual check-in and, if needed, renew the stored identity
|
||||
certificate.
|
||||
"""
|
||||
|
||||
help = 'Manage Candlepin consumer registration and certificate lifecycle'
|
||||
|
||||
def create_parser(self, prog_name, subcommand, **kwargs):
|
||||
return super().create_parser(
|
||||
prog_name,
|
||||
subcommand,
|
||||
formatter_class=RawDescriptionHelpFormatter,
|
||||
epilog='\n'.join(
|
||||
[
|
||||
'SUBCOMMANDS',
|
||||
'',
|
||||
' register Register this instance as a Candlepin consumer.',
|
||||
' Credentials are read from AWX database by default',
|
||||
' (REDHAT_USERNAME, REDHAT_PASSWORD). The organization is',
|
||||
' discovered automatically from the Candlepin account.',
|
||||
' Pass --username / --password-stdin / --org to override.',
|
||||
' Example: echo "password" | awx-manage candlepin_cert register --username user --password-stdin',
|
||||
'',
|
||||
' renew Perform a manual check-in and proactive cert renewal.',
|
||||
' Reads the stored cert/key/UUID from database.',
|
||||
' Use --force to renew even if the cert is not near expiry.',
|
||||
'',
|
||||
'CONFIGURATION',
|
||||
'',
|
||||
' Settings can be configured via Django settings (awx/settings/defaults.py):',
|
||||
'',
|
||||
' AWX_ANALYTICS_CANDLEPIN_URL Candlepin base URL',
|
||||
' (default: https://subscription.example.com/candlepin)',
|
||||
' AWX_ANALYTICS_CANDLEPIN_CA Path to Candlepin CA cert for TLS verification',
|
||||
' AWX_ANALYTICS_CANDLEPIN_RENEWAL_THRESHOLD_DAYS Days before expiry to trigger renewal (default: 90)',
|
||||
' AWX_ANALYTICS_CANDLEPIN_PROXY_URL HTTP/HTTPS proxy for Candlepin API calls',
|
||||
]
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
subparsers = parser.add_subparsers(dest='subcommand', metavar='subcommand')
|
||||
subparsers.required = True
|
||||
|
||||
# --- register ---
|
||||
reg = subparsers.add_parser(
|
||||
'register',
|
||||
help='Register this instance as a Candlepin consumer',
|
||||
formatter_class=RawDescriptionHelpFormatter,
|
||||
)
|
||||
reg.add_argument('--username', help='Red Hat subscription username (overrides REDHAT_USERNAME from database)')
|
||||
reg.add_argument(
|
||||
'--password-stdin', dest='password_stdin', action='store_true', help='Read password from stdin (overrides REDHAT_PASSWORD from database)'
|
||||
)
|
||||
reg.add_argument('--org', help='Candlepin owner/org key (overrides auto-discovered organization)')
|
||||
reg.add_argument('--candlepin-url', dest='candlepin_url', help='Candlepin base URL (overrides AWX_ANALYTICS_CANDLEPIN_URL setting)')
|
||||
reg.add_argument(
|
||||
'--candlepin-ca', dest='candlepin_ca', help='Path to Candlepin CA cert for TLS verification (overrides AWX_ANALYTICS_CANDLEPIN_CA setting)'
|
||||
)
|
||||
reg.add_argument('--proxy', help='HTTP/HTTPS proxy URL (overrides AWX_ANALYTICS_CANDLEPIN_PROXY_URL setting)')
|
||||
reg.add_argument('--no-verify-tls', dest='no_verify_tls', action='store_true', help='Disable TLS certificate verification for Candlepin API calls')
|
||||
reg.add_argument('--force', action='store_true', help='Re-register even if a certificate already exists in database')
|
||||
reg.add_argument('--dry-run', dest='dry_run', action='store_true', help='Perform registration but do not save the result to database')
|
||||
|
||||
# --- renew ---
|
||||
ren = subparsers.add_parser(
|
||||
'renew',
|
||||
help='Check in and renew the Candlepin identity certificate',
|
||||
formatter_class=RawDescriptionHelpFormatter,
|
||||
)
|
||||
ren.add_argument('--candlepin-url', dest='candlepin_url', help='Candlepin base URL (overrides AWX_ANALYTICS_CANDLEPIN_URL setting)')
|
||||
ren.add_argument(
|
||||
'--candlepin-ca', dest='candlepin_ca', help='Path to Candlepin CA cert for TLS verification (overrides AWX_ANALYTICS_CANDLEPIN_CA setting)'
|
||||
)
|
||||
ren.add_argument('--proxy', help='HTTP/HTTPS proxy URL (overrides AWX_ANALYTICS_CANDLEPIN_PROXY_URL setting)')
|
||||
ren.add_argument('--no-verify-tls', dest='no_verify_tls', action='store_true', help='Disable TLS certificate verification for Candlepin API calls')
|
||||
ren.add_argument('--force', action='store_true', help='Renew the certificate even if it is not near expiry')
|
||||
ren.add_argument('--dry-run', dest='dry_run', action='store_true', help='Perform check-in and renewal but do not save the result to database')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
subcommand = options['subcommand']
|
||||
if subcommand == 'register':
|
||||
ok = self._handle_register(options)
|
||||
elif subcommand == 'renew':
|
||||
ok = self._handle_renew(options)
|
||||
else:
|
||||
self.stderr.write(f'Unknown subcommand: {subcommand}')
|
||||
sys.exit(1)
|
||||
|
||||
if not ok:
|
||||
sys.exit(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# register
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_and_validate_credentials(self, options):
|
||||
"""Merge CLI options with DB values and validate all required fields are present.
|
||||
|
||||
Returns ``(username, password, org, db_install_uuid)`` on success, or ``None``
|
||||
if any required field is missing (errors are written to ``self.stderr``).
|
||||
"""
|
||||
username_override = options.get('username')
|
||||
org_override = options.get('org')
|
||||
verify_tls = not options.get('no_verify_tls', False)
|
||||
|
||||
# Read password from stdin if --password-stdin is set
|
||||
if options.get('password_stdin'):
|
||||
password_override = sys.stdin.read().strip()
|
||||
if not password_override:
|
||||
self.stderr.write('--password-stdin specified but no password provided on stdin')
|
||||
return None
|
||||
else:
|
||||
password_override = None
|
||||
|
||||
# Use shared resolution and validation function
|
||||
username, password, org, install_uuid, errors = resolve_registration_credentials(
|
||||
username_override=username_override, password_override=password_override, org_override=org_override, verify_tls=verify_tls
|
||||
)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
self.stderr.write(f'Missing required value: {error}')
|
||||
return None
|
||||
|
||||
return username, password, org, install_uuid
|
||||
|
||||
def _handle_register(self, options):
|
||||
dry_run = options['dry_run']
|
||||
force = options['force']
|
||||
|
||||
# Check whether a cert is already stored unless --force.
|
||||
existing_cert, existing_key, _ = _fetch_candlepin_cert_from_db()
|
||||
if existing_cert and existing_key and not force:
|
||||
self.stdout.write('A Candlepin identity certificate is already stored in database. Use --force to re-register and replace it.')
|
||||
return True
|
||||
|
||||
# Resolve credentials: CLI flags take precedence over database.
|
||||
resolved = self._resolve_and_validate_credentials(options)
|
||||
if resolved is None:
|
||||
return False
|
||||
username, password, org, db_install_uuid = resolved
|
||||
|
||||
candlepin_url = options.get('candlepin_url') or get_candlepin_url()
|
||||
candlepin_ca = options.get('candlepin_ca') or get_candlepin_ca()
|
||||
proxy = options.get('proxy') or get_proxy_url()
|
||||
verify_tls = not options.get('no_verify_tls', False)
|
||||
|
||||
# If dry-run, display what would happen and exit early before any Candlepin operations
|
||||
if dry_run:
|
||||
self.stdout.write('[dry-run] Would register with Candlepin:')
|
||||
self.stdout.write(f' URL : {candlepin_url}')
|
||||
self.stdout.write(f' Organization : {org}')
|
||||
self.stdout.write(f' Username : {username}')
|
||||
self.stdout.write(f' Install UUID : {db_install_uuid}')
|
||||
if candlepin_ca:
|
||||
self.stdout.write(f' CA cert : {candlepin_ca}')
|
||||
if proxy:
|
||||
self.stdout.write(f' Proxy : {proxy}')
|
||||
self.stdout.write(f' Verify TLS : {verify_tls}')
|
||||
self.stdout.write('[dry-run] No Candlepin operations performed.')
|
||||
return True
|
||||
|
||||
client = CandlepinClient(base_url=candlepin_url, candlepin_ca=candlepin_ca, proxy=proxy, verify_tls=verify_tls)
|
||||
|
||||
self.stdout.write(f'Registering with Candlepin at {candlepin_url} (org={org}) ...')
|
||||
try:
|
||||
cert_pem, key_pem, consumer_uuid = client.register_consumer(username, password, org, install_uuid=db_install_uuid)
|
||||
except Exception as e:
|
||||
self.stderr.write(f'Registration failed: {e}')
|
||||
return False
|
||||
|
||||
self.stdout.write('Registered successfully.')
|
||||
self.stdout.write(f' Consumer UUID : {consumer_uuid}')
|
||||
|
||||
# Save to database
|
||||
if _save_candlepin_registration_to_db(cert_pem, key_pem, consumer_uuid):
|
||||
self.stdout.write('Certificate, key, and consumer UUID saved to database.')
|
||||
else:
|
||||
self.stderr.write('Failed to save registration to database.')
|
||||
return False
|
||||
|
||||
# Best-effort certificate metadata display
|
||||
try:
|
||||
info = parse_cert(cert_pem)
|
||||
self.stdout.write(f' Cert serial : {info["serial"]}')
|
||||
self.stdout.write(f' Cert CN : {info["cn"]}')
|
||||
self.stdout.write(f' Valid until : {info["not_after"]} ({info["days_remaining"]} days remaining)')
|
||||
except ValueError as e:
|
||||
self.stdout.write(f'Certificate metadata unavailable: {e}')
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# renew
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_renew(self, options):
|
||||
dry_run = options['dry_run']
|
||||
force = options['force']
|
||||
|
||||
cert_pem, key_pem, consumer_uuid = _fetch_candlepin_cert_from_db()
|
||||
|
||||
if not cert_pem or not key_pem:
|
||||
self.stderr.write('No Candlepin identity certificate found in database. Run the register subcommand first.')
|
||||
return False
|
||||
|
||||
if not consumer_uuid:
|
||||
self.stderr.write('CANDLEPIN_CONSUMER_UUID is not set. Run the register subcommand first.')
|
||||
return False
|
||||
|
||||
try:
|
||||
info = parse_cert(cert_pem)
|
||||
self.stdout.write('Current certificate:')
|
||||
self.stdout.write(f' Serial : {info["serial"]}')
|
||||
self.stdout.write(f' CN : {info["cn"]}')
|
||||
self.stdout.write(f' Valid until : {info["not_after"]} ({info["days_remaining"]} days remaining)')
|
||||
except ValueError as e:
|
||||
self.stdout.write('Current certificate:')
|
||||
self.stdout.write(f' Certificate metadata unavailable: {e}')
|
||||
info = None
|
||||
|
||||
candlepin_url = options.get('candlepin_url') or get_candlepin_url()
|
||||
candlepin_ca = options.get('candlepin_ca') or get_candlepin_ca()
|
||||
proxy = options.get('proxy') or get_proxy_url()
|
||||
verify_tls = not options.get('no_verify_tls', False)
|
||||
renewal_days = get_renewal_days()
|
||||
|
||||
# Check if renewal is needed (without force, just check cert expiry locally)
|
||||
renewal_needed = force or needs_renewal(cert_pem, renewal_days)
|
||||
|
||||
# If dry-run, display what would happen and exit early before any Candlepin operations
|
||||
if dry_run:
|
||||
self.stdout.write('[dry-run] Would perform the following operations:')
|
||||
self.stdout.write(f' URL : {candlepin_url}')
|
||||
self.stdout.write(f' Consumer UUID : {consumer_uuid}')
|
||||
if candlepin_ca:
|
||||
self.stdout.write(f' CA cert : {candlepin_ca}')
|
||||
if proxy:
|
||||
self.stdout.write(f' Proxy : {proxy}')
|
||||
self.stdout.write(f' Verify TLS : {verify_tls}')
|
||||
self.stdout.write(' 1. Check in with Candlepin')
|
||||
if renewal_needed:
|
||||
reason = 'forced via --force' if force else f'expiry within {renewal_days} days'
|
||||
self.stdout.write(f' 2. Renew certificate ({reason})')
|
||||
else:
|
||||
if info:
|
||||
self.stdout.write(f' 2. No renewal needed ({info["days_remaining"]} days remaining, threshold: {renewal_days} days)')
|
||||
else:
|
||||
self.stdout.write(f' 2. No renewal needed (threshold: {renewal_days} days)')
|
||||
self.stdout.write('[dry-run] No Candlepin operations performed.')
|
||||
return True
|
||||
|
||||
client = CandlepinClient(base_url=candlepin_url, candlepin_ca=candlepin_ca, proxy=proxy, verify_tls=verify_tls)
|
||||
|
||||
self.stdout.write(f'Checking in with Candlepin at {candlepin_url} (consumer={consumer_uuid}) ...')
|
||||
checkin_success = client.checkin(consumer_uuid, cert_pem, key_pem)
|
||||
|
||||
if not checkin_success:
|
||||
self.stderr.write('Check-in with Candlepin failed. Unable to verify certificate status.')
|
||||
self.stderr.write('Certificate renewal may still be needed. Use --force to renew anyway, or check logs for details.')
|
||||
return False
|
||||
|
||||
self.stdout.write('Check-in successful.')
|
||||
|
||||
if not renewal_needed:
|
||||
if info:
|
||||
self.stdout.write(f'Certificate has {info["days_remaining"]} days remaining (renewal threshold: {renewal_days} days). No renewal needed.')
|
||||
else:
|
||||
self.stdout.write(f'Certificate renewal threshold is {renewal_days} days. No renewal needed.')
|
||||
return True
|
||||
|
||||
reason = 'forced via --force' if force else f'expiry within {renewal_days} days'
|
||||
self.stdout.write(f'Renewing certificate ({reason}) ...')
|
||||
try:
|
||||
new_cert_pem, new_key_pem = client.regenerate_cert(consumer_uuid, cert_pem, key_pem)
|
||||
except Exception as e:
|
||||
self.stderr.write(f'Certificate renewal failed: {e}')
|
||||
return False
|
||||
|
||||
self.stdout.write('Certificate renewed successfully.')
|
||||
|
||||
# Save to database
|
||||
if _save_candlepin_cert_to_db(new_cert_pem, new_key_pem):
|
||||
self.stdout.write('Renewed certificate and key saved to database.')
|
||||
else:
|
||||
self.stderr.write('Failed to save renewed certificate to database.')
|
||||
return False
|
||||
|
||||
# Best-effort certificate metadata display
|
||||
try:
|
||||
new_info = parse_cert(new_cert_pem)
|
||||
if info:
|
||||
self.stdout.write(f' Old serial : {info["serial"]}')
|
||||
self.stdout.write(f' New serial : {new_info["serial"]}')
|
||||
self.stdout.write(f' Valid until : {new_info["not_after"]} ({new_info["days_remaining"]} days remaining)')
|
||||
except ValueError as e:
|
||||
self.stdout.write(f'Certificate metadata unavailable: {e}')
|
||||
|
||||
return True
|
||||
@@ -1,11 +1,9 @@
|
||||
# Copyright (c) 2015 Ansible, Inc.
|
||||
# All Rights Reserved
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
from awx.main.utils.db import db_requirement_violations
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Checks connection to the database, and prints out connection info if not connected"""
|
||||
@@ -15,8 +13,4 @@ class Command(BaseCommand):
|
||||
cursor.execute("SELECT version()")
|
||||
version = str(cursor.fetchone()[0])
|
||||
|
||||
violations = db_requirement_violations()
|
||||
if violations:
|
||||
raise CommandError(violations)
|
||||
|
||||
return "Database Version: {}".format(version)
|
||||
|
||||
@@ -52,11 +52,7 @@ class Command(BaseCommand):
|
||||
|
||||
ssh_type = CredentialType.objects.filter(namespace='ssh').first()
|
||||
c, _ = Credential.objects.get_or_create(
|
||||
credential_type=ssh_type,
|
||||
name='Demo Credential',
|
||||
inputs={'username': getattr(superuser, 'username', 'null')},
|
||||
created_by=superuser,
|
||||
organization=o,
|
||||
credential_type=ssh_type, name='Demo Credential', inputs={'username': getattr(superuser, 'username', 'null')}, created_by=superuser
|
||||
)
|
||||
|
||||
if superuser:
|
||||
|
||||
@@ -409,12 +409,10 @@ class Command(BaseCommand):
|
||||
del_child_group_pks = list(set(db_children_name_pk_map.values()))
|
||||
for offset in range(0, len(del_child_group_pks), self._batch_size):
|
||||
child_group_pks = del_child_group_pks[offset : (offset + self._batch_size)]
|
||||
children_to_remove = list(db_children.filter(pk__in=child_group_pks))
|
||||
if children_to_remove:
|
||||
group_group_count += len(children_to_remove)
|
||||
db_group.children.remove(*children_to_remove)
|
||||
for db_child in children_to_remove:
|
||||
logger.debug('Group "%s" removed from group "%s"', db_child.name, db_group.name)
|
||||
for db_child in db_children.filter(pk__in=child_group_pks):
|
||||
group_group_count += 1
|
||||
db_group.children.remove(db_child)
|
||||
logger.debug('Group "%s" removed from group "%s"', db_child.name, db_group.name)
|
||||
# FIXME: Inventory source group relationships
|
||||
# Delete group/host relationships not present in imported data.
|
||||
db_hosts = db_group.hosts
|
||||
@@ -443,12 +441,12 @@ class Command(BaseCommand):
|
||||
del_host_pks = list(del_host_pks)
|
||||
for offset in range(0, len(del_host_pks), self._batch_size):
|
||||
del_pks = del_host_pks[offset : (offset + self._batch_size)]
|
||||
hosts_to_remove = list(db_hosts.filter(pk__in=del_pks))
|
||||
if hosts_to_remove:
|
||||
group_host_count += len(hosts_to_remove)
|
||||
db_group.hosts.remove(*hosts_to_remove)
|
||||
for db_host in hosts_to_remove:
|
||||
logger.debug('Host "%s" removed from group "%s"', db_host.name, db_group.name)
|
||||
for db_host in db_hosts.filter(pk__in=del_pks):
|
||||
group_host_count += 1
|
||||
if db_host not in db_group.hosts.all():
|
||||
continue
|
||||
db_group.hosts.remove(db_host)
|
||||
logger.debug('Host "%s" removed from group "%s"', db_host.name, db_group.name)
|
||||
if settings.SQL_DEBUG:
|
||||
logger.warning(
|
||||
'group-group and group-host deletions took %d queries for %d relationships',
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from ansible_base.lib.utils.db import advisory_lock
|
||||
@@ -24,65 +23,7 @@ class DeferJobCreatedManager(models.Manager):
|
||||
return super(DeferJobCreatedManager, self).get_queryset().defer('job_created')
|
||||
|
||||
|
||||
class HostLatestSummaryQuerySet(models.QuerySet):
|
||||
"""Queryset that annotates and bulk-attaches the latest JobHostSummary
|
||||
at queryset evaluation time, similar to prefetch_related().
|
||||
|
||||
Why not use Django's Prefetch?
|
||||
Django's Prefetch with [:1] slicing fetches 1 record globally, not per-host
|
||||
(Django ticket #26780). Window-function workarounds require Django 4.2+ and
|
||||
are more complex. Prefetching all summaries then filtering in Python wastes
|
||||
memory for hosts with many job runs. The approach here — annotate the latest
|
||||
ID via Subquery, then in_bulk() only those IDs — is the same 2-query pattern
|
||||
prefetch_related uses internally, customized for "latest per group."
|
||||
|
||||
Not streaming-safe: relies on _result_cache existing after _fetch_all().
|
||||
"""
|
||||
|
||||
_awx_latest_summary_attached = False
|
||||
|
||||
def _clone(self):
|
||||
clone = super()._clone()
|
||||
clone._awx_latest_summary_attached = self._awx_latest_summary_attached
|
||||
return clone
|
||||
|
||||
def with_latest_summary_id(self):
|
||||
from awx.main.models.jobs import JobHostSummary
|
||||
|
||||
latest_summary = JobHostSummary.objects.filter(host_id=OuterRef('pk')).order_by('-id')
|
||||
return self.annotate(
|
||||
_latest_summary_id=Subquery(latest_summary.values('id')[:1]),
|
||||
)
|
||||
|
||||
def _fetch_all(self):
|
||||
super()._fetch_all()
|
||||
|
||||
if self._awx_latest_summary_attached or not self._result_cache:
|
||||
return
|
||||
|
||||
# Only bulk-attach if the queryset was annotated via with_latest_summary_id().
|
||||
# Without this guard, we'd set _latest_summary_cache=None on every host,
|
||||
# masking the per-object fallback query in Host.latest_summary.
|
||||
if not hasattr(self._result_cache[0], '_latest_summary_id'):
|
||||
return
|
||||
|
||||
from awx.main.models.jobs import JobHostSummary
|
||||
|
||||
latest_summary_ids = [host._latest_summary_id for host in self._result_cache if host._latest_summary_id is not None]
|
||||
|
||||
if latest_summary_ids:
|
||||
summaries_by_id = JobHostSummary.objects.select_related('job', 'job__job_template').in_bulk(latest_summary_ids)
|
||||
else:
|
||||
summaries_by_id = {}
|
||||
|
||||
for host in self._result_cache:
|
||||
latest_summary_id = getattr(host, '_latest_summary_id', None)
|
||||
host._latest_summary_cache = summaries_by_id.get(latest_summary_id)
|
||||
|
||||
self._awx_latest_summary_attached = True
|
||||
|
||||
|
||||
class HostManager(models.Manager.from_queryset(HostLatestSummaryQuerySet)):
|
||||
class HostManager(models.Manager):
|
||||
"""Custom manager class for Hosts model."""
|
||||
|
||||
def active_count(self):
|
||||
@@ -90,46 +31,38 @@ class HostManager(models.Manager.from_queryset(HostLatestSummaryQuerySet)):
|
||||
Construction of query involves:
|
||||
- remove any ordering specified in model's Meta
|
||||
- Exclude hosts sourced from another Tower
|
||||
- Exclude hosts in constructed inventories (these are shadow rows of source-inventory hosts)
|
||||
- Restrict the query to only return the name column
|
||||
- Only consider results that are unique
|
||||
- Return the count of this query
|
||||
"""
|
||||
return (
|
||||
self.order_by()
|
||||
.exclude(inventory_sources__source='controller')
|
||||
.exclude(inventory__kind='constructed')
|
||||
.values(name_lower=Lower('name'))
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
return self.order_by().exclude(inventory_sources__source='controller').values(name_lower=Lower('name')).distinct().count()
|
||||
|
||||
def org_active_count(self, org_id):
|
||||
"""Return count of active, unique hosts used by an organization.
|
||||
Construction of query involves:
|
||||
- remove any ordering specified in model's Meta
|
||||
- Exclude hosts sourced from another Tower
|
||||
- Exclude hosts in constructed inventories (these are shadow rows of source-inventory hosts)
|
||||
- Consider only hosts where the canonical inventory is owned by the organization
|
||||
- Restrict the query to only return the name column
|
||||
- Only consider results that are unique
|
||||
- Return the count of this query
|
||||
"""
|
||||
return (
|
||||
self.order_by()
|
||||
.exclude(inventory_sources__source='controller')
|
||||
.exclude(inventory__kind='constructed')
|
||||
.filter(inventory__organization=org_id)
|
||||
.values('name')
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
return self.order_by().exclude(inventory_sources__source='controller').filter(inventory__organization=org_id).values('name').distinct().count()
|
||||
|
||||
def get_queryset(self):
|
||||
"""When the parent instance of the host query set has a `kind=smart` and a `host_filter`
|
||||
set. Use the `host_filter` to generate the queryset for the hosts.
|
||||
"""
|
||||
qs = super().get_queryset().defer('ansible_facts')
|
||||
qs = (
|
||||
super(HostManager, self)
|
||||
.get_queryset()
|
||||
.defer(
|
||||
'last_job__extra_vars',
|
||||
'last_job_host_summary__job__extra_vars',
|
||||
'last_job__artifacts',
|
||||
'last_job_host_summary__job__artifacts',
|
||||
)
|
||||
)
|
||||
|
||||
if hasattr(self, 'instance') and hasattr(self.instance, 'host_filter') and hasattr(self.instance, 'kind'):
|
||||
if self.instance.kind == 'smart' and self.instance.host_filter is not None:
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2026-02-20 03:39
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0204_squashed_deletions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='instancegroup',
|
||||
options={
|
||||
'default_permissions': ('change', 'delete', 'view'),
|
||||
'ordering': ('pk',),
|
||||
'permissions': [('use_instancegroup', 'Can use instance group in a preference list of a resource')],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='workflowjobnode',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='workflowjobtemplatenode',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
]
|
||||
@@ -211,7 +211,7 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
|
||||
return AdHocCommand.objects.create(**data)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
|
||||
def add_to_update_fields(name):
|
||||
if name not in update_fields:
|
||||
|
||||
@@ -177,7 +177,7 @@ class CreatedModifiedModel(BaseModel):
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = list(kwargs.get('update_fields') or [])
|
||||
update_fields = list(kwargs.get('update_fields', []))
|
||||
# Manually perform auto_now_add and auto_now logic.
|
||||
if not self.pk and not self.created:
|
||||
self.created = now()
|
||||
@@ -207,7 +207,7 @@ class PasswordFieldsModel(BaseModel):
|
||||
new_instance = not bool(self.pk)
|
||||
# If update_fields has been specified, add our field names to it,
|
||||
# if it hasn't been specified, then we're just doing a normal save.
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
# When first saving to the database, don't store any password field
|
||||
# values, but instead save them until after the instance is created.
|
||||
# Otherwise, store encrypted values to the database.
|
||||
@@ -322,7 +322,7 @@ class PrimordialModel(HasEditsMixin, CreatedModifiedModel):
|
||||
self._prior_values_store = {}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
user = get_current_user()
|
||||
if user and not user.id:
|
||||
user = None
|
||||
|
||||
@@ -28,7 +28,6 @@ from rest_framework.serializers import ValidationError as DRFValidationError
|
||||
from ansible_base.lib.utils.db import advisory_lock
|
||||
|
||||
# AWX
|
||||
from awx.main.constants import OIDC_CREDENTIAL_TYPE_NAMESPACES
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.fields import (
|
||||
ImplicitRoleField,
|
||||
@@ -47,9 +46,12 @@ from awx.main.models.rbac import (
|
||||
)
|
||||
from awx.main.models import Team, Organization
|
||||
from awx.main.utils import encrypt_field
|
||||
from awx.main.utils.lazy_registry import LazyLoadDict
|
||||
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
|
||||
|
||||
# DAB
|
||||
from ansible_base.resource_registry.tasks.sync import get_resource_server_client
|
||||
from ansible_base.resource_registry.utils.settings import resource_server_defined
|
||||
|
||||
__all__ = ['Credential', 'CredentialType', 'CredentialInputSource', 'build_safe_env']
|
||||
|
||||
logger = logging.getLogger('awx.main.models.credential')
|
||||
@@ -77,6 +79,46 @@ def build_safe_env(env):
|
||||
return safe_env
|
||||
|
||||
|
||||
def check_resource_server_for_user_in_organization(user, organization, requesting_user):
|
||||
if not resource_server_defined():
|
||||
return False
|
||||
|
||||
if not requesting_user:
|
||||
return False
|
||||
|
||||
client = get_resource_server_client(settings.RESOURCE_SERVICE_PATH, jwt_user_id=str(requesting_user.resource.ansible_id), raise_if_bad_request=False)
|
||||
# need to get the organization object_id in resource server, by querying with ansible_id
|
||||
response = client._make_request(path=f'resources/?ansible_id={str(organization.resource.ansible_id)}', method='GET')
|
||||
response_json = response.json()
|
||||
if response.status_code != 200:
|
||||
logger.error(f'Failed to get organization object_id in resource server: {response_json.get("detail", "")}')
|
||||
return False
|
||||
|
||||
if response_json.get('count', 0) == 0:
|
||||
return False
|
||||
org_id_in_resource_server = response_json['results'][0]['object_id']
|
||||
|
||||
client.base_url = client.base_url.replace('/api/gateway/v1/service-index/', '/api/gateway/v1/')
|
||||
# find role assignments with:
|
||||
# - roles Organization Member or Organization Admin
|
||||
# - user ansible id
|
||||
# - organization object id
|
||||
|
||||
response = client._make_request(
|
||||
path=f'role_user_assignments/?role_definition__name__in=Organization Member,Organization Admin&user__resource__ansible_id={str(user.resource.ansible_id)}&object_id={org_id_in_resource_server}',
|
||||
method='GET',
|
||||
)
|
||||
response_json = response.json()
|
||||
if response.status_code != 200:
|
||||
logger.error(f'Failed to get role user assignments in resource server: {response_json.get("detail", "")}')
|
||||
return False
|
||||
|
||||
if response_json.get('count', 0) > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
"""
|
||||
A credential contains information about how to talk to a remote resource
|
||||
@@ -200,29 +242,6 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
needed.append('vault_password')
|
||||
return needed
|
||||
|
||||
@functools.cached_property
|
||||
def context(self):
|
||||
"""
|
||||
Property for storing runtime context during credential resolution.
|
||||
|
||||
The context is a dict keyed by CredentialInputSource PK, where each value
|
||||
is a dict of runtime fields for that input source. Example::
|
||||
|
||||
{
|
||||
<input_source_pk>: {
|
||||
"workload_identity_token": "<jwt_token>"
|
||||
},
|
||||
<another_input_source_pk>: {
|
||||
"workload_identity_token": "<different_jwt_token>"
|
||||
},
|
||||
}
|
||||
|
||||
This structure allows each input source to have its own set of runtime
|
||||
values, avoiding conflicts when a credential has multiple input sources
|
||||
with different configurations (e.g., different JWT audiences).
|
||||
"""
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def dynamic_input_fields(self):
|
||||
# if the credential is not yet saved we can't access the input_sources
|
||||
@@ -348,20 +367,21 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
def _get_dynamic_input(self, field_name):
|
||||
for input_source in self.input_sources.all():
|
||||
if input_source.input_field_name == field_name:
|
||||
return input_source.get_input_value(context=self.context)
|
||||
return input_source.get_input_value()
|
||||
else:
|
||||
raise ValueError('{} is not a dynamic input field'.format(field_name))
|
||||
|
||||
def validate_role_assignment(self, actor, role_definition, **kwargs):
|
||||
requesting_user = kwargs.get('requesting_user', None)
|
||||
if requesting_user and requesting_user.is_superuser:
|
||||
return
|
||||
if self.organization:
|
||||
if isinstance(actor, User):
|
||||
if actor.is_superuser:
|
||||
return
|
||||
if Organization.access_qs(actor, 'member').filter(id=self.organization.id).exists():
|
||||
return
|
||||
|
||||
requesting_user = kwargs.get('requesting_user', None)
|
||||
if check_resource_server_for_user_in_organization(actor, self.organization, requesting_user):
|
||||
return
|
||||
if isinstance(actor, Team):
|
||||
if actor.organization == self.organization:
|
||||
return
|
||||
@@ -415,15 +435,13 @@ class CredentialType(CommonModelNameNotUnique):
|
||||
def from_db(cls, db, field_names, values):
|
||||
instance = super(CredentialType, cls).from_db(db, field_names, values)
|
||||
if instance.managed and instance.namespace and instance.kind != "external":
|
||||
native = ManagedCredentialType.registry.get(instance.namespace)
|
||||
if native:
|
||||
instance.inputs = native.inputs
|
||||
instance.injectors = native.injectors
|
||||
instance.custom_injectors = getattr(native, 'custom_injectors', None)
|
||||
native = ManagedCredentialType.registry[instance.namespace]
|
||||
instance.inputs = native.inputs
|
||||
instance.injectors = native.injectors
|
||||
instance.custom_injectors = getattr(native, 'custom_injectors', None)
|
||||
elif instance.namespace and instance.kind == "external":
|
||||
native = ManagedCredentialType.registry.get(instance.namespace)
|
||||
if native:
|
||||
instance.inputs = native.inputs
|
||||
native = ManagedCredentialType.registry[instance.namespace]
|
||||
instance.inputs = native.inputs
|
||||
|
||||
return instance
|
||||
|
||||
@@ -487,7 +505,6 @@ class CredentialType(CommonModelNameNotUnique):
|
||||
existing = ct_class.objects.filter(name=default.name, kind=default.kind).first()
|
||||
if existing is not None:
|
||||
existing.namespace = default.namespace
|
||||
existing.description = getattr(default, 'description', '')
|
||||
existing.inputs = {}
|
||||
existing.injectors = {}
|
||||
existing.save()
|
||||
@@ -527,14 +544,7 @@ class CredentialType(CommonModelNameNotUnique):
|
||||
@classmethod
|
||||
def load_plugin(cls, ns, plugin):
|
||||
# TODO: User "side-loaded" credential custom_injectors isn't supported
|
||||
ManagedCredentialType.registry[ns] = SimpleNamespace(
|
||||
namespace=ns,
|
||||
name=plugin.name,
|
||||
kind='external',
|
||||
inputs=plugin.inputs,
|
||||
backend=plugin.backend,
|
||||
description=getattr(plugin, 'plugin_description', ''),
|
||||
)
|
||||
ManagedCredentialType.registry[ns] = SimpleNamespace(namespace=ns, name=plugin.name, kind='external', inputs=plugin.inputs, backend=plugin.backend)
|
||||
|
||||
def inject_credential(self, credential, env, safe_env, args, private_data_dir, container_root=None):
|
||||
from awx_plugins.interfaces._temporary_private_inject_api import inject_credential
|
||||
@@ -546,13 +556,7 @@ class CredentialTypeHelper:
|
||||
@classmethod
|
||||
def get_creation_params(cls, cred_type):
|
||||
if cred_type.kind == 'external':
|
||||
return {
|
||||
'namespace': cred_type.namespace,
|
||||
'kind': cred_type.kind,
|
||||
'name': cred_type.name,
|
||||
'managed': True,
|
||||
'description': getattr(cred_type, 'description', ''),
|
||||
}
|
||||
return dict(namespace=cred_type.namespace, kind=cred_type.kind, name=cred_type.name, managed=True)
|
||||
return dict(
|
||||
namespace=cred_type.namespace,
|
||||
kind=cred_type.kind,
|
||||
@@ -570,7 +574,7 @@ class CredentialTypeHelper:
|
||||
|
||||
|
||||
class ManagedCredentialType(SimpleNamespace):
|
||||
registry = None # initialized as LazyLoadDict after load_credentials is defined
|
||||
registry = {}
|
||||
|
||||
|
||||
class CredentialInputSource(PrimordialModel):
|
||||
@@ -618,15 +622,7 @@ class CredentialInputSource(PrimordialModel):
|
||||
raise ValidationError(_('Input field must be defined on target credential (options are {}).'.format(', '.join(sorted(defined_fields)))))
|
||||
return self.input_field_name
|
||||
|
||||
def get_input_value(self, context: dict | None = None):
|
||||
"""
|
||||
Retrieve the value from the external credential backend.
|
||||
|
||||
Args:
|
||||
context: Optional runtime context dict passed from the target credential.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
def get_input_value(self):
|
||||
backend = self.source_credential.credential_type.plugin.backend
|
||||
backend_kwargs = {}
|
||||
for field_name, value in self.source_credential.inputs.items():
|
||||
@@ -637,17 +633,6 @@ class CredentialInputSource(PrimordialModel):
|
||||
|
||||
backend_kwargs.update(self.metadata)
|
||||
|
||||
# Resolve internal fields from the per-input-source context.
|
||||
# The context dict is keyed by input source PK, e.g.:
|
||||
# {42: {"workload_identity_token": "eyJ..."}, 43: {"workload_identity_token": "eyX..."}}
|
||||
# This allows each input source to carry its own runtime values.
|
||||
input_source_context = context.get(self.pk, {})
|
||||
for field in self.source_credential.credential_type.inputs.get('fields', []):
|
||||
if field.get('internal'):
|
||||
value = input_source_context.get(field['id'])
|
||||
if value is not None:
|
||||
backend_kwargs[field['id']] = value
|
||||
|
||||
with set_environ(**settings.AWX_TASK_ENV):
|
||||
return backend(**backend_kwargs)
|
||||
|
||||
@@ -656,22 +641,13 @@ class CredentialInputSource(PrimordialModel):
|
||||
return reverse(view_name, kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
|
||||
def _is_oidc_namespace_disabled(ns):
|
||||
"""Check if a credential namespace should be skipped based on the OIDC feature flag."""
|
||||
return ns in OIDC_CREDENTIAL_TYPE_NAMESPACES and not getattr(settings, 'FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED', False)
|
||||
|
||||
|
||||
def load_credentials():
|
||||
ManagedCredentialType.registry.clear()
|
||||
|
||||
awx_entry_points = {ep.name: ep for ep in entry_points(group='awx_plugins.managed_credentials')}
|
||||
supported_entry_points = {ep.name: ep for ep in entry_points(group='awx_plugins.managed_credentials.supported')}
|
||||
plugin_entry_points = awx_entry_points if detect_server_product_name() == 'AWX' else {**awx_entry_points, **supported_entry_points}
|
||||
|
||||
for ns, ep in plugin_entry_points.items():
|
||||
if _is_oidc_namespace_disabled(ns):
|
||||
continue
|
||||
|
||||
cred_plugin = ep.load()
|
||||
if not hasattr(cred_plugin, 'inputs'):
|
||||
setattr(cred_plugin, 'inputs', {})
|
||||
@@ -690,13 +666,5 @@ def load_credentials():
|
||||
credential_plugins = {}
|
||||
|
||||
for ns, ep in credential_plugins.items():
|
||||
if _is_oidc_namespace_disabled(ns):
|
||||
continue
|
||||
|
||||
plugin = ep.load()
|
||||
CredentialType.load_plugin(ns, plugin)
|
||||
|
||||
|
||||
# load_credentials writes directly into this dict via registry[ns] = ...,
|
||||
# LazyLoadDict just ensures it runs once before the first read access
|
||||
ManagedCredentialType.registry = LazyLoadDict(load_credentials)
|
||||
|
||||
@@ -24,6 +24,7 @@ from awx.main.managers import DeferJobCreatedManager
|
||||
from awx.main.constants import MINIMAL_EVENTS
|
||||
from awx.main.models.base import CreatedModifiedModel
|
||||
from awx.main.utils import ignore_inventory_computed_fields, camelcase_to_underscore
|
||||
from awx.main.utils.db import bulk_update_sorted_by_id
|
||||
|
||||
analytics_logger = logging.getLogger('awx.analytics.job_events')
|
||||
|
||||
@@ -589,8 +590,20 @@ class JobEvent(BasePlaybookEvent):
|
||||
|
||||
JobHostSummary.objects.bulk_create(summaries.values())
|
||||
|
||||
# last_job and last_job_host_summary are now derived via
|
||||
# JobHostSummary.latest_for_host / latest_job_for_host
|
||||
# update the last_job_id and last_job_host_summary_id
|
||||
# in single queries
|
||||
host_mapping = dict((summary['host_id'], summary['id']) for summary in JobHostSummary.objects.filter(job_id=job.id).values('id', 'host_id'))
|
||||
updated_hosts = set()
|
||||
for h in all_hosts:
|
||||
# if the hostname *shows up* in the playbook_on_stats event
|
||||
if h.name in hostnames:
|
||||
h.last_job_id = job.id
|
||||
updated_hosts.add(h)
|
||||
if h.id in host_mapping:
|
||||
h.last_job_host_summary_id = host_mapping[h.id]
|
||||
updated_hosts.add(h)
|
||||
|
||||
bulk_update_sorted_by_id(Host, updated_hosts, ['last_job_id', 'last_job_host_summary_id'])
|
||||
|
||||
# Create/update Host Metrics
|
||||
self._update_host_metrics(updated_hosts_list)
|
||||
|
||||
@@ -58,6 +58,8 @@ class ExecutionEnvironment(CommonModel):
|
||||
return reverse('api:execution_environment_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
def validate_role_assignment(self, actor, role_definition, **kwargs):
|
||||
from awx.main.models.credential import check_resource_server_for_user_in_organization
|
||||
|
||||
if self.managed:
|
||||
raise ValidationError({'object_id': _('Can not assign object roles to managed Execution Environments')})
|
||||
if self.organization_id is None:
|
||||
@@ -67,4 +69,8 @@ class ExecutionEnvironment(CommonModel):
|
||||
if actor.has_obj_perm(self.organization, 'view'):
|
||||
return
|
||||
|
||||
requesting_user = kwargs.get('requesting_user', None)
|
||||
if check_resource_server_for_user_in_organization(actor, self.organization, requesting_user):
|
||||
return
|
||||
|
||||
raise ValidationError({'user': _('User must have view permission to Execution Environment organization')})
|
||||
|
||||
@@ -485,7 +485,6 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin, ResourceMi
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('pk',)
|
||||
permissions = [('use_instancegroup', 'Can use instance group in a preference list of a resource')]
|
||||
# Since this has no direct organization field only superuser can add, so remove add permission
|
||||
default_permissions = ('change', 'delete', 'view')
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.db import transaction
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import resolve
|
||||
from django.utils.timezone import now
|
||||
from django.db.models import Q, Subquery, OuterRef
|
||||
from django.db.models import Q
|
||||
|
||||
# REST Framework
|
||||
from rest_framework.exceptions import ParseError
|
||||
@@ -27,10 +27,7 @@ from ansible_base.lib.utils.models import prevent_search
|
||||
|
||||
# AWX
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.utils.common import load_all_entry_points_for
|
||||
from awx.main.utils.lazy_registry import LazyLoadDict
|
||||
from awx.main.utils.plugins import discover_available_cloud_provider_plugin_names, compute_cloud_inventory_sources
|
||||
from awx_plugins.interfaces._temporary_private_licensing_api import detect_server_product_name
|
||||
from awx.main.consumers import emit_channel_notification
|
||||
from awx.main.fields import (
|
||||
ImplicitRoleField,
|
||||
@@ -389,10 +386,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin, OpaQu
|
||||
logger.debug("Going to update inventory computed fields, pk={0}".format(self.pk))
|
||||
start_time = time.time()
|
||||
active_hosts = self.hosts
|
||||
from awx.main.models.jobs import JobHostSummary # circular import: inventory.py loads before jobs.py
|
||||
|
||||
latest_summary_failed = Subquery(JobHostSummary.objects.filter(host_id=OuterRef('pk')).order_by('-id').values('failed')[:1])
|
||||
failed_hosts = active_hosts.annotate(_latest_failed=latest_summary_failed).filter(_latest_failed=True)
|
||||
failed_hosts = active_hosts.filter(last_job_host_summary__failed=True)
|
||||
active_groups = self.groups
|
||||
if self.kind == 'smart':
|
||||
active_groups = active_groups.none()
|
||||
@@ -588,23 +582,6 @@ class Host(CommonModelNameNotUnique, RelatedJobsMixin):
|
||||
|
||||
objects = HostManager()
|
||||
|
||||
@property
|
||||
def latest_summary(self):
|
||||
if hasattr(self, '_latest_summary_cache'):
|
||||
return self._latest_summary_cache
|
||||
from awx.main.models.jobs import JobHostSummary
|
||||
|
||||
summary = JobHostSummary.objects.filter(host_id=self.pk).order_by('-id').select_related('job', 'job__job_template').first()
|
||||
self._latest_summary_cache = summary
|
||||
return summary
|
||||
|
||||
@property
|
||||
def latest_job(self):
|
||||
summary = self.latest_summary
|
||||
if summary is None:
|
||||
return None
|
||||
return summary.job
|
||||
|
||||
def get_absolute_url(self, request=None):
|
||||
return reverse('api:host_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
@@ -929,22 +906,12 @@ class HostMetricSummaryMonthly(models.Model):
|
||||
indirectly_managed_hosts = models.IntegerField(default=0, help_text=("Manually entered number indirectly managed hosts for a certain month"))
|
||||
|
||||
|
||||
def _load_inventory_plugins():
|
||||
is_awx = detect_server_product_name() == 'AWX'
|
||||
extra_entry_point_groups = () if is_awx else ('inventory.supported',)
|
||||
all_entry_points = load_all_entry_points_for(['inventory', *extra_entry_point_groups])
|
||||
|
||||
for entry_point_name, entry_point in all_entry_points.items():
|
||||
cls = entry_point.load()
|
||||
InventorySourceOptions.injectors[entry_point_name] = cls
|
||||
|
||||
|
||||
class InventorySourceOptions(BaseModel):
|
||||
"""
|
||||
Common fields for InventorySource and InventoryUpdate.
|
||||
"""
|
||||
|
||||
injectors = LazyLoadDict(_load_inventory_plugins)
|
||||
injectors = dict()
|
||||
|
||||
# From the options of the Django management base command
|
||||
INVENTORY_UPDATE_VERBOSITY_CHOICES = [
|
||||
@@ -1162,7 +1129,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
||||
|
||||
# If update_fields has been specified, add our field names to it,
|
||||
# if it hasn't been specified, then we're just doing a normal save.
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
is_new_instance = not bool(self.pk)
|
||||
|
||||
# Set name automatically. Include PK (or placeholder) to make sure the names are always unique.
|
||||
|
||||
@@ -52,7 +52,7 @@ from awx.main.models.mixins import (
|
||||
WebhookTemplateMixin,
|
||||
OpaQueryPathMixin,
|
||||
)
|
||||
from awx.main.utils.common import get_job_variable_prefixes
|
||||
from awx.main.constants import JOB_VARIABLE_PREFIXES
|
||||
|
||||
logger = logging.getLogger('awx.main.models.jobs')
|
||||
|
||||
@@ -347,7 +347,7 @@ class JobTemplate(
|
||||
return actual_slice_count
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
# if project is deleted for some reason, then keep the old organization
|
||||
# to retain ownership for organization admins
|
||||
if self.project and self.project.organization_id != self.organization_id:
|
||||
@@ -817,20 +817,19 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
|
||||
def awx_meta_vars(self):
|
||||
r = super(Job, self).awx_meta_vars()
|
||||
prefixes = get_job_variable_prefixes()
|
||||
if self.project:
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_project_revision'.format(name)] = self.project.scm_revision
|
||||
r['{}_project_scm_branch'.format(name)] = self.project.scm_branch
|
||||
if self.scm_branch:
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_job_scm_branch'.format(name)] = self.scm_branch
|
||||
if self.job_template:
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_job_template_id'.format(name)] = self.job_template.pk
|
||||
r['{}_job_template_name'.format(name)] = self.job_template.name
|
||||
if self.execution_node:
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_execution_node'.format(name)] = self.execution_node
|
||||
return r
|
||||
|
||||
@@ -846,21 +845,6 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
def get_notification_friendly_name(self):
|
||||
return "Job"
|
||||
|
||||
def get_source_hosts_for_constructed_inventory(self):
|
||||
"""Return a QuerySet of the source (input inventory) hosts for a constructed inventory.
|
||||
|
||||
Constructed inventory hosts have an instance_id pointing to the real
|
||||
host in the input inventory. This resolves those references and returns
|
||||
a proper QuerySet (never a list), suitable for use with finish_fact_cache.
|
||||
"""
|
||||
Host = JobHostSummary._meta.get_field('host').related_model
|
||||
if not self.inventory_id:
|
||||
return Host.objects.none()
|
||||
id_field = Host._meta.get_field('id')
|
||||
return Host.objects.filter(id__in=self.inventory.hosts.exclude(instance_id='').values_list(Cast('instance_id', output_field=id_field))).only(
|
||||
*HOST_FACTS_FIELDS
|
||||
)
|
||||
|
||||
def get_hosts_for_fact_cache(self):
|
||||
"""
|
||||
Builds the queryset to use for writing or finalizing the fact cache
|
||||
@@ -868,15 +852,17 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
For constructed inventories, that means the original (input inventory) hosts
|
||||
when slicing, that means only returning hosts in that slice
|
||||
"""
|
||||
Host = JobHostSummary._meta.get_field('host').related_model
|
||||
if not self.inventory_id:
|
||||
Host = JobHostSummary._meta.get_field('host').related_model
|
||||
return Host.objects.none()
|
||||
|
||||
if self.inventory.kind == 'constructed':
|
||||
host_qs = self.get_source_hosts_for_constructed_inventory()
|
||||
id_field = Host._meta.get_field('id')
|
||||
host_qs = Host.objects.filter(id__in=self.inventory.hosts.exclude(instance_id='').values_list(Cast('instance_id', output_field=id_field)))
|
||||
else:
|
||||
host_qs = self.inventory.hosts.only(*HOST_FACTS_FIELDS)
|
||||
host_qs = self.inventory.hosts
|
||||
|
||||
host_qs = host_qs.only(*HOST_FACTS_FIELDS)
|
||||
host_qs = self.inventory.get_sliced_hosts(host_qs, self.job_slice_number, self.job_slice_count)
|
||||
return host_qs
|
||||
|
||||
@@ -1141,22 +1127,6 @@ class JobHostSummary(CreatedModifiedModel):
|
||||
self.skipped,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def latest_for_host(cls, host_id):
|
||||
"""Return the most recent JobHostSummary for a given host, or None."""
|
||||
return cls.objects.filter(host_id=host_id).order_by('-id').first()
|
||||
|
||||
@classmethod
|
||||
def latest_job_for_host(cls, host_id):
|
||||
"""Return the Job from the most recent JobHostSummary for a host, or None."""
|
||||
summary = cls.latest_for_host(host_id)
|
||||
if summary:
|
||||
try:
|
||||
return summary.job
|
||||
except cls.job.field.related_model.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_absolute_url(self, request=None):
|
||||
return reverse('api:job_host_summary_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
@@ -1165,7 +1135,7 @@ class JobHostSummary(CreatedModifiedModel):
|
||||
# if it hasn't been specified, then we're just doing a normal save.
|
||||
if self.host is not None:
|
||||
self.host_name = self.host.name
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
self.failed = bool(self.dark or self.failures)
|
||||
update_fields.append('failed')
|
||||
super(JobHostSummary, self).save(*args, **kwargs)
|
||||
|
||||
@@ -99,7 +99,7 @@ class NotificationTemplate(CommonModelNameNotUnique):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
new_instance = not bool(self.pk)
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
|
||||
# preserve existing notification messages if not overwritten by new messages
|
||||
if not new_instance:
|
||||
|
||||
@@ -367,7 +367,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
||||
pre_save_vals = getattr(self, '_prior_values_store', {})
|
||||
# If update_fields has been specified, add our field names to it,
|
||||
# if it hasn't been specified, then we're just doing a normal save.
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
self._skip_update = bool(kwargs.pop('skip_update', False))
|
||||
# Create auto-generated local path if project uses SCM.
|
||||
if self.pk and self.scm_type and not self.local_path.startswith('_'):
|
||||
|
||||
@@ -613,7 +613,7 @@ def get_role_from_object_role(object_role):
|
||||
model_name, role_name = rd.name.split()
|
||||
role_name = role_name.lower()
|
||||
role_name += '_role'
|
||||
return getattr(object_role.content_object, role_name, None)
|
||||
return getattr(object_role.content_object, role_name)
|
||||
|
||||
|
||||
def give_or_remove_permission(role, actor, giving=True, rd=None):
|
||||
@@ -649,8 +649,6 @@ def give_creator_permissions(user, obj):
|
||||
if assignment:
|
||||
with disable_rbac_sync():
|
||||
old_role = get_role_from_object_role(assignment.object_role)
|
||||
if old_role is None:
|
||||
return
|
||||
old_role.members.add(user)
|
||||
|
||||
|
||||
|
||||
@@ -72,10 +72,10 @@ def _fast_forward_rrule(rrule, ref_dt=None):
|
||||
if ref_dt is None:
|
||||
ref_dt = now()
|
||||
|
||||
dtstart_tz = rrule._dtstart.tzinfo
|
||||
ref_dt = ref_dt.astimezone(dtstart_tz)
|
||||
ref_dt = ref_dt.astimezone(datetime.timezone.utc)
|
||||
|
||||
if rrule._dtstart > ref_dt:
|
||||
rrule_dtstart_utc = rrule._dtstart.astimezone(datetime.timezone.utc)
|
||||
if rrule_dtstart_utc > ref_dt:
|
||||
return rrule
|
||||
|
||||
interval = rrule._interval if rrule._interval else 1
|
||||
@@ -84,14 +84,20 @@ def _fast_forward_rrule(rrule, ref_dt=None):
|
||||
elif rrule._freq == dateutil.rrule.MINUTELY:
|
||||
interval *= 60
|
||||
|
||||
# if after converting to seconds the interval is still a fraction,
|
||||
# just return original rrule
|
||||
if isinstance(interval, float) and not interval.is_integer():
|
||||
return rrule
|
||||
|
||||
seconds_since_dtstart = (ref_dt - rrule._dtstart).total_seconds()
|
||||
seconds_since_dtstart = (ref_dt - rrule_dtstart_utc).total_seconds()
|
||||
|
||||
# it is important to fast forward by a number that is divisible by
|
||||
# interval. For example, if interval is 7 hours, we fast forward by 7, 14, 21, etc. hours.
|
||||
# Otherwise, the occurrences after the fast forward might not match the ones before.
|
||||
# x // y is integer division, lopping off any remainder, so that we get the outcome we want.
|
||||
interval_aligned_offset = datetime.timedelta(seconds=(seconds_since_dtstart // interval) * interval)
|
||||
new_start = rrule._dtstart + interval_aligned_offset
|
||||
new_rrule = rrule.replace(dtstart=new_start)
|
||||
new_start = rrule_dtstart_utc + interval_aligned_offset
|
||||
new_rrule = rrule.replace(dtstart=new_start.astimezone(rrule._dtstart.tzinfo))
|
||||
return new_rrule
|
||||
|
||||
|
||||
|
||||
@@ -58,8 +58,7 @@ from awx.main.utils.common import (
|
||||
)
|
||||
from awx.main.utils.encryption import encrypt_dict, decrypt_field
|
||||
from awx.main.utils import polymorphic
|
||||
from awx.main.constants import ACTIVE_STATES, CAN_CANCEL
|
||||
from awx.main.utils.common import get_job_variable_prefixes
|
||||
from awx.main.constants import ACTIVE_STATES, CAN_CANCEL, JOB_VARIABLE_PREFIXES
|
||||
from awx.main.redact import UriCleaner, REPLACE_STR
|
||||
from awx.main.consumers import emit_channel_notification
|
||||
from awx.main.fields import AskForField, OrderedManyToManyField
|
||||
@@ -305,7 +304,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn
|
||||
def save(self, *args, **kwargs):
|
||||
# If update_fields has been specified, add our field names to it,
|
||||
# if it hasn't been specified, then we're just doing a normal save.
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
# Update status and last_updated fields.
|
||||
if not getattr(_inventory_updates, 'is_updating', False):
|
||||
updated_fields = self._set_status_and_last_job_run(save=False)
|
||||
@@ -877,7 +876,7 @@ class UnifiedJob(
|
||||
"""
|
||||
# If update_fields has been specified, add our field names to it,
|
||||
# if it hasn't been specified, then we're just doing a normal save.
|
||||
update_fields = kwargs.get('update_fields') or []
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
|
||||
# Get status before save...
|
||||
status_before = self.status or 'new'
|
||||
@@ -1569,8 +1568,7 @@ class UnifiedJob(
|
||||
by AWX, for purposes of client playbook hooks
|
||||
"""
|
||||
r = {}
|
||||
prefixes = get_job_variable_prefixes()
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_job_id'.format(name)] = self.pk
|
||||
r['{}_job_launch_type'.format(name)] = self.launch_type
|
||||
|
||||
@@ -1579,7 +1577,7 @@ class UnifiedJob(
|
||||
wj = self.get_workflow_job()
|
||||
if wj:
|
||||
schedule = getattr_dne(wj, 'schedule')
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_workflow_job_id'.format(name)] = wj.pk
|
||||
r['{}_workflow_job_name'.format(name)] = wj.name
|
||||
r['{}_workflow_job_launch_type'.format(name)] = wj.launch_type
|
||||
@@ -1590,12 +1588,12 @@ class UnifiedJob(
|
||||
if not created_by:
|
||||
schedule = getattr_dne(self, 'schedule')
|
||||
if schedule:
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_schedule_id'.format(name)] = schedule.pk
|
||||
r['{}_schedule_name'.format(name)] = schedule.name
|
||||
|
||||
if created_by:
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_user_id'.format(name)] = created_by.pk
|
||||
r['{}_user_name'.format(name)] = created_by.username
|
||||
r['{}_user_email'.format(name)] = created_by.email
|
||||
@@ -1604,7 +1602,7 @@ class UnifiedJob(
|
||||
|
||||
inventory = getattr_dne(self, 'inventory')
|
||||
if inventory:
|
||||
for name in prefixes:
|
||||
for name in JOB_VARIABLE_PREFIXES:
|
||||
r['{}_inventory_id'.format(name)] = inventory.pk
|
||||
r['{}_inventory_name'.format(name)] = inventory.name
|
||||
|
||||
|
||||
@@ -200,7 +200,6 @@ class WorkflowJobTemplateNode(WorkflowNodeBase):
|
||||
indexes = [
|
||||
models.Index(fields=['identifier']),
|
||||
]
|
||||
ordering = ('pk',)
|
||||
|
||||
def get_absolute_url(self, request=None):
|
||||
return reverse('api:workflow_job_template_node_detail', kwargs={'pk': self.pk}, request=request)
|
||||
@@ -287,7 +286,6 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
models.Index(fields=["identifier", "workflow_job"]),
|
||||
models.Index(fields=['identifier']),
|
||||
]
|
||||
ordering = ('pk',)
|
||||
|
||||
@property
|
||||
def event_processing_finished(self):
|
||||
@@ -345,11 +343,7 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
)
|
||||
data.update(accepted_fields) # missing fields are handled in the scheduler
|
||||
# build ancestor artifacts, save them to node model for later
|
||||
# initialize from pre-seeded ancestor_artifacts (set on root nodes of
|
||||
# child workflows via seed_root_ancestor_artifacts to carry artifacts
|
||||
# from the parent workflow); exclude job_slice which is internal
|
||||
# metadata handled separately below
|
||||
aa_dict = {k: v for k, v in self.ancestor_artifacts.items() if k != 'job_slice'} if self.ancestor_artifacts else {}
|
||||
aa_dict = {}
|
||||
is_root_node = True
|
||||
for parent_node in self.get_parent_nodes():
|
||||
is_root_node = False
|
||||
@@ -370,13 +364,11 @@ class WorkflowJobNode(WorkflowNodeBase):
|
||||
data['survey_passwords'] = password_dict
|
||||
# process extra_vars
|
||||
extra_vars = data.get('extra_vars', {})
|
||||
if ujt_obj and isinstance(ujt_obj, JobTemplate):
|
||||
if ujt_obj and isinstance(ujt_obj, (JobTemplate, WorkflowJobTemplate)):
|
||||
if aa_dict:
|
||||
functional_aa_dict = copy(aa_dict)
|
||||
functional_aa_dict.pop('_ansible_no_log', None)
|
||||
extra_vars.update(functional_aa_dict)
|
||||
elif ujt_obj and isinstance(ujt_obj, WorkflowJobTemplate):
|
||||
pass # artifacts are applied via seed_root_ancestor_artifacts in the task manager
|
||||
|
||||
# Workflow Job extra_vars higher precedence than ancestor artifacts
|
||||
extra_vars.update(wj_special_vars)
|
||||
@@ -740,18 +732,6 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
||||
wj = wj.get_workflow_job()
|
||||
return ancestors
|
||||
|
||||
def seed_root_ancestor_artifacts(self, artifacts):
|
||||
"""Apply parent workflow artifacts to root nodes so they propagate
|
||||
through the normal ancestor_artifacts channel instead of being
|
||||
baked into this workflow's extra_vars."""
|
||||
self.workflow_job_nodes.exclude(
|
||||
workflowjobnodes_success__isnull=False,
|
||||
).exclude(
|
||||
workflowjobnodes_failure__isnull=False,
|
||||
).exclude(
|
||||
workflowjobnodes_always__isnull=False,
|
||||
).update(ancestor_artifacts=artifacts)
|
||||
|
||||
def get_effective_artifacts(self, **kwargs):
|
||||
"""
|
||||
For downstream jobs of a workflow nested inside of a workflow,
|
||||
@@ -900,7 +880,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
||||
return 'workflow_approval_template'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = list(kwargs.get('update_fields') or [])
|
||||
update_fields = list(kwargs.get('update_fields', []))
|
||||
if self.timeout != 0 and ((not self.pk) or (not update_fields) or ('timeout' in update_fields)):
|
||||
if not self.created: # on creation, created will be set by parent class, so we fudge it here
|
||||
created = now()
|
||||
@@ -936,17 +916,6 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
||||
ScheduleWorkflowManager().schedule()
|
||||
return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
def cancel(self, job_explanation=None, is_chain=False):
|
||||
# WorkflowApprovals have no dispatcher process (they wait for human
|
||||
# input) and are excluded from TaskManager processing, so the base
|
||||
# cancel() would only set cancel_flag without ever transitioning the
|
||||
# status. We call super() for the flag, then transition directly.
|
||||
has_already_canceled = bool(self.status == 'canceled')
|
||||
super().cancel(job_explanation=job_explanation, is_chain=is_chain)
|
||||
if self.status != 'canceled' and not has_already_canceled:
|
||||
self.status = 'canceled'
|
||||
self.save(update_fields=['status'])
|
||||
|
||||
def signal_start(self, **kwargs):
|
||||
can_start = super(WorkflowApproval, self).signal_start(**kwargs)
|
||||
self.started = self.created
|
||||
|
||||
@@ -19,8 +19,13 @@ class ActivityStreamRegistrar(object):
|
||||
pre_delete.connect(activity_stream_delete, sender=model, dispatch_uid=str(self.__class__) + str(model) + "_delete")
|
||||
|
||||
for m2mfield in model._meta.many_to_many:
|
||||
m2m_attr = getattr(model, m2mfield.name)
|
||||
m2m_changed.connect(activity_stream_associate, sender=m2m_attr.through, dispatch_uid=str(self.__class__) + str(m2m_attr.through) + "_associate")
|
||||
try:
|
||||
m2m_attr = getattr(model, m2mfield.name)
|
||||
m2m_changed.connect(
|
||||
activity_stream_associate, sender=m2m_attr.through, dispatch_uid=str(self.__class__) + str(m2m_attr.through) + "_associate"
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def disconnect(self, model):
|
||||
if model in self.models:
|
||||
|
||||
@@ -48,6 +48,11 @@ class SimpleDAG(object):
|
||||
'''
|
||||
self.node_to_edges_by_label = dict()
|
||||
|
||||
def __contains__(self, obj):
|
||||
if self.node['node_object'] in self.node_obj_to_node_index:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __len__(self):
|
||||
return len(self.nodes)
|
||||
|
||||
|
||||
@@ -122,11 +122,8 @@ class WorkflowDAG(SimpleDAG):
|
||||
if not job:
|
||||
continue
|
||||
elif job.can_cancel:
|
||||
cancel_finished = False
|
||||
job.cancel()
|
||||
# If the job is not yet in a terminal state after .cancel(),
|
||||
# the TaskManager still needs to process it.
|
||||
if job.status not in ('successful', 'failed', 'canceled', 'error'):
|
||||
cancel_finished = False
|
||||
return cancel_finished
|
||||
|
||||
def is_workflow_done(self):
|
||||
|
||||
@@ -196,10 +196,6 @@ class WorkflowManager(TaskBase):
|
||||
workflow_job.start_args = '' # blank field to remove encrypted passwords
|
||||
workflow_job.save(update_fields=['status', 'start_args'])
|
||||
status_changed = True
|
||||
else:
|
||||
# Speed-up: schedule the task manager so it can process the
|
||||
# canceled pending jobs without waiting for the next cycle.
|
||||
ScheduleTaskManager().schedule()
|
||||
else:
|
||||
dnr_nodes = dag.mark_dnr_nodes()
|
||||
WorkflowJobNode.objects.bulk_update(dnr_nodes, ['do_not_run'])
|
||||
@@ -241,8 +237,6 @@ class WorkflowManager(TaskBase):
|
||||
job = spawn_node.unified_job_template.create_unified_job(**kv)
|
||||
spawn_node.job = job
|
||||
spawn_node.save()
|
||||
if spawn_node.ancestor_artifacts and isinstance(spawn_node.unified_job_template, WorkflowJobTemplate):
|
||||
job.seed_root_ancestor_artifacts(spawn_node.ancestor_artifacts)
|
||||
logger.debug('Spawned %s in %s for node %s', job.log_format, workflow_job.log_format, spawn_node.pk)
|
||||
can_start = True
|
||||
if isinstance(spawn_node.unified_job_template, WorkflowJobTemplate):
|
||||
@@ -449,29 +443,17 @@ class TaskManager(TaskBase):
|
||||
self.controlplane_ig = self.tm_models.instance_groups.controlplane_ig
|
||||
|
||||
def process_job_dep_failures(self, task):
|
||||
"""If job depends on a job that has failed or been canceled, mark as failed.
|
||||
|
||||
Returns True if a dep failure was found, False otherwise.
|
||||
"""
|
||||
"""If job depends on a job that has failed, mark as failed and handle misc stuff."""
|
||||
for dep in task.dependent_jobs.all():
|
||||
# if we detect a failed, error, or canceled dependency, go ahead and fail this task.
|
||||
if dep.status in ("error", "failed", "canceled"):
|
||||
# if we detect a failed or error dependency, go ahead and fail this task.
|
||||
if dep.status in ("error", "failed"):
|
||||
task.status = 'failed'
|
||||
if dep.status == 'canceled':
|
||||
logger.warning(f'Previous task canceled, failing task: {task.id} dep: {dep.id} task manager')
|
||||
task.job_explanation = 'Previous Task Canceled: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
||||
get_type_for_model(type(dep)),
|
||||
dep.name,
|
||||
dep.id,
|
||||
)
|
||||
ScheduleWorkflowManager().schedule() # speedup for dependency chains in workflow, on workflow cancel
|
||||
else:
|
||||
logger.warning(f'Previous task failed, failing task: {task.id} dep: {dep.id} task manager')
|
||||
task.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
||||
get_type_for_model(type(dep)),
|
||||
dep.name,
|
||||
dep.id,
|
||||
)
|
||||
logger.warning(f'Previous task failed task: {task.id} dep: {dep.id} task manager')
|
||||
task.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
||||
get_type_for_model(type(dep)),
|
||||
dep.name,
|
||||
dep.id,
|
||||
)
|
||||
task.save(update_fields=['status', 'job_explanation'])
|
||||
task.websocket_emit_status('failed')
|
||||
self.pre_start_failed.append(task.id)
|
||||
@@ -563,17 +545,8 @@ class TaskManager(TaskBase):
|
||||
logger.warning("Task manager has reached time out while processing pending jobs, exiting loop early")
|
||||
break
|
||||
|
||||
if task.cancel_flag:
|
||||
logger.debug(f"Canceling pending task {task.log_format} because cancel_flag is set")
|
||||
task.status = 'canceled'
|
||||
task.job_explanation = gettext_noop("This job was canceled before it started.")
|
||||
task.save(update_fields=['status', 'job_explanation'])
|
||||
task.websocket_emit_status('canceled')
|
||||
self.pre_start_failed.append(task.id)
|
||||
ScheduleWorkflowManager().schedule()
|
||||
continue
|
||||
|
||||
if self.process_job_dep_failures(task):
|
||||
has_failed = self.process_job_dep_failures(task)
|
||||
if has_failed:
|
||||
continue
|
||||
|
||||
blocked_by = self.job_blocked_by(task)
|
||||
@@ -688,17 +661,6 @@ class TaskManager(TaskBase):
|
||||
logger.error(f'{j.execution_node} is not a registered instance; reaping {j.log_format}')
|
||||
reap_job(j, 'failed')
|
||||
|
||||
# Reset waiting jobs whose controller_node was deprovisioned (e.g. K8s pod replaced).
|
||||
# These jobs will never be picked up because no live node is listening for them.
|
||||
registered_control_nodes = Instance.objects.filter(node_type__in=('control', 'hybrid')).values_list('hostname', flat=True)
|
||||
orphaned_waiting = UnifiedJob.objects.filter(status='waiting').exclude(controller_node__in=registered_control_nodes)
|
||||
for j in orphaned_waiting:
|
||||
logger.warning(f'{j.controller_node} is not a registered instance; resetting {j.log_format} to pending')
|
||||
j.status = 'pending'
|
||||
j.controller_node = ''
|
||||
j.execution_node = ''
|
||||
j.save(update_fields=['status', 'controller_node', 'execution_node'])
|
||||
|
||||
def process_tasks(self):
|
||||
# maintain a list of jobs that went to an early failure state,
|
||||
# meaning the dispatcher never got these jobs,
|
||||
|
||||
@@ -36,6 +36,7 @@ from awx.main.models import (
|
||||
Inventory,
|
||||
InventorySource,
|
||||
Job,
|
||||
JobHostSummary,
|
||||
Organization,
|
||||
Project,
|
||||
Role,
|
||||
@@ -250,9 +251,45 @@ def migrate_children_from_deleted_group_to_parent_groups(sender, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
# Host.last_job and Host.last_job_host_summary are now derived from
|
||||
# JobHostSummary.latest_for_host / latest_job_for_host.
|
||||
# No signal handlers needed to maintain these denormalized FKs.
|
||||
# Update host pointers to last_job and last_job_host_summary when a job is deleted
|
||||
|
||||
|
||||
def _update_host_last_jhs(host):
|
||||
jhs_qs = JobHostSummary.objects.filter(host__pk=host.pk)
|
||||
try:
|
||||
jhs = jhs_qs.order_by('-job__pk')[0]
|
||||
except IndexError:
|
||||
jhs = None
|
||||
update_fields = []
|
||||
try:
|
||||
last_job = jhs.job if jhs else None
|
||||
except Job.DoesNotExist:
|
||||
# The job (and its summaries) have already been/are currently being
|
||||
# deleted, so there's no need to update the host w/ a reference to it
|
||||
return
|
||||
if host.last_job != last_job:
|
||||
host.last_job = last_job
|
||||
update_fields.append('last_job')
|
||||
if host.last_job_host_summary != jhs:
|
||||
host.last_job_host_summary = jhs
|
||||
update_fields.append('last_job_host_summary')
|
||||
if update_fields:
|
||||
host.save(update_fields=update_fields)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Job)
|
||||
def save_host_pks_before_job_delete(sender, **kwargs):
|
||||
instance = kwargs['instance']
|
||||
hosts_qs = Host.objects.filter(last_job__pk=instance.pk)
|
||||
instance._saved_hosts_pks = set(hosts_qs.values_list('pk', flat=True))
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Job)
|
||||
def update_host_last_job_after_job_deleted(sender, **kwargs):
|
||||
instance = kwargs['instance']
|
||||
hosts_pks = getattr(instance, '_saved_hosts_pks', [])
|
||||
for host in Host.objects.filter(pk__in=hosts_pks):
|
||||
_update_host_last_jhs(host)
|
||||
|
||||
|
||||
# Set via ActivityStreamRegistrar to record activity stream events
|
||||
|
||||
@@ -54,6 +54,9 @@ def try_load_query_file(artifact_dir) -> Tuple[bool, Optional[dict]]:
|
||||
returns the contents of ansible_data.json if present
|
||||
"""
|
||||
|
||||
if not flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
|
||||
return False, None
|
||||
|
||||
queries_path = os.path.join(artifact_dir, COLLECTION_FILENAME)
|
||||
if not os.path.isfile(queries_path):
|
||||
logger.info(f"no query file found: {queries_path}")
|
||||
@@ -274,6 +277,20 @@ class RunnerCallback:
|
||||
def artifacts_handler(self, artifact_dir):
|
||||
success, query_file_contents = try_load_query_file(artifact_dir)
|
||||
if success:
|
||||
self.delay_update(event_queries_processed=False)
|
||||
collections_info = collect_queries(query_file_contents)
|
||||
for collection, data in collections_info.items():
|
||||
version = data['version']
|
||||
event_query = data['host_query']
|
||||
instance = EventQuery(fqcn=collection, collection_version=version, event_query=event_query)
|
||||
try:
|
||||
instance.validate_unique()
|
||||
instance.save()
|
||||
|
||||
logger.info(f"eventy query for collection {collection}, version {version} created")
|
||||
except ValidationError as e:
|
||||
logger.info(e)
|
||||
|
||||
if 'installed_collections' in query_file_contents:
|
||||
self.delay_update(installed_collections=query_file_contents['installed_collections'])
|
||||
else:
|
||||
@@ -284,21 +301,6 @@ class RunnerCallback:
|
||||
else:
|
||||
logger.warning(f'The file {COLLECTION_FILENAME} unexpectedly did not contain ansible_version')
|
||||
|
||||
if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
|
||||
self.delay_update(event_queries_processed=False)
|
||||
collections_info = collect_queries(query_file_contents)
|
||||
for collection, data in collections_info.items():
|
||||
version = data['version']
|
||||
event_query = data['host_query']
|
||||
instance = EventQuery(fqcn=collection, collection_version=version, event_query=event_query)
|
||||
try:
|
||||
instance.validate_unique()
|
||||
instance.save()
|
||||
|
||||
logger.info(f"event query for collection {collection}, version {version} created")
|
||||
except ValidationError as e:
|
||||
logger.info(e)
|
||||
|
||||
self.artifacts_processed = True
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ def start_fact_cache(hosts, artifacts_dir, timeout=None, inventory_id=None, log_
|
||||
log_data = log_data or {}
|
||||
log_data['inventory_id'] = inventory_id
|
||||
log_data['written_ct'] = 0
|
||||
# Dict mapping host name -> bool (True if a fact file was written)
|
||||
hosts_cached = {}
|
||||
hosts_cached = []
|
||||
|
||||
# Create the fact_cache directory inside artifacts_dir
|
||||
fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache')
|
||||
@@ -38,14 +37,13 @@ def start_fact_cache(hosts, artifacts_dir, timeout=None, inventory_id=None, log_
|
||||
last_write_time = None
|
||||
|
||||
for host in hosts:
|
||||
hosts_cached.append(host.name)
|
||||
if not host.ansible_facts_modified or (timeout and host.ansible_facts_modified < now() - datetime.timedelta(seconds=timeout)):
|
||||
hosts_cached[host.name] = False
|
||||
continue # facts are expired - do not write them
|
||||
|
||||
filepath = os.path.join(fact_cache_dir, host.name)
|
||||
if not os.path.realpath(filepath).startswith(fact_cache_dir):
|
||||
logger.error(f'facts for host {smart_str(host.name)} could not be cached')
|
||||
hosts_cached[host.name] = False
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -53,18 +51,9 @@ def start_fact_cache(hosts, artifacts_dir, timeout=None, inventory_id=None, log_
|
||||
os.chmod(f.name, 0o600)
|
||||
json.dump(host.ansible_facts, f)
|
||||
log_data['written_ct'] += 1
|
||||
# Backdate the file by 2 seconds so finish_fact_cache can reliably
|
||||
# distinguish these reference files from files updated by ansible.
|
||||
# This guarantees fact file mtime < summary file mtime even with
|
||||
# zipfile's 2-second timestamp rounding during artifact transfer.
|
||||
mtime = os.path.getmtime(filepath)
|
||||
backdated = mtime - 2
|
||||
os.utime(filepath, (backdated, backdated))
|
||||
last_write_time = backdated
|
||||
hosts_cached[host.name] = True
|
||||
last_write_time = os.path.getmtime(filepath)
|
||||
except IOError:
|
||||
logger.error(f'facts for host {smart_str(host.name)} could not be cached')
|
||||
hosts_cached[host.name] = False
|
||||
continue
|
||||
|
||||
# Write summary file directly to the artifacts_dir
|
||||
@@ -73,6 +62,7 @@ def start_fact_cache(hosts, artifacts_dir, timeout=None, inventory_id=None, log_
|
||||
summary_data = {
|
||||
'last_write_time': last_write_time,
|
||||
'hosts_cached': hosts_cached,
|
||||
'written_ct': log_data['written_ct'],
|
||||
}
|
||||
with open(summary_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(summary_data, f, indent=2)
|
||||
@@ -84,7 +74,7 @@ def start_fact_cache(hosts, artifacts_dir, timeout=None, inventory_id=None, log_
|
||||
msg='Inventory {inventory_id} host facts: updated {updated_ct}, cleared {cleared_ct}, unchanged {unmodified_ct}, took {delta:.3f} s',
|
||||
add_log_data=True,
|
||||
)
|
||||
def finish_fact_cache(host_qs, artifacts_dir, job_id=None, inventory_id=None, job_created=None, log_data=None):
|
||||
def finish_fact_cache(artifacts_dir, job_id=None, inventory_id=None, log_data=None):
|
||||
log_data = log_data or {}
|
||||
log_data['inventory_id'] = inventory_id
|
||||
log_data['updated_ct'] = 0
|
||||
@@ -99,118 +89,63 @@ def finish_fact_cache(host_qs, artifacts_dir, job_id=None, inventory_id=None, jo
|
||||
try:
|
||||
with open(summary_path, 'r', encoding='utf-8') as f:
|
||||
summary = json.load(f)
|
||||
facts_write_time = os.path.getmtime(summary_path)
|
||||
facts_write_time = os.path.getmtime(summary_path) # After successful read
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f'Error reading summary file at {summary_path}: {e}')
|
||||
return
|
||||
|
||||
hosts_cached_map = summary.get('hosts_cached', {})
|
||||
host_names = summary.get('hosts_cached', [])
|
||||
hosts_cached = Host.objects.filter(name__in=host_names).order_by('id').iterator()
|
||||
# Path where individual fact files were written
|
||||
fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache')
|
||||
hosts_to_update = []
|
||||
|
||||
# Phase 1: Scan files on disk to discover which hosts have updated or missing facts
|
||||
hosts_with_updates = set() # hostnames whose fact file was modified by Ansible
|
||||
hosts_to_clear = [] # hostnames where Ansible removed the fact file
|
||||
seen_in_dir = set() # hostnames we found as files on disk
|
||||
for host in hosts_cached:
|
||||
filepath = os.path.join(fact_cache_dir, host.name)
|
||||
if not os.path.realpath(filepath).startswith(fact_cache_dir):
|
||||
logger.error(f'Invalid path for facts file: {filepath}')
|
||||
continue
|
||||
|
||||
if os.path.isdir(fact_cache_dir):
|
||||
for filename in os.listdir(fact_cache_dir):
|
||||
if filename not in hosts_cached_map:
|
||||
continue # not an expected host for this job
|
||||
if os.path.exists(filepath):
|
||||
# If the file changed since we wrote the last facts file, pre-playbook run...
|
||||
modified = os.path.getmtime(filepath)
|
||||
if not facts_write_time or modified >= facts_write_time:
|
||||
try:
|
||||
with codecs.open(filepath, 'r', encoding='utf-8') as f:
|
||||
ansible_facts = json.load(f)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
filepath = os.path.join(fact_cache_dir, filename)
|
||||
if os.path.islink(filepath):
|
||||
logger.error(f'Invalid path for facts file: {filepath}')
|
||||
continue
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
|
||||
seen_in_dir.add(filename)
|
||||
try:
|
||||
modified = os.path.getmtime(filepath)
|
||||
except OSError as e:
|
||||
logger.warning(f'Could not stat facts file {filepath}: {e}')
|
||||
continue
|
||||
if modified >= facts_write_time:
|
||||
hosts_with_updates.add(filename)
|
||||
if ansible_facts != host.ansible_facts:
|
||||
host.ansible_facts = ansible_facts
|
||||
host.ansible_facts_modified = now()
|
||||
hosts_to_update.append(host)
|
||||
logger.info(
|
||||
f'New fact for inventory {smart_str(host.inventory.name)} host {smart_str(host.name)}',
|
||||
extra=dict(
|
||||
inventory_id=host.inventory.id,
|
||||
host_name=host.name,
|
||||
ansible_facts=host.ansible_facts,
|
||||
ansible_facts_modified=host.ansible_facts_modified.isoformat(),
|
||||
job_id=job_id,
|
||||
),
|
||||
)
|
||||
log_data['updated_ct'] += 1
|
||||
else:
|
||||
log_data['unmodified_ct'] += 1
|
||||
else:
|
||||
log_data['unmodified_ct'] += 1
|
||||
|
||||
# Check for files we wrote pre-job that are now missing (Ansible cleared facts)
|
||||
for hostname, was_written in hosts_cached_map.items():
|
||||
if hostname in seen_in_dir:
|
||||
continue # already handled above
|
||||
if was_written:
|
||||
hosts_to_clear.append(hostname)
|
||||
else:
|
||||
log_data['unmodified_ct'] += 1
|
||||
# if the file goes missing, ansible removed it (likely via clear_facts)
|
||||
# if the file goes missing, but the host has not started facts, then we should not clear the facts
|
||||
host.ansible_facts = {}
|
||||
host.ansible_facts_modified = now()
|
||||
hosts_to_update.append(host)
|
||||
logger.info(f'Facts cleared for inventory {smart_str(host.inventory.name)} host {smart_str(host.name)}')
|
||||
log_data['cleared_ct'] += 1
|
||||
|
||||
# Phase 2: Stream updated facts to database in batches
|
||||
if hosts_with_updates:
|
||||
hosts_to_save = []
|
||||
total_rows_updated = 0
|
||||
for host in host_qs.filter(name__in=list(hosts_with_updates)).select_related('inventory').iterator():
|
||||
filepath = os.path.join(fact_cache_dir, host.name)
|
||||
try:
|
||||
with codecs.open(filepath, 'r', encoding='utf-8') as f:
|
||||
new_facts = json.load(f)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if len(hosts_to_update) >= 100:
|
||||
bulk_update_sorted_by_id(Host, hosts_to_update, fields=['ansible_facts', 'ansible_facts_modified'])
|
||||
hosts_to_update = []
|
||||
|
||||
if new_facts != host.ansible_facts:
|
||||
host.ansible_facts = new_facts
|
||||
host.ansible_facts_modified = now()
|
||||
hosts_to_save.append(host)
|
||||
logger.info(
|
||||
f'New fact for inventory {smart_str(host.inventory.name)} host {smart_str(host.name)}',
|
||||
extra=dict(
|
||||
inventory_id=host.inventory.id,
|
||||
host_name=host.name,
|
||||
ansible_facts=host.ansible_facts,
|
||||
ansible_facts_modified=host.ansible_facts_modified.isoformat(),
|
||||
job_id=job_id,
|
||||
),
|
||||
)
|
||||
log_data['updated_ct'] += 1
|
||||
else:
|
||||
log_data['unmodified_ct'] += 1
|
||||
|
||||
if len(hosts_to_save) >= 100:
|
||||
total_rows_updated += bulk_update_sorted_by_id(Host, hosts_to_save, fields=['ansible_facts', 'ansible_facts_modified'])
|
||||
hosts_to_save = []
|
||||
|
||||
if hosts_to_save:
|
||||
total_rows_updated += bulk_update_sorted_by_id(Host, hosts_to_save, fields=['ansible_facts', 'ansible_facts_modified'])
|
||||
|
||||
# Mismatch means a concurrent process changed or deleted hosts between our read and bulk update
|
||||
if total_rows_updated != log_data['updated_ct']:
|
||||
logger.warning(
|
||||
f'Fact update for inventory {inventory_id} job {job_id}: expected to update {log_data["updated_ct"]} hosts but {total_rows_updated} rows were changed'
|
||||
)
|
||||
|
||||
# Phase 3: Clear facts for hosts whose files were removed by Ansible
|
||||
if hosts_to_clear:
|
||||
hosts = list(host_qs.filter(name__in=hosts_to_clear).select_related('inventory'))
|
||||
clear_hosts = []
|
||||
for host in hosts:
|
||||
if job_created and host.ansible_facts_modified and host.ansible_facts_modified > job_created:
|
||||
logger.warning(
|
||||
f'Skipping fact clear for host {smart_str(host.name)} in job {job_id} '
|
||||
f'inventory {inventory_id}: host ansible_facts_modified '
|
||||
f'({host.ansible_facts_modified.isoformat()}) is after this job\'s '
|
||||
f'created time ({job_created.isoformat()}). '
|
||||
f'A concurrent job likely updated this host\'s facts while this job was running.'
|
||||
)
|
||||
log_data['unmodified_ct'] += 1
|
||||
else:
|
||||
host.ansible_facts = {}
|
||||
host.ansible_facts_modified = now()
|
||||
clear_hosts.append(host)
|
||||
logger.info(f'Facts cleared for inventory {smart_str(host.inventory.name)} host {smart_str(host.name)}')
|
||||
log_data['cleared_ct'] += 1
|
||||
|
||||
if clear_hosts:
|
||||
rows = bulk_update_sorted_by_id(Host, clear_hosts, fields=['ansible_facts', 'ansible_facts_modified'])
|
||||
if rows != len(clear_hosts):
|
||||
logger.warning(f'Fact clear for inventory {inventory_id} job {job_id}: expected to clear {len(clear_hosts)} hosts but {rows} rows were changed')
|
||||
|
||||
logger.debug(f'Updated {log_data["updated_ct"]} host facts for inventory {inventory_id} in job {job_id}')
|
||||
bulk_update_sorted_by_id(Host, hosts_to_update, fields=['ansible_facts', 'ansible_facts_modified'])
|
||||
|
||||
@@ -17,6 +17,7 @@ import urllib.parse as urlparse
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
# Shared code for the AWX platform
|
||||
from awx_plugins.interfaces._temporary_private_container_api import CONTAINER_ROOT, get_incontainer_path
|
||||
@@ -94,7 +95,6 @@ from flags.state import flag_enabled
|
||||
|
||||
# Workload Identity
|
||||
from ansible_base.lib.workload_identity.controller import AutomationControllerJobScope
|
||||
from awx.main.utils.workload_identity import retrieve_workload_identity_jwt_with_claims
|
||||
|
||||
logger = logging.getLogger('awx.main.tasks.jobs')
|
||||
|
||||
@@ -104,6 +104,11 @@ def populate_claims_for_workload(unified_job) -> dict:
|
||||
Extract JWT claims from a Controller workload for the aap_controller_automation_job scope.
|
||||
"""
|
||||
|
||||
# Related objects in the UnifiedJob model, applies to all job types
|
||||
organization = getattr_dne(unified_job, 'organization')
|
||||
ujt = getattr_dne(unified_job, 'unified_job_template')
|
||||
instance_group = getattr_dne(unified_job, 'instance_group')
|
||||
|
||||
claims = {
|
||||
AutomationControllerJobScope.CLAIM_JOB_ID: unified_job.id,
|
||||
AutomationControllerJobScope.CLAIM_JOB_NAME: unified_job.name,
|
||||
@@ -158,24 +163,6 @@ def populate_claims_for_workload(unified_job) -> dict:
|
||||
return claims
|
||||
|
||||
|
||||
def retrieve_workload_identity_jwt(
|
||||
unified_job: UnifiedJob,
|
||||
audience: str,
|
||||
scope: str,
|
||||
workload_ttl_seconds: int | None = None,
|
||||
) -> str:
|
||||
"""Retrieve JWT token from workload claims.
|
||||
Raises:
|
||||
RuntimeError: if the workload identity client is not configured.
|
||||
"""
|
||||
return retrieve_workload_identity_jwt_with_claims(
|
||||
populate_claims_for_workload(unified_job),
|
||||
audience,
|
||||
scope,
|
||||
workload_ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
def with_path_cleanup(f):
|
||||
@functools.wraps(f)
|
||||
def _wrapped(self, *args, **kwargs):
|
||||
@@ -202,7 +189,6 @@ def dispatch_waiting_jobs(binder):
|
||||
if not kwargs:
|
||||
kwargs = {}
|
||||
binder.control('run', data={'task': serialize_task(uj._get_task_class()), 'args': [uj.id], 'kwargs': kwargs, 'uuid': uj.celery_task_id})
|
||||
UnifiedJob.objects.filter(pk=uj.pk, status='waiting').update(status='running', start_args='')
|
||||
|
||||
|
||||
class BaseTask(object):
|
||||
@@ -217,63 +203,6 @@ class BaseTask(object):
|
||||
self.update_attempts = int(getattr(settings, 'DISPATCHER_DB_DOWNTOWN_TOLLERANCE', settings.DISPATCHER_DB_DOWNTIME_TOLERANCE) / 5)
|
||||
self.runner_callback = self.callback_class(model=self.model)
|
||||
|
||||
@functools.cached_property
|
||||
def _credentials(self):
|
||||
"""
|
||||
Credentials for the task execution.
|
||||
Fetches credentials once using build_credentials_list() and stores
|
||||
them for the duration of the task to avoid redundant database queries.
|
||||
"""
|
||||
credentials_list = self.build_credentials_list(self.instance)
|
||||
# Convert to list to prevent re-evaluation of QuerySet
|
||||
return list(credentials_list)
|
||||
|
||||
def populate_workload_identity_tokens(self, additional_credentials=None):
|
||||
"""
|
||||
Populate credentials with workload identity tokens.
|
||||
|
||||
Sets the context on Credential objects that have input sources
|
||||
using compatible external credential types.
|
||||
"""
|
||||
credentials = list(self._credentials)
|
||||
if additional_credentials:
|
||||
credentials.extend(additional_credentials)
|
||||
credential_input_sources = (
|
||||
(credential.context, src)
|
||||
for credential in credentials
|
||||
for src in credential.input_sources.all()
|
||||
if any(
|
||||
field.get('id') == 'workload_identity_token' and field.get('internal')
|
||||
for field in src.source_credential.credential_type.inputs.get('fields', [])
|
||||
)
|
||||
)
|
||||
for credential_ctx, input_src in credential_input_sources:
|
||||
if flag_enabled("FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED"):
|
||||
effective_timeout = self.get_instance_timeout(self.instance)
|
||||
workload_ttl = effective_timeout if effective_timeout else None
|
||||
try:
|
||||
jwt = retrieve_workload_identity_jwt(
|
||||
self.instance,
|
||||
audience=input_src.source_credential.get_input('url'),
|
||||
scope=AutomationControllerJobScope.name,
|
||||
workload_ttl_seconds=workload_ttl,
|
||||
)
|
||||
# Store token keyed by input source PK, since a credential can have
|
||||
# multiple input sources (one per field), each potentially with a different audience
|
||||
credential_ctx[input_src.pk] = {"workload_identity_token": jwt}
|
||||
except Exception as e:
|
||||
self.instance.job_explanation = (
|
||||
f'Could not generate workload identity token for credential {input_src.source_credential.name} used in this job. Error:\n{e}'
|
||||
)
|
||||
self.instance.status = 'error'
|
||||
self.instance.save()
|
||||
else:
|
||||
self.instance.job_explanation = (
|
||||
f'Flag FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED is not enabled, required for credential {input_src.source_credential.name} used in this job.'
|
||||
)
|
||||
self.instance.status = 'error'
|
||||
self.instance.save()
|
||||
|
||||
def update_model(self, pk, _attempt=0, **updates):
|
||||
return update_model(self.model, pk, _attempt=0, _max_attempts=self.update_attempts, **updates)
|
||||
|
||||
@@ -425,19 +354,6 @@ class BaseTask(object):
|
||||
private_data_files['credentials'][credential] = self.write_private_data_file(private_data_dir, None, data, sub_dir='env')
|
||||
for credential, data in private_data.get('certificates', {}).items():
|
||||
self.write_private_data_file(private_data_dir, 'ssh_key_data-cert.pub', data, sub_dir=os.path.join('artifacts', str(self.instance.id)))
|
||||
|
||||
# Copy vendor collections to private_data_dir for indirect node counting
|
||||
# This makes external query files available to the callback plugin in EEs
|
||||
if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
|
||||
vendor_src = '/var/lib/awx/vendor_collections'
|
||||
vendor_dest = os.path.join(private_data_dir, 'vendor_collections')
|
||||
if os.path.exists(vendor_src):
|
||||
try:
|
||||
shutil.copytree(vendor_src, vendor_dest)
|
||||
logger.debug(f"Copied vendor collections from {vendor_src} to {vendor_dest}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to copy vendor collections: {e}")
|
||||
|
||||
return private_data_files, ssh_key_data
|
||||
|
||||
def build_passwords(self, instance, runtime_passwords):
|
||||
@@ -511,7 +427,6 @@ class BaseTask(object):
|
||||
return []
|
||||
|
||||
def get_instance_timeout(self, instance):
|
||||
"""Return the effective job timeout in seconds."""
|
||||
global_timeout_setting_name = instance._global_timeout_setting()
|
||||
if global_timeout_setting_name:
|
||||
global_timeout = getattr(settings, global_timeout_setting_name, 0)
|
||||
@@ -620,32 +535,48 @@ class BaseTask(object):
|
||||
def should_use_fact_cache(self):
|
||||
return False
|
||||
|
||||
def transition_status(self, pk: int) -> bool:
|
||||
"""Atomically transition status to running, if False returned, another process got it"""
|
||||
with transaction.atomic():
|
||||
# Explanation of parts for the fetch:
|
||||
# .values - avoid loading a full object, this is known to lead to deadlocks due to signals
|
||||
# the signals load other related rows which another process may be locking, and happens in practice
|
||||
# of=('self',) - keeps FK tables out of the lock list, another way deadlocks can happen
|
||||
# .get - just load the single job
|
||||
instance_data = UnifiedJob.objects.select_for_update(of=('self',)).values('status', 'cancel_flag').get(pk=pk)
|
||||
|
||||
# If status is not waiting (obtained under lock) then this process does not have clearence to run
|
||||
if instance_data['status'] == 'waiting':
|
||||
if instance_data['cancel_flag']:
|
||||
updated_status = 'canceled'
|
||||
else:
|
||||
updated_status = 'running'
|
||||
# Explanation of the update:
|
||||
# .filter - again, do not load the full object
|
||||
# .update - a bulk update on just that one row, avoid loading unintended data
|
||||
UnifiedJob.objects.filter(pk=pk).update(status=updated_status, start_args='')
|
||||
elif instance_data['status'] == 'running':
|
||||
logger.info(f'Job {pk} is being ran by another process, exiting')
|
||||
return False
|
||||
return True
|
||||
|
||||
@with_path_cleanup
|
||||
@with_signal_handling
|
||||
def run(self, pk, **kwargs):
|
||||
"""
|
||||
Run the job/task and capture its output.
|
||||
"""
|
||||
|
||||
if not self.instance: # Used to skip fetch for local runs
|
||||
# Load the instance
|
||||
self.instance = self.update_model(pk)
|
||||
|
||||
# status should be "running" from dispatch_waiting_jobs,
|
||||
# but may still be "waiting" if the worker picked this up before the status update landed.
|
||||
if self.instance.status == 'waiting':
|
||||
UnifiedJob.objects.filter(pk=pk).update(status="running", start_args='')
|
||||
self.instance.refresh_from_db()
|
||||
if not self.transition_status(pk):
|
||||
logger.info(f'Job {pk} is being ran by another process, exiting')
|
||||
return
|
||||
|
||||
# Load the instance
|
||||
self.instance = self.update_model(pk)
|
||||
if self.instance.status != 'running':
|
||||
logger.error(f'Not starting {self.instance.status} task pk={pk} because its status "{self.instance.status}" is not expected')
|
||||
return
|
||||
|
||||
if self.instance.cancel_flag:
|
||||
self.instance = self.update_model(pk, status='canceled')
|
||||
self.instance.websocket_emit_status('canceled')
|
||||
return
|
||||
|
||||
self.instance.websocket_emit_status("running")
|
||||
status, rc = 'error', None
|
||||
self.runner_callback.event_ct = 0
|
||||
@@ -684,12 +615,6 @@ class BaseTask(object):
|
||||
if not os.path.exists(settings.AWX_ISOLATION_BASE_PATH):
|
||||
raise RuntimeError('AWX_ISOLATION_BASE_PATH=%s does not exist' % settings.AWX_ISOLATION_BASE_PATH)
|
||||
|
||||
if flag_enabled("FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED"):
|
||||
logger.info(f'Generating workload identity tokens for {self.instance.log_format}')
|
||||
self.populate_workload_identity_tokens()
|
||||
if self.instance.status == 'error':
|
||||
raise RuntimeError('not starting %s task' % self.instance.status)
|
||||
|
||||
# May have to serialize the value
|
||||
private_data_files, ssh_key_data = self.build_private_data_files(self.instance, private_data_dir)
|
||||
passwords = self.build_passwords(self.instance, kwargs)
|
||||
@@ -707,7 +632,7 @@ class BaseTask(object):
|
||||
|
||||
self.runner_callback.job_created = str(self.instance.created)
|
||||
|
||||
credentials = self._credentials
|
||||
credentials = self.build_credentials_list(self.instance)
|
||||
|
||||
container_root = None
|
||||
if settings.IS_K8S and isinstance(self.instance, ProjectUpdate):
|
||||
@@ -1002,29 +927,6 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
model = Job
|
||||
event_model = JobEvent
|
||||
|
||||
def _extract_credentials_of_kind(self, kind: str):
|
||||
return (cred for cred in self._credentials if cred.credential_type.kind == kind)
|
||||
|
||||
@property
|
||||
def _machine_credential(self) -> object:
|
||||
"""Get machine credential."""
|
||||
return next(self._extract_credentials_of_kind('ssh'), None)
|
||||
|
||||
@property
|
||||
def _vault_credentials(self) -> list[object]:
|
||||
"""Get vault credentials."""
|
||||
return list(self._extract_credentials_of_kind('vault'))
|
||||
|
||||
@property
|
||||
def _network_credentials(self) -> list[object]:
|
||||
"""Get network credentials."""
|
||||
return list(self._extract_credentials_of_kind('net'))
|
||||
|
||||
@property
|
||||
def _cloud_credentials(self) -> list[object]:
|
||||
"""Get cloud credentials."""
|
||||
return list(self._extract_credentials_of_kind('cloud'))
|
||||
|
||||
def build_private_data(self, job, private_data_dir):
|
||||
"""
|
||||
Returns a dict of the form
|
||||
@@ -1042,7 +944,7 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
}
|
||||
"""
|
||||
private_data = {'credentials': {}}
|
||||
for credential in self._credentials:
|
||||
for credential in job.credentials.prefetch_related('input_sources__source_credential').all():
|
||||
# If we were sent SSH credentials, decrypt them and send them
|
||||
# back (they will be written to a temporary file).
|
||||
if credential.has_input('ssh_key_data'):
|
||||
@@ -1058,14 +960,14 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
and ansible-vault.
|
||||
"""
|
||||
passwords = super(RunJob, self).build_passwords(job, runtime_passwords)
|
||||
cred = self._machine_credential
|
||||
cred = job.machine_credential
|
||||
if cred:
|
||||
for field in ('ssh_key_unlock', 'ssh_password', 'become_password', 'vault_password'):
|
||||
value = runtime_passwords.get(field, cred.get_input('password' if field == 'ssh_password' else field, default=''))
|
||||
if value not in ('', 'ASK'):
|
||||
passwords[field] = value
|
||||
|
||||
for cred in self._vault_credentials:
|
||||
for cred in job.vault_credentials:
|
||||
field = 'vault_password'
|
||||
vault_id = cred.get_input('vault_id', default=None)
|
||||
if vault_id:
|
||||
@@ -1081,7 +983,7 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
key unlock over network key unlock.
|
||||
'''
|
||||
if 'ssh_key_unlock' not in passwords:
|
||||
for cred in self._network_credentials:
|
||||
for cred in job.network_credentials:
|
||||
if cred.inputs.get('ssh_key_unlock'):
|
||||
passwords['ssh_key_unlock'] = runtime_passwords.get('ssh_key_unlock', cred.get_input('ssh_key_unlock', default=''))
|
||||
break
|
||||
@@ -1116,11 +1018,11 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
|
||||
# Set environment variables for cloud credentials.
|
||||
cred_files = private_data_files.get('credentials', {})
|
||||
for cloud_cred in self._cloud_credentials:
|
||||
for cloud_cred in job.cloud_credentials:
|
||||
if cloud_cred and cloud_cred.credential_type.namespace == 'openstack' and cred_files.get(cloud_cred, ''):
|
||||
env['OS_CLIENT_CONFIG_FILE'] = get_incontainer_path(cred_files.get(cloud_cred, ''), private_data_dir)
|
||||
|
||||
for network_cred in self._network_credentials:
|
||||
for network_cred in job.network_credentials:
|
||||
env['ANSIBLE_NET_USERNAME'] = network_cred.get_input('username', default='')
|
||||
env['ANSIBLE_NET_PASSWORD'] = network_cred.get_input('password', default='')
|
||||
|
||||
@@ -1138,11 +1040,12 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
('ANSIBLE_COLLECTIONS_PATH', 'collections_path', 'requirements_collections', '~/.ansible/collections:/usr/share/ansible/collections'),
|
||||
]
|
||||
|
||||
path_vars.append(
|
||||
('ANSIBLE_CALLBACK_PLUGINS', 'callback_plugins', 'plugins_path', '~/.ansible/plugins:/plugins/callback:/usr/share/ansible/plugins/callback'),
|
||||
)
|
||||
if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
|
||||
path_vars.append(
|
||||
('ANSIBLE_CALLBACK_PLUGINS', 'callback_plugins', 'plugins_path', '~/.ansible/plugins:/plugins/callback:/usr/share/ansible/plugins/callback'),
|
||||
)
|
||||
|
||||
config_values = read_ansible_config(os.path.join(private_data_dir, 'project'), list(map(lambda x: x[1], path_vars)) + ['callbacks_enabled'])
|
||||
config_values = read_ansible_config(os.path.join(private_data_dir, 'project'), list(map(lambda x: x[1], path_vars)))
|
||||
|
||||
for env_key, config_setting, folder, default in path_vars:
|
||||
paths = default.split(':')
|
||||
@@ -1157,16 +1060,10 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
paths = [os.path.join(CONTAINER_ROOT, folder)] + paths
|
||||
env[env_key] = os.pathsep.join(paths)
|
||||
|
||||
env['ANSIBLE_CALLBACKS_ENABLED'] = 'indirect_instance_count'
|
||||
if 'callbacks_enabled' in config_values:
|
||||
env['ANSIBLE_CALLBACKS_ENABLED'] += ',' + config_values['callbacks_enabled']
|
||||
|
||||
if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
|
||||
env['AWX_COLLECT_HOST_QUERIES'] = '1'
|
||||
# Add vendor collections path for external query file discovery
|
||||
vendor_collections_path = os.path.join(CONTAINER_ROOT, 'vendor_collections')
|
||||
env['ANSIBLE_COLLECTIONS_PATH'] = f"{vendor_collections_path}:{env['ANSIBLE_COLLECTIONS_PATH']}"
|
||||
logger.debug(f"ANSIBLE_COLLECTIONS_PATH updated for vendor collections: {env['ANSIBLE_COLLECTIONS_PATH']}")
|
||||
env['ANSIBLE_CALLBACKS_ENABLED'] = 'indirect_instance_count'
|
||||
if 'callbacks_enabled' in config_values:
|
||||
env['ANSIBLE_CALLBACKS_ENABLED'] += ':' + config_values['callbacks_enabled']
|
||||
|
||||
return env
|
||||
|
||||
@@ -1175,7 +1072,7 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
Build command line argument list for running ansible-playbook,
|
||||
optionally using ssh-agent for public/private key authentication.
|
||||
"""
|
||||
creds = self._machine_credential
|
||||
creds = job.machine_credential
|
||||
|
||||
ssh_username, become_username, become_method = '', '', ''
|
||||
if creds:
|
||||
@@ -1327,17 +1224,10 @@ class RunJob(SourceControlMixin, BaseTask):
|
||||
return
|
||||
if self.should_use_fact_cache() and self.runner_callback.artifacts_processed:
|
||||
job.log_lifecycle("finish_job_fact_cache")
|
||||
if job.inventory.kind == 'constructed':
|
||||
hosts_qs = job.get_source_hosts_for_constructed_inventory()
|
||||
else:
|
||||
hosts_qs = job.inventory.hosts
|
||||
hosts_qs = hosts_qs.only(*HOST_FACTS_FIELDS)
|
||||
finish_fact_cache(
|
||||
hosts_qs,
|
||||
artifacts_dir=os.path.join(private_data_dir, 'artifacts', str(job.id)),
|
||||
job_id=job.id,
|
||||
inventory_id=job.inventory_id,
|
||||
job_created=job.created,
|
||||
)
|
||||
|
||||
def final_run_hook(self, job, status, private_data_dir):
|
||||
@@ -1612,14 +1502,16 @@ class RunProjectUpdate(BaseTask):
|
||||
shutil.copytree(cache_subpath, dest_subpath, symlinks=True)
|
||||
logger.debug('{0} {1} prepared {2} from cache'.format(type(project).__name__, project.pk, dest_subpath))
|
||||
|
||||
pdd_plugins_path = os.path.join(job_private_data_dir, 'plugins_path')
|
||||
if not os.path.exists(pdd_plugins_path):
|
||||
os.mkdir(pdd_plugins_path)
|
||||
from awx.playbooks import library
|
||||
if flag_enabled("FEATURE_INDIRECT_NODE_COUNTING_ENABLED"):
|
||||
# copy the special callback (not stdout type) plugin to get list of collections
|
||||
pdd_plugins_path = os.path.join(job_private_data_dir, 'plugins_path')
|
||||
if not os.path.exists(pdd_plugins_path):
|
||||
os.mkdir(pdd_plugins_path)
|
||||
from awx.playbooks import library
|
||||
|
||||
plugin_file_source = os.path.join(library.__path__[0], 'indirect_instance_count.py')
|
||||
plugin_file_dest = os.path.join(pdd_plugins_path, 'indirect_instance_count.py')
|
||||
shutil.copyfile(plugin_file_source, plugin_file_dest)
|
||||
plugin_file_source = os.path.join(library.__path__._path[0], 'indirect_instance_count.py')
|
||||
plugin_file_dest = os.path.join(pdd_plugins_path, 'indirect_instance_count.py')
|
||||
shutil.copyfile(plugin_file_source, plugin_file_dest)
|
||||
|
||||
def post_run_hook(self, instance, status):
|
||||
super(RunProjectUpdate, self).post_run_hook(instance, status)
|
||||
@@ -1682,7 +1574,7 @@ class RunProjectUpdate(BaseTask):
|
||||
return params
|
||||
|
||||
def build_credentials_list(self, project_update):
|
||||
if project_update.credential:
|
||||
if project_update.scm_type == 'insights' and project_update.credential:
|
||||
return [project_update.credential]
|
||||
return []
|
||||
|
||||
@@ -1865,24 +1757,6 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask):
|
||||
# All credentials not used by inventory source injector
|
||||
return inventory_update.get_extra_credentials()
|
||||
|
||||
def populate_workload_identity_tokens(self, additional_credentials=None):
|
||||
"""Also generate OIDC tokens for the cloud credential.
|
||||
|
||||
The cloud credential is not in _credentials (it is handled by the
|
||||
inventory source injector), but it may still need a workload identity
|
||||
token generated for it.
|
||||
"""
|
||||
cloud_cred = self.instance.get_cloud_credential()
|
||||
creds = list(additional_credentials or [])
|
||||
if cloud_cred:
|
||||
creds.append(cloud_cred)
|
||||
super().populate_workload_identity_tokens(additional_credentials=creds or None)
|
||||
# Override get_cloud_credential on this instance so the injector
|
||||
# uses the credential with OIDC context instead of doing a fresh
|
||||
# DB fetch that would lose it.
|
||||
if cloud_cred and cloud_cred.context:
|
||||
self.instance.get_cloud_credential = lambda: cloud_cred
|
||||
|
||||
def build_project_dir(self, inventory_update, private_data_dir):
|
||||
source_project = None
|
||||
if inventory_update.inventory_source:
|
||||
|
||||
@@ -393,9 +393,9 @@ def evaluate_policy(instance):
|
||||
raise PolicyEvaluationError(_('Following certificate settings are missing for OPA_AUTH_TYPE=Certificate: {}').format(cert_settings_missing))
|
||||
|
||||
query_paths = [
|
||||
('Organization', instance.organization.opa_query_path if instance.organization else None),
|
||||
('Inventory', instance.inventory.opa_query_path if instance.inventory else None),
|
||||
('Job template', instance.job_template.opa_query_path if instance.job_template else None),
|
||||
('Organization', instance.organization.opa_query_path),
|
||||
('Inventory', instance.inventory.opa_query_path),
|
||||
('Job template', instance.job_template.opa_query_path),
|
||||
]
|
||||
violations = dict()
|
||||
errors = dict()
|
||||
|
||||
@@ -19,7 +19,6 @@ from dispatcherd.publish import task
|
||||
# Runner
|
||||
import ansible_runner.cleanup
|
||||
import psycopg
|
||||
from ansible_base.lib.cache.tasks import clear_cache as dab_clear_cache
|
||||
from ansible_base.lib.utils.db import advisory_lock
|
||||
|
||||
# django-ansible-base
|
||||
@@ -69,12 +68,10 @@ from awx.main.models import (
|
||||
UnifiedJob,
|
||||
convert_jsonfields,
|
||||
)
|
||||
from awx.main.models.credential import CredentialType
|
||||
from awx.main.tasks.helpers import is_run_threshold_reached
|
||||
from awx.main.tasks.host_indirect import save_indirect_host_entries
|
||||
from awx.main.tasks.receptor import administrative_workunit_reaper, get_receptor_ctl, worker_cleanup, worker_info, write_receptor_config
|
||||
from awx.main.utils.common import ignore_inventory_computed_fields, ignore_inventory_group_removal
|
||||
from awx.main.utils.migration import is_database_synchronized
|
||||
from awx.main.utils.reload import stop_local_services
|
||||
|
||||
logger = logging.getLogger('awx.main.tasks.system')
|
||||
@@ -86,16 +83,6 @@ Try upgrading OpenSSH or providing your private key in an different format. \
|
||||
'''
|
||||
|
||||
|
||||
def _sync_credential_types_to_db():
|
||||
"""Ensure CredentialType DB rows match the installed plugins.
|
||||
|
||||
The in-memory registry is populated lazily on first access via LazyLoadDict.
|
||||
This function only handles the DB sync step.
|
||||
"""
|
||||
if is_database_synchronized():
|
||||
CredentialType.setup_tower_managed_defaults()
|
||||
|
||||
|
||||
def _run_dispatch_startup_common():
|
||||
"""
|
||||
Execute the common startup initialization steps.
|
||||
@@ -111,11 +98,6 @@ def _run_dispatch_startup_common():
|
||||
except Exception:
|
||||
logger.exception("Failed to write receptor config, skipping.")
|
||||
|
||||
try:
|
||||
_sync_credential_types_to_db()
|
||||
except Exception:
|
||||
logger.exception("Failed to sync credential types to DB, skipping.")
|
||||
|
||||
try:
|
||||
convert_jsonfields()
|
||||
except Exception:
|
||||
@@ -258,17 +240,12 @@ def apply_cluster_membership_policies():
|
||||
# Process policy instance list first, these will represent manually managed memberships
|
||||
instance_hostnames_map = {inst.hostname: inst for inst in all_instances}
|
||||
for ig in all_groups:
|
||||
# we don't want to allow execution nodes in the control plane
|
||||
exclude_type = 'execution' if ig.name == settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME else 'control'
|
||||
group_actual = Group(obj=ig, instances=[], prior_instances=[instance.pk for instance in ig.instances.all()]) # obtained in prefetch
|
||||
for hostname in ig.policy_instance_list:
|
||||
if hostname not in instance_hostnames_map:
|
||||
logger.info("Unknown instance {} in {} policy list".format(hostname, ig.name))
|
||||
continue
|
||||
inst = instance_hostnames_map[hostname]
|
||||
if inst.node_type == exclude_type:
|
||||
logger.info("Instance {} is excluded in {} policy list".format(hostname, ig.name))
|
||||
continue
|
||||
group_actual.instances.append(inst.id)
|
||||
# NOTE: arguable behavior: policy-list-group is not added to
|
||||
# instance's group count for consideration in minimum-policy rules
|
||||
@@ -349,22 +326,24 @@ def apply_cluster_membership_policies():
|
||||
logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
|
||||
|
||||
|
||||
def _resolve_setting_dependents(key):
|
||||
return settings_registry.get_dependent_settings(key)
|
||||
@task(queue='tower_settings_change', timeout=600)
|
||||
def clear_setting_cache(setting_keys):
|
||||
# log that cache is being cleared
|
||||
logger.info(f"clear_setting_cache of keys {setting_keys}")
|
||||
orig_len = len(setting_keys)
|
||||
for i in range(orig_len):
|
||||
for dependent_key in settings_registry.get_dependent_settings(setting_keys[i]):
|
||||
setting_keys.append(dependent_key)
|
||||
cache_keys = set(setting_keys)
|
||||
logger.debug('cache delete_many(%r)', cache_keys)
|
||||
cache.delete_many(cache_keys)
|
||||
|
||||
|
||||
def _post_setting_invalidation(invalidated_keys):
|
||||
if 'LOG_AGGREGATOR_LEVEL' in invalidated_keys:
|
||||
if 'LOG_AGGREGATOR_LEVEL' in setting_keys:
|
||||
ctl = get_control_from_settings()
|
||||
ctl.queuename = get_task_queuename()
|
||||
ctl.control('set_log_level', data={'level': settings.LOG_AGGREGATOR_LEVEL})
|
||||
|
||||
|
||||
@task(queue='tower_settings_change', timeout=600)
|
||||
def clear_setting_cache(setting_keys):
|
||||
dab_clear_cache(setting_keys, _resolve_setting_dependents, _post_setting_invalidation)
|
||||
|
||||
|
||||
@task(queue='tower_broadcast_all', timeout=600)
|
||||
def delete_project_files(project_path):
|
||||
# TODO: possibly implement some retry logic
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
authors:
|
||||
- AWX Project Contributors <awx-project@googlegroups.com>
|
||||
dependencies: {}
|
||||
description: External query testing collection. No embedded query file. Not for use in production.
|
||||
documentation: https://github.com/ansible/awx
|
||||
homepage: https://github.com/ansible/awx
|
||||
issues: https://github.com/ansible/awx
|
||||
license:
|
||||
- GPL-3.0-or-later
|
||||
name: external
|
||||
namespace: demo
|
||||
readme: README.md
|
||||
repository: https://github.com/ansible/awx
|
||||
tags:
|
||||
- demo
|
||||
- testing
|
||||
- external_query
|
||||
version: 1.0.0
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
# Same licensing as AWX
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: example
|
||||
|
||||
short_description: Module for specific live tests
|
||||
|
||||
version_added: "2.0.0"
|
||||
|
||||
description: This module is part of a test collection in local source. Used for external query testing.
|
||||
|
||||
options:
|
||||
host_name:
|
||||
description: Name to return as the host name.
|
||||
required: false
|
||||
type: str
|
||||
|
||||
author:
|
||||
- AWX Live Tests
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Test with defaults
|
||||
demo.external.example:
|
||||
|
||||
- name: Test with custom host name
|
||||
demo.external.example:
|
||||
host_name: foo_host
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
direct_host_name:
|
||||
description: The name of the host, this will be collected with the feature.
|
||||
type: str
|
||||
returned: always
|
||||
sample: 'foo_host'
|
||||
'''
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def run_module():
|
||||
module_args = dict(
|
||||
host_name=dict(type='str', required=False, default='foo_host_default'),
|
||||
)
|
||||
|
||||
result = dict(
|
||||
changed=False,
|
||||
other_data='sample_string',
|
||||
)
|
||||
|
||||
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
|
||||
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
result['direct_host_name'] = module.params['host_name']
|
||||
result['nested_host_name'] = {'host_name': module.params['host_name']}
|
||||
result['name'] = 'vm-foo'
|
||||
|
||||
# non-cononical facts
|
||||
result['device_type'] = 'Fake Host'
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
def main():
|
||||
run_module()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
authors:
|
||||
- AWX Project Contributors <awx-project@googlegroups.com>
|
||||
dependencies: {}
|
||||
description: External query testing collection v1.5.0. No embedded query file. Not for use in production.
|
||||
documentation: https://github.com/ansible/awx
|
||||
homepage: https://github.com/ansible/awx
|
||||
issues: https://github.com/ansible/awx
|
||||
license:
|
||||
- GPL-3.0-or-later
|
||||
name: external
|
||||
namespace: demo
|
||||
readme: README.md
|
||||
repository: https://github.com/ansible/awx
|
||||
tags:
|
||||
- demo
|
||||
- testing
|
||||
- external_query
|
||||
version: 1.5.0
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
# Same licensing as AWX
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: example
|
||||
|
||||
short_description: Module for specific live tests
|
||||
|
||||
version_added: "2.0.0"
|
||||
|
||||
description: This module is part of a test collection in local source. Used for external query testing.
|
||||
|
||||
options:
|
||||
host_name:
|
||||
description: Name to return as the host name.
|
||||
required: false
|
||||
type: str
|
||||
|
||||
author:
|
||||
- AWX Live Tests
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Test with defaults
|
||||
demo.external.example:
|
||||
|
||||
- name: Test with custom host name
|
||||
demo.external.example:
|
||||
host_name: foo_host
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
direct_host_name:
|
||||
description: The name of the host, this will be collected with the feature.
|
||||
type: str
|
||||
returned: always
|
||||
sample: 'foo_host'
|
||||
'''
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def run_module():
|
||||
module_args = dict(
|
||||
host_name=dict(type='str', required=False, default='foo_host_default'),
|
||||
)
|
||||
|
||||
result = dict(
|
||||
changed=False,
|
||||
other_data='sample_string',
|
||||
)
|
||||
|
||||
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
|
||||
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
result['direct_host_name'] = module.params['host_name']
|
||||
result['nested_host_name'] = {'host_name': module.params['host_name']}
|
||||
result['name'] = 'vm-foo'
|
||||
|
||||
# non-cononical facts
|
||||
result['device_type'] = 'Fake Host'
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
def main():
|
||||
run_module()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
authors:
|
||||
- AWX Project Contributors <awx-project@googlegroups.com>
|
||||
dependencies: {}
|
||||
description: External query testing collection v3.0.0. No embedded query file. Not for use in production.
|
||||
documentation: https://github.com/ansible/awx
|
||||
homepage: https://github.com/ansible/awx
|
||||
issues: https://github.com/ansible/awx
|
||||
license:
|
||||
- GPL-3.0-or-later
|
||||
name: external
|
||||
namespace: demo
|
||||
readme: README.md
|
||||
repository: https://github.com/ansible/awx
|
||||
tags:
|
||||
- demo
|
||||
- testing
|
||||
- external_query
|
||||
version: 3.0.0
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
# Same licensing as AWX
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: example
|
||||
|
||||
short_description: Module for specific live tests
|
||||
|
||||
version_added: "2.0.0"
|
||||
|
||||
description: This module is part of a test collection in local source. Used for external query testing.
|
||||
|
||||
options:
|
||||
host_name:
|
||||
description: Name to return as the host name.
|
||||
required: false
|
||||
type: str
|
||||
|
||||
author:
|
||||
- AWX Live Tests
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Test with defaults
|
||||
demo.external.example:
|
||||
|
||||
- name: Test with custom host name
|
||||
demo.external.example:
|
||||
host_name: foo_host
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
direct_host_name:
|
||||
description: The name of the host, this will be collected with the feature.
|
||||
type: str
|
||||
returned: always
|
||||
sample: 'foo_host'
|
||||
'''
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def run_module():
|
||||
module_args = dict(
|
||||
host_name=dict(type='str', required=False, default='foo_host_default'),
|
||||
)
|
||||
|
||||
result = dict(
|
||||
changed=False,
|
||||
other_data='sample_string',
|
||||
)
|
||||
|
||||
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
|
||||
|
||||
if module.check_mode:
|
||||
module.exit_json(**result)
|
||||
|
||||
result['direct_host_name'] = module.params['host_name']
|
||||
result['nested_host_name'] = {'host_name': module.params['host_name']}
|
||||
result['name'] = 'vm-foo'
|
||||
|
||||
# non-cononical facts
|
||||
result['device_type'] = 'Fake Host'
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
def main():
|
||||
run_module()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
- hosts: all
|
||||
gather_facts: false
|
||||
connection: local
|
||||
tasks:
|
||||
- name: Set artifacts via set_stats
|
||||
ansible.builtin.set_stats:
|
||||
data: "{{ stats_data }}"
|
||||
per_host: false
|
||||
aggregate: false
|
||||
when: stats_data is defined
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
# Generated by Claude Opus 4.6 (claude-opus-4-6).
|
||||
|
||||
- hosts: all
|
||||
vars:
|
||||
extra_value: ""
|
||||
gather_facts: false
|
||||
connection: local
|
||||
tasks:
|
||||
- name: set a custom fact
|
||||
set_fact:
|
||||
foo: "bar{{ extra_value }}"
|
||||
bar:
|
||||
a:
|
||||
b:
|
||||
- "c"
|
||||
- "d"
|
||||
cacheable: true
|
||||
- name: sleep to create overlap window for concurrent job testing
|
||||
wait_for:
|
||||
timeout: 2
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
collections:
|
||||
- name: 'file:///tmp/live_tests/host_query_external_v1_0_0'
|
||||
type: git
|
||||
version: devel
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- hosts: all
|
||||
gather_facts: false
|
||||
connection: local
|
||||
tasks:
|
||||
- demo.external.example:
|
||||
register: result
|
||||
- debug: var=result
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
collections:
|
||||
- name: 'file:///tmp/live_tests/host_query_external_v1_5_0'
|
||||
type: git
|
||||
version: devel
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- hosts: all
|
||||
gather_facts: false
|
||||
connection: local
|
||||
tasks:
|
||||
- demo.external.example:
|
||||
register: result
|
||||
- debug: var=result
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
collections:
|
||||
- name: 'file:///tmp/live_tests/host_query_external_v3_0_0'
|
||||
type: git
|
||||
version: devel
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- hosts: all
|
||||
gather_facts: false
|
||||
connection: local
|
||||
tasks:
|
||||
- demo.external.example:
|
||||
register: result
|
||||
- debug: var=result
|
||||
@@ -74,9 +74,9 @@ def temp_analytic_tar():
|
||||
|
||||
@pytest.fixture
|
||||
def mock_analytic_post():
|
||||
# Patch get_or_generate_candlepin_certificate to skip mTLS path
|
||||
with mock.patch('awx.main.analytics.core.get_or_generate_candlepin_certificate', return_value=(None, None)):
|
||||
yield
|
||||
# Patch the Session.post method to return a mock response with status_code 200
|
||||
with mock.patch('awx.main.analytics.core.requests.Session.post', return_value=mock.Mock(status_code=200)) as mock_post:
|
||||
yield mock_post
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -141,22 +141,15 @@ def mock_analytic_post():
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_ship_credential(setting_map, expected_result, expected_auth, temp_analytic_tar, mock_analytic_post):
|
||||
with override_settings(**setting_map, AUTOMATION_ANALYTICS_URL='https://example.com/api'):
|
||||
with mock.patch('awx.main.analytics.core.OIDCClient') as mock_oidc:
|
||||
mock_oidc_instance = mock.Mock()
|
||||
mock_oidc_instance.make_request.return_value = mock.Mock(status_code=200)
|
||||
mock_oidc.return_value = mock_oidc_instance
|
||||
with override_settings(**setting_map):
|
||||
result = ship(temp_analytic_tar)
|
||||
|
||||
result = ship(temp_analytic_tar)
|
||||
|
||||
assert result == expected_result
|
||||
if expected_auth:
|
||||
# Verify OIDC client was instantiated with correct credentials
|
||||
mock_oidc.assert_called_once_with(expected_auth[0], expected_auth[1])
|
||||
mock_oidc_instance.make_request.assert_called_once()
|
||||
else:
|
||||
# When credentials are missing, OIDCClient should not be called
|
||||
mock_oidc.assert_not_called()
|
||||
assert result == expected_result
|
||||
if expected_auth:
|
||||
mock_analytic_post.assert_called_once()
|
||||
assert mock_analytic_post.call_args[1]['auth'] == expected_auth
|
||||
else:
|
||||
mock_analytic_post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import pytest
|
||||
from awx.api.versioning import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from awx.main.models.jobs import JobTemplate
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestConfigEndpointFields:
|
||||
def test_base_fields_all_users(self, get, rando):
|
||||
url = reverse('api:api_v2_config_view')
|
||||
response = get(url, rando, expect=200)
|
||||
|
||||
assert 'time_zone' in response.data
|
||||
assert 'license_info' in response.data
|
||||
assert 'version' in response.data
|
||||
assert 'eula' in response.data
|
||||
assert 'analytics_status' in response.data
|
||||
assert 'analytics_collectors' in response.data
|
||||
assert 'become_methods' in response.data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role_type",
|
||||
[
|
||||
"superuser",
|
||||
"system_auditor",
|
||||
"org_admin",
|
||||
"org_auditor",
|
||||
"org_project_admin",
|
||||
],
|
||||
)
|
||||
def test_privileged_users_conditional_fields(self, get, user, organization, admin, role_type):
|
||||
url = reverse('api:api_v2_config_view')
|
||||
|
||||
if role_type == "superuser":
|
||||
test_user = admin
|
||||
elif role_type == "system_auditor":
|
||||
test_user = user('system-auditor', is_superuser=False)
|
||||
test_user.is_system_auditor = True
|
||||
test_user.save()
|
||||
elif role_type == "org_admin":
|
||||
test_user = user('org-admin', is_superuser=False)
|
||||
organization.admin_role.members.add(test_user)
|
||||
elif role_type == "org_auditor":
|
||||
test_user = user('org-auditor', is_superuser=False)
|
||||
organization.auditor_role.members.add(test_user)
|
||||
elif role_type == "org_project_admin":
|
||||
test_user = user('org-project-admin', is_superuser=False)
|
||||
organization.project_admin_role.members.add(test_user)
|
||||
|
||||
response = get(url, test_user, expect=200)
|
||||
|
||||
assert 'project_base_dir' in response.data
|
||||
assert 'project_local_paths' in response.data
|
||||
assert 'custom_virtualenvs' in response.data
|
||||
|
||||
def test_job_template_admin_gets_venvs_only(self, get, user, organization, project, inventory):
|
||||
"""Test that JobTemplate admin without org access gets only custom_virtualenvs"""
|
||||
jt_admin = user('jt-admin', is_superuser=False)
|
||||
|
||||
jt = JobTemplate.objects.create(name='test-jt', organization=organization, project=project, inventory=inventory)
|
||||
jt.admin_role.members.add(jt_admin)
|
||||
|
||||
url = reverse('api:api_v2_config_view')
|
||||
response = get(url, jt_admin, expect=200)
|
||||
|
||||
assert 'custom_virtualenvs' in response.data
|
||||
assert 'project_base_dir' not in response.data
|
||||
assert 'project_local_paths' not in response.data
|
||||
|
||||
def test_normal_user_no_conditional_fields(self, get, rando):
|
||||
url = reverse('api:api_v2_config_view')
|
||||
response = get(url, rando, expect=200)
|
||||
|
||||
assert 'project_base_dir' not in response.data
|
||||
assert 'project_local_paths' not in response.data
|
||||
assert 'custom_virtualenvs' not in response.data
|
||||
|
||||
def test_unauthenticated_denied(self, get):
|
||||
"""Test that unauthenticated requests are denied"""
|
||||
url = reverse('api:api_v2_config_view')
|
||||
response = get(url, None, expect=401)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -200,7 +200,6 @@ def test_grant_org_credential_to_org_user_through_user_roles(post, credential, o
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_org_credential_to_non_org_user_through_role_users(post, credential, organization, org_admin, alice):
|
||||
# NOTE: this endpoint is going away soon
|
||||
credential.organization = organization
|
||||
credential.save()
|
||||
response = post(reverse('api:role_users_list', kwargs={'pk': credential.use_role.id}), {'id': alice.id}, org_admin)
|
||||
@@ -209,7 +208,6 @@ def test_grant_org_credential_to_non_org_user_through_role_users(post, credentia
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_org_credential_to_non_org_user_through_user_roles(post, credential, organization, org_admin, alice):
|
||||
# NOTE: this endpoint is going away soon
|
||||
credential.organization = organization
|
||||
credential.save()
|
||||
response = post(reverse('api:user_roles_list', kwargs={'pk': alice.id}), {'id': credential.use_role.id}, org_admin)
|
||||
@@ -218,18 +216,18 @@ def test_grant_org_credential_to_non_org_user_through_user_roles(post, credentia
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_private_credential_to_user_through_role_users(post, credential, alice, bob):
|
||||
# NOTE: this endpoint is going away soon
|
||||
# normal users can't do this
|
||||
credential.admin_role.members.add(alice)
|
||||
response = post(reverse('api:role_users_list', kwargs={'pk': credential.use_role.id}), {'id': bob.id}, alice)
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_private_credential_to_org_user_through_role_users(post, credential, org_admin, org_member):
|
||||
# NOTE: this endpoint is going away soon
|
||||
# org admins can't either
|
||||
credential.admin_role.members.add(org_admin)
|
||||
response = post(reverse('api:role_users_list', kwargs={'pk': credential.use_role.id}), {'id': org_member.id}, org_admin)
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -241,18 +239,18 @@ def test_sa_grant_private_credential_to_user_through_role_users(post, credential
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_private_credential_to_user_through_user_roles(post, credential, alice, bob):
|
||||
# NOTE: this endpoint is going away soon
|
||||
# normal users can't do this
|
||||
credential.admin_role.members.add(alice)
|
||||
response = post(reverse('api:user_roles_list', kwargs={'pk': bob.id}), {'id': credential.use_role.id}, alice)
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_private_credential_to_org_user_through_user_roles(post, credential, org_admin, org_member):
|
||||
# NOTE: this endpoint is going away soon
|
||||
# org admins can't either
|
||||
credential.admin_role.members.add(org_admin)
|
||||
response = post(reverse('api:user_roles_list', kwargs={'pk': org_member.id}), {'id': credential.use_role.id}, org_admin)
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -284,14 +282,14 @@ def test_grant_org_credential_to_team_through_team_roles(post, credential, organ
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sa_grant_private_credential_to_team_through_role_teams(post, credential, admin, team):
|
||||
# NOTE: this endpoint is going away soon
|
||||
# not even a system admin can grant a private cred to a team though
|
||||
response = post(reverse('api:role_teams_list', kwargs={'pk': credential.use_role.id}), {'id': team.id}, admin)
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_credential_to_team_different_organization_through_role_teams(post, get, credential, organizations, admin, org_admin, team, team_member):
|
||||
# NOTE: this endpoint is going away soon
|
||||
# # Test that credential from different org can be assigned to team by a superuser through role_teams_list endpoint
|
||||
orgs = organizations(2)
|
||||
credential.organization = orgs[0]
|
||||
credential.save()
|
||||
@@ -301,7 +299,10 @@ def test_grant_credential_to_team_different_organization_through_role_teams(post
|
||||
# Non-superuser (org_admin) trying cross-org assignment should be denied
|
||||
response = post(reverse('api:role_teams_list', kwargs={'pk': credential.use_role.id}), {'id': team.id}, org_admin)
|
||||
assert response.status_code == 400
|
||||
assert "You cannot grant credential access to a Team not in the credentials' organization" in str(response.data['detail'])
|
||||
assert (
|
||||
"You cannot grant a team access to a credential in a different organization. Only superusers can grant cross-organization credential access to teams"
|
||||
in response.data['msg']
|
||||
)
|
||||
|
||||
# Superuser (admin) can do cross-org assignment
|
||||
response = post(reverse('api:role_teams_list', kwargs={'pk': credential.use_role.id}), {'id': team.id}, admin)
|
||||
@@ -315,17 +316,20 @@ def test_grant_credential_to_team_different_organization_through_role_teams(post
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_credential_to_team_different_organization(post, get, credential, organizations, admin, org_admin, team, team_member):
|
||||
# NOTE: this endpoint is going away soon
|
||||
# Test that credential from different org can be assigned to team by a superuser
|
||||
orgs = organizations(2)
|
||||
credential.organization = orgs[0]
|
||||
credential.save()
|
||||
team.organization = orgs[1]
|
||||
team.save()
|
||||
|
||||
# Non-superuser (org_admin) trying cross-org assignment should be denied
|
||||
# Non-superuser (org_admin, ...) trying cross-org assignment should be denied
|
||||
response = post(reverse('api:team_roles_list', kwargs={'pk': team.id}), {'id': credential.use_role.id}, org_admin)
|
||||
assert response.status_code == 400
|
||||
assert "You cannot grant credential access to a Team not in the credentials' organization" in str(response.data['detail'])
|
||||
assert (
|
||||
"You cannot grant a team access to a credential in a different organization. Only superusers can grant cross-organization credential access to teams"
|
||||
in response.data['msg']
|
||||
)
|
||||
|
||||
# Superuser (system admin) can do cross-org assignment
|
||||
response = post(reverse('api:team_roles_list', kwargs={'pk': team.id}), {'id': credential.use_role.id}, admin)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from ansible_base.lib.testing.util import feature_flag_enabled, feature_flag_disabled
|
||||
|
||||
from awx.main.models import CredentialInputSource
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
@@ -318,60 +316,3 @@ def test_create_credential_input_source_with_already_used_input_returns_400(post
|
||||
]
|
||||
all_responses = [post(list_url, params, admin) for params in all_params]
|
||||
assert all_responses.pop().status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_input_source_passes_workload_identity_token_when_flag_enabled(vault_credential, external_credential, mocker):
|
||||
"""Test that workload_identity_token is passed to backend when flag is enabled."""
|
||||
with feature_flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'):
|
||||
# Add workload_identity_token as an internal field on the external credential type
|
||||
# so get_input_value resolves it from the per-input-source context
|
||||
external_credential.credential_type.inputs['fields'].append(
|
||||
{'id': 'workload_identity_token', 'label': 'Workload Identity Token', 'type': 'string', 'internal': True}
|
||||
)
|
||||
|
||||
# Create an input source
|
||||
input_source = CredentialInputSource.objects.create(
|
||||
target_credential=vault_credential,
|
||||
source_credential=external_credential,
|
||||
input_field_name='vault_password',
|
||||
metadata={'key': 'test_key'},
|
||||
)
|
||||
|
||||
# Mock the credential plugin backend
|
||||
mock_backend = mocker.patch.object(external_credential.credential_type.plugin, 'backend', autospec=True, return_value='test_value')
|
||||
|
||||
# Call with context keyed by input source PK
|
||||
test_context = {input_source.pk: {'workload_identity_token': 'jwt_token_here'}}
|
||||
result = input_source.get_input_value(context=test_context)
|
||||
|
||||
# Verify backend was called with workload_identity_token
|
||||
assert result == 'test_value'
|
||||
call_kwargs = mock_backend.call_args[1]
|
||||
assert call_kwargs['workload_identity_token'] == 'jwt_token_here'
|
||||
assert call_kwargs['key'] == 'test_key'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_input_source_skips_workload_identity_token_when_flag_disabled(vault_credential, external_credential, mocker):
|
||||
"""Test that workload_identity_token is NOT passed when flag is disabled."""
|
||||
with feature_flag_disabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'):
|
||||
# Create an input source
|
||||
input_source = CredentialInputSource.objects.create(
|
||||
target_credential=vault_credential,
|
||||
source_credential=external_credential,
|
||||
input_field_name='vault_password',
|
||||
metadata={'key': 'test_key'},
|
||||
)
|
||||
# Mock the credential plugin backend
|
||||
mock_backend = mocker.patch.object(external_credential.credential_type.plugin, 'backend', autospec=True, return_value='test_value')
|
||||
# Call with context containing workload_identity_token but NO internal field defined,
|
||||
# simulating a flag-disabled scenario where tokens are not generated upstream
|
||||
test_context = {input_source.pk: {'workload_identity_token': 'jwt_token_here'}}
|
||||
result = input_source.get_input_value(context=test_context)
|
||||
# Verify backend was called WITHOUT workload_identity_token since the credential type
|
||||
# does not define it as an internal field (flag-disabled path doesn't register it)
|
||||
assert result == 'test_value'
|
||||
call_kwargs = mock_backend.call_args[1]
|
||||
assert 'workload_identity_token' not in call_kwargs
|
||||
assert call_kwargs['key'] == 'test_key'
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ansible_base.lib.testing.util import feature_flag_enabled
|
||||
from awx.main.models.credential import CredentialType, Credential
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
@@ -160,8 +159,7 @@ def test_create_as_admin(get, post, admin):
|
||||
response = get(reverse('api:credential_type_list'), admin)
|
||||
assert response.data['count'] == 1
|
||||
assert response.data['results'][0]['name'] == 'Custom Credential Type'
|
||||
# Serializer normalizes empty inputs to {'fields': []}
|
||||
assert response.data['results'][0]['inputs'] == {'fields': []}
|
||||
assert response.data['results'][0]['inputs'] == {}
|
||||
assert response.data['results'][0]['injectors'] == {}
|
||||
assert response.data['results'][0]['managed'] is False
|
||||
|
||||
@@ -476,98 +474,3 @@ def test_credential_type_rbac_external_test(post, alice, admin, credentialtype_e
|
||||
data = {'inputs': {}, 'metadata': {}}
|
||||
assert post(url, data, admin).status_code == 202
|
||||
assert post(url, data, alice).status_code == 403
|
||||
|
||||
|
||||
# --- Tests for internal field filtering with None/invalid inputs ---
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_type_with_none_inputs(get, admin):
|
||||
"""Test that credential type with empty inputs dict works correctly."""
|
||||
# Create a credential type with empty dict
|
||||
ct = CredentialType.objects.create(
|
||||
kind='cloud',
|
||||
name='Test Type',
|
||||
managed=False,
|
||||
inputs={}, # Empty dict, not None (DB has NOT NULL constraint)
|
||||
)
|
||||
|
||||
url = reverse('api:credential_type_detail', kwargs={'pk': ct.pk})
|
||||
response = get(url, admin)
|
||||
assert response.status_code == 200
|
||||
# Should have normalized inputs to empty dict
|
||||
assert 'inputs' in response.data
|
||||
assert isinstance(response.data['inputs'], dict)
|
||||
assert response.data['inputs']['fields'] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_type_with_invalid_inputs_type(get, admin):
|
||||
"""Test that credential type with non-dict inputs doesn't cause errors."""
|
||||
# Create a credential type with invalid inputs type
|
||||
ct = CredentialType.objects.create(kind='cloud', name='Test Type', managed=False, inputs={'fields': 'not-a-list'})
|
||||
|
||||
url = reverse('api:credential_type_detail', kwargs={'pk': ct.pk})
|
||||
response = get(url, admin)
|
||||
assert response.status_code == 200
|
||||
# Should gracefully handle invalid fields type
|
||||
assert 'inputs' in response.data
|
||||
assert response.data['inputs']['fields'] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_type_filters_internal_fields(get, admin):
|
||||
"""Test that internal fields are filtered from API responses."""
|
||||
ct = CredentialType.objects.create(
|
||||
kind='cloud',
|
||||
name='Test OIDC Type',
|
||||
managed=False,
|
||||
inputs={
|
||||
'fields': [
|
||||
{'id': 'url', 'label': 'URL', 'type': 'string'},
|
||||
{'id': 'token', 'label': 'Token', 'type': 'string', 'secret': True, 'internal': True},
|
||||
{'id': 'public_field', 'label': 'Public', 'type': 'string'},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
url = reverse('api:credential_type_detail', kwargs={'pk': ct.pk})
|
||||
with feature_flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'):
|
||||
response = get(url, admin)
|
||||
assert response.status_code == 200
|
||||
|
||||
field_ids = [f['id'] for f in response.data['inputs']['fields']]
|
||||
# Internal field should be filtered out
|
||||
assert 'token' not in field_ids
|
||||
assert 'url' in field_ids
|
||||
assert 'public_field' in field_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_type_list_filters_internal_fields(get, admin):
|
||||
"""Test that internal fields are filtered in list view."""
|
||||
CredentialType.objects.create(
|
||||
kind='cloud',
|
||||
name='Test OIDC Type',
|
||||
managed=False,
|
||||
inputs={
|
||||
'fields': [
|
||||
{'id': 'url', 'label': 'URL', 'type': 'string'},
|
||||
{'id': 'workload_identity_token', 'label': 'Token', 'type': 'string', 'secret': True, 'internal': True},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
url = reverse('api:credential_type_list')
|
||||
with feature_flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'):
|
||||
response = get(url, admin)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Find our credential type in the results
|
||||
test_ct = next((ct for ct in response.data['results'] if ct['name'] == 'Test OIDC Type'), None)
|
||||
assert test_ct is not None
|
||||
|
||||
field_ids = [f['id'] for f in test_ct['inputs']['fields']]
|
||||
# Internal field should be filtered out
|
||||
assert 'workload_identity_token' not in field_ids
|
||||
assert 'url' in field_ids
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models import Host, Inventory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dashboard_hosts_total_excludes_constructed(get, admin_user, organization):
|
||||
"""
|
||||
Constructed inventory hosts are not counted in the dashboard
|
||||
"""
|
||||
source_inv = Inventory.objects.create(name='source-inv', organization=organization)
|
||||
source_host = source_inv.hosts.create(name='host1')
|
||||
|
||||
constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization)
|
||||
Host.objects.create(name='host1', inventory=constructed, instance_id=str(source_host.pk))
|
||||
|
||||
response = get(reverse('api:dashboard_view'), user=admin_user, expect=200)
|
||||
assert response.data['hosts']['total'] == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_host_list_still_returns_constructed(get, admin_user, organization):
|
||||
"""
|
||||
Constructed inventory hosts are still visible through the API
|
||||
"""
|
||||
source_inv = Inventory.objects.create(name='source-inv', organization=organization)
|
||||
source_host = source_inv.hosts.create(name='host1')
|
||||
|
||||
constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization)
|
||||
Host.objects.create(name='host1', inventory=constructed, instance_id=str(source_host.pk))
|
||||
|
||||
response = get(reverse('api:host_list'), user=admin_user, expect=200)
|
||||
assert response.data['count'] == 2
|
||||
@@ -1,3 +1,5 @@
|
||||
# TODO: As of writing this our only concern is ensuring that the fact feature is reflected in the Host endpoint.
|
||||
# Other host tests should live here to make this test suite more complete.
|
||||
import pytest
|
||||
import urllib.parse
|
||||
|
||||
@@ -18,48 +20,6 @@ def inventory_structure():
|
||||
Group.objects.create(name="g3", inventory=inv)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_filter_inventory():
|
||||
"""Inventory with hosts and groups matching the tower-qa test_host_filter structure.
|
||||
|
||||
Groups: groupA (contains groupAA as child), groupAA, groupB
|
||||
Hosts: hostA (in groupA), hostAA (in groupAA), hostB (in groupB), hostDup (in all 3 groups)
|
||||
"""
|
||||
org = Organization.objects.create(name="hf-org")
|
||||
inv = Inventory.objects.create(name="hf-inv", organization=org)
|
||||
|
||||
groupA = Group.objects.create(name="groupA", inventory=inv)
|
||||
groupAA = Group.objects.create(name="groupAA", inventory=inv)
|
||||
groupB = Group.objects.create(name="groupB", inventory=inv)
|
||||
|
||||
hostA = Host.objects.create(name="hostA", inventory=inv)
|
||||
hostAA = Host.objects.create(name="hostAA", inventory=inv)
|
||||
hostB = Host.objects.create(name="hostB", inventory=inv)
|
||||
hostDup = Host.objects.create(name="hostDup", inventory=inv)
|
||||
|
||||
groupA.hosts.add(hostA, hostDup)
|
||||
groupAA.hosts.add(hostAA, hostDup)
|
||||
groupB.hosts.add(hostB, hostDup)
|
||||
groupA.children.add(groupAA)
|
||||
|
||||
return {
|
||||
'org': org,
|
||||
'inv': inv,
|
||||
'hosts': {'hostA': hostA, 'hostAA': hostAA, 'hostB': hostB, 'hostDup': hostDup},
|
||||
'groups': {'groupA': groupA, 'groupAA': groupAA, 'groupB': groupB},
|
||||
}
|
||||
|
||||
|
||||
def get_host_names(response):
|
||||
return sorted(h['name'] for h in response.data['results'])
|
||||
|
||||
|
||||
def host_filter_get(get, user, host_filter):
|
||||
url = reverse('api:host_list')
|
||||
params = "?host_filter=%s" % urllib.parse.quote(host_filter, safe='')
|
||||
return get(url + params, user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_q1(inventory_structure, get, user):
|
||||
def evaluate_query(query, expected_hosts):
|
||||
@@ -90,184 +50,3 @@ def test_q1(inventory_structure, get, user):
|
||||
# The following test verifies if the search in host_filter is case insensitive.
|
||||
query = 'search="HOST1"'
|
||||
evaluate_query(query, [hosts[0]])
|
||||
|
||||
|
||||
# --- Host filter query tests (migrated from tower-qa test_host_filter.py) ---
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"host_filter, expected",
|
||||
[
|
||||
("name=hostA", ["hostA"]),
|
||||
("name=not_found", []),
|
||||
("name=hostDup", ["hostDup"]),
|
||||
],
|
||||
)
|
||||
def test_basic_host_name_search(host_filter_inventory, get, admin_user, host_filter, expected):
|
||||
response = host_filter_get(get, admin_user, host_filter)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"host_filter, expected",
|
||||
[
|
||||
("name=hostA or name=hostB", ["hostA", "hostB"]),
|
||||
("name=hostA or name=not_found", ["hostA"]),
|
||||
("name=not_found or name=not_found", []),
|
||||
("name=hostA or name=hostA", ["hostA"]),
|
||||
("name=hostDup or name=hostDup", ["hostDup"]),
|
||||
("name=hostA or name=hostAA or name=not_found", ["hostA", "hostAA"]),
|
||||
],
|
||||
)
|
||||
def test_host_name_search_with_or(host_filter_inventory, get, admin_user, host_filter, expected):
|
||||
response = host_filter_get(get, admin_user, host_filter)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"host_filter, expected",
|
||||
[
|
||||
("name=hostA and name=hostB", []),
|
||||
("name=hostA and name=hostA", ["hostA"]),
|
||||
("name=not_found and name=not_found", []),
|
||||
("name=hostDup and name=hostDup", ["hostDup"]),
|
||||
("name=hostA and name=hostB and name=not_found", []),
|
||||
],
|
||||
)
|
||||
def test_host_name_search_with_and(host_filter_inventory, get, admin_user, host_filter, expected):
|
||||
response = host_filter_get(get, admin_user, host_filter)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"host_filter, expected",
|
||||
[
|
||||
("groups__name=groupA", ["hostA", "hostDup"]),
|
||||
("groups__name=groupAA", ["hostAA", "hostDup"]),
|
||||
("groups__name=not_found", []),
|
||||
],
|
||||
)
|
||||
def test_basic_group_search(host_filter_inventory, get, admin_user, host_filter, expected):
|
||||
response = host_filter_get(get, admin_user, host_filter)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"host_filter, expected",
|
||||
[
|
||||
("groups__name=groupA or groups__name=groupB", ["hostA", "hostB", "hostDup"]),
|
||||
("groups__name=groupA or groups__name=not_found", ["hostA", "hostDup"]),
|
||||
("groups__name=not_found or groups__name=not_found", []),
|
||||
("groups__name=groupA or groups__name=groupA", ["hostA", "hostDup"]),
|
||||
(
|
||||
"groups__name=groupA or groups__name=groupAA or groups__name=not_found",
|
||||
["hostA", "hostAA", "hostDup"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_group_search_with_or(host_filter_inventory, get, admin_user, host_filter, expected):
|
||||
response = host_filter_get(get, admin_user, host_filter)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"host_filter, expected",
|
||||
[
|
||||
("groups__name=groupA and groups__name=groupB", ["hostDup"]),
|
||||
("groups__name=groupA and groups__name=groupA", ["hostA", "hostDup"]),
|
||||
("groups__name=not_found and groups__name=not_found", []),
|
||||
("groups__name=groupA and groups__name=groupB and groups__name=not_found", []),
|
||||
],
|
||||
)
|
||||
def test_group_search_with_and(host_filter_inventory, get, admin_user, host_filter, expected):
|
||||
response = host_filter_get(get, admin_user, host_filter)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"host_filter, expected",
|
||||
[
|
||||
("name=hostA or groups__name=groupB", ["hostA", "hostB", "hostDup"]),
|
||||
("name=hostA and groups__name=groupA", ["hostA"]),
|
||||
("name=hostA and groups__name=not_found", []),
|
||||
("name=not_found and groups__name=not_found", []),
|
||||
("name=hostDup and groups__name=groupA", ["hostDup"]),
|
||||
("name=hostDup and groups__name=groupB", ["hostDup"]),
|
||||
],
|
||||
)
|
||||
def test_basic_hybrid_search(host_filter_inventory, get, admin_user, host_filter, expected):
|
||||
response = host_filter_get(get, admin_user, host_filter)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == sorted(expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_smart_search(get, admin_user):
|
||||
org = Organization.objects.create(name="search-org")
|
||||
inv = Inventory.objects.create(name="search-inv", organization=org)
|
||||
host = Host.objects.create(name="unique_search_target", description="findme_description", inventory=inv)
|
||||
|
||||
for search_term in ["unique_search_target", "findme_description"]:
|
||||
response = host_filter_get(get, admin_user, "search=%s" % search_term)
|
||||
assert response.status_code == 200
|
||||
names = get_host_names(response)
|
||||
assert host.name in names
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_field_filter_blocked(get, admin_user):
|
||||
url = reverse('api:host_list')
|
||||
filters = [
|
||||
"created_by__password__icontains=pas3w3rd",
|
||||
"search=foo or created_by__password__icontains=pas3w3rd",
|
||||
"created_by__password__icontains=passw3rd or search=foo",
|
||||
]
|
||||
for f in filters:
|
||||
params = "?host_filter=%s" % urllib.parse.quote(f, safe='')
|
||||
response = get(url + params, admin_user)
|
||||
assert response.status_code == 400, f"Expected 400 for filter: {f}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unicode_host_filter(get, admin_user):
|
||||
org = Organization.objects.create(name="unicode-org")
|
||||
inv = Inventory.objects.create(name="unicode-inv", organization=org)
|
||||
host = Host.objects.create(name="ホスト", inventory=inv)
|
||||
group = Group.objects.create(name="グループ", inventory=inv)
|
||||
group.hosts.add(host)
|
||||
|
||||
response = host_filter_get(get, admin_user, "name=ホスト")
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['results']) == 1
|
||||
assert response.data['results'][0]['id'] == host.id
|
||||
|
||||
response = host_filter_get(get, admin_user, "groups__name=グループ")
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['results']) == 1
|
||||
assert response.data['results'][0]['id'] == host.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_filter",
|
||||
["string_without_equals", "1", "1.0", "true"],
|
||||
ids=["bare_string", "integer", "float", "bool"],
|
||||
)
|
||||
def test_invalid_host_filter(get, admin_user, invalid_filter):
|
||||
url = reverse('api:host_list')
|
||||
params = "?host_filter=%s" % urllib.parse.quote(invalid_filter, safe='')
|
||||
response = get(url + params, admin_user)
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
from awx.main.models import InventorySource, Inventory, ActivityStream, Organization
|
||||
from awx.main.models import InventorySource, Inventory, ActivityStream
|
||||
from awx.main.utils.inventory_vars import update_group_variables
|
||||
|
||||
|
||||
@@ -963,45 +963,3 @@ class TestInventoryAllVariables:
|
||||
# Test step 6: Value of var x from source A reappears, because the
|
||||
# latest update from source B did not contain var x.
|
||||
self.update_and_verify(inv_src_c, {}, expect={"x": 1}, teststep=6)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inventory_names_unique_per_organization(post, admin_user):
|
||||
"""Validate that two inventories can have the same name if they belong to different organizations."""
|
||||
org1 = Organization.objects.create(name='org-inv-1')
|
||||
org2 = Organization.objects.create(name='org-inv-2')
|
||||
inv_name = 'SharedInventoryName'
|
||||
|
||||
# Create inventory with same name in org1
|
||||
resp1 = post(
|
||||
reverse('api:inventory_list'),
|
||||
{'name': inv_name, 'organization': org1.id},
|
||||
admin_user,
|
||||
expect=201,
|
||||
)
|
||||
inv1_id = resp1.data['id']
|
||||
|
||||
# Create inventory with same name in org2 - should succeed
|
||||
resp2 = post(
|
||||
reverse('api:inventory_list'),
|
||||
{'name': inv_name, 'organization': org2.id},
|
||||
admin_user,
|
||||
expect=201,
|
||||
)
|
||||
inv2_id = resp2.data['id']
|
||||
|
||||
assert inv1_id != inv2_id
|
||||
inv1 = Inventory.objects.get(id=inv1_id)
|
||||
inv2 = Inventory.objects.get(id=inv2_id)
|
||||
assert inv1.name == inv2.name == inv_name
|
||||
assert inv1.organization.id == org1.id
|
||||
assert inv2.organization.id == org2.id
|
||||
|
||||
# Attempt to create another inventory with same name in org1 - should fail
|
||||
resp3 = post(
|
||||
reverse('api:inventory_list'),
|
||||
{'name': inv_name, 'organization': org1.id},
|
||||
admin_user,
|
||||
expect=400,
|
||||
)
|
||||
assert 'Inventory with this Name and Organization already exists' in json.dumps(resp3.data)
|
||||
|
||||
@@ -485,3 +485,47 @@ class TestJobTemplateCallbackProxyIntegration:
|
||||
expect=400,
|
||||
**headers
|
||||
)
|
||||
|
||||
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=[])
|
||||
def test_only_first_entry_in_comma_separated_header_is_considered(self, job_template, admin_user, post):
|
||||
"""
|
||||
Test that only the first entry in a comma-separated header value is used for host matching.
|
||||
This is important for X-Forwarded-For style headers where the format is "client, proxy1, proxy2".
|
||||
Only the original client (first entry) should be matched against inventory hosts.
|
||||
"""
|
||||
# Create host that matches the SECOND entry in the comma-separated list
|
||||
job_template.inventory.hosts.create(name='second-host.example.com')
|
||||
|
||||
headers = {
|
||||
# First entry is 'first-host.example.com', second is 'second-host.example.com'
|
||||
# Only the first should be considered, so this should NOT match
|
||||
'HTTP_X_FROM_THE_LOAD_BALANCER': 'first-host.example.com, second-host.example.com',
|
||||
'REMOTE_ADDR': 'unrelated-addr',
|
||||
'REMOTE_HOST': 'unrelated-host',
|
||||
}
|
||||
|
||||
# Should return 400 because only 'first-host.example.com' is considered,
|
||||
# and that host is NOT in the inventory
|
||||
r = post(
|
||||
url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), data={'host_config_key': 'abcd'}, user=admin_user, expect=400, **headers
|
||||
)
|
||||
assert r.data['msg'] == 'No matching host could be found!'
|
||||
|
||||
@override_settings(REMOTE_HOST_HEADERS=['HTTP_X_FROM_THE_LOAD_BALANCER', 'REMOTE_ADDR', 'REMOTE_HOST'], PROXY_IP_ALLOWED_LIST=[])
|
||||
def test_first_entry_in_comma_separated_header_matches(self, job_template, admin_user, post):
|
||||
"""
|
||||
Test that the first entry in a comma-separated header value correctly matches an inventory host.
|
||||
"""
|
||||
# Create host that matches the FIRST entry in the comma-separated list
|
||||
job_template.inventory.hosts.create(name='first-host.example.com')
|
||||
|
||||
headers = {
|
||||
# First entry is 'first-host.example.com', second is 'second-host.example.com'
|
||||
# The first entry matches the inventory host
|
||||
'HTTP_X_FROM_THE_LOAD_BALANCER': 'first-host.example.com, second-host.example.com',
|
||||
'REMOTE_ADDR': 'unrelated-addr',
|
||||
'REMOTE_HOST': 'unrelated-host',
|
||||
}
|
||||
|
||||
# Should return 201 because 'first-host.example.com' is the first entry and matches
|
||||
post(url=reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), data={'host_config_key': 'abcd'}, user=admin_user, expect=201, **headers)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models import NotificationTemplate, Organization
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_template_names_unique_per_organization(post, admin_user):
|
||||
"""
|
||||
Validate that notification templates must have unique names within an organization,
|
||||
but can have the same name across different organizations.
|
||||
"""
|
||||
org1 = Organization.objects.create(name='org-notif-1')
|
||||
org2 = Organization.objects.create(name='org-notif-2')
|
||||
template_name = 'SharedNotificationName'
|
||||
|
||||
# Create notification template in org1
|
||||
resp1 = post(
|
||||
reverse('api:notification_template_list'),
|
||||
{
|
||||
'name': template_name,
|
||||
'organization': org1.id,
|
||||
'notification_type': 'email',
|
||||
'notification_configuration': {
|
||||
'username': 'user@example.com',
|
||||
'password': 'pass',
|
||||
'sender': 'sender@example.com',
|
||||
'recipients': ['recipient@example.com'],
|
||||
'host': 'smtp.example.com',
|
||||
'port': 25,
|
||||
'use_tls': False,
|
||||
'use_ssl': False,
|
||||
},
|
||||
},
|
||||
admin_user,
|
||||
expect=201,
|
||||
)
|
||||
template1_id = resp1.data['id']
|
||||
|
||||
# Create notification template with same name in org2 - should succeed
|
||||
resp2 = post(
|
||||
reverse('api:notification_template_list'),
|
||||
{
|
||||
'name': template_name,
|
||||
'organization': org2.id,
|
||||
'notification_type': 'email',
|
||||
'notification_configuration': {
|
||||
'username': 'user@example.com',
|
||||
'password': 'pass',
|
||||
'sender': 'sender@example.com',
|
||||
'recipients': ['recipient@example.com'],
|
||||
'host': 'smtp.example.com',
|
||||
'port': 25,
|
||||
'use_tls': False,
|
||||
'use_ssl': False,
|
||||
},
|
||||
},
|
||||
admin_user,
|
||||
expect=201,
|
||||
)
|
||||
template2_id = resp2.data['id']
|
||||
|
||||
assert template1_id != template2_id
|
||||
template1 = NotificationTemplate.objects.get(id=template1_id)
|
||||
template2 = NotificationTemplate.objects.get(id=template2_id)
|
||||
assert template1.name == template2.name == template_name
|
||||
assert template1.organization.id == org1.id
|
||||
assert template2.organization.id == org2.id
|
||||
|
||||
# Attempt to create another notification template with same name in org1 - should fail
|
||||
resp3 = post(
|
||||
reverse('api:notification_template_list'),
|
||||
{
|
||||
'name': template_name,
|
||||
'organization': org1.id,
|
||||
'notification_type': 'email',
|
||||
'notification_configuration': {
|
||||
'username': 'user@example.com',
|
||||
'password': 'pass',
|
||||
'sender': 'sender@example.com',
|
||||
'recipients': ['recipient@example.com'],
|
||||
'host': 'smtp.example.com',
|
||||
'port': 25,
|
||||
'use_tls': False,
|
||||
'use_ssl': False,
|
||||
},
|
||||
},
|
||||
admin_user,
|
||||
expect=400,
|
||||
)
|
||||
assert 'Notification template with this Organization and Name already exists' in str(resp3.data)
|
||||
@@ -1,163 +0,0 @@
|
||||
"""
|
||||
Tests for OIDC workload identity credential type feature flag.
|
||||
|
||||
The FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED flag is an install-time flag that
|
||||
controls whether OIDC credential types are loaded into the registry at startup.
|
||||
When disabled, OIDC credential types are not loaded and do not exist in the database.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
from awx.main.constants import OIDC_CREDENTIAL_TYPE_NAMESPACES
|
||||
from awx.main.models.credential import CredentialType, ManagedCredentialType, load_credentials
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reload_credentials_with_flag(django_db_setup, django_db_blocker):
|
||||
"""
|
||||
Fixture that reloads credentials with a specific flag state.
|
||||
This simulates what happens at application startup.
|
||||
"""
|
||||
# Save original registry state
|
||||
original_registry = ManagedCredentialType.registry.copy()
|
||||
|
||||
def _reload(flag_enabled):
|
||||
with django_db_blocker.unblock():
|
||||
# Clear the entire registry before reloading
|
||||
ManagedCredentialType.registry.clear()
|
||||
|
||||
# Reload credentials with the specified flag state
|
||||
with override_settings(FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED=flag_enabled):
|
||||
with mock.patch('awx.main.models.credential.detect_server_product_name', return_value='NOT_AWX'):
|
||||
load_credentials()
|
||||
|
||||
# Sync to database
|
||||
CredentialType.setup_tower_managed_defaults(lock=False)
|
||||
|
||||
# In tests, the session fixture pre-loads all credential types into the DB.
|
||||
# Remove OIDC types when testing the disabled state so the API test is accurate.
|
||||
if not flag_enabled:
|
||||
CredentialType.objects.filter(namespace__in=OIDC_CREDENTIAL_TYPE_NAMESPACES).delete()
|
||||
|
||||
yield _reload
|
||||
|
||||
# Restore original registry state after tests
|
||||
ManagedCredentialType.registry.clear()
|
||||
ManagedCredentialType.registry.update(original_registry)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_registry():
|
||||
"""Save and restore the ManagedCredentialType registry, with full isolation via mocked entry_points."""
|
||||
original_registry = ManagedCredentialType.registry.copy()
|
||||
ManagedCredentialType.registry.clear()
|
||||
yield
|
||||
ManagedCredentialType.registry.clear()
|
||||
ManagedCredentialType.registry.update(original_registry)
|
||||
|
||||
|
||||
def _make_mock_entry_point(name):
|
||||
"""Create a mock entry point that mimics a credential plugin."""
|
||||
ep = mock.MagicMock()
|
||||
ep.name = name
|
||||
ep.value = f'test_plugin:{name}'
|
||||
plugin = mock.MagicMock(spec=[])
|
||||
ep.load.return_value = plugin
|
||||
return ep
|
||||
|
||||
|
||||
def _mock_entry_points_factory(managed_names, supported_names):
|
||||
"""Return a side_effect function for mocking entry_points() with controlled plugins."""
|
||||
managed = [_make_mock_entry_point(n) for n in managed_names]
|
||||
supported = [_make_mock_entry_point(n) for n in supported_names]
|
||||
|
||||
def _entry_points(group):
|
||||
if group == 'awx_plugins.managed_credentials':
|
||||
return managed
|
||||
elif group == 'awx_plugins.managed_credentials.supported':
|
||||
return supported
|
||||
return []
|
||||
|
||||
return _entry_points
|
||||
|
||||
|
||||
# --- Unit tests for load_credentials() registry behavior ---
|
||||
|
||||
|
||||
def test_oidc_types_in_registry_when_flag_enabled(isolated_registry):
|
||||
"""Test that OIDC credential types are added to the registry when flag is enabled."""
|
||||
mock_eps = _mock_entry_points_factory(
|
||||
managed_names=['ssh', 'vault'],
|
||||
supported_names=['hashivault-kv-oidc', 'hashivault-ssh-oidc'],
|
||||
)
|
||||
with override_settings(FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED=True):
|
||||
with mock.patch('awx.main.models.credential.detect_server_product_name', return_value='NOT_AWX'):
|
||||
with mock.patch('awx.main.models.credential.entry_points', side_effect=mock_eps):
|
||||
load_credentials()
|
||||
|
||||
for ns in OIDC_CREDENTIAL_TYPE_NAMESPACES:
|
||||
assert ns in ManagedCredentialType.registry, f"{ns} should be in registry when flag is enabled"
|
||||
assert 'ssh' in ManagedCredentialType.registry
|
||||
assert 'vault' in ManagedCredentialType.registry
|
||||
|
||||
|
||||
def test_oidc_types_not_in_registry_when_flag_disabled(isolated_registry):
|
||||
"""Test that OIDC credential types are excluded from the registry when flag is disabled."""
|
||||
mock_eps = _mock_entry_points_factory(
|
||||
managed_names=['ssh', 'vault'],
|
||||
supported_names=['hashivault-kv-oidc', 'hashivault-ssh-oidc'],
|
||||
)
|
||||
with override_settings(FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED=False):
|
||||
with mock.patch('awx.main.models.credential.detect_server_product_name', return_value='NOT_AWX'):
|
||||
with mock.patch('awx.main.models.credential.entry_points', side_effect=mock_eps):
|
||||
load_credentials()
|
||||
|
||||
for ns in OIDC_CREDENTIAL_TYPE_NAMESPACES:
|
||||
assert ns not in ManagedCredentialType.registry, f"{ns} should not be in registry when flag is disabled"
|
||||
# Non-OIDC types should still be loaded
|
||||
assert 'ssh' in ManagedCredentialType.registry
|
||||
assert 'vault' in ManagedCredentialType.registry
|
||||
|
||||
|
||||
def test_oidc_namespaces_constant():
|
||||
"""Test that OIDC_CREDENTIAL_TYPE_NAMESPACES contains the expected namespaces."""
|
||||
assert 'hashivault-kv-oidc' in OIDC_CREDENTIAL_TYPE_NAMESPACES
|
||||
assert 'hashivault-ssh-oidc' in OIDC_CREDENTIAL_TYPE_NAMESPACES
|
||||
assert len(OIDC_CREDENTIAL_TYPE_NAMESPACES) == 2
|
||||
|
||||
|
||||
# --- Functional API tests ---
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_oidc_types_loaded_when_flag_enabled(get, admin, reload_credentials_with_flag):
|
||||
"""Test that OIDC credential types are visible in the API when flag is enabled."""
|
||||
reload_credentials_with_flag(flag_enabled=True)
|
||||
|
||||
response = get(reverse('api:credential_type_list'), admin)
|
||||
assert response.status_code == 200
|
||||
|
||||
namespaces = [ct['namespace'] for ct in response.data['results']]
|
||||
assert 'hashivault-kv-oidc' in namespaces
|
||||
assert 'hashivault-ssh-oidc' in namespaces
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_oidc_types_not_loaded_when_flag_disabled(get, admin, reload_credentials_with_flag):
|
||||
"""Test that OIDC credential types are not visible in the API when flag is disabled."""
|
||||
reload_credentials_with_flag(flag_enabled=False)
|
||||
|
||||
response = get(reverse('api:credential_type_list'), admin)
|
||||
assert response.status_code == 200
|
||||
|
||||
namespaces = [ct['namespace'] for ct in response.data['results']]
|
||||
assert 'hashivault-kv-oidc' not in namespaces
|
||||
assert 'hashivault-ssh-oidc' not in namespaces
|
||||
|
||||
# Verify they're also not in the database
|
||||
assert not CredentialType.objects.filter(namespace='hashivault-kv-oidc').exists()
|
||||
assert not CredentialType.objects.filter(namespace='hashivault-ssh-oidc').exists()
|
||||
@@ -1,311 +0,0 @@
|
||||
"""
|
||||
Tests for OIDC workload identity credential test endpoints.
|
||||
|
||||
Tests the /api/v2/credentials/<id>/test/ and /api/v2/credential_types/<id>/test/
|
||||
endpoints when used with OIDC-enabled credential types.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
from awx.main.models import Credential, CredentialType, JobTemplate
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_template(organization, project):
|
||||
"""Job template with organization and project for OIDC JWT generation."""
|
||||
return JobTemplate.objects.create(name='test-jt', organization=organization, project=project, playbook='helloworld.yml')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_credentialtype():
|
||||
"""Create a credential type with workload_identity_token internal field."""
|
||||
oidc_type_inputs = {
|
||||
'fields': [
|
||||
{'id': 'url', 'label': 'Vault URL', 'type': 'string', 'help_text': 'The Vault server URL.'},
|
||||
{'id': 'auth_path', 'label': 'Auth Path', 'type': 'string', 'help_text': 'JWT auth mount path.'},
|
||||
{'id': 'role_id', 'label': 'Role ID', 'type': 'string', 'help_text': 'Vault role.'},
|
||||
{'id': 'workload_identity_token', 'label': 'Workload Identity Token', 'type': 'string', 'secret': True, 'internal': True},
|
||||
],
|
||||
'metadata': [
|
||||
{'id': 'secret_path', 'label': 'Secret Path', 'type': 'string'},
|
||||
{'id': 'job_template_id', 'label': 'Job Template ID', 'type': 'string'},
|
||||
],
|
||||
'required': ['url', 'auth_path', 'role_id'],
|
||||
}
|
||||
|
||||
class MockPlugin(object):
|
||||
def backend(self, **kwargs):
|
||||
# Simulate successful backend call
|
||||
return 'secret'
|
||||
|
||||
with mock.patch('awx.main.models.credential.CredentialType.plugin', new_callable=mock.PropertyMock) as mock_plugin:
|
||||
mock_plugin.return_value = MockPlugin()
|
||||
oidc_type = CredentialType(kind='external', managed=True, namespace='hashivault-kv-oidc', name='HashiCorp Vault KV (OIDC)', inputs=oidc_type_inputs)
|
||||
oidc_type.save()
|
||||
yield oidc_type
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_credential(oidc_credentialtype):
|
||||
"""Create a credential using the OIDC credential type."""
|
||||
return Credential.objects.create(
|
||||
credential_type=oidc_credentialtype,
|
||||
name='oidc-vault-cred',
|
||||
inputs={'url': 'http://vault.example.com:8200', 'auth_path': 'jwt', 'role_id': 'test-role'},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oidc_backend():
|
||||
"""Fixture that mocks OIDC JWT generation and credential backend."""
|
||||
with mock.patch('awx.api.views.retrieve_workload_identity_jwt_with_claims') as mock_jwt, mock.patch('awx.api.views._jwt_decode') as mock_decode, mock.patch(
|
||||
'awx.main.models.credential.CredentialType.plugin', new_callable=mock.PropertyMock
|
||||
) as mock_plugin:
|
||||
|
||||
# Set default return values
|
||||
mock_jwt.return_value = 'fake.jwt.token'
|
||||
mock_decode.return_value = {'iss': 'http://gateway/o', 'aud': 'vault'}
|
||||
|
||||
# Create mock backend
|
||||
mock_backend = mock.MagicMock()
|
||||
mock_backend.backend.return_value = 'secret'
|
||||
mock_plugin.return_value = mock_backend
|
||||
|
||||
# Yield all mocks for test customization
|
||||
yield {
|
||||
'jwt': mock_jwt,
|
||||
'decode': mock_decode,
|
||||
'plugin': mock_plugin,
|
||||
'backend': mock_backend,
|
||||
}
|
||||
|
||||
|
||||
# --- Tests for CredentialExternalTest endpoint ---
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED=False)
|
||||
def test_credential_test_without_oidc_feature_flag(post, admin, oidc_credential):
|
||||
"""Test that credential test works without OIDC feature flag enabled."""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret', 'job_template_id': '1'}}
|
||||
|
||||
with mock.patch('awx.main.models.credential.CredentialType.plugin', new_callable=mock.PropertyMock) as mock_plugin:
|
||||
mock_backend = mock.MagicMock()
|
||||
mock_backend.backend.return_value = 'secret'
|
||||
mock_plugin.return_value = mock_backend
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 202
|
||||
# Should not contain JWT payload when feature flag is disabled
|
||||
assert 'details' not in response.data or 'sent_jwt_payload' not in response.data.get('details', {})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
@pytest.mark.parametrize(
|
||||
'job_template_id, expected_error',
|
||||
[
|
||||
(None, 'Job template ID is required'),
|
||||
('not-an-integer', 'must be an integer'),
|
||||
('99999', 'does not exist'),
|
||||
],
|
||||
ids=['missing_job_template_id', 'invalid_job_template_id_type', 'nonexistent_job_template_id'],
|
||||
)
|
||||
def test_credential_test_job_template_validation(mock_flag, post, admin, oidc_credential, job_template_id, expected_error):
|
||||
"""Test that invalid job_template_id values return 400 with appropriate error messages."""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret'}}
|
||||
if job_template_id is not None:
|
||||
data['metadata']['job_template_id'] = job_template_id
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 400
|
||||
assert 'details' in response.data
|
||||
assert 'error_message' in response.data['details']
|
||||
assert expected_error in response.data['details']['error_message']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_test_no_access_to_job_template(mock_flag, post, alice, oidc_credential, job_template):
|
||||
"""Test that user without access to job template gets 403."""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)}}
|
||||
|
||||
# Give alice use permission on credential but not on job template
|
||||
oidc_credential.use_role.members.add(alice)
|
||||
|
||||
response = post(url, data, alice)
|
||||
assert response.status_code == 403
|
||||
assert 'You do not have access to job template' in str(response.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_test_success_returns_jwt_payload(mock_flag, post, admin, oidc_credential, job_template, mock_oidc_backend):
|
||||
"""Test that successful test returns JWT payload in response."""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)}}
|
||||
|
||||
# Customize mock for this test
|
||||
mock_oidc_backend['decode'].return_value = {
|
||||
'iss': 'http://gateway/o',
|
||||
'sub': 'system:serviceaccount:default:awx-operator',
|
||||
'aud': 'vault',
|
||||
'job_template_id': job_template.id,
|
||||
}
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 202
|
||||
assert 'details' in response.data
|
||||
assert 'sent_jwt_payload' in response.data['details']
|
||||
assert response.data['details']['sent_jwt_payload']['job_template_id'] == job_template.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_test_response_does_not_contain_secret_value(mock_flag, post, admin, oidc_credential, job_template, mock_oidc_backend):
|
||||
"""
|
||||
the OIDC credential test endpoint must not echo the resolved Vault secret back to the caller.
|
||||
"""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)}}
|
||||
|
||||
credential_secret_value = 'CREDENTIAL_SECRET'
|
||||
mock_oidc_backend['backend'].backend.return_value = credential_secret_value
|
||||
|
||||
response = post(url, data, admin)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert 'details' in response.data
|
||||
assert 'sent_jwt_payload' in response.data['details']
|
||||
assert 'secret_value' not in response.data['details']
|
||||
assert credential_secret_value not in str(response.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_test_backend_failure_returns_jwt_and_error(mock_flag, post, admin, oidc_credential, job_template, mock_oidc_backend):
|
||||
"""Test that backend failure still returns JWT payload along with error message."""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)}}
|
||||
|
||||
# Make backend fail
|
||||
mock_oidc_backend['backend'].backend.side_effect = RuntimeError('Connection failed')
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 400
|
||||
assert 'details' in response.data
|
||||
# Both JWT payload and error message should be present
|
||||
assert 'sent_jwt_payload' in response.data['details']
|
||||
assert 'error_message' in response.data['details']
|
||||
assert 'Connection failed' in response.data['details']['error_message']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_test_jwt_generation_failure(mock_flag, post, admin, oidc_credential, job_template):
|
||||
"""Test that JWT generation failure returns error without JWT payload."""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)}}
|
||||
|
||||
with mock.patch('awx.api.views.OIDCCredentialTestMixin._get_workload_identity_token') as mock_jwt:
|
||||
mock_jwt.side_effect = RuntimeError('Failed to generate JWT')
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 400
|
||||
assert 'details' in response.data
|
||||
assert 'error_message' in response.data['details']
|
||||
assert 'Failed to generate JWT' in response.data['details']['error_message']
|
||||
# No JWT payload when generation fails
|
||||
assert 'sent_jwt_payload' not in response.data['details']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_test_job_template_id_not_passed_to_backend(mock_flag, post, admin, oidc_credential, job_template, mock_oidc_backend):
|
||||
"""Test that job_template_id is removed from backend_kwargs."""
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': oidc_credential.pk})
|
||||
data = {'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)}}
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 202
|
||||
|
||||
# Check that backend was called without job_template_id but with url and workload_identity_token
|
||||
call_kwargs = mock_oidc_backend['backend'].backend.call_args[1]
|
||||
assert 'job_template_id' not in call_kwargs
|
||||
assert 'url' in call_kwargs
|
||||
assert 'workload_identity_token' in call_kwargs
|
||||
|
||||
|
||||
# --- Tests for CredentialTypeExternalTest endpoint ---
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_type_test_response_does_not_contain_secret_value(mock_flag, post, admin, oidc_credentialtype, job_template, mock_oidc_backend):
|
||||
"""
|
||||
the credential-type variant of the test endpoint should not return the secret value
|
||||
"""
|
||||
url = reverse('api:credential_type_external_test', kwargs={'pk': oidc_credentialtype.pk})
|
||||
data = {
|
||||
'inputs': {'url': 'http://vault.example.com:8200', 'auth_path': 'jwt', 'role_id': 'test-role', 'jwt_aud': 'vault'},
|
||||
'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)},
|
||||
}
|
||||
|
||||
credential_type_seret_value = 'CREDENTIAL_TYPE_SECRET'
|
||||
mock_oidc_backend['backend'].backend.return_value = credential_type_seret_value
|
||||
response = post(url, data, admin)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert 'details' in response.data
|
||||
assert 'sent_jwt_payload' in response.data['details']
|
||||
assert 'secret_value' not in response.data['details']
|
||||
assert credential_type_seret_value not in str(response.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_type_test_missing_job_template_id(mock_flag, post, admin, oidc_credentialtype):
|
||||
"""Test that missing job_template_id returns 400 for credential type test endpoint."""
|
||||
url = reverse('api:credential_type_external_test', kwargs={'pk': oidc_credentialtype.pk})
|
||||
data = {
|
||||
'inputs': {'url': 'http://vault.example.com:8200', 'auth_path': 'jwt', 'role_id': 'test-role'},
|
||||
'metadata': {'secret_path': 'test/secret'},
|
||||
}
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 400
|
||||
assert 'details' in response.data
|
||||
assert 'error_message' in response.data['details']
|
||||
assert 'Job template ID is required' in response.data['details']['error_message']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch('awx.api.views.flag_enabled', return_value=True)
|
||||
def test_credential_type_test_success_returns_jwt_payload(mock_flag, post, admin, oidc_credentialtype, job_template, mock_oidc_backend):
|
||||
"""Test that successful credential type test returns JWT payload."""
|
||||
url = reverse('api:credential_type_external_test', kwargs={'pk': oidc_credentialtype.pk})
|
||||
data = {
|
||||
'inputs': {'url': 'http://vault.example.com:8200', 'auth_path': 'jwt', 'role_id': 'test-role'},
|
||||
'metadata': {'secret_path': 'test/secret', 'job_template_id': str(job_template.id)},
|
||||
}
|
||||
|
||||
response = post(url, data, admin)
|
||||
assert response.status_code == 202
|
||||
assert 'details' in response.data
|
||||
assert 'sent_jwt_payload' in response.data['details']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_external_test_returns_400_for_non_external_credential(post, admin, credential):
|
||||
# credential fixture creates a non-external credential (e.g. SSH/vault kind)
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': credential.pk})
|
||||
response = post(url, {'metadata': {}}, admin)
|
||||
assert response.status_code == 400
|
||||
assert 'not testable' in response.data.get('detail', '').lower()
|
||||
@@ -139,7 +139,6 @@ def test_survey_password_default(post, patch, admin_user, project, inventory, su
|
||||
("DTSTART:20300308T050000Z", "One or more rule required in rrule"),
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=MONTHLY;INTERVAL=1; EXDATE:20220401", "EXDATE not allowed in rrule"),
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=MONTHLY;INTERVAL=1; RDATE:20220401", "RDATE not allowed in rrule"),
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=YEARLY;INTERVAL=0;BYDAY=MO", "INTERVAL must be a positive integer"),
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=SECONDLY;INTERVAL=5;COUNT=6", "SECONDLY is not supported"),
|
||||
# Individual rule test
|
||||
("DTSTART:20300308T050000Z RRULE:NONSENSE", "INTERVAL required in rrule"),
|
||||
@@ -203,7 +202,6 @@ def test_multiple_invalid_rrules(post, admin_user, project, inventory):
|
||||
"rrule": [
|
||||
"Multiple DTSTART is not supported.",
|
||||
"INTERVAL required in rrule: RULE:FREQ=SECONDLY",
|
||||
"SECONDLY is not supported: RULE:FREQ=SECONDLY",
|
||||
"RRULE may not contain both COUNT and UNTIL: RULE:FREQ=MINUTELY;INTERVAL=10;COUNT=5;UNTIL=20220101",
|
||||
"rrule parsing failed validation: 'NoneType' object has no attribute 'group'",
|
||||
]
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models import Organization, Host, Group, Inventory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smart_inv_org():
|
||||
return Organization.objects.create(name="smart-org")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smart_inv_source(smart_inv_org):
|
||||
inv = Inventory.objects.create(name="smart-source-inv", organization=smart_inv_org)
|
||||
Host.objects.create(name="hostA", inventory=inv)
|
||||
Host.objects.create(name="hostB", inventory=inv)
|
||||
Host.objects.create(name="hostDup", inventory=inv)
|
||||
groupA = Group.objects.create(name="groupA", inventory=inv)
|
||||
groupB = Group.objects.create(name="groupB", inventory=inv)
|
||||
groupA.hosts.add(*inv.hosts.filter(name__in=["hostA", "hostDup"]))
|
||||
groupB.hosts.add(*inv.hosts.filter(name__in=["hostB", "hostDup"]))
|
||||
return inv
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_smart_inventory(post, admin_user, smart_inv_org):
|
||||
resp = post(
|
||||
reverse('api:inventory_list'),
|
||||
{
|
||||
'name': 'my-smart-inv',
|
||||
'kind': 'smart',
|
||||
'organization': smart_inv_org.pk,
|
||||
'host_filter': 'name=hostA',
|
||||
},
|
||||
admin_user,
|
||||
expect=201,
|
||||
)
|
||||
assert resp.data['kind'] == 'smart'
|
||||
assert resp.data['host_filter'] == 'name=hostA'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_smart_inventory_requires_host_filter(post, admin_user, smart_inv_org):
|
||||
resp = post(
|
||||
reverse('api:inventory_list'),
|
||||
{
|
||||
'name': 'no-filter-smart',
|
||||
'kind': 'smart',
|
||||
'organization': smart_inv_org.pk,
|
||||
},
|
||||
admin_user,
|
||||
expect=400,
|
||||
)
|
||||
assert 'host_filter' in json.dumps(resp.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unable_to_create_host_in_smart_inventory(post, admin_user, smart_inv_org):
|
||||
smart_inv = Inventory.objects.create(
|
||||
name="no-host-create",
|
||||
kind="smart",
|
||||
host_filter="name=hostA",
|
||||
organization=smart_inv_org,
|
||||
)
|
||||
url = reverse('api:inventory_hosts_list', kwargs={'pk': smart_inv.pk})
|
||||
resp = post(url, {'name': 'new-host'}, admin_user, expect=400)
|
||||
assert 'Cannot create' in json.dumps(resp.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unable_to_create_group_in_smart_inventory(post, admin_user, smart_inv_org):
|
||||
smart_inv = Inventory.objects.create(
|
||||
name="no-group-create",
|
||||
kind="smart",
|
||||
host_filter="name=hostA",
|
||||
organization=smart_inv_org,
|
||||
)
|
||||
url = reverse('api:inventory_groups_list', kwargs={'pk': smart_inv.pk})
|
||||
resp = post(url, {'name': 'new-group'}, admin_user, expect=400)
|
||||
assert 'Cannot create' in json.dumps(resp.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unable_to_create_inventory_source_in_smart_inventory(post, admin_user, smart_inv_org):
|
||||
smart_inv = Inventory.objects.create(
|
||||
name="no-src-create",
|
||||
kind="smart",
|
||||
host_filter="name=hostA",
|
||||
organization=smart_inv_org,
|
||||
)
|
||||
url = reverse('api:inventory_inventory_sources_list', kwargs={'pk': smart_inv.pk})
|
||||
resp = post(url, {'name': 'new-src', 'source': 'ec2'}, admin_user, expect=400)
|
||||
assert 'Cannot create' in json.dumps(resp.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_convert_smart_to_regular_inventory(admin_user, smart_inv_org):
|
||||
smart_inv = Inventory.objects.create(
|
||||
name="convert-to-regular",
|
||||
kind="smart",
|
||||
host_filter="name=anything",
|
||||
organization=smart_inv_org,
|
||||
)
|
||||
assert smart_inv.kind == 'smart'
|
||||
smart_inv.host_filter = ''
|
||||
smart_inv.kind = ''
|
||||
smart_inv.save()
|
||||
smart_inv.refresh_from_db()
|
||||
assert smart_inv.kind == ''
|
||||
assert not smart_inv.host_filter
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_smart_inventory_deletion_does_not_cascade(admin_user, smart_inv_source, smart_inv_org):
|
||||
host = smart_inv_source.hosts.first()
|
||||
smart_inv = Inventory.objects.create(
|
||||
name="delete-no-cascade",
|
||||
kind="smart",
|
||||
host_filter="name=%s" % host.name,
|
||||
organization=smart_inv_org,
|
||||
)
|
||||
smart_inv.delete()
|
||||
assert Host.objects.filter(pk=host.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_urlencode_host_filter(post, admin_user, smart_inv_org):
|
||||
post(
|
||||
reverse('api:inventory_list'),
|
||||
data={
|
||||
'name': 'url-encoded-smart',
|
||||
'kind': 'smart',
|
||||
'organization': smart_inv_org.pk,
|
||||
'host_filter': 'ansible_facts__ansible_distribution_version=%227.4%22',
|
||||
},
|
||||
user=admin_user,
|
||||
expect=201,
|
||||
)
|
||||
si = Inventory.objects.get(name='url-encoded-smart')
|
||||
assert si.host_filter == 'ansible_facts__ansible_distribution_version="7.4"'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_host_filter_unicode(post, admin_user, smart_inv_org):
|
||||
post(
|
||||
reverse('api:inventory_list'),
|
||||
data={
|
||||
'name': 'unicode-smart',
|
||||
'kind': 'smart',
|
||||
'organization': smart_inv_org.pk,
|
||||
'host_filter': u'ansible_facts__ansible_distribution=レッドハット',
|
||||
},
|
||||
user=admin_user,
|
||||
expect=201,
|
||||
)
|
||||
si = Inventory.objects.get(name='unicode-smart')
|
||||
assert si.host_filter == u'ansible_facts__ansible_distribution=レッドハット'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("lookup", ['icontains', 'has_keys'])
|
||||
def test_host_filter_invalid_ansible_facts_lookup(post, admin_user, smart_inv_org, lookup):
|
||||
resp = post(
|
||||
reverse('api:inventory_list'),
|
||||
data={
|
||||
'name': 'invalid-lookup-smart',
|
||||
'kind': 'smart',
|
||||
'organization': smart_inv_org.pk,
|
||||
'host_filter': u'ansible_facts__ansible_distribution__{}=cent'.format(lookup),
|
||||
},
|
||||
user=admin_user,
|
||||
expect=400,
|
||||
)
|
||||
assert 'ansible_facts does not support searching with __{}'.format(lookup) in json.dumps(resp.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_host_filter_ansible_facts_exact(post, admin_user, smart_inv_org):
|
||||
post(
|
||||
reverse('api:inventory_list'),
|
||||
data={
|
||||
'name': 'exact-smart',
|
||||
'kind': 'smart',
|
||||
'organization': smart_inv_org.pk,
|
||||
'host_filter': 'ansible_facts__ansible_distribution__exact="CentOS"',
|
||||
},
|
||||
user=admin_user,
|
||||
expect=201,
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import date
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -252,7 +253,7 @@ def test_user_verify_attribute_created(admin, get):
|
||||
resp = get(reverse('api:user_detail', kwargs={'pk': admin.pk}), admin)
|
||||
assert resp.data['created'] == admin.date_joined
|
||||
|
||||
past = "2020-01-01T00:00:00Z"
|
||||
past = date(2020, 1, 1).isoformat()
|
||||
for op, count in (('gt', 1), ('lt', 0)):
|
||||
resp = get(reverse('api:user_list') + f'?created__{op}={past}', admin)
|
||||
assert resp.data['count'] == count
|
||||
|
||||
@@ -13,7 +13,6 @@ from awx.main.models.workflow import (
|
||||
WorkflowJobTemplateNode,
|
||||
)
|
||||
from awx.main.models.credential import Credential
|
||||
from awx.main.models.label import Label
|
||||
from awx.main.scheduler import TaskManager, WorkflowManager, DependencyManager
|
||||
|
||||
# Django
|
||||
@@ -52,31 +51,6 @@ def test_node_accepts_prompted_fields(inventory, project, workflow_job_template,
|
||||
post(url, {'unified_job_template': job_template.pk, 'limit': 'webservers'}, user=admin_user, expect=201)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_node_extra_data_patch_with_unprompted_labels(inventory, project, organization, workflow_job_template, patch, admin_user):
|
||||
"""AAP-41742: PATCH extra_data on a workflow node should succeed even when
|
||||
the node has labels associated but the JT has ask_labels_on_launch=False."""
|
||||
jt = JobTemplate.objects.create(
|
||||
inventory=inventory,
|
||||
project=project,
|
||||
playbook='helloworld.yml',
|
||||
ask_variables_on_launch=True,
|
||||
ask_labels_on_launch=False,
|
||||
)
|
||||
label = Label.objects.create(name='repro-label', organization=organization)
|
||||
|
||||
node = WorkflowJobTemplateNode.objects.create(
|
||||
workflow_job_template=workflow_job_template,
|
||||
unified_job_template=jt,
|
||||
extra_data={'foo': 'bar'},
|
||||
)
|
||||
node.labels.add(label)
|
||||
|
||||
url = reverse('api:workflow_job_template_node_detail', kwargs={'pk': node.pk})
|
||||
r = patch(url, {'extra_data': {'foo': 'edited'}}, user=admin_user, expect=200)
|
||||
assert r.data['extra_data'] == {'foo': 'edited'}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"field_name, field_value",
|
||||
|
||||
@@ -48,7 +48,7 @@ class TestCallbackBrokerWorker(TransactionTestCase):
|
||||
worker = CallbackBrokerWorker()
|
||||
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
|
||||
worker.buff = {InventoryUpdateEvent: events}
|
||||
worker.flush(force=True)
|
||||
worker.flush()
|
||||
assert worker.buff.get(InventoryUpdateEvent, []) == []
|
||||
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
|
||||
|
||||
@@ -61,7 +61,7 @@ class TestCallbackBrokerWorker(TransactionTestCase):
|
||||
InventoryUpdateEvent(uuid=str(uuid4()), stdout='good2', **kwargs),
|
||||
]
|
||||
worker.buff = {InventoryUpdateEvent: events.copy()}
|
||||
worker.flush(force=True)
|
||||
worker.flush()
|
||||
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
|
||||
assert InventoryUpdateEvent.objects.filter(uuid=events[1].uuid).count() == 0
|
||||
assert InventoryUpdateEvent.objects.filter(uuid=events[2].uuid).count() == 1
|
||||
@@ -71,7 +71,7 @@ class TestCallbackBrokerWorker(TransactionTestCase):
|
||||
worker = CallbackBrokerWorker()
|
||||
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
|
||||
worker.buff = {InventoryUpdateEvent: events.copy()}
|
||||
worker.flush(force=True)
|
||||
worker.flush()
|
||||
|
||||
# put current saved event in buffer (error case)
|
||||
worker.buff = {InventoryUpdateEvent: [InventoryUpdateEvent.objects.get(uuid=events[0].uuid)]}
|
||||
@@ -113,7 +113,7 @@ class TestCallbackBrokerWorker(TransactionTestCase):
|
||||
|
||||
with mock.patch.object(InventoryUpdateEvent.objects, 'bulk_create', side_effect=ValueError):
|
||||
with mock.patch.object(events[0], 'save', side_effect=ValueError):
|
||||
worker.flush(force=True)
|
||||
worker.flush()
|
||||
|
||||
assert "\x00" not in events[0].stdout
|
||||
|
||||
|
||||
@@ -305,47 +305,6 @@ class TestINIImports:
|
||||
has_host_group = inventory.groups.get(name='has_a_host')
|
||||
assert has_host_group.hosts.count() == 1
|
||||
|
||||
@mock.patch.object(inventory_import, 'AnsibleInventoryLoader', MockLoader)
|
||||
def test_overwrite_removes_stale_memberships(self, inventory):
|
||||
"""When overwrite is enabled, host-group and group-group memberships
|
||||
that are no longer in the imported data should be removed."""
|
||||
# First import: parent_group has two children, host_group has two hosts
|
||||
inventory_import.AnsibleInventoryLoader._data = {
|
||||
"_meta": {"hostvars": {"host1": {}, "host2": {}}},
|
||||
"all": {"children": ["ungrouped", "parent_group", "child_a", "child_b", "host_group"]},
|
||||
"parent_group": {"children": ["child_a", "child_b"]},
|
||||
"host_group": {"hosts": ["host1", "host2"]},
|
||||
"ungrouped": {"hosts": []},
|
||||
}
|
||||
cmd = inventory_import.Command()
|
||||
cmd.handle(inventory_id=inventory.pk, source=__file__, overwrite=True)
|
||||
|
||||
parent = inventory.groups.get(name='parent_group')
|
||||
assert set(parent.children.values_list('name', flat=True)) == {'child_a', 'child_b'}
|
||||
host_grp = inventory.groups.get(name='host_group')
|
||||
assert set(host_grp.hosts.values_list('name', flat=True)) == {'host1', 'host2'}
|
||||
|
||||
# Second import: child_b removed from parent_group, host2 moved out of host_group
|
||||
inventory_import.AnsibleInventoryLoader._data = {
|
||||
"_meta": {"hostvars": {"host1": {}, "host2": {}}},
|
||||
"all": {"children": ["ungrouped", "parent_group", "child_a", "child_b", "host_group"]},
|
||||
"parent_group": {"children": ["child_a"]},
|
||||
"host_group": {"hosts": ["host1"]},
|
||||
"ungrouped": {"hosts": ["host2"]},
|
||||
}
|
||||
cmd = inventory_import.Command()
|
||||
cmd.handle(inventory_id=inventory.pk, source=__file__, overwrite=True)
|
||||
|
||||
parent.refresh_from_db()
|
||||
host_grp.refresh_from_db()
|
||||
# child_b should be removed from parent_group
|
||||
assert set(parent.children.values_list('name', flat=True)) == {'child_a'}
|
||||
# host2 should be removed from host_group
|
||||
assert set(host_grp.hosts.values_list('name', flat=True)) == {'host1'}
|
||||
# host2 and child_b should still exist in the inventory, just not in those groups
|
||||
assert inventory.hosts.filter(name='host2').exists()
|
||||
assert inventory.groups.filter(name='child_b').exists()
|
||||
|
||||
@mock.patch.object(inventory_import, 'AnsibleInventoryLoader', MockLoader)
|
||||
def test_recursive_group_error(self, inventory):
|
||||
inventory_import.AnsibleInventoryLoader._data = {
|
||||
|
||||
@@ -131,18 +131,14 @@ def test_workflow_creation_permissions(setup_managed_roles, organization, workfl
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_credential_to_user_of_another_org(setup_managed_roles, credential, admin_user, rando, org_admin, organization, post):
|
||||
'''Test that a credential can only be assigned to a user in the same organization by non-superusers'''
|
||||
'''Test that a credential can only be assigned to a user in the same organization'''
|
||||
# cannot assign credential to rando, as rando is not in the same org as the credential
|
||||
rd = RoleDefinition.objects.get(name="Credential Admin")
|
||||
credential.organization = organization
|
||||
credential.save(update_fields=['organization'])
|
||||
assert credential.organization not in Organization.access_qs(rando, 'member')
|
||||
url = django_reverse('roleuserassignment-list')
|
||||
|
||||
# superuser can assign cross-org
|
||||
post(url=url, data={"user": rando.id, "role_definition": rd.id, "object_id": credential.id}, user=admin_user, expect=201)
|
||||
|
||||
# non-superuser (org_admin) cannot assign cross-org
|
||||
resp = post(url=url, data={"user": rando.id, "role_definition": rd.id, "object_id": credential.id}, user=org_admin, expect=400)
|
||||
resp = post(url=url, data={"user": rando.id, "role_definition": rd.id, "object_id": credential.id}, user=admin_user, expect=400)
|
||||
assert "You cannot grant credential access to a User not in the credentials' organization" in str(resp.data)
|
||||
|
||||
# can assign credential to superuser
|
||||
@@ -150,7 +146,7 @@ def test_assign_credential_to_user_of_another_org(setup_managed_roles, credentia
|
||||
rando.save()
|
||||
post(url=url, data={"user": rando.id, "role_definition": rd.id, "object_id": credential.id}, user=admin_user, expect=201)
|
||||
|
||||
# can assign credential to org_admin (same org)
|
||||
# can assign credential to org_admin
|
||||
assert credential.organization in Organization.access_qs(org_admin, 'member')
|
||||
post(url=url, data={"user": org_admin.id, "role_definition": rd.id, "object_id": credential.id}, user=admin_user, expect=201)
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""
|
||||
Tests for AAP-68023: host_list_rbac performance optimization.
|
||||
|
||||
The host list endpoint fetches the large ansible_facts JSON column
|
||||
unnecessarily. The HostManager now defers it by default so that
|
||||
list queries avoid transferring this data from PostgreSQL.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from awx.main.models import Host
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AAP-68023: Verify ansible_facts column is deferred by HostManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHostManagerDeferral:
|
||||
"""AAP-68023: The host list fetches 200+ columns unnecessarily.
|
||||
|
||||
The ansible_facts JSON column is large and not used by the list
|
||||
serializer. HostManager.get_queryset() must defer it so that
|
||||
every query through Host.objects avoids fetching it by default.
|
||||
"""
|
||||
|
||||
def test_ansible_facts_deferred_by_default(self):
|
||||
"""ansible_facts should be in the deferred set for default Host queries."""
|
||||
qs = Host.objects.all()
|
||||
deferred = qs.query.deferred_loading[0]
|
||||
assert 'ansible_facts' in deferred, f'ansible_facts should be deferred by the HostManager. ' f'Deferred fields: {deferred}'
|
||||
|
||||
def test_ansible_facts_accessible_when_needed(self, inventory):
|
||||
"""Deferred fields are still accessible — Django fetches on access."""
|
||||
host = Host.objects.create(
|
||||
name='facts-host',
|
||||
inventory=inventory,
|
||||
ansible_facts={'os': 'linux'},
|
||||
)
|
||||
loaded = Host.objects.get(pk=host.pk)
|
||||
assert loaded.ansible_facts == {'os': 'linux'}
|
||||
@@ -1,240 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models import NotificationTemplate, Organization
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
from ansible_base.rbac import permission_registry
|
||||
|
||||
NT_DATA = {
|
||||
'notification_type': 'webhook',
|
||||
'notification_configuration': {
|
||||
'url': 'http://localhost',
|
||||
'username': '',
|
||||
'password': '',
|
||||
'headers': {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def nt_url(pk):
|
||||
return reverse('api:notification_template_detail', kwargs={'pk': pk})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nt_add_role(setup_managed_roles):
|
||||
"""A custom role with only add_notificationtemplate and view_organization.
|
||||
This is intentionally narrower than Organization NotificationTemplate Admin
|
||||
so that give_creator_permissions actually creates creator permissions."""
|
||||
rd, _ = RoleDefinition.objects.get_or_create(
|
||||
name='nt-add-only',
|
||||
permissions=['add_notificationtemplate', 'view_organization'],
|
||||
content_type=permission_registry.content_type_model.objects.get_for_model(Organization),
|
||||
)
|
||||
return rd
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_with_add_only_role_gets_creator_permissions(rando, organization, post, get, patch, nt_add_role):
|
||||
"""User with only add permission creates a notification template and gets
|
||||
creator permissions (change, delete, view) via give_creator_permissions.
|
||||
This exercises the fix for models without old-style roles (AAP-57274)."""
|
||||
nt_add_role.give_permission(rando, organization)
|
||||
|
||||
r = post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='rando-nt', organization=organization.id, **NT_DATA),
|
||||
user=rando,
|
||||
expect=201,
|
||||
)
|
||||
nt = NotificationTemplate.objects.get(pk=r.data['id'])
|
||||
assert rando.has_obj_perm(nt, 'change')
|
||||
assert rando.has_obj_perm(nt, 'view')
|
||||
|
||||
# Creator permissions survive revocation of the org-level add role
|
||||
nt_add_role.remove_permission(rando, organization)
|
||||
get(nt_url(nt.pk), user=rando, expect=200)
|
||||
patch(nt_url(nt.pk), data={'description': 'updated'}, user=rando, expect=200)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_org_admin_can_crud(rando, organization, post, get, patch, delete, setup_managed_roles):
|
||||
"""User with org-level notification admin can create, view, edit, and delete"""
|
||||
rd = RoleDefinition.objects.get(name='Organization NotificationTemplate Admin')
|
||||
rd.give_permission(rando, organization)
|
||||
|
||||
r = post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='rando-nt', organization=organization.id, **NT_DATA),
|
||||
user=rando,
|
||||
expect=201,
|
||||
)
|
||||
pk = r.data['id']
|
||||
url = nt_url(pk)
|
||||
|
||||
get(url, user=rando, expect=200)
|
||||
patch(url, data={'description': 'updated'}, user=rando, expect=200)
|
||||
delete(url, user=rando, expect=204)
|
||||
assert not NotificationTemplate.objects.filter(pk=pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unpermissioned_user_cannot_access(rando, notification_template, get, patch, delete, setup_managed_roles):
|
||||
"""User without any permissions cannot view, edit, or delete a notification template"""
|
||||
url = nt_url(notification_template.pk)
|
||||
|
||||
get(url, user=rando, expect=403)
|
||||
patch(url, data={'description': 'nope'}, user=rando, expect=403)
|
||||
delete(url, user=rando, expect=403)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grant_and_revoke_object_role(rando, notification_template, get, patch, setup_managed_roles):
|
||||
"""Granting and revoking NotificationTemplate Admin role controls access"""
|
||||
rd = RoleDefinition.objects.get(name='NotificationTemplate Admin')
|
||||
url = nt_url(notification_template.pk)
|
||||
|
||||
get(url, user=rando, expect=403)
|
||||
|
||||
rd.give_permission(rando, notification_template)
|
||||
get(url, user=rando, expect=200)
|
||||
patch(url, data={'description': 'changed'}, user=rando, expect=200)
|
||||
|
||||
rd.remove_permission(rando, notification_template)
|
||||
get(url, user=rando, expect=403)
|
||||
patch(url, data={'description': 'nope'}, user=rando, expect=403)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_creator_can_access_sub_endpoints(rando, organization, post, get, nt_add_role):
|
||||
"""Creator can access notification list sub-endpoint"""
|
||||
nt_add_role.give_permission(rando, organization)
|
||||
|
||||
r = post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='rando-nt', organization=organization.id, **NT_DATA),
|
||||
user=rando,
|
||||
expect=201,
|
||||
)
|
||||
pk = r.data['id']
|
||||
|
||||
# Revoke org-level role so only creator permissions remain
|
||||
nt_add_role.remove_permission(rando, organization)
|
||||
|
||||
get(
|
||||
reverse('api:notification_template_notification_list', kwargs={'pk': pk}),
|
||||
user=rando,
|
||||
expect=200,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_filtered_by_permissions(rando, admin_user, organization, post, get, nt_add_role):
|
||||
"""Notification template list only shows templates the user has access to"""
|
||||
nt_add_role.give_permission(rando, organization)
|
||||
|
||||
post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='admin-nt', organization=organization.id, **NT_DATA),
|
||||
user=admin_user,
|
||||
expect=201,
|
||||
)
|
||||
post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='rando-nt', organization=organization.id, **NT_DATA),
|
||||
user=rando,
|
||||
expect=201,
|
||||
)
|
||||
|
||||
# rando has org-level add, but admin-nt was created by admin → rando shouldn't see it
|
||||
# unless org admin role also gives view. With add-only role, rando has view_organization
|
||||
# but not view_notificationtemplate at the org level, so they only see their own (via creator perms)
|
||||
nt_add_role.remove_permission(rando, organization)
|
||||
r = get(reverse('api:notification_template_list'), user=rando, expect=200)
|
||||
visible_names = {item['name'] for item in r.data['results']}
|
||||
assert 'rando-nt' in visible_names
|
||||
assert 'admin-nt' not in visible_names
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_creator_access_list_with_add_only_role(rando, organization, post, get, nt_add_role):
|
||||
"""User with add_only role creates a notification template and can access its access_list endpoint"""
|
||||
from ansible_base.rbac.models import DABContentType
|
||||
|
||||
nt_add_role.give_permission(rando, organization)
|
||||
|
||||
r = post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='rando-nt', organization=organization.id, **NT_DATA),
|
||||
user=rando,
|
||||
expect=201,
|
||||
)
|
||||
nt = NotificationTemplate.objects.get(pk=r.data['id'])
|
||||
|
||||
# Revoke org-level role so only creator permissions remain
|
||||
nt_add_role.remove_permission(rando, organization)
|
||||
|
||||
# Creator should be able to access the access_list endpoint for their own notification template
|
||||
# Use the DAB access_list endpoint pattern: /api/v2/role_user_access/{model_name}/{pk}/
|
||||
ct = DABContentType.objects.get_for_model(NotificationTemplate)
|
||||
access_list_url = f'/api/v2/role_user_access/{ct.api_slug}/{nt.pk}/?order_by=id'
|
||||
r = get(access_list_url, user=rando, expect=200)
|
||||
|
||||
# The creator should be listed in the access list
|
||||
usernames = {user['username'] for user in r.data['results']}
|
||||
assert rando.username in usernames
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unpermissioned_user_cannot_access_access_list(rando, organization, post, admin_user, get, setup_managed_roles):
|
||||
"""User without view permission cannot access the access_list endpoint"""
|
||||
from ansible_base.rbac.models import DABContentType
|
||||
|
||||
# Create a notification template as admin
|
||||
r = post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='admin-nt', organization=organization.id, **NT_DATA),
|
||||
user=admin_user,
|
||||
expect=201,
|
||||
)
|
||||
nt = NotificationTemplate.objects.get(pk=r.data['id'])
|
||||
|
||||
ct = DABContentType.objects.get_for_model(NotificationTemplate)
|
||||
access_list_url = f'/api/v2/role_user_access/{ct.api_slug}/{nt.pk}/?order_by=id'
|
||||
# rando has no permissions on this notification template, so they can't see it or its access list
|
||||
# The endpoint returns 404 (not found) instead of 403 when user can't view the resource
|
||||
get(access_list_url, user=rando, expect=404)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_list_shows_creator(rando, organization, post, get, nt_add_role, setup_managed_roles):
|
||||
"""Access list shows the creator with direct permissions"""
|
||||
from ansible_base.rbac.models import DABContentType
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
|
||||
nt_add_role.give_permission(rando, organization)
|
||||
|
||||
# rando creates a notification template
|
||||
r = post(
|
||||
reverse('api:notification_template_list'),
|
||||
dict(name='rando-nt', organization=organization.id, **NT_DATA),
|
||||
user=rando,
|
||||
expect=201,
|
||||
)
|
||||
nt = NotificationTemplate.objects.get(pk=r.data['id'])
|
||||
|
||||
# Now assign them the object admin role directly too
|
||||
rd = RoleDefinition.objects.get(name='NotificationTemplate Admin')
|
||||
rd.give_permission(rando, nt)
|
||||
|
||||
ct = DABContentType.objects.get_for_model(NotificationTemplate)
|
||||
access_list_url = f'/api/v2/role_user_access/{ct.api_slug}/{nt.pk}/?order_by=id'
|
||||
r = get(access_list_url, user=rando, expect=200)
|
||||
|
||||
# rando should be listed with direct permissions from both creator and object role assignment
|
||||
user_data = {item['username']: item for item in r.data['results']}
|
||||
assert rando.username in user_data
|
||||
|
||||
# Verify they have direct role assignments
|
||||
assert len(user_data[rando.username]['object_role_assignments']) > 0
|
||||
assert any(assign.get('type') == 'direct' for assign in user_data[rando.username]['object_role_assignments'])
|
||||
@@ -173,22 +173,6 @@ def test_creator_permission(rando, admin_user, inventory, setup_managed_roles):
|
||||
assert rando in inventory.admin_role.members.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_creator_permission_notification_template(rando, organization, setup_managed_roles):
|
||||
"""NotificationTemplate has no old-style roles, give_creator_permissions should not error"""
|
||||
from awx.main.models import NotificationTemplate
|
||||
|
||||
nt = NotificationTemplate.objects.create(
|
||||
name='test-nt',
|
||||
organization=organization,
|
||||
notification_type='slack',
|
||||
notification_configuration={'token': 'x', 'channels': ['#test']},
|
||||
)
|
||||
give_creator_permissions(rando, nt)
|
||||
assignment = RoleUserAssignment.objects.filter(user=rando, object_id=nt.pk).first()
|
||||
assert assignment is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_implicit_parents_no_assignments(organization):
|
||||
"""Through the normal course of creating models, we should not be changing DAB RBAC permissions"""
|
||||
|
||||
@@ -8,7 +8,7 @@ from awx.main.management.commands.dispatcherd import _hash_config
|
||||
def test_dispatcherd_config_hash_is_stable(settings, monkeypatch):
|
||||
monkeypatch.setenv('AWX_COMPONENT', 'dispatcher')
|
||||
settings.CLUSTER_HOST_ID = 'test-node'
|
||||
settings.DISPATCHER_MIN_WORKERS = 1
|
||||
settings.JOB_EVENT_WORKERS = 1
|
||||
settings.DISPATCHER_SCHEDULE = {}
|
||||
|
||||
config_one = get_dispatcherd_config(for_service=True)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import pytest
|
||||
|
||||
# AWX context managers for testing
|
||||
from awx.main.signals import disable_activity_stream, disable_computed_fields
|
||||
from awx.main.tasks.system import update_inventory_computed_fields
|
||||
from awx.main.signals import disable_activity_stream, disable_computed_fields, update_inventory_computed_fields
|
||||
|
||||
# AWX models
|
||||
from awx.main.models.organization import Organization
|
||||
|
||||
@@ -71,10 +71,8 @@ class TestEvents:
|
||||
assert s.skipped == 0
|
||||
|
||||
for host in Host.objects.all():
|
||||
latest_summary = JobHostSummary.latest_for_host(host.id)
|
||||
assert latest_summary is not None
|
||||
assert latest_summary.job_id == self.job.id
|
||||
assert latest_summary.host == host
|
||||
assert host.last_job_id == self.job.id
|
||||
assert host.last_job_host_summary.host == host
|
||||
|
||||
def test_host_summary_generation_with_deleted_hosts(self):
|
||||
self._generate_hosts(10)
|
||||
@@ -93,7 +91,8 @@ class TestEvents:
|
||||
def test_host_summary_generation_with_limit(self):
|
||||
# Make an inventory with 10 hosts, run a playbook with a --limit
|
||||
# pointed at *one* host,
|
||||
# Verify that *only* that host has an associated JobHostSummary.
|
||||
# Verify that *only* that host has an associated JobHostSummary and that
|
||||
# *only* that host has an updated value for .last_job.
|
||||
self._generate_hosts(10)
|
||||
|
||||
# by making the playbook_on_stats *only* include Host 1, we're emulating
|
||||
@@ -106,14 +105,13 @@ class TestEvents:
|
||||
# be related to the appropriate Host)
|
||||
assert JobHostSummary.objects.count() == 1
|
||||
for h in Host.objects.all():
|
||||
latest_summary = JobHostSummary.latest_for_host(h.id)
|
||||
if h.name == 'Host 1':
|
||||
assert latest_summary is not None
|
||||
assert latest_summary.job_id == self.job.id
|
||||
assert latest_summary.id == JobHostSummary.objects.first().id
|
||||
assert h.last_job_id == self.job.id
|
||||
assert h.last_job_host_summary_id == JobHostSummary.objects.first().id
|
||||
else:
|
||||
# all other hosts in the inventory should have no summary
|
||||
assert latest_summary is None
|
||||
# all other hosts in the inventory should remain untouched
|
||||
assert h.last_job_id is None
|
||||
assert h.last_job_host_summary_id is None
|
||||
|
||||
def test_host_metrics_insert(self):
|
||||
self._generate_hosts(10)
|
||||
|
||||
@@ -10,23 +10,9 @@ from django.test.utils import override_settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_multiple_hybrid_instances():
|
||||
for i in range(3):
|
||||
Instance.objects.create(hostname=f'foo{i}', node_type='hybrid')
|
||||
assert is_ha_environment()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_double_control_instances():
|
||||
def test_multiple_instances():
|
||||
for i in range(2):
|
||||
Instance.objects.create(hostname=f'foo{i}', node_type='control')
|
||||
assert is_ha_environment()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mix_hybrid_control_instances():
|
||||
Instance.objects.create(hostname='control_node', node_type='control')
|
||||
Instance.objects.create(hostname='hybrid_node', node_type='hybrid')
|
||||
Instance.objects.create(hostname=f'foo{i}', node_type='hybrid')
|
||||
assert is_ha_environment()
|
||||
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.db import connection
|
||||
from django.utils.timezone import now
|
||||
|
||||
from awx.main.models import Job, JobEvent, Inventory, Host, JobHostSummary
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHostLatestSummaryQuerySet:
|
||||
"""Tests for HostLatestSummaryQuerySet and Host.latest_summary property."""
|
||||
|
||||
def _create_inventory_with_hosts(self, count=5):
|
||||
inventory = Inventory()
|
||||
inventory.save()
|
||||
Host.objects.bulk_create([Host(created=now(), modified=now(), name=f'host-{i}', inventory_id=inventory.id) for i in range(count)])
|
||||
return inventory
|
||||
|
||||
def _run_job(self, inventory, host_names=None):
|
||||
"""Run a fake job that creates JobHostSummary records for the given hosts."""
|
||||
if host_names is None:
|
||||
host_names = list(inventory.hosts.values_list('name', flat=True))
|
||||
job = Job(inventory=inventory)
|
||||
job.save()
|
||||
host_map = dict(inventory.hosts.values_list('name', 'id'))
|
||||
JobEvent.create_from_data(
|
||||
job_id=job.pk,
|
||||
parent_uuid='abc123',
|
||||
event='playbook_on_stats',
|
||||
event_data={
|
||||
'ok': {name: 1 for name in host_names},
|
||||
'changed': {},
|
||||
'dark': {},
|
||||
'failures': {},
|
||||
'ignored': {},
|
||||
'processed': {},
|
||||
'rescued': {},
|
||||
'skipped': {},
|
||||
},
|
||||
host_map=host_map,
|
||||
).save()
|
||||
return job
|
||||
|
||||
def test_with_latest_summary_id_annotates_hosts(self):
|
||||
inventory = self._create_inventory_with_hosts(3)
|
||||
job = self._run_job(inventory)
|
||||
|
||||
hosts = Host.objects.filter(inventory=inventory).with_latest_summary_id()
|
||||
for host in hosts:
|
||||
assert hasattr(host, '_latest_summary_id')
|
||||
summary = JobHostSummary.objects.filter(host=host, job=job).first()
|
||||
assert host._latest_summary_id == summary.id
|
||||
|
||||
def test_with_latest_summary_id_returns_most_recent(self):
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
self._run_job(inventory)
|
||||
job2 = self._run_job(inventory)
|
||||
|
||||
host = Host.objects.filter(inventory=inventory).with_latest_summary_id().first()
|
||||
latest = JobHostSummary.objects.filter(host_id=host.id).order_by('-id').first()
|
||||
assert latest.job_id == job2.id
|
||||
assert host._latest_summary_id == latest.id
|
||||
|
||||
def test_with_latest_summary_id_none_for_no_summaries(self):
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
# No job run — no summaries
|
||||
host = Host.objects.filter(inventory=inventory).with_latest_summary_id().first()
|
||||
assert host._latest_summary_id is None
|
||||
|
||||
def test_fetch_all_bulk_attaches_summaries(self):
|
||||
inventory = self._create_inventory_with_hosts(5)
|
||||
self._run_job(inventory)
|
||||
|
||||
hosts = list(Host.objects.filter(inventory=inventory).with_latest_summary_id())
|
||||
for host in hosts:
|
||||
assert hasattr(host, '_latest_summary_cache')
|
||||
assert host._latest_summary_cache is not None
|
||||
assert isinstance(host._latest_summary_cache, JobHostSummary)
|
||||
|
||||
def test_fetch_all_skips_non_annotated_querysets(self):
|
||||
"""Non-annotated querysets should NOT set _latest_summary_cache,
|
||||
preserving the per-object fallback in Host.latest_summary."""
|
||||
inventory = self._create_inventory_with_hosts(3)
|
||||
self._run_job(inventory)
|
||||
|
||||
hosts = list(Host.objects.filter(inventory=inventory))
|
||||
for host in hosts:
|
||||
assert not hasattr(host, '_latest_summary_cache')
|
||||
|
||||
def test_count_does_not_trigger_fetch_all(self):
|
||||
"""Calling .count() should not trigger _fetch_all or the bulk-attach logic."""
|
||||
inventory = self._create_inventory_with_hosts(5)
|
||||
self._run_job(inventory)
|
||||
|
||||
qs = Host.objects.filter(inventory=inventory).with_latest_summary_id()
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
result = qs.count()
|
||||
|
||||
assert result == 5
|
||||
# count() should produce a single COUNT query, not fetch all rows + summaries
|
||||
assert len(ctx.captured_queries) == 1
|
||||
assert 'COUNT' in ctx.captured_queries[0]['sql'].upper()
|
||||
|
||||
def test_exists_does_not_trigger_fetch_all(self):
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
self._run_job(inventory)
|
||||
|
||||
qs = Host.objects.filter(inventory=inventory).with_latest_summary_id()
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
result = qs.exists()
|
||||
|
||||
assert result is True
|
||||
assert len(ctx.captured_queries) == 1
|
||||
|
||||
def test_latest_summary_property_uses_cache(self):
|
||||
"""When loaded via with_latest_summary_id(), Host.latest_summary
|
||||
should use the bulk-attached cache without extra queries."""
|
||||
inventory = self._create_inventory_with_hosts(3)
|
||||
self._run_job(inventory)
|
||||
|
||||
hosts = list(Host.objects.filter(inventory=inventory).with_latest_summary_id())
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
for host in hosts:
|
||||
summary = host.latest_summary
|
||||
assert summary is not None
|
||||
|
||||
# No additional queries — all data came from the bulk-attach
|
||||
assert len(ctx.captured_queries) == 0
|
||||
|
||||
def test_latest_summary_property_fallback(self):
|
||||
"""When loaded without annotation, Host.latest_summary should
|
||||
fall back to a per-object query."""
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
job = self._run_job(inventory)
|
||||
|
||||
host = Host.objects.filter(inventory=inventory).first()
|
||||
assert not hasattr(host, '_latest_summary_cache')
|
||||
|
||||
summary = host.latest_summary
|
||||
assert summary is not None
|
||||
assert summary.job_id == job.id
|
||||
# After first access, the cache should be populated
|
||||
assert hasattr(host, '_latest_summary_cache')
|
||||
|
||||
def test_latest_summary_none_when_no_summaries(self):
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
host = Host.objects.filter(inventory=inventory).with_latest_summary_id().first()
|
||||
assert host.latest_summary is None
|
||||
|
||||
def test_latest_job_property(self):
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
job = self._run_job(inventory)
|
||||
|
||||
host = Host.objects.filter(inventory=inventory).with_latest_summary_id().first()
|
||||
assert host.latest_job is not None
|
||||
assert host.latest_job.id == job.id
|
||||
|
||||
def test_latest_job_none_when_no_summaries(self):
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
host = Host.objects.filter(inventory=inventory).first()
|
||||
assert host.latest_job is None
|
||||
|
||||
def test_bulk_attach_select_related(self):
|
||||
"""The bulk-attach should select_related job and job__job_template
|
||||
so accessing them doesn't cause extra queries."""
|
||||
inventory = self._create_inventory_with_hosts(3)
|
||||
self._run_job(inventory)
|
||||
|
||||
hosts = list(Host.objects.filter(inventory=inventory).with_latest_summary_id())
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
for host in hosts:
|
||||
summary = host.latest_summary
|
||||
_ = summary.job # should not query
|
||||
|
||||
assert len(ctx.captured_queries) == 0
|
||||
|
||||
def test_chaining_preserves_annotation(self):
|
||||
"""Chaining .filter() after .with_latest_summary_id() should
|
||||
preserve the annotation and bulk-attach behavior."""
|
||||
inventory = self._create_inventory_with_hosts(5)
|
||||
self._run_job(inventory)
|
||||
|
||||
hosts = list(Host.objects.filter(inventory=inventory).with_latest_summary_id().filter(name__startswith='host-').order_by('name'))
|
||||
assert len(hosts) == 5
|
||||
for host in hosts:
|
||||
assert hasattr(host, '_latest_summary_cache')
|
||||
assert host._latest_summary_cache is not None
|
||||
|
||||
def test_multiple_jobs_latest_wins(self):
|
||||
"""After multiple jobs, latest_summary should return the most recent."""
|
||||
inventory = self._create_inventory_with_hosts(1)
|
||||
self._run_job(inventory)
|
||||
self._run_job(inventory)
|
||||
job3 = self._run_job(inventory)
|
||||
|
||||
host = Host.objects.filter(inventory=inventory).with_latest_summary_id().first()
|
||||
assert host.latest_summary.job_id == job3.id
|
||||
|
||||
def test_partial_host_coverage(self):
|
||||
"""When a job only touches some hosts, only those hosts get summaries."""
|
||||
inventory = self._create_inventory_with_hosts(5)
|
||||
self._run_job(inventory, host_names=['host-0', 'host-1'])
|
||||
|
||||
hosts = list(Host.objects.filter(inventory=inventory).with_latest_summary_id().order_by('name'))
|
||||
with_summary = [h for h in hosts if h.latest_summary is not None]
|
||||
without_summary = [h for h in hosts if h.latest_summary is None]
|
||||
|
||||
assert len(with_summary) == 2
|
||||
assert len(without_summary) == 3
|
||||
assert sorted([h.name for h in with_summary]) == ['host-0', 'host-1']
|
||||
@@ -1,111 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from django.utils.timezone import now
|
||||
|
||||
from awx.main.models import Job, JobEvent, JobTemplate, Inventory, Host, JobHostSummary, Project
|
||||
from awx.api.serializers import HostSerializer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHostSummaryFields:
|
||||
"""Tests for summary_fields of last_job and last_job_host_summary on HostSerializer."""
|
||||
|
||||
def _setup_host_with_job(self, status='canceled'):
|
||||
inventory = Inventory()
|
||||
inventory.save()
|
||||
host = Host(created=now(), modified=now(), name='test-host', inventory=inventory)
|
||||
host.save()
|
||||
|
||||
project = Project(name='test-project')
|
||||
project.save()
|
||||
jt = JobTemplate(name='test-jt', inventory=inventory, project=project)
|
||||
jt.save()
|
||||
|
||||
job = Job(inventory=inventory, job_template=jt, status=status)
|
||||
if status in ('successful', 'failed', 'canceled', 'error'):
|
||||
job.finished = now()
|
||||
if status == 'canceled':
|
||||
job.canceled_on = now()
|
||||
job.save()
|
||||
|
||||
host_map = {host.name: host.id}
|
||||
JobEvent.create_from_data(
|
||||
job_id=job.pk,
|
||||
parent_uuid='abc123',
|
||||
event='playbook_on_stats',
|
||||
event_data={
|
||||
'ok': {host.name: 1},
|
||||
'changed': {},
|
||||
'dark': {},
|
||||
'failures': {},
|
||||
'ignored': {},
|
||||
'processed': {},
|
||||
'rescued': {},
|
||||
'skipped': {},
|
||||
},
|
||||
host_map=host_map,
|
||||
).save()
|
||||
|
||||
summary = JobHostSummary.objects.filter(host=host, job=job).first()
|
||||
host.last_job = job
|
||||
host.last_job_host_summary = summary
|
||||
host.save(update_fields=['last_job', 'last_job_host_summary'])
|
||||
host.refresh_from_db()
|
||||
|
||||
return host, job, summary
|
||||
|
||||
def test_last_job_summary_fields_canceled_job(self):
|
||||
host, job, summary = self._setup_host_with_job(status='canceled')
|
||||
|
||||
serializer = HostSerializer()
|
||||
d = serializer.get_summary_fields(host)
|
||||
|
||||
assert 'last_job' in d
|
||||
last_job = d['last_job']
|
||||
|
||||
expected_keys = {'id', 'name', 'description', 'finished', 'status', 'failed', 'canceled_on', 'job_template_id', 'job_template_name'}
|
||||
assert set(last_job.keys()) == expected_keys, f"Unexpected last_job keys: {set(last_job.keys())}"
|
||||
assert last_job['id'] == job.id
|
||||
assert last_job['status'] == 'canceled'
|
||||
assert last_job['canceled_on'] == job.canceled_on
|
||||
assert last_job['job_template_id'] == job.job_template.id
|
||||
assert last_job['job_template_name'] == job.job_template.name
|
||||
|
||||
def test_last_job_summary_fields_successful_job(self):
|
||||
host, job, summary = self._setup_host_with_job(status='successful')
|
||||
|
||||
serializer = HostSerializer()
|
||||
d = serializer.get_summary_fields(host)
|
||||
|
||||
assert 'last_job' in d
|
||||
last_job = d['last_job']
|
||||
|
||||
expected_keys = {'id', 'name', 'description', 'finished', 'status', 'failed', 'job_template_id', 'job_template_name'}
|
||||
assert set(last_job.keys()) == expected_keys, f"Unexpected last_job keys: {set(last_job.keys())}"
|
||||
assert last_job['id'] == job.id
|
||||
assert last_job['status'] == 'successful'
|
||||
assert 'canceled_on' not in last_job, "canceled_on should not appear when None"
|
||||
|
||||
def test_last_job_host_summary_fields(self):
|
||||
host, job, summary = self._setup_host_with_job(status='successful')
|
||||
|
||||
serializer = HostSerializer()
|
||||
d = serializer.get_summary_fields(host)
|
||||
|
||||
assert 'last_job_host_summary' in d
|
||||
last_jhs = d['last_job_host_summary']
|
||||
|
||||
assert last_jhs['id'] == summary.id
|
||||
assert 'failed' in last_jhs
|
||||
|
||||
def test_no_summary_fields_without_job(self):
|
||||
inventory = Inventory()
|
||||
inventory.save()
|
||||
host = Host(created=now(), modified=now(), name='lonely-host', inventory=inventory)
|
||||
host.save()
|
||||
|
||||
serializer = HostSerializer()
|
||||
d = serializer.get_summary_fields(host)
|
||||
|
||||
assert 'last_job' not in d
|
||||
assert 'last_job_host_summary' not in d
|
||||
@@ -108,28 +108,6 @@ class TestActiveCount:
|
||||
source.hosts.create(name='remotely-managed-host', inventory=inventory)
|
||||
assert Host.objects.active_count() == 1
|
||||
|
||||
def test_active_count_minus_constructed(self, organization):
|
||||
"""
|
||||
Active hosts do not include duplicated hosts from construted inventories.
|
||||
"""
|
||||
inv = Inventory.objects.create(name='source-inv', organization=organization)
|
||||
inv.hosts.create(name='host1')
|
||||
assert Host.objects.active_count() == 1
|
||||
|
||||
constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization)
|
||||
Host.objects.create(name='host1', inventory=constructed)
|
||||
assert Host.objects.active_count() == 1
|
||||
|
||||
def test_org_active_count_minus_constructed(self, organization):
|
||||
"""Org-scoped count must also exclude constructed-inventory shadow rows."""
|
||||
inv = Inventory.objects.create(name='source-inv', organization=organization)
|
||||
inv.hosts.create(name='host1')
|
||||
assert Host.objects.org_active_count(organization.id) == 1
|
||||
|
||||
constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization)
|
||||
Host.objects.create(name='host1', inventory=constructed)
|
||||
assert Host.objects.org_active_count(organization.id) == 1
|
||||
|
||||
def test_host_case_insensitivity(self, organization):
|
||||
inv1 = Inventory.objects.create(name='inv1', organization=organization)
|
||||
inv2 = Inventory.objects.create(name='inv2', organization=organization)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from awx.main.models import JobTemplate, Job, JobHostSummary, WorkflowJob, Inventory, Host, Project, Organization
|
||||
from awx.main.models import JobTemplate, Job, JobHostSummary, WorkflowJob, Inventory, Project, Organization
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -87,47 +87,3 @@ class TestSlicingModels:
|
||||
|
||||
unified_job = job_template.create_unified_job(job_slice_count=2)
|
||||
assert isinstance(unified_job, Job)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGetSourceHostsForConstructedInventory:
|
||||
"""Tests for Job.get_source_hosts_for_constructed_inventory"""
|
||||
|
||||
def test_returns_source_hosts_via_instance_id(self):
|
||||
"""Constructed hosts with instance_id pointing to source hosts are resolved correctly."""
|
||||
org = Organization.objects.create(name='test-org')
|
||||
inv_input = Inventory.objects.create(organization=org, name='input-inv')
|
||||
source_host1 = inv_input.hosts.create(name='host1')
|
||||
source_host2 = inv_input.hosts.create(name='host2')
|
||||
|
||||
inv_constructed = Inventory.objects.create(organization=org, name='constructed-inv', kind='constructed')
|
||||
inv_constructed.input_inventories.add(inv_input)
|
||||
Host.objects.create(inventory=inv_constructed, name='host1', instance_id=str(source_host1.id))
|
||||
Host.objects.create(inventory=inv_constructed, name='host2', instance_id=str(source_host2.id))
|
||||
|
||||
job = Job.objects.create(name='test-job', inventory=inv_constructed)
|
||||
result = job.get_source_hosts_for_constructed_inventory()
|
||||
|
||||
assert set(result.values_list('id', flat=True)) == {source_host1.id, source_host2.id}
|
||||
|
||||
def test_no_inventory_returns_empty(self):
|
||||
"""A job with no inventory returns an empty queryset."""
|
||||
job = Job.objects.create(name='test-job')
|
||||
result = job.get_source_hosts_for_constructed_inventory()
|
||||
assert result.count() == 0
|
||||
|
||||
def test_ignores_hosts_without_instance_id(self):
|
||||
"""Hosts with empty instance_id are excluded from the result."""
|
||||
org = Organization.objects.create(name='test-org')
|
||||
inv_input = Inventory.objects.create(organization=org, name='input-inv')
|
||||
source_host = inv_input.hosts.create(name='host1')
|
||||
|
||||
inv_constructed = Inventory.objects.create(organization=org, name='constructed-inv', kind='constructed')
|
||||
inv_constructed.input_inventories.add(inv_input)
|
||||
Host.objects.create(inventory=inv_constructed, name='host1', instance_id=str(source_host.id))
|
||||
Host.objects.create(inventory=inv_constructed, name='host-no-ref', instance_id='')
|
||||
|
||||
job = Job.objects.create(name='test-job', inventory=inv_constructed)
|
||||
result = job.get_source_hosts_for_constructed_inventory()
|
||||
|
||||
assert list(result.values_list('id', flat=True)) == [source_host.id]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user